## Summary Implemented a comprehensive API versioning system with resource-level version changes, strong typing using Zod schemas, and a composable getApi* pattern. ## Key Changes ### Versioning Infrastructure - **VersionChange base class**: Now strongly typed with Zod schemas (TNewSchema, TOldSchema, TDataSchema) - **Bidirectional transforms**: `transformRequest` (old→new) and `transformResponse` (new→old) - **Input + Data pattern**: `input` for version-specific data, `data` for additional context - **applyVersionChanges**: Fixed TypeScript errors and added biome-ignore comments for necessary `any` types ### Resource Organization - **Folder structure**: Each resource gets its own `changes/` folder - `shared/api/customers/changes/` - Customer-level changes - `shared/api/customers/cusProducts/changes/` - Product-level changes - `shared/api/customers/cusFeatures/changes/` - Feature-level changes ### Version Changes (Strongly Typed) - **V0_2_ProductItems**: Products gained items field (V0_2+ → V0_1) - **V1_2_FeaturesArrayToObject**: Features object ↔ array (V1_2 → V1_1) - **V1_1_MergedResponse**: Merged customer response ↔ split (V1_1+ → V1_0) - **V1_1_LegacyExpandInvoices**: Side-effect only (invoices auto-expand in V1_0) ### getApi* Pattern (server/src/internal/customers/cusUtils/apiCusUtils/) - **getApiCusProduct**: Builds product in latest format, applies V0_2_ProductItems transform - **getApiCusFeature**: Transforms balances (used→usage), applies V1_2_FeaturesArrayToObject - **getApiCustomer**: Orchestrates products/features, merges, applies V1_1_MergedResponse ### V2 Handler Demo - **handleGetCustomerV2**: Demonstrates zero version branching in handler - Calls `getApiCustomer` which handles all versioning internally - Side effects handled explicitly (expand invoices for V1_0) ## Type Safety - All version changes use Zod schemas for input/output types - Runtime validation with `parse()` ensures data integrity - Compile-time type checking catches transformation errors - Context data support via optional `data` parameter ## Benefits ✅ No scattered if-else version checks ✅ Composable resource-based architecture ✅ Strong typing with Zod schemas ✅ Self-documenting version changes ✅ Easy to add new versions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
11 KiB
API Versioning Architecture Guide
Overview
This guide explains how to handle API versioning when schema components are shared across multiple resources and endpoints.
The Challenge: Shared Schema Components
When schema components are embedded in multiple resources, version changes need to be applied consistently across all endpoints.
Example:
// CoreCusFeatureSchema is shared across:
// 1. Customer → features: ApiCusFeature[]
// 2. CheckResult → extends CoreCusFeatureSchema directly
export const ApiCusFeatureSchema = z.object({
id: z.string(),
type: z.enum(ApiFeatureType),
name: z.string().nullish(),
}).extend(CoreCusFeatureSchema.shape);
export const CheckResultSchema = z.object({
allowed: z.boolean(),
customer_id: z.string(),
feature_id: z.string(),
// ...
}).extend(CoreCusFeatureSchema.shape); // ← Same schema!
Architecture: Compositional Version Changes
Principle
Define version changes at the schema component level, then compose them for each resource.
Step 1: Define Shared Transformation Logic
Create reusable transformation functions for shared schema components:
// shared/api/customers/cusFeatures/changes/featureTransformUtils.ts
import type { ApiCusFeature } from "../apiCusFeature.js";
import type { ApiCusFeatureV2 } from "../previousVersions/apiCusFeatureV2.js";
/**
* Transform a V1.2+ feature (with breakdown) to V1.1 format (expanded intervals)
*
* @param feature - Feature in V1.2+ format
* @returns Array of features in V1.1 format (one per interval if breakdown exists)
*/
export const transformFeatureV1_2ToV1_1 = (
feature: ApiCusFeature
): ApiCusFeatureV2[] => {
const v1_1_features: ApiCusFeatureV2[] = [];
// If feature has breakdown, expand into separate entries
if (feature.breakdown && feature.breakdown.length > 0) {
for (const breakdownItem of feature.breakdown) {
v1_1_features.push({
feature_id: feature.id,
interval: breakdownItem.interval,
interval_count: breakdownItem.interval_count,
balance: breakdownItem.balance,
usage: breakdownItem.usage,
included_usage: breakdownItem.included_usage,
next_reset_at: breakdownItem.next_reset_at,
usage_limit: breakdownItem.usage_limit || breakdownItem.included_usage,
rollovers: breakdownItem.rollovers,
unlimited: false,
overage_allowed: false,
});
}
} else {
// Handle single feature without breakdown
if (feature.unlimited) {
v1_1_features.push({
feature_id: feature.id,
unlimited: true,
});
} else if (feature.type === "static") {
v1_1_features.push({
feature_id: feature.id,
});
} else {
v1_1_features.push({
feature_id: feature.id,
interval: feature.interval === "multiple" ? null : feature.interval,
interval_count: feature.interval_count,
balance: feature.balance,
usage: feature.usage,
included_usage: feature.included_usage,
next_reset_at: feature.next_reset_at,
usage_limit: feature.usage_limit || feature.included_usage,
rollovers: feature.rollovers,
unlimited: feature.unlimited,
overage_allowed: feature.overage_allowed,
});
}
}
return v1_1_features;
};
Step 2: Create Resource-Specific Version Changes
Use the shared transformation logic in each resource's version change:
Customer Version Change
// shared/api/customers/changes/V1_2_FeaturesArrayToObject.ts
import { defineVersionChange } from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import { transformFeatureV1_2ToV1_1 } from "../cusFeatures/changes/featureTransformUtils.js";
export const V1_2_FeaturesArrayToObject = defineVersionChange({
version: ApiVersion.V1_2,
description: "Features: object with breakdown → array with expanded intervals",
affectedResources: [AffectedResource.Customer],
newSchema: V1_2_CustomerSchema,
oldSchema: V1_1_CustomerSchema,
transformResponse: ({ input }) => {
// Transform all features using shared logic
const v1_1_features = Object.values(input.features)
.flatMap(transformFeatureV1_2ToV1_1); // ← Reuse!
return {
...input,
features: v1_1_features,
};
},
});
CheckResult Version Change
// shared/api/core/changes/V1_2_CheckResultFeatureFields.ts
import { defineVersionChange } from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import { transformFeatureV1_2ToV1_1 } from "@api/customers/cusFeatures/changes/featureTransformUtils.js";
export const V1_2_CheckResultFeatureFields = defineVersionChange({
version: ApiVersion.V1_2,
description: "CheckResult: feature fields with breakdown → expanded fields",
affectedResources: [AffectedResource.CheckResult],
newSchema: CheckResultV1_2Schema,
oldSchema: CheckResultV1_1Schema,
transformResponse: ({ input }) => {
// Extract non-feature fields
const {
allowed,
customer_id,
feature_id,
code,
required_balance,
entity_id,
preview,
...featureFields
} = input;
// Transform feature fields using shared logic
const transformedFeatures = transformFeatureV1_2ToV1_1({
id: feature_id,
type: input.type,
name: input.name,
...featureFields,
} as ApiCusFeature);
// CheckResult has a single feature, so take the first transformed one
const transformedFeature = transformedFeatures[0];
return {
allowed,
customer_id,
feature_id: transformedFeature.feature_id,
code,
required_balance,
entity_id,
preview,
...transformedFeature,
};
},
});
Step 3: Apply Transformations at Endpoints
Each endpoint applies version changes for its specific resource:
// server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomer.ts
export const getApiCustomer = async ({ ctx, fullCus }) => {
const apiCustomer = ApiCustomerSchema.parse({
// ... build customer data
features: apiCusFeatures,
});
// Applies V1_2_FeaturesArrayToObject (Customer resource)
return applyResponseVersionChanges({
input: apiCustomer,
targetVersion: ctx.apiVersion,
resource: AffectedResource.Customer,
});
};
// server/src/internal/api/entitled/checkUtils/getV2CheckResponse.ts
export const getV2CheckResponse = async ({ ... }) => {
const checkResult = CheckResultSchema.parse({
customer_id: fullCus.id,
feature_id: featureToUse.id,
allowed,
...cusFeature, // ← Contains CoreCusFeatureSchema fields
});
// Applies V1_2_CheckResultFeatureFields (CheckResult resource)
return applyResponseVersionChanges({
input: checkResult,
targetVersion: ctx.apiVersion,
resource: AffectedResource.CheckResult,
});
};
Key Principles
1. Shared Logic, Multiple Applications
- Define transformation logic once in utility functions
- Apply consistently across all resources that embed the schema
2. Resource-Level Version Changes
- Each resource (Customer, CheckResult) has its own version change
- Version changes specify which resources they affect via
affectedResources
3. Schema Composition Drives Change Composition
- If
CheckResultSchemaextendsCoreCusFeatureSchema, the version change must handle both - If
ApiCustomerSchemacontainsfeatures: Record<string, ApiCusFeature>, the version change must transform the features
4. Automatic Application
- Call
applyResponseVersionChanges()with the appropriateresourceparameter - The system automatically finds and applies relevant version changes
Adding New Shared Schema Components
When adding a new schema component that's shared across resources:
-
Create transformation utilities
// shared/api/[component]/changes/[component]TransformUtils.ts export const transformComponentV1_2ToV1_1 = (input) => { /* ... */ }; -
Create version changes for each affected resource
// For each resource that embeds the component: export const V1_2_ResourceA_ComponentChange = defineVersionChange({ affectedResources: [AffectedResource.ResourceA], transformResponse: ({ input }) => { // Use shared transformation const transformed = transformComponentV1_2ToV1_1(input.component); return { ...input, component: transformed }; }, }); -
Register version changes
// shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts export const V1_2_CHANGES = [ V1_2_ResourceA_ComponentChange, V1_2_ResourceB_ComponentChange, // ... ]; -
Apply at endpoints
return applyResponseVersionChanges({ input: data, targetVersion: ctx.apiVersion, resource: AffectedResource.ResourceA, });
Benefits of This Architecture
✅ DRY - Transformation logic defined once, reused everywhere
✅ Type-safe - Schemas enforce correctness at compile-time
✅ Automatic - Applied via applyResponseVersionChanges
✅ Scalable - Easy to add more shared components or resources
✅ Consistent - Same logic applies everywhere the schema is used
✅ Testable - Transformation utilities can be unit tested independently
Anti-Patterns to Avoid
❌ Duplicating transformation logic across resources
❌ Applying version changes at the wrong level (e.g., at feature level when it affects customer)
❌ Manual field-by-field transformations in endpoint handlers
❌ Side-effect version changes (use hasSideEffects: false when possible)
❌ Mixing concerns (transformation logic vs business logic)
Example: Complete Flow
User requests Customer with API version 1.1
↓
1. getApiCustomer() builds latest format (1.2+)
↓
2. applyResponseVersionChanges() called with:
- input: Customer with features as object with breakdown
- targetVersion: 1.1
- resource: AffectedResource.Customer
↓
3. System finds V1_2_FeaturesArrayToObject
↓
4. Calls transformResponse():
- Uses transformFeatureV1_2ToV1_1() for each feature
- Expands breakdown into separate array entries
- Converts object to array
↓
5. Returns Customer in 1.1 format
↓
Response sent to user
Questions?
If you're unsure how to handle a new versioning scenario:
- Identify what schema components are shared across resources
- Extract transformation logic into utility functions
- Create version changes for each affected resource
- Apply at the outermost level (endpoint/handler)
- Test both directions (forward and backward transformations)
Remember: Version changes should mirror your schema composition. If schemas are nested, version changes should handle the entire nesting hierarchy at the outermost level.