working on api versioning system
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -106,5 +106,5 @@ supabase/
|
||||
migration.sh
|
||||
stat.sh
|
||||
|
||||
CLAUDE.md
|
||||
|
||||
interview
|
||||
|
||||
32
CLAUDE.md
Normal file
32
CLAUDE.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Basic rules
|
||||
- Never run a "dev" or "build" command, chances are I'm already running it in the background. Just ask me to check for updates or whatever you need
|
||||
- Never ever ever write a "TO DO" comment. If you've been told to do something, DO IT. Don't stop halfway. Never give up and just leave a "to do" comment and say - "haha heres working code :)" - that is unacceptible. Always finish your task, no matter how many iterations you need to perform.
|
||||
|
||||
# Linting and Codebase rules
|
||||
- You can access the biome linter by running `npx biome check <folder or file path>`. Always specify a folder path, as the codebase is quite large and you will get out of scope errors that you are not burdened to correct. If you would like to let biome automatically fix as much as it can, use `npx biome check --write <folder or file path>`
|
||||
|
||||
- Note, biome does not perform typechecking. In which case you need to, you may run `tsc --noEmit --skipLibCheck <folder or file path>`
|
||||
|
||||
- This codebase uses Bun as its preferred package manager and Node runtime.
|
||||
|
||||
- Always prefer foo({ bar }) over foo(bar) method signatures - no matter if we are using only one argument or not, object as param are always better, as in the future when wanting to change the order of parameters, or add new ones - its easier.
|
||||
|
||||
- When creating "hooks" folders, don't nest them under "components"
|
||||
|
||||
- Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand.
|
||||
|
||||
## Bad example
|
||||
/ root
|
||||
-> components
|
||||
|-> hooks
|
||||
|
||||
## Good example
|
||||
/ root
|
||||
-> components
|
||||
-> hooks
|
||||
|
||||
# Figma MCP guidance
|
||||
- When you are using the Figma MCP server, you **must** follow our design system. Below is an example implementation of CVA with out design system
|
||||
|
||||
## File Naming
|
||||
DON'T name files one word (like index.ts, model.ts, etc.). Give proper indication in the filename to which resource it's targeting. For example, a utility file for organizations should be named orgUtils.ts. This is because it's easier to search for files like this. That being said, the filename shouldn't be overly long (less than three words is ideal)
|
||||
@@ -1,33 +1,78 @@
|
||||
import { ErrCode } from "@autumn/shared";
|
||||
import {
|
||||
ApiVersionClass,
|
||||
ErrCode,
|
||||
legacyToSemVer,
|
||||
parseVersion,
|
||||
} from "@autumn/shared";
|
||||
import type { Context, Next } from "hono";
|
||||
import semver, { type SemVer } from "semver";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import RecaseError from "@/utils/errorUtils.js";
|
||||
import { floatToVersion } from "@/utils/versionUtils/legacyVersionUtils.js";
|
||||
|
||||
/**
|
||||
* Middleware to verify and set API version from x-api-version header
|
||||
* Middleware to verify and set API version
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. x-api-version header (if provided)
|
||||
* 2. org.api_version (version system 2: 1.0, 1.1, 1.2, 1.4)
|
||||
* 3. org.config.api_version (version system 1: 0.1, 0.2)
|
||||
* 4. Default to org's default version
|
||||
*
|
||||
* Supports:
|
||||
* - CalVer format (YYYY-MM-DD) e.g., "2025-04-17"
|
||||
* - Legacy float format (X.X) e.g., "1.1"
|
||||
* - SemVer format (X.Y.Z) e.g., "1.1.0"
|
||||
*
|
||||
* Stores ApiVersionClass instance in ctx.apiVersion for easy access:
|
||||
* @example
|
||||
* if (ctx.apiVersion.gte(ApiVersion.V1_1)) { ... }
|
||||
*/
|
||||
export const apiVersionMiddleware = async (c: Context<HonoEnv>, next: Next) => {
|
||||
const ctx = c.get("ctx");
|
||||
const version = c.req.header("x-api-version");
|
||||
const versionHeader = c.req.header("x-api-version");
|
||||
const org = ctx.org;
|
||||
|
||||
if (version) {
|
||||
const versionFloat = parseFloat(version);
|
||||
const apiVersion = floatToVersion(versionFloat);
|
||||
let finalVersion: ApiVersionClass;
|
||||
|
||||
if (Number.isNaN(versionFloat) || !apiVersion) {
|
||||
// 1. Check header first
|
||||
if (versionHeader) {
|
||||
const parsedVersion = parseVersion({ versionStr: versionHeader });
|
||||
|
||||
if (!parsedVersion) {
|
||||
throw new RecaseError({
|
||||
message: `${version} is not a valid API version`,
|
||||
message: `"${versionHeader}" is not a valid API version. Use CalVer (e.g., "2025-04-17") or SemVer (e.g., "1.1.0")`,
|
||||
code: ErrCode.InvalidApiVersion,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Store in context
|
||||
// ctx.apiVersion = apiVersion.toString();
|
||||
ctx.apiVersion = semver.parse(version) as SemVer;
|
||||
// ctx.apiVersion.
|
||||
finalVersion = new ApiVersionClass(parsedVersion);
|
||||
}
|
||||
// 2. Check org.api_version (version system 2)
|
||||
else if (org?.api_version) {
|
||||
const semver = legacyToSemVer({ legacyVersion: org.api_version });
|
||||
if (semver) {
|
||||
finalVersion = new ApiVersionClass(semver);
|
||||
}
|
||||
}
|
||||
// 3. Check org.config.api_version (version system 1)
|
||||
else if (org?.config?.api_version) {
|
||||
const semver = legacyToSemVer({ legacyVersion: org.config.api_version });
|
||||
if (semver) {
|
||||
finalVersion = new ApiVersionClass(semver);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback
|
||||
if (!finalVersion) {
|
||||
const legacyVersion = org?.api_version || org?.config?.api_version || 1.0;
|
||||
const defaultVersion = legacyToSemVer({ legacyVersion });
|
||||
if (defaultVersion) {
|
||||
finalVersion = new ApiVersionClass(defaultVersion);
|
||||
}
|
||||
}
|
||||
|
||||
// Store in context - now you can do ctx.apiVersion.gte(ApiVersion.V1_1)
|
||||
ctx.apiVersion = finalVersion;
|
||||
|
||||
await next();
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { ZodType, z } from "zod/v4";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import { validator } from "./validatorMiddleware.js";
|
||||
import { versionedValidator } from "./versionedValidator.js";
|
||||
import type { AffectedResource, ApiVersion } from "@autumn/shared";
|
||||
|
||||
/**
|
||||
* Extended context type that includes validated input
|
||||
@@ -27,31 +29,44 @@ type ValidatedContext<
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Version-specific schemas configuration
|
||||
*/
|
||||
type VersionedSchemas<T extends ZodType> = Partial<Record<ApiVersion, ZodType>> & {
|
||||
latest: T; // Latest version schema (required)
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a type-safe route with validation that preserves full type inference!
|
||||
* By typing the context parameter, TypeScript knows what's available on c.req.valid()
|
||||
*
|
||||
* @example
|
||||
* Supports two patterns:
|
||||
*
|
||||
* **Pattern 1: Single version (most endpoints)**
|
||||
* ```ts
|
||||
* export const createProduct = createRoute({
|
||||
* body: CreateProductParamsSchema,
|
||||
* body: CreateProductSchema,
|
||||
* handler: async (c) => {
|
||||
* const body = c.req.valid("json"); // ✅ Fully typed from schema!
|
||||
* const body = c.req.valid("json"); // ✅ Fully typed!
|
||||
* return c.json({ success: true });
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* // With query validation too:
|
||||
* export const listProducts = createRoute({
|
||||
* query: ListProductsQuerySchema,
|
||||
* **Pattern 2: Multiple versions (when API changed)**
|
||||
* ```ts
|
||||
* export const createProduct = createRoute({
|
||||
* versionedBody: {
|
||||
* latest: CreateProductV3Schema, // Required
|
||||
* [ApiVersion.V1_1]: CreateProductV2Schema,
|
||||
* [ApiVersion.V0_2]: CreateProductV1Schema,
|
||||
* },
|
||||
* resource: AffectedResource.Product,
|
||||
* handler: async (c) => {
|
||||
* const query = c.req.valid("query"); // ✅ Fully typed!
|
||||
* return c.json({ products: [] });
|
||||
* const body = c.req.valid("json"); // ✅ Always latest schema type!
|
||||
* // Old versions auto-transformed to latest format
|
||||
* return c.json({ success: true });
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* // In router:
|
||||
* honoProductRouter.post("", ...createProduct);
|
||||
* ```
|
||||
*/
|
||||
export function createRoute<
|
||||
@@ -59,7 +74,10 @@ export function createRoute<
|
||||
Query extends ZodType | undefined = undefined,
|
||||
>(opts: {
|
||||
body?: Body;
|
||||
versionedBody?: Body extends ZodType ? VersionedSchemas<Body> : never;
|
||||
query?: Query;
|
||||
versionedQuery?: Query extends ZodType ? VersionedSchemas<Query> : never;
|
||||
resource?: AffectedResource;
|
||||
withTx?: boolean;
|
||||
handler: (
|
||||
c: ValidatedContext<HonoEnv, Body, Query>,
|
||||
@@ -67,10 +85,30 @@ export function createRoute<
|
||||
}) {
|
||||
const middlewares: MiddlewareHandler[] = [];
|
||||
|
||||
if (opts.body) {
|
||||
// Use versioned validator if versionedBody provided
|
||||
if (opts.versionedBody && opts.resource) {
|
||||
middlewares.push(
|
||||
versionedValidator({
|
||||
target: "json",
|
||||
schemas: opts.versionedBody,
|
||||
resource: opts.resource,
|
||||
}),
|
||||
);
|
||||
} else if (opts.body) {
|
||||
// Fallback to regular validator for single-version endpoints
|
||||
middlewares.push(validator("json", opts.body));
|
||||
}
|
||||
if (opts.query) {
|
||||
|
||||
// Same for query
|
||||
if (opts.versionedQuery && opts.resource) {
|
||||
middlewares.push(
|
||||
versionedValidator({
|
||||
target: "query",
|
||||
schemas: opts.versionedQuery,
|
||||
resource: opts.resource,
|
||||
}),
|
||||
);
|
||||
} else if (opts.query) {
|
||||
middlewares.push(validator("query", opts.query));
|
||||
}
|
||||
|
||||
|
||||
87
server/src/honoMiddlewares/versionedValidator.ts
Normal file
87
server/src/honoMiddlewares/versionedValidator.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import type { ZodType } from "zod/v4";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import {
|
||||
ApiVersionClass,
|
||||
type ApiVersion,
|
||||
type AffectedResource,
|
||||
LATEST_VERSION,
|
||||
applyRequestVersionChanges,
|
||||
} from "@autumn/shared";
|
||||
|
||||
/**
|
||||
* Version-aware validator middleware
|
||||
*
|
||||
* Flow:
|
||||
* 1. Determine user's API version from ctx.apiVersion
|
||||
* 2. Select appropriate schema for that version
|
||||
* 3. Validate data against version-specific schema (clear errors for their version)
|
||||
* 4. Transform validated data to latest format
|
||||
* 5. Store transformed data for handler
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* router.post(
|
||||
* "/products",
|
||||
* versionedValidator({
|
||||
* target: "json",
|
||||
* schemas: {
|
||||
* latest: CreateProductV3Schema,
|
||||
* [ApiVersion.V1_1]: CreateProductV2Schema,
|
||||
* [ApiVersion.V0_2]: CreateProductV1Schema,
|
||||
* },
|
||||
* resource: AffectedResource.Product,
|
||||
* }),
|
||||
* async (c) => {
|
||||
* const body = c.req.valid("json"); // Always latest format!
|
||||
* }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export const versionedValidator = ({
|
||||
target,
|
||||
schemas,
|
||||
resource,
|
||||
}: {
|
||||
target: "json" | "query" | "param" | "header" | "form";
|
||||
schemas: Partial<Record<ApiVersion, ZodType>> & { latest: ZodType };
|
||||
resource: AffectedResource;
|
||||
}): MiddlewareHandler => {
|
||||
return async (c, next) => {
|
||||
const ctx = c.get("ctx");
|
||||
const userVersion = ctx.apiVersion;
|
||||
|
||||
// Select schema for user's version, fallback to latest
|
||||
const schema = schemas[userVersion.value] ?? schemas.latest;
|
||||
|
||||
// Validate with version-specific schema using zValidator
|
||||
const validatorMiddleware = zValidator(target, schema, (result, _c) => {
|
||||
if (!result.success) {
|
||||
// Validation errors reference fields from user's version ✅
|
||||
throw result.error;
|
||||
}
|
||||
});
|
||||
|
||||
// Run validation
|
||||
await validatorMiddleware(c, async () => {});
|
||||
|
||||
// Get validated data
|
||||
const validatedData = c.req.valid(target);
|
||||
|
||||
// If user is on older version, transform to latest
|
||||
if (!userVersion.eq(new ApiVersionClass(LATEST_VERSION))) {
|
||||
const transformed = applyRequestVersionChanges({
|
||||
data: validatedData,
|
||||
targetVersion: userVersion,
|
||||
currentVersion: new ApiVersionClass(LATEST_VERSION),
|
||||
resource,
|
||||
});
|
||||
|
||||
// Replace validated data with transformed version
|
||||
// This ensures handler always receives latest format
|
||||
c.req.addValidatedData(target, transformed);
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { AppEnv, AuthType, Feature, Organization } from "@autumn/shared";
|
||||
import type {
|
||||
ApiVersionClass,
|
||||
AppEnv,
|
||||
AuthType,
|
||||
Feature,
|
||||
Organization,
|
||||
} from "@autumn/shared";
|
||||
import type { ClickHouseClient } from "@clickhouse/client";
|
||||
import type { SemVer } from "semver";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import type { Logger } from "@/external/logtail/logtailUtils.js";
|
||||
|
||||
@@ -20,7 +25,7 @@ export type RequestContext = {
|
||||
id: string;
|
||||
isPublic: boolean;
|
||||
authType: AuthType;
|
||||
apiVersion: SemVer;
|
||||
apiVersion: ApiVersionClass;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
AffectedResource,
|
||||
type ApiVersion,
|
||||
ApiVersionClass,
|
||||
applyResponseVersionChanges,
|
||||
CusEntResponseSchema,
|
||||
type Feature,
|
||||
FeatureType,
|
||||
LATEST_VERSION,
|
||||
} from "@autumn/shared";
|
||||
|
||||
/**
|
||||
* Transforms feature balances to API format for requested version
|
||||
*
|
||||
* Latest format (V1_2):
|
||||
* - Object keyed by feature_id
|
||||
* - Has usage/included_usage fields (not used/allowance)
|
||||
*
|
||||
* V1_1:
|
||||
* - Array format
|
||||
* - Has usage/included_usage fields
|
||||
*
|
||||
* V1_0:
|
||||
* - Array format (split response, not in customer object)
|
||||
* - Has used/allowance fields
|
||||
*/
|
||||
export const getApiCusFeature = ({
|
||||
balances,
|
||||
features,
|
||||
apiVersion,
|
||||
}: {
|
||||
balances: any[]; // Raw balances from getCusBalances
|
||||
features: Feature[]; // Feature definitions
|
||||
apiVersion: ApiVersion;
|
||||
}): any => {
|
||||
// Transform balances to latest format (V1_1+: usage/included_usage)
|
||||
const transformedBalances = balances.map((b) => {
|
||||
const isBoolean =
|
||||
features.find((f: Feature) => f.id === b.feature_id)?.type ===
|
||||
FeatureType.Boolean;
|
||||
|
||||
if (b.unlimited || isBoolean) {
|
||||
return b;
|
||||
}
|
||||
|
||||
return CusEntResponseSchema.parse({
|
||||
...b,
|
||||
usage: b.used,
|
||||
included_usage: b.allowance,
|
||||
});
|
||||
});
|
||||
|
||||
// Build in latest format (V1_2: object keyed by feature_id)
|
||||
const featuresObject: Record<string, any> = {};
|
||||
for (const balance of transformedBalances) {
|
||||
featuresObject[balance.feature_id] = balance;
|
||||
}
|
||||
|
||||
// Apply version changes to transform to requested version
|
||||
// V1_2 stays as object
|
||||
// V1_1 → array (via V1_2_FeaturesArrayToObject transform)
|
||||
return applyResponseVersionChanges({
|
||||
input: featuresObject,
|
||||
currentVersion: new ApiVersionClass(LATEST_VERSION),
|
||||
targetVersion: new ApiVersionClass(apiVersion),
|
||||
resource: AffectedResource.CusFeature,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
AffectedResource,
|
||||
APICusProductSchema,
|
||||
type ApiVersion,
|
||||
ApiVersionClass,
|
||||
applyResponseVersionChanges,
|
||||
CusProductStatus,
|
||||
type Feature,
|
||||
type FullCusProduct,
|
||||
LATEST_VERSION,
|
||||
type Subscription,
|
||||
} from "@autumn/shared";
|
||||
import { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js";
|
||||
import { fullCusProductToProduct } from "../../cusProducts/cusProductUtils.js";
|
||||
|
||||
/**
|
||||
* Builds a customer product in LATEST format, then applies version changes
|
||||
*
|
||||
* Latest format (V1_1+):
|
||||
* - Has `items` field with product features
|
||||
* - Has `started_at` instead of `starts_at`
|
||||
* - Includes current_period_start/end for trials
|
||||
*/
|
||||
export const getApiCusProduct = async ({
|
||||
cusProduct,
|
||||
subs,
|
||||
features,
|
||||
apiVersion,
|
||||
entity,
|
||||
}: {
|
||||
cusProduct: FullCusProduct;
|
||||
subs?: Subscription[];
|
||||
features: Feature[];
|
||||
apiVersion: ApiVersion;
|
||||
entity?: any;
|
||||
}): Promise<any> => {
|
||||
// Determine if trialing
|
||||
const trialing =
|
||||
cusProduct.trial_ends_at && cusProduct.trial_ends_at > Date.now();
|
||||
|
||||
// Build stripe subscription data
|
||||
const subIds = cusProduct.subscription_ids;
|
||||
let stripeSubData = {};
|
||||
|
||||
if ((!subIds || subIds.length === 0) && trialing) {
|
||||
stripeSubData = {
|
||||
current_period_start: cusProduct.starts_at,
|
||||
current_period_end: cusProduct.trial_ends_at,
|
||||
};
|
||||
} else if (subIds && subIds.length > 0 && subs) {
|
||||
const baseSub = subs.find(
|
||||
(s) => s.id === subIds[0] || (s as Subscription).stripe_id === subIds[0],
|
||||
);
|
||||
if (baseSub) {
|
||||
stripeSubData = {
|
||||
current_period_end: baseSub.current_period_end
|
||||
? baseSub.current_period_end * 1000
|
||||
: null,
|
||||
current_period_start: baseSub.current_period_start
|
||||
? baseSub.current_period_start * 1000
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Build product in LATEST format (V1_1+)
|
||||
const fullProduct = fullCusProductToProduct(cusProduct);
|
||||
const v2Product = await getProductResponse({
|
||||
product: fullProduct,
|
||||
features,
|
||||
withDisplay: false,
|
||||
options: cusProduct.options,
|
||||
});
|
||||
|
||||
const latestProduct = APICusProductSchema.parse({
|
||||
id: fullProduct.id,
|
||||
name: fullProduct.name,
|
||||
group: fullProduct.group || null,
|
||||
status: trialing ? CusProductStatus.Trialing : cusProduct.status,
|
||||
canceled_at: cusProduct.canceled_at || null,
|
||||
is_default: fullProduct.is_default || false,
|
||||
is_add_on: fullProduct.is_add_on || false,
|
||||
version: fullProduct.version,
|
||||
quantity: cusProduct.quantity,
|
||||
started_at: cusProduct.starts_at,
|
||||
entity_id: entity?.id || cusProduct.entity_id || undefined,
|
||||
...stripeSubData,
|
||||
items: v2Product.items, // V1_1+ has items field
|
||||
});
|
||||
|
||||
// Apply version changes to transform to requested version
|
||||
// This will remove items field for V0_1
|
||||
return applyResponseVersionChanges({
|
||||
input: latestProduct,
|
||||
currentVersion: new ApiVersionClass(LATEST_VERSION),
|
||||
targetVersion: new ApiVersionClass(apiVersion),
|
||||
resource: AffectedResource.CusProduct,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
AffectedResource,
|
||||
type APICusProduct,
|
||||
APICustomerSchema,
|
||||
type ApiVersion,
|
||||
ApiVersionClass,
|
||||
applyResponseVersionChanges,
|
||||
type CusProductStatus,
|
||||
type Feature,
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
LATEST_VERSION,
|
||||
} from "@autumn/shared";
|
||||
import { getApiCusFeature } from "./getApiCusFeature.js";
|
||||
import { getApiCusProduct } from "./getApiCusProduct.js";
|
||||
|
||||
/**
|
||||
* Merges customer products by id and status
|
||||
* This is how V1_1+ handles multiple subscriptions to the same product
|
||||
*/
|
||||
const mergeApiCusProducts = ({
|
||||
cusProductResponses,
|
||||
}: {
|
||||
cusProductResponses: APICusProduct[];
|
||||
}) => {
|
||||
const getProductKey = (product: APICusProduct) => {
|
||||
const status = ACTIVE_STATUSES.includes(product.status as CusProductStatus)
|
||||
? "active"
|
||||
: product.status;
|
||||
return `${product.id}:${status}`;
|
||||
};
|
||||
|
||||
const record: Record<string, any> = {};
|
||||
|
||||
for (const curr of cusProductResponses) {
|
||||
const key = getProductKey(curr);
|
||||
const latest = record[key];
|
||||
|
||||
const currStartedAt = curr.started_at;
|
||||
|
||||
record[key] = {
|
||||
...(latest || curr),
|
||||
version: Math.max(latest?.version || 1, curr?.version || 1),
|
||||
canceled_at: curr.canceled_at
|
||||
? curr.canceled_at
|
||||
: latest?.canceled_at || null,
|
||||
started_at: latest?.started_at
|
||||
? Math.min(latest?.started_at, currStartedAt)
|
||||
: currStartedAt,
|
||||
quantity: (latest?.quantity || 0) + (curr?.quantity || 0),
|
||||
};
|
||||
}
|
||||
|
||||
return Object.values(record);
|
||||
};
|
||||
|
||||
export const getApiCustomer = async ({
|
||||
customer,
|
||||
cusProducts,
|
||||
balances,
|
||||
features,
|
||||
apiVersion,
|
||||
invoices,
|
||||
trialsUsed,
|
||||
rewards,
|
||||
entities,
|
||||
referrals,
|
||||
upcomingInvoice,
|
||||
paymentMethod,
|
||||
withAutumnId = false,
|
||||
}: {
|
||||
customer: FullCustomer;
|
||||
cusProducts: FullCusProduct[];
|
||||
balances: any[];
|
||||
features: Feature[];
|
||||
apiVersion: ApiVersion;
|
||||
invoices?: any[];
|
||||
trialsUsed?: any[];
|
||||
rewards?: any;
|
||||
entities?: any[];
|
||||
referrals?: any[];
|
||||
upcomingInvoice?: any;
|
||||
paymentMethod?: any;
|
||||
withAutumnId?: boolean;
|
||||
}): Promise<any> => {
|
||||
const subs = customer.subscriptions || [];
|
||||
|
||||
// Process each product using getApiCusProduct (builds latest format + applies transforms)
|
||||
let main: APICusProduct[] = [];
|
||||
let addOns: APICusProduct[] = [];
|
||||
|
||||
for (const cusProduct of cusProducts) {
|
||||
const processed = await getApiCusProduct({
|
||||
cusProduct,
|
||||
subs,
|
||||
features,
|
||||
apiVersion,
|
||||
});
|
||||
|
||||
const isAddOn = cusProduct.product.is_add_on;
|
||||
if (isAddOn) {
|
||||
addOns.push(processed);
|
||||
} else {
|
||||
main.push(processed);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge products (V1_1+ behavior, always do this in latest format)
|
||||
main = mergeApiCusProducts({ cusProductResponses: main });
|
||||
addOns = mergeApiCusProducts({ cusProductResponses: addOns });
|
||||
|
||||
// Merge main and addOns into single products array (V1_1+ behavior)
|
||||
const allProducts = [...main, ...addOns];
|
||||
|
||||
// Get versioned features (handles field mapping + object vs array format)
|
||||
const apiFeatures = getApiCusFeature({
|
||||
balances,
|
||||
features,
|
||||
apiVersion,
|
||||
});
|
||||
|
||||
// Build customer in latest format (V1_1+: merged response with features/products)
|
||||
const latestCustomer = APICustomerSchema.parse({
|
||||
autumn_id: withAutumnId ? customer.internal_id : undefined,
|
||||
id: customer.id,
|
||||
email: customer.email,
|
||||
name: customer.name,
|
||||
fingerprint: customer.fingerprint,
|
||||
stripe_id: customer.processor?.id,
|
||||
env: customer.env,
|
||||
created_at: customer.created_at,
|
||||
features: apiFeatures, // Already versioned (object for V1_2, array for V1_1)
|
||||
products: allProducts, // Merged products (V1_1+ format)
|
||||
invoices,
|
||||
trials_used: trialsUsed,
|
||||
rewards,
|
||||
metadata: customer.metadata || {},
|
||||
entities,
|
||||
referrals,
|
||||
upcoming_invoice: upcomingInvoice,
|
||||
payment_method: paymentMethod,
|
||||
});
|
||||
|
||||
// Apply customer-level version changes (e.g., V1_1_MergedResponse)
|
||||
// This will split the merged response for V1_0 users
|
||||
return applyResponseVersionChanges({
|
||||
input: latestCustomer,
|
||||
currentVersion: new ApiVersionClass(LATEST_VERSION),
|
||||
targetVersion: new ApiVersionClass(apiVersion),
|
||||
resource: AffectedResource.Customer,
|
||||
});
|
||||
};
|
||||
@@ -187,6 +187,7 @@ export const getCustomerDetails = async ({
|
||||
return cusResponse;
|
||||
}
|
||||
} else {
|
||||
// Probably don't need items...?
|
||||
const withItems = org.config.api_version >= BREAK_API_VERSION;
|
||||
|
||||
const processedInvoices = await getCusInvoices({
|
||||
|
||||
172
server/src/internal/customers/handlers/v2/handleGetCustomerV2.ts
Normal file
172
server/src/internal/customers/handlers/v2/handleGetCustomerV2.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
ApiVersion,
|
||||
CusExpand,
|
||||
CusProductStatus,
|
||||
cusProductsToCusEnts,
|
||||
cusProductsToCusPrices,
|
||||
ErrCode,
|
||||
} from "@autumn/shared";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||
import { invoicesToResponse } from "@/internal/invoices/invoiceUtils.js";
|
||||
import { getCusWithCache } from "../../cusCache/getCusWithCache.js";
|
||||
import { getApiCustomer } from "../../cusUtils/apiCusUtils/getApiCustomer.js";
|
||||
import { getCusBalances } from "../../cusUtils/cusFeatureResponseUtils/getCusBalances.js";
|
||||
import { getCusPaymentMethodRes } from "../../cusUtils/cusResponseUtils/getCusPaymentMethodRes.js";
|
||||
import { getCusReferrals } from "../../cusUtils/cusResponseUtils/getCusReferrals.js";
|
||||
import { getCusRewards } from "../../cusUtils/cusResponseUtils/getCusRewards.js";
|
||||
import { getCusUpcomingInvoice } from "../../cusUtils/cusResponseUtils/getCusUpcomingInvoice.js";
|
||||
import { parseCusExpand } from "../../cusUtils/cusUtils.js";
|
||||
|
||||
/**
|
||||
* GET /customers/:customer_id (V2 with versioning system)
|
||||
*
|
||||
* This is the NEW implementation using the versioning system.
|
||||
* DO NOT touch the old handleGetCustomer.ts until this is validated.
|
||||
*
|
||||
* Key differences:
|
||||
* 1. Each resource (customer/product/feature) handles its own versioning
|
||||
* 2. getApiCustomer/getApiCusProduct/getApiCusFeature apply version changes
|
||||
* 3. No version branching in handler logic
|
||||
* 4. Side effects handled explicitly (expand invoices for V1_0)
|
||||
*/
|
||||
export const handleGetCustomerV2 = createRoute({
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const customerId = c.req.param("customer_id");
|
||||
const { env, db, logger, org, features } = ctx;
|
||||
const { expand } = c.req.query();
|
||||
|
||||
const expandArray = parseCusExpand(expand);
|
||||
|
||||
// Side effect: V1_0 always expands invoices
|
||||
if (ctx.apiVersion.lt(ApiVersion.V1_1)) {
|
||||
expandArray.push(CusExpand.Invoices);
|
||||
}
|
||||
|
||||
logger.info(`[V2] Getting customer ${customerId} for org ${org.slug}`);
|
||||
const startTime = Date.now();
|
||||
|
||||
const customer = await getCusWithCache({
|
||||
db,
|
||||
idOrInternalId: customerId,
|
||||
org,
|
||||
env,
|
||||
expand: expandArray,
|
||||
allowNotFound: true,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info(`[V2] Get customer took ${Date.now() - startTime}ms`);
|
||||
|
||||
if (!customer) {
|
||||
logger.warn(`[V2] Customer ${customerId} not found | Org: ${org.slug}`);
|
||||
return c.json(
|
||||
{
|
||||
message: `Customer ${customerId} not found`,
|
||||
code: ErrCode.CustomerNotFound,
|
||||
},
|
||||
StatusCodes.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
// Get feature balances
|
||||
const inStatuses = org.config.include_past_due
|
||||
? [CusProductStatus.Active, CusProductStatus.PastDue]
|
||||
: [CusProductStatus.Active];
|
||||
|
||||
const cusEnts = cusProductsToCusEnts({
|
||||
cusProducts: customer.customer_products,
|
||||
inStatuses,
|
||||
});
|
||||
const balances = await getCusBalances({
|
||||
cusEntsWithCusProduct: cusEnts,
|
||||
cusPrices: cusProductsToCusPrices({
|
||||
cusProducts: customer.customer_products,
|
||||
inStatuses,
|
||||
}),
|
||||
org,
|
||||
apiVersion: ctx.apiVersion.semver as any, // TODO: fix type
|
||||
});
|
||||
|
||||
// Fetch optional expanded fields
|
||||
const subIds = customer.customer_products.flatMap(
|
||||
(cp: any) => cp.subscription_ids || [],
|
||||
);
|
||||
|
||||
const rewards = await getCusRewards({
|
||||
org,
|
||||
env,
|
||||
fullCus: customer,
|
||||
subIds,
|
||||
expand: expandArray,
|
||||
});
|
||||
|
||||
const upcomingInvoice = await getCusUpcomingInvoice({
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
fullCus: customer,
|
||||
expand: expandArray,
|
||||
});
|
||||
|
||||
const referrals = await getCusReferrals({
|
||||
db,
|
||||
fullCus: customer,
|
||||
expand: expandArray,
|
||||
});
|
||||
|
||||
const paymentMethod = await getCusPaymentMethodRes({
|
||||
org,
|
||||
env,
|
||||
fullCus: customer,
|
||||
expand: expandArray,
|
||||
});
|
||||
|
||||
const invoices = expandArray.includes(CusExpand.Invoices)
|
||||
? invoicesToResponse({
|
||||
invoices: customer.invoices || [],
|
||||
logger,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const entities = expandArray.includes(CusExpand.Entities)
|
||||
? customer.entities.map((e: any) => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
customer_id: customer.id,
|
||||
feature_id: e.feature_id,
|
||||
created_at: e.created_at,
|
||||
env: customer.env,
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
const trialsUsed = expandArray.includes(CusExpand.TrialsUsed)
|
||||
? customer.trials_used
|
||||
: undefined;
|
||||
|
||||
const { with_autumn_id } = c.req.query();
|
||||
|
||||
// Use getApiCustomer - it handles all versioning internally!
|
||||
// - Calls getApiCusProduct for each product (builds latest + applies transforms)
|
||||
// - Calls getApiCusFeature for features (field mapping + object↔array)
|
||||
// - Applies customer-level changes (splits response for V1_0)
|
||||
const customerResponse = await getApiCustomer({
|
||||
customer,
|
||||
cusProducts: customer.customer_products,
|
||||
balances,
|
||||
features,
|
||||
apiVersion: ctx.apiVersion.semver,
|
||||
invoices,
|
||||
trialsUsed,
|
||||
rewards,
|
||||
entities,
|
||||
referrals,
|
||||
upcomingInvoice,
|
||||
paymentMethod,
|
||||
withAutumnId: with_autumn_id === "true",
|
||||
});
|
||||
|
||||
return c.json(customerResponse);
|
||||
},
|
||||
});
|
||||
0
shared/CLAUDE.md
Normal file
0
shared/CLAUDE.md
Normal file
@@ -2,7 +2,7 @@ import { EntityDataSchema } from "@api/common/entityData.js";
|
||||
import { APIProductSchema } from "@api/products/apiProduct.js";
|
||||
import { z } from "zod/v4";
|
||||
import { CustomerDataSchema } from "../common/customerData.js";
|
||||
import { CoreCusFeatureSchema } from "../customers/components/apiCusFeature.js";
|
||||
import { CoreCusFeatureSchema } from "../customers/cusFeatures/apiCusFeature.js";
|
||||
|
||||
// Check Feature Enums
|
||||
export const CheckFeatureScenarioSchema = z
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { APICusFeatureSchema } from "@api/customers/components/apiCusFeature.js";
|
||||
import { APICusProductSchema } from "@api/customers/components/apiCusProduct.js";
|
||||
import { APICusReferralSchema } from "@api/customers/components/apiCusReferral.js";
|
||||
import { APICusFeatureSchema } from "@api/customers/cusFeatures/apiCusFeature.js";
|
||||
import { APICusRewardsSchema } from "@api/models.js";
|
||||
import { APIInvoiceSchema } from "@api/others/apiInvoice.js";
|
||||
import { EntityResponseSchema } from "@models/cusModels/entityModels/entityResModels.js";
|
||||
|
||||
41
shared/api/customers/changes/V1_1_LegacyExpandInvoices.ts
Normal file
41
shared/api/customers/changes/V1_1_LegacyExpandInvoices.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
|
||||
import {
|
||||
AffectedResource,
|
||||
VersionChange,
|
||||
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* V1_1: Invoices expansion behavior changed (side effect only)
|
||||
*
|
||||
* V1_1+: Invoices require explicit expand parameter
|
||||
* V1_0: Invoices always included (side effect - must be handled in handler)
|
||||
*
|
||||
* This change has side effects and doesn't transform data.
|
||||
* The handler must add CusExpand.Invoices for V1_0 requests.
|
||||
*/
|
||||
|
||||
// Schema is just any since this is a side-effect only change
|
||||
const NoOpSchema = z.any();
|
||||
|
||||
export class V1_1_LegacyExpandInvoices extends VersionChange<
|
||||
typeof NoOpSchema,
|
||||
typeof NoOpSchema
|
||||
> {
|
||||
readonly version = ApiVersion.V1_1;
|
||||
readonly description = "Invoices always expanded before V1_1";
|
||||
readonly affectedResources = [AffectedResource.Customer];
|
||||
readonly hasSideEffects = true;
|
||||
|
||||
readonly newSchema = NoOpSchema;
|
||||
readonly oldSchema = NoOpSchema;
|
||||
|
||||
// No transformation needed - this is a side effect handled in the handler
|
||||
transformResponse({
|
||||
input,
|
||||
}: {
|
||||
input: z.infer<typeof NoOpSchema>;
|
||||
}): z.infer<typeof NoOpSchema> {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
74
shared/api/customers/changes/V1_1_MergedResponse.ts
Normal file
74
shared/api/customers/changes/V1_1_MergedResponse.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { APICustomerSchema } from "@api/customers/apiCustomer.js";
|
||||
import { APICusProductSchema } from "@api/customers/components/apiCusProduct.js";
|
||||
import { APICusFeatureSchema } from "@api/customers/cusFeatures/apiCusFeature.js";
|
||||
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
|
||||
import {
|
||||
AffectedResource,
|
||||
VersionChange,
|
||||
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* V1_1: Customer response structure changed from split to merged
|
||||
*
|
||||
* V1_1+ format: Merged single object with features/products
|
||||
* V1_0 format: Split into {customer, products, add_ons, entitlements, invoices}
|
||||
*/
|
||||
|
||||
// V1_1+ merged response schema
|
||||
const V1_1_CustomerResponseSchema = APICustomerSchema;
|
||||
|
||||
// V1_0 split response schema
|
||||
const V1_0_CustomerResponseSchema = z.object({
|
||||
customer: APICustomerSchema.omit({
|
||||
features: true,
|
||||
products: true,
|
||||
invoices: true,
|
||||
trials_used: true,
|
||||
}),
|
||||
products: z.array(APICusProductSchema),
|
||||
add_ons: z.array(APICusProductSchema),
|
||||
entitlements: z.array(APICusFeatureSchema),
|
||||
invoices: z.array(z.any()),
|
||||
trials_used: z.array(z.any()).optional(),
|
||||
});
|
||||
|
||||
export class V1_1_MergedResponse extends VersionChange<
|
||||
typeof V1_1_CustomerResponseSchema,
|
||||
typeof V1_0_CustomerResponseSchema
|
||||
> {
|
||||
readonly version = ApiVersion.V1_1;
|
||||
readonly description = "Merged customer response → split structure";
|
||||
readonly affectedResources = [AffectedResource.Customer];
|
||||
readonly affectsRequest = false;
|
||||
readonly affectsResponse = true;
|
||||
|
||||
readonly newSchema = V1_1_CustomerResponseSchema;
|
||||
readonly oldSchema = V1_0_CustomerResponseSchema;
|
||||
|
||||
// Response: V1_1 merged → V1_0 split
|
||||
transformResponse({
|
||||
input,
|
||||
}: {
|
||||
input: z.infer<typeof V1_1_CustomerResponseSchema>;
|
||||
}): z.infer<typeof V1_0_CustomerResponseSchema> {
|
||||
const {
|
||||
features,
|
||||
products = [],
|
||||
invoices,
|
||||
trials_used,
|
||||
...customerFields
|
||||
} = input;
|
||||
|
||||
return {
|
||||
customer: customerFields,
|
||||
products: products.filter((p) => !p.is_add_on),
|
||||
add_ons: products.filter((p) => p.is_add_on),
|
||||
entitlements: Array.isArray(features)
|
||||
? features
|
||||
: Object.values(features),
|
||||
invoices: invoices || [],
|
||||
trials_used,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -68,5 +68,4 @@ export const APICusFeatureSchema = z
|
||||
|
||||
export type CusEntResponse = z.infer<typeof CusEntResponseSchema>;
|
||||
export type CusEntResponseV2 = z.infer<typeof APICusFeatureSchema>;
|
||||
|
||||
export type CusRollover = z.infer<typeof CusRolloverSchema>;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { APICusFeatureSchema } from "@api/customers/cusFeatures/apiCusFeature.js";
|
||||
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
|
||||
import {
|
||||
AffectedResource,
|
||||
VersionChange,
|
||||
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* V1_2: Features changed from array to object
|
||||
*
|
||||
* V1_2+ format: Object keyed by feature_id
|
||||
* V1_1 format: Array of features
|
||||
*/
|
||||
|
||||
// V1_2+ features schema (object format)
|
||||
const V1_2_FeaturesSchema = z.record(z.string(), APICusFeatureSchema);
|
||||
|
||||
// V1_1 features schema (array format)
|
||||
const V1_1_FeaturesSchema = z.array(APICusFeatureSchema);
|
||||
|
||||
export class V1_2_FeaturesArrayToObject extends VersionChange<
|
||||
typeof V1_2_FeaturesSchema,
|
||||
typeof V1_1_FeaturesSchema
|
||||
> {
|
||||
readonly version = ApiVersion.V1_2;
|
||||
readonly description = "Features: object → array";
|
||||
readonly affectedResources = [AffectedResource.CusFeature];
|
||||
readonly affectsRequest = false;
|
||||
readonly affectsResponse = true;
|
||||
|
||||
readonly newSchema = V1_2_FeaturesSchema;
|
||||
readonly oldSchema = V1_1_FeaturesSchema;
|
||||
|
||||
// Response: V1_2 object → V1_1 array
|
||||
transformResponse({
|
||||
input,
|
||||
}: {
|
||||
input: z.infer<typeof V1_2_FeaturesSchema>;
|
||||
}): z.infer<typeof V1_1_FeaturesSchema> {
|
||||
// Convert object to array
|
||||
return Object.values(input);
|
||||
}
|
||||
}
|
||||
88
shared/api/customers/cusFeatures/previous/apiCusFeatureV0.ts
Normal file
88
shared/api/customers/cusFeatures/previous/apiCusFeatureV0.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { EntInterval } from "@models/productModels/entModels/entEnums.js";
|
||||
import { ProductItemFeatureType } from "@models/productV2Models/productItemModels/productItemModels.js";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
// const res: any = {
|
||||
// feature_id: ent.feature.id,
|
||||
// unlimited: isBoolean ? undefined : unlimited,
|
||||
// interval: isBoolean || unlimited ? null : ent.interval || undefined,
|
||||
// balance: isBoolean ? undefined : unlimited ? null : 0,
|
||||
// total: isBoolean || unlimited ? undefined : 0,
|
||||
// adjustment: isBoolean || unlimited ? undefined : 0,
|
||||
// used: isBoolean ? undefined : unlimited ? null : 0,
|
||||
// unused: 0,
|
||||
// };
|
||||
|
||||
/**
|
||||
* ApiCusFeatureV0Schema - The very first version of the customer feature API model
|
||||
*/
|
||||
export const ApiCusFeatureV0Schema = z.object({
|
||||
feature_id: z.string(),
|
||||
interval: z.enum(EntInterval).nullish(),
|
||||
interval_count: z.number().nullish(),
|
||||
unlimited: z.boolean().nullish(),
|
||||
balance: z.number().nullish(),
|
||||
});
|
||||
|
||||
// OLD CUS FEATURE RESPONSE
|
||||
export const CusEntResponseSchema = z.object({
|
||||
feature_id: z.string(),
|
||||
interval: z.enum(EntInterval).nullish(),
|
||||
interval_count: z.number().nullish(),
|
||||
unlimited: z.boolean().nullish(),
|
||||
balance: z.number().nullish(), //
|
||||
usage: z.number().nullish(),
|
||||
included_usage: z.number().nullish(),
|
||||
next_reset_at: z.number().nullish(),
|
||||
overage_allowed: z.boolean().nullish(),
|
||||
usage_limit: z.number().nullish(),
|
||||
// rollovers: z.array(CusRolloverSchema).nullish(),
|
||||
});
|
||||
|
||||
// NEW CUS FEATURE RESPONSE
|
||||
export const CoreCusFeatureSchema = z.object({
|
||||
interval: z.enum(EntInterval).or(z.literal("multiple")).nullish(),
|
||||
interval_count: z.number().nullish(),
|
||||
unlimited: z.boolean().nullish(),
|
||||
balance: z.number().nullish(),
|
||||
usage: z.number().nullish(),
|
||||
included_usage: z.number().nullish(),
|
||||
next_reset_at: z.number().nullish(),
|
||||
overage_allowed: z.boolean().nullish(),
|
||||
|
||||
breakdown: z
|
||||
.array(
|
||||
z.object({
|
||||
interval: z.enum(EntInterval),
|
||||
interval_count: z.number().nullish(),
|
||||
balance: z.number().nullish(),
|
||||
usage: z.number().nullish(),
|
||||
included_usage: z.number().nullish(),
|
||||
next_reset_at: z.number().nullish(),
|
||||
}),
|
||||
)
|
||||
.nullish(),
|
||||
credit_schema: z
|
||||
.array(
|
||||
z.object({
|
||||
feature_id: z.string(),
|
||||
credit_amount: z.number(),
|
||||
}),
|
||||
)
|
||||
.nullish(),
|
||||
|
||||
usage_limit: z.number().nullish(),
|
||||
rollovers: z.array(CusRolloverSchema).nullish(),
|
||||
});
|
||||
|
||||
export const APICusFeatureSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
type: z.enum(ProductItemFeatureType),
|
||||
name: z.string().nullish(),
|
||||
})
|
||||
.extend(CoreCusFeatureSchema.shape);
|
||||
|
||||
export type CusEntResponse = z.infer<typeof CusEntResponseSchema>;
|
||||
export type CusEntResponseV2 = z.infer<typeof APICusFeatureSchema>;
|
||||
export type CusRollover = z.infer<typeof CusRolloverSchema>;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { APICusProductSchema } from "@api/customers/components/apiCusProduct.js";
|
||||
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
|
||||
import {
|
||||
AffectedResource,
|
||||
VersionChange,
|
||||
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* V0_2: Customer products gained 'items' field
|
||||
*
|
||||
* V0_2+ format: Has items field with product features
|
||||
* V0_1 format: No items field, no current_period_end/start
|
||||
*/
|
||||
|
||||
// V0_2+ product schema (with items)
|
||||
const V0_2_CusProductSchema = APICusProductSchema;
|
||||
|
||||
// V0_1 product schema (without items and period fields)
|
||||
const V0_1_CusProductSchema = APICusProductSchema.omit({
|
||||
items: true,
|
||||
current_period_end: true,
|
||||
current_period_start: true,
|
||||
});
|
||||
|
||||
export class V0_2_ProductItems extends VersionChange<
|
||||
typeof V0_2_CusProductSchema,
|
||||
typeof V0_1_CusProductSchema
|
||||
> {
|
||||
readonly version = ApiVersion.V0_2;
|
||||
readonly description = "Products gained 'items' field";
|
||||
readonly affectedResources = [AffectedResource.CusProduct];
|
||||
readonly affectsRequest = false;
|
||||
readonly affectsResponse = true;
|
||||
|
||||
readonly newSchema = V0_2_CusProductSchema;
|
||||
readonly oldSchema = V0_1_CusProductSchema;
|
||||
|
||||
transformResponse({
|
||||
input,
|
||||
}: {
|
||||
input: z.infer<typeof V0_2_CusProductSchema>;
|
||||
}): z.infer<typeof V0_1_CusProductSchema> {
|
||||
// Remove items and period fields for V0_1
|
||||
// biome-ignore lint/correctness/noUnusedVariables: Using destructuring to omit fields
|
||||
const { items, current_period_end, current_period_start, ...rest } = input;
|
||||
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,9 @@ export * from "./core/coreOpModels.js";
|
||||
// Customers
|
||||
|
||||
export * from "./customers/apiCustomer.js";
|
||||
export * from "./customers/components/apiCusFeature.js";
|
||||
export * from "./customers/components/apiCusProduct.js";
|
||||
export * from "./customers/components/apiCusReferral.js";
|
||||
export * from "./customers/cusFeatures/apiCusFeature.js";
|
||||
export * from "./customers/customerOpModels.js";
|
||||
export * from "./customers/customersOpenApi.js";
|
||||
|
||||
|
||||
18
shared/api/versionUtils/ApiVersion.ts
Normal file
18
shared/api/versionUtils/ApiVersion.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* API Version enum (SemVer format, descending order)
|
||||
* Users send CalVer in x-api-version header (e.g., "2025-04-17")
|
||||
* Internally we use SemVer for comparison (e.g., "1.1.0")
|
||||
*/
|
||||
export enum ApiVersion {
|
||||
V1_4 = "1.4.0",
|
||||
V1_2 = "1.2.0",
|
||||
V1_1 = "1.1.0",
|
||||
V0_2 = "0.2.0",
|
||||
V0_1 = "0.1.0",
|
||||
}
|
||||
|
||||
export type ApiVersionString = `${ApiVersion}`;
|
||||
|
||||
export const API_VERSIONS = Object.values(ApiVersion);
|
||||
|
||||
export const LATEST_VERSION = ApiVersion.V1_2;
|
||||
140
shared/api/versionUtils/ApiVersionClass.ts
Normal file
140
shared/api/versionUtils/ApiVersionClass.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { ApiVersion, API_VERSIONS } from "./ApiVersion.js";
|
||||
import type { VersionMetadata } from "./versionRegistry.js";
|
||||
import { getVersionMetadata, getVersionsSorted } from "./versionRegistryUtils.js";
|
||||
|
||||
/**
|
||||
* ApiVersionClass - Encapsulates version comparison logic
|
||||
*
|
||||
* Provides semantic comparison methods (gt, lt, gte, lte, eq) for API versions.
|
||||
* Only accepts valid versions from the ApiVersion enum.
|
||||
*
|
||||
* @example
|
||||
* const v1 = new ApiVersionClass(ApiVersion.V1_1);
|
||||
* const v2 = new ApiVersionClass(ApiVersion.V1_2);
|
||||
* v2.gt(v1); // true
|
||||
* v1.gte(ApiVersion.V1_1); // true
|
||||
*/
|
||||
export class ApiVersionClass {
|
||||
private readonly version: ApiVersion;
|
||||
private readonly metadata: VersionMetadata;
|
||||
private readonly sortedVersions: ApiVersion[];
|
||||
|
||||
constructor(version: ApiVersion) {
|
||||
if (!API_VERSIONS.includes(version)) {
|
||||
throw new Error(`Invalid API version: ${version}`);
|
||||
}
|
||||
this.version = version;
|
||||
this.metadata = getVersionMetadata({ version });
|
||||
this.sortedVersions = getVersionsSorted();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current version
|
||||
*/
|
||||
get value(): ApiVersion {
|
||||
return this.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version metadata
|
||||
*/
|
||||
get meta(): VersionMetadata {
|
||||
return this.metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CalVer representation (for headers)
|
||||
*/
|
||||
get calver(): string {
|
||||
return this.metadata.calver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SemVer representation
|
||||
*/
|
||||
get semver(): ApiVersion {
|
||||
return this.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the index of this version in the sorted list
|
||||
*/
|
||||
private getIndex(version: ApiVersion): number {
|
||||
return this.sortedVersions.indexOf(version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Greater than
|
||||
*/
|
||||
gt(other: ApiVersion | ApiVersionClass): boolean {
|
||||
const otherVersion = other instanceof ApiVersionClass ? other.value : other;
|
||||
return this.getIndex(this.version) > this.getIndex(otherVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Greater than or equal
|
||||
*/
|
||||
gte(other: ApiVersion | ApiVersionClass): boolean {
|
||||
const otherVersion = other instanceof ApiVersionClass ? other.value : other;
|
||||
return this.getIndex(this.version) >= this.getIndex(otherVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Less than
|
||||
*/
|
||||
lt(other: ApiVersion | ApiVersionClass): boolean {
|
||||
const otherVersion = other instanceof ApiVersionClass ? other.value : other;
|
||||
return this.getIndex(this.version) < this.getIndex(otherVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Less than or equal
|
||||
*/
|
||||
lte(other: ApiVersion | ApiVersionClass): boolean {
|
||||
const otherVersion = other instanceof ApiVersionClass ? other.value : other;
|
||||
return this.getIndex(this.version) <= this.getIndex(otherVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Equal
|
||||
*/
|
||||
eq(other: ApiVersion | ApiVersionClass): boolean {
|
||||
const otherVersion = other instanceof ApiVersionClass ? other.value : other;
|
||||
return this.version === otherVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Not equal
|
||||
*/
|
||||
neq(other: ApiVersion | ApiVersionClass): boolean {
|
||||
return !this.eq(other);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this version is deprecated
|
||||
*/
|
||||
isDeprecated(): boolean {
|
||||
return this.metadata.deprecated === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the version to migrate to (if deprecated)
|
||||
*/
|
||||
getMigrationVersion(): ApiVersion | null {
|
||||
return this.metadata.migrateToVersion || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* String representation
|
||||
*/
|
||||
toString(): string {
|
||||
return this.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON representation
|
||||
*/
|
||||
toJSON(): string {
|
||||
return this.version;
|
||||
}
|
||||
}
|
||||
221
shared/api/versionUtils/README.md
Normal file
221
shared/api/versionUtils/README.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# 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_2_FeaturesArrayToObject.ts
|
||||
├── V1_1_MergedResponse.ts
|
||||
├── V1_1_LegacyExpandInvoices.ts # Side effect
|
||||
└── V0_2_ProductItems.ts
|
||||
```
|
||||
|
||||
### 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{version}_{Description}.ts`
|
||||
- `V1_2_FeaturesArrayToObject.ts`
|
||||
- `V1_1_MergedResponse.ts`
|
||||
|
||||
## Creating Version Changes
|
||||
|
||||
### 1. Create Change Class
|
||||
|
||||
```typescript
|
||||
// shared/api/customers/changes/V1_3_MyChange.ts
|
||||
import { ApiVersion, VersionChange, AffectedResource } from "@autumn/shared";
|
||||
|
||||
export class V1_3_MyChange extends VersionChange {
|
||||
readonly version = ApiVersion.V1_3;
|
||||
readonly description = "Brief description";
|
||||
readonly affectedResources = [AffectedResource.Customer];
|
||||
|
||||
transform({ data }: { data: any }): any {
|
||||
// Transform FROM V1_3 TO V1_2
|
||||
return { ...data, oldField: data.newField };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Register in Registry
|
||||
|
||||
```typescript
|
||||
// versionChangeUtils/versionChangeRegistry.ts
|
||||
export const V1_3_CHANGES = [
|
||||
V1_3_MyChange,
|
||||
V1_3_AnotherChange,
|
||||
];
|
||||
|
||||
export function registerAllVersionChanges() {
|
||||
VersionChangeRegistryClass.register({
|
||||
version: ApiVersion.V1_3,
|
||||
changes: V1_3_CHANGES
|
||||
});
|
||||
// ... other versions
|
||||
}
|
||||
```
|
||||
|
||||
### Side Effect Changes
|
||||
|
||||
```typescript
|
||||
export class V1_3_MySideEffect extends VersionChange {
|
||||
readonly hasSideEffects = true; // Mark as side effect
|
||||
// ... rest
|
||||
}
|
||||
|
||||
// In handler:
|
||||
if (ctx.apiVersion.lt(ApiVersion.V1_3)) {
|
||||
// Handle side effect logic
|
||||
}
|
||||
```
|
||||
|
||||
## 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
|
||||
112
shared/api/versionUtils/convertVersionUtils.ts
Normal file
112
shared/api/versionUtils/convertVersionUtils.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { LegacyVersion } from "../../enums/APIVersion.js";
|
||||
import { ApiVersion } from "./ApiVersion.js";
|
||||
import { VERSION_REGISTRY } from "./versionRegistry.js";
|
||||
import { CALVER_TO_SEMVER_MAP } from "./versionRegistryUtils.js";
|
||||
|
||||
/**
|
||||
* CalVer → SemVer (supports .clover suffix for future non-breaking changes)
|
||||
* @example calVerToSemVer({ calver: "2025-04-17" }) // ApiVersion.V1_1
|
||||
* @example calVerToSemVer({ calver: "2025-04-17.clover" }) // ApiVersion.V1_1
|
||||
*/
|
||||
export function calVerToSemVer({
|
||||
calver,
|
||||
}: {
|
||||
calver: string;
|
||||
}): ApiVersion | null {
|
||||
return CALVER_TO_SEMVER_MAP[calver] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* SemVer → CalVer
|
||||
* @example semVerToCalVer({ semver: ApiVersion.V1_1 }) // "2025-04-17"
|
||||
*/
|
||||
export function semVerToCalVer({ semver }: { semver: ApiVersion }): string {
|
||||
const meta = VERSION_REGISTRY[semver];
|
||||
if (!meta) {
|
||||
throw new Error(`Unknown version: ${semver}`);
|
||||
}
|
||||
return meta.calver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy float → SemVer
|
||||
* @example legacyToSemVer({ legacyVersion: 1.1 }) // ApiVersion.V1_1
|
||||
*/
|
||||
export function legacyToSemVer({
|
||||
legacyVersion,
|
||||
}: {
|
||||
legacyVersion: number;
|
||||
}): ApiVersion | null {
|
||||
switch (legacyVersion) {
|
||||
case LegacyVersion.v1:
|
||||
case 0.2:
|
||||
return ApiVersion.V0_2;
|
||||
case 0.1:
|
||||
return ApiVersion.V0_1;
|
||||
case LegacyVersion.v1_1:
|
||||
case 1.1:
|
||||
return ApiVersion.V1_1;
|
||||
case LegacyVersion.v1_2:
|
||||
case 1.2:
|
||||
return ApiVersion.V1_2;
|
||||
case LegacyVersion.v1_4:
|
||||
case 1.4:
|
||||
return ApiVersion.V1_4;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SemVer → Legacy float
|
||||
* @example semVerToLegacy({ semver: ApiVersion.V1_1 }) // 1.1
|
||||
*/
|
||||
export function semVerToLegacy({
|
||||
semver,
|
||||
}: {
|
||||
semver: ApiVersion;
|
||||
}): number | null {
|
||||
switch (semver) {
|
||||
case ApiVersion.V0_1:
|
||||
return 0.1;
|
||||
case ApiVersion.V0_2:
|
||||
return LegacyVersion.v1;
|
||||
case ApiVersion.V1_1:
|
||||
return LegacyVersion.v1_1;
|
||||
case ApiVersion.V1_2:
|
||||
return LegacyVersion.v1_2;
|
||||
case ApiVersion.V1_4:
|
||||
return LegacyVersion.v1_4;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse version string (CalVer, SemVer, or legacy)
|
||||
* Supports .clover suffix: "2025-04-17.clover"
|
||||
* @example parseVersion({ versionStr: "2025-04-17" }) // ApiVersion.V1_1
|
||||
*/
|
||||
export function parseVersion({
|
||||
versionStr,
|
||||
}: {
|
||||
versionStr: string;
|
||||
}): ApiVersion | null {
|
||||
// CalVer with optional .clover suffix
|
||||
if (/^\d{4}-\d{2}-\d{2}(\.clover)?$/.test(versionStr)) {
|
||||
return calVerToSemVer({ calver: versionStr });
|
||||
}
|
||||
|
||||
// SemVer (X.Y.Z)
|
||||
if (Object.values(ApiVersion).includes(versionStr as ApiVersion)) {
|
||||
return versionStr as ApiVersion;
|
||||
}
|
||||
|
||||
// Legacy float
|
||||
const floatVersion = Number.parseFloat(versionStr);
|
||||
if (!Number.isNaN(floatVersion)) {
|
||||
return legacyToSemVer({ legacyVersion: floatVersion });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
41
shared/api/versionUtils/orgVersionUtils.ts
Normal file
41
shared/api/versionUtils/orgVersionUtils.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Organization } from "@models/orgModels/orgTable.js";
|
||||
import { ApiVersionClass } from "./ApiVersionClass.js";
|
||||
import { legacyToSemVer, semVerToLegacy } from "./convertVersionUtils.js";
|
||||
|
||||
/**
|
||||
* DEPRECATED: Use ctx.apiVersion directly
|
||||
* Middleware handles version resolution automatically
|
||||
*/
|
||||
export function getOrgApiVersion({
|
||||
org,
|
||||
reqApiVersion,
|
||||
}: {
|
||||
org: Organization;
|
||||
reqApiVersion?: ApiVersionClass;
|
||||
}): ApiVersionClass {
|
||||
if (reqApiVersion) {
|
||||
return reqApiVersion;
|
||||
}
|
||||
|
||||
const legacyVersion = org.api_version || org.config?.api_version || 1.0;
|
||||
const semver = legacyToSemVer({ legacyVersion });
|
||||
|
||||
if (!semver) {
|
||||
throw new Error(`Invalid version for org: ${legacyVersion}`);
|
||||
}
|
||||
|
||||
return new ApiVersionClass(semver);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ApiVersionClass → legacy float
|
||||
* @example toLegacyVersion({ apiVersion: ctx.apiVersion }) // 1.1
|
||||
*/
|
||||
export function toLegacyVersion({
|
||||
apiVersion,
|
||||
}: {
|
||||
apiVersion: ApiVersionClass;
|
||||
}): number {
|
||||
const legacy = semVerToLegacy({ semver: apiVersion.value });
|
||||
return legacy || 1.0;
|
||||
}
|
||||
199
shared/api/versionUtils/versionBranchUtils.ts
Normal file
199
shared/api/versionUtils/versionBranchUtils.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Standardized utilities for version-based branching logic
|
||||
*
|
||||
* These utilities provide clean, readable ways to handle version-dependent
|
||||
* logic (side effects) that can't be encapsulated in response transforms.
|
||||
*
|
||||
* Use these instead of raw if/else checks for better maintainability.
|
||||
*/
|
||||
|
||||
import type { ApiVersion } from "./ApiVersion.js";
|
||||
import type { ApiVersionClass } from "./ApiVersionClass.js";
|
||||
|
||||
/**
|
||||
* Execute callback if version meets condition
|
||||
*
|
||||
* @example
|
||||
* ifVersion(apiVersion, {
|
||||
* gte: ApiVersion.V1_1,
|
||||
* callback: () => {
|
||||
* expandArray.push(CusExpand.Invoices);
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
export function ifVersion({
|
||||
version,
|
||||
condition,
|
||||
callback,
|
||||
}: {
|
||||
version: ApiVersionClass;
|
||||
condition:
|
||||
| { gt: ApiVersion }
|
||||
| { gte: ApiVersion }
|
||||
| { lt: ApiVersion }
|
||||
| { lte: ApiVersion }
|
||||
| { eq: ApiVersion };
|
||||
callback: () => void;
|
||||
}): void {
|
||||
let shouldExecute = false;
|
||||
|
||||
if ("gt" in condition) {
|
||||
shouldExecute = version.gt(condition.gt);
|
||||
} else if ("gte" in condition) {
|
||||
shouldExecute = version.gte(condition.gte);
|
||||
} else if ("lt" in condition) {
|
||||
shouldExecute = version.lt(condition.lt);
|
||||
} else if ("lte" in condition) {
|
||||
shouldExecute = version.lte(condition.lte);
|
||||
} else if ("eq" in condition) {
|
||||
shouldExecute = version.eq(condition.eq);
|
||||
}
|
||||
|
||||
if (shouldExecute) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return value based on version
|
||||
*
|
||||
* @example
|
||||
* const schema = versionSwitch(apiVersion, {
|
||||
* [ApiVersion.V1_2]: SchemaV1_2,
|
||||
* [ApiVersion.V1_1]: SchemaV1_1,
|
||||
* default: SchemaV1
|
||||
* });
|
||||
*/
|
||||
export function versionSwitch<T>({
|
||||
version,
|
||||
cases,
|
||||
}: {
|
||||
version: ApiVersionClass;
|
||||
cases: Partial<Record<ApiVersion, T>> & { default: T };
|
||||
}): T {
|
||||
// Try exact match first
|
||||
const exactMatch = cases[version.value];
|
||||
if (exactMatch !== undefined) {
|
||||
return exactMatch;
|
||||
}
|
||||
|
||||
// Walk down from current version to find closest match
|
||||
const sortedCases = Object.keys(cases)
|
||||
.filter((k) => k !== "default")
|
||||
.sort()
|
||||
.reverse() as ApiVersion[];
|
||||
|
||||
for (const caseVersion of sortedCases) {
|
||||
if (version.gte(caseVersion)) {
|
||||
return cases[caseVersion]!;
|
||||
}
|
||||
}
|
||||
|
||||
return cases.default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value from map based on version range
|
||||
*
|
||||
* @example
|
||||
* const withItems = versionRange(apiVersion, {
|
||||
* gte: ApiVersion.V0_2,
|
||||
* value: true,
|
||||
* default: false
|
||||
* });
|
||||
*/
|
||||
export function versionRange<T>({
|
||||
version,
|
||||
range,
|
||||
}: {
|
||||
version: ApiVersionClass;
|
||||
range:
|
||||
| { gte: ApiVersion; value: T; default: T }
|
||||
| { gt: ApiVersion; value: T; default: T }
|
||||
| { lte: ApiVersion; value: T; default: T }
|
||||
| { lt: ApiVersion; value: T; default: T };
|
||||
}): T {
|
||||
if ("gte" in range && version.gte(range.gte)) {
|
||||
return range.value;
|
||||
}
|
||||
if ("gt" in range && version.gt(range.gt)) {
|
||||
return range.value;
|
||||
}
|
||||
if ("lte" in range && version.lte(range.lte)) {
|
||||
return range.value;
|
||||
}
|
||||
if ("lt" in range && version.lt(range.lt)) {
|
||||
return range.value;
|
||||
}
|
||||
return range.default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard clause - require minimum version
|
||||
*
|
||||
* @example
|
||||
* requireVersion(apiVersion, {
|
||||
* min: ApiVersion.V1_1,
|
||||
* error: "This endpoint requires API version 2025-04-17 or later"
|
||||
* });
|
||||
*/
|
||||
export function requireVersion({
|
||||
version,
|
||||
min,
|
||||
error,
|
||||
}: {
|
||||
version: ApiVersionClass;
|
||||
min: ApiVersion;
|
||||
error?: string;
|
||||
}): void {
|
||||
if (version.lt(min)) {
|
||||
throw new Error(
|
||||
error ||
|
||||
`This feature requires API version ${min} or later (current: ${version.value})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one of two callbacks based on version comparison
|
||||
*
|
||||
* @example
|
||||
* const result = versionTernary({
|
||||
* version: apiVersion,
|
||||
* condition: { gte: ApiVersion.V1_1 },
|
||||
* ifTrue: () => getNewFormat(),
|
||||
* ifFalse: () => getLegacyFormat()
|
||||
* });
|
||||
*/
|
||||
export function versionTernary<T>({
|
||||
version,
|
||||
condition,
|
||||
ifTrue,
|
||||
ifFalse,
|
||||
}: {
|
||||
version: ApiVersionClass;
|
||||
condition:
|
||||
| { gt: ApiVersion }
|
||||
| { gte: ApiVersion }
|
||||
| { lt: ApiVersion }
|
||||
| { lte: ApiVersion }
|
||||
| { eq: ApiVersion };
|
||||
ifTrue: () => T;
|
||||
ifFalse: () => T;
|
||||
}): T {
|
||||
let shouldExecuteTrue = false;
|
||||
|
||||
if ("gt" in condition) {
|
||||
shouldExecuteTrue = version.gt(condition.gt);
|
||||
} else if ("gte" in condition) {
|
||||
shouldExecuteTrue = version.gte(condition.gte);
|
||||
} else if ("lt" in condition) {
|
||||
shouldExecuteTrue = version.lt(condition.lt);
|
||||
} else if ("lte" in condition) {
|
||||
shouldExecuteTrue = version.lte(condition.lte);
|
||||
} else if ("eq" in condition) {
|
||||
shouldExecuteTrue = version.eq(condition.eq);
|
||||
}
|
||||
|
||||
return shouldExecuteTrue ? ifTrue() : ifFalse();
|
||||
}
|
||||
154
shared/api/versionUtils/versionChangeUtils/VersionChange.ts
Normal file
154
shared/api/versionUtils/versionChangeUtils/VersionChange.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { ZodType, z } from "zod/v4";
|
||||
import type { ApiVersion } from "../ApiVersion.js";
|
||||
|
||||
/**
|
||||
* Resources that can be affected by version changes
|
||||
*/
|
||||
export enum AffectedResource {
|
||||
Customer = "customer",
|
||||
CusProduct = "cus_product",
|
||||
CusFeature = "cus_feature",
|
||||
CusBalance = "cus_balance",
|
||||
Invoice = "invoice",
|
||||
Product = "product",
|
||||
// Add more as needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base class for bidirectional version changes with Zod schema validation
|
||||
*
|
||||
* Uses Zod schemas for runtime validation and type inference.
|
||||
*
|
||||
* @example
|
||||
* // Features changed from array to object in V1_2
|
||||
* const V1_2_FeaturesSchema = z.record(z.string(), APICusFeatureSchema);
|
||||
* const V1_1_FeaturesSchema = z.array(APICusFeatureSchema);
|
||||
*
|
||||
* class V1_2_FeaturesArrayToObject extends VersionChange {
|
||||
* version = ApiVersion.V1_2;
|
||||
* description = "Features: array ↔ object";
|
||||
* affectedResources = [AffectedResource.CusFeature];
|
||||
*
|
||||
* newSchema = V1_2_FeaturesSchema;
|
||||
* oldSchema = V1_1_FeaturesSchema;
|
||||
*
|
||||
* transformResponse({ input }) {
|
||||
* // input is validated against newSchema
|
||||
* return Object.values(input); // Returns V1_1 format (array)
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export abstract class VersionChange<
|
||||
TNewSchema extends ZodType = ZodType,
|
||||
TOldSchema extends ZodType = ZodType,
|
||||
TDataSchema extends ZodType = ZodType,
|
||||
> {
|
||||
/**
|
||||
* The version this change was introduced in
|
||||
*/
|
||||
abstract readonly version: ApiVersion;
|
||||
|
||||
/**
|
||||
* Human-readable description of the change
|
||||
*/
|
||||
abstract readonly description: string;
|
||||
|
||||
/**
|
||||
* Resources affected by this change
|
||||
*/
|
||||
abstract readonly affectedResources: AffectedResource[];
|
||||
|
||||
/**
|
||||
* Zod schema for the newer version format
|
||||
*/
|
||||
abstract readonly newSchema: TNewSchema;
|
||||
|
||||
/**
|
||||
* Zod schema for the older version format
|
||||
*/
|
||||
abstract readonly oldSchema: TOldSchema;
|
||||
|
||||
/**
|
||||
* Optional Zod schema for additional context data
|
||||
*/
|
||||
readonly dataSchema?: TDataSchema;
|
||||
|
||||
/**
|
||||
* Whether this change affects request transformations
|
||||
* Default: true
|
||||
*/
|
||||
readonly affectsRequest: boolean = true;
|
||||
|
||||
/**
|
||||
* Whether this change affects response transformations
|
||||
* Default: true
|
||||
*/
|
||||
readonly affectsResponse: boolean = true;
|
||||
|
||||
/**
|
||||
* Whether this change has side effects beyond transformation
|
||||
* If true, transforms become no-ops and you must handle logic elsewhere
|
||||
*/
|
||||
readonly hasSideEffects: boolean = false;
|
||||
|
||||
/**
|
||||
* Transform request data forward (old → new format)
|
||||
* Applied when user sends old version, we transform to latest
|
||||
*
|
||||
* @param input - Request data in previous version format (validated against oldSchema)
|
||||
* @param data - Additional context data for transformation (validated against dataSchema if provided)
|
||||
* @returns Data in current version format (should match newSchema)
|
||||
*/
|
||||
transformRequest({
|
||||
input,
|
||||
data: _data,
|
||||
}: {
|
||||
input: z.infer<TOldSchema>;
|
||||
data?: TDataSchema extends ZodType ? z.infer<TDataSchema> : never;
|
||||
}): z.infer<TNewSchema> {
|
||||
// Default: no-op (override if change affects requests)
|
||||
return input as unknown as z.infer<TNewSchema>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform response data backward (new → old format)
|
||||
* Applied when user expects old version, we transform from latest
|
||||
*
|
||||
* @param input - Response data in current version format (validated against newSchema)
|
||||
* @param data - Additional context data for transformation (validated against dataSchema if provided)
|
||||
* @returns Data in previous version format (should match oldSchema)
|
||||
*/
|
||||
transformResponse({
|
||||
input,
|
||||
data: _data,
|
||||
}: {
|
||||
input: z.infer<TNewSchema>;
|
||||
data?: TDataSchema extends ZodType ? z.infer<TDataSchema> : never;
|
||||
}): z.infer<TOldSchema> {
|
||||
// Default: no-op (override if change affects responses)
|
||||
return input as unknown as z.infer<TOldSchema>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this change affects a specific resource
|
||||
*/
|
||||
affects(resource: AffectedResource): boolean {
|
||||
return this.affectedResources.includes(resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of this change class
|
||||
*/
|
||||
get name(): string {
|
||||
return this.constructor.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper type for constructing version changes
|
||||
*/
|
||||
export type VersionChangeConstructor = new () => VersionChange<
|
||||
ZodType,
|
||||
ZodType,
|
||||
ZodType
|
||||
>;
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ApiVersion } from "../ApiVersion.js";
|
||||
import type { VersionChange, VersionChangeConstructor } from "./VersionChange.js";
|
||||
|
||||
/**
|
||||
* Registry for version changes
|
||||
* Maps versions → change classes
|
||||
*/
|
||||
export class VersionChangeRegistryClass {
|
||||
private static changes: Map<ApiVersion, VersionChangeConstructor[]> = new Map();
|
||||
private static instances: Map<string, VersionChange> = new Map();
|
||||
|
||||
static register({
|
||||
version,
|
||||
changes,
|
||||
}: {
|
||||
version: ApiVersion;
|
||||
changes: VersionChangeConstructor[];
|
||||
}) {
|
||||
this.changes.set(version, changes);
|
||||
}
|
||||
|
||||
static getChangesForVersion({ version }: { version: ApiVersion }): VersionChange[] {
|
||||
const changeClasses = this.changes.get(version) || [];
|
||||
return changeClasses.map((ChangeClass) => {
|
||||
const key = `${version}-${ChangeClass.name}`;
|
||||
if (!this.instances.has(key)) {
|
||||
this.instances.set(key, new ChangeClass());
|
||||
}
|
||||
const instance = this.instances.get(key);
|
||||
if (!instance) {
|
||||
throw new Error(`Failed to create instance for ${ChangeClass.name}`);
|
||||
}
|
||||
return instance;
|
||||
});
|
||||
}
|
||||
|
||||
static getRegisteredVersions(): ApiVersion[] {
|
||||
return Array.from(this.changes.keys());
|
||||
}
|
||||
|
||||
static isChangeActive({
|
||||
targetVersion,
|
||||
currentVersion,
|
||||
changeClass,
|
||||
}: {
|
||||
targetVersion: ApiVersion;
|
||||
currentVersion: ApiVersion;
|
||||
changeClass: VersionChangeConstructor;
|
||||
}): boolean {
|
||||
const change = new changeClass();
|
||||
return this.versionLt({ v1: targetVersion, v2: change.version });
|
||||
}
|
||||
|
||||
private static versionLt({ v1, v2 }: { v1: ApiVersion; v2: ApiVersion }): boolean {
|
||||
const versions = Array.from(this.changes.keys()).sort();
|
||||
return versions.indexOf(v1) < versions.indexOf(v2);
|
||||
}
|
||||
|
||||
static clear() {
|
||||
this.changes.clear();
|
||||
this.instances.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import type { ApiVersion } from "../ApiVersion.js";
|
||||
import type { ApiVersionClass } from "../ApiVersionClass.js";
|
||||
import { getVersionsBetween } from "../versionRegistryUtils.js";
|
||||
import type { AffectedResource } from "./VersionChange.js";
|
||||
import { VersionChangeRegistryClass } from "./VersionChangeRegistryClass.js";
|
||||
|
||||
/**
|
||||
* Apply response transformations (backward: new → old)
|
||||
*
|
||||
* Walks backwards from currentVersion to targetVersion,
|
||||
* applying each version change's transformResponse() function.
|
||||
*
|
||||
* @param input - Data in the newest version format
|
||||
* @param data - Additional context data for transformations (optional)
|
||||
* @param currentVersion - Version of the input data
|
||||
* @param targetVersion - Version to transform to (older)
|
||||
* @param resource - Resource being transformed
|
||||
*
|
||||
* @example
|
||||
* // Data is in V1_2 format, transform to V1_1
|
||||
* const v1_1_data = applyResponseVersionChanges({
|
||||
* input: v1_2_customer,
|
||||
* currentVersion: new ApiVersionClass(ApiVersion.V1_2),
|
||||
* targetVersion: new ApiVersionClass(ApiVersion.V1_1),
|
||||
* resource: AffectedResource.Customer
|
||||
* });
|
||||
*/
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Generic type parameter needs flexibility
|
||||
export function applyResponseVersionChanges<T = any, TData = any>({
|
||||
input,
|
||||
data,
|
||||
currentVersion,
|
||||
targetVersion,
|
||||
resource,
|
||||
}: {
|
||||
input: T;
|
||||
data?: TData;
|
||||
currentVersion: ApiVersionClass;
|
||||
targetVersion: ApiVersionClass;
|
||||
resource: AffectedResource;
|
||||
}): T {
|
||||
// If versions are equal, no transformation needed
|
||||
if (currentVersion.eq(targetVersion)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
// If target is newer than current, throw error (can't transform forward)
|
||||
if (targetVersion.gt(currentVersion)) {
|
||||
throw new Error(
|
||||
`Cannot transform forward from ${currentVersion} to ${targetVersion}. ` +
|
||||
"Transforms only work backwards to older versions.",
|
||||
);
|
||||
}
|
||||
|
||||
// Get all versions between current and target (exclusive of target, inclusive of current)
|
||||
const versionsToApply = getVersionsBetween({
|
||||
from: targetVersion.value,
|
||||
to: currentVersion.value,
|
||||
}).filter((v) => v !== targetVersion.value); // Exclude target itself
|
||||
|
||||
// Sort versions from newest to oldest (we apply backwards)
|
||||
versionsToApply.reverse();
|
||||
|
||||
// Apply each version's changes
|
||||
let transformedData = input;
|
||||
for (const version of versionsToApply) {
|
||||
const changes = VersionChangeRegistryClass.getChangesForVersion({
|
||||
version,
|
||||
});
|
||||
|
||||
for (const change of changes) {
|
||||
// Skip if this change doesn't affect our resource
|
||||
if (!change.affects(resource)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if doesn't affect responses
|
||||
if (!change.affectsResponse) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if this change has side effects (must be handled elsewhere)
|
||||
if (change.hasSideEffects) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply the response transformation (backward)
|
||||
transformedData = change.transformResponse({
|
||||
input: transformedData,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Runtime type flexibility needed for version changes
|
||||
data: data as any,
|
||||
}) as T;
|
||||
}
|
||||
}
|
||||
|
||||
return transformedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply request transformations (forward: old → new)
|
||||
*
|
||||
* Walks FORWARD from targetVersion to currentVersion,
|
||||
* applying each version change's transformRequest() function.
|
||||
*
|
||||
* This is used to transform old request formats to the latest version.
|
||||
*
|
||||
* @param input - Data in the older version format
|
||||
* @param data - Additional context data for transformations (optional)
|
||||
* @param targetVersion - User's version (older)
|
||||
* @param currentVersion - Latest version (newer)
|
||||
* @param resource - Resource being transformed
|
||||
*
|
||||
* @example
|
||||
* // Data is in V1_1 format, transform to V1_2
|
||||
* const v1_2_data = applyRequestVersionChanges({
|
||||
* input: v1_1_request,
|
||||
* targetVersion: new ApiVersionClass(ApiVersion.V1_1), // User's version
|
||||
* currentVersion: new ApiVersionClass(ApiVersion.V1_2), // Latest
|
||||
* resource: AffectedResource.Product
|
||||
* });
|
||||
*/
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Generic type parameter needs flexibility
|
||||
export function applyRequestVersionChanges<T = any, TData = any>({
|
||||
input,
|
||||
data,
|
||||
targetVersion,
|
||||
currentVersion,
|
||||
resource,
|
||||
}: {
|
||||
input: T;
|
||||
data?: TData;
|
||||
targetVersion: ApiVersionClass; // User's version (old)
|
||||
currentVersion: ApiVersionClass; // Latest version (new)
|
||||
resource: AffectedResource;
|
||||
}): T {
|
||||
// If versions are equal, no transformation needed
|
||||
if (targetVersion.eq(currentVersion)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
// If target is newer than current, throw error
|
||||
if (targetVersion.gt(currentVersion)) {
|
||||
throw new Error(
|
||||
`Cannot transform forward from ${currentVersion} to ${targetVersion}. ` +
|
||||
"Current version should be >= target version.",
|
||||
);
|
||||
}
|
||||
|
||||
// Get all versions between target and current (exclusive of current, inclusive of target)
|
||||
const versionsToApply = getVersionsBetween({
|
||||
from: targetVersion.value,
|
||||
to: currentVersion.value,
|
||||
}).filter((v) => v !== currentVersion.value); // Exclude current itself
|
||||
|
||||
// Don't reverse - we want to go forward (old → new)
|
||||
// versionsToApply is already in ascending order from getVersionsBetween
|
||||
|
||||
// Apply each version's changes
|
||||
let transformedData = input;
|
||||
for (const version of versionsToApply) {
|
||||
const changes = VersionChangeRegistryClass.getChangesForVersion({
|
||||
version,
|
||||
});
|
||||
|
||||
for (const change of changes) {
|
||||
// Skip if this change doesn't affect our resource
|
||||
if (!change.affects(resource)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if doesn't affect requests
|
||||
if (!change.affectsRequest) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if this change has side effects (must be handled elsewhere)
|
||||
if (change.hasSideEffects) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply the request transformation (forward)
|
||||
transformedData = change.transformRequest({
|
||||
input: transformedData,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Runtime type flexibility needed for version changes
|
||||
data: data as any,
|
||||
}) as T;
|
||||
}
|
||||
}
|
||||
|
||||
return transformedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific change is active for the given target version
|
||||
*
|
||||
* Used for side-effect changes that can't be encapsulated in transforms.
|
||||
* A change is "active" if the target version is older than the version
|
||||
* where the change was introduced.
|
||||
*
|
||||
* @example
|
||||
* // In your code:
|
||||
* if (isChangeActive(targetVersion, LegacyExpandInvoicesChange)) {
|
||||
* // Add expand=invoices to the query
|
||||
* expandArray.push(CusExpand.Invoices);
|
||||
* }
|
||||
*/
|
||||
export function isChangeActive(
|
||||
targetVersion: ApiVersionClass,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Generic version change class constructor
|
||||
changeClass: new () => any,
|
||||
): boolean {
|
||||
const change = new changeClass();
|
||||
return targetVersion.lt(change.version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to apply response changes to an array of objects
|
||||
*/
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Generic type parameter needs flexibility
|
||||
export function applyResponseVersionChangesToArray<T = any, TData = any>({
|
||||
inputArray,
|
||||
data,
|
||||
currentVersion,
|
||||
targetVersion,
|
||||
resource,
|
||||
}: {
|
||||
inputArray: T[];
|
||||
data?: TData;
|
||||
currentVersion: ApiVersionClass;
|
||||
targetVersion: ApiVersionClass;
|
||||
resource: AffectedResource;
|
||||
}): T[] {
|
||||
return inputArray.map((item) =>
|
||||
applyResponseVersionChanges({
|
||||
input: item,
|
||||
data,
|
||||
currentVersion,
|
||||
targetVersion,
|
||||
resource,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to apply request changes to an array of objects
|
||||
*/
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Generic type parameter needs flexibility
|
||||
export function applyRequestVersionChangesToArray<T = any, TData = any>({
|
||||
inputArray,
|
||||
data,
|
||||
targetVersion,
|
||||
currentVersion,
|
||||
resource,
|
||||
}: {
|
||||
inputArray: T[];
|
||||
data?: TData;
|
||||
targetVersion: ApiVersionClass;
|
||||
currentVersion: ApiVersionClass;
|
||||
resource: AffectedResource;
|
||||
}): T[] {
|
||||
return inputArray.map((item) =>
|
||||
applyRequestVersionChanges({
|
||||
input: item,
|
||||
data,
|
||||
targetVersion,
|
||||
currentVersion,
|
||||
resource,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all version changes that affect a specific resource between two versions
|
||||
*/
|
||||
export function getChangesForResource({
|
||||
currentVersion,
|
||||
targetVersion,
|
||||
resource,
|
||||
}: {
|
||||
currentVersion: ApiVersion;
|
||||
targetVersion: ApiVersion;
|
||||
resource: AffectedResource;
|
||||
}) {
|
||||
const versionsToApply = getVersionsBetween({
|
||||
from: targetVersion,
|
||||
to: currentVersion,
|
||||
}).filter((v) => v !== targetVersion);
|
||||
|
||||
versionsToApply.reverse();
|
||||
|
||||
const allChanges = [];
|
||||
for (const version of versionsToApply) {
|
||||
const changes = VersionChangeRegistryClass.getChangesForVersion({
|
||||
version,
|
||||
});
|
||||
for (const change of changes) {
|
||||
if (change.affects(resource)) {
|
||||
allChanges.push(change);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allChanges;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ApiVersion } from "../ApiVersion.js";
|
||||
import { VersionChangeRegistryClass } from "./VersionChangeRegistryClass.js";
|
||||
import type { VersionChangeConstructor } from "./VersionChange.js";
|
||||
|
||||
// Import customer changes
|
||||
import { V1_1_MergedResponse } from "@api/customers/changes/V1_1_MergedResponse.js";
|
||||
import { V1_1_LegacyExpandInvoices } from "@api/customers/changes/V1_1_LegacyExpandInvoices.js";
|
||||
|
||||
// Import customer feature changes
|
||||
import { V1_2_FeaturesArrayToObject } from "@api/customers/cusFeatures/changes/V1_2_FeaturesArrayToObject.js";
|
||||
|
||||
// Import customer product changes
|
||||
import { V0_2_ProductItems } from "@api/customers/cusProducts/changes/V0_2_ProductItems.js";
|
||||
|
||||
/**
|
||||
* V1_4 (2025-06-01) - Beta Features
|
||||
*
|
||||
* Breaking changes:
|
||||
* - TBD (beta version)
|
||||
*/
|
||||
export const V1_4_CHANGES: VersionChangeConstructor[] = [
|
||||
// Add beta changes here when needed
|
||||
];
|
||||
|
||||
/**
|
||||
* V1_2 (2025-05-05) - Features Redesign
|
||||
*
|
||||
* Breaking changes:
|
||||
* - customer.features: array → object (keyed by feature_id)
|
||||
*/
|
||||
export const V1_2_CHANGES: VersionChangeConstructor[] = [
|
||||
V1_2_FeaturesArrayToObject,
|
||||
];
|
||||
|
||||
/**
|
||||
* V1_1 (2025-04-17) - Unified Customer Response
|
||||
*
|
||||
* Breaking changes:
|
||||
* - Merged split customer response into single object
|
||||
* - Renamed entitlements → features
|
||||
* - invoices now require explicit expand parameter (side effect)
|
||||
*/
|
||||
export const V1_1_CHANGES: VersionChangeConstructor[] = [
|
||||
V1_1_MergedResponse,
|
||||
V1_1_LegacyExpandInvoices, // Side effect
|
||||
];
|
||||
|
||||
/**
|
||||
* V0_2 (2025-04-01) - Product Items
|
||||
*
|
||||
* Breaking changes:
|
||||
* - Customer products gained 'items' field
|
||||
* - Enhanced product structure
|
||||
*/
|
||||
export const V0_2_CHANGES: VersionChangeConstructor[] = [V0_2_ProductItems];
|
||||
|
||||
/**
|
||||
* V0_1 (2025-02-01) - Original
|
||||
*
|
||||
* No changes (original version)
|
||||
*/
|
||||
export const V0_1_CHANGES: VersionChangeConstructor[] = [];
|
||||
|
||||
/**
|
||||
* Register all version changes (newest first)
|
||||
*
|
||||
* Auto-runs on import
|
||||
*/
|
||||
export function registerAllVersionChanges() {
|
||||
VersionChangeRegistryClass.register({
|
||||
version: ApiVersion.V1_4,
|
||||
changes: V1_4_CHANGES,
|
||||
});
|
||||
VersionChangeRegistryClass.register({
|
||||
version: ApiVersion.V1_2,
|
||||
changes: V1_2_CHANGES,
|
||||
});
|
||||
VersionChangeRegistryClass.register({
|
||||
version: ApiVersion.V1_1,
|
||||
changes: V1_1_CHANGES,
|
||||
});
|
||||
VersionChangeRegistryClass.register({
|
||||
version: ApiVersion.V0_2,
|
||||
changes: V0_2_CHANGES,
|
||||
});
|
||||
VersionChangeRegistryClass.register({
|
||||
version: ApiVersion.V0_1,
|
||||
changes: V0_1_CHANGES,
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-register on import
|
||||
registerAllVersionChanges();
|
||||
47
shared/api/versionUtils/versionRegistry.ts
Normal file
47
shared/api/versionUtils/versionRegistry.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ApiVersion } from "./ApiVersion.js";
|
||||
|
||||
export interface VersionMetadata {
|
||||
semver: ApiVersion;
|
||||
calver: string;
|
||||
releasedAt: number;
|
||||
description: string;
|
||||
deprecated?: boolean;
|
||||
migrateToVersion?: ApiVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Version Registry (descending order - newest first)
|
||||
* SemVer ↔ CalVer mappings and metadata
|
||||
*/
|
||||
export const VERSION_REGISTRY: Record<ApiVersion, VersionMetadata> = {
|
||||
[ApiVersion.V1_4]: {
|
||||
semver: ApiVersion.V1_4,
|
||||
calver: "2025-06-01",
|
||||
releasedAt: new Date("2025-06-01").getTime(),
|
||||
description: "Beta version with experimental features",
|
||||
},
|
||||
[ApiVersion.V1_2]: {
|
||||
semver: ApiVersion.V1_2,
|
||||
calver: "2025-05-05",
|
||||
releasedAt: new Date("2025-05-05").getTime(),
|
||||
description: "Features as object (keyed by feature_id)",
|
||||
},
|
||||
[ApiVersion.V1_1]: {
|
||||
semver: ApiVersion.V1_1,
|
||||
calver: "2025-04-17",
|
||||
releasedAt: new Date("2025-04-17").getTime(),
|
||||
description: "Merged customer response, features as array",
|
||||
},
|
||||
[ApiVersion.V0_2]: {
|
||||
semver: ApiVersion.V0_2,
|
||||
calver: "2025-04-01",
|
||||
releasedAt: new Date("2025-04-01").getTime(),
|
||||
description: "Customer products with items field",
|
||||
},
|
||||
[ApiVersion.V0_1]: {
|
||||
semver: ApiVersion.V0_1,
|
||||
calver: "2025-02-01",
|
||||
releasedAt: new Date("2025-02-01").getTime(),
|
||||
description: "Original customer schema",
|
||||
},
|
||||
};
|
||||
53
shared/api/versionUtils/versionRegistryUtils.ts
Normal file
53
shared/api/versionUtils/versionRegistryUtils.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { API_VERSIONS, ApiVersion } from "./ApiVersion.js";
|
||||
import { VERSION_REGISTRY, type VersionMetadata } from "./versionRegistry.js";
|
||||
|
||||
/**
|
||||
* CalVer → SemVer lookup map
|
||||
*/
|
||||
export const CALVER_TO_SEMVER_MAP: Record<string, ApiVersion> = Object.values(
|
||||
VERSION_REGISTRY,
|
||||
).reduce(
|
||||
(acc, meta) => {
|
||||
acc[meta.calver] = meta.semver;
|
||||
// Support .clover suffix for future non-breaking changes
|
||||
acc[`${meta.calver}.clover`] = meta.semver;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, ApiVersion>,
|
||||
);
|
||||
|
||||
export function getVersionMetadata({
|
||||
version,
|
||||
}: {
|
||||
version: ApiVersion;
|
||||
}): VersionMetadata {
|
||||
return VERSION_REGISTRY[version];
|
||||
}
|
||||
|
||||
export function isValidVersion(params: { version: string }): params is { version: ApiVersion } {
|
||||
return API_VERSIONS.includes(params.version as ApiVersion);
|
||||
}
|
||||
|
||||
export function getVersionsSorted(): ApiVersion[] {
|
||||
return Object.values(VERSION_REGISTRY)
|
||||
.sort((a, b) => a.releasedAt - b.releasedAt)
|
||||
.map((meta) => meta.semver);
|
||||
}
|
||||
|
||||
export function getVersionsBetween({
|
||||
from,
|
||||
to,
|
||||
}: {
|
||||
from: ApiVersion;
|
||||
to: ApiVersion;
|
||||
}): ApiVersion[] {
|
||||
const sorted = getVersionsSorted();
|
||||
const fromIndex = sorted.indexOf(from);
|
||||
const toIndex = sorted.indexOf(to);
|
||||
|
||||
if (fromIndex === -1 || toIndex === -1) {
|
||||
throw new Error(`Invalid version range: ${from} to ${to}`);
|
||||
}
|
||||
|
||||
return sorted.slice(fromIndex, toIndex + 1);
|
||||
}
|
||||
64
shared/api/versionUtils/versionUtils.ts
Normal file
64
shared/api/versionUtils/versionUtils.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Main exports for the unified API versioning system
|
||||
*/
|
||||
|
||||
// Version types and core
|
||||
export type { ApiVersionString } from "./ApiVersion.js";
|
||||
export { API_VERSIONS, ApiVersion, LATEST_VERSION } from "./ApiVersion.js";
|
||||
export { ApiVersionClass } from "./ApiVersionClass.js";
|
||||
|
||||
// Version registry
|
||||
export type { VersionMetadata } from "./versionRegistry.js";
|
||||
export { VERSION_REGISTRY } from "./versionRegistry.js";
|
||||
|
||||
// Version registry utilities
|
||||
export {
|
||||
CALVER_TO_SEMVER_MAP,
|
||||
getVersionMetadata,
|
||||
getVersionsBetween,
|
||||
getVersionsSorted,
|
||||
isValidVersion,
|
||||
} from "./versionRegistryUtils.js";
|
||||
|
||||
// Conversion utilities
|
||||
export {
|
||||
calVerToSemVer,
|
||||
legacyToSemVer,
|
||||
parseVersion,
|
||||
semVerToCalVer,
|
||||
semVerToLegacy,
|
||||
} from "./convertVersionUtils.js";
|
||||
|
||||
// Org-specific utilities (deprecated)
|
||||
export { getOrgApiVersion, toLegacyVersion } from "./orgVersionUtils.js";
|
||||
|
||||
// Branching utilities
|
||||
export {
|
||||
ifVersion,
|
||||
requireVersion,
|
||||
versionRange,
|
||||
versionSwitch,
|
||||
versionTernary,
|
||||
} from "./versionBranchUtils.js";
|
||||
|
||||
// Version changes
|
||||
export {
|
||||
AffectedResource,
|
||||
VersionChange,
|
||||
type VersionChangeConstructor,
|
||||
} from "./versionChangeUtils/VersionChange.js";
|
||||
export { VersionChangeRegistryClass } from "./versionChangeUtils/VersionChangeRegistryClass.js";
|
||||
export {
|
||||
applyResponseVersionChanges,
|
||||
applyResponseVersionChangesToArray,
|
||||
applyRequestVersionChanges,
|
||||
applyRequestVersionChangesToArray,
|
||||
getChangesForResource,
|
||||
isChangeActive,
|
||||
// Deprecated aliases (for backward compatibility)
|
||||
applyResponseVersionChanges as applyVersionChanges,
|
||||
applyResponseVersionChangesToArray as applyVersionChangesToArray,
|
||||
} from "./versionChangeUtils/applyVersionChanges.js";
|
||||
|
||||
// Auto-register all version changes
|
||||
import "./versionChangeUtils/versionChangeRegistry.js";
|
||||
@@ -6,6 +6,9 @@ export { schemas };
|
||||
export * from "./api/models.js";
|
||||
export * from "./api/operations.js";
|
||||
|
||||
// API VERSIONING SYSTEM
|
||||
export * from "./api/versionUtils/versionUtils.js";
|
||||
|
||||
// Auth Models
|
||||
export * from "./db/auth-schema.js";
|
||||
export * from "./enums/APIVersion.js";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { APICusFeatureSchema } from "@api/customers/components/apiCusFeature.js";
|
||||
import { APICusProductSchema } from "@api/customers/components/apiCusProduct.js";
|
||||
import { APICusFeatureSchema } from "@api/customers/cusFeatures/apiCusFeature.js";
|
||||
import { APIInvoiceSchema } from "@api/others/apiInvoice.js";
|
||||
import { z } from "zod/v4";
|
||||
import { AppEnv } from "../../genModels/genEnums.js";
|
||||
|
||||
Reference in New Issue
Block a user