324 lines
9.4 KiB
TypeScript
324 lines
9.4 KiB
TypeScript
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,
|
|
TLegacyDataSchema extends ZodType = ZodType,
|
|
> {
|
|
/**
|
|
* The newer version where the breaking change was introduced
|
|
*/
|
|
abstract readonly newVersion: ApiVersion;
|
|
|
|
/**
|
|
* The older version - transform is applied when targetVersion <= oldVersion
|
|
*/
|
|
abstract readonly oldVersion: ApiVersion;
|
|
|
|
/**
|
|
* Human-readable description of the change
|
|
* Can be a single string or an array of strings for multiple changes
|
|
*/
|
|
abstract readonly description: string | 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 legacy fields data (deprecated fields from newer versions)
|
|
*/
|
|
readonly legacyDataSchema?: TLegacyDataSchema;
|
|
|
|
/**
|
|
* Whether this change affects request transformations
|
|
* @default false
|
|
*/
|
|
readonly affectsRequest: boolean = false;
|
|
|
|
/**
|
|
* 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
|
|
* @default false
|
|
*/
|
|
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 legacyData - Legacy fields data for transformation (validated against legacyDataSchema if provided)
|
|
* @returns Data in current version format (should match newSchema)
|
|
*/
|
|
transformRequest({
|
|
input,
|
|
legacyData: _legacyData,
|
|
}: {
|
|
input: z.infer<TOldSchema>;
|
|
legacyData?: TLegacyDataSchema extends ZodType
|
|
? z.infer<TLegacyDataSchema>
|
|
: 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 legacyData - Legacy fields data for transformation (validated against legacyDataSchema if provided)
|
|
* @returns Data in previous version format (should match oldSchema)
|
|
*/
|
|
transformResponse({
|
|
input,
|
|
legacyData: _legacyData,
|
|
}: {
|
|
input: z.infer<TNewSchema>;
|
|
legacyData?: TLegacyDataSchema extends ZodType
|
|
? z.infer<TLegacyDataSchema>
|
|
: 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
|
|
>;
|
|
|
|
/**
|
|
* Configuration interface for version changes
|
|
* Use this with defineVersionChange() for full autocomplete and type safety
|
|
*/
|
|
export interface VersionChangeConfig<
|
|
TNewSchema extends ZodType = ZodType,
|
|
TOldSchema extends ZodType = ZodType,
|
|
TLegacyDataSchema extends ZodType = ZodType,
|
|
> {
|
|
/** The newer version where the breaking change was introduced */
|
|
newVersion: ApiVersion;
|
|
|
|
/** The older version - transform is applied when targetVersion <= oldVersion */
|
|
oldVersion: ApiVersion;
|
|
|
|
/** Human-readable description of the change (single string or array of strings) */
|
|
description: string | string[];
|
|
|
|
/** Resources affected by this change */
|
|
affectedResources: AffectedResource[];
|
|
|
|
/** Zod schema for the newer version format */
|
|
newSchema: TNewSchema;
|
|
|
|
/** Zod schema for the older version format */
|
|
oldSchema: TOldSchema;
|
|
|
|
/** Optional Zod schema for legacy fields data (deprecated fields from newer versions) */
|
|
legacyDataSchema?: TLegacyDataSchema;
|
|
|
|
/**
|
|
* Whether this change affects request transformations
|
|
* @default false
|
|
*/
|
|
affectsRequest?: boolean;
|
|
|
|
/**
|
|
* Whether this change affects response transformations
|
|
* @default true
|
|
*/
|
|
affectsResponse?: boolean;
|
|
|
|
/**
|
|
* Whether this change has side effects beyond transformation
|
|
* If true, transforms become no-ops and you must handle logic elsewhere
|
|
* @default false
|
|
*/
|
|
hasSideEffects?: boolean;
|
|
|
|
/**
|
|
* Transform request data forward (old → new format)
|
|
* Applied when user sends old version, we transform to latest
|
|
*/
|
|
transformRequest?: (params: {
|
|
input: z.infer<TOldSchema>;
|
|
legacyData?: TLegacyDataSchema extends ZodType
|
|
? z.infer<TLegacyDataSchema>
|
|
: never;
|
|
}) => z.infer<TNewSchema>;
|
|
|
|
/**
|
|
* Transform response data backward (new → old format)
|
|
* Applied when user expects old version, we transform from latest
|
|
*
|
|
* Note: Output is validated with safeParse() - validation failures return
|
|
* unvalidated data to ensure graceful degradation. Successful validation
|
|
* strips extra fields. For compile-time excess property errors, use schema.strict().
|
|
*/
|
|
transformResponse?: (params: {
|
|
input: z.infer<TNewSchema>;
|
|
legacyData?: TLegacyDataSchema extends ZodType
|
|
? z.infer<TLegacyDataSchema>
|
|
: never;
|
|
}) => z.infer<TOldSchema>;
|
|
}
|
|
|
|
/**
|
|
* Helper to define version changes with full autocomplete and type safety
|
|
*
|
|
* @example
|
|
* export const V1_2_FeaturesArrayToObject = defineVersionChange({
|
|
* version: ApiVersion.V1_2,
|
|
* description: "Features: object → array",
|
|
* affectedResources: [AffectedResource.Customer],
|
|
* newSchema: V1_2_FeaturesSchema,
|
|
* oldSchema: V1_1_FeaturesSchema,
|
|
* affectsRequest: false,
|
|
* affectsResponse: true,
|
|
* hasSideEffects: true, // ← Full autocomplete for all options!
|
|
* transformResponse: ({ input }) => Object.values(input),
|
|
* });
|
|
*/
|
|
export function defineVersionChange<
|
|
TNewSchema extends ZodType,
|
|
TOldSchema extends ZodType,
|
|
TLegacyDataSchema extends ZodType = ZodType,
|
|
>(
|
|
config: VersionChangeConfig<TNewSchema, TOldSchema, TLegacyDataSchema>,
|
|
): VersionChangeConstructor {
|
|
return class extends VersionChange<
|
|
TNewSchema,
|
|
TOldSchema,
|
|
TLegacyDataSchema
|
|
> {
|
|
readonly newVersion = config.newVersion;
|
|
readonly oldVersion = config.oldVersion;
|
|
readonly description = config.description;
|
|
readonly affectedResources = config.affectedResources;
|
|
readonly newSchema = config.newSchema;
|
|
readonly oldSchema = config.oldSchema;
|
|
readonly legacyDataSchema = config.legacyDataSchema;
|
|
readonly affectsRequest = config.affectsRequest ?? false;
|
|
readonly affectsResponse = config.affectsResponse ?? true;
|
|
readonly hasSideEffects = config.hasSideEffects ?? false;
|
|
|
|
transformRequest(params: {
|
|
input: z.infer<TOldSchema>;
|
|
legacyData?: TLegacyDataSchema extends ZodType
|
|
? z.infer<TLegacyDataSchema>
|
|
: never;
|
|
}): z.infer<TNewSchema> {
|
|
if (config.transformRequest) {
|
|
const result = config.transformRequest(params);
|
|
// Validate with safeParse - gracefully handles failures without throwing
|
|
const parsed = this.newSchema.safeParse(result);
|
|
if (!parsed.success) {
|
|
// Return unvalidated result to avoid breaking the request
|
|
return result as z.infer<TNewSchema>;
|
|
}
|
|
return parsed.data;
|
|
}
|
|
return super.transformRequest(params);
|
|
}
|
|
|
|
transformResponse(params: {
|
|
input: z.infer<TNewSchema>;
|
|
legacyData?: TLegacyDataSchema extends ZodType
|
|
? z.infer<TLegacyDataSchema>
|
|
: never;
|
|
}): z.infer<TOldSchema> {
|
|
if (config.transformResponse) {
|
|
const result = config.transformResponse(
|
|
params,
|
|
) satisfies z.infer<TOldSchema>;
|
|
// Validate with safeParse - gracefully handles failures without throwing
|
|
// Note: TypeScript allows excess properties in spreads. Use .strict() for compile-time errors.
|
|
const parsed = this.oldSchema.safeParse(result);
|
|
if (!parsed.success) {
|
|
// Return unvalidated result to avoid breaking the request
|
|
return result as z.infer<TOldSchema>;
|
|
}
|
|
return parsed.data;
|
|
}
|
|
return super.transformResponse(params);
|
|
}
|
|
};
|
|
}
|