## 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>
346 lines
11 KiB
Markdown
346 lines
11 KiB
Markdown
# 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:**
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
// 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,
|
|
});
|
|
};
|
|
```
|
|
|
|
```typescript
|
|
// 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 `CheckResultSchema` extends `CoreCusFeatureSchema`, the version change must handle both
|
|
- If `ApiCustomerSchema` contains `features: Record<string, ApiCusFeature>`, the version change must transform the features
|
|
|
|
### 4. **Automatic Application**
|
|
- Call `applyResponseVersionChanges()` with the appropriate `resource` parameter
|
|
- 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:
|
|
|
|
1. **Create transformation utilities**
|
|
```typescript
|
|
// shared/api/[component]/changes/[component]TransformUtils.ts
|
|
export const transformComponentV1_2ToV1_1 = (input) => { /* ... */ };
|
|
```
|
|
|
|
2. **Create version changes for each affected resource**
|
|
```typescript
|
|
// 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 };
|
|
},
|
|
});
|
|
```
|
|
|
|
3. **Register version changes**
|
|
```typescript
|
|
// shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts
|
|
export const V1_2_CHANGES = [
|
|
V1_2_ResourceA_ComponentChange,
|
|
V1_2_ResourceB_ComponentChange,
|
|
// ...
|
|
];
|
|
```
|
|
|
|
4. **Apply at endpoints**
|
|
```typescript
|
|
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:
|
|
|
|
1. **Identify what schema components are shared** across resources
|
|
2. **Extract transformation logic** into utility functions
|
|
3. **Create version changes** for each affected resource
|
|
4. **Apply at the outermost level** (endpoint/handler)
|
|
5. **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.
|
|
|