搜索

PythonReactSEOPDF
GitHub Skill

api-versioning

Create and maintain API version changes. Use when adding breaking changes to API responses/requests, creating version change files, transforming data between versions, or handling backward compatibility.

useautumn☆ 2,331 仓库 Stars179 仓库 Forks

安装与使用

请根据来源仓库和你的 AI 工具说明安装此技能。先阅读技能文件和依赖要求,再添加到工作流。

查看来源 ↗使用指南

技能说明

API Versioning

Create version change files that transform API data between versions.

Core Concept

Always build latest format, transform backwards automatically.

User on V1.2 ← transformResponse ← Latest (V2.0)
User on V1.2 → transformRequest  → Latest (V2.0)
  • Response transforms: Go BACKWARDS (new → old). User expects old format.
  • Request transforms: Go FORWARDS (old → new). Handler expects latest format.

Version Flow

V2.0 (latest)
  ↓ V1_2_CustomerChange.transformResponse()
V1_Beta
  ↓ V1_1_FeaturesArrayToObject.transformResponse()
V1.2
  ↓ V0_2_CustomerChange.transformResponse()
V1.1
  ↓ ...
V0.1

Each change file is a single-step mapping from one version to the previous.

Quick Reference

File Location Purpose
shared/api/{resource}/changes/V{X}_{Y}_{Name}.ts Response transforms (top-level resources)
shared/api/{resource}/requestChanges/V{X}_{Y}_{Name}.ts Request transforms
shared/api/{resource}/previousVersions/ Old schema definitions
shared/api/versionUtils/versionChangeRegistry.ts Register all changes

Creating a Version Change

Step 1: Define Schemas

Create/identify both schemas in shared/api/{resource}/:

// Latest schema (shared/api/customers/apiCustomer.ts)
export const ApiCustomerSchema = z.object({
  subscriptions: z.array(ApiSubscriptionSchema),  // V2.0: renamed from "products"
  balances: z.record(ApiBalanceSchema),           // V2.0: renamed from "features"
});

// Old schema (shared/api/customers/previousVersions/apiCustomerV3.ts)
export const ApiCustomerV3Schema = z.object({
  products: z.array(ApiCusProductV3Schema),  // V1.2 name
  features: z.record(ApiCusFeatureV3Schema), // V1.2 name
});
Step 2: Create the Change File

File: shared/api/customers/changes/V1.2_CustomerChange.ts

