Files
cfw-autumn/shared/api/VERSIONING_GUIDE.md
John Yeo 1847470403 feat: implement resource-based API versioning system with strong typing
## 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>
2025-10-06 19:25:21 +01:00

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 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

    // shared/api/[component]/changes/[component]TransformUtils.ts
    export const transformComponentV1_2ToV1_1 = (input) => { /* ... */ };
    
  2. 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 };
      },
    });
    
  3. Register version changes

    // shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts
    export const V1_2_CHANGES = [
      V1_2_ResourceA_ComponentChange,
      V1_2_ResourceB_ComponentChange,
      // ...
    ];
    
  4. 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:

  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.