254 lines
7.0 KiB
Markdown
254 lines
7.0 KiB
Markdown
# API Versioning System
|
|
|
|
Stripe-inspired versioning with CalVer (external) and SemVer (internal).
|
|
|
|
## Core Concept
|
|
|
|
**Always build latest format → Transform backwards automatically**
|
|
|
|
```typescript
|
|
// Your handler
|
|
const latestData = { features: { f1: {...} } }; // V1_2 format
|
|
|
|
return applyVersionChanges({
|
|
data: latestData,
|
|
currentVersion: new ApiVersionClass(LATEST_VERSION),
|
|
targetVersion: ctx.apiVersion, // From middleware
|
|
resource: AffectedResource.Customer
|
|
});
|
|
```
|
|
|
|
## Quick Reference
|
|
|
|
### Version Comparison
|
|
```typescript
|
|
// ctx.apiVersion ready in middleware
|
|
if (ctx.apiVersion.gte(ApiVersion.V1_1)) { ... }
|
|
if (ctx.apiVersion.lt(ApiVersion.V1_2)) { ... }
|
|
```
|
|
|
|
### Version Mapping
|
|
|
|
| SemVer | CalVer | Legacy v1 | Legacy v2 |
|
|
|--------|--------|-----------|-----------|
|
|
| V1_4 | 2025-06-01 | - | 1.4 (beta) |
|
|
| V1_2 | 2025-05-05 | - | 1.2 |
|
|
| V1_1 | 2025-04-17 | - | 1.1 |
|
|
| V0_2 | 2025-04-01 | 0.2 | 1.0 |
|
|
| V0_1 | 2025-02-01 | 0.1 | - |
|
|
|
|
### Side Effects
|
|
```typescript
|
|
if (ctx.apiVersion.lt(ApiVersion.V1_1)) {
|
|
expandArray.push(CusExpand.Invoices);
|
|
}
|
|
```
|
|
|
|
## How Transforms Work
|
|
|
|
1. **User requests V1_1**, your data is V1_2:
|
|
```
|
|
V1_2 { features: { f1: {...} } }
|
|
↓ FeaturesArrayToObject.transform()
|
|
V1_1 { features: [{ feature_id: 'f1', ...}] }
|
|
```
|
|
|
|
2. **Multiple versions back** (V1_2 → V0_2):
|
|
```
|
|
V1_2 → V1_1 → V0_2
|
|
(Each transform applied in sequence)
|
|
```
|
|
|
|
## File Structure
|
|
|
|
```
|
|
shared/api/
|
|
├── versionUtils/
|
|
│ ├── ApiVersion.ts # Version enum
|
|
│ ├── ApiVersionClass.ts # Comparison methods
|
|
│ ├── versionRegistry.ts # SemVer ↔ CalVer mappings
|
|
│ ├── versionRegistryUtils.ts # Helper functions
|
|
│ ├── convertVersionUtils.ts # Conversion utils
|
|
│ ├── versionBranchUtils.ts # Branching helpers
|
|
│ └── versionChangeUtils/
|
|
│ ├── VersionChange.ts # Abstract base
|
|
│ ├── VersionChangeRegistryClass.ts # Registry class
|
|
│ ├── versionChangeRegistry.ts # Register all changes
|
|
│ └── applyVersionChanges.ts # Transform engine
|
|
└── customers/
|
|
└── changes/ # Customer-specific changes
|
|
├── V1_1_FeaturesArrayToObject.ts # Transforms TO V1_1
|
|
├── V0_2_CustomerChange.ts # Transforms TO V0_2
|
|
├── V0_1_CusFeatureChange.ts # Transforms TO V0_1
|
|
└── cusProducts/
|
|
└── changes/
|
|
└── V0_1_ProductItems.ts # Transforms TO V0_1
|
|
```
|
|
|
|
### Change Organization
|
|
|
|
**Version changes live with the resource they affect:**
|
|
- Customer changes → `shared/api/customers/changes/`
|
|
- Product changes → `shared/api/products/changes/`
|
|
- Invoice changes → `shared/api/invoices/changes/`
|
|
|
|
**Naming convention:** `V{target_version}_{Description}.ts`
|
|
|
|
Files are named after the **target version** (the older version we're transforming TO):
|
|
- `V1_1_FeaturesArrayToObject.ts` - Registered at V1_2, transforms TO V1_1
|
|
- `V0_2_CustomerChange.ts` - Registered at V1_1, transforms TO V0_2
|
|
- `V0_1_CusFeatureChange.ts` - Registered at V0_2, transforms TO V0_1
|
|
|
|
This makes it clear which version format the change produces.
|
|
|
|
## Creating Version Changes
|
|
|
|
**📝 Use the template:** Copy `versionChangeUtils/versionChangeTemplate.ts` as a starting point!
|
|
|
|
### 1. Create Change File
|
|
|
|
```typescript
|
|
// shared/api/customers/changes/V1_2_MyChange.ts
|
|
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
|
|
import {
|
|
AffectedResource,
|
|
defineVersionChange,
|
|
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
|
|
|
|
export const V1_2_MyChange = defineVersionChange({
|
|
oldVersion: ApiVersion.V1_2, // Applied when targetVersion <= V1_2
|
|
newVersion: ApiVersion.V1_3, // Breaking change introduced in V1_3
|
|
description: "Brief description of the change",
|
|
affectedResources: [AffectedResource.Customer],
|
|
newSchema: V1_3_Schema, // Latest format
|
|
oldSchema: V1_2_Schema, // Older format
|
|
|
|
transformResponse: ({ input }) => {
|
|
// Transform FROM V1_3 TO V1_2
|
|
const { newField, ...rest } = input;
|
|
return { ...rest, oldField: newField };
|
|
},
|
|
});
|
|
```
|
|
|
|
### 2. Register in Registry
|
|
|
|
```typescript
|
|
// versionChangeUtils/versionChangeRegistry.ts
|
|
import { V1_2_MyChange } from "@api/customers/changes/V1_2_MyChange.js";
|
|
|
|
// Register at oldVersion (V1_2), applied when targetVersion <= V1_2
|
|
const V1_2_CHANGES = [
|
|
V1_2_MyChange,
|
|
V1_2_AnotherChange,
|
|
];
|
|
|
|
export function registerAllVersionChanges() {
|
|
VersionChangeRegistryClass.register({
|
|
version: ApiVersion.V1_2,
|
|
changes: V1_2_CHANGES
|
|
});
|
|
// ... other versions
|
|
}
|
|
```
|
|
|
|
### Side Effect Changes
|
|
|
|
For changes that only affect behavior (no data transformation):
|
|
|
|
```typescript
|
|
import { NoOpSchema } from "@api/versionUtils/versionChangeUtils/VersionChange.js";
|
|
|
|
export const V0_2_InvoicesAlwaysExpanded = defineVersionChange({
|
|
oldVersion: ApiVersion.V0_2,
|
|
newVersion: ApiVersion.V1_1,
|
|
description: "Invoices were always expanded in V0_2 (no expand param)",
|
|
affectedResources: [AffectedResource.Customer],
|
|
hasSideEffects: true, // This is a side-effect-only change
|
|
newSchema: NoOpSchema,
|
|
oldSchema: NoOpSchema,
|
|
|
|
transformResponse: ({ input }) => input, // No transformation
|
|
});
|
|
|
|
// In handler:
|
|
if (ctx.apiVersion.lt(ApiVersion.V1_1)) {
|
|
// Handle side effect logic (e.g., always expand invoices)
|
|
expandArray.push(CusExpand.Invoices);
|
|
}
|
|
```
|
|
|
|
## CalVer with .clover Support
|
|
|
|
System supports `.clover` suffix for non-breaking changes:
|
|
- `2025-04-17` → Breaking change
|
|
- `2025-04-17.clover` → Non-breaking update (future use)
|
|
|
|
Both map to same SemVer internally.
|
|
|
|
## Usage Patterns
|
|
|
|
### Standard Handler
|
|
|
|
```typescript
|
|
export const handleGet = createRoute({
|
|
handler: async (c) => {
|
|
const ctx = c.get("ctx");
|
|
|
|
// Build latest
|
|
const data = buildLatest();
|
|
|
|
// Transform
|
|
return c.json(applyVersionChanges({
|
|
data,
|
|
currentVersion: new ApiVersionClass(LATEST_VERSION),
|
|
targetVersion: ctx.apiVersion,
|
|
resource: AffectedResource.Customer
|
|
}));
|
|
}
|
|
});
|
|
```
|
|
|
|
### With Version Logic
|
|
|
|
```typescript
|
|
// Check version
|
|
if (ctx.apiVersion.lt(ApiVersion.V1_1)) {
|
|
expandArray.push(CusExpand.Invoices);
|
|
}
|
|
|
|
// Use helper
|
|
const withItems = ctx.apiVersion.gte(ApiVersion.V0_2);
|
|
```
|
|
|
|
## Middleware
|
|
|
|
`apiVersionMiddleware` resolves version from:
|
|
1. `x-api-version` header (CalVer: "2025-04-17")
|
|
2. `org.api_version` (legacy: 1.1)
|
|
3. `org.config.api_version` (legacy: 0.2)
|
|
4. Default: V0_2
|
|
|
|
Result stored in `ctx.apiVersion` (ApiVersionClass).
|
|
|
|
## Migration from Old System
|
|
|
|
### Before
|
|
```typescript
|
|
const apiVersion = orgToVersion({ org, reqApiVersion });
|
|
if (apiVersion >= LegacyVersion.v1_1) { ... }
|
|
```
|
|
|
|
### After
|
|
```typescript
|
|
if (ctx.apiVersion.gte(ApiVersion.V1_1)) { ... }
|
|
```
|
|
|
|
## Key Principles
|
|
|
|
1. **Always build latest** - Let transforms handle old versions
|
|
2. **Transforms go backwards** - New → Old, never Old → New
|
|
3. **Object parameters** - All functions use `{ param }` signature
|
|
4. **Descending order** - Version lists newest first
|
|
5. **Resource organization** - Changes live with affected resources
|