From 7cc1f3e05937c614df1d76e42d65db656e8335bd Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 29 Apr 2025 20:23:11 +0200 Subject: [PATCH 01/35] POC: detaching Diagnostics from extractObjectSchema by utilizing toJSONSchema() and a props collecting traverse. --- express-zod-api/src/diagnostics.ts | 17 ++++------- express-zod-api/src/io-schema.ts | 42 +++++++++++++++++++++++++++ express-zod-api/tests/routing.spec.ts | 8 +---- 3 files changed, 48 insertions(+), 19 deletions(-) diff --git a/express-zod-api/src/diagnostics.ts b/express-zod-api/src/diagnostics.ts index 245989149..9b7ec7dc3 100644 --- a/express-zod-api/src/diagnostics.ts +++ b/express-zod-api/src/diagnostics.ts @@ -1,19 +1,17 @@ -import type { $ZodShape } from "@zod/core"; -import * as R from "ramda"; import { z } from "zod"; import { responseVariants } from "./api-response"; import { FlatObject, getRoutePathParams } from "./common-helpers"; import { contentTypes } from "./content-type"; import { findJsonIncompatible } from "./deep-checks"; import { AbstractEndpoint } from "./endpoint"; -import { extractObjectSchema } from "./io-schema"; +import { extract2 } from "./io-schema"; import { ActualLogger } from "./logger-helpers"; export class Diagnostics { #verifiedEndpoints = new WeakSet(); #verifiedPaths = new WeakMap< AbstractEndpoint, - { shape: $ZodShape; paths: string[] } + { flat: ReturnType; paths: string[] } >(); constructor(protected logger: ActualLogger) {} @@ -65,20 +63,15 @@ export class Diagnostics { if (ref?.paths.includes(path)) return; const params = getRoutePathParams(path); if (params.length === 0) return; // next statement can be expensive - const { shape } = - ref || - R.tryCatch(extractObjectSchema, (err) => { - this.logger.warn("Diagnostics::checkPathParams()", err); - return z.object({}); - })(endpoint.inputSchema); + const flat = ref?.flat || extract2(endpoint.inputSchema); for (const param of params) { - if (param in shape) continue; + if (param in flat) continue; this.logger.warn( "The input schema of the endpoint is most likely missing the parameter of the path it's assigned to.", Object.assign(ctx, { path, param }), ); } if (ref) ref.paths.push(path); - else this.#verifiedPaths.set(endpoint, { shape, paths: [path] }); + else this.#verifiedPaths.set(endpoint, { flat, paths: [path] }); } } diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index a9130e0c4..b17a729bd 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -1,3 +1,4 @@ +import type { JSONSchema } from "@zod/core"; import * as R from "ramda"; import { z } from "zod"; import { IOSchemaError } from "./errors"; @@ -60,3 +61,44 @@ export const extractObjectSchema = (subject: IOSchema): z.ZodObject => { } throw new IOSchemaError("Can not flatten IOSchema", { cause: subject }); }; + +const isJsonObjectSchema = ( + subject: JSONSchema.BaseSchema, +): subject is JSONSchema.ObjectSchema => subject.type === "object"; + +export const extract2 = (subject: IOSchema) => { + const jsonSchema = z.toJSONSchema(subject, { + unrepresentable: "any", + io: "input", + }); + const stack = [jsonSchema]; + const props: Record = {}; + while (stack.length) { + const entry = stack.shift()!; + if (isJsonObjectSchema(entry)) { + if (entry.properties) Object.assign(props, entry.properties); + if (entry.propertyNames) { + const keys: string[] = []; + if (typeof entry.propertyNames.const === "string") + keys.push(entry.propertyNames.const); + if (entry.propertyNames.enum) { + keys.push( + ...entry.propertyNames.enum.filter( + (one) => typeof one === "string", + ), + ); + } + const value = + typeof entry.additionalProperties === "object" + ? entry.additionalProperties + : {}; + for (const key of keys) props[key] = value; + } + } + if (entry.allOf) stack.push(...entry.allOf); + if (entry.anyOf) stack.push(...entry.anyOf); + if (entry.oneOf) stack.push(...entry.oneOf); + if (entry.not) stack.push(entry.not); + } + return props; +}; diff --git a/express-zod-api/tests/routing.spec.ts b/express-zod-api/tests/routing.spec.ts index 88ee2defb..23e1ee355 100644 --- a/express-zod-api/tests/routing.spec.ts +++ b/express-zod-api/tests/routing.spec.ts @@ -464,7 +464,7 @@ describe("Routing", () => { test.each([ z.object({ id: z.string() }), - z.record(z.literal("id"), z.string()), // @todo should support records as an IOSchema compliant one + z.record(z.literal("id"), z.string()), ])("should warn about unused path params %#", (input) => { const endpoint = new EndpointsFactory(defaultResultHandler).build({ input, @@ -479,12 +479,6 @@ describe("Routing", () => { config: configMock as CommonConfig, routing: { v1: { ":idx": endpoint } }, }); - if (input instanceof z.ZodRecord) { - expect(logger._getLogs().warn).toContainEqual([ - "Diagnostics::checkPathParams()", - expect.any(IOSchemaError), - ]); - } expect(logger._getLogs().warn).toContainEqual([ "The input schema of the endpoint is most likely missing the parameter of the path it's assigned to.", { method: "get", param: "idx", path: "/v1/:idx" }, From d1dce7b076753ae1328a7fc5d764b80058b9c740 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 29 Apr 2025 20:26:50 +0200 Subject: [PATCH 02/35] rm redundant import. --- express-zod-api/tests/routing.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/express-zod-api/tests/routing.spec.ts b/express-zod-api/tests/routing.spec.ts index 23e1ee355..5cb0e5d51 100644 --- a/express-zod-api/tests/routing.spec.ts +++ b/express-zod-api/tests/routing.spec.ts @@ -1,4 +1,3 @@ -import { IOSchemaError } from "../src/errors"; import { appMock, expressMock, From 70e4a1439219d4e6430c00ebbfc6ca1aa7f6c08e Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 29 Apr 2025 21:13:52 +0200 Subject: [PATCH 03/35] Making the new implementation to accept JSON Schema, so that it can be used by Documentation. --- express-zod-api/src/diagnostics.ts | 9 ++++++++- express-zod-api/src/io-schema.ts | 6 +----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/express-zod-api/src/diagnostics.ts b/express-zod-api/src/diagnostics.ts index 9b7ec7dc3..c253c5dea 100644 --- a/express-zod-api/src/diagnostics.ts +++ b/express-zod-api/src/diagnostics.ts @@ -63,7 +63,14 @@ export class Diagnostics { if (ref?.paths.includes(path)) return; const params = getRoutePathParams(path); if (params.length === 0) return; // next statement can be expensive - const flat = ref?.flat || extract2(endpoint.inputSchema); + const flat = + ref?.flat || + extract2( + z.toJSONSchema(endpoint.inputSchema, { + unrepresentable: "any", + io: "input", + }), + ); for (const param of params) { if (param in flat) continue; this.logger.warn( diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index b17a729bd..8b9c032c9 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -66,11 +66,7 @@ const isJsonObjectSchema = ( subject: JSONSchema.BaseSchema, ): subject is JSONSchema.ObjectSchema => subject.type === "object"; -export const extract2 = (subject: IOSchema) => { - const jsonSchema = z.toJSONSchema(subject, { - unrepresentable: "any", - io: "input", - }); +export const extract2 = (jsonSchema: JSONSchema.BaseSchema) => { const stack = [jsonSchema]; const props: Record = {}; while (stack.length) { From 9127977320ca8e8ec24b3ea35dc12e7e6a8f280b Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 29 Apr 2025 21:14:47 +0200 Subject: [PATCH 04/35] Using the new implementation by depictRequestParams(), several things should be fixed and improved. --- express-zod-api/src/documentation-helpers.ts | 29 +++++--- .../documentation-helpers.spec.ts.snap | 14 ++-- .../__snapshots__/documentation.spec.ts.snap | 73 +++++++++++-------- 3 files changed, 65 insertions(+), 51 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index ccee20d85..b72a1b93c 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -44,7 +44,7 @@ import { ezDateOutBrand } from "./date-out-schema"; import { contentTypes } from "./content-type"; import { DocumentationError } from "./errors"; import { ezFileBrand } from "./file-schema"; -import { extractObjectSchema, IOSchema } from "./io-schema"; +import { extract2, extractObjectSchema, IOSchema } from "./io-schema"; import { Alternatives } from "./logical-container"; import { metaSymbol } from "./metadata"; import { Method } from "./method"; @@ -404,7 +404,12 @@ export const depictRequestParams = ({ isHeader?: IsHeader; security?: Alternatives; }) => { - const objectSchema = extractObjectSchema(schema); + const flat = extract2( + depict(schema, { + rules: { ...brandHandling, ...depicters }, + ctx: { isResponse: false, makeRef, path, method }, + }) as JSONSchema.BaseSchema, // @todo fix this, consider detaching it from ensureCompliance + ); const pathParams = getRoutePathParams(path); const isQueryEnabled = inputSources.includes("query"); const areParamsEnabled = inputSources.includes("params"); @@ -419,8 +424,8 @@ export const depictRequestParams = ({ areHeadersEnabled && (isHeader?.(name, method, path) ?? defaultIsHeader(name, securityHeaders)); - return Object.entries(objectSchema.shape).reduce( - (acc, [name, paramSchema]) => { + return Object.entries(flat).reduce( + (acc, [name, jsonSchema]) => { const location = isPathParam(name) ? "path" : isHeaderParam(name) @@ -429,22 +434,22 @@ export const depictRequestParams = ({ ? "query" : undefined; if (!location) return acc; - const depicted = depict(paramSchema, { - rules: { ...brandHandling, ...depicters }, - ctx: { isResponse: false, makeRef, path, method }, - }); + const depicted = ensureCompliance(jsonSchema); const result = composition === "components" - ? makeRef(paramSchema, depicted, makeCleanId(description, name)) + ? makeRef(jsonSchema, depicted, makeCleanId(description, name)) : depicted; return acc.concat({ name, in: location, - deprecated: globalRegistry.get(paramSchema)?.deprecated, - required: !doesAccept(paramSchema, undefined), + deprecated: jsonSchema.deprecated, + required: false, // @todo support it by extractor description: depicted.description || description, schema: result, - examples: depictParamExamples(objectSchema, name), + examples: + isSchemaObject(depicted) && depicted.examples + ? enumerateExamples(depicted.examples) + : undefined, }); }, [], diff --git a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap index 91089c014..df7e6029b 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -291,7 +291,7 @@ exports[`Documentation helpers > depictRequestParams() > Features 1180 and 2344: "examples": undefined, "in": "header", "name": "x-request-id", - "required": true, + "required": false, "schema": { "type": "string", }, @@ -302,7 +302,7 @@ exports[`Documentation helpers > depictRequestParams() > Features 1180 and 2344: "examples": undefined, "in": "path", "name": "id", - "required": true, + "required": false, "schema": { "type": "string", }, @@ -313,7 +313,7 @@ exports[`Documentation helpers > depictRequestParams() > Features 1180 and 2344: "examples": undefined, "in": "query", "name": "test", - "required": true, + "required": false, "schema": { "type": "boolean", }, @@ -324,7 +324,7 @@ exports[`Documentation helpers > depictRequestParams() > Features 1180 and 2344: "examples": undefined, "in": "header", "name": "secure", - "required": true, + "required": false, "schema": { "type": "string", }, @@ -342,7 +342,7 @@ exports[`Documentation helpers > depictRequestParams() > should depict only path "examples": undefined, "in": "path", "name": "id", - "required": true, + "required": false, "schema": { "type": "string", }, @@ -358,7 +358,7 @@ exports[`Documentation helpers > depictRequestParams() > should depict query and "examples": undefined, "in": "path", "name": "id", - "required": true, + "required": false, "schema": { "type": "string", }, @@ -369,7 +369,7 @@ exports[`Documentation helpers > depictRequestParams() > should depict query and "examples": undefined, "in": "query", "name": "test", - "required": true, + "required": false, "schema": { "type": "boolean", }, diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index c3d08b78d..ef4506753 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -330,13 +330,13 @@ paths: parameters: - name: key in: query - required: true + required: false description: GET /v1/getSomething Parameter schema: type: string - name: str in: query - required: true + required: false description: GET /v1/getSomething Parameter schema: type: string @@ -625,7 +625,7 @@ paths: parameters: - name: array in: query - required: true + required: false description: GET /v1/getSomething Parameter schema: type: array @@ -636,7 +636,7 @@ paths: exclusiveMaximum: 9007199254740991 - name: unlimited in: query - required: true + required: false description: GET /v1/getSomething Parameter schema: type: array @@ -644,7 +644,7 @@ paths: type: boolean - name: transformer in: query - required: true + required: false description: GET /v1/getSomething Parameter schema: type: string @@ -1291,7 +1291,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Schema2" + $ref: "#/components/schemas/Schema3" responses: "200": description: POST /v1/getSomething Positive response @@ -1307,7 +1307,7 @@ paths: type: object properties: zodExample: - $ref: "#/components/schemas/Schema1" + $ref: "#/components/schemas/Schema2" required: - zodExample required: @@ -1365,6 +1365,18 @@ components: required: - name - subcategories + Schema3: + type: object + properties: + name: + type: string + subcategories: + type: array + items: + $ref: "#/components/schemas/Schema3" + required: + - name + - subcategories responses: {} parameters: {} examples: {} @@ -2218,7 +2230,7 @@ paths: format: string (preprocessed) - name: number in: query - required: true + required: false description: GET /v1/getSomething Parameter schema: maximum: 9007199254740991 @@ -2390,19 +2402,19 @@ paths: parameters: - name: name in: path - required: true + required: false description: GET /v1/:name Parameter schema: summary: My custom schema - name: other in: query - required: true + required: false description: GET /v1/:name Parameter schema: summary: My custom schema - name: regular in: query - required: true + required: false description: GET /v1/:name Parameter schema: type: boolean @@ -2481,13 +2493,13 @@ paths: parameters: - name: user_id in: query - required: true + required: false description: GET /v1/test Parameter schema: type: string - name: at in: query - required: true + required: false description: YYYY-MM-DDTHH:mm:ss.sssZ schema: type: string @@ -2571,7 +2583,7 @@ paths: parameters: - name: user_id in: query - required: true + required: false description: GET /v1/test Parameter schema: type: string @@ -2650,13 +2662,13 @@ paths: parameters: - name: id in: query - required: true + required: false description: GET /v1/test Parameter schema: type: string - name: x-request-id in: header - required: true + required: false description: GET /v1/test Parameter schema: type: string @@ -2732,13 +2744,13 @@ paths: parameters: - name: id in: query - required: true + required: false description: POST /v1/test Parameter schema: type: string - name: x-request-id in: header - required: true + required: false description: POST /v1/test Parameter schema: type: string @@ -2822,7 +2834,7 @@ paths: parameters: - name: x-request-id in: header - required: true + required: false description: PUT /v1/test Parameter schema: type: string @@ -2910,7 +2922,7 @@ paths: parameters: - name: arr in: query - required: true + required: false description: GET /v1/getSomething Parameter schema: type: array @@ -3174,7 +3186,7 @@ paths: - name: str in: query deprecated: true - required: true + required: false description: GET /v1/getSomething Parameter schema: type: string @@ -3540,13 +3552,10 @@ paths: parameters: - name: strNum in: query - required: true + required: false description: GET /v1/getSomething Parameter schema: type: string - examples: - example1: - value: "123" responses: "200": description: GET /v1/getSomething Positive response @@ -3726,7 +3735,7 @@ paths: parameters: - name: strNum in: query - required: true + required: false description: GET /v1/getSomething Parameter schema: type: string @@ -3818,7 +3827,7 @@ paths: parameters: - name: str in: query - required: true + required: false description: here is the test schema: type: string @@ -3900,7 +3909,7 @@ paths: parameters: - name: name in: path - required: true + required: false description: parameter of post /v1/:name schema: anyOf: @@ -3992,7 +4001,7 @@ paths: parameters: - name: name in: path - required: true + required: false description: parameter of post /v1/:name schema: $ref: "#/components/schemas/ParameterOfPostV1NameName" @@ -4092,7 +4101,7 @@ paths: parameters: - name: name in: path - required: true + required: false description: GET /v1/:name Parameter schema: anyOf: @@ -4102,7 +4111,7 @@ paths: const: Jane - name: other in: query - required: true + required: false description: GET /v1/:name Parameter schema: type: boolean @@ -4178,7 +4187,7 @@ paths: parameters: - name: name in: path - required: true + required: false description: POST /v1/:name Parameter schema: anyOf: From ded851eac7c0c5c07e5f1dbf90b861cd3542210e Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 29 Apr 2025 21:35:21 +0200 Subject: [PATCH 05/35] Using the new implementation by depictBody(). Pulled examples missing, needs a refactor. --- express-zod-api/src/documentation-helpers.ts | 20 +++++++++++++------ .../__snapshots__/documentation.spec.ts.snap | 8 -------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index b72a1b93c..31df7208f 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -44,7 +44,7 @@ import { ezDateOutBrand } from "./date-out-schema"; import { contentTypes } from "./content-type"; import { DocumentationError } from "./errors"; import { ezFileBrand } from "./file-schema"; -import { extract2, extractObjectSchema, IOSchema } from "./io-schema"; +import { extract2, IOSchema } from "./io-schema"; import { Alternatives } from "./logical-container"; import { metaSymbol } from "./metadata"; import { Method } from "./method"; @@ -727,11 +727,12 @@ export const depictBody = ({ mimeType: string; paramNames: string[]; }) => { + const full = depict(schema, { + rules: { ...brandHandling, ...depicters }, + ctx: { isResponse: false, makeRef, path, method }, + }); const [withoutParams, hasRequired] = excludeParamsFromDepiction( - depict(schema, { - rules: { ...brandHandling, ...depicters }, - ctx: { isResponse: false, makeRef, path, method }, - }), + full, paramNames, ); const bodyDepiction = excludeExamplesFromDepiction(withoutParams); @@ -740,7 +741,14 @@ export const depictBody = ({ composition === "components" ? makeRef(schema, bodyDepiction, makeCleanId(description)) : bodyDepiction, - examples: depictExamples(extractObjectSchema(schema), false, paramNames), + // @todo these examples lack pullProps in onEach, that used to be done by depictExamples + // @todo maybe should refactor usage of excludeExamplesFromDepiction() above? + examples: enumerateExamples( + R.pluck( + "examples", + R.values(R.omit(paramNames, extract2(full as JSONSchema.BaseSchema))), + ).filter(R.isNotNil), + ), }; const body: RequestBodyObject = { description, diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index ef4506753..298621b14 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -3271,10 +3271,6 @@ paths: type: string required: - a - examples: - example1: - value: - a: first required: true responses: "200": @@ -3647,10 +3643,6 @@ paths: type: string required: - strNum - examples: - example1: - value: - strNum: "123" required: true responses: "200": From c9c23b95fca0a16bd33ccf01b4c04f87e4bcde36 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 29 Apr 2025 21:44:46 +0200 Subject: [PATCH 06/35] Removing old implementation and updating tests. --- express-zod-api/src/io-schema.ts | 30 ----- .../__snapshots__/io-schema.spec.ts.snap | 107 ++---------------- express-zod-api/tests/io-schema.spec.ts | 91 ++++++--------- 3 files changed, 46 insertions(+), 182 deletions(-) diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index 8b9c032c9..114c8d4ab 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -1,7 +1,6 @@ import type { JSONSchema } from "@zod/core"; import * as R from "ramda"; import { z } from "zod"; -import { IOSchemaError } from "./errors"; import { mixExamples } from "./metadata"; import { AbstractMiddleware } from "./middleware"; @@ -33,35 +32,6 @@ export const getFinalEndpointInputSchema = < ) as z.ZodIntersection; }; -export const extractObjectSchema = (subject: IOSchema): z.ZodObject => { - if (subject instanceof z.ZodObject) return subject; - if (subject instanceof z.ZodInterface) { - const { optional } = subject._zod.def; - const mask = R.zipObj(optional, Array(optional.length).fill(true)); - const partial = subject.pick(mask); - const required = subject.omit(mask); - return z - .object(required._zod.def.shape) - .extend(z.object(partial._zod.def.shape).partial()); - } - if ( - subject instanceof z.ZodUnion || - subject instanceof z.ZodDiscriminatedUnion - ) { - return subject._zod.def.options - .map((option) => extractObjectSchema(option as IOSchema)) - .reduce((acc, option) => acc.extend(option.partial()), z.object({})); - } - if (subject instanceof z.ZodPipe) - return extractObjectSchema(subject.in as IOSchema); - if (subject instanceof z.ZodIntersection) { - return extractObjectSchema(subject._zod.def.left as IOSchema).extend( - extractObjectSchema(subject._zod.def.right as IOSchema), - ); - } - throw new IOSchemaError("Can not flatten IOSchema", { cause: subject }); -}; - const isJsonObjectSchema = ( subject: JSONSchema.BaseSchema, ): subject is JSONSchema.ObjectSchema => subject.type === "object"; diff --git a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap index 31da5d7f7..5fdf19687 100644 --- a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap @@ -1,117 +1,32 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`I/O Schema and related helpers > extractObjectSchema() > Feature #600: Top level refinements > should handle refined object schema 1`] = ` -{ - "properties": { - "one": { - "type": "string", - }, - }, - "required": [ - "one", - ], - "type": "object", -} -`; - -exports[`I/O Schema and related helpers > extractObjectSchema() > Feature #1869: Top level transformations > should handle transformations to another object 1`] = ` -{ - "properties": { - "one": { - "type": "string", - }, - }, - "required": [ - "one", - ], - "type": "object", -} -`; - -exports[`I/O Schema and related helpers > extractObjectSchema() > Zod 4 > should handle interfaces with optional props 1`] = ` -{ - "properties": { - "one": { - "type": "boolean", - }, - "two": { - "type": "boolean", - }, - }, - "required": [ - "one", - ], - "type": "object", -} -`; - -exports[`I/O Schema and related helpers > extractObjectSchema() > Zod 4 > should throw for incompatible ones 1`] = ` -IOSchemaError({ - "cause": { - "type": "string", - }, - "message": "Can not flatten IOSchema", -}) -`; - exports[`I/O Schema and related helpers > extractObjectSchema() > should pass the object schema through 1`] = ` { - "properties": { - "one": { - "type": "string", - }, + "one": { + "type": "string", }, - "required": [ - "one", - ], - "type": "object", } `; exports[`I/O Schema and related helpers > extractObjectSchema() > should return object schema for the intersection of object schemas 1`] = ` { - "properties": { - "one": { - "type": "string", - }, - "two": { - "type": "number", - }, + "one": { + "type": "string", + }, + "two": { + "type": "number", }, - "required": [ - "one", - "two", - ], - "type": "object", } `; exports[`I/O Schema and related helpers > extractObjectSchema() > should return object schema for the union of object schemas 1`] = ` { - "properties": { - "one": { - "type": "string", - }, - "two": { - "type": "number", - }, + "one": { + "type": "string", }, - "required": [], - "type": "object", -} -`; - -exports[`I/O Schema and related helpers > extractObjectSchema() > should support ez.raw() 1`] = ` -{ - "properties": { - "raw": { - "x-brand": "Symbol(File)", - }, + "two": { + "type": "number", }, - "required": [ - "raw", - ], - "type": "object", } `; diff --git a/express-zod-api/tests/io-schema.spec.ts b/express-zod-api/tests/io-schema.spec.ts index cce312fcf..5ce7b067d 100644 --- a/express-zod-api/tests/io-schema.spec.ts +++ b/express-zod-api/tests/io-schema.spec.ts @@ -1,10 +1,7 @@ import { expectTypeOf } from "vitest"; import { z } from "zod"; import { IOSchema, Middleware, ez } from "../src"; -import { - extractObjectSchema, - getFinalEndpointInputSchema, -} from "../src/io-schema"; +import { extract2, getFinalEndpointInputSchema } from "../src/io-schema"; import { metaSymbol } from "../src/metadata"; import { AbstractMiddleware } from "../src/middleware"; @@ -291,67 +288,49 @@ describe("I/O Schema and related helpers", () => { describe("extractObjectSchema()", () => { test("should pass the object schema through", () => { - const subject = extractObjectSchema(z.object({ one: z.string() })); - expect(subject).toBeInstanceOf(z.ZodObject); + const subject = extract2({ + type: "object", + properties: { one: { type: "string" } }, + required: ["one"], + }); expect(subject).toMatchSnapshot(); }); + // @todo should reflect none required test("should return object schema for the union of object schemas", () => { - const subject = extractObjectSchema( - z.object({ one: z.string() }).or(z.object({ two: z.number() })), - ); - expect(subject).toBeInstanceOf(z.ZodObject); + const subject = extract2({ + oneOf: [ + { + type: "object", + properties: { one: { type: "string" } }, + required: ["one"], + }, + { + type: "object", + properties: { two: { type: "number" } }, + required: ["two"], + }, + ], + }); expect(subject).toMatchSnapshot(); }); test("should return object schema for the intersection of object schemas", () => { - const subject = extractObjectSchema( - z.object({ one: z.string() }).and(z.object({ two: z.number() })), - ); - expect(subject).toBeInstanceOf(z.ZodObject); - expect(subject).toMatchSnapshot(); - }); - - test("should support ez.raw()", () => { - const subject = extractObjectSchema(ez.raw()); - expect(subject).toBeInstanceOf(z.ZodObject); - expect(subject).toMatchSnapshot(); - }); - - describe("Feature #600: Top level refinements", () => { - test("should handle refined object schema", () => { - const subject = extractObjectSchema( - z.object({ one: z.string() }).refine(() => true), - ); - expect(subject).toBeInstanceOf(z.ZodObject); - expect(subject).toMatchSnapshot(); - }); - }); - - describe("Feature #1869: Top level transformations", () => { - test("should handle transformations to another object", () => { - const subject = extractObjectSchema( - z.object({ one: z.string() }).transform(({ one }) => ({ two: one })), - ); - expect(subject).toBeInstanceOf(z.ZodObject); - expect(subject).toMatchSnapshot(); - }); - }); - - describe("Zod 4", () => { - test("should handle interfaces with optional props", () => { - expect( - extractObjectSchema( - z.interface({ one: z.boolean(), "two?": z.boolean() }), - ), - ).toMatchSnapshot(); - }); - - test("should throw for incompatible ones", () => { - expect(() => - extractObjectSchema(z.string() as unknown as IOSchema), - ).toThrowErrorMatchingSnapshot(); + const subject = extract2({ + allOf: [ + { + type: "object", + properties: { one: { type: "string" } }, + required: ["one"], + }, + { + type: "object", + properties: { two: { type: "number" } }, + required: ["two"], + }, + ], }); + expect(subject).toMatchSnapshot(); }); }); }); From 1604cc083fc8a3fa534486a43256d93419ba5eeb Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 29 Apr 2025 21:45:10 +0200 Subject: [PATCH 07/35] Minor: todo name. --- express-zod-api/tests/io-schema.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/express-zod-api/tests/io-schema.spec.ts b/express-zod-api/tests/io-schema.spec.ts index 5ce7b067d..dd4b60722 100644 --- a/express-zod-api/tests/io-schema.spec.ts +++ b/express-zod-api/tests/io-schema.spec.ts @@ -286,6 +286,7 @@ describe("I/O Schema and related helpers", () => { }); }); + // @todo change the name! describe("extractObjectSchema()", () => { test("should pass the object schema through", () => { const subject = extract2({ From bfaa733b5bd6c0aa71c2f3692162db9983669120 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 29 Apr 2025 22:00:25 +0200 Subject: [PATCH 08/35] REF: new implementation to return flat JSON schema object. --- express-zod-api/src/diagnostics.ts | 2 +- express-zod-api/src/documentation-helpers.ts | 9 +++-- express-zod-api/src/io-schema.ts | 11 ++++--- .../__snapshots__/io-schema.spec.ts.snap | 33 ++++++++++++------- 4 files changed, 36 insertions(+), 19 deletions(-) diff --git a/express-zod-api/src/diagnostics.ts b/express-zod-api/src/diagnostics.ts index c253c5dea..8b146da31 100644 --- a/express-zod-api/src/diagnostics.ts +++ b/express-zod-api/src/diagnostics.ts @@ -72,7 +72,7 @@ export class Diagnostics { }), ); for (const param of params) { - if (param in flat) continue; + if (param in flat.properties) continue; this.logger.warn( "The input schema of the endpoint is most likely missing the parameter of the path it's assigned to.", Object.assign(ctx, { path, param }), diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 31df7208f..8128d55be 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -424,7 +424,7 @@ export const depictRequestParams = ({ areHeadersEnabled && (isHeader?.(name, method, path) ?? defaultIsHeader(name, securityHeaders)); - return Object.entries(flat).reduce( + return Object.entries(flat.properties).reduce( (acc, [name, jsonSchema]) => { const location = isPathParam(name) ? "path" @@ -746,7 +746,12 @@ export const depictBody = ({ examples: enumerateExamples( R.pluck( "examples", - R.values(R.omit(paramNames, extract2(full as JSONSchema.BaseSchema))), + R.values( + R.omit( + paramNames, + extract2(full as JSONSchema.BaseSchema).properties, + ), + ), ).filter(R.isNotNil), ), }; diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index 114c8d4ab..88ed36eb3 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -38,11 +38,14 @@ const isJsonObjectSchema = ( export const extract2 = (jsonSchema: JSONSchema.BaseSchema) => { const stack = [jsonSchema]; - const props: Record = {}; + const flat: Required> = { + type: "object", + properties: {}, + }; while (stack.length) { const entry = stack.shift()!; if (isJsonObjectSchema(entry)) { - if (entry.properties) Object.assign(props, entry.properties); + if (entry.properties) Object.assign(flat.properties, entry.properties); if (entry.propertyNames) { const keys: string[] = []; if (typeof entry.propertyNames.const === "string") @@ -58,7 +61,7 @@ export const extract2 = (jsonSchema: JSONSchema.BaseSchema) => { typeof entry.additionalProperties === "object" ? entry.additionalProperties : {}; - for (const key of keys) props[key] = value; + for (const key of keys) flat.properties[key] = value; } } if (entry.allOf) stack.push(...entry.allOf); @@ -66,5 +69,5 @@ export const extract2 = (jsonSchema: JSONSchema.BaseSchema) => { if (entry.oneOf) stack.push(...entry.oneOf); if (entry.not) stack.push(entry.not); } - return props; + return flat; }; diff --git a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap index 5fdf19687..71bfb7a82 100644 --- a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap @@ -2,31 +2,40 @@ exports[`I/O Schema and related helpers > extractObjectSchema() > should pass the object schema through 1`] = ` { - "one": { - "type": "string", + "properties": { + "one": { + "type": "string", + }, }, + "type": "object", } `; exports[`I/O Schema and related helpers > extractObjectSchema() > should return object schema for the intersection of object schemas 1`] = ` { - "one": { - "type": "string", - }, - "two": { - "type": "number", + "properties": { + "one": { + "type": "string", + }, + "two": { + "type": "number", + }, }, + "type": "object", } `; exports[`I/O Schema and related helpers > extractObjectSchema() > should return object schema for the union of object schemas 1`] = ` { - "one": { - "type": "string", - }, - "two": { - "type": "number", + "properties": { + "one": { + "type": "string", + }, + "two": { + "type": "number", + }, }, + "type": "object", } `; From feb5afda30e00f21468a90349e0410e2a975c92b Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 29 Apr 2025 22:10:19 +0200 Subject: [PATCH 09/35] Feat: processing required props by the new implementation. --- express-zod-api/src/io-schema.ts | 32 ++++++++++++++----- .../__snapshots__/io-schema.spec.ts.snap | 8 +++++ express-zod-api/tests/io-schema.spec.ts | 1 - 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index 88ed36eb3..960afdaf2 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -37,15 +37,22 @@ const isJsonObjectSchema = ( ): subject is JSONSchema.ObjectSchema => subject.type === "object"; export const extract2 = (jsonSchema: JSONSchema.BaseSchema) => { - const stack = [jsonSchema]; - const flat: Required> = { + const stack = [{ entry: jsonSchema, isOptional: false }]; + const flat: Required< + Pick + > = { type: "object", properties: {}, + required: [], }; while (stack.length) { - const entry = stack.shift()!; + const { entry, isOptional } = stack.shift()!; if (isJsonObjectSchema(entry)) { - if (entry.properties) Object.assign(flat.properties, entry.properties); + if (entry.properties) { + Object.assign(flat.properties, entry.properties); + if (!isOptional && entry.required) + flat.required.push(...entry.required); + } if (entry.propertyNames) { const keys: string[] = []; if (typeof entry.propertyNames.const === "string") @@ -62,12 +69,21 @@ export const extract2 = (jsonSchema: JSONSchema.BaseSchema) => { ? entry.additionalProperties : {}; for (const key of keys) flat.properties[key] = value; + if (!isOptional) flat.required.push(...keys); } } - if (entry.allOf) stack.push(...entry.allOf); - if (entry.anyOf) stack.push(...entry.anyOf); - if (entry.oneOf) stack.push(...entry.oneOf); - if (entry.not) stack.push(entry.not); + if (entry.allOf) + stack.push(...entry.allOf.map((one) => ({ entry: one, isOptional }))); + if (entry.anyOf) { + stack.push( + ...entry.anyOf.map((one) => ({ entry: one, isOptional: true })), + ); + } + if (entry.oneOf) { + stack.push( + ...entry.oneOf.map((one) => ({ entry: one, isOptional: true })), + ); + } } return flat; }; diff --git a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap index 71bfb7a82..3bca891df 100644 --- a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap @@ -7,6 +7,9 @@ exports[`I/O Schema and related helpers > extractObjectSchema() > should pass th "type": "string", }, }, + "required": [ + "one", + ], "type": "object", } `; @@ -21,6 +24,10 @@ exports[`I/O Schema and related helpers > extractObjectSchema() > should return "type": "number", }, }, + "required": [ + "one", + "two", + ], "type": "object", } `; @@ -35,6 +42,7 @@ exports[`I/O Schema and related helpers > extractObjectSchema() > should return "type": "number", }, }, + "required": [], "type": "object", } `; diff --git a/express-zod-api/tests/io-schema.spec.ts b/express-zod-api/tests/io-schema.spec.ts index dd4b60722..0464360f0 100644 --- a/express-zod-api/tests/io-schema.spec.ts +++ b/express-zod-api/tests/io-schema.spec.ts @@ -297,7 +297,6 @@ describe("I/O Schema and related helpers", () => { expect(subject).toMatchSnapshot(); }); - // @todo should reflect none required test("should return object schema for the union of object schemas", () => { const subject = extract2({ oneOf: [ From 46a7bf84b80f907ea97d310b117c68d76435356c Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 29 Apr 2025 22:13:11 +0200 Subject: [PATCH 10/35] Restoring required prop value in depictRequestParams. --- express-zod-api/src/documentation-helpers.ts | 2 +- .../documentation-helpers.spec.ts.snap | 14 ++--- .../__snapshots__/documentation.spec.ts.snap | 62 +++++++++---------- 3 files changed, 39 insertions(+), 39 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 8128d55be..2a49aa7c7 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -443,7 +443,7 @@ export const depictRequestParams = ({ name, in: location, deprecated: jsonSchema.deprecated, - required: false, // @todo support it by extractor + required: flat.required.includes(name), description: depicted.description || description, schema: result, examples: diff --git a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap index df7e6029b..91089c014 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -291,7 +291,7 @@ exports[`Documentation helpers > depictRequestParams() > Features 1180 and 2344: "examples": undefined, "in": "header", "name": "x-request-id", - "required": false, + "required": true, "schema": { "type": "string", }, @@ -302,7 +302,7 @@ exports[`Documentation helpers > depictRequestParams() > Features 1180 and 2344: "examples": undefined, "in": "path", "name": "id", - "required": false, + "required": true, "schema": { "type": "string", }, @@ -313,7 +313,7 @@ exports[`Documentation helpers > depictRequestParams() > Features 1180 and 2344: "examples": undefined, "in": "query", "name": "test", - "required": false, + "required": true, "schema": { "type": "boolean", }, @@ -324,7 +324,7 @@ exports[`Documentation helpers > depictRequestParams() > Features 1180 and 2344: "examples": undefined, "in": "header", "name": "secure", - "required": false, + "required": true, "schema": { "type": "string", }, @@ -342,7 +342,7 @@ exports[`Documentation helpers > depictRequestParams() > should depict only path "examples": undefined, "in": "path", "name": "id", - "required": false, + "required": true, "schema": { "type": "string", }, @@ -358,7 +358,7 @@ exports[`Documentation helpers > depictRequestParams() > should depict query and "examples": undefined, "in": "path", "name": "id", - "required": false, + "required": true, "schema": { "type": "string", }, @@ -369,7 +369,7 @@ exports[`Documentation helpers > depictRequestParams() > should depict query and "examples": undefined, "in": "query", "name": "test", - "required": false, + "required": true, "schema": { "type": "boolean", }, diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index 298621b14..cd617bd72 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -330,13 +330,13 @@ paths: parameters: - name: key in: query - required: false + required: true description: GET /v1/getSomething Parameter schema: type: string - name: str in: query - required: false + required: true description: GET /v1/getSomething Parameter schema: type: string @@ -625,7 +625,7 @@ paths: parameters: - name: array in: query - required: false + required: true description: GET /v1/getSomething Parameter schema: type: array @@ -636,7 +636,7 @@ paths: exclusiveMaximum: 9007199254740991 - name: unlimited in: query - required: false + required: true description: GET /v1/getSomething Parameter schema: type: array @@ -644,7 +644,7 @@ paths: type: boolean - name: transformer in: query - required: false + required: true description: GET /v1/getSomething Parameter schema: type: string @@ -956,7 +956,7 @@ paths: type: string - name: optDefault in: query - required: false + required: true description: GET /v1/getSomething Parameter schema: type: string @@ -971,7 +971,7 @@ paths: - "null" - name: nuDefault in: query - required: false + required: true description: GET /v1/getSomething Parameter schema: type: @@ -2147,7 +2147,7 @@ paths: parameters: - name: any in: query - required: false + required: true description: GET /v1/getSomething Parameter schema: {} responses: @@ -2224,13 +2224,13 @@ paths: parameters: - name: string in: query - required: false + required: true description: GET /v1/getSomething Parameter schema: format: string (preprocessed) - name: number in: query - required: false + required: true description: GET /v1/getSomething Parameter schema: maximum: 9007199254740991 @@ -2402,19 +2402,19 @@ paths: parameters: - name: name in: path - required: false + required: true description: GET /v1/:name Parameter schema: summary: My custom schema - name: other in: query - required: false + required: true description: GET /v1/:name Parameter schema: summary: My custom schema - name: regular in: query - required: false + required: true description: GET /v1/:name Parameter schema: type: boolean @@ -2493,13 +2493,13 @@ paths: parameters: - name: user_id in: query - required: false + required: true description: GET /v1/test Parameter schema: type: string - name: at in: query - required: false + required: true description: YYYY-MM-DDTHH:mm:ss.sssZ schema: type: string @@ -2583,7 +2583,7 @@ paths: parameters: - name: user_id in: query - required: false + required: true description: GET /v1/test Parameter schema: type: string @@ -2662,13 +2662,13 @@ paths: parameters: - name: id in: query - required: false + required: true description: GET /v1/test Parameter schema: type: string - name: x-request-id in: header - required: false + required: true description: GET /v1/test Parameter schema: type: string @@ -2744,13 +2744,13 @@ paths: parameters: - name: id in: query - required: false + required: true description: POST /v1/test Parameter schema: type: string - name: x-request-id in: header - required: false + required: true description: POST /v1/test Parameter schema: type: string @@ -2834,7 +2834,7 @@ paths: parameters: - name: x-request-id in: header - required: false + required: true description: PUT /v1/test Parameter schema: type: string @@ -2922,7 +2922,7 @@ paths: parameters: - name: arr in: query - required: false + required: true description: GET /v1/getSomething Parameter schema: type: array @@ -3186,7 +3186,7 @@ paths: - name: str in: query deprecated: true - required: false + required: true description: GET /v1/getSomething Parameter schema: type: string @@ -3548,7 +3548,7 @@ paths: parameters: - name: strNum in: query - required: false + required: true description: GET /v1/getSomething Parameter schema: type: string @@ -3727,7 +3727,7 @@ paths: parameters: - name: strNum in: query - required: false + required: true description: GET /v1/getSomething Parameter schema: type: string @@ -3819,7 +3819,7 @@ paths: parameters: - name: str in: query - required: false + required: true description: here is the test schema: type: string @@ -3901,7 +3901,7 @@ paths: parameters: - name: name in: path - required: false + required: true description: parameter of post /v1/:name schema: anyOf: @@ -3993,7 +3993,7 @@ paths: parameters: - name: name in: path - required: false + required: true description: parameter of post /v1/:name schema: $ref: "#/components/schemas/ParameterOfPostV1NameName" @@ -4093,7 +4093,7 @@ paths: parameters: - name: name in: path - required: false + required: true description: GET /v1/:name Parameter schema: anyOf: @@ -4103,7 +4103,7 @@ paths: const: Jane - name: other in: query - required: false + required: true description: GET /v1/:name Parameter schema: type: boolean @@ -4179,7 +4179,7 @@ paths: parameters: - name: name in: path - required: false + required: true description: POST /v1/:name Parameter schema: anyOf: From 5e6347ed0131c765336576c3a309bcee31b0c6ea Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Tue, 29 Apr 2025 22:32:24 +0200 Subject: [PATCH 11/35] Ref: naming. --- express-zod-api/src/diagnostics.ts | 6 +++--- express-zod-api/src/documentation-helpers.ts | 6 +++--- express-zod-api/src/io-schema.ts | 2 +- .../tests/__snapshots__/io-schema.spec.ts.snap | 6 +++--- express-zod-api/tests/io-schema.spec.ts | 11 +++++------ 5 files changed, 15 insertions(+), 16 deletions(-) diff --git a/express-zod-api/src/diagnostics.ts b/express-zod-api/src/diagnostics.ts index 8b146da31..9668530cf 100644 --- a/express-zod-api/src/diagnostics.ts +++ b/express-zod-api/src/diagnostics.ts @@ -4,14 +4,14 @@ import { FlatObject, getRoutePathParams } from "./common-helpers"; import { contentTypes } from "./content-type"; import { findJsonIncompatible } from "./deep-checks"; import { AbstractEndpoint } from "./endpoint"; -import { extract2 } from "./io-schema"; +import { flattenIO } from "./io-schema"; import { ActualLogger } from "./logger-helpers"; export class Diagnostics { #verifiedEndpoints = new WeakSet(); #verifiedPaths = new WeakMap< AbstractEndpoint, - { flat: ReturnType; paths: string[] } + { flat: ReturnType; paths: string[] } >(); constructor(protected logger: ActualLogger) {} @@ -65,7 +65,7 @@ export class Diagnostics { if (params.length === 0) return; // next statement can be expensive const flat = ref?.flat || - extract2( + flattenIO( z.toJSONSchema(endpoint.inputSchema, { unrepresentable: "any", io: "input", diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 2a49aa7c7..58c8c7f4e 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -44,7 +44,7 @@ import { ezDateOutBrand } from "./date-out-schema"; import { contentTypes } from "./content-type"; import { DocumentationError } from "./errors"; import { ezFileBrand } from "./file-schema"; -import { extract2, IOSchema } from "./io-schema"; +import { flattenIO, IOSchema } from "./io-schema"; import { Alternatives } from "./logical-container"; import { metaSymbol } from "./metadata"; import { Method } from "./method"; @@ -404,7 +404,7 @@ export const depictRequestParams = ({ isHeader?: IsHeader; security?: Alternatives; }) => { - const flat = extract2( + const flat = flattenIO( depict(schema, { rules: { ...brandHandling, ...depicters }, ctx: { isResponse: false, makeRef, path, method }, @@ -749,7 +749,7 @@ export const depictBody = ({ R.values( R.omit( paramNames, - extract2(full as JSONSchema.BaseSchema).properties, + flattenIO(full as JSONSchema.BaseSchema).properties, ), ), ).filter(R.isNotNil), diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index 960afdaf2..4b273cb32 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -36,7 +36,7 @@ const isJsonObjectSchema = ( subject: JSONSchema.BaseSchema, ): subject is JSONSchema.ObjectSchema => subject.type === "object"; -export const extract2 = (jsonSchema: JSONSchema.BaseSchema) => { +export const flattenIO = (jsonSchema: JSONSchema.BaseSchema) => { const stack = [{ entry: jsonSchema, isOptional: false }]; const flat: Required< Pick diff --git a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap index 3bca891df..7a1eeaee3 100644 --- a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`I/O Schema and related helpers > extractObjectSchema() > should pass the object schema through 1`] = ` +exports[`I/O Schema and related helpers > flattenIO() > should pass the object schema through 1`] = ` { "properties": { "one": { @@ -14,7 +14,7 @@ exports[`I/O Schema and related helpers > extractObjectSchema() > should pass th } `; -exports[`I/O Schema and related helpers > extractObjectSchema() > should return object schema for the intersection of object schemas 1`] = ` +exports[`I/O Schema and related helpers > flattenIO() > should return object schema for the intersection of object schemas 1`] = ` { "properties": { "one": { @@ -32,7 +32,7 @@ exports[`I/O Schema and related helpers > extractObjectSchema() > should return } `; -exports[`I/O Schema and related helpers > extractObjectSchema() > should return object schema for the union of object schemas 1`] = ` +exports[`I/O Schema and related helpers > flattenIO() > should return object schema for the union of object schemas 1`] = ` { "properties": { "one": { diff --git a/express-zod-api/tests/io-schema.spec.ts b/express-zod-api/tests/io-schema.spec.ts index 0464360f0..0325202b7 100644 --- a/express-zod-api/tests/io-schema.spec.ts +++ b/express-zod-api/tests/io-schema.spec.ts @@ -1,7 +1,7 @@ import { expectTypeOf } from "vitest"; import { z } from "zod"; import { IOSchema, Middleware, ez } from "../src"; -import { extract2, getFinalEndpointInputSchema } from "../src/io-schema"; +import { flattenIO, getFinalEndpointInputSchema } from "../src/io-schema"; import { metaSymbol } from "../src/metadata"; import { AbstractMiddleware } from "../src/middleware"; @@ -286,10 +286,9 @@ describe("I/O Schema and related helpers", () => { }); }); - // @todo change the name! - describe("extractObjectSchema()", () => { + describe("flattenIO()", () => { test("should pass the object schema through", () => { - const subject = extract2({ + const subject = flattenIO({ type: "object", properties: { one: { type: "string" } }, required: ["one"], @@ -298,7 +297,7 @@ describe("I/O Schema and related helpers", () => { }); test("should return object schema for the union of object schemas", () => { - const subject = extract2({ + const subject = flattenIO({ oneOf: [ { type: "object", @@ -316,7 +315,7 @@ describe("I/O Schema and related helpers", () => { }); test("should return object schema for the intersection of object schemas", () => { - const subject = extract2({ + const subject = flattenIO({ allOf: [ { type: "object", From 9366d0ff0843efd14f1df31e4ccb62df9907b110 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 30 Apr 2025 07:22:11 +0200 Subject: [PATCH 12/35] Add depictObject to correct required props on requests. --- express-zod-api/src/documentation-helpers.ts | 14 ++++++++++++++ .../tests/__snapshots__/documentation.spec.ts.snap | 8 ++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 58c8c7f4e..58e71aea1 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -1,4 +1,5 @@ import type { + $ZodObject, $ZodPipe, $ZodTransform, $ZodTuple, @@ -209,6 +210,18 @@ export const depictLiteral: Depicter = ({ jsonSchema }) => ({ ...jsonSchema, }); +const depictObject: Depicter = ({ zodSchema, jsonSchema }, { isResponse }) => { + if (isResponse) return jsonSchema; + if (!isSchema<$ZodObject>(zodSchema, "object")) return jsonSchema; + const { required = [] } = jsonSchema as JSONSchema.ObjectSchema; + const result: string[] = []; + for (const key of required) { + const valueSchema = zodSchema._zod.def.shape[key]; + if (valueSchema && !doesAccept(valueSchema, undefined)) result.push(key); + } + return { ...jsonSchema, required: result }; +}; + const ensureCompliance = ({ $ref, type, @@ -467,6 +480,7 @@ const depicters: Partial> = pipe: depictPipeline, literal: depictLiteral, enum: depictEnum, + object: depictObject, [ezDateInBrand]: depictDateIn, [ezDateOutBrand]: depictDateOut, [ezUploadBrand]: depictUpload, diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index cd617bd72..3c314bc75 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -956,7 +956,7 @@ paths: type: string - name: optDefault in: query - required: true + required: false description: GET /v1/getSomething Parameter schema: type: string @@ -971,7 +971,7 @@ paths: - "null" - name: nuDefault in: query - required: true + required: false description: GET /v1/getSomething Parameter schema: type: @@ -2147,7 +2147,7 @@ paths: parameters: - name: any in: query - required: true + required: false description: GET /v1/getSomething Parameter schema: {} responses: @@ -2224,7 +2224,7 @@ paths: parameters: - name: string in: query - required: true + required: false description: GET /v1/getSomething Parameter schema: format: string (preprocessed) From 2dbf95091b8b5730b43e9d7b9b773b7db29e0ecb Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 30 Apr 2025 07:39:44 +0200 Subject: [PATCH 13/35] Fixed todo: detached depict() from ensureCompliance(). --- express-zod-api/src/documentation-helpers.ts | 25 +++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 58e71aea1..5ed401fd6 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -319,7 +319,7 @@ export const depictPipeline: Depicter = ({ zodSchema, jsonSchema }, ctx) => { ctx.isResponse ? "in" : "out" ]; if (!isSchema<$ZodTransform>(target, "transform")) return jsonSchema; - const opposingDepiction = depict(opposite, { ctx }); + const opposingDepiction = ensureCompliance(depict(opposite, { ctx })); if (isSchemaObject(opposingDepiction)) { if (!ctx.isResponse) { const { type: opposingType, ...rest } = opposingDepiction; @@ -421,7 +421,7 @@ export const depictRequestParams = ({ depict(schema, { rules: { ...brandHandling, ...depicters }, ctx: { isResponse: false, makeRef, path, method }, - }) as JSONSchema.BaseSchema, // @todo fix this, consider detaching it from ensureCompliance + }), ); const pathParams = getRoutePathParams(path); const isQueryEnabled = inputSources.includes("query"); @@ -525,7 +525,7 @@ const fixReferences = ( } if (R.is(Array, entry)) stack.push(...R.values(entry)); } - return ensureCompliance(subject); + return subject; }; /** @link https://github.com/colinhacks/zod/issues/4275 */ @@ -620,10 +620,12 @@ export const depictResponse = ({ }): ResponseObject => { if (!mimeTypes) return { description }; const depictedSchema = excludeExamplesFromDepiction( - depict(schema, { - rules: { ...brandHandling, ...depicters }, - ctx: { isResponse: true, makeRef, path, method }, - }), + ensureCompliance( + depict(schema, { + rules: { ...brandHandling, ...depicters }, + ctx: { isResponse: true, makeRef, path, method }, + }), + ), ); const media: MediaTypeObject = { schema: @@ -746,7 +748,7 @@ export const depictBody = ({ ctx: { isResponse: false, makeRef, path, method }, }); const [withoutParams, hasRequired] = excludeParamsFromDepiction( - full, + ensureCompliance(full), paramNames, ); const bodyDepiction = excludeExamplesFromDepiction(withoutParams); @@ -760,12 +762,7 @@ export const depictBody = ({ examples: enumerateExamples( R.pluck( "examples", - R.values( - R.omit( - paramNames, - flattenIO(full as JSONSchema.BaseSchema).properties, - ), - ), + R.values(R.omit(paramNames, flattenIO(full).properties)), ).filter(R.isNotNil), ), }; From caeee54b2e9023580bebc35eca804d37fface244 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 30 Apr 2025 08:27:04 +0200 Subject: [PATCH 14/35] support examples by flattenIO and using them in depictRequestParams. --- express-zod-api/src/documentation-helpers.ts | 13 +++++++++---- express-zod-api/src/io-schema.ts | 12 +++++++++++- .../tests/__snapshots__/documentation.spec.ts.snap | 3 +++ .../tests/__snapshots__/io-schema.spec.ts.snap | 3 +++ 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 5ed401fd6..1999ce21c 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -381,6 +381,7 @@ export const depictExamples = ( pullProps: true, }); +// @todo remove export const depictParamExamples = ( schema: z.ZodType, param: string, @@ -459,10 +460,14 @@ export const depictRequestParams = ({ required: flat.required.includes(name), description: depicted.description || description, schema: result, - examples: - isSchemaObject(depicted) && depicted.examples - ? enumerateExamples(depicted.examples) - : undefined, + examples: enumerateExamples( + isSchemaObject(depicted) && depicted.examples?.length + ? depicted.examples // own examples or from the flat: + : R.pluck( + name, + flat.examples.filter(R.both(isObject, R.has(name))), + ), + ), }); }, [], diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index 4b273cb32..c392094b4 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -1,6 +1,7 @@ import type { JSONSchema } from "@zod/core"; import * as R from "ramda"; import { z } from "zod"; +import { combinations, FlatObject, isObject } from "./common-helpers"; import { mixExamples } from "./metadata"; import { AbstractMiddleware } from "./middleware"; @@ -39,11 +40,15 @@ const isJsonObjectSchema = ( export const flattenIO = (jsonSchema: JSONSchema.BaseSchema) => { const stack = [{ entry: jsonSchema, isOptional: false }]; const flat: Required< - Pick + Pick< + JSONSchema.ObjectSchema, + "type" | "properties" | "required" | "examples" + > > = { type: "object", properties: {}, required: [], + examples: [], }; while (stack.length) { const { entry, isOptional } = stack.shift()!; @@ -52,6 +57,11 @@ export const flattenIO = (jsonSchema: JSONSchema.BaseSchema) => { Object.assign(flat.properties, entry.properties); if (!isOptional && entry.required) flat.required.push(...entry.required); + flat.examples = combinations( + flat.examples.filter(isObject), + entry.examples?.filter(isObject) || [], + ([a, b]) => ({ ...a, ...b }), + ); } if (entry.propertyNames) { const keys: string[] = []; diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index 3c314bc75..b515c0084 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -3552,6 +3552,9 @@ paths: description: GET /v1/getSomething Parameter schema: type: string + examples: + example1: + value: "123" responses: "200": description: GET /v1/getSomething Positive response diff --git a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap index 7a1eeaee3..1f80524d8 100644 --- a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap @@ -2,6 +2,7 @@ exports[`I/O Schema and related helpers > flattenIO() > should pass the object schema through 1`] = ` { + "examples": [], "properties": { "one": { "type": "string", @@ -16,6 +17,7 @@ exports[`I/O Schema and related helpers > flattenIO() > should pass the object s exports[`I/O Schema and related helpers > flattenIO() > should return object schema for the intersection of object schemas 1`] = ` { + "examples": [], "properties": { "one": { "type": "string", @@ -34,6 +36,7 @@ exports[`I/O Schema and related helpers > flattenIO() > should return object sch exports[`I/O Schema and related helpers > flattenIO() > should return object schema for the union of object schemas 1`] = ` { + "examples": [], "properties": { "one": { "type": "string", From 73dcc90d5ddec07c4e32964c548ea2097a6cdb10 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 30 Apr 2025 09:52:50 +0200 Subject: [PATCH 15/35] Fix: handling own examples by depictBody. --- express-zod-api/src/documentation-helpers.ts | 12 ++++++------ .../tests/__snapshots__/documentation.spec.ts.snap | 13 +++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 1999ce21c..7930358e2 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -762,13 +762,13 @@ export const depictBody = ({ composition === "components" ? makeRef(schema, bodyDepiction, makeCleanId(description)) : bodyDepiction, - // @todo these examples lack pullProps in onEach, that used to be done by depictExamples - // @todo maybe should refactor usage of excludeExamplesFromDepiction() above? examples: enumerateExamples( - R.pluck( - "examples", - R.values(R.omit(paramNames, flattenIO(full).properties)), - ).filter(R.isNotNil), + isSchemaObject(withoutParams) && withoutParams.examples?.length + ? withoutParams.examples + : R.pluck( + "examples", + R.values(R.omit(paramNames, flattenIO(full).properties)), + ).filter(R.isNotNil), ), }; const body: RequestBodyObject = { diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index b515c0084..c741dcff1 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -3271,6 +3271,10 @@ paths: type: string required: - a + examples: + example1: + value: + a: first required: true responses: "200": @@ -3464,6 +3468,11 @@ paths: required: - key - str + examples: + example1: + value: + key: 1234-56789-01 + str: test required: true responses: "200": @@ -3646,6 +3655,10 @@ paths: type: string required: - strNum + examples: + example1: + value: + strNum: "123" required: true responses: "200": From d2451ba2f1857bbf2b3825cc9345c1cca459d16a Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Wed, 30 Apr 2025 12:05:09 +0200 Subject: [PATCH 16/35] rm redundant import --- express-zod-api/src/io-schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index c392094b4..52b3f39e0 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -1,7 +1,7 @@ import type { JSONSchema } from "@zod/core"; import * as R from "ramda"; import { z } from "zod"; -import { combinations, FlatObject, isObject } from "./common-helpers"; +import { combinations, isObject } from "./common-helpers"; import { mixExamples } from "./metadata"; import { AbstractMiddleware } from "./middleware"; From f46af921681055baa988d99b8420c88899b833da Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 30 Apr 2025 12:10:45 +0200 Subject: [PATCH 17/35] rm unused helper. --- express-zod-api/src/documentation-helpers.ts | 13 --------- .../tests/documentation-helpers.spec.ts | 27 ------------------- 2 files changed, 40 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 7930358e2..dd9cdd84e 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -381,19 +381,6 @@ export const depictExamples = ( pullProps: true, }); -// @todo remove -export const depictParamExamples = ( - schema: z.ZodType, - param: string, -): ExamplesObject | undefined => { - return R.pipe( - getExamples, - R.filter(R.both(isObject, R.has(param))), - R.pluck(param), - enumerateExamples, - )({ schema, variant: "original", validate: true, pullProps: true }); -}; - export const defaultIsHeader = ( name: string, familiar?: string[], diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index 6f05998b9..c8ea746d8 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -6,7 +6,6 @@ import { ez } from "../src"; import { OpenAPIContext, depictExamples, - depictParamExamples, depictRequestParams, depictSecurity, depictSecurityRefs, @@ -411,32 +410,6 @@ describe("Documentation helpers", () => { }); }); - describe("depictParamExamples()", () => { - test("should pass examples for the given parameter", () => { - expect( - depictParamExamples( - z - .object({ - one: z.string().transform((v) => v.length), - two: z.number().transform((v) => `${v}`), - three: z.boolean(), - }) - .example({ - one: "test", - two: 123, - three: true, - }) - .example({ - one: "test2", - two: 456, - three: false, - }), - "two", - ), - ).toMatchSnapshot(); - }); - }); - describe("defaultIsHeader()", () => { test.each([ { name: "x-request-id", expected: true }, From b7b0309e9a39ceda6d946e1b05103d41df9351cd Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 30 Apr 2025 12:59:06 +0200 Subject: [PATCH 18/35] Fix: introducing depictRequest() to share its returns among depictRequestParams and depictBody. --- express-zod-api/src/documentation-helpers.ts | 60 ++++++++++------- express-zod-api/src/documentation.ts | 5 +- .../documentation-helpers.spec.ts.snap | 18 +++++ .../__snapshots__/documentation.spec.ts.snap | 14 +--- .../tests/documentation-helpers.spec.ts | 66 ++++++++++++++----- 5 files changed, 107 insertions(+), 56 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index dd9cdd84e..1701d632a 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -81,13 +81,7 @@ export type IsHeader = ( export type BrandHandling = Record; -interface ReqResHandlingProps - extends Omit { - schema: S; - composition: "inline" | "components"; - description?: string; - brandHandling?: BrandHandling; -} +type ReqResCommons = Omit; const shortDescriptionLimit = 50; const isoDateDocumentationUrl = @@ -392,25 +386,22 @@ export const defaultIsHeader = ( export const depictRequestParams = ({ path, method, - schema, + request, inputSources, makeRef, composition, - brandHandling, isHeader, security, description = `${method.toUpperCase()} ${path} Parameter`, -}: ReqResHandlingProps & { +}: ReqResCommons & { + composition: "inline" | "components"; + description?: string; + request: JSONSchema.BaseSchema; inputSources: InputSource[]; isHeader?: IsHeader; security?: Alternatives; }) => { - const flat = flattenIO( - depict(schema, { - rules: { ...brandHandling, ...depicters }, - ctx: { isResponse: false, makeRef, path, method }, - }), - ); + const flat = flattenIO(request); const pathParams = getRoutePathParams(path); const isQueryEnabled = inputSources.includes("query"); const areParamsEnabled = inputSources.includes("params"); @@ -604,7 +595,11 @@ export const depictResponse = ({ description = `${method.toUpperCase()} ${path} ${ucFirst(variant)} response ${ hasMultipleStatusCodes ? statusCode : "" }`.trim(), -}: ReqResHandlingProps<$ZodType> & { +}: ReqResCommons & { + schema: $ZodType; + composition: "inline" | "components"; + description?: string; + brandHandling?: BrandHandling; mimeTypes: ReadonlyArray | null; variant: ResponseVariant; statusCode: number; @@ -721,26 +716,41 @@ export const depictSecurityRefs = ( }, {}), ); +export const depictRequest = ({ + schema, + brandHandling, + makeRef, + path, + method, +}: ReqResCommons & { + schema: IOSchema; + brandHandling?: BrandHandling; +}) => + depict(schema, { + rules: { ...brandHandling, ...depicters }, + ctx: { isResponse: false, makeRef, path, method }, + }); + export const depictBody = ({ method, path, schema, + request, mimeType, makeRef, composition, - brandHandling, paramNames, description = `${method.toUpperCase()} ${path} Request body`, -}: ReqResHandlingProps & { +}: ReqResCommons & { + schema: IOSchema; + composition: "inline" | "components"; + description?: string; + request: JSONSchema.BaseSchema; mimeType: string; paramNames: string[]; }) => { - const full = depict(schema, { - rules: { ...brandHandling, ...depicters }, - ctx: { isResponse: false, makeRef, path, method }, - }); const [withoutParams, hasRequired] = excludeParamsFromDepiction( - ensureCompliance(full), + ensureCompliance(request), paramNames, ); const bodyDepiction = excludeExamplesFromDepiction(withoutParams); @@ -754,7 +764,7 @@ export const depictBody = ({ ? withoutParams.examples : R.pluck( "examples", - R.values(R.omit(paramNames, flattenIO(full).properties)), + R.values(R.omit(paramNames, flattenIO(request).properties)), ).filter(R.isNotNil), ), }; diff --git a/express-zod-api/src/documentation.ts b/express-zod-api/src/documentation.ts index 8dd6cefeb..dadac4ac9 100644 --- a/express-zod-api/src/documentation.ts +++ b/express-zod-api/src/documentation.ts @@ -27,6 +27,7 @@ import { IsHeader, nonEmpty, BrandHandling, + depictRequest, } from "./documentation-helpers"; import { Routing } from "./routing"; import { OnEndpoint, walkRouting } from "./routing-walker"; @@ -173,13 +174,14 @@ export class Documentation extends OpenApiBuilder { endpoint.getOperationId(method), ); + const request = depictRequest({ ...commons, schema: inputSchema }); const security = processContainers(endpoint.security); const depictedParams = depictRequestParams({ ...commons, inputSources, isHeader, security, - schema: inputSchema, + request, description: descriptions?.requestParameter?.call(null, { method, path, @@ -214,6 +216,7 @@ export class Documentation extends OpenApiBuilder { const requestBody = inputSources.includes("body") ? depictBody({ ...commons, + request, paramNames: R.pluck("name", depictedParams), schema: inputSchema, mimeType: contentTypes[endpoint.requestType], diff --git a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap index 91089c014..08d2e4e84 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -283,6 +283,24 @@ exports[`Documentation helpers > depictRaw() > should extract the raw property 1 } `; +exports[`Documentation helpers > depictRequest > should simply delegate it all to Zod 4 1`] = ` +{ + "properties": { + "id": { + "type": "string", + }, + "test": { + "type": "boolean", + }, + }, + "required": [ + "id", + "test", + ], + "type": "object", +} +`; + exports[`Documentation helpers > depictRequestParams() > Features 1180 and 2344: should depict header params when enabled 1`] = ` [ { diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index c741dcff1..8a0e50999 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -1291,7 +1291,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Schema3" + $ref: "#/components/schemas/Schema1" responses: "200": description: POST /v1/getSomething Positive response @@ -1365,18 +1365,6 @@ components: required: - name - subcategories - Schema3: - type: object - properties: - name: - type: string - subcategories: - type: array - items: - $ref: "#/components/schemas/Schema3" - required: - - name - - subcategories responses: {} parameters: {} examples: {} diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index c8ea746d8..475a28280 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -30,6 +30,7 @@ import { depictBody, depictEnum, depictLiteral, + depictRequest, } from "../src/documentation-helpers"; describe("Documentation helpers", () => { @@ -428,14 +429,32 @@ describe("Documentation helpers", () => { ); }); - describe("depictRequestParams()", () => { - test("should depict query and path params", () => { + describe("depictRequest", () => { + test("should simply delegate it all to Zod 4", () => { expect( - depictRequestParams({ + depictRequest({ schema: z.object({ id: z.string(), test: z.boolean(), }), + ...requestCtx, + }), + ).toMatchSnapshot(); + }); + }); + + describe("depictRequestParams()", () => { + test("should depict query and path params", () => { + expect( + depictRequestParams({ + request: { + properties: { + id: { type: "string" }, + test: { type: "boolean" }, + }, + required: ["id", "test"], + type: "object", + }, inputSources: ["query", "params"], composition: "inline", ...requestCtx, @@ -446,10 +465,14 @@ describe("Documentation helpers", () => { test("should depict only path params if query is disabled", () => { expect( depictRequestParams({ - schema: z.object({ - id: z.string(), - test: z.boolean(), - }), + request: { + properties: { + id: { type: "string" }, + test: { type: "boolean" }, + }, + required: ["id", "test"], + type: "object", + }, inputSources: ["body", "params"], composition: "inline", ...requestCtx, @@ -460,10 +483,14 @@ describe("Documentation helpers", () => { test("should depict none if both query and params are disabled", () => { expect( depictRequestParams({ - schema: z.object({ - id: z.string(), - test: z.boolean(), - }), + request: { + properties: { + id: { type: "string" }, + test: { type: "boolean" }, + }, + required: ["id", "test"], + type: "object", + }, inputSources: ["body"], composition: "inline", ...requestCtx, @@ -474,12 +501,16 @@ describe("Documentation helpers", () => { test("Features 1180 and 2344: should depict header params when enabled", () => { expect( depictRequestParams({ - schema: z.object({ - "x-request-id": z.string(), - id: z.string(), - test: z.boolean(), - secure: z.string(), - }), + request: { + properties: { + "x-request-id": { type: "string" }, + id: { type: "string" }, + test: { type: "boolean" }, + secure: { type: "string" }, + }, + required: ["x-request-id", "id", "test", "secure"], + type: "object", + }, inputSources: ["query", "headers", "params"], composition: "inline", security: [[{ type: "header", name: "secure" }]], @@ -506,6 +537,7 @@ describe("Documentation helpers", () => { const body = depictBody({ ...requestCtx, schema: ez.raw(), + request: { type: "string", format: "binary" }, composition: "inline", mimeType: "application/octet-stream", // raw content type paramNames: [], From ca5d34ff732167d152db1726461d15d60fce1b99 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 30 Apr 2025 13:37:27 +0200 Subject: [PATCH 19/35] RM: excludeExamplesFromDepicton() and depictExamples(). --- express-zod-api/src/documentation-helpers.ts | 52 +++++-------------- .../tests/documentation-helpers.spec.ts | 44 ---------------- 2 files changed, 14 insertions(+), 82 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 1701d632a..7d5c142c3 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -28,7 +28,6 @@ import { ResponseVariant } from "./api-response"; import { combinations, doesAccept, - FlatObject, getExamples, getRoutePathParams, getTransformedType, @@ -354,27 +353,6 @@ const enumerateExamples = (examples: unknown[]): ExamplesObject | undefined => ) : undefined; -export const depictExamples = ( - schema: $ZodType, - isResponse: boolean, - omitProps: string[] = [], -): ExamplesObject | undefined => - R.pipe( - getExamples, - R.map( - R.when( - (one): one is FlatObject => isObject(one) && !Array.isArray(one), - R.omit(omitProps), - ), - ), - enumerateExamples, - )({ - schema, - variant: isResponse ? "parsed" : "original", - validate: true, - pullProps: true, - }); - export const defaultIsHeader = ( name: string, familiar?: string[], @@ -576,11 +554,6 @@ export const excludeParamsFromDepiction = ( return [result, hasRequired || Boolean(result.required?.length)]; }; -export const excludeExamplesFromDepiction = ( - depicted: SchemaObject | ReferenceObject, -): SchemaObject | ReferenceObject => - isReferenceObject(depicted) ? depicted : R.omit(["examples"], depicted); - export const depictResponse = ({ method, path, @@ -606,20 +579,21 @@ export const depictResponse = ({ hasMultipleStatusCodes: boolean; }): ResponseObject => { if (!mimeTypes) return { description }; - const depictedSchema = excludeExamplesFromDepiction( - ensureCompliance( - depict(schema, { - rules: { ...brandHandling, ...depicters }, - ctx: { isResponse: true, makeRef, path, method }, - }), - ), + const response = ensureCompliance( + depict(schema, { + rules: { ...brandHandling, ...depicters }, + ctx: { isResponse: true, makeRef, path, method }, + }), ); + const { examples = [], ...depictedSchema } = isSchemaObject(response) + ? response + : { ...response }; const media: MediaTypeObject = { schema: composition === "components" ? makeRef(schema, depictedSchema, makeCleanId(description)) : depictedSchema, - examples: depictExamples(schema, true), + examples: enumerateExamples(examples), }; return { description, content: R.fromPairs(R.xprod(mimeTypes, [media])) }; }; @@ -753,15 +727,17 @@ export const depictBody = ({ ensureCompliance(request), paramNames, ); - const bodyDepiction = excludeExamplesFromDepiction(withoutParams); + const { examples = [], ...bodyDepiction } = isSchemaObject(withoutParams) + ? withoutParams + : { ...withoutParams }; const media: MediaTypeObject = { schema: composition === "components" ? makeRef(schema, bodyDepiction, makeCleanId(description)) : bodyDepiction, examples: enumerateExamples( - isSchemaObject(withoutParams) && withoutParams.examples?.length - ? withoutParams.examples + examples.length + ? examples : R.pluck( "examples", R.values(R.omit(paramNames, flattenIO(request).properties)), diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index 475a28280..141ada8ea 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -5,13 +5,11 @@ import { z } from "zod"; import { ez } from "../src"; import { OpenAPIContext, - depictExamples, depictRequestParams, depictSecurity, depictSecurityRefs, depictTags, ensureShortDescription, - excludeExamplesFromDepiction, excludeParamsFromDepiction, defaultIsHeader, reformatParamsInPath, @@ -381,36 +379,6 @@ describe("Documentation helpers", () => { }); }); - describe("depictExamples()", () => { - test.each<{ isResponse: boolean } & Record<"case" | "action", string>>([ - { isResponse: false, case: "request", action: "pass" }, - { isResponse: true, case: "response", action: "transform" }, - ])("should $action examples in case of $case", ({ isResponse }) => { - expect( - depictExamples( - z - .object({ - one: z.string().transform((v) => v.length), - two: z.number().transform((v) => `${v}`), - three: z.boolean(), - }) - .example({ - one: "test", - two: 123, - three: true, - }) - .example({ - one: "test2", - two: 456, - three: false, - }), - isResponse, - ["three"], - ), - ).toMatchSnapshot(); - }); - }); - describe("defaultIsHeader()", () => { test.each([ { name: "x-request-id", expected: true }, @@ -520,18 +488,6 @@ describe("Documentation helpers", () => { }); }); - describe("excludeExamplesFromDepiction()", () => { - test("should remove example property of supplied object", () => { - expect( - excludeExamplesFromDepiction({ - type: "string", - description: "test", - examples: ["test"], - }), - ).toMatchSnapshot(); - }); - }); - describe("depictBody", () => { test("should mark ez.raw() body as required", () => { const body = depictBody({ From 8f617ed715ed382b51b413aaf700df15321898ba Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 30 Apr 2025 14:17:03 +0200 Subject: [PATCH 20/35] Updating the example docs (has issue). --- example/example.documentation.yaml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index d43165b6b..c4fb284fe 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -137,12 +137,12 @@ paths: key: type: string minLength: 1 - examples: + examples: &a1 - 1234-5678-90 name: type: string minLength: 1 - examples: + examples: &a2 - John Doe birthday: description: YYYY-MM-DDTHH:mm:ss.sssZ @@ -151,7 +151,7 @@ paths: pattern: ^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?)?Z?$ externalDocs: url: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString - examples: + examples: &a3 - 1963-04-21 required: - key @@ -159,10 +159,11 @@ paths: - birthday examples: example1: - value: - key: 1234-5678-90 - name: John Doe - birthday: 1963-04-21 + value: *a1 + example2: + value: *a2 + example3: + value: *a3 required: true security: - APIKEY_1: [] From 3c90fa208b2a1b7ed2b25a75c57ccbfed8874068 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 30 Apr 2025 14:27:52 +0200 Subject: [PATCH 21/35] Fix: enabling pullProps in onEach. --- example/example.documentation.yaml | 18 ++++++++++-------- express-zod-api/src/documentation-helpers.ts | 1 + 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index c4fb284fe..1f025f2e7 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -137,12 +137,12 @@ paths: key: type: string minLength: 1 - examples: &a1 + examples: - 1234-5678-90 name: type: string minLength: 1 - examples: &a2 + examples: - John Doe birthday: description: YYYY-MM-DDTHH:mm:ss.sssZ @@ -151,7 +151,7 @@ paths: pattern: ^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?)?Z?$ externalDocs: url: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString - examples: &a3 + examples: - 1963-04-21 required: - key @@ -159,11 +159,10 @@ paths: - birthday examples: example1: - value: *a1 - example2: - value: *a2 - example3: - value: *a3 + value: + key: 1234-5678-90 + name: John Doe + birthday: 1963-04-21 required: true security: - APIKEY_1: [] @@ -197,6 +196,9 @@ paths: required: - name - createdAt + examples: + - name: John Doe + createdAt: 2021-12-31T00:00:00.000Z required: - status - data diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 7d5c142c3..518682945 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -457,6 +457,7 @@ const onEach: Depicter = ({ zodSchema, jsonSchema }, { isResponse }) => { schema: zodSchema, variant: isResponse ? "parsed" : "original", validate: true, + pullProps: true, }); if (examples.length) result.examples = examples.slice(); return result; From 6551241d53be14f895953fd73222ac10ff0ee26c Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 30 Apr 2025 15:17:49 +0200 Subject: [PATCH 22/35] Updating snapshots and remove redundant ones. --- .../documentation-helpers.spec.ts.snap | 52 ------------------- .../__snapshots__/documentation.spec.ts.snap | 2 + 2 files changed, 2 insertions(+), 52 deletions(-) diff --git a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap index 08d2e4e84..01eda5a4c 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -62,40 +62,6 @@ exports[`Documentation helpers > depictEnum() > should set type 1`] = ` } `; -exports[`Documentation helpers > depictExamples() > should 'pass' examples in case of 'request' 1`] = ` -{ - "example1": { - "value": { - "one": "test", - "two": 123, - }, - }, - "example2": { - "value": { - "one": "test2", - "two": 456, - }, - }, -} -`; - -exports[`Documentation helpers > depictExamples() > should 'transform' examples in case of 'response' 1`] = ` -{ - "example1": { - "value": { - "one": 4, - "two": "123", - }, - }, - "example2": { - "value": { - "one": 5, - "two": "456", - }, - }, -} -`; - exports[`Documentation helpers > depictFile() > should set type:string and format accordingly 0 1`] = ` { "format": "file", @@ -253,17 +219,6 @@ exports[`Documentation helpers > depictNullable() > should not add null type whe } `; -exports[`Documentation helpers > depictParamExamples() > should pass examples for the given parameter 1`] = ` -{ - "example1": { - "value": 123, - }, - "example2": { - "value": 456, - }, -} -`; - exports[`Documentation helpers > depictPipeline > should depict as 'number (out)' 1`] = ` { "type": "number", @@ -677,13 +632,6 @@ DocumentationError({ }) `; -exports[`Documentation helpers > excludeExamplesFromDepiction() > should remove example property of supplied object 1`] = ` -{ - "description": "test", - "type": "string", -} -`; - exports[`Documentation helpers > excludeParamsFromDepiction() > should handle the ReferenceObject 1`] = ` { "$ref": "test", diff --git a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap index 8a0e50999..3862c60e3 100644 --- a/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation.spec.ts.snap @@ -3760,6 +3760,8 @@ paths: - "123" required: - numericStr + examples: + - numericStr: "123" required: - status - data From d84b9141509ab99f598269dafff747407f44292d Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 30 Apr 2025 16:42:24 +0200 Subject: [PATCH 23/35] Ref: taking examples from flattened IO. --- express-zod-api/src/documentation-helpers.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 518682945..b52688631 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -28,6 +28,7 @@ import { ResponseVariant } from "./api-response"; import { combinations, doesAccept, + FlatObject, getExamples, getRoutePathParams, getTransformedType, @@ -739,10 +740,11 @@ export const depictBody = ({ examples: enumerateExamples( examples.length ? examples - : R.pluck( - "examples", - R.values(R.omit(paramNames, flattenIO(request).properties)), - ).filter(R.isNotNil), + : flattenIO(request) + .examples.filter( + (one): one is FlatObject => isObject(one) && !Array.isArray(one), + ) + .map(R.omit(paramNames)), ), }; const body: RequestBodyObject = { From b23e91ea6d4505961c0269b45b5ced0e12237c68 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 30 Apr 2025 16:58:42 +0200 Subject: [PATCH 24/35] No combining examples for unions, testing flattenIO for examples. --- express-zod-api/src/io-schema.ts | 16 +++++++++----- .../__snapshots__/io-schema.spec.ts.snap | 22 ++++++++++++++++--- express-zod-api/tests/io-schema.spec.ts | 5 +++++ 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index 52b3f39e0..b15b99a9c 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -57,11 +57,17 @@ export const flattenIO = (jsonSchema: JSONSchema.BaseSchema) => { Object.assign(flat.properties, entry.properties); if (!isOptional && entry.required) flat.required.push(...entry.required); - flat.examples = combinations( - flat.examples.filter(isObject), - entry.examples?.filter(isObject) || [], - ([a, b]) => ({ ...a, ...b }), - ); + if (entry.examples) { + if (isOptional) { + flat.examples.push(...entry.examples); + } else { + flat.examples = combinations( + flat.examples.filter(isObject), + entry.examples.filter(isObject), + ([a, b]) => ({ ...a, ...b }), + ); + } + } } if (entry.propertyNames) { const keys: string[] = []; diff --git a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap index 1f80524d8..9a99f6399 100644 --- a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap @@ -2,7 +2,11 @@ exports[`I/O Schema and related helpers > flattenIO() > should pass the object schema through 1`] = ` { - "examples": [], + "examples": [ + { + "one": "test", + }, + ], "properties": { "one": { "type": "string", @@ -17,7 +21,12 @@ exports[`I/O Schema and related helpers > flattenIO() > should pass the object s exports[`I/O Schema and related helpers > flattenIO() > should return object schema for the intersection of object schemas 1`] = ` { - "examples": [], + "examples": [ + { + "one": "test", + "two": "jest", + }, + ], "properties": { "one": { "type": "string", @@ -36,7 +45,14 @@ exports[`I/O Schema and related helpers > flattenIO() > should return object sch exports[`I/O Schema and related helpers > flattenIO() > should return object schema for the union of object schemas 1`] = ` { - "examples": [], + "examples": [ + { + "one": "test", + }, + { + "two": "jest", + }, + ], "properties": { "one": { "type": "string", diff --git a/express-zod-api/tests/io-schema.spec.ts b/express-zod-api/tests/io-schema.spec.ts index 0325202b7..c2d0206c7 100644 --- a/express-zod-api/tests/io-schema.spec.ts +++ b/express-zod-api/tests/io-schema.spec.ts @@ -292,6 +292,7 @@ describe("I/O Schema and related helpers", () => { type: "object", properties: { one: { type: "string" } }, required: ["one"], + examples: [{ one: "test" }], }); expect(subject).toMatchSnapshot(); }); @@ -303,11 +304,13 @@ describe("I/O Schema and related helpers", () => { type: "object", properties: { one: { type: "string" } }, required: ["one"], + examples: [{ one: "test" }], }, { type: "object", properties: { two: { type: "number" } }, required: ["two"], + examples: [{ two: "jest" }], }, ], }); @@ -321,11 +324,13 @@ describe("I/O Schema and related helpers", () => { type: "object", properties: { one: { type: "string" } }, required: ["one"], + examples: [{ one: "test" }], }, { type: "object", properties: { two: { type: "number" } }, required: ["two"], + examples: [{ two: "jest" }], }, ], }); From c3b3e6ce4138888203726cbf25accbdb66cf8174 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 30 Apr 2025 20:51:10 +0200 Subject: [PATCH 25/35] mv examples handling outta props condition. --- express-zod-api/src/io-schema.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index b15b99a9c..ecb4e86ef 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -57,16 +57,16 @@ export const flattenIO = (jsonSchema: JSONSchema.BaseSchema) => { Object.assign(flat.properties, entry.properties); if (!isOptional && entry.required) flat.required.push(...entry.required); - if (entry.examples) { - if (isOptional) { - flat.examples.push(...entry.examples); - } else { - flat.examples = combinations( - flat.examples.filter(isObject), - entry.examples.filter(isObject), - ([a, b]) => ({ ...a, ...b }), - ); - } + } + if (entry.examples) { + if (isOptional) { + flat.examples.push(...entry.examples); + } else { + flat.examples = combinations( + flat.examples.filter(isObject), + entry.examples.filter(isObject), + ([a, b]) => ({ ...a, ...b }), + ); } } if (entry.propertyNames) { From 3b68db19a674cd360dbff255f119896fff8b5845 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Wed, 30 Apr 2025 21:03:50 +0200 Subject: [PATCH 26/35] Testing records. --- express-zod-api/src/io-schema.ts | 1 + .../__snapshots__/io-schema.spec.ts.snap | 35 +++++++++++++++++++ express-zod-api/tests/io-schema.spec.ts | 19 ++++++++++ 3 files changed, 55 insertions(+) diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index ecb4e86ef..fed677f9a 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -59,6 +59,7 @@ export const flattenIO = (jsonSchema: JSONSchema.BaseSchema) => { flat.required.push(...entry.required); } if (entry.examples) { + console.log(entry.examples); if (isOptional) { flat.examples.push(...entry.examples); } else { diff --git a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap index 9a99f6399..8b5c1f1b6 100644 --- a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap @@ -1,5 +1,40 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`I/O Schema and related helpers > flattenIO() > should handle records 1`] = ` +{ + "examples": [ + { + "one": "test", + "two": "jest", + }, + { + "one": "some", + "two": "another", + }, + { + "four": 456, + "three": 123, + }, + ], + "properties": { + "four": { + "type": "number", + }, + "one": { + "type": "string", + }, + "three": { + "type": "number", + }, + "two": { + "type": "string", + }, + }, + "required": [], + "type": "object", +} +`; + exports[`I/O Schema and related helpers > flattenIO() > should pass the object schema through 1`] = ` { "examples": [ diff --git a/express-zod-api/tests/io-schema.spec.ts b/express-zod-api/tests/io-schema.spec.ts index c2d0206c7..7783865e2 100644 --- a/express-zod-api/tests/io-schema.spec.ts +++ b/express-zod-api/tests/io-schema.spec.ts @@ -336,5 +336,24 @@ describe("I/O Schema and related helpers", () => { }); expect(subject).toMatchSnapshot(); }); + + test("should handle records", () => { + const subject = z.toJSONSchema( + z + .record(z.literal(["one", "two"]), z.string()) + .meta({ + examples: [ + { one: "test", two: "jest" }, + { one: "some", two: "another" }, + ], + }) + .or( + z + .record(z.enum(["three", "four"]), z.number()) + .meta({ examples: [{ three: 123, four: 456 }] }), + ), + ); + expect(flattenIO(subject)).toMatchSnapshot(); + }); }); }); From 29b37f83371c95ccfdd910c7c928d7a82826b7da Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 1 May 2025 08:34:50 +0200 Subject: [PATCH 27/35] Fix coverage for flattenIO. --- express-zod-api/src/io-schema.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index fed677f9a..c89bae725 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -81,10 +81,7 @@ export const flattenIO = (jsonSchema: JSONSchema.BaseSchema) => { ), ); } - const value = - typeof entry.additionalProperties === "object" - ? entry.additionalProperties - : {}; + const value = { ...Object(entry.additionalProperties) }; // it can be bool for (const key of keys) flat.properties[key] = value; if (!isOptional) flat.required.push(...keys); } From 1332f66e5285657e1aafd85559649cec5583e79a Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 1 May 2025 08:39:48 +0200 Subject: [PATCH 28/35] rm console.log. --- express-zod-api/src/io-schema.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index c89bae725..deb60d886 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -59,7 +59,6 @@ export const flattenIO = (jsonSchema: JSONSchema.BaseSchema) => { flat.required.push(...entry.required); } if (entry.examples) { - console.log(entry.examples); if (isOptional) { flat.examples.push(...entry.examples); } else { From 3bc2c80986b9e3aeddbd5ac303e26d12a475106e Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 1 May 2025 09:38:48 +0200 Subject: [PATCH 29/35] Fix coverage for depictResponse. --- express-zod-api/src/documentation-helpers.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index b52688631..6e3f2f64f 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -587,14 +587,16 @@ export const depictResponse = ({ ctx: { isResponse: true, makeRef, path, method }, }), ); - const { examples = [], ...depictedSchema } = isSchemaObject(response) - ? response - : { ...response }; + const examples = []; + if (isSchemaObject(response) && response.examples) { + examples.push(...response.examples); + delete response.examples; // moving them up + } const media: MediaTypeObject = { schema: composition === "components" - ? makeRef(schema, depictedSchema, makeCleanId(description)) - : depictedSchema, + ? makeRef(schema, response, makeCleanId(description)) + : response, examples: enumerateExamples(examples), }; return { description, content: R.fromPairs(R.xprod(mimeTypes, [media])) }; From 4ee618f28f997218167b3bb43c1b934685aa18bb Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 1 May 2025 09:48:49 +0200 Subject: [PATCH 30/35] Ref: straigthening condition on flattenIO. --- express-zod-api/src/io-schema.ts | 62 +++++++++++++++----------------- 1 file changed, 29 insertions(+), 33 deletions(-) diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index deb60d886..00438b971 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -52,39 +52,6 @@ export const flattenIO = (jsonSchema: JSONSchema.BaseSchema) => { }; while (stack.length) { const { entry, isOptional } = stack.shift()!; - if (isJsonObjectSchema(entry)) { - if (entry.properties) { - Object.assign(flat.properties, entry.properties); - if (!isOptional && entry.required) - flat.required.push(...entry.required); - } - if (entry.examples) { - if (isOptional) { - flat.examples.push(...entry.examples); - } else { - flat.examples = combinations( - flat.examples.filter(isObject), - entry.examples.filter(isObject), - ([a, b]) => ({ ...a, ...b }), - ); - } - } - if (entry.propertyNames) { - const keys: string[] = []; - if (typeof entry.propertyNames.const === "string") - keys.push(entry.propertyNames.const); - if (entry.propertyNames.enum) { - keys.push( - ...entry.propertyNames.enum.filter( - (one) => typeof one === "string", - ), - ); - } - const value = { ...Object(entry.additionalProperties) }; // it can be bool - for (const key of keys) flat.properties[key] = value; - if (!isOptional) flat.required.push(...keys); - } - } if (entry.allOf) stack.push(...entry.allOf.map((one) => ({ entry: one, isOptional }))); if (entry.anyOf) { @@ -97,6 +64,35 @@ export const flattenIO = (jsonSchema: JSONSchema.BaseSchema) => { ...entry.oneOf.map((one) => ({ entry: one, isOptional: true })), ); } + if (!isJsonObjectSchema(entry)) continue; + if (entry.properties) { + Object.assign(flat.properties, entry.properties); + if (!isOptional && entry.required) flat.required.push(...entry.required); + } + if (entry.examples) { + if (isOptional) { + flat.examples.push(...entry.examples); + } else { + flat.examples = combinations( + flat.examples.filter(isObject), + entry.examples.filter(isObject), + ([a, b]) => ({ ...a, ...b }), + ); + } + } + if (entry.propertyNames) { + const keys: string[] = []; + if (typeof entry.propertyNames.const === "string") + keys.push(entry.propertyNames.const); + if (entry.propertyNames.enum) { + keys.push( + ...entry.propertyNames.enum.filter((one) => typeof one === "string"), + ); + } + const value = { ...Object(entry.additionalProperties) }; // it can be bool + for (const key of keys) flat.properties[key] = value; + if (!isOptional) flat.required.push(...keys); + } } return flat; }; From 4f29485231c9e838cafdb63959a215987a903ddf Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Thu, 1 May 2025 10:09:35 +0200 Subject: [PATCH 31/35] Ref: consistent handling of examples pulling for depictBody --- express-zod-api/src/documentation-helpers.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 6e3f2f64f..4aa9ce8d3 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -731,14 +731,16 @@ export const depictBody = ({ ensureCompliance(request), paramNames, ); - const { examples = [], ...bodyDepiction } = isSchemaObject(withoutParams) - ? withoutParams - : { ...withoutParams }; + const examples = []; + if (isSchemaObject(withoutParams) && withoutParams.examples) { + examples.push(...withoutParams.examples); + delete withoutParams.examples; // pull up + } const media: MediaTypeObject = { schema: composition === "components" - ? makeRef(schema, bodyDepiction, makeCleanId(description)) - : bodyDepiction, + ? makeRef(schema, withoutParams, makeCleanId(description)) + : withoutParams, examples: enumerateExamples( examples.length ? examples From adae75ec806efc8288aaa1288ab09c86db9eaea2 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 1 May 2025 12:59:16 +0200 Subject: [PATCH 32/35] Restoring and adjusting the test for depictObject. --- express-zod-api/src/documentation-helpers.ts | 5 ++- .../documentation-helpers.spec.ts.snap | 33 +++++++++++++++++++ .../tests/documentation-helpers.spec.ts | 29 ++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 4aa9ce8d3..e89a17c64 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -204,7 +204,10 @@ export const depictLiteral: Depicter = ({ jsonSchema }) => ({ ...jsonSchema, }); -const depictObject: Depicter = ({ zodSchema, jsonSchema }, { isResponse }) => { +export const depictObject: Depicter = ( + { zodSchema, jsonSchema }, + { isResponse }, +) => { if (isResponse) return jsonSchema; if (!isSchema<$ZodObject>(zodSchema, "object")) return jsonSchema; const { required = [] } = jsonSchema as JSONSchema.ObjectSchema; diff --git a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap index 01eda5a4c..ef43084e2 100644 --- a/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/documentation-helpers.spec.ts.snap @@ -219,6 +219,39 @@ exports[`Documentation helpers > depictNullable() > should not add null type whe } `; +exports[`Documentation helpers > depictObject() > should remove optional props from required for request 0 1`] = ` +{ + "properties": { + "a": { + "type": "number", + }, + "b": { + "type": "string", + }, + }, + "required": [ + "a", + "b", + ], + "type": "object", +} +`; + +exports[`Documentation helpers > depictObject() > should remove optional props from required for request 1 1`] = ` +{ + "properties": { + "a": { + "type": "number", + }, + "b": { + "type": "string", + }, + }, + "required": [], + "type": "object", +} +`; + exports[`Documentation helpers > depictPipeline > should depict as 'number (out)' 1`] = ` { "type": "number", diff --git a/express-zod-api/tests/documentation-helpers.spec.ts b/express-zod-api/tests/documentation-helpers.spec.ts index 141ada8ea..022446b7d 100644 --- a/express-zod-api/tests/documentation-helpers.spec.ts +++ b/express-zod-api/tests/documentation-helpers.spec.ts @@ -29,6 +29,7 @@ import { depictEnum, depictLiteral, depictRequest, + depictObject, } from "../src/documentation-helpers"; describe("Documentation helpers", () => { @@ -332,6 +333,34 @@ describe("Documentation helpers", () => { ); }); + describe("depictObject()", () => { + test.each([ + { + zodSchema: z.object({ a: z.number(), b: z.string() }), + jsonSchema: { + type: "object", + properties: { a: { type: "number" }, b: { type: "string" } }, + required: ["a", "b"], + }, + }, + { + zodSchema: z.object({ a: z.number().optional(), b: z.coerce.string() }), + jsonSchema: { + type: "object", + properties: { a: { type: "number" }, b: { type: "string" } }, + required: ["b"], + }, + }, + ])( + "should remove optional props from required for request %#", + ({ zodSchema, jsonSchema }) => { + expect( + depictObject({ zodSchema, jsonSchema }, requestCtx), + ).toMatchSnapshot(); + }, + ); + }); + describe("depictBigInt()", () => { test("should set type:string and format:bigint", () => { expect( From bb4d7cc907dcecb97f51b9d39507e744ba2f6df6 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 1 May 2025 15:07:47 +0200 Subject: [PATCH 33/35] Fix: prevent duplicates in the flattened required props list. --- express-zod-api/src/io-schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index 00438b971..fb1ebf5c6 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -94,5 +94,6 @@ export const flattenIO = (jsonSchema: JSONSchema.BaseSchema) => { if (!isOptional) flat.required.push(...keys); } } + if (flat.required.length > 1) flat.required = [...new Set(flat.required)]; // drop duplicates return flat; }; From 8e8e4d834c6a2ac2df165ed9afda2b62563807cf Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 1 May 2025 15:49:28 +0200 Subject: [PATCH 34/35] REF: moving flattenIO into a dedicated JSON Schema helpers file. --- express-zod-api/src/diagnostics.ts | 2 +- express-zod-api/src/documentation-helpers.ts | 3 +- express-zod-api/src/io-schema.ts | 67 ------------ express-zod-api/src/json-schema-helpers.ts | 67 ++++++++++++ .../__snapshots__/io-schema.spec.ts.snap | 101 ----------------- .../json-schema-helpers.spec.ts.snap | 102 ++++++++++++++++++ express-zod-api/tests/io-schema.spec.ts | 73 +------------ .../tests/json-schema-helpers.spec.ts | 75 +++++++++++++ 8 files changed, 248 insertions(+), 242 deletions(-) create mode 100644 express-zod-api/src/json-schema-helpers.ts create mode 100644 express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap create mode 100644 express-zod-api/tests/json-schema-helpers.spec.ts diff --git a/express-zod-api/src/diagnostics.ts b/express-zod-api/src/diagnostics.ts index 9668530cf..024f4ffcf 100644 --- a/express-zod-api/src/diagnostics.ts +++ b/express-zod-api/src/diagnostics.ts @@ -4,7 +4,7 @@ import { FlatObject, getRoutePathParams } from "./common-helpers"; import { contentTypes } from "./content-type"; import { findJsonIncompatible } from "./deep-checks"; import { AbstractEndpoint } from "./endpoint"; -import { flattenIO } from "./io-schema"; +import { flattenIO } from "./json-schema-helpers"; import { ActualLogger } from "./logger-helpers"; export class Diagnostics { diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index e89a17c64..4567ccd01 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -45,7 +45,8 @@ import { ezDateOutBrand } from "./date-out-schema"; import { contentTypes } from "./content-type"; import { DocumentationError } from "./errors"; import { ezFileBrand } from "./file-schema"; -import { flattenIO, IOSchema } from "./io-schema"; +import { IOSchema } from "./io-schema"; +import { flattenIO } from "./json-schema-helpers"; import { Alternatives } from "./logical-container"; import { metaSymbol } from "./metadata"; import { Method } from "./method"; diff --git a/express-zod-api/src/io-schema.ts b/express-zod-api/src/io-schema.ts index fb1ebf5c6..ea8160b51 100644 --- a/express-zod-api/src/io-schema.ts +++ b/express-zod-api/src/io-schema.ts @@ -1,7 +1,5 @@ -import type { JSONSchema } from "@zod/core"; import * as R from "ramda"; import { z } from "zod"; -import { combinations, isObject } from "./common-helpers"; import { mixExamples } from "./metadata"; import { AbstractMiddleware } from "./middleware"; @@ -32,68 +30,3 @@ export const getFinalEndpointInputSchema = < finalSchema, ) as z.ZodIntersection; }; - -const isJsonObjectSchema = ( - subject: JSONSchema.BaseSchema, -): subject is JSONSchema.ObjectSchema => subject.type === "object"; - -export const flattenIO = (jsonSchema: JSONSchema.BaseSchema) => { - const stack = [{ entry: jsonSchema, isOptional: false }]; - const flat: Required< - Pick< - JSONSchema.ObjectSchema, - "type" | "properties" | "required" | "examples" - > - > = { - type: "object", - properties: {}, - required: [], - examples: [], - }; - while (stack.length) { - const { entry, isOptional } = stack.shift()!; - if (entry.allOf) - stack.push(...entry.allOf.map((one) => ({ entry: one, isOptional }))); - if (entry.anyOf) { - stack.push( - ...entry.anyOf.map((one) => ({ entry: one, isOptional: true })), - ); - } - if (entry.oneOf) { - stack.push( - ...entry.oneOf.map((one) => ({ entry: one, isOptional: true })), - ); - } - if (!isJsonObjectSchema(entry)) continue; - if (entry.properties) { - Object.assign(flat.properties, entry.properties); - if (!isOptional && entry.required) flat.required.push(...entry.required); - } - if (entry.examples) { - if (isOptional) { - flat.examples.push(...entry.examples); - } else { - flat.examples = combinations( - flat.examples.filter(isObject), - entry.examples.filter(isObject), - ([a, b]) => ({ ...a, ...b }), - ); - } - } - if (entry.propertyNames) { - const keys: string[] = []; - if (typeof entry.propertyNames.const === "string") - keys.push(entry.propertyNames.const); - if (entry.propertyNames.enum) { - keys.push( - ...entry.propertyNames.enum.filter((one) => typeof one === "string"), - ); - } - const value = { ...Object(entry.additionalProperties) }; // it can be bool - for (const key of keys) flat.properties[key] = value; - if (!isOptional) flat.required.push(...keys); - } - } - if (flat.required.length > 1) flat.required = [...new Set(flat.required)]; // drop duplicates - return flat; -}; diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts new file mode 100644 index 000000000..9bb1d2da1 --- /dev/null +++ b/express-zod-api/src/json-schema-helpers.ts @@ -0,0 +1,67 @@ +import type { JSONSchema } from "@zod/core"; +import { combinations, isObject } from "./common-helpers"; + +const isJsonObjectSchema = ( + subject: JSONSchema.BaseSchema, +): subject is JSONSchema.ObjectSchema => subject.type === "object"; + +export const flattenIO = (jsonSchema: JSONSchema.BaseSchema) => { + const stack = [{ entry: jsonSchema, isOptional: false }]; + const flat: Required< + Pick< + JSONSchema.ObjectSchema, + "type" | "properties" | "required" | "examples" + > + > = { + type: "object", + properties: {}, + required: [], + examples: [], + }; + while (stack.length) { + const { entry, isOptional } = stack.shift()!; + if (entry.allOf) + stack.push(...entry.allOf.map((one) => ({ entry: one, isOptional }))); + if (entry.anyOf) { + stack.push( + ...entry.anyOf.map((one) => ({ entry: one, isOptional: true })), + ); + } + if (entry.oneOf) { + stack.push( + ...entry.oneOf.map((one) => ({ entry: one, isOptional: true })), + ); + } + if (!isJsonObjectSchema(entry)) continue; + if (entry.properties) { + Object.assign(flat.properties, entry.properties); + if (!isOptional && entry.required) flat.required.push(...entry.required); + } + if (entry.examples) { + if (isOptional) { + flat.examples.push(...entry.examples); + } else { + flat.examples = combinations( + flat.examples.filter(isObject), + entry.examples.filter(isObject), + ([a, b]) => ({ ...a, ...b }), + ); + } + } + if (entry.propertyNames) { + const keys: string[] = []; + if (typeof entry.propertyNames.const === "string") + keys.push(entry.propertyNames.const); + if (entry.propertyNames.enum) { + keys.push( + ...entry.propertyNames.enum.filter((one) => typeof one === "string"), + ); + } + const value = { ...Object(entry.additionalProperties) }; // it can be bool + for (const key of keys) flat.properties[key] = value; + if (!isOptional) flat.required.push(...keys); + } + } + if (flat.required.length > 1) flat.required = [...new Set(flat.required)]; // drop duplicates + return flat; +}; diff --git a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap index 8b5c1f1b6..16bdb933c 100644 --- a/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/io-schema.spec.ts.snap @@ -1,106 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`I/O Schema and related helpers > flattenIO() > should handle records 1`] = ` -{ - "examples": [ - { - "one": "test", - "two": "jest", - }, - { - "one": "some", - "two": "another", - }, - { - "four": 456, - "three": 123, - }, - ], - "properties": { - "four": { - "type": "number", - }, - "one": { - "type": "string", - }, - "three": { - "type": "number", - }, - "two": { - "type": "string", - }, - }, - "required": [], - "type": "object", -} -`; - -exports[`I/O Schema and related helpers > flattenIO() > should pass the object schema through 1`] = ` -{ - "examples": [ - { - "one": "test", - }, - ], - "properties": { - "one": { - "type": "string", - }, - }, - "required": [ - "one", - ], - "type": "object", -} -`; - -exports[`I/O Schema and related helpers > flattenIO() > should return object schema for the intersection of object schemas 1`] = ` -{ - "examples": [ - { - "one": "test", - "two": "jest", - }, - ], - "properties": { - "one": { - "type": "string", - }, - "two": { - "type": "number", - }, - }, - "required": [ - "one", - "two", - ], - "type": "object", -} -`; - -exports[`I/O Schema and related helpers > flattenIO() > should return object schema for the union of object schemas 1`] = ` -{ - "examples": [ - { - "one": "test", - }, - { - "two": "jest", - }, - ], - "properties": { - "one": { - "type": "string", - }, - "two": { - "type": "number", - }, - }, - "required": [], - "type": "object", -} -`; - exports[`I/O Schema and related helpers > getFinalEndpointInputSchema() > Should handle no middlewares 1`] = ` { "properties": { diff --git a/express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap b/express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap new file mode 100644 index 000000000..bdb347c0d --- /dev/null +++ b/express-zod-api/tests/__snapshots__/json-schema-helpers.spec.ts.snap @@ -0,0 +1,102 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`JSON Schema helpers > flattenIO() > should handle records 1`] = ` +{ + "examples": [ + { + "one": "test", + "two": "jest", + }, + { + "one": "some", + "two": "another", + }, + { + "four": 456, + "three": 123, + }, + ], + "properties": { + "four": { + "type": "number", + }, + "one": { + "type": "string", + }, + "three": { + "type": "number", + }, + "two": { + "type": "string", + }, + }, + "required": [], + "type": "object", +} +`; + +exports[`JSON Schema helpers > flattenIO() > should pass the object schema through 1`] = ` +{ + "examples": [ + { + "one": "test", + }, + ], + "properties": { + "one": { + "type": "string", + }, + }, + "required": [ + "one", + ], + "type": "object", +} +`; + +exports[`JSON Schema helpers > flattenIO() > should return object schema for the intersection of object schemas 1`] = ` +{ + "examples": [ + { + "one": "test", + "two": "jest", + }, + ], + "properties": { + "one": { + "type": "string", + }, + "two": { + "type": "number", + }, + }, + "required": [ + "one", + "two", + ], + "type": "object", +} +`; + +exports[`JSON Schema helpers > flattenIO() > should return object schema for the union of object schemas 1`] = ` +{ + "examples": [ + { + "one": "test", + }, + { + "two": "jest", + }, + ], + "properties": { + "one": { + "type": "string", + }, + "two": { + "type": "number", + }, + }, + "required": [], + "type": "object", +} +`; diff --git a/express-zod-api/tests/io-schema.spec.ts b/express-zod-api/tests/io-schema.spec.ts index 7783865e2..3a4bd7a5d 100644 --- a/express-zod-api/tests/io-schema.spec.ts +++ b/express-zod-api/tests/io-schema.spec.ts @@ -1,7 +1,7 @@ import { expectTypeOf } from "vitest"; import { z } from "zod"; import { IOSchema, Middleware, ez } from "../src"; -import { flattenIO, getFinalEndpointInputSchema } from "../src/io-schema"; +import { getFinalEndpointInputSchema } from "../src/io-schema"; import { metaSymbol } from "../src/metadata"; import { AbstractMiddleware } from "../src/middleware"; @@ -285,75 +285,4 @@ describe("I/O Schema and related helpers", () => { ]); }); }); - - describe("flattenIO()", () => { - test("should pass the object schema through", () => { - const subject = flattenIO({ - type: "object", - properties: { one: { type: "string" } }, - required: ["one"], - examples: [{ one: "test" }], - }); - expect(subject).toMatchSnapshot(); - }); - - test("should return object schema for the union of object schemas", () => { - const subject = flattenIO({ - oneOf: [ - { - type: "object", - properties: { one: { type: "string" } }, - required: ["one"], - examples: [{ one: "test" }], - }, - { - type: "object", - properties: { two: { type: "number" } }, - required: ["two"], - examples: [{ two: "jest" }], - }, - ], - }); - expect(subject).toMatchSnapshot(); - }); - - test("should return object schema for the intersection of object schemas", () => { - const subject = flattenIO({ - allOf: [ - { - type: "object", - properties: { one: { type: "string" } }, - required: ["one"], - examples: [{ one: "test" }], - }, - { - type: "object", - properties: { two: { type: "number" } }, - required: ["two"], - examples: [{ two: "jest" }], - }, - ], - }); - expect(subject).toMatchSnapshot(); - }); - - test("should handle records", () => { - const subject = z.toJSONSchema( - z - .record(z.literal(["one", "two"]), z.string()) - .meta({ - examples: [ - { one: "test", two: "jest" }, - { one: "some", two: "another" }, - ], - }) - .or( - z - .record(z.enum(["three", "four"]), z.number()) - .meta({ examples: [{ three: 123, four: 456 }] }), - ), - ); - expect(flattenIO(subject)).toMatchSnapshot(); - }); - }); }); diff --git a/express-zod-api/tests/json-schema-helpers.spec.ts b/express-zod-api/tests/json-schema-helpers.spec.ts new file mode 100644 index 000000000..defeeed76 --- /dev/null +++ b/express-zod-api/tests/json-schema-helpers.spec.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; +import { flattenIO } from "../src/json-schema-helpers"; + +describe("JSON Schema helpers", () => { + describe("flattenIO()", () => { + test("should pass the object schema through", () => { + const subject = flattenIO({ + type: "object", + properties: { one: { type: "string" } }, + required: ["one"], + examples: [{ one: "test" }], + }); + expect(subject).toMatchSnapshot(); + }); + + test("should return object schema for the union of object schemas", () => { + const subject = flattenIO({ + oneOf: [ + { + type: "object", + properties: { one: { type: "string" } }, + required: ["one"], + examples: [{ one: "test" }], + }, + { + type: "object", + properties: { two: { type: "number" } }, + required: ["two"], + examples: [{ two: "jest" }], + }, + ], + }); + expect(subject).toMatchSnapshot(); + }); + + test("should return object schema for the intersection of object schemas", () => { + const subject = flattenIO({ + allOf: [ + { + type: "object", + properties: { one: { type: "string" } }, + required: ["one"], + examples: [{ one: "test" }], + }, + { + type: "object", + properties: { two: { type: "number" } }, + required: ["two"], + examples: [{ two: "jest" }], + }, + ], + }); + expect(subject).toMatchSnapshot(); + }); + + test("should handle records", () => { + const subject = z.toJSONSchema( + z + .record(z.literal(["one", "two"]), z.string()) + .meta({ + examples: [ + { one: "test", two: "jest" }, + { one: "some", two: "another" }, + ], + }) + .or( + z + .record(z.enum(["three", "four"]), z.number()) + .meta({ examples: [{ three: 123, four: 456 }] }), + ), + ); + expect(flattenIO(subject)).toMatchSnapshot(); + }); + }); +}); From 32946e82081ec9291fce522a4833f7fcd0b974a6 Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Thu, 1 May 2025 15:50:53 +0200 Subject: [PATCH 35/35] todo for refactoring. --- express-zod-api/src/documentation-helpers.ts | 1 + express-zod-api/src/json-schema-helpers.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/express-zod-api/src/documentation-helpers.ts b/express-zod-api/src/documentation-helpers.ts index 4567ccd01..57bc2f080 100644 --- a/express-zod-api/src/documentation-helpers.ts +++ b/express-zod-api/src/documentation-helpers.ts @@ -160,6 +160,7 @@ const canMerge = R.pipe( R.isEmpty, ); +/** @todo DNRY with flattenIO() */ const intersect = ( children: Array, ): JSONSchema.ObjectSchema => { diff --git a/express-zod-api/src/json-schema-helpers.ts b/express-zod-api/src/json-schema-helpers.ts index 9bb1d2da1..8132315ae 100644 --- a/express-zod-api/src/json-schema-helpers.ts +++ b/express-zod-api/src/json-schema-helpers.ts @@ -5,6 +5,7 @@ const isJsonObjectSchema = ( subject: JSONSchema.BaseSchema, ): subject is JSONSchema.ObjectSchema => subject.type === "object"; +/** @todo DNRY with intersect() */ export const flattenIO = (jsonSchema: JSONSchema.BaseSchema) => { const stack = [{ entry: jsonSchema, isOptional: false }]; const flat: Required<