import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import {
  AffectedResource,
  defineVersionChange,
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import { ApiCustomerSchema } from "../apiCustomer.js";
import { ApiCustomerV3Schema } from "../previousVersions/apiCustomerV3.js";

export const V1_2_CustomerChange = defineVersionChange({
  newVersion: ApiVersion.V2_0,    // Version where breaking change was introduced
  oldVersion: ApiVersion.V1_Beta, // Version we're transforming TO
  description: [
    "Products renamed to subscriptions",
    "Features renamed to balances",
  ],
  affectedResources: [AffectedResource.Customer],
  newSchema: ApiCustomerSchema,
  oldSchema: ApiCustomerV3Schema,

  // Response: V2.0 → V1.2 (transform TO older format)
  transformResponse: ({ input, legacyData, ctx }) => {
    return {
      ...input,
      products: input.subscriptions.map(sub => 
        transformSubscriptionToCusProductV3({ input: sub, ctx })
      ),
      features: Object.fromEntries(
        Object.entries(input.balances).map(([id, bal]) => [
          id, 
          transformBalanceToCusFeatureV3({ input: bal })
        ])
      ),
    };
  },
});
Step 3: Register the Change

File: shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts

import { V1_2_CustomerChange } from "@api/customers/changes/V1.2_CustomerChange.js";

export const V2_CHANGES: VersionChangeConstructor[] = [
  V1_2_CustomerChange,  // Registered at V2.0, transforms TO V1.2
  // ... other V2.0 changes
];

export function registerAllVersionChanges() {
  VersionChangeRegistryClass.register({
    version: ApiVersion.V2_0,
    changes: V2_CHANGES,
  });
  // ... other versions
}

Nested Models (NOT Registered)

For nested models like ApiSubscription inside ApiCustomer:

  1. Create the change file with an exported transform function
  2. DO NOT register in versionChangeRegistry.ts
  3. Call the function from the parent's transform
// shared/api/customers/cusPlans/changes/V1.2_CusPlanChange.ts

// Export the function for reuse
export function transformSubscriptionToCusProductV3({
  input,
  legacyData,
  ctx,
}: {
  input: ApiSubscription;
  legacyData?: CusProductLegacyData;
  ctx: VersionContext;
}): ApiCusProductV3 {
  return {
    id: input.plan_id,
    name: input.plan?.name ?? null,
    status: mapStatus(input),
    // ... transform fields
  };
}

// Define the change (for reference, not registered)
export const V1_2_CusPlanChange = defineVersionChange({
  // ...
  transformResponse: transformSubscriptionToCusProductV3,
});

Then call from parent:

// V1_2_CustomerChange.ts
import { transformSubscriptionToCusProductV3 } from "../cusPlans/changes/V1.2_CusPlanChange.js";

transformResponse: ({ input, ctx }) => {
  return {
    products: input.subscriptions.map(sub => 
      transformSubscriptionToCusProductV3({ input: sub, ctx })
    ),
  };
}

Request Transformations (Old → New)

For transforming incoming requests to latest format:

// shared/api/customers/requestChanges/V1.2_CustomerQueryChange.ts

export const V1_2_CustomerQueryChange = defineVersionChange({
  newVersion: ApiVersion.V2_0,
  oldVersion: ApiVersion.V1_Beta,
  description: "Auto-expand plans.plan for V1.2 clients",
  affectedResources: [AffectedResource.Customer],
  newSchema: GetCustomerQuerySchema,
  oldSchema: GetCustomerQuerySchema,

  affectsRequest: true,   // Enable request transform
  affectsResponse: false, // Disable response transform

  // Request: V1.2 → V2.0 (transform TO newer format)
  transformRequest: ({ input }) => {
    return {
      ...input,
      expand: [...(input.expand || []), CusExpand.SubscriptionsPlan],
    };
  },
});

Handler Usage

Response Versioning (Automatic)
// Most handlers use getApiCustomer() which handles versioning internally
const customer = await getApiCustomer({ ctx, fullCustomer });
return c.json(customer);
Manual Response Versioning
import { applyResponseVersionChanges, AffectedResource } from "@autumn/shared";

const planResponse = await getPlanResponse({ product, features });

const versionedResponse = applyResponseVersionChanges<ApiPlan>({
  input: planResponse,
  targetVersion: ctx.apiVersion,
  resource: AffectedResource.Product,
  legacyData: { features: ctx.features },
  ctx,
});

return c.json(versionedResponse);
Versioned Request Validation
export const handleUpdatePlan = createRoute({
  versionedBody: {
    latest: UpdatePlanParamsSchema,
    [ApiVersion.V1_Beta]: UpdateProductV2ParamsSchema,
  },
  versionedQuery: {
    latest: UpdatePlanQuerySchema,
    [ApiVersion.V1_Beta]: UpdateProductQuerySchema,
  },
  resource: AffectedResource.Product,
  handler: async (c) => {
    const body = c.req.valid("json"); // Always latest format!
    // ...
  },
});

When Business Logic Needs Old Format

Sometimes business logic is tied to an old model version. Convert in the handler:

// server/src/internal/products/handlers/handleUpdatePlan.ts

const body = c.req.valid("json"); // Latest format (UpdatePlanParams)

// Convert to old format for existing business logic
const v1_2Body = ctx.apiVersion.gte(new ApiVersionClass(ApiVersion.V2_0))
  ? planToProductV2({ plan: body as ApiPlan, features: ctx.features })
  : (body as UpdateProductV2Params);

// Use v1_2Body with existing business functions
await handleUpdateProductDetails({
  newProduct: UpdateProductSchema.parse(v1_2Body),
  // ...
});

Side Effect Changes

For changes that affect behavior (not just data shape):

export const V0_2_InvoicesAlwaysExpanded = defineVersionChange({
  newVersion: ApiVersion.V1_1,
  oldVersion: ApiVersion.V0_2,
  description: "Invoices always expanded in V0_2",
  affectedResources: [AffectedResource.Customer],
  hasSideEffects: true, // Marks as side-effect only
  newSchema: NoOpSchema,
  oldSchema: NoOpSchema,
  transformResponse: ({ input }) => input, // No-op
});

Check in handler:

import { backwardsChangeActive, V0_2_InvoicesAlwaysExpanded } from "@autumn/shared";

if (backwardsChangeActive({
  apiVersion: ctx.apiVersion,
  versionChange: V0_2_InvoicesAlwaysExpanded,
})) {
  expand.push(CusExpand.Invoices);
}

File Naming Convention

Pattern Example Purpose
V{X}.{Y}_{Resource}Change.ts V1.2_CustomerChange.ts Main resource transform
V{X}.{Y}_{Description}.ts V1.1_FeaturesArrayToObject.ts Specific change
V{X}.{Y}_{Resource}QueryChange.ts V1.2_CustomerQueryChange.ts Request transform

Name after the TARGET version (the older version we're transforming TO).

Key Principles

  1. Build latest, transform backwards - Handlers always work with latest format
  2. Single-step transforms - Each change file maps ONE version to the PREVIOUS
  3. Register top-level only - Nested model transforms are called manually
  4. Use satisfies - Ensure return types match schema: return {...} satisfies z.infer<Schema>

References

使用示例

查看来源仓库中的使用示例与配置说明。

查看来源 ↗

来源历史

收录时间
2026-01-20
索引更新时间
2026-03-03
Branch
main

收录与同步日期属于本站记录。源码修改历史请查看来源仓库。

GitHub ↗

chat Comments (0)

chat_bubble_outline

No comments yet. Be the first to share your thoughts!