From e90bdfb719eac2f1165d9c66354efe49ddd89b84 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 10 May 2024 00:14:42 +0200 Subject: [PATCH 01/24] Feature #1470 draft. --- src/documentation-helpers.ts | 10 ++- src/documentation.ts | 5 ++ src/schema-walker.ts | 4 +- .../__snapshots__/documentation.spec.ts.snap | 85 +++++++++++++++++++ tests/unit/documentation.spec.ts | 32 +++++++ 5 files changed, 132 insertions(+), 4 deletions(-) diff --git a/src/documentation-helpers.ts b/src/documentation-helpers.ts index d939948e0..57c406c63 100644 --- a/src/documentation-helpers.ts +++ b/src/documentation-helpers.ts @@ -102,6 +102,7 @@ interface ReqResDepictHelperCommonProps mimeTypes: ReadonlyArray; composition: "inline" | "components"; description?: string; + customBrands?: HandlingRules; } const shortDescriptionLimit = 50; @@ -700,6 +701,7 @@ export const depictRequestParams = ({ getRef, makeRef, composition, + customBrands, description = `${method.toUpperCase()} ${path} Parameter`, }: Omit & { inputSources: InputSource[]; @@ -727,7 +729,7 @@ export const depictRequestParams = ({ const depicted = walkSchema({ schema: shape[name], isResponse: false, - rules: depicters, + rules: { ...depicters, ...customBrands }, onEach, onMissing, serializer, @@ -884,6 +886,7 @@ export const depictResponse = ({ composition, hasMultipleStatusCodes, statusCode, + customBrands, description = `${method.toUpperCase()} ${path} ${ucFirst(variant)} response ${ hasMultipleStatusCodes ? statusCode : "" }`.trim(), @@ -896,7 +899,7 @@ export const depictResponse = ({ walkSchema({ schema, isResponse: true, - rules: depicters, + rules: { ...depicters, ...customBrands }, onEach, onMissing, serializer, @@ -1028,6 +1031,7 @@ export const depictRequest = ({ getRef, makeRef, composition, + customBrands, description = `${method.toUpperCase()} ${path} Request body`, }: ReqResDepictHelperCommonProps): RequestBodyObject => { const pathParams = getRoutePathParams(path); @@ -1036,7 +1040,7 @@ export const depictRequest = ({ walkSchema({ schema, isResponse: false, - rules: depicters, + rules: { ...depicters, ...customBrands }, onEach, onMissing, serializer, diff --git a/src/documentation.ts b/src/documentation.ts index 29d343a47..24641b25f 100644 --- a/src/documentation.ts +++ b/src/documentation.ts @@ -18,6 +18,7 @@ import { CommonConfig } from "./config-type"; import { mapLogicalContainer } from "./logical-container"; import { Method } from "./method"; import { + OpenAPIContext, depictRequest, depictRequestParams, depictResponse, @@ -29,6 +30,7 @@ import { } from "./documentation-helpers"; import { Routing } from "./routing"; import { RoutingWalkerParams, walkRouting } from "./routing-walker"; +import { HandlingRules } from "./schema-walker"; type Component = | "positiveResponse" @@ -64,6 +66,7 @@ interface DocumentationParams { * @default JSON.stringify() + SHA1 hash as a hex digest * */ serializer?: (schema: z.ZodTypeAny) => string; + customBrands?: HandlingRules; } export class Documentation extends OpenApiBuilder { @@ -132,6 +135,7 @@ export class Documentation extends OpenApiBuilder { version, serverUrl, descriptions, + customBrands, hasSummaryFromDescription = true, composition = "inline", serializer = defaultSerializer, @@ -153,6 +157,7 @@ export class Documentation extends OpenApiBuilder { endpoint, composition, serializer, + customBrands, getRef: this.getRef.bind(this), makeRef: this.makeRef.bind(this), }; diff --git a/src/schema-walker.ts b/src/schema-walker.ts index 3156eb7e7..5e7929374 100644 --- a/src/schema-walker.ts +++ b/src/schema-walker.ts @@ -28,9 +28,11 @@ export type SchemaHandler< Variant extends HandlingVariant = "regular", > = (params: SchemaHandlingProps) => U; +export type CustomBrand = string | symbol; + export type HandlingRules = Partial< Record< - z.ZodFirstPartyTypeKind | ProprietaryBrand, + z.ZodFirstPartyTypeKind | ProprietaryBrand | CustomBrand, SchemaHandler // keeping "any" here in order to avoid excessive complexity > >; diff --git a/tests/unit/__snapshots__/documentation.spec.ts.snap b/tests/unit/__snapshots__/documentation.spec.ts.snap index 6e78a8635..13250e93e 100644 --- a/tests/unit/__snapshots__/documentation.spec.ts.snap +++ b/tests/unit/__snapshots__/documentation.spec.ts.snap @@ -3592,6 +3592,91 @@ servers: " `; +exports[`Documentation > Feature #1470: Custom brands > should be handled accordingly in request, response and params 1`] = ` +"openapi: 3.1.0 +info: + title: Testing custom brands handling + version: 3.4.5 +paths: + /v1/{name}: + get: + operationId: GetV1Name + parameters: + - name: name + in: path + required: true + description: GET /v1/:name Parameter + schema: + summary: My custom schema + - name: other + in: query + required: true + description: GET /v1/:name Parameter + schema: + summary: My custom schema + responses: + "200": + description: GET /v1/:name Positive response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: success + data: + type: object + properties: + test: + summary: My custom schema + required: + - test + required: + - status + - data + "400": + description: GET /v1/:name Negative response + content: + application/json: + schema: + type: object + properties: + status: + type: string + const: error + error: + type: object + properties: + message: + type: string + required: + - message + required: + - status + - error + examples: + example1: + value: + status: error + error: + message: Sample error message +components: + schemas: {} + responses: {} + parameters: {} + examples: {} + requestBodies: {} + headers: {} + securitySchemes: {} + links: {} + callbacks: {} +tags: [] +servers: + - url: https://example.com +" +`; + exports[`Documentation > Feature 1180: Headers opt-in params > should describe x- inputs as header params 1`] = ` "openapi: 3.1.0 info: diff --git a/tests/unit/documentation.spec.ts b/tests/unit/documentation.spec.ts index c62794568..bfb4085bf 100644 --- a/tests/unit/documentation.spec.ts +++ b/tests/unit/documentation.spec.ts @@ -1270,4 +1270,36 @@ describe("Documentation", () => { expect(spec).toMatchSnapshot(); }); }); + + describe("Feature #1470: Custom brands", () => { + test("should be handled accordingly in request, response and params", () => { + const spec = new Documentation({ + config: sampleConfig, + routing: { + v1: { + ":name": defaultEndpointsFactory.build({ + method: "get", + input: z.object({ + name: z.string().brand("CUSTOM"), + other: z.boolean().brand("CUSTOM"), + }), + output: z.object({ + test: z.number().brand("CUSTOM"), + }), + handler: vi.fn(), + }), + }, + }, + customBrands: { + CUSTOM: () => ({ + summary: "My custom schema", + }), + }, + version: "3.4.5", + title: "Testing custom brands handling", + serverUrl: "https://example.com", + }).getSpecAsYaml(); + expect(spec).toMatchSnapshot(); + }); + }); }); From 26b7ba936cc270377296036e260a446acea1c02f Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 10 May 2024 20:14:07 +0200 Subject: [PATCH 02/24] REF: Moving the subject schema in front of the arguments of the Schema Walker. --- src/deep-checks.ts | 92 +++---- src/documentation-helpers.ts | 311 +++++++++++------------ src/integration.ts | 12 +- src/schema-walker.ts | 43 ++-- src/zts-helpers.ts | 6 +- src/zts.ts | 148 +++++------ tests/helpers.ts | 83 +++--- tests/unit/checks.spec.ts | 9 +- tests/unit/documentation-helpers.spec.ts | 200 +++++++-------- tests/unit/zts.spec.ts | 53 ++-- 10 files changed, 437 insertions(+), 520 deletions(-) diff --git a/src/deep-checks.ts b/src/deep-checks.ts index 9438313ab..918ae92d8 100644 --- a/src/deep-checks.ts +++ b/src/deep-checks.ts @@ -6,59 +6,56 @@ import { HandlingRules, SchemaHandler } from "./schema-walker"; import { ezUploadBrand } from "./upload-schema"; /** @desc Check is a schema handling rule returning boolean */ -type Check = SchemaHandler; +type Check = SchemaHandler; -const onSomeUnion: Check< - | z.ZodUnion - | z.ZodDiscriminatedUnion[]> -> = ({ schema: { options }, next }) => options.some(next); +const onSomeUnion: Check = ( + schema: + | z.ZodUnion + | z.ZodDiscriminatedUnion[]>, + { next }, +) => schema.options.some(next); -const onIntersection: Check> = ({ - schema: { _def }, - next, -}) => [_def.left, _def.right].some(next); +const onIntersection: Check = ( + { _def }: z.ZodIntersection, + { next }, +) => [_def.left, _def.right].some(next); -const onObject: Check> = ({ schema, next }) => - Object.values(schema.shape).some(next); -const onElective: Check< - z.ZodOptional | z.ZodNullable -> = ({ schema, next }) => next(schema.unwrap()); -const onEffects: Check> = ({ schema, next }) => - next(schema.innerType()); -const onRecord: Check = ({ schema, next }) => - next(schema.valueSchema); -const onArray: Check> = ({ schema, next }) => - next(schema.element); -const onDefault: Check> = ({ schema, next }) => - next(schema._def.innerType); +const onElective: Check = ( + schema: z.ZodOptional | z.ZodNullable, + { next }, +) => next(schema.unwrap()); const checks: HandlingRules = { - ZodObject: onObject, + ZodObject: ({ shape }: z.ZodObject, { next }) => + Object.values(shape).some(next), ZodUnion: onSomeUnion, ZodDiscriminatedUnion: onSomeUnion, ZodIntersection: onIntersection, - ZodEffects: onEffects, + ZodEffects: (schema: z.ZodEffects, { next }) => + next(schema.innerType()), ZodOptional: onElective, ZodNullable: onElective, - ZodRecord: onRecord, - ZodArray: onArray, - ZodDefault: onDefault, + ZodRecord: ({ valueSchema }: z.ZodRecord, { next }) => next(valueSchema), + ZodArray: ({ element }: z.ZodArray, { next }) => next(element), + ZodDefault: ({ _def }: z.ZodDefault, { next }) => + next(_def.innerType), }; /** @desc The optimized version of the schema walker for boolean checks */ -export const hasNestedSchema = ({ - subject, - condition, - rules = checks, - depth = 1, - maxDepth = Number.POSITIVE_INFINITY, -}: { - subject: z.ZodTypeAny; - condition: (schema: z.ZodTypeAny) => boolean; - rules?: HandlingRules; - maxDepth?: number; - depth?: number; -}): boolean => { +export const hasNestedSchema = ( + subject: z.ZodTypeAny, + { + condition, + rules = checks, + depth = 1, + maxDepth = Number.POSITIVE_INFINITY, + }: { + condition: (schema: z.ZodTypeAny) => boolean; + rules?: HandlingRules; + maxDepth?: number; + depth?: number; + }, +): boolean => { if (condition(subject)) { return true; } @@ -67,11 +64,9 @@ export const hasNestedSchema = ({ ? rules[subject._def.typeName as keyof typeof rules] : undefined; if (handler) { - return handler({ - schema: subject, + return handler(subject, { next: (schema) => - hasNestedSchema({ - subject: schema, + hasNestedSchema(schema, { condition, rules, maxDepth, @@ -83,8 +78,7 @@ export const hasNestedSchema = ({ }; export const hasTransformationOnTop = (subject: IOSchema): boolean => - hasNestedSchema({ - subject, + hasNestedSchema(subject, { maxDepth: 3, rules: { ZodUnion: onSomeUnion, ZodIntersection: onIntersection }, condition: (schema) => @@ -93,14 +87,12 @@ export const hasTransformationOnTop = (subject: IOSchema): boolean => }); export const hasUpload = (subject: IOSchema) => - hasNestedSchema({ - subject, + hasNestedSchema(subject, { condition: (schema) => schema._def[metaSymbol]?.brand === ezUploadBrand, }); export const hasRaw = (subject: IOSchema) => - hasNestedSchema({ - subject, + hasNestedSchema(subject, { condition: (schema) => schema._def[metaSymbol]?.brand === ezRawBrand, maxDepth: 3, }); diff --git a/src/documentation-helpers.ts b/src/documentation-helpers.ts index 57c406c63..bf3272405 100644 --- a/src/documentation-helpers.ts +++ b/src/documentation-helpers.ts @@ -88,10 +88,11 @@ export interface OpenAPIContext extends FlatObject { method: Method; } -type Depicter< - T extends z.ZodTypeAny, - Variant extends HandlingVariant = "regular", -> = SchemaHandler; +type Depicter = SchemaHandler< + SchemaObject | ReferenceObject, + OpenAPIContext, + Variant +>; interface ReqResDepictHelperCommonProps extends Pick< @@ -128,26 +129,22 @@ export const getRoutePathParams = (path: string): string[] => export const reformatParamsInPath = (path: string) => path.replace(routePathParamsRegex, (param) => `{${param.slice(1)}}`); -export const depictDefault: Depicter> = ({ - schema, - next, -}) => ({ - ...next(schema._def.innerType), - default: schema._def[metaSymbol]?.defaultLabel || schema._def.defaultValue(), +export const depictDefault: Depicter = ( + { _def }: z.ZodDefault, + { next }, +) => ({ + ...next(_def.innerType), + default: _def[metaSymbol]?.defaultLabel || _def.defaultValue(), }); -export const depictCatch: Depicter> = ({ - schema: { - _def: { innerType }, - }, - next, -}) => next(innerType); +export const depictCatch: Depicter = ( + { _def: { innerType } }: z.ZodCatch, + { next }, +) => next(innerType); -export const depictAny: Depicter = () => ({ - format: "any", -}); +export const depictAny: Depicter = () => ({ format: "any" }); -export const depictUpload: Depicter = (ctx) => { +export const depictUpload: Depicter = ({}: UploadSchema, ctx) => { assert( !ctx.isResponse, new DocumentationError({ @@ -155,13 +152,10 @@ export const depictUpload: Depicter = (ctx) => { ...ctx, }), ); - return { - type: "string", - format: "binary", - }; + return { type: "string", format: "binary" }; }; -export const depictFile: Depicter = ({ schema }) => { +export const depictFile: Depicter = (schema: FileSchema) => { const subject = schema.unwrap(); return { type: "string", @@ -174,14 +168,18 @@ export const depictFile: Depicter = ({ schema }) => { }; }; -export const depictUnion: Depicter> = ({ - schema: { options }, - next, -}) => ({ oneOf: options.map(next) }); +export const depictUnion: Depicter = ( + { options }: z.ZodUnion, + { next }, +) => ({ oneOf: options.map(next) }); -export const depictDiscriminatedUnion: Depicter< - z.ZodDiscriminatedUnion[]> -> = ({ schema: { options, discriminator }, next }) => { +export const depictDiscriminatedUnion: Depicter = ( + { + options, + discriminator, + }: z.ZodDiscriminatedUnion[]>, + { next }, +) => { return { discriminator: { propertyName: discriminator }, oneOf: options.map(next), @@ -227,14 +225,10 @@ const tryFlattenIntersection = ( return flat; }; -export const depictIntersection: Depicter< - z.ZodIntersection -> = ({ - schema: { - _def: { left, right }, - }, - next, -}) => { +export const depictIntersection: Depicter = ( + { _def: { left, right } }: z.ZodIntersection, + { next }, +) => { const children = [left, right].map(next); try { return tryFlattenIntersection(children); @@ -242,21 +236,21 @@ export const depictIntersection: Depicter< return { allOf: children }; }; -export const depictOptional: Depicter> = ({ - schema, - next, -}) => next(schema.unwrap()); +export const depictOptional: Depicter = ( + schema: z.ZodOptional, + { next }, +) => next(schema.unwrap()); -export const depictReadonly: Depicter> = ({ - schema, - next, -}) => next(schema._def.innerType); +export const depictReadonly: Depicter = ( + schema: z.ZodReadonly, + { next }, +) => next(schema.unwrap()); /** @since OAS 3.1 nullable replaced with type array having null */ -export const depictNullable: Depicter> = ({ - schema, - next, -}) => { +export const depictNullable: Depicter = ( + schema: z.ZodNullable, + { next }, +) => { const nested = next(schema.unwrap()); if (!isReferenceObject(nested)) { nested.type = makeNullableType(nested); @@ -280,25 +274,22 @@ const getSupportedType = (value: unknown): SchemaObjectType | undefined => { : undefined; }; -export const depictEnum: Depicter< - z.ZodEnum<[string, ...string[]]> | z.ZodNativeEnum // keeping "any" for ZodNativeEnum as compatibility fix -> = ({ schema }) => ({ +export const depictEnum: Depicter = ( + schema: z.ZodEnum<[string, ...string[]]> | z.ZodNativeEnum, +) => ({ type: getSupportedType(Object.values(schema.enum)[0]), enum: Object.values(schema.enum), }); -export const depictLiteral: Depicter> = ({ - schema: { value }, -}) => ({ +export const depictLiteral: Depicter = ({ value }: z.ZodLiteral) => ({ type: getSupportedType(value), // constructor allows z.Primitive only, but ZodLiteral does not have that constrant const: value, }); -export const depictObject: Depicter> = ({ - schema, - isResponse, - ...rest -}) => { +export const depictObject: Depicter = ( + schema: z.ZodObject, + { isResponse, next }, +) => { const keys = Object.keys(schema.shape); const isOptionalProp = (prop: z.ZodTypeAny) => isResponse && hasCoercion(prop) @@ -307,7 +298,7 @@ export const depictObject: Depicter> = ({ const required = keys.filter((key) => !isOptionalProp(schema.shape[key])); const result: SchemaObject = { type: "object" }; if (keys.length) { - result.properties = depictObjectProperties({ schema, isResponse, ...rest }); + result.properties = depictObjectProperties(schema, next); } if (required.length) { result.required = required; @@ -319,9 +310,9 @@ export const depictObject: Depicter> = ({ * @see https://swagger.io/docs/specification/data-models/data-types/ * @since OAS 3.1: using type: "null" * */ -export const depictNull: Depicter = () => ({ type: "null" }); +export const depictNull: Depicter = () => ({ type: "null" }); -export const depictDateIn: Depicter = (ctx) => { +export const depictDateIn: Depicter = ({}: DateInSchema, ctx) => { assert( !ctx.isResponse, new DocumentationError({ @@ -340,7 +331,7 @@ export const depictDateIn: Depicter = (ctx) => { }; }; -export const depictDateOut: Depicter = (ctx) => { +export const depictDateOut: Depicter = ({}: DateOutSchema, ctx) => { assert( ctx.isResponse, new DocumentationError({ @@ -359,7 +350,7 @@ export const depictDateOut: Depicter = (ctx) => { }; /** @throws DocumentationError */ -export const depictDate: Depicter = (ctx) => +export const depictDate: Depicter = ({}: z.ZodDate, ctx) => assert.fail( new DocumentationError({ message: `Using z.date() within ${ @@ -371,11 +362,9 @@ export const depictDate: Depicter = (ctx) => }), ); -export const depictBoolean: Depicter = () => ({ - type: "boolean", -}); +export const depictBoolean: Depicter = () => ({ type: "boolean" }); -export const depictBigInt: Depicter = () => ({ +export const depictBigInt: Depicter = () => ({ type: "integer", format: "bigint", }); @@ -385,18 +374,18 @@ const areOptionsLiteral = ( ): subject is z.ZodLiteral[] => subject.every((option) => option instanceof z.ZodLiteral); -export const depictRecord: Depicter> = ({ - schema: { keySchema, valueSchema }, - ...rest -}) => { +export const depictRecord: Depicter = ( + { keySchema, valueSchema }: z.ZodRecord, + { next }, +) => { if (keySchema instanceof z.ZodEnum || keySchema instanceof z.ZodNativeEnum) { const keys = Object.values(keySchema.enum) as string[]; const result: SchemaObject = { type: "object" }; if (keys.length) { - result.properties = depictObjectProperties({ - schema: z.object(fromPairs(xprod(keys, [valueSchema]))), - ...rest, - }); + result.properties = depictObjectProperties( + z.object(fromPairs(xprod(keys, [valueSchema]))), + next, + ); result.required = keys; } return result; @@ -404,10 +393,10 @@ export const depictRecord: Depicter> = ({ if (keySchema instanceof z.ZodLiteral) { return { type: "object", - properties: depictObjectProperties({ - schema: z.object({ [keySchema.value]: valueSchema }), - ...rest, - }), + properties: depictObjectProperties( + z.object({ [keySchema.value]: valueSchema }), + next, + ), required: [keySchema.value], }; } @@ -416,23 +405,23 @@ export const depictRecord: Depicter> = ({ const shape = fromPairs(xprod(required, [valueSchema])); return { type: "object", - properties: depictObjectProperties({ schema: z.object(shape), ...rest }), + properties: depictObjectProperties(z.object(shape), next), required, }; } - return { type: "object", additionalProperties: rest.next(valueSchema) }; + return { type: "object", additionalProperties: next(valueSchema) }; }; -export const depictArray: Depicter> = ({ - schema: { _def: def, element }, - next, -}) => { +export const depictArray: Depicter = ( + { _def: { minLength, maxLength }, element }: z.ZodArray, + { next }, +) => { const result: SchemaObject = { type: "array", items: next(element) }; - if (def.minLength) { - result.minItems = def.minLength.value; + if (minLength) { + result.minItems = minLength.value; } - if (def.maxLength) { - result.maxItems = def.maxLength.value; + if (maxLength) { + result.maxItems = maxLength.value; } return result; }; @@ -441,35 +430,30 @@ export const depictArray: Depicter> = ({ * @since OAS 3.1 using prefixItems for depicting tuples * @since 17.5.0 added rest handling, fixed tuple type * */ -export const depictTuple: Depicter = ({ - schema: { - items, - _def: { rest }, - }, - next, -}) => ({ +export const depictTuple: Depicter = ( + { items, _def: { rest } }: z.AnyZodTuple, + { next }, +) => ({ type: "array", prefixItems: items.map(next), // does not appear to support items:false, so not:{} is a recommended alias items: rest === null ? { not: {} } : next(rest), }); -export const depictString: Depicter = ({ - schema: { - isEmail, - isURL, - minLength, - maxLength, - isUUID, - isCUID, - isCUID2, - isULID, - isIP, - isEmoji, - isDatetime, - _def: { checks }, - }, -}) => { +export const depictString: Depicter = ({ + isEmail, + isURL, + minLength, + maxLength, + isUUID, + isCUID, + isCUID2, + isULID, + isIP, + isEmoji, + isDatetime, + _def: { checks }, +}: z.ZodString) => { const regexCheck = checks.find( (check): check is Extract => check.kind === "regex", @@ -518,30 +502,35 @@ export const depictString: Depicter = ({ }; /** @since OAS 3.1: exclusive min/max are numbers */ -export const depictNumber: Depicter = ({ schema }) => { - const minCheck = schema._def.checks.find(({ kind }) => kind === "min") as +export const depictNumber: Depicter = ({ + isInt, + maxValue, + minValue, + _def: { checks }, +}: z.ZodNumber) => { + const minCheck = checks.find(({ kind }) => kind === "min") as | Extract | undefined; const minimum = - schema.minValue === null - ? schema.isInt + minValue === null + ? isInt ? Number.MIN_SAFE_INTEGER : -Number.MAX_VALUE - : schema.minValue; + : minValue; const isMinInclusive = minCheck ? minCheck.inclusive : true; - const maxCheck = schema._def.checks.find(({ kind }) => kind === "max") as + const maxCheck = checks.find(({ kind }) => kind === "max") as | Extract | undefined; const maximum = - schema.maxValue === null - ? schema.isInt + maxValue === null + ? isInt ? Number.MAX_SAFE_INTEGER : Number.MAX_VALUE - : schema.maxValue; + : maxValue; const isMaxInclusive = maxCheck ? maxCheck.inclusive : true; const result: SchemaObject = { - type: schema.isInt ? "integer" : "number", - format: schema.isInt ? "int64" : "double", + type: isInt ? "integer" : "number", + format: isInt ? "int64" : "double", }; if (isMinInclusive) { result.minimum = minimum; @@ -556,10 +545,10 @@ export const depictNumber: Depicter = ({ schema }) => { return result; }; -export const depictObjectProperties = ({ - schema: { shape }, - next, -}: Parameters>>[0]) => map(next, shape); +export const depictObjectProperties = ( + { shape }: z.ZodObject, + next: Parameters[1]["next"], +) => map(next, shape); const makeSample = (depicted: SchemaObject) => { const firstType = ( @@ -576,11 +565,10 @@ const makeNullableType = (prev: SchemaObject): SchemaObjectType[] => { return current.concat("null"); }; -export const depictEffect: Depicter> = ({ - schema, - isResponse, - next, -}) => { +export const depictEffect: Depicter = ( + schema: z.ZodEffects, + { isResponse, next }, +) => { const input = next(schema.innerType()); const { effect } = schema._def; if (isResponse && effect.type === "transform" && !isReferenceObject(input)) { @@ -605,33 +593,31 @@ export const depictEffect: Depicter> = ({ return input; }; -export const depictPipeline: Depicter< - z.ZodPipeline -> = ({ schema, isResponse, next }) => - next(schema._def[isResponse ? "out" : "in"]); +export const depictPipeline: Depicter = ( + { _def }: z.ZodPipeline, + { isResponse, next }, +) => next(_def[isResponse ? "out" : "in"]); -export const depictBranded: Depicter< - z.ZodBranded -> = ({ schema, next }) => next(schema.unwrap()); +export const depictBranded: Depicter = ( + schema: z.ZodBranded, + { next }, +) => next(schema.unwrap()); -export const depictLazy: Depicter> = ({ - next, - schema: lazy, - serializer: serialize, - getRef, - makeRef, -}): ReferenceObject => { - const hash = serialize(lazy.schema); +export const depictLazy: Depicter = ( + { schema }: z.ZodLazy, + { next, serializer: serialize, getRef, makeRef }, +): ReferenceObject => { + const hash = serialize(schema); return ( getRef(hash) || (() => { makeRef(hash, {}); // make empty ref first - return makeRef(hash, next(lazy.schema)); // update + return makeRef(hash, next(schema)); // update })() ); }; -export const depictRaw: Depicter = ({ next, schema }) => +export const depictRaw: Depicter = (schema: RawSchema, { next }) => next(schema.unwrap().shape.raw); const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined => @@ -726,8 +712,7 @@ export const depictRequestParams = ({ return Object.keys(shape) .filter((name) => isQueryEnabled || isPathParam(name)) .map((name) => { - const depicted = walkSchema({ - schema: shape[name], + const depicted = walkSchema(shape[name], { isResponse: false, rules: { ...depicters, ...customBrands }, onEach, @@ -794,11 +779,7 @@ export const depicters: HandlingRules< [ezRawBrand]: depictRaw, }; -export const onEach: Depicter = ({ - schema, - isResponse, - prev, -}) => { +export const onEach: Depicter<"each"> = (schema, { isResponse, prev }) => { if (isReferenceObject(prev)) { return {}; } @@ -831,7 +812,7 @@ export const onEach: Depicter = ({ return result; }; -export const onMissing: Depicter = ({ schema, ...ctx }) => +export const onMissing: Depicter<"last"> = (schema, ctx) => assert.fail( new DocumentationError({ message: `Zod type ${schema.constructor.name} is unsupported.`, @@ -896,8 +877,7 @@ export const depictResponse = ({ hasMultipleStatusCodes: boolean; }): ResponseObject => { const depictedSchema = excludeExamplesFromDepiction( - walkSchema({ - schema, + walkSchema(schema, { isResponse: true, rules: { ...depicters, ...customBrands }, onEach, @@ -1037,8 +1017,7 @@ export const depictRequest = ({ const pathParams = getRoutePathParams(path); const bodyDepiction = excludeExamplesFromDepiction( excludeParamsFromDepiction( - walkSchema({ - schema, + walkSchema(schema, { isResponse: false, rules: { ...depicters, ...customBrands }, onEach, diff --git a/src/integration.ts b/src/integration.ts index f369573cd..e145a0223 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -159,9 +159,8 @@ export class Integration { optionalPropStyle, }; const inputId = makeCleanId(method, path, "input"); - const input = zodToTs({ + const input = zodToTs(endpoint.getSchema("input"), { ...commons, - schema: endpoint.getSchema("input"), isResponse: false, }); const positiveResponseId = splitResponse @@ -169,10 +168,9 @@ export class Integration { : undefined; const positiveSchema = endpoint.getSchema("positive"); const positiveResponse = splitResponse - ? zodToTs({ + ? zodToTs(positiveSchema, { ...commons, isResponse: true, - schema: positiveSchema, }) : undefined; const negativeResponseId = splitResponse @@ -180,10 +178,9 @@ export class Integration { : undefined; const negativeSchema = endpoint.getSchema("negative"); const negativeResponse = splitResponse - ? zodToTs({ + ? zodToTs(negativeSchema, { ...commons, isResponse: true, - schema: negativeSchema, }) : undefined; const genericResponseId = makeCleanId(method, path, "response"); @@ -193,10 +190,9 @@ export class Integration { f.createTypeReferenceNode(positiveResponseId), f.createTypeReferenceNode(negativeResponseId), ]) - : zodToTs({ + : zodToTs(positiveSchema.or(negativeSchema), { ...commons, isResponse: true, - schema: positiveSchema.or(negativeSchema), }); this.program.push(createTypeAlias(input, inputId)); if (positiveResponse && positiveResponseId) { diff --git a/src/schema-walker.ts b/src/schema-walker.ts index 5e7929374..e4dd91c6a 100644 --- a/src/schema-walker.ts +++ b/src/schema-walker.ts @@ -12,52 +12,49 @@ interface VariantDependingProps { export type HandlingVariant = keyof VariantDependingProps; type SchemaHandlingProps< - T extends z.ZodTypeAny, U, Context extends FlatObject, Variant extends HandlingVariant, -> = Context & - VariantDependingProps[Variant] & { - schema: T; - }; +> = Context & VariantDependingProps[Variant]; export type SchemaHandler< - T extends z.ZodTypeAny, U, Context extends FlatObject = {}, Variant extends HandlingVariant = "regular", -> = (params: SchemaHandlingProps) => U; +> = (schema: any, params: SchemaHandlingProps) => U; export type CustomBrand = string | symbol; export type HandlingRules = Partial< Record< z.ZodFirstPartyTypeKind | ProprietaryBrand | CustomBrand, - SchemaHandler // keeping "any" here in order to avoid excessive complexity + SchemaHandler > >; /** @since 10.1.1 calling onEach _after_ handler and giving it the previously achieved result */ -export const walkSchema = ({ - schema, - onEach, - rules, - onMissing, - ...rest -}: SchemaHandlingProps & { - onEach?: SchemaHandler; - rules: HandlingRules; - onMissing: SchemaHandler; -}): U => { +export const walkSchema = ( + schema: z.ZodTypeAny, + { + onEach, + rules, + onMissing, + ...rest + }: SchemaHandlingProps & { + onEach?: SchemaHandler; + rules: HandlingRules; + onMissing: SchemaHandler; + }, +): U => { const handler = rules[schema._def[metaSymbol]?.brand as keyof typeof rules] || rules[schema._def.typeName as keyof typeof rules]; const ctx = rest as unknown as Context; const next = (subject: z.ZodTypeAny) => - walkSchema({ schema: subject, ...ctx, onEach, rules, onMissing }); + walkSchema(subject, { ...ctx, onEach, rules, onMissing }); const result = handler - ? handler({ schema, ...ctx, next }) - : onMissing({ schema, ...ctx }); - const overrides = onEach && onEach({ schema, prev: result, ...ctx }); + ? handler(schema, { ...ctx, next }) + : onMissing(schema, ctx); + const overrides = onEach && onEach(schema, { prev: result, ...ctx }); return overrides ? { ...result, ...overrides } : result; }; diff --git a/src/zts-helpers.ts b/src/zts-helpers.ts index 8bef5a37f..4976b665c 100644 --- a/src/zts-helpers.ts +++ b/src/zts-helpers.ts @@ -15,11 +15,7 @@ export interface ZTSContext extends FlatObject { optionalPropStyle: { withQuestionMark?: boolean; withUndefined?: boolean }; } -export type Producer = SchemaHandler< - T, - ts.TypeNode, - ZTSContext ->; +export type Producer = SchemaHandler; export const addJsDocComment = (node: ts.Node, text: string) => { ts.addSyntheticLeadingComment( diff --git a/src/zts.ts b/src/zts.ts index 1d687c083..525c06bc7 100644 --- a/src/zts.ts +++ b/src/zts.ts @@ -26,9 +26,7 @@ const samples = { [ts.SyntaxKind.UndefinedKeyword]: undefined, } satisfies Partial>; -const onLiteral: Producer> = ({ - schema: { value }, -}) => +const onLiteral: Producer = ({ value }: z.ZodLiteral) => f.createLiteralTypeNode( typeof value === "number" ? f.createNumericLiteral(value) @@ -39,12 +37,14 @@ const onLiteral: Producer> = ({ : f.createStringLiteral(value), ); -const onObject: Producer> = ({ - schema: { shape }, - isResponse, - next, - optionalPropStyle: { withQuestionMark: hasQuestionMark }, -}) => { +const onObject: Producer = ( + { shape }: z.ZodObject, + { + isResponse, + next, + optionalPropStyle: { withQuestionMark: hasQuestionMark }, + }, +) => { const members = Object.entries(shape).map(([key, value]) => { const isOptional = isResponse && hasCoercion(value) @@ -66,33 +66,32 @@ const onObject: Producer> = ({ return f.createTypeLiteralNode(members); }; -const onArray: Producer> = ({ - schema: { element }, - next, -}) => f.createArrayTypeNode(next(element)); +const onArray: Producer = ({ element }: z.ZodArray, { next }) => + f.createArrayTypeNode(next(element)); -const onEnum: Producer> = ({ - schema: { options }, -}) => +const onEnum: Producer = ({ options }: z.ZodEnum<[string, ...string[]]>) => f.createUnionTypeNode( options.map((option) => f.createLiteralTypeNode(f.createStringLiteral(option)), ), ); -const onSomeUnion: Producer< - | z.ZodUnion - | z.ZodDiscriminatedUnion[]> -> = ({ schema: { options }, next }) => f.createUnionTypeNode(options.map(next)); +const onSomeUnion: Producer = ( + { + options, + }: + | z.ZodUnion + | z.ZodDiscriminatedUnion[]>, + { next }, +) => f.createUnionTypeNode(options.map(next)); const makeSample = (produced: ts.TypeNode) => samples?.[produced.kind as keyof typeof samples]; -const onEffects: Producer> = ({ - schema, - next, - isResponse, -}) => { +const onEffects: Producer = ( + schema: z.ZodEffects, + { next, isResponse }, +) => { const input = next(schema.innerType()); const effect = schema._def.effect; if (isResponse && effect.type === "transform") { @@ -114,7 +113,7 @@ const onEffects: Producer> = ({ return input; }; -const onNativeEnum: Producer> = ({ schema }) => +const onNativeEnum: Producer = (schema: z.ZodNativeEnum) => f.createUnionTypeNode( Object.values(schema.enum).map((value) => f.createLiteralTypeNode( @@ -125,11 +124,10 @@ const onNativeEnum: Producer> = ({ schema }) => ), ); -const onOptional: Producer> = ({ - next, - schema, - optionalPropStyle: { withUndefined: hasUndefined }, -}) => { +const onOptional: Producer = ( + schema: z.ZodOptional, + { next, optionalPropStyle: { withUndefined: hasUndefined } }, +) => { const actualTypeNode = next(schema.unwrap()); return hasUndefined ? f.createUnionTypeNode([ @@ -139,84 +137,74 @@ const onOptional: Producer> = ({ : actualTypeNode; }; -const onNullable: Producer> = ({ next, schema }) => +const onNullable: Producer = (schema: z.ZodNullable, { next }) => f.createUnionTypeNode([ next(schema.unwrap()), f.createLiteralTypeNode(f.createNull()), ]); -const onTuple: Producer = ({ - next, - schema: { - items, - _def: { rest }, - }, -}) => +const onTuple: Producer = ({ items, _def: { rest } }: z.ZodTuple, { next }) => f.createTupleTypeNode( items .map(next) .concat(rest === null ? [] : f.createRestTypeNode(next(rest))), ); -const onRecord: Producer = ({ - next, - schema: { keySchema, valueSchema }, -}) => +const onRecord: Producer = ( + { keySchema, valueSchema }: z.ZodRecord, + { next }, +) => f.createExpressionWithTypeArguments( f.createIdentifier("Record"), [keySchema, valueSchema].map(next), ); -const onIntersection: Producer< - z.ZodIntersection -> = ({ next, schema }) => - f.createIntersectionTypeNode([schema._def.left, schema._def.right].map(next)); +const onIntersection: Producer = ( + { _def }: z.ZodIntersection, + { next }, +) => f.createIntersectionTypeNode([_def.left, _def.right].map(next)); -const onDefault: Producer> = ({ next, schema }) => - next(schema._def.innerType); +const onDefault: Producer = ({ _def }: z.ZodDefault, { next }) => + next(_def.innerType); const onPrimitive = - (syntaxKind: ts.KeywordTypeSyntaxKind): Producer => + (syntaxKind: ts.KeywordTypeSyntaxKind): Producer => () => f.createKeywordTypeNode(syntaxKind); -const onBranded: Producer< - z.ZodBranded -> = ({ next, schema }) => next(schema.unwrap()); +const onBranded: Producer = ( + schema: z.ZodBranded, + { next }, +) => next(schema.unwrap()); -const onReadonly: Producer> = ({ next, schema }) => - next(schema._def.innerType); +const onReadonly: Producer = (schema: z.ZodReadonly, { next }) => + next(schema.unwrap()); -const onCatch: Producer> = ({ next, schema }) => - next(schema._def.innerType); +const onCatch: Producer = ({ _def }: z.ZodCatch, { next }) => + next(_def.innerType); -const onPipeline: Producer> = ({ - schema, - next, - isResponse, -}) => next(schema._def[isResponse ? "out" : "in"]); +const onPipeline: Producer = ( + { _def }: z.ZodPipeline, + { next, isResponse }, +) => next(_def[isResponse ? "out" : "in"]); -const onNull: Producer = () => - f.createLiteralTypeNode(f.createNull()); +const onNull: Producer = () => f.createLiteralTypeNode(f.createNull()); -const onLazy: Producer> = ({ - getAlias, - makeAlias, - next, - serializer: serialize, - schema: lazy, -}) => { - const name = `Type${serialize(lazy.schema)}`; +const onLazy: Producer = ( + { schema }: z.ZodLazy, + { getAlias, makeAlias, next, serializer: serialize }, +) => { + const name = `Type${serialize(schema)}`; return ( getAlias(name) || (() => { makeAlias(name, f.createLiteralTypeNode(f.createNull())); // make empty type first - return makeAlias(name, next(lazy.schema)); // update + return makeAlias(name, next(schema)); // update })() ); }; -const onFile: Producer = ({ schema }) => { +const onFile: Producer = (schema: FileSchema) => { const subject = schema.unwrap(); const stringType = f.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); const bufferType = f.createTypeReferenceNode("Buffer"); @@ -228,7 +216,7 @@ const onFile: Producer = ({ schema }) => { : bufferType; }; -const onRaw: Producer = ({ next, schema }) => +const onRaw: Producer = (schema: RawSchema, { next }) => next(schema.unwrap().shape.raw); const producers: HandlingRules = { @@ -263,14 +251,8 @@ const producers: HandlingRules = { [ezRawBrand]: onRaw, }; -export const zodToTs = ({ - schema, - ...ctx -}: { - schema: z.ZodTypeAny; -} & ZTSContext) => - walkSchema({ - schema, +export const zodToTs = (schema: z.ZodTypeAny, ctx: ZTSContext) => + walkSchema(schema, { rules: producers, onMissing: () => f.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword), ...ctx, diff --git a/tests/helpers.ts b/tests/helpers.ts index 56e331544..b348c645e 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -35,20 +35,27 @@ export const waitFor = async (cb: () => boolean) => export const serializeSchemaForTest = ( subject: z.ZodTypeAny, ): Record => { - const onSomeUnion: SchemaHandler< - | z.ZodUnion - | z.ZodDiscriminatedUnion[]>, - object - > = ({ schema, next }) => ({ - options: Array.from(schema.options.values()).map(next), + const onSomeUnion: SchemaHandler = ( + { + options, + }: + | z.ZodUnion + | z.ZodDiscriminatedUnion< + string, + z.ZodDiscriminatedUnionOption[] + >, + { next }, + ) => ({ + options: Array.from(options.values()).map(next), + }); + const onOptionalOrNullable: SchemaHandler = ( + schema: z.ZodOptional | z.ZodNullable, + { next }, + ) => ({ + value: next(schema.unwrap()), }); - const onOptionalOrNullable: SchemaHandler< - z.ZodOptional | z.ZodNullable, - object - > = ({ schema, next }) => ({ value: next(schema.unwrap()) }); const onPrimitive = () => ({}); - return walkSchema({ - schema: subject, + return walkSchema(subject, { rules: { ZodNull: onPrimitive, ZodNumber: onPrimitive, @@ -58,36 +65,42 @@ export const serializeSchemaForTest = ( ZodDiscriminatedUnion: onSomeUnion, ZodOptional: onOptionalOrNullable, ZodNullable: onOptionalOrNullable, - ZodIntersection: ({ schema, next }) => ({ - left: next(schema._def.left), - right: next(schema._def.right), + ZodIntersection: ({ _def }: z.ZodIntersection, { next }) => ({ + left: next(_def.left), + right: next(_def.right), + }), + ZodObject: ({ shape }: z.ZodObject, { next }) => ({ + shape: map(next, shape), + }), + ZodEffects: ({ _def }: z.ZodEffects, { next }) => ({ + value: next(_def.schema), + }), + ZodRecord: ({ keySchema, valueSchema }: z.ZodRecord, { next }) => ({ + keys: next(keySchema), + values: next(valueSchema), }), - ZodObject: ({ schema, next }) => ({ shape: map(next, schema.shape) }), - ZodEffects: ({ schema, next }) => ({ value: next(schema._def.schema) }), - ZodRecord: ({ schema, next }) => ({ - keys: next(schema.keySchema), - values: next(schema.valueSchema), + ZodArray: ({ element }: z.ZodArray, { next }) => ({ + items: next(element), }), - ZodArray: ({ schema, next }) => ({ items: next(schema.element) }), - ZodLiteral: ({ schema }) => ({ value: schema.value }), - ZodDefault: ({ schema, next }) => ({ - value: next(schema._def.innerType), - default: schema._def.defaultValue(), + ZodLiteral: ({ value }: z.ZodLiteral) => ({ value }), + ZodDefault: ({ _def }: z.ZodDefault, { next }) => ({ + value: next(_def.innerType), + default: _def.defaultValue(), }), - ZodReadonly: ({ schema, next }) => next(schema._def.innerType), - ZodCatch: ({ schema, next }) => ({ - value: next(schema._def.innerType), - fallback: schema._def.defaultValue(), + ZodReadonly: (schema: z.ZodReadonly, { next }) => + next(schema.unwrap()), + ZodCatch: ({ _def }: z.ZodCatch, { next }) => ({ + value: next(_def.innerType), }), - ZodPipeline: ({ schema, next }) => ({ - from: next(schema._def.in), - to: next(schema._def.out), + ZodPipeline: ({ _def }: z.ZodPipeline, { next }) => ({ + from: next(_def.in), + to: next(_def.out), }), [ezFileBrand]: () => ({ brand: ezFileBrand }), }, - onEach: ({ schema }) => ({ _type: schema._def.typeName }), - onMissing: ({ schema }) => { - console.warn(`There is no serializer for ${schema._def.typeName}`); + onEach: ({ _def }: z.ZodTypeAny) => ({ _type: _def.typeName }), + onMissing: ({ _def }: z.ZodTypeAny) => { + console.warn(`There is no serializer for ${_def.typeName}`); return {}; }, }); diff --git a/tests/unit/checks.spec.ts b/tests/unit/checks.spec.ts index b91963c1b..f537c2c34 100644 --- a/tests/unit/checks.spec.ts +++ b/tests/unit/checks.spec.ts @@ -12,7 +12,7 @@ describe("Checks", () => { subject._def[metaSymbol]?.brand === ezUploadBrand; test("should return true for given argument satisfying condition", () => { - expect(hasNestedSchema({ subject: ez.upload(), condition })).toBeTruthy(); + expect(hasNestedSchema(ez.upload(), { condition })).toBeTruthy(); }); test.each([ @@ -26,7 +26,7 @@ describe("Checks", () => { ez.upload().refine(() => true), z.array(ez.upload()), ])("should return true for wrapped needle %#", (subject) => { - expect(hasNestedSchema({ subject, condition })).toBeTruthy(); + expect(hasNestedSchema(subject, { condition })).toBeTruthy(); }); test.each([ @@ -36,7 +36,7 @@ describe("Checks", () => { z.boolean().and(z.literal(true)), z.number().or(z.string()), ])("should return false in other cases %#", (subject) => { - expect(hasNestedSchema({ subject, condition })).toBeFalsy(); + expect(hasNestedSchema(subject, { condition })).toBeFalsy(); }); test("should finish early", () => { @@ -48,8 +48,7 @@ describe("Checks", () => { }), }); const check = vi.fn((schema) => schema instanceof z.ZodObject); - hasNestedSchema({ - subject, + hasNestedSchema(subject, { condition: check, }); expect(check.mock.calls.length).toBe(1); diff --git a/tests/unit/documentation-helpers.spec.ts b/tests/unit/documentation-helpers.spec.ts index e3b6c287c..496d239fb 100644 --- a/tests/unit/documentation-helpers.spec.ts +++ b/tests/unit/documentation-helpers.spec.ts @@ -79,8 +79,7 @@ describe("Documentation helpers", () => { serializer: defaultSerializer, }; const makeNext = (ctx: OpenAPIContext) => (schema: z.ZodTypeAny) => - walkSchema({ - schema, + walkSchema(schema, { rules: depicters, ...ctx, onEach, @@ -188,8 +187,7 @@ describe("Documentation helpers", () => { .record(z.literal("a"), z.string()) .and(z.record(z.string(), z.string())), ])("should omit specified path params %#", (schema) => { - const depicted = walkSchema({ - schema, + const depicted = walkSchema(schema, { ...requestCtx, onEach, rules: depicters, @@ -221,8 +219,7 @@ describe("Documentation helpers", () => { describe("depictDefault()", () => { test("should set default property", () => { expect( - depictDefault({ - schema: z.boolean().default(true), + depictDefault(z.boolean().default(true), { ...requestCtx, next: makeNext(requestCtx), }), @@ -230,15 +227,17 @@ describe("Documentation helpers", () => { }); test("Feature #1706: should override the default value by a label from metadata", () => { expect( - depictDefault({ - schema: z + depictDefault( + z .string() .datetime() .default(() => new Date().toISOString()) .label("Today"), - ...responseCtx, - next: makeNext(responseCtx), - }), + { + ...responseCtx, + next: makeNext(responseCtx), + }, + ), ).toMatchSnapshot(); }); }); @@ -246,8 +245,7 @@ describe("Documentation helpers", () => { describe("depictCatch()", () => { test("should pass next depicter", () => { expect( - depictCatch({ - schema: z.boolean().catch(true), + depictCatch(z.boolean().catch(true), { ...requestCtx, next: makeNext(requestCtx), }), @@ -258,8 +256,7 @@ describe("Documentation helpers", () => { describe("depictAny()", () => { test("should set format:any", () => { expect( - depictAny({ - schema: z.any(), + depictAny(z.any(), { ...requestCtx, next: makeNext(requestCtx), }), @@ -270,8 +267,7 @@ describe("Documentation helpers", () => { describe("depictUpload()", () => { test("should set format:binary and type:string", () => { expect( - depictUpload({ - schema: ez.upload(), + depictUpload(ez.upload(), { ...requestCtx, next: makeNext(requestCtx), }), @@ -279,8 +275,7 @@ describe("Documentation helpers", () => { }); test("should throw when using in response", () => { try { - depictUpload({ - schema: ez.upload(), + depictUpload(ez.upload(), { ...responseCtx, next: makeNext(responseCtx), }); @@ -301,8 +296,7 @@ describe("Documentation helpers", () => { ez.file("buffer"), ])("should set type:string and format accordingly %#", (schema) => { expect( - depictFile({ - schema, + depictFile(schema, { ...responseCtx, next: makeNext(responseCtx), }), @@ -313,8 +307,7 @@ describe("Documentation helpers", () => { describe("depictUnion()", () => { test("should wrap next depicters into oneOf property", () => { expect( - depictUnion({ - schema: z.string().or(z.number()), + depictUnion(z.string().or(z.number()), { ...requestCtx, next: makeNext(requestCtx), }), @@ -325,17 +318,19 @@ describe("Documentation helpers", () => { describe("depictDiscriminatedUnion()", () => { test("should wrap next depicters in oneOf prop and set discriminator prop", () => { expect( - depictDiscriminatedUnion({ - schema: z.discriminatedUnion("status", [ + depictDiscriminatedUnion( + z.discriminatedUnion("status", [ z.object({ status: z.literal("success"), data: z.any() }), z.object({ status: z.literal("error"), error: z.object({ message: z.string() }), }), ]), - ...requestCtx, - next: makeNext(requestCtx), - }), + { + ...requestCtx, + next: makeNext(requestCtx), + }, + ), ).toMatchSnapshot(); }); }); @@ -343,20 +338,20 @@ describe("Documentation helpers", () => { describe("depictIntersection()", () => { test("should flatten two object schemas", () => { expect( - depictIntersection({ - schema: z - .object({ one: z.number() }) - .and(z.object({ two: z.number() })), - ...requestCtx, - next: makeNext(requestCtx), - }), + depictIntersection( + z.object({ one: z.number() }).and(z.object({ two: z.number() })), + { + ...requestCtx, + next: makeNext(requestCtx), + }, + ), ).toMatchSnapshot(); }); test("should merge examples deeply", () => { expect( - depictIntersection({ - schema: z + depictIntersection( + z .object({ test: z.object({ a: z.number() }) }) .example({ test: { a: 123 } }) .and( @@ -364,35 +359,41 @@ describe("Documentation helpers", () => { .object({ test: z.object({ b: z.number() }) }) .example({ test: { b: 456 } }), ), - ...requestCtx, - next: makeNext(requestCtx), - }), + { + ...requestCtx, + next: makeNext(requestCtx), + }, + ), ).toMatchSnapshot(); }); test("should flatten three object schemas with examples", () => { expect( - depictIntersection({ - schema: z + depictIntersection( + z .object({ one: z.number() }) .example({ one: 123 }) .and(z.object({ two: z.number() }).example({ two: 456 })) .and(z.object({ three: z.number() }).example({ three: 789 })), - ...requestCtx, - next: makeNext(requestCtx), - }), + { + ...requestCtx, + next: makeNext(requestCtx), + }, + ), ).toMatchSnapshot(); }); test("should maintain uniqueness in the array of required props", () => { expect( - depictIntersection({ - schema: z + depictIntersection( + z .record(z.literal("test"), z.number()) .and(z.object({ test: z.literal(5) })), - ...requestCtx, - next: makeNext(requestCtx), - }), + { + ...requestCtx, + next: makeNext(requestCtx), + }, + ), ).toMatchSnapshot(); }); @@ -401,8 +402,7 @@ describe("Documentation helpers", () => { z.number().and(z.literal(5)), // not objects ])("should fall back to allOf in other cases %#", (schema) => { expect( - depictIntersection({ - schema, + depictIntersection(schema, { ...requestCtx, next: makeNext(requestCtx), }), @@ -415,8 +415,7 @@ describe("Documentation helpers", () => { "should pass the next depicter %#", (ctx) => { expect( - depictOptional({ - schema: z.string().optional(), + depictOptional(z.string().optional(), { ...ctx, next: makeNext(ctx), }), @@ -430,8 +429,7 @@ describe("Documentation helpers", () => { "should add null to the type %#", (ctx) => { expect( - depictNullable({ - schema: z.string().nullable(), + depictNullable(z.string().nullable(), { ...ctx, next: makeNext(ctx), }), @@ -445,8 +443,7 @@ describe("Documentation helpers", () => { z.string().nullable().nullable(), ])("should only add null type once %#", (schema) => { expect( - depictNullable({ - schema, + depictNullable(schema, { ...requestCtx, next: makeNext(requestCtx), }), @@ -463,8 +460,7 @@ describe("Documentation helpers", () => { "should set type and enum properties", (schema) => { expect( - depictEnum({ - schema, + depictEnum(schema, { ...requestCtx, next: makeNext(requestCtx), }), @@ -478,8 +474,7 @@ describe("Documentation helpers", () => { "should set type and involve const property %#", (value) => { expect( - depictLiteral({ - schema: z.literal(value), + depictLiteral(z.literal(value), { ...requestCtx, next: makeNext(requestCtx), }), @@ -505,8 +500,7 @@ describe("Documentation helpers", () => { "should type:object, properties and required props %#", ({ shape, ctx }) => { expect( - depictObject({ - schema: z.object(shape), + depictObject(z.object(shape), { ...ctx, next: makeNext(ctx), }), @@ -521,8 +515,7 @@ describe("Documentation helpers", () => { c: z.coerce.string().optional(), }); expect( - depictObject({ - schema, + depictObject(schema, { ...responseCtx, next: makeNext(responseCtx), }), @@ -533,8 +526,7 @@ describe("Documentation helpers", () => { describe("depictNull()", () => { test("should give type:null", () => { expect( - depictNull({ - schema: z.null(), + depictNull(z.null(), { ...requestCtx, next: makeNext(requestCtx), }), @@ -545,8 +537,7 @@ describe("Documentation helpers", () => { describe("depictBoolean()", () => { test("should set type:boolean", () => { expect( - depictBoolean({ - schema: z.boolean(), + depictBoolean(z.boolean(), { ...requestCtx, next: makeNext(requestCtx), }), @@ -557,8 +548,7 @@ describe("Documentation helpers", () => { describe("depictBigInt()", () => { test("should set type:integer and format:bigint", () => { expect( - depictBigInt({ - schema: z.bigint(), + depictBigInt(z.bigint(), { ...requestCtx, next: makeNext(requestCtx), }), @@ -578,8 +568,7 @@ describe("Documentation helpers", () => { "should set properties+required or additionalProperties props %#", (schema) => { expect( - depictRecord({ - schema, + depictRecord(schema, { ...requestCtx, next: makeNext(requestCtx), }), @@ -591,8 +580,7 @@ describe("Documentation helpers", () => { describe("depictArray()", () => { test("should set type:array and pass items depiction", () => { expect( - depictArray({ - schema: z.array(z.boolean()), + depictArray(z.array(z.boolean()), { ...requestCtx, next: makeNext(requestCtx), }), @@ -603,8 +591,7 @@ describe("Documentation helpers", () => { describe("depictTuple()", () => { test("should utilize prefixItems and set items:not:{}", () => { expect( - depictTuple({ - schema: z.tuple([z.boolean(), z.string(), z.literal("test")]), + depictTuple(z.tuple([z.boolean(), z.string(), z.literal("test")]), { ...requestCtx, next: makeNext(requestCtx), }), @@ -612,8 +599,7 @@ describe("Documentation helpers", () => { }); test("should depict rest as items when defined", () => { expect( - depictTuple({ - schema: z.tuple([z.boolean()]).rest(z.string()), + depictTuple(z.tuple([z.boolean()]).rest(z.string()), { ...requestCtx, next: makeNext(requestCtx), }), @@ -621,8 +607,7 @@ describe("Documentation helpers", () => { }); test("should depict empty tuples as is", () => { expect( - depictTuple({ - schema: z.tuple([]), + depictTuple(z.tuple([]), { ...requestCtx, next: makeNext(requestCtx), }), @@ -633,8 +618,7 @@ describe("Documentation helpers", () => { describe("depictString()", () => { test("should set type:string", () => { expect( - depictString({ - schema: z.string(), + depictString(z.string(), { ...requestCtx, next: makeNext(requestCtx), }), @@ -651,8 +635,7 @@ describe("Documentation helpers", () => { z.string().regex(/^\d+.\d+.\d+$/), ])("should set format, pattern and min/maxLength props %#", (schema) => { expect( - depictString({ - schema, + depictString(schema, { ...requestCtx, next: makeNext(requestCtx), }), @@ -665,8 +648,7 @@ describe("Documentation helpers", () => { "should type:number, min/max, format and exclusiveness props", (schema) => { expect( - depictNumber({ - schema, + depictNumber(schema, { ...requestCtx, next: makeNext(requestCtx), }), @@ -678,14 +660,13 @@ describe("Documentation helpers", () => { describe("depictObjectProperties()", () => { test("should wrap next depicters in a shape of object", () => { expect( - depictObjectProperties({ - schema: z.object({ + depictObjectProperties( + z.object({ one: z.string(), two: z.boolean(), }), - ...requestCtx, - next: makeNext(requestCtx), - }), + makeNext(requestCtx), + ), ).toMatchSnapshot(); }); }); @@ -720,8 +701,7 @@ describe("Documentation helpers", () => { }, ])("should depict as $expected", ({ schema, ctx }) => { expect( - depictEffect({ - schema, + depictEffect(schema, { ...ctx, next: makeNext(ctx), }), @@ -733,8 +713,7 @@ describe("Documentation helpers", () => { z.number().transform(() => assert.fail("this should be handled")), ])("should handle edge cases", (schema) => { expect( - depictEffect({ - schema, + depictEffect(schema, { ...responseCtx, next: makeNext(responseCtx), }), @@ -748,8 +727,7 @@ describe("Documentation helpers", () => { { ctx: requestCtx, expected: "string (in)" }, ])("should depict as $expected", ({ ctx }) => { expect( - depictPipeline({ - schema: z.string().pipe(z.coerce.boolean()), + depictPipeline(z.string().pipe(z.coerce.boolean()), { ...ctx, next: makeNext(ctx), }), @@ -887,8 +865,7 @@ describe("Documentation helpers", () => { describe("depictDateIn", () => { test("should set type:string, pattern and format", () => { expect( - depictDateIn({ - schema: ez.dateIn(), + depictDateIn(ez.dateIn(), { ...requestCtx, next: makeNext(requestCtx), }), @@ -896,8 +873,7 @@ describe("Documentation helpers", () => { }); test("should throw when ZodDateIn in response", () => { try { - depictDateIn({ - schema: ez.dateIn(), + depictDateIn(ez.dateIn(), { ...responseCtx, next: makeNext(responseCtx), }); @@ -912,8 +888,7 @@ describe("Documentation helpers", () => { describe("depictDateOut", () => { test("should set type:string, description and format", () => { expect( - depictDateOut({ - schema: ez.dateOut(), + depictDateOut(ez.dateOut(), { ...responseCtx, next: makeNext(responseCtx), }), @@ -921,8 +896,7 @@ describe("Documentation helpers", () => { }); test("should throw when ZodDateOut in request", () => { try { - depictDateOut({ - schema: ez.dateOut(), + depictDateOut(ez.dateOut(), { ...requestCtx, next: makeNext(requestCtx), }); @@ -939,8 +913,7 @@ describe("Documentation helpers", () => { "should throw clear error %#", (ctx) => { try { - depictDate({ - schema: z.date(), + depictDate(z.date(), { ...ctx, next: makeNext(ctx), }); @@ -956,8 +929,7 @@ describe("Documentation helpers", () => { describe("depictBranded", () => { test("should pass the next depicter", () => { expect( - depictBranded({ - schema: z.string().min(2).brand<"Test">(), + depictBranded(z.string().min(2).brand<"Test">(), { ...responseCtx, next: makeNext(responseCtx), }), @@ -968,8 +940,7 @@ describe("Documentation helpers", () => { describe("depictReadonly", () => { test("should pass the next depicter", () => { expect( - depictReadonly({ - schema: z.string().readonly(), + depictReadonly(z.string().readonly(), { ...responseCtx, next: makeNext(responseCtx), }), @@ -1009,8 +980,7 @@ describe("Documentation helpers", () => { ); expect(getRefMock.mock.calls.length).toBe(0); expect( - depictLazy({ - schema, + depictLazy(schema, { ...responseCtx, next: makeNext(responseCtx), }), diff --git a/tests/unit/zts.spec.ts b/tests/unit/zts.spec.ts index 6efef2f9b..478b0eb8d 100644 --- a/tests/unit/zts.spec.ts +++ b/tests/unit/zts.spec.ts @@ -20,20 +20,20 @@ describe("zod-to-ts", () => { describe("z.array()", () => { test("outputs correct typescript", () => { - const node = zodToTs({ - schema: z.object({ id: z.number(), value: z.string() }).array(), - ...defaultCtx, - }); + const node = zodToTs( + z.object({ id: z.number(), value: z.string() }).array(), + defaultCtx, + ); expect(printNodeTest(node)).toMatchSnapshot(); }); }); describe("createTypeAlias()", () => { const identifier = "User"; - const node = zodToTs({ - schema: z.object({ username: z.string(), age: z.number() }), - ...defaultCtx, - }); + const node = zodToTs( + z.object({ username: z.string(), age: z.number() }), + defaultCtx, + ); test("outputs correct typescript", () => { const typeAlias = createTypeAlias(node, identifier); @@ -73,9 +73,7 @@ describe("zod-to-ts", () => { { schema: z.nativeEnum(Fruit), feature: "string" }, { schema: z.nativeEnum(StringLiteral), feature: "quoted string" }, ])("handles $feature literals", ({ schema }) => { - expect( - printNodeTest(zodToTs({ schema, ...defaultCtx })), - ).toMatchSnapshot(); + expect(printNodeTest(zodToTs(schema, defaultCtx))).toMatchSnapshot(); }); }); @@ -178,10 +176,7 @@ describe("zod-to-ts", () => { }); test("should produce the expected results", () => { - const node = zodToTs({ - schema: example, - ...defaultCtx, - }); + const node = zodToTs(example, defaultCtx); expect(printNode(node)).toMatchSnapshot(); }); }); @@ -209,12 +204,12 @@ describe("zod-to-ts", () => { }); test("outputs correct typescript", () => { - const node = zodToTs({ schema: optionalStringSchema, ...defaultCtx }); + const node = zodToTs(optionalStringSchema, defaultCtx); expect(printNodeTest(node)).toMatchSnapshot(); }); test("should output `?:` and undefined union for optional properties", () => { - const node = zodToTs({ schema: objectWithOptionals, ...defaultCtx }); + const node = zodToTs(objectWithOptionals, defaultCtx); expect(printNodeTest(node)).toMatchSnapshot(); }); }); @@ -223,7 +218,7 @@ describe("zod-to-ts", () => { const nullableUsernameSchema = z.object({ username: z.string().nullable(), }); - const node = zodToTs({ schema: nullableUsernameSchema, ...defaultCtx }); + const node = zodToTs(nullableUsernameSchema, defaultCtx); test("outputs correct typescript", () => { expect(printNodeTest(node)).toMatchSnapshot(); @@ -236,7 +231,7 @@ describe("zod-to-ts", () => { "string-literal": z.string(), 5: z.number(), }); - const node = zodToTs({ schema, ...defaultCtx }); + const node = zodToTs(schema, defaultCtx); expect(printNodeTest(node)).toMatchSnapshot(); }); @@ -246,7 +241,7 @@ describe("zod-to-ts", () => { name: z.string(), countryOfOrigin: z.string(), }); - const node = zodToTs({ schema, ...defaultCtx }); + const node = zodToTs(schema, defaultCtx); expect(printNodeTest(node)).toMatchSnapshot(); }); @@ -262,7 +257,7 @@ describe("zod-to-ts", () => { _r: z.any(), "-r": z.undefined(), }); - const node = zodToTs({ schema, ...defaultCtx }); + const node = zodToTs(schema, defaultCtx); expect(printNodeTest(node)).toMatchSnapshot(); }); @@ -271,7 +266,7 @@ describe("zod-to-ts", () => { name: z.string().describe("The name of the item"), price: z.number().describe("The price of the item"), }); - const node = zodToTs({ schema, ...defaultCtx }); + const node = zodToTs(schema, defaultCtx); expect(printNodeTest(node)).toMatchSnapshot(); }); }); @@ -289,7 +284,7 @@ describe("zod-to-ts", () => { unknown: z.unknown(), never: z.never(), }); - const node = zodToTs({ schema: primitiveSchema, ...defaultCtx }); + const node = zodToTs(primitiveSchema, defaultCtx); test("outputs correct typescript", () => { expect(printNodeTest(node)).toMatchSnapshot(); @@ -302,7 +297,7 @@ describe("zod-to-ts", () => { z.object({ kind: z.literal("square"), x: z.number() }), z.object({ kind: z.literal("triangle"), x: z.number(), y: z.number() }), ]); - const node = zodToTs({ schema: shapeSchema, ...defaultCtx }); + const node = zodToTs(shapeSchema, defaultCtx); test("outputs correct typescript", () => { expect(printNodeTest(node)).toMatchSnapshot(); @@ -316,9 +311,7 @@ describe("zod-to-ts", () => { z.literal(false), z.literal(123), ])("Should produce the correct typescript %#", (schema) => { - expect( - printNodeTest(zodToTs({ schema, ...defaultCtx })), - ).toMatchSnapshot(); + expect(printNodeTest(zodToTs(schema, defaultCtx))).toMatchSnapshot(); }); }); @@ -330,14 +323,14 @@ describe("zod-to-ts", () => { ])("should produce the schema type $expected", ({ isResponse }) => { const schema = z.number().transform((num) => `${num}`); expect( - printNodeTest(zodToTs({ schema, ...defaultCtx, isResponse })), + printNodeTest(zodToTs(schema, { ...defaultCtx, isResponse })), ).toMatchSnapshot(); }); test("should handle unsupported transformation in response", () => { const schema = z.number().transform((num) => () => num); expect( - printNodeTest(zodToTs({ schema, ...defaultCtx, isResponse: true })), + printNodeTest(zodToTs(schema, { ...defaultCtx, isResponse: true })), ).toMatchSnapshot(); }); @@ -346,7 +339,7 @@ describe("zod-to-ts", () => { .number() .transform(() => assert.fail("this should be handled")); expect( - printNodeTest(zodToTs({ schema, ...defaultCtx, isResponse: true })), + printNodeTest(zodToTs(schema, { ...defaultCtx, isResponse: true })), ).toMatchSnapshot(); }); }); From 79166c6ae0751614dcadea6ad121edccfebd7ac2 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 10 May 2024 20:55:14 +0200 Subject: [PATCH 03/24] REF: extracting ctx to a dedicated property of Schema Walker. --- src/documentation-helpers.ts | 42 ++++++++++++++---------- src/schema-walker.ts | 8 ++--- src/zts.ts | 4 +-- tests/unit/documentation-helpers.spec.ts | 6 ++-- 4 files changed, 33 insertions(+), 27 deletions(-) diff --git a/src/documentation-helpers.ts b/src/documentation-helpers.ts index bf3272405..fba615968 100644 --- a/src/documentation-helpers.ts +++ b/src/documentation-helpers.ts @@ -713,15 +713,17 @@ export const depictRequestParams = ({ .filter((name) => isQueryEnabled || isPathParam(name)) .map((name) => { const depicted = walkSchema(shape[name], { - isResponse: false, rules: { ...depicters, ...customBrands }, onEach, onMissing, - serializer, - getRef, - makeRef, - path, - method, + ctx: { + isResponse: false, + serializer, + getRef, + makeRef, + path, + method, + }, }); const result = composition === "components" @@ -878,15 +880,17 @@ export const depictResponse = ({ }): ResponseObject => { const depictedSchema = excludeExamplesFromDepiction( walkSchema(schema, { - isResponse: true, rules: { ...depicters, ...customBrands }, onEach, onMissing, - serializer, - getRef, - makeRef, - path, - method, + ctx: { + isResponse: true, + serializer, + getRef, + makeRef, + path, + method, + }, }), ); const media: MediaTypeObject = { @@ -1018,15 +1022,17 @@ export const depictRequest = ({ const bodyDepiction = excludeExamplesFromDepiction( excludeParamsFromDepiction( walkSchema(schema, { - isResponse: false, rules: { ...depicters, ...customBrands }, onEach, onMissing, - serializer, - getRef, - makeRef, - path, - method, + ctx: { + isResponse: false, + serializer, + getRef, + makeRef, + path, + method, + }, }), pathParams, ), diff --git a/src/schema-walker.ts b/src/schema-walker.ts index e4dd91c6a..95053a14e 100644 --- a/src/schema-walker.ts +++ b/src/schema-walker.ts @@ -39,8 +39,9 @@ export const walkSchema = ( onEach, rules, onMissing, - ...rest - }: SchemaHandlingProps & { + ctx = {} as Context, + }: SchemaHandlingProps & { + ctx?: Context; onEach?: SchemaHandler; rules: HandlingRules; onMissing: SchemaHandler; @@ -49,9 +50,8 @@ export const walkSchema = ( const handler = rules[schema._def[metaSymbol]?.brand as keyof typeof rules] || rules[schema._def.typeName as keyof typeof rules]; - const ctx = rest as unknown as Context; const next = (subject: z.ZodTypeAny) => - walkSchema(subject, { ...ctx, onEach, rules, onMissing }); + walkSchema(subject, { ctx, onEach, rules, onMissing }); const result = handler ? handler(schema, { ...ctx, next }) : onMissing(schema, ctx); diff --git a/src/zts.ts b/src/zts.ts index 525c06bc7..fb5bcb104 100644 --- a/src/zts.ts +++ b/src/zts.ts @@ -252,8 +252,8 @@ const producers: HandlingRules = { }; export const zodToTs = (schema: z.ZodTypeAny, ctx: ZTSContext) => - walkSchema(schema, { + walkSchema(schema, { rules: producers, onMissing: () => f.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword), - ...ctx, + ctx, }); diff --git a/tests/unit/documentation-helpers.spec.ts b/tests/unit/documentation-helpers.spec.ts index 496d239fb..b73d8eb7b 100644 --- a/tests/unit/documentation-helpers.spec.ts +++ b/tests/unit/documentation-helpers.spec.ts @@ -81,9 +81,9 @@ describe("Documentation helpers", () => { const makeNext = (ctx: OpenAPIContext) => (schema: z.ZodTypeAny) => walkSchema(schema, { rules: depicters, - ...ctx, onEach, onMissing, + ctx, }); beforeEach(() => { @@ -188,9 +188,9 @@ describe("Documentation helpers", () => { .and(z.record(z.string(), z.string())), ])("should omit specified path params %#", (schema) => { const depicted = walkSchema(schema, { - ...requestCtx, - onEach, + ctx: requestCtx, rules: depicters, + onEach, onMissing, }); expect(excludeParamsFromDepiction(depicted, ["a"])).toMatchSnapshot(); From abd4034fc2e96142a63d048b87d3450cf6375d28 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 10 May 2024 21:00:53 +0200 Subject: [PATCH 04/24] REF: Easier types for Schema Walker. --- src/schema-walker.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/schema-walker.ts b/src/schema-walker.ts index 95053a14e..eafa2fdb1 100644 --- a/src/schema-walker.ts +++ b/src/schema-walker.ts @@ -11,17 +11,11 @@ interface VariantDependingProps { export type HandlingVariant = keyof VariantDependingProps; -type SchemaHandlingProps< - U, - Context extends FlatObject, - Variant extends HandlingVariant, -> = Context & VariantDependingProps[Variant]; - export type SchemaHandler< U, Context extends FlatObject = {}, Variant extends HandlingVariant = "regular", -> = (schema: any, params: SchemaHandlingProps) => U; +> = (schema: any, ctx: Context & VariantDependingProps[Variant]) => U; export type CustomBrand = string | symbol; @@ -40,7 +34,7 @@ export const walkSchema = ( rules, onMissing, ctx = {} as Context, - }: SchemaHandlingProps & { + }: { ctx?: Context; onEach?: SchemaHandler; rules: HandlingRules; From e0240c9206487433e01502ad84da8069435e2dbd Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 10 May 2024 21:13:38 +0200 Subject: [PATCH 05/24] Ref: making for HandlingRules the paramteric keys. --- src/deep-checks.ts | 2 +- src/documentation-helpers.ts | 4 +++- src/schema-walker.ts | 14 +++++--------- src/zts.ts | 7 ++++++- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/deep-checks.ts b/src/deep-checks.ts index 918ae92d8..88fad3779 100644 --- a/src/deep-checks.ts +++ b/src/deep-checks.ts @@ -25,7 +25,7 @@ const onElective: Check = ( { next }, ) => next(schema.unwrap()); -const checks: HandlingRules = { +const checks: HandlingRules = { ZodObject: ({ shape }: z.ZodObject, { next }) => Object.values(shape).some(next), ZodUnion: onSomeUnion, diff --git a/src/documentation-helpers.ts b/src/documentation-helpers.ts index fba615968..77556cc8a 100644 --- a/src/documentation-helpers.ts +++ b/src/documentation-helpers.ts @@ -64,6 +64,7 @@ import { } from "./logical-container"; import { metaSymbol } from "./metadata"; import { Method } from "./method"; +import { ProprietaryBrand } from "./proprietary-schemas"; import { RawSchema, ezRawBrand } from "./raw-schema"; import { HandlingRules, @@ -746,7 +747,8 @@ export const depictRequestParams = ({ export const depicters: HandlingRules< SchemaObject | ReferenceObject, - OpenAPIContext + OpenAPIContext, + z.ZodFirstPartyTypeKind | ProprietaryBrand > = { ZodString: depictString, ZodNumber: depictNumber, diff --git a/src/schema-walker.ts b/src/schema-walker.ts index eafa2fdb1..1821ae1c5 100644 --- a/src/schema-walker.ts +++ b/src/schema-walker.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import type { FlatObject } from "./common-helpers"; import { metaSymbol } from "./metadata"; -import { ProprietaryBrand } from "./proprietary-schemas"; interface VariantDependingProps { regular: { next: (schema: z.ZodTypeAny) => U }; @@ -17,14 +16,11 @@ export type SchemaHandler< Variant extends HandlingVariant = "regular", > = (schema: any, ctx: Context & VariantDependingProps[Variant]) => U; -export type CustomBrand = string | symbol; - -export type HandlingRules = Partial< - Record< - z.ZodFirstPartyTypeKind | ProprietaryBrand | CustomBrand, - SchemaHandler - > ->; +export type HandlingRules< + U, + Context extends FlatObject = {}, + K extends string | symbol = string | symbol, +> = Partial>>; /** @since 10.1.1 calling onEach _after_ handler and giving it the previously achieved result */ export const walkSchema = ( diff --git a/src/zts.ts b/src/zts.ts index fb5bcb104..6102c3433 100644 --- a/src/zts.ts +++ b/src/zts.ts @@ -4,6 +4,7 @@ import { hasCoercion, tryToTransform } from "./common-helpers"; import { ezDateInBrand } from "./date-in-schema"; import { ezDateOutBrand } from "./date-out-schema"; import { FileSchema, ezFileBrand } from "./file-schema"; +import { ProprietaryBrand } from "./proprietary-schemas"; import { RawSchema, ezRawBrand } from "./raw-schema"; import { HandlingRules, walkSchema } from "./schema-walker"; import { @@ -219,7 +220,11 @@ const onFile: Producer = (schema: FileSchema) => { const onRaw: Producer = (schema: RawSchema, { next }) => next(schema.unwrap().shape.raw); -const producers: HandlingRules = { +const producers: HandlingRules< + ts.TypeNode, + ZTSContext, + z.ZodFirstPartyTypeKind | ProprietaryBrand +> = { ZodString: onPrimitive(ts.SyntaxKind.StringKeyword), ZodNumber: onPrimitive(ts.SyntaxKind.NumberKeyword), ZodBigInt: onPrimitive(ts.SyntaxKind.BigIntKeyword), From 3c9294add68a7bec792e70a93ab7fcaa603350e7 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 10 May 2024 21:16:39 +0200 Subject: [PATCH 06/24] Ref: naming: brandHandling. --- src/documentation-helpers.ts | 14 +++++++------- src/documentation.ts | 6 +++--- tests/unit/documentation.spec.ts | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/documentation-helpers.ts b/src/documentation-helpers.ts index 77556cc8a..f064c051b 100644 --- a/src/documentation-helpers.ts +++ b/src/documentation-helpers.ts @@ -104,7 +104,7 @@ interface ReqResDepictHelperCommonProps mimeTypes: ReadonlyArray; composition: "inline" | "components"; description?: string; - customBrands?: HandlingRules; + brandHandling?: HandlingRules; } const shortDescriptionLimit = 50; @@ -688,7 +688,7 @@ export const depictRequestParams = ({ getRef, makeRef, composition, - customBrands, + brandHandling, description = `${method.toUpperCase()} ${path} Parameter`, }: Omit & { inputSources: InputSource[]; @@ -714,7 +714,7 @@ export const depictRequestParams = ({ .filter((name) => isQueryEnabled || isPathParam(name)) .map((name) => { const depicted = walkSchema(shape[name], { - rules: { ...depicters, ...customBrands }, + rules: { ...brandHandling, ...depicters }, onEach, onMissing, ctx: { @@ -871,7 +871,7 @@ export const depictResponse = ({ composition, hasMultipleStatusCodes, statusCode, - customBrands, + brandHandling, description = `${method.toUpperCase()} ${path} ${ucFirst(variant)} response ${ hasMultipleStatusCodes ? statusCode : "" }`.trim(), @@ -882,7 +882,7 @@ export const depictResponse = ({ }): ResponseObject => { const depictedSchema = excludeExamplesFromDepiction( walkSchema(schema, { - rules: { ...depicters, ...customBrands }, + rules: { ...brandHandling, ...depicters }, onEach, onMissing, ctx: { @@ -1017,14 +1017,14 @@ export const depictRequest = ({ getRef, makeRef, composition, - customBrands, + brandHandling, description = `${method.toUpperCase()} ${path} Request body`, }: ReqResDepictHelperCommonProps): RequestBodyObject => { const pathParams = getRoutePathParams(path); const bodyDepiction = excludeExamplesFromDepiction( excludeParamsFromDepiction( walkSchema(schema, { - rules: { ...depicters, ...customBrands }, + rules: { ...brandHandling, ...depicters }, onEach, onMissing, ctx: { diff --git a/src/documentation.ts b/src/documentation.ts index 24641b25f..4a8827af4 100644 --- a/src/documentation.ts +++ b/src/documentation.ts @@ -66,7 +66,7 @@ interface DocumentationParams { * @default JSON.stringify() + SHA1 hash as a hex digest * */ serializer?: (schema: z.ZodTypeAny) => string; - customBrands?: HandlingRules; + brandHandling?: HandlingRules; } export class Documentation extends OpenApiBuilder { @@ -135,7 +135,7 @@ export class Documentation extends OpenApiBuilder { version, serverUrl, descriptions, - customBrands, + brandHandling, hasSummaryFromDescription = true, composition = "inline", serializer = defaultSerializer, @@ -157,7 +157,7 @@ export class Documentation extends OpenApiBuilder { endpoint, composition, serializer, - customBrands, + brandHandling, getRef: this.getRef.bind(this), makeRef: this.makeRef.bind(this), }; diff --git a/tests/unit/documentation.spec.ts b/tests/unit/documentation.spec.ts index bfb4085bf..9193745cd 100644 --- a/tests/unit/documentation.spec.ts +++ b/tests/unit/documentation.spec.ts @@ -1290,7 +1290,7 @@ describe("Documentation", () => { }), }, }, - customBrands: { + brandHandling: { CUSTOM: () => ({ summary: "My custom schema", }), From ee964bb35a59a21adf5f38e67c8d2827531c173b Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 10 May 2024 21:28:14 +0200 Subject: [PATCH 07/24] Jsdoc for brandHandling in Documentation. --- src/documentation.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/documentation.ts b/src/documentation.ts index 4a8827af4..d9ac613c2 100644 --- a/src/documentation.ts +++ b/src/documentation.ts @@ -66,6 +66,12 @@ interface DocumentationParams { * @default JSON.stringify() + SHA1 hash as a hex digest * */ serializer?: (schema: z.ZodTypeAny) => string; + /** + * @desc Handling rules for your own branded types. + * @desc Keys: brands (recommended to use unique symbols). + * @desc Values: functions having schema as first argument that you should assign type to, second one is a context. + * @example { MyBrand: ( schema: typeof myBrandSchema, { next } ) => ({ type: "object" }) + */ brandHandling?: HandlingRules; } From d683a8ffdd65ea2867582b40f02e45d1d20179b0 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 10 May 2024 21:54:25 +0200 Subject: [PATCH 08/24] FEAT: Integration with brandHandling. --- src/integration.ts | 27 ++++++++++++++++++--------- src/zts.ts | 13 +++++++++++-- tests/unit/zts.spec.ts | 41 ++++++++++++++++++++--------------------- 3 files changed, 49 insertions(+), 32 deletions(-) diff --git a/src/integration.ts b/src/integration.ts index e145a0223..4edd8eaf6 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -33,8 +33,9 @@ import { contentTypes } from "./content-type"; import { loadPeer } from "./peer-helpers"; import { Routing } from "./routing"; import { walkRouting } from "./routing-walker"; +import { HandlingRules } from "./schema-walker"; import { zodToTs } from "./zts"; -import { createTypeAlias, printNode } from "./zts-helpers"; +import { ZTSContext, createTypeAlias, printNode } from "./zts-helpers"; import type Prettier from "prettier"; type IOKind = "input" | "response" | "positive" | "negative"; @@ -74,6 +75,13 @@ interface IntegrationParams { */ withUndefined?: boolean; }; + /** + * @desc Handling rules for your own branded types. + * @desc Keys: brands (recommended to use unique symbols). + * @desc Values: functions having schema as first argument that you should assign type to, second one is a context. + * @example { MyBrand: ( schema: typeof myBrandSchema, { next } ) => createKeywordTypeNode(SyntaxKind.AnyKeyword) + */ + brandHandling?: HandlingRules; } interface FormattedPrintingOptions { @@ -144,6 +152,7 @@ export class Integration { public constructor({ routing, + brandHandling, variant = "client", serializer = defaultSerializer, splitResponse = false, @@ -160,8 +169,8 @@ export class Integration { }; const inputId = makeCleanId(method, path, "input"); const input = zodToTs(endpoint.getSchema("input"), { - ...commons, - isResponse: false, + brandHandling, + ctx: { ...commons, isResponse: false }, }); const positiveResponseId = splitResponse ? makeCleanId(method, path, "positive.response") @@ -169,8 +178,8 @@ export class Integration { const positiveSchema = endpoint.getSchema("positive"); const positiveResponse = splitResponse ? zodToTs(positiveSchema, { - ...commons, - isResponse: true, + brandHandling, + ctx: { ...commons, isResponse: true }, }) : undefined; const negativeResponseId = splitResponse @@ -179,8 +188,8 @@ export class Integration { const negativeSchema = endpoint.getSchema("negative"); const negativeResponse = splitResponse ? zodToTs(negativeSchema, { - ...commons, - isResponse: true, + brandHandling, + ctx: { ...commons, isResponse: true }, }) : undefined; const genericResponseId = makeCleanId(method, path, "response"); @@ -191,8 +200,8 @@ export class Integration { f.createTypeReferenceNode(negativeResponseId), ]) : zodToTs(positiveSchema.or(negativeSchema), { - ...commons, - isResponse: true, + brandHandling, + ctx: { ...commons, isResponse: true }, }); this.program.push(createTypeAlias(input, inputId)); if (positiveResponse && positiveResponseId) { diff --git a/src/zts.ts b/src/zts.ts index 6102c3433..31c288cca 100644 --- a/src/zts.ts +++ b/src/zts.ts @@ -256,9 +256,18 @@ const producers: HandlingRules< [ezRawBrand]: onRaw, }; -export const zodToTs = (schema: z.ZodTypeAny, ctx: ZTSContext) => +export const zodToTs = ( + schema: z.ZodTypeAny, + { + brandHandling, + ctx, + }: { + brandHandling?: HandlingRules; + ctx: ZTSContext; + }, +) => walkSchema(schema, { - rules: producers, + rules: { ...brandHandling, ...producers }, onMissing: () => f.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword), ctx, }); diff --git a/tests/unit/zts.spec.ts b/tests/unit/zts.spec.ts index 478b0eb8d..74bc544de 100644 --- a/tests/unit/zts.spec.ts +++ b/tests/unit/zts.spec.ts @@ -10,7 +10,7 @@ import { describe, expect, test, vi } from "vitest"; describe("zod-to-ts", () => { const printNodeTest = (node: ts.Node) => printNode(node, { newLine: ts.NewLineKind.LineFeed }); - const defaultCtx: ZTSContext = { + const ctx: ZTSContext = { isResponse: false, getAlias: vi.fn((name: string) => f.createTypeReferenceNode(name)), makeAlias: vi.fn(), @@ -22,7 +22,7 @@ describe("zod-to-ts", () => { test("outputs correct typescript", () => { const node = zodToTs( z.object({ id: z.number(), value: z.string() }).array(), - defaultCtx, + { ctx }, ); expect(printNodeTest(node)).toMatchSnapshot(); }); @@ -30,10 +30,9 @@ describe("zod-to-ts", () => { describe("createTypeAlias()", () => { const identifier = "User"; - const node = zodToTs( - z.object({ username: z.string(), age: z.number() }), - defaultCtx, - ); + const node = zodToTs(z.object({ username: z.string(), age: z.number() }), { + ctx, + }); test("outputs correct typescript", () => { const typeAlias = createTypeAlias(node, identifier); @@ -73,7 +72,7 @@ describe("zod-to-ts", () => { { schema: z.nativeEnum(Fruit), feature: "string" }, { schema: z.nativeEnum(StringLiteral), feature: "quoted string" }, ])("handles $feature literals", ({ schema }) => { - expect(printNodeTest(zodToTs(schema, defaultCtx))).toMatchSnapshot(); + expect(printNodeTest(zodToTs(schema, { ctx }))).toMatchSnapshot(); }); }); @@ -176,7 +175,7 @@ describe("zod-to-ts", () => { }); test("should produce the expected results", () => { - const node = zodToTs(example, defaultCtx); + const node = zodToTs(example, { ctx }); expect(printNode(node)).toMatchSnapshot(); }); }); @@ -204,12 +203,12 @@ describe("zod-to-ts", () => { }); test("outputs correct typescript", () => { - const node = zodToTs(optionalStringSchema, defaultCtx); + const node = zodToTs(optionalStringSchema, { ctx }); expect(printNodeTest(node)).toMatchSnapshot(); }); test("should output `?:` and undefined union for optional properties", () => { - const node = zodToTs(objectWithOptionals, defaultCtx); + const node = zodToTs(objectWithOptionals, { ctx }); expect(printNodeTest(node)).toMatchSnapshot(); }); }); @@ -218,7 +217,7 @@ describe("zod-to-ts", () => { const nullableUsernameSchema = z.object({ username: z.string().nullable(), }); - const node = zodToTs(nullableUsernameSchema, defaultCtx); + const node = zodToTs(nullableUsernameSchema, { ctx }); test("outputs correct typescript", () => { expect(printNodeTest(node)).toMatchSnapshot(); @@ -231,7 +230,7 @@ describe("zod-to-ts", () => { "string-literal": z.string(), 5: z.number(), }); - const node = zodToTs(schema, defaultCtx); + const node = zodToTs(schema, { ctx }); expect(printNodeTest(node)).toMatchSnapshot(); }); @@ -241,7 +240,7 @@ describe("zod-to-ts", () => { name: z.string(), countryOfOrigin: z.string(), }); - const node = zodToTs(schema, defaultCtx); + const node = zodToTs(schema, { ctx }); expect(printNodeTest(node)).toMatchSnapshot(); }); @@ -257,7 +256,7 @@ describe("zod-to-ts", () => { _r: z.any(), "-r": z.undefined(), }); - const node = zodToTs(schema, defaultCtx); + const node = zodToTs(schema, { ctx }); expect(printNodeTest(node)).toMatchSnapshot(); }); @@ -266,7 +265,7 @@ describe("zod-to-ts", () => { name: z.string().describe("The name of the item"), price: z.number().describe("The price of the item"), }); - const node = zodToTs(schema, defaultCtx); + const node = zodToTs(schema, { ctx }); expect(printNodeTest(node)).toMatchSnapshot(); }); }); @@ -284,7 +283,7 @@ describe("zod-to-ts", () => { unknown: z.unknown(), never: z.never(), }); - const node = zodToTs(primitiveSchema, defaultCtx); + const node = zodToTs(primitiveSchema, { ctx }); test("outputs correct typescript", () => { expect(printNodeTest(node)).toMatchSnapshot(); @@ -297,7 +296,7 @@ describe("zod-to-ts", () => { z.object({ kind: z.literal("square"), x: z.number() }), z.object({ kind: z.literal("triangle"), x: z.number(), y: z.number() }), ]); - const node = zodToTs(shapeSchema, defaultCtx); + const node = zodToTs(shapeSchema, { ctx }); test("outputs correct typescript", () => { expect(printNodeTest(node)).toMatchSnapshot(); @@ -311,7 +310,7 @@ describe("zod-to-ts", () => { z.literal(false), z.literal(123), ])("Should produce the correct typescript %#", (schema) => { - expect(printNodeTest(zodToTs(schema, defaultCtx))).toMatchSnapshot(); + expect(printNodeTest(zodToTs(schema, { ctx }))).toMatchSnapshot(); }); }); @@ -323,14 +322,14 @@ describe("zod-to-ts", () => { ])("should produce the schema type $expected", ({ isResponse }) => { const schema = z.number().transform((num) => `${num}`); expect( - printNodeTest(zodToTs(schema, { ...defaultCtx, isResponse })), + printNodeTest(zodToTs(schema, { ctx: { ...ctx, isResponse } })), ).toMatchSnapshot(); }); test("should handle unsupported transformation in response", () => { const schema = z.number().transform((num) => () => num); expect( - printNodeTest(zodToTs(schema, { ...defaultCtx, isResponse: true })), + printNodeTest(zodToTs(schema, { ctx: { ...ctx, isResponse: true } })), ).toMatchSnapshot(); }); @@ -339,7 +338,7 @@ describe("zod-to-ts", () => { .number() .transform(() => assert.fail("this should be handled")); expect( - printNodeTest(zodToTs(schema, { ...defaultCtx, isResponse: true })), + printNodeTest(zodToTs(schema, { ctx: { ...ctx, isResponse: true } })), ).toMatchSnapshot(); }); }); From 7ba4de4b6d12ad723027f55050e3a90019308965 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 10 May 2024 22:07:36 +0200 Subject: [PATCH 09/24] Test for custom brands handling in Integration. --- .../__snapshots__/integration.spec.ts.snap | 47 +++++++++++++++++++ tests/unit/integration.spec.ts | 29 ++++++++++++ 2 files changed, 76 insertions(+) diff --git a/tests/unit/__snapshots__/integration.spec.ts.snap b/tests/unit/__snapshots__/integration.spec.ts.snap index b0302ad51..ce3521bf4 100644 --- a/tests/unit/__snapshots__/integration.spec.ts.snap +++ b/tests/unit/__snapshots__/integration.spec.ts.snap @@ -376,6 +376,53 @@ client.provide("get", "/v1/user/retrieve", { id: "10" }); " `; +exports[`Integration > Feature #1470: Custom brands > should by handled accordingly 1`] = ` +"type PostV1CustomInput = { + string: boolean; +}; + +type PostV1CustomPositiveResponse = { + status: "success"; + data: { + number: boolean; + }; +}; + +type PostV1CustomNegativeResponse = { + status: "error"; + error: { + message: string; + }; +}; + +type PostV1CustomResponse = + | PostV1CustomPositiveResponse + | PostV1CustomNegativeResponse; + +export type Path = "/v1/custom"; + +export type Method = "get" | "post" | "put" | "delete" | "patch"; + +export type MethodPath = \`\${Method} \${Path}\`; + +export interface Input extends Record { + "post /v1/custom": PostV1CustomInput; +} + +export interface PositiveResponse extends Record { + "post /v1/custom": PostV1CustomPositiveResponse; +} + +export interface NegativeResponse extends Record { + "post /v1/custom": PostV1CustomNegativeResponse; +} + +export interface Response extends Record { + "post /v1/custom": PostV1CustomResponse; +} +" +`; + exports[`Integration > Should generate a client for example API 1`] = ` "type Type2048581c137c5b2130eb860e3ae37da196dfc25b = { title: string; diff --git a/tests/unit/integration.spec.ts b/tests/unit/integration.spec.ts index a48cc8518..a2c950c5f 100644 --- a/tests/unit/integration.spec.ts +++ b/tests/unit/integration.spec.ts @@ -1,3 +1,4 @@ +import ts from "typescript"; import { z } from "zod"; import { routing } from "../../example/routing"; import { @@ -7,6 +8,7 @@ import { defaultEndpointsFactory, } from "../../src"; import { describe, expect, test, vi } from "vitest"; +import { f } from "../../src/integration-helpers"; describe("Integration", () => { test.each(["client", "types"] as const)( @@ -118,4 +120,31 @@ describe("Integration", () => { }); expect(await client.printFormatted()).toMatchSnapshot(); }); + + describe("Feature #1470: Custom brands", () => { + test("should by handled accordingly", async () => { + const client = new Integration({ + splitResponse: true, + variant: "types", + brandHandling: { + CUSTOM: () => f.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword), + }, + routing: { + v1: { + custom: defaultEndpointsFactory.build({ + method: "post", + input: z.object({ + string: z.string().brand("CUSTOM"), + }), + output: z.object({ + number: z.number().brand("CUSTOM"), + }), + handler: vi.fn(), + }), + }, + }, + }); + expect(await client.printFormatted()).toMatchSnapshot(); + }); + }); }); From 7a444bde8a7e130b00bac6f732291d73fc2dbc56 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 10 May 2024 22:26:16 +0200 Subject: [PATCH 10/24] Testing of calling next(). --- tests/unit/__snapshots__/documentation.spec.ts.snap | 10 ++++++++-- tests/unit/__snapshots__/integration.spec.ts.snap | 1 + tests/unit/documentation.spec.ts | 5 ++++- tests/unit/integration.spec.ts | 3 +++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/unit/__snapshots__/documentation.spec.ts.snap b/tests/unit/__snapshots__/documentation.spec.ts.snap index 13250e93e..0a5a0e419 100644 --- a/tests/unit/__snapshots__/documentation.spec.ts.snap +++ b/tests/unit/__snapshots__/documentation.spec.ts.snap @@ -3614,6 +3614,12 @@ paths: description: GET /v1/:name Parameter schema: summary: My custom schema + - name: regular + in: query + required: true + description: GET /v1/:name Parameter + schema: + type: boolean responses: "200": description: GET /v1/:name Positive response @@ -3628,10 +3634,10 @@ paths: data: type: object properties: - test: + number: summary: My custom schema required: - - test + - number required: - status - data diff --git a/tests/unit/__snapshots__/integration.spec.ts.snap b/tests/unit/__snapshots__/integration.spec.ts.snap index ce3521bf4..00a64c9ce 100644 --- a/tests/unit/__snapshots__/integration.spec.ts.snap +++ b/tests/unit/__snapshots__/integration.spec.ts.snap @@ -379,6 +379,7 @@ client.provide("get", "/v1/user/retrieve", { id: "10" }); exports[`Integration > Feature #1470: Custom brands > should by handled accordingly 1`] = ` "type PostV1CustomInput = { string: boolean; + regular: string; }; type PostV1CustomPositiveResponse = { diff --git a/tests/unit/documentation.spec.ts b/tests/unit/documentation.spec.ts index 9193745cd..7929b995d 100644 --- a/tests/unit/documentation.spec.ts +++ b/tests/unit/documentation.spec.ts @@ -1282,9 +1282,10 @@ describe("Documentation", () => { input: z.object({ name: z.string().brand("CUSTOM"), other: z.boolean().brand("CUSTOM"), + regular: z.boolean().brand("DEEP"), }), output: z.object({ - test: z.number().brand("CUSTOM"), + number: z.number().brand("CUSTOM"), }), handler: vi.fn(), }), @@ -1294,6 +1295,8 @@ describe("Documentation", () => { CUSTOM: () => ({ summary: "My custom schema", }), + DEEP: (schema: z.ZodBranded, { next }) => + next(schema.unwrap()), }, version: "3.4.5", title: "Testing custom brands handling", diff --git a/tests/unit/integration.spec.ts b/tests/unit/integration.spec.ts index a2c950c5f..bbbac6f46 100644 --- a/tests/unit/integration.spec.ts +++ b/tests/unit/integration.spec.ts @@ -128,6 +128,8 @@ describe("Integration", () => { variant: "types", brandHandling: { CUSTOM: () => f.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword), + DEEP: (schema: z.ZodBranded, { next }) => + next(schema.unwrap()), }, routing: { v1: { @@ -135,6 +137,7 @@ describe("Integration", () => { method: "post", input: z.object({ string: z.string().brand("CUSTOM"), + regular: z.string().brand("DEEP"), }), output: z.object({ number: z.number().brand("CUSTOM"), From 2f71bbe3adb9c3ede8b777c4a72b96e8611ed48b Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 10 May 2024 22:28:06 +0200 Subject: [PATCH 11/24] Also testing symbols. --- tests/unit/documentation.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/documentation.spec.ts b/tests/unit/documentation.spec.ts index 7929b995d..af7e1fdec 100644 --- a/tests/unit/documentation.spec.ts +++ b/tests/unit/documentation.spec.ts @@ -1273,6 +1273,7 @@ describe("Documentation", () => { describe("Feature #1470: Custom brands", () => { test("should be handled accordingly in request, response and params", () => { + const deep = Symbol("DEEP"); const spec = new Documentation({ config: sampleConfig, routing: { @@ -1282,7 +1283,7 @@ describe("Documentation", () => { input: z.object({ name: z.string().brand("CUSTOM"), other: z.boolean().brand("CUSTOM"), - regular: z.boolean().brand("DEEP"), + regular: z.boolean().brand(deep), }), output: z.object({ number: z.number().brand("CUSTOM"), @@ -1295,7 +1296,7 @@ describe("Documentation", () => { CUSTOM: () => ({ summary: "My custom schema", }), - DEEP: (schema: z.ZodBranded, { next }) => + [deep]: (schema: z.ZodBranded, { next }) => next(schema.unwrap()), }, version: "3.4.5", From 307c6056a90ba22adb8bfffdda0813b111abfb97 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Fri, 10 May 2024 22:56:58 +0200 Subject: [PATCH 12/24] Changelog: featuring 19.1.0 example. --- CHANGELOG.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92aca4903..b9e6decd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,49 @@ ## Version 19 +### v19.1.0 + +- Feature: customizable handling rules for your branded schemas in Documentation and Integration: + - You can make your schemas special by branding them using `.brand()` method; + - The distinguishes the branded schemas in runtime; + - The constructors of `Documentation` and `Integration` now accept new property `brandHandling` (object); + - Its keys should be the brands you want to handle in a special way; + - Its values are functions having your schema as the first argument and a context in the second place. + +```ts +import { z } from "zod"; +import { Documentation, Integration } from "express-zod-api"; + +const myBrand = Symbol("MamaToldMeImSpecial"); // I highly recommend using symbols for this purpose +const myBrandedSchema = z.string().brand(myBrand); + +new Documentation({ + /* config, routing, title, version */ + brandHandling: { + [myBrand]: ( + schema: typeof myBrandedSchema, // you should assign type yourself + { next, path, method, isResponse }, // handle a nested schema using next() + ) => { + const defaultResult = next(schema.unwrap()); // { type: string } + return { summary: "Special type of data" }; + }, + }, +}); + +import ts from "typescript"; +const { factory: f } = ts; + +new Integration({ + /* routing */ + brandHandling: { + [myBrand]: ( + schema: typeof myBrandedSchema, // you should assign type yourself + { next, isResponse, serializer }, // handle a nested schema using next() + ) => f.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword), + }, +}); +``` + ### v19.0.0 - **Breaking changes**: From e57f9038cfd1baa05830bd5073eef4c04155a039 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sat, 11 May 2024 09:58:50 +0200 Subject: [PATCH 13/24] Readme: listing the feature. --- CHANGELOG.md | 2 +- README.md | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9e6decd2..5aed48aff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - Feature: customizable handling rules for your branded schemas in Documentation and Integration: - You can make your schemas special by branding them using `.brand()` method; - - The distinguishes the branded schemas in runtime; + - The library distinguishes the branded schemas in runtime; - The constructors of `Documentation` and `Integration` now accept new property `brandHandling` (object); - Its keys should be the brands you want to handle in a special way; - Its values are functions having your schema as the first argument and a context in the second place. diff --git a/README.md b/README.md index c8344c9bd..b81284aae 100644 --- a/README.md +++ b/README.md @@ -1149,6 +1149,44 @@ const exampleEndpoint = defaultEndpointsFactory.build({ _See the example of the generated documentation [here](https://github.com/RobinTail/express-zod-api/blob/master/example/example.documentation.yaml)_ +## Customizable brands handling + +You can customize handling rules for your schemas in Documentation and Integration. Use the `.brand()` method on your +schema to make it special and distinguishable for the library in runtime. Using symbols is recommended for branding. +After that utilize the `brandHandling` feature of both constructors to declare your custom implementation. + +```ts +import { z } from "zod"; +import { Documentation, Integration } from "express-zod-api"; + +const myBrand = Symbol("MamaToldMeImSpecial"); +const myBrandedSchema = z.string().brand(myBrand); + +new Documentation({ + brandHandling: { + [myBrand]: ( + schema: typeof myBrandedSchema, // you should assign type yourself + { next, path, method, isResponse }, // handle a nested schema using next() + ) => { + const defaultResult = next(schema.unwrap()); // { type: string } + return { summary: "Special type of data" }; + }, + }, +}); + +import ts from "typescript"; +const { factory: f } = ts; + +new Integration({ + brandHandling: { + [myBrand]: ( + schema: typeof myBrandedSchema, // you should assign type yourself + { next, isResponse, serializer }, // handle a nested schema using next() + ) => f.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword), + }, +}); +``` + ## Tagging the endpoints When generating documentation, you may find it necessary to classify endpoints into groups. For this, the From c1d7aa564b3e3bd21f632bf7efbe3b4a019555d5 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Sat, 11 May 2024 10:01:35 +0200 Subject: [PATCH 14/24] Readme: alignment and index. --- README.md | 75 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 9c773ff6d..f84ff6411 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Start your API server with I/O schema validation and custom middlewares in minut 2. [Generating a Frontend Client](#generating-a-frontend-client) 3. [Creating a documentation](#creating-a-documentation) 4. [Tagging the endpoints](#tagging-the-endpoints) + 5. [Customizable brands handling](#customizable-brands-handling) 8. [Caveats](#caveats) 1. [Coercive schema of Zod](#coercive-schema-of-zod) 2. [Excessive properties in endpoint output](#excessive-properties-in-endpoint-output) @@ -1150,6 +1151,43 @@ const exampleEndpoint = defaultEndpointsFactory.build({ _See the example of the generated documentation [here](https://github.com/RobinTail/express-zod-api/blob/master/example/example.documentation.yaml)_ +## Tagging the endpoints + +When generating documentation, you may find it necessary to classify endpoints into groups. For this, the +possibility of tagging endpoints is provided. In order to achieve the consistency of tags across all endpoints, the +possible tags should be declared in the configuration first and another instantiation approach of the +`EndpointsFactory` is required. Consider the following example: + +```typescript +import { + createConfig, + EndpointsFactory, + defaultResultHandler, +} from "express-zod-api"; + +const config = createConfig({ + // ..., use the simple or the advanced syntax: + tags: { + users: "Everything about the users", + files: { + description: "Everything about the files processing", + url: "https://example.com", + }, + }, +}); + +// instead of defaultEndpointsFactory use the following approach: +const taggedEndpointsFactory = new EndpointsFactory({ + resultHandler: defaultResultHandler, // or use your custom one + config, // <—— supply your config here +}); + +const exampleEndpoint = taggedEndpointsFactory.build({ + // ... + tag: "users", // or tags: ["users", "files"] +}); +``` + ## Customizable brands handling You can customize handling rules for your schemas in Documentation and Integration. Use the `.brand()` method on your @@ -1188,43 +1226,6 @@ new Integration({ }); ``` -## Tagging the endpoints - -When generating documentation, you may find it necessary to classify endpoints into groups. For this, the -possibility of tagging endpoints is provided. In order to achieve the consistency of tags across all endpoints, the -possible tags should be declared in the configuration first and another instantiation approach of the -`EndpointsFactory` is required. Consider the following example: - -```typescript -import { - createConfig, - EndpointsFactory, - defaultResultHandler, -} from "express-zod-api"; - -const config = createConfig({ - // ..., use the simple or the advanced syntax: - tags: { - users: "Everything about the users", - files: { - description: "Everything about the files processing", - url: "https://example.com", - }, - }, -}); - -// instead of defaultEndpointsFactory use the following approach: -const taggedEndpointsFactory = new EndpointsFactory({ - resultHandler: defaultResultHandler, // or use your custom one - config, // <—— supply your config here -}); - -const exampleEndpoint = taggedEndpointsFactory.build({ - // ... - tag: "users", // or tags: ["users", "files"] -}); -``` - # Caveats There are some well-known issues and limitations, or third party bugs that cannot be fixed in the usual way, but you From 8cb046650d47ac6221373ac75ae12df2033d9e99 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 14 May 2024 13:10:50 +0200 Subject: [PATCH 15/24] Ref: extracting NestedSchemaLookupProps. --- src/deep-checks.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/deep-checks.ts b/src/deep-checks.ts index 88fad3779..28382c9a4 100644 --- a/src/deep-checks.ts +++ b/src/deep-checks.ts @@ -41,6 +41,13 @@ const checks: HandlingRules = { next(_def.innerType), }; +interface NestedSchemaLookupProps { + condition: (schema: z.ZodTypeAny) => boolean; + rules?: HandlingRules; + maxDepth?: number; + depth?: number; +} + /** @desc The optimized version of the schema walker for boolean checks */ export const hasNestedSchema = ( subject: z.ZodTypeAny, @@ -49,12 +56,7 @@ export const hasNestedSchema = ( rules = checks, depth = 1, maxDepth = Number.POSITIVE_INFINITY, - }: { - condition: (schema: z.ZodTypeAny) => boolean; - rules?: HandlingRules; - maxDepth?: number; - depth?: number; - }, + }: NestedSchemaLookupProps, ): boolean => { if (condition(subject)) { return true; From 7670935a1fa78093a66865d89950884af7a6c564 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 14 May 2024 13:26:41 +0200 Subject: [PATCH 16/24] Exposing Producer type. --- src/index.ts | 3 +++ tests/unit/index.spec.ts | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/src/index.ts b/src/index.ts index 5bf6ce251..52db05dd1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,9 @@ export { Integration } from "./integration"; export { ez } from "./proprietary-schemas"; +// Convenience types +export type { Producer } from "./zts-helpers"; + // Issues 952, 1182, 1269: Insufficient exports for consumer's declaration export type { MockOverrides } from "./testing"; export type { Routing } from "./routing"; diff --git a/tests/unit/index.spec.ts b/tests/unit/index.spec.ts index 9468b2ee5..860447e0a 100644 --- a/tests/unit/index.spec.ts +++ b/tests/unit/index.spec.ts @@ -1,5 +1,6 @@ import { IRouter } from "express"; import { expectType } from "tsd"; +import ts from "typescript"; import { z } from "zod"; import * as entrypoint from "../../src"; import { @@ -19,6 +20,7 @@ import { MockOverrides, OAuth2Security, OpenIdSecurity, + Producer, ResultHandlerDefinition, Routing, ServerConfig, @@ -42,6 +44,12 @@ describe("Index Entrypoint", () => { } }); + test("Convenience types should be exposed", () => { + expectType(() => + ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword), + ); + }); + test("Issue 952, 1182, 1269: should expose certain types and interfaces", () => { expectType(vi.fn()); expectType("get"); From 1511fb850a31a33adb2957f0644a4f6cbe1451b9 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 14 May 2024 14:19:06 +0200 Subject: [PATCH 17/24] Removing argument from Depicter type. --- src/documentation-helpers.ts | 25 ++++++++++++------------- src/schema-walker.ts | 3 +-- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/documentation-helpers.ts b/src/documentation-helpers.ts index f064c051b..306d7705f 100644 --- a/src/documentation-helpers.ts +++ b/src/documentation-helpers.ts @@ -66,12 +66,7 @@ import { metaSymbol } from "./metadata"; import { Method } from "./method"; import { ProprietaryBrand } from "./proprietary-schemas"; import { RawSchema, ezRawBrand } from "./raw-schema"; -import { - HandlingRules, - HandlingVariant, - SchemaHandler, - walkSchema, -} from "./schema-walker"; +import { HandlingRules, SchemaHandler, walkSchema } from "./schema-walker"; import { Security } from "./security"; import { UploadSchema, ezUploadBrand } from "./upload-schema"; @@ -89,11 +84,7 @@ export interface OpenAPIContext extends FlatObject { method: Method; } -type Depicter = SchemaHandler< - SchemaObject | ReferenceObject, - OpenAPIContext, - Variant ->; +type Depicter = SchemaHandler; interface ReqResDepictHelperCommonProps extends Pick< @@ -783,7 +774,11 @@ export const depicters: HandlingRules< [ezRawBrand]: depictRaw, }; -export const onEach: Depicter<"each"> = (schema, { isResponse, prev }) => { +export const onEach: SchemaHandler< + SchemaObject | ReferenceObject, + OpenAPIContext, + "each" +> = (schema, { isResponse, prev }) => { if (isReferenceObject(prev)) { return {}; } @@ -816,7 +811,11 @@ export const onEach: Depicter<"each"> = (schema, { isResponse, prev }) => { return result; }; -export const onMissing: Depicter<"last"> = (schema, ctx) => +export const onMissing: SchemaHandler< + SchemaObject | ReferenceObject, + OpenAPIContext, + "last" +> = (schema, ctx) => assert.fail( new DocumentationError({ message: `Zod type ${schema.constructor.name} is unsupported.`, diff --git a/src/schema-walker.ts b/src/schema-walker.ts index 1821ae1c5..e7b9ae73c 100644 --- a/src/schema-walker.ts +++ b/src/schema-walker.ts @@ -7,8 +7,7 @@ interface VariantDependingProps { each: { prev: U }; last: {}; } - -export type HandlingVariant = keyof VariantDependingProps; +type HandlingVariant = keyof VariantDependingProps; export type SchemaHandler< U, From f0836b659eeafdaa278f0eaa40eb27fcbbdba849 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 14 May 2024 14:21:39 +0200 Subject: [PATCH 18/24] Exposing type Depicter. --- src/documentation-helpers.ts | 5 ++++- src/index.ts | 1 + tests/unit/index.spec.ts | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/documentation-helpers.ts b/src/documentation-helpers.ts index 306d7705f..cd90b450c 100644 --- a/src/documentation-helpers.ts +++ b/src/documentation-helpers.ts @@ -84,7 +84,10 @@ export interface OpenAPIContext extends FlatObject { method: Method; } -type Depicter = SchemaHandler; +export type Depicter = SchemaHandler< + SchemaObject | ReferenceObject, + OpenAPIContext +>; interface ReqResDepictHelperCommonProps extends Pick< diff --git a/src/index.ts b/src/index.ts index 52db05dd1..55775e9d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,7 @@ export { Integration } from "./integration"; export { ez } from "./proprietary-schemas"; // Convenience types +export type { Depicter } from "./documentation-helpers"; export type { Producer } from "./zts-helpers"; // Issues 952, 1182, 1269: Insufficient exports for consumer's declaration diff --git a/tests/unit/index.spec.ts b/tests/unit/index.spec.ts index 860447e0a..c0ee076d5 100644 --- a/tests/unit/index.spec.ts +++ b/tests/unit/index.spec.ts @@ -11,6 +11,7 @@ import { CommonConfig, CookieSecurity, CustomHeaderSecurity, + Depicter, FlatObject, IOSchema, InputSecurity, @@ -45,6 +46,7 @@ describe("Index Entrypoint", () => { }); test("Convenience types should be exposed", () => { + expectType(() => ({ type: "number" })); expectType(() => ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword), ); From b3f93fe8a42d1203241e17c69bd116cbafb12f27 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 14 May 2024 14:36:43 +0200 Subject: [PATCH 19/24] Changelog: update on reusing Depicter and Producer. --- CHANGELOG.md | 44 +++++++++++++++++--------------- tests/unit/documentation.spec.ts | 6 +++-- tests/unit/integration.spec.ts | 6 +++-- 3 files changed, 32 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 114196b93..2622d93fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,39 +9,43 @@ - The library distinguishes the branded schemas in runtime; - The constructors of `Documentation` and `Integration` now accept new property `brandHandling` (object); - Its keys should be the brands you want to handle in a special way; - - Its values are functions having your schema as the first argument and a context in the second place. + - Its values are functions having your schema as the first argument and a context in the second place; + - In case you need to reuse a handling rule for multiple brands, use the exposed types `Depicter` and `Producer`. ```ts +import ts from "typescript"; import { z } from "zod"; -import { Documentation, Integration } from "express-zod-api"; +import { + Documentation, + Integration, + Depicter, + Producer, +} from "express-zod-api"; const myBrand = Symbol("MamaToldMeImSpecial"); // I highly recommend using symbols for this purpose const myBrandedSchema = z.string().brand(myBrand); +const ruleForDocs: Depicter = ( + schema: typeof myBrandedSchema, // you should assign type yourself + { next, path, method, isResponse }, // handle a nested schema using next() +) => { + const defaultDepiction = next(schema.unwrap()); // { type: string } + return { summary: "Special type of data" }; +}; + +const ruleForClient: Producer = ( + schema: typeof myBrandedSchema, // you should assign type yourself + { next, isResponse, serializer }, // handle a nested schema using next() +) => ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword); + new Documentation({ /* config, routing, title, version */ - brandHandling: { - [myBrand]: ( - schema: typeof myBrandedSchema, // you should assign type yourself - { next, path, method, isResponse }, // handle a nested schema using next() - ) => { - const defaultResult = next(schema.unwrap()); // { type: string } - return { summary: "Special type of data" }; - }, - }, + brandHandling: { [myBrand]: ruleForDocs }, }); -import ts from "typescript"; -const { factory: f } = ts; - new Integration({ /* routing */ - brandHandling: { - [myBrand]: ( - schema: typeof myBrandedSchema, // you should assign type yourself - { next, isResponse, serializer }, // handle a nested schema using next() - ) => f.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword), - }, + brandHandling: { [myBrand]: ruleForClient }, }); ``` diff --git a/tests/unit/documentation.spec.ts b/tests/unit/documentation.spec.ts index af7e1fdec..c5153ed6e 100644 --- a/tests/unit/documentation.spec.ts +++ b/tests/unit/documentation.spec.ts @@ -1,6 +1,7 @@ import { config as exampleConfig } from "../../example/config"; import { routing } from "../../example/routing"; import { + Depicter, Documentation, DocumentationError, EndpointsFactory, @@ -1274,6 +1275,8 @@ describe("Documentation", () => { describe("Feature #1470: Custom brands", () => { test("should be handled accordingly in request, response and params", () => { const deep = Symbol("DEEP"); + const rule: Depicter = (schema: z.ZodBranded, { next }) => + next(schema.unwrap()); const spec = new Documentation({ config: sampleConfig, routing: { @@ -1296,8 +1299,7 @@ describe("Documentation", () => { CUSTOM: () => ({ summary: "My custom schema", }), - [deep]: (schema: z.ZodBranded, { next }) => - next(schema.unwrap()), + [deep]: rule, }, version: "3.4.5", title: "Testing custom brands handling", diff --git a/tests/unit/integration.spec.ts b/tests/unit/integration.spec.ts index bbbac6f46..6f74bf31b 100644 --- a/tests/unit/integration.spec.ts +++ b/tests/unit/integration.spec.ts @@ -4,6 +4,7 @@ import { routing } from "../../example/routing"; import { EndpointsFactory, Integration, + Producer, createResultHandler, defaultEndpointsFactory, } from "../../src"; @@ -123,13 +124,14 @@ describe("Integration", () => { describe("Feature #1470: Custom brands", () => { test("should by handled accordingly", async () => { + const rule: Producer = (schema: z.ZodBranded, { next }) => + next(schema.unwrap()); const client = new Integration({ splitResponse: true, variant: "types", brandHandling: { CUSTOM: () => f.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword), - DEEP: (schema: z.ZodBranded, { next }) => - next(schema.unwrap()), + DEEP: rule, }, routing: { v1: { From 802a40ab7855fb553c59a19e0c497342efbfec0b Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 14 May 2024 17:43:51 +0200 Subject: [PATCH 20/24] Apply suggestions from code review --- src/documentation.ts | 2 +- src/integration.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/documentation.ts b/src/documentation.ts index d9ac613c2..8423e48f7 100644 --- a/src/documentation.ts +++ b/src/documentation.ts @@ -67,7 +67,7 @@ interface DocumentationParams { * */ serializer?: (schema: z.ZodTypeAny) => string; /** - * @desc Handling rules for your own branded types. + * @desc Handling rules for your own branded schemas. * @desc Keys: brands (recommended to use unique symbols). * @desc Values: functions having schema as first argument that you should assign type to, second one is a context. * @example { MyBrand: ( schema: typeof myBrandSchema, { next } ) => ({ type: "object" }) diff --git a/src/integration.ts b/src/integration.ts index 4edd8eaf6..62cc730fb 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -76,7 +76,7 @@ interface IntegrationParams { withUndefined?: boolean; }; /** - * @desc Handling rules for your own branded types. + * @desc Handling rules for your own branded schemas. * @desc Keys: brands (recommended to use unique symbols). * @desc Values: functions having schema as first argument that you should assign type to, second one is a context. * @example { MyBrand: ( schema: typeof myBrandSchema, { next } ) => createKeywordTypeNode(SyntaxKind.AnyKeyword) From cafa4aa4aa11dc006e429a5aadb32dc943226408 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 14 May 2024 19:18:40 +0200 Subject: [PATCH 21/24] Ref: shortening all documentation helper tests. --- tests/unit/documentation-helpers.spec.ts | 315 +++++------------------ 1 file changed, 71 insertions(+), 244 deletions(-) diff --git a/tests/unit/documentation-helpers.spec.ts b/tests/unit/documentation-helpers.spec.ts index b73d8eb7b..0c85f9acc 100644 --- a/tests/unit/documentation-helpers.spec.ts +++ b/tests/unit/documentation-helpers.spec.ts @@ -62,29 +62,36 @@ describe("Documentation helpers", () => { $ref: `#/components/schemas/${name}`, }), ); - const requestCtx: OpenAPIContext = { + const requestCtx = { path: "/v1/user/:id", method: "get", isResponse: false, getRef: getRefMock, makeRef: makeRefMock, serializer: defaultSerializer, - }; - const responseCtx: OpenAPIContext = { + next: (schema: z.ZodTypeAny) => + walkSchema(schema, { + rules: depicters, + onEach, + onMissing, + ctx: requestCtx, + }), + } satisfies OpenAPIContext; + const responseCtx = { path: "/v1/user/:id", method: "get", isResponse: true, getRef: getRefMock, makeRef: makeRefMock, serializer: defaultSerializer, - }; - const makeNext = (ctx: OpenAPIContext) => (schema: z.ZodTypeAny) => - walkSchema(schema, { - rules: depicters, - onEach, - onMissing, - ctx, - }); + next: (schema: z.ZodTypeAny) => + walkSchema(schema, { + rules: depicters, + onEach, + onMissing, + ctx: responseCtx, + }), + } satisfies OpenAPIContext; beforeEach(() => { getRefMock.mockClear(); @@ -219,10 +226,7 @@ describe("Documentation helpers", () => { describe("depictDefault()", () => { test("should set default property", () => { expect( - depictDefault(z.boolean().default(true), { - ...requestCtx, - next: makeNext(requestCtx), - }), + depictDefault(z.boolean().default(true), requestCtx), ).toMatchSnapshot(); }); test("Feature #1706: should override the default value by a label from metadata", () => { @@ -233,10 +237,7 @@ describe("Documentation helpers", () => { .datetime() .default(() => new Date().toISOString()) .label("Today"), - { - ...responseCtx, - next: makeNext(responseCtx), - }, + responseCtx, ), ).toMatchSnapshot(); }); @@ -245,40 +246,24 @@ describe("Documentation helpers", () => { describe("depictCatch()", () => { test("should pass next depicter", () => { expect( - depictCatch(z.boolean().catch(true), { - ...requestCtx, - next: makeNext(requestCtx), - }), + depictCatch(z.boolean().catch(true), requestCtx), ).toMatchSnapshot(); }); }); describe("depictAny()", () => { test("should set format:any", () => { - expect( - depictAny(z.any(), { - ...requestCtx, - next: makeNext(requestCtx), - }), - ).toMatchSnapshot(); + expect(depictAny(z.any(), requestCtx)).toMatchSnapshot(); }); }); describe("depictUpload()", () => { test("should set format:binary and type:string", () => { - expect( - depictUpload(ez.upload(), { - ...requestCtx, - next: makeNext(requestCtx), - }), - ).toMatchSnapshot(); + expect(depictUpload(ez.upload(), requestCtx)).toMatchSnapshot(); }); test("should throw when using in response", () => { try { - depictUpload(ez.upload(), { - ...responseCtx, - next: makeNext(responseCtx), - }); + depictUpload(ez.upload(), responseCtx); expect.fail("Should not be here"); } catch (e) { expect(e).toBeInstanceOf(DocumentationError); @@ -295,22 +280,14 @@ describe("Documentation helpers", () => { ez.file("string"), ez.file("buffer"), ])("should set type:string and format accordingly %#", (schema) => { - expect( - depictFile(schema, { - ...responseCtx, - next: makeNext(responseCtx), - }), - ).toMatchSnapshot(); + expect(depictFile(schema, responseCtx)).toMatchSnapshot(); }); }); describe("depictUnion()", () => { test("should wrap next depicters into oneOf property", () => { expect( - depictUnion(z.string().or(z.number()), { - ...requestCtx, - next: makeNext(requestCtx), - }), + depictUnion(z.string().or(z.number()), requestCtx), ).toMatchSnapshot(); }); }); @@ -326,10 +303,7 @@ describe("Documentation helpers", () => { error: z.object({ message: z.string() }), }), ]), - { - ...requestCtx, - next: makeNext(requestCtx), - }, + requestCtx, ), ).toMatchSnapshot(); }); @@ -340,10 +314,7 @@ describe("Documentation helpers", () => { expect( depictIntersection( z.object({ one: z.number() }).and(z.object({ two: z.number() })), - { - ...requestCtx, - next: makeNext(requestCtx), - }, + requestCtx, ), ).toMatchSnapshot(); }); @@ -359,10 +330,7 @@ describe("Documentation helpers", () => { .object({ test: z.object({ b: z.number() }) }) .example({ test: { b: 456 } }), ), - { - ...requestCtx, - next: makeNext(requestCtx), - }, + requestCtx, ), ).toMatchSnapshot(); }); @@ -375,10 +343,7 @@ describe("Documentation helpers", () => { .example({ one: 123 }) .and(z.object({ two: z.number() }).example({ two: 456 })) .and(z.object({ three: z.number() }).example({ three: 789 })), - { - ...requestCtx, - next: makeNext(requestCtx), - }, + requestCtx, ), ).toMatchSnapshot(); }); @@ -389,10 +354,7 @@ describe("Documentation helpers", () => { z .record(z.literal("test"), z.number()) .and(z.object({ test: z.literal(5) })), - { - ...requestCtx, - next: makeNext(requestCtx), - }, + requestCtx, ), ).toMatchSnapshot(); }); @@ -401,39 +363,24 @@ describe("Documentation helpers", () => { z.record(z.string(), z.number()).and(z.object({ test: z.number() })), // has additionalProperties z.number().and(z.literal(5)), // not objects ])("should fall back to allOf in other cases %#", (schema) => { - expect( - depictIntersection(schema, { - ...requestCtx, - next: makeNext(requestCtx), - }), - ).toMatchSnapshot(); + expect(depictIntersection(schema, requestCtx)).toMatchSnapshot(); }); }); describe("depictOptional()", () => { - test.each([requestCtx, responseCtx])( + test.each([requestCtx, responseCtx])( "should pass the next depicter %#", (ctx) => { - expect( - depictOptional(z.string().optional(), { - ...ctx, - next: makeNext(ctx), - }), - ).toMatchSnapshot(); + expect(depictOptional(z.string().optional(), ctx)).toMatchSnapshot(); }, ); }); describe("depictNullable()", () => { - test.each([requestCtx, responseCtx])( + test.each([requestCtx, responseCtx])( "should add null to the type %#", (ctx) => { - expect( - depictNullable(z.string().nullable(), { - ...ctx, - next: makeNext(ctx), - }), - ).toMatchSnapshot(); + expect(depictNullable(z.string().nullable(), ctx)).toMatchSnapshot(); }, ); @@ -442,12 +389,7 @@ describe("Documentation helpers", () => { z.null().nullable(), z.string().nullable().nullable(), ])("should only add null type once %#", (schema) => { - expect( - depictNullable(schema, { - ...requestCtx, - next: makeNext(requestCtx), - }), - ).toMatchSnapshot(); + expect(depictNullable(schema, requestCtx)).toMatchSnapshot(); }); }); @@ -459,12 +401,7 @@ describe("Documentation helpers", () => { test.each([z.enum(["one", "two"]), z.nativeEnum(Test)])( "should set type and enum properties", (schema) => { - expect( - depictEnum(schema, { - ...requestCtx, - next: makeNext(requestCtx), - }), - ).toMatchSnapshot(); + expect(depictEnum(schema, requestCtx)).toMatchSnapshot(); }, ); }); @@ -473,18 +410,13 @@ describe("Documentation helpers", () => { test.each(["testng", null, BigInt(123), Symbol("test")])( "should set type and involve const property %#", (value) => { - expect( - depictLiteral(z.literal(value), { - ...requestCtx, - next: makeNext(requestCtx), - }), - ).toMatchSnapshot(); + expect(depictLiteral(z.literal(value), requestCtx)).toMatchSnapshot(); }, ); }); describe("depictObject()", () => { - test.each<{ ctx: OpenAPIContext; shape: z.ZodRawShape }>([ + test.each([ { ctx: requestCtx, shape: { a: z.number(), b: z.string() } }, { ctx: responseCtx, shape: { a: z.number(), b: z.string() } }, { @@ -499,12 +431,7 @@ describe("Documentation helpers", () => { ])( "should type:object, properties and required props %#", ({ shape, ctx }) => { - expect( - depictObject(z.object(shape), { - ...ctx, - next: makeNext(ctx), - }), - ).toMatchSnapshot(); + expect(depictObject(z.object(shape), ctx)).toMatchSnapshot(); }, ); @@ -514,45 +441,25 @@ describe("Documentation helpers", () => { b: z.coerce.string(), c: z.coerce.string().optional(), }); - expect( - depictObject(schema, { - ...responseCtx, - next: makeNext(responseCtx), - }), - ).toMatchSnapshot(); + expect(depictObject(schema, responseCtx)).toMatchSnapshot(); }); }); describe("depictNull()", () => { test("should give type:null", () => { - expect( - depictNull(z.null(), { - ...requestCtx, - next: makeNext(requestCtx), - }), - ).toMatchSnapshot(); + expect(depictNull(z.null(), requestCtx)).toMatchSnapshot(); }); }); describe("depictBoolean()", () => { test("should set type:boolean", () => { - expect( - depictBoolean(z.boolean(), { - ...requestCtx, - next: makeNext(requestCtx), - }), - ).toMatchSnapshot(); + expect(depictBoolean(z.boolean(), requestCtx)).toMatchSnapshot(); }); }); describe("depictBigInt()", () => { test("should set type:integer and format:bigint", () => { - expect( - depictBigInt(z.bigint(), { - ...requestCtx, - next: makeNext(requestCtx), - }), - ).toMatchSnapshot(); + expect(depictBigInt(z.bigint(), requestCtx)).toMatchSnapshot(); }); }); @@ -567,62 +474,39 @@ describe("Documentation helpers", () => { ])( "should set properties+required or additionalProperties props %#", (schema) => { - expect( - depictRecord(schema, { - ...requestCtx, - next: makeNext(requestCtx), - }), - ).toMatchSnapshot(); + expect(depictRecord(schema, requestCtx)).toMatchSnapshot(); }, ); }); describe("depictArray()", () => { test("should set type:array and pass items depiction", () => { - expect( - depictArray(z.array(z.boolean()), { - ...requestCtx, - next: makeNext(requestCtx), - }), - ).toMatchSnapshot(); + expect(depictArray(z.array(z.boolean()), requestCtx)).toMatchSnapshot(); }); }); describe("depictTuple()", () => { test("should utilize prefixItems and set items:not:{}", () => { expect( - depictTuple(z.tuple([z.boolean(), z.string(), z.literal("test")]), { - ...requestCtx, - next: makeNext(requestCtx), - }), + depictTuple( + z.tuple([z.boolean(), z.string(), z.literal("test")]), + requestCtx, + ), ).toMatchSnapshot(); }); test("should depict rest as items when defined", () => { expect( - depictTuple(z.tuple([z.boolean()]).rest(z.string()), { - ...requestCtx, - next: makeNext(requestCtx), - }), + depictTuple(z.tuple([z.boolean()]).rest(z.string()), requestCtx), ).toMatchSnapshot(); }); test("should depict empty tuples as is", () => { - expect( - depictTuple(z.tuple([]), { - ...requestCtx, - next: makeNext(requestCtx), - }), - ).toMatchSnapshot(); + expect(depictTuple(z.tuple([]), requestCtx)).toMatchSnapshot(); }); }); describe("depictString()", () => { test("should set type:string", () => { - expect( - depictString(z.string(), { - ...requestCtx, - next: makeNext(requestCtx), - }), - ).toMatchSnapshot(); + expect(depictString(z.string(), requestCtx)).toMatchSnapshot(); }); test.each([ @@ -634,12 +518,7 @@ describe("Documentation helpers", () => { z.string().datetime({ offset: true }), z.string().regex(/^\d+.\d+.\d+$/), ])("should set format, pattern and min/maxLength props %#", (schema) => { - expect( - depictString(schema, { - ...requestCtx, - next: makeNext(requestCtx), - }), - ).toMatchSnapshot(); + expect(depictString(schema, requestCtx)).toMatchSnapshot(); }); }); @@ -647,12 +526,7 @@ describe("Documentation helpers", () => { test.each([z.number(), z.number().int().min(10).max(20)])( "should type:number, min/max, format and exclusiveness props", (schema) => { - expect( - depictNumber(schema, { - ...requestCtx, - next: makeNext(requestCtx), - }), - ).toMatchSnapshot(); + expect(depictNumber(schema, requestCtx)).toMatchSnapshot(); }, ); }); @@ -665,18 +539,14 @@ describe("Documentation helpers", () => { one: z.string(), two: z.boolean(), }), - makeNext(requestCtx), + requestCtx.next, ), ).toMatchSnapshot(); }); }); describe("depictEffect()", () => { - test.each<{ - ctx: OpenAPIContext; - schema: z.ZodEffects; - expected: string; - }>([ + test.each([ { schema: z.string().transform((v) => parseInt(v, 10)), ctx: responseCtx, @@ -700,37 +570,24 @@ describe("Documentation helpers", () => { expected: "object (refinement)", }, ])("should depict as $expected", ({ schema, ctx }) => { - expect( - depictEffect(schema, { - ...ctx, - next: makeNext(ctx), - }), - ).toMatchSnapshot(); + expect(depictEffect(schema, ctx)).toMatchSnapshot(); }); test.each([ z.number().transform((num) => () => num), z.number().transform(() => assert.fail("this should be handled")), ])("should handle edge cases", (schema) => { - expect( - depictEffect(schema, { - ...responseCtx, - next: makeNext(responseCtx), - }), - ).toMatchSnapshot(); + expect(depictEffect(schema, responseCtx)).toMatchSnapshot(); }); }); describe("depictPipeline", () => { - test.each<{ ctx: OpenAPIContext; expected: string }>([ + test.each([ { ctx: responseCtx, expected: "boolean (out)" }, { ctx: requestCtx, expected: "string (in)" }, ])("should depict as $expected", ({ ctx }) => { expect( - depictPipeline(z.string().pipe(z.coerce.boolean()), { - ...ctx, - next: makeNext(ctx), - }), + depictPipeline(z.string().pipe(z.coerce.boolean()), ctx), ).toMatchSnapshot(); }); }); @@ -864,19 +721,11 @@ describe("Documentation helpers", () => { describe("depictDateIn", () => { test("should set type:string, pattern and format", () => { - expect( - depictDateIn(ez.dateIn(), { - ...requestCtx, - next: makeNext(requestCtx), - }), - ).toMatchSnapshot(); + expect(depictDateIn(ez.dateIn(), requestCtx)).toMatchSnapshot(); }); test("should throw when ZodDateIn in response", () => { try { - depictDateIn(ez.dateIn(), { - ...responseCtx, - next: makeNext(responseCtx), - }); + depictDateIn(ez.dateIn(), responseCtx); expect.fail("should not be here"); } catch (e) { expect(e).toBeInstanceOf(DocumentationError); @@ -887,19 +736,11 @@ describe("Documentation helpers", () => { describe("depictDateOut", () => { test("should set type:string, description and format", () => { - expect( - depictDateOut(ez.dateOut(), { - ...responseCtx, - next: makeNext(responseCtx), - }), - ).toMatchSnapshot(); + expect(depictDateOut(ez.dateOut(), responseCtx)).toMatchSnapshot(); }); test("should throw when ZodDateOut in request", () => { try { - depictDateOut(ez.dateOut(), { - ...requestCtx, - next: makeNext(requestCtx), - }); + depictDateOut(ez.dateOut(), requestCtx); expect.fail("should not be here"); } catch (e) { expect(e).toBeInstanceOf(DocumentationError); @@ -909,14 +750,11 @@ describe("Documentation helpers", () => { }); describe("depictDate", () => { - test.each([responseCtx, requestCtx])( + test.each([responseCtx, requestCtx])( "should throw clear error %#", (ctx) => { try { - depictDate(z.date(), { - ...ctx, - next: makeNext(ctx), - }); + depictDate(z.date(), ctx); expect.fail("should not be here"); } catch (e) { expect(e).toBeInstanceOf(DocumentationError); @@ -929,10 +767,7 @@ describe("Documentation helpers", () => { describe("depictBranded", () => { test("should pass the next depicter", () => { expect( - depictBranded(z.string().min(2).brand<"Test">(), { - ...responseCtx, - next: makeNext(responseCtx), - }), + depictBranded(z.string().min(2).brand("Test"), responseCtx), ).toMatchSnapshot(); }); }); @@ -940,10 +775,7 @@ describe("Documentation helpers", () => { describe("depictReadonly", () => { test("should pass the next depicter", () => { expect( - depictReadonly(z.string().readonly(), { - ...responseCtx, - next: makeNext(responseCtx), - }), + depictReadonly(z.string().readonly(), responseCtx), ).toMatchSnapshot(); }); }); @@ -979,12 +811,7 @@ describe("Documentation helpers", () => { }), ); expect(getRefMock.mock.calls.length).toBe(0); - expect( - depictLazy(schema, { - ...responseCtx, - next: makeNext(responseCtx), - }), - ).toMatchSnapshot(); + expect(depictLazy(schema, responseCtx)).toMatchSnapshot(); expect(getRefMock).toHaveBeenCalledTimes(2); for (const call of getRefMock.mock.calls) { expect(call[0]).toBe(hash); From 11c28407547db3b5f404e89eed0760f3bb677f7a Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 14 May 2024 19:24:52 +0200 Subject: [PATCH 22/24] Ref: less imports in test. --- tests/unit/integration.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/integration.spec.ts b/tests/unit/integration.spec.ts index 6f74bf31b..8a927e042 100644 --- a/tests/unit/integration.spec.ts +++ b/tests/unit/integration.spec.ts @@ -9,7 +9,6 @@ import { defaultEndpointsFactory, } from "../../src"; import { describe, expect, test, vi } from "vitest"; -import { f } from "../../src/integration-helpers"; describe("Integration", () => { test.each(["client", "types"] as const)( @@ -130,7 +129,8 @@ describe("Integration", () => { splitResponse: true, variant: "types", brandHandling: { - CUSTOM: () => f.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword), + CUSTOM: () => + ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword), DEEP: rule, }, routing: { From 18bf02fd4b140cb5096d157339eb7fcd28806923 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 14 May 2024 19:29:47 +0200 Subject: [PATCH 23/24] Changelog: mentioning that brands in runtime is the plugin feature. --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2622d93fd..0ad39157d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - Feature: customizable handling rules for your branded schemas in Documentation and Integration: - You can make your schemas special by branding them using `.brand()` method; - - The library distinguishes the branded schemas in runtime; + - The library (being a Zod Plugin as well) distinguishes the branded schemas in runtime; - The constructors of `Documentation` and `Integration` now accept new property `brandHandling` (object); - Its keys should be the brands you want to handle in a special way; - Its values are functions having your schema as the first argument and a context in the second place; @@ -22,7 +22,7 @@ import { Producer, } from "express-zod-api"; -const myBrand = Symbol("MamaToldMeImSpecial"); // I highly recommend using symbols for this purpose +const myBrand = Symbol("MamaToldMeImSpecial"); // I recommend to use symbols for this purpose const myBrandedSchema = z.string().brand(myBrand); const ruleForDocs: Depicter = ( From 3c93150459e03aa561a544e2d08d93be684a0859 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 14 May 2024 19:38:33 +0200 Subject: [PATCH 24/24] Readme: copy from changelog. --- README.md | 48 +++++++++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 55197bddb..0b36c6ad3 100644 --- a/README.md +++ b/README.md @@ -1175,37 +1175,43 @@ const exampleEndpoint = taggedEndpointsFactory.build({ You can customize handling rules for your schemas in Documentation and Integration. Use the `.brand()` method on your schema to make it special and distinguishable for the library in runtime. Using symbols is recommended for branding. -After that utilize the `brandHandling` feature of both constructors to declare your custom implementation. +After that utilize the `brandHandling` feature of both constructors to declare your custom implementation. In case you +need to reuse a handling rule for multiple brands, use the exposed types `Depicter` and `Producer`. ```ts +import ts from "typescript"; import { z } from "zod"; -import { Documentation, Integration } from "express-zod-api"; +import { + Documentation, + Integration, + Depicter, + Producer, +} from "express-zod-api"; -const myBrand = Symbol("MamaToldMeImSpecial"); +const myBrand = Symbol("MamaToldMeImSpecial"); // I recommend to use symbols for this purpose const myBrandedSchema = z.string().brand(myBrand); +const ruleForDocs: Depicter = ( + schema: typeof myBrandedSchema, // you should assign type yourself + { next, path, method, isResponse }, // handle a nested schema using next() +) => { + const defaultDepiction = next(schema.unwrap()); // { type: string } + return { summary: "Special type of data" }; +}; + +const ruleForClient: Producer = ( + schema: typeof myBrandedSchema, // you should assign type yourself + { next, isResponse, serializer }, // handle a nested schema using next() +) => ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword); + new Documentation({ - brandHandling: { - [myBrand]: ( - schema: typeof myBrandedSchema, // you should assign type yourself - { next, path, method, isResponse }, // handle a nested schema using next() - ) => { - const defaultResult = next(schema.unwrap()); // { type: string } - return { summary: "Special type of data" }; - }, - }, + /* config, routing, title, version */ + brandHandling: { [myBrand]: ruleForDocs }, }); -import ts from "typescript"; -const { factory: f } = ts; - new Integration({ - brandHandling: { - [myBrand]: ( - schema: typeof myBrandedSchema, // you should assign type yourself - { next, isResponse, serializer }, // handle a nested schema using next() - ) => f.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword), - }, + /* routing */ + brandHandling: { [myBrand]: ruleForClient }, }); ```