From 50fa468560ab9c52d3165dcca3791ce35e787e9a Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 21 Apr 2025 18:14:14 +0200 Subject: [PATCH 1/3] Removing optionalPropStyle. --- express-zod-api/src/integration.ts | 22 +- express-zod-api/src/zts-helpers.ts | 2 - express-zod-api/src/zts.ts | 25 +- .../__snapshots__/integration.spec.ts.snap | 447 ------------------ express-zod-api/tests/integration.spec.ts | 24 - 5 files changed, 8 insertions(+), 512 deletions(-) diff --git a/express-zod-api/src/integration.ts b/express-zod-api/src/integration.ts index 7ee6f5a8d..509c8be57 100644 --- a/express-zod-api/src/integration.ts +++ b/express-zod-api/src/integration.ts @@ -41,22 +41,6 @@ interface IntegrationParams { * @default https://example.com * */ serverUrl?: string; - /** - * @desc configures the style of object's optional properties - * @default { withQuestionMark: true, withUndefined: true } - */ - optionalPropStyle?: { - /** - * @desc add question mark to the optional property definition - * @example { someProp?: boolean } - * */ - withQuestionMark?: boolean; - /** - * @desc add undefined to the property union type - * @example { someProp: boolean | undefined } - */ - withUndefined?: boolean; - }; /** * @desc The schema to use for responses without body such as 204 * @default z.undefined() @@ -110,14 +94,10 @@ export class Integration extends IntegrationBase { clientClassName = "Client", subscriptionClassName = "Subscription", serverUrl = "https://example.com", - optionalPropStyle = { withQuestionMark: true, withUndefined: true }, noContent = z.undefined(), }: IntegrationParams) { super(serverUrl); - const commons = { - makeAlias: this.#makeAlias.bind(this), - optionalPropStyle, - }; + const commons = { makeAlias: this.#makeAlias.bind(this) }; const ctxIn = { brandHandling, ctx: { ...commons, isResponse: false } }; const ctxOut = { brandHandling, ctx: { ...commons, isResponse: true } }; const onEndpoint: OnEndpoint = (endpoint, path, method) => { diff --git a/express-zod-api/src/zts-helpers.ts b/express-zod-api/src/zts-helpers.ts index 32eacb554..53261c7d7 100644 --- a/express-zod-api/src/zts-helpers.ts +++ b/express-zod-api/src/zts-helpers.ts @@ -9,8 +9,6 @@ export interface ZTSContext extends FlatObject { schema: $ZodType | (() => $ZodType), produce: () => ts.TypeNode, ) => ts.TypeNode; - // @todo remove it in favor of z.interface - optionalPropStyle: { withQuestionMark?: boolean; withUndefined?: boolean }; } export type Producer = SchemaHandler; diff --git a/express-zod-api/src/zts.ts b/express-zod-api/src/zts.ts index d76f98ab4..8d8335cb2 100644 --- a/express-zod-api/src/zts.ts +++ b/express-zod-api/src/zts.ts @@ -85,11 +85,7 @@ const onInterface: Producer = (int: z.ZodInterface, { next, makeAlias }) => const onObject: Producer = ( { _zod: { def } }: z.ZodObject, - { - isResponse, - next, - optionalPropStyle: { withQuestionMark: hasQuestionMark }, - }, + { isResponse, next }, ) => { const members = Object.entries(def.shape).map( ([key, value]) => { @@ -103,7 +99,7 @@ const onObject: Producer = ( return makeInterfaceProp(key, next(value), { comment, isDeprecated, - isOptional: isOptional && hasQuestionMark, + isOptional, }); }, ); @@ -131,18 +127,11 @@ const onSomeUnion: Producer = ( const makeSample = (produced: ts.TypeNode) => samples?.[produced.kind as keyof typeof samples]; -const onOptional: Producer = ( - { _zod: { def } }: $ZodOptional, - { next, optionalPropStyle: { withUndefined: hasUndefined } }, -) => { - const actualTypeNode = next(def.innerType); - return hasUndefined - ? f.createUnionTypeNode([ - actualTypeNode, - ensureTypeNode(ts.SyntaxKind.UndefinedKeyword), - ]) - : actualTypeNode; -}; +const onOptional: Producer = ({ _zod: { def } }: $ZodOptional, { next }) => + f.createUnionTypeNode([ + next(def.innerType), + ensureTypeNode(ts.SyntaxKind.UndefinedKeyword), + ]); const onNullable: Producer = ({ _zod: { def } }: $ZodNullable, { next }) => f.createUnionTypeNode([next(def.innerType), makeLiteralType(null)]); diff --git a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap index 74210082a..38e79e133 100644 --- a/express-zod-api/tests/__snapshots__/integration.spec.ts.snap +++ b/express-zod-api/tests/__snapshots__/integration.spec.ts.snap @@ -1,452 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Integration > Feature #945: should have configurable treatment of optionals 0 1`] = ` -"type SomeOf = T[keyof T]; - -/** post /v1/test-with-dashes */ -type PostV1TestWithDashesInput = { - opt?: string; -}; - -/** post /v1/test-with-dashes */ -type PostV1TestWithDashesPositiveVariant1 = { - status: "success"; - data: { - similar?: number; - }; -}; - -/** post /v1/test-with-dashes */ -interface PostV1TestWithDashesPositiveResponseVariants { - 200: PostV1TestWithDashesPositiveVariant1; -} - -/** post /v1/test-with-dashes */ -type PostV1TestWithDashesNegativeVariant1 = { - status: "error"; - error: { - message: string; - }; -}; - -/** post /v1/test-with-dashes */ -interface PostV1TestWithDashesNegativeResponseVariants { - 400: PostV1TestWithDashesNegativeVariant1; -} - -export type Path = "/v1/test-with-dashes"; - -export type Method = "get" | "post" | "put" | "delete" | "patch"; - -export interface Input { - "post /v1/test-with-dashes": PostV1TestWithDashesInput; -} - -export interface PositiveResponse { - "post /v1/test-with-dashes": SomeOf; -} - -export interface NegativeResponse { - "post /v1/test-with-dashes": SomeOf; -} - -export interface EncodedResponse { - "post /v1/test-with-dashes": PostV1TestWithDashesPositiveResponseVariants & - PostV1TestWithDashesNegativeResponseVariants; -} - -export interface Response { - "post /v1/test-with-dashes": - | PositiveResponse["post /v1/test-with-dashes"] - | NegativeResponse["post /v1/test-with-dashes"]; -} - -export type Request = keyof Input; - -export const endpointTags = { "post /v1/test-with-dashes": [] }; - -const parseRequest = (request: string) => - request.split(/ (.+)/, 2) as [Method, Path]; - -const substitute = (path: string, params: Record) => { - const rest = { ...params }; - for (const key in params) { - path = path.replace(\`:\${key}\`, () => { - delete rest[key]; - return params[key]; - }); - } - return [path, rest] as const; -}; - -export type Implementation = ( - method: Method, - path: string, - params: Record, - ctx?: T, -) => Promise; - -const defaultImplementation: Implementation = async (method, path, params) => { - const hasBody = !["get", "delete"].includes(method); - const searchParams = hasBody ? "" : \`?\${new URLSearchParams(params)}\`; - const response = await fetch( - new URL(\`\${path}\${searchParams}\`, "https://example.com"), - { - method: method.toUpperCase(), - headers: hasBody ? { "Content-Type": "application/json" } : undefined, - body: hasBody ? JSON.stringify(params) : undefined, - }, - ); - const contentType = response.headers.get("content-type"); - if (!contentType) return; - const isJSON = contentType.startsWith("application/json"); - return response[isJSON ? "json" : "text"](); -}; - -export class Client { - public constructor( - protected readonly implementation: Implementation = defaultImplementation, - ) {} - public provide( - request: K, - params: Input[K], - ctx?: T, - ): Promise { - const [method, path] = parseRequest(request); - return this.implementation(method, ...substitute(path, params), ctx); - } -} - -export class Subscription< - K extends Extract, - R extends Extract, -> { - public source: EventSource; - public constructor(request: K, params: Input[K]) { - const [path, rest] = substitute(parseRequest(request)[1], params); - const searchParams = \`?\${new URLSearchParams(rest)}\`; - this.source = new EventSource( - new URL(\`\${path}\${searchParams}\`, "https://example.com"), - ); - } - public on( - event: E, - handler: (data: Extract["data"]) => void | Promise, - ) { - this.source.addEventListener(event, (msg) => - handler(JSON.parse((msg as MessageEvent).data)), - ); - return this; - } -} - -// Usage example: -/* -const client = new Client(); -client.provide("get /v1/user/retrieve", { id: "10" }); -new Subscription("get /v1/events/stream", {}).on("time", (time) => {}); -*/ -" -`; - -exports[`Integration > Feature #945: should have configurable treatment of optionals 1 1`] = ` -"type SomeOf = T[keyof T]; - -/** post /v1/test-with-dashes */ -type PostV1TestWithDashesInput = { - opt: string | undefined; -}; - -/** post /v1/test-with-dashes */ -type PostV1TestWithDashesPositiveVariant1 = { - status: "success"; - data: { - similar: number | undefined; - }; -}; - -/** post /v1/test-with-dashes */ -interface PostV1TestWithDashesPositiveResponseVariants { - 200: PostV1TestWithDashesPositiveVariant1; -} - -/** post /v1/test-with-dashes */ -type PostV1TestWithDashesNegativeVariant1 = { - status: "error"; - error: { - message: string; - }; -}; - -/** post /v1/test-with-dashes */ -interface PostV1TestWithDashesNegativeResponseVariants { - 400: PostV1TestWithDashesNegativeVariant1; -} - -export type Path = "/v1/test-with-dashes"; - -export type Method = "get" | "post" | "put" | "delete" | "patch"; - -export interface Input { - "post /v1/test-with-dashes": PostV1TestWithDashesInput; -} - -export interface PositiveResponse { - "post /v1/test-with-dashes": SomeOf; -} - -export interface NegativeResponse { - "post /v1/test-with-dashes": SomeOf; -} - -export interface EncodedResponse { - "post /v1/test-with-dashes": PostV1TestWithDashesPositiveResponseVariants & - PostV1TestWithDashesNegativeResponseVariants; -} - -export interface Response { - "post /v1/test-with-dashes": - | PositiveResponse["post /v1/test-with-dashes"] - | NegativeResponse["post /v1/test-with-dashes"]; -} - -export type Request = keyof Input; - -export const endpointTags = { "post /v1/test-with-dashes": [] }; - -const parseRequest = (request: string) => - request.split(/ (.+)/, 2) as [Method, Path]; - -const substitute = (path: string, params: Record) => { - const rest = { ...params }; - for (const key in params) { - path = path.replace(\`:\${key}\`, () => { - delete rest[key]; - return params[key]; - }); - } - return [path, rest] as const; -}; - -export type Implementation = ( - method: Method, - path: string, - params: Record, - ctx?: T, -) => Promise; - -const defaultImplementation: Implementation = async (method, path, params) => { - const hasBody = !["get", "delete"].includes(method); - const searchParams = hasBody ? "" : \`?\${new URLSearchParams(params)}\`; - const response = await fetch( - new URL(\`\${path}\${searchParams}\`, "https://example.com"), - { - method: method.toUpperCase(), - headers: hasBody ? { "Content-Type": "application/json" } : undefined, - body: hasBody ? JSON.stringify(params) : undefined, - }, - ); - const contentType = response.headers.get("content-type"); - if (!contentType) return; - const isJSON = contentType.startsWith("application/json"); - return response[isJSON ? "json" : "text"](); -}; - -export class Client { - public constructor( - protected readonly implementation: Implementation = defaultImplementation, - ) {} - public provide( - request: K, - params: Input[K], - ctx?: T, - ): Promise { - const [method, path] = parseRequest(request); - return this.implementation(method, ...substitute(path, params), ctx); - } -} - -export class Subscription< - K extends Extract, - R extends Extract, -> { - public source: EventSource; - public constructor(request: K, params: Input[K]) { - const [path, rest] = substitute(parseRequest(request)[1], params); - const searchParams = \`?\${new URLSearchParams(rest)}\`; - this.source = new EventSource( - new URL(\`\${path}\${searchParams}\`, "https://example.com"), - ); - } - public on( - event: E, - handler: (data: Extract["data"]) => void | Promise, - ) { - this.source.addEventListener(event, (msg) => - handler(JSON.parse((msg as MessageEvent).data)), - ); - return this; - } -} - -// Usage example: -/* -const client = new Client(); -client.provide("get /v1/user/retrieve", { id: "10" }); -new Subscription("get /v1/events/stream", {}).on("time", (time) => {}); -*/ -" -`; - -exports[`Integration > Feature #945: should have configurable treatment of optionals 2 1`] = ` -"type SomeOf = T[keyof T]; - -/** post /v1/test-with-dashes */ -type PostV1TestWithDashesInput = { - opt: string; -}; - -/** post /v1/test-with-dashes */ -type PostV1TestWithDashesPositiveVariant1 = { - status: "success"; - data: { - similar: number; - }; -}; - -/** post /v1/test-with-dashes */ -interface PostV1TestWithDashesPositiveResponseVariants { - 200: PostV1TestWithDashesPositiveVariant1; -} - -/** post /v1/test-with-dashes */ -type PostV1TestWithDashesNegativeVariant1 = { - status: "error"; - error: { - message: string; - }; -}; - -/** post /v1/test-with-dashes */ -interface PostV1TestWithDashesNegativeResponseVariants { - 400: PostV1TestWithDashesNegativeVariant1; -} - -export type Path = "/v1/test-with-dashes"; - -export type Method = "get" | "post" | "put" | "delete" | "patch"; - -export interface Input { - "post /v1/test-with-dashes": PostV1TestWithDashesInput; -} - -export interface PositiveResponse { - "post /v1/test-with-dashes": SomeOf; -} - -export interface NegativeResponse { - "post /v1/test-with-dashes": SomeOf; -} - -export interface EncodedResponse { - "post /v1/test-with-dashes": PostV1TestWithDashesPositiveResponseVariants & - PostV1TestWithDashesNegativeResponseVariants; -} - -export interface Response { - "post /v1/test-with-dashes": - | PositiveResponse["post /v1/test-with-dashes"] - | NegativeResponse["post /v1/test-with-dashes"]; -} - -export type Request = keyof Input; - -export const endpointTags = { "post /v1/test-with-dashes": [] }; - -const parseRequest = (request: string) => - request.split(/ (.+)/, 2) as [Method, Path]; - -const substitute = (path: string, params: Record) => { - const rest = { ...params }; - for (const key in params) { - path = path.replace(\`:\${key}\`, () => { - delete rest[key]; - return params[key]; - }); - } - return [path, rest] as const; -}; - -export type Implementation = ( - method: Method, - path: string, - params: Record, - ctx?: T, -) => Promise; - -const defaultImplementation: Implementation = async (method, path, params) => { - const hasBody = !["get", "delete"].includes(method); - const searchParams = hasBody ? "" : \`?\${new URLSearchParams(params)}\`; - const response = await fetch( - new URL(\`\${path}\${searchParams}\`, "https://example.com"), - { - method: method.toUpperCase(), - headers: hasBody ? { "Content-Type": "application/json" } : undefined, - body: hasBody ? JSON.stringify(params) : undefined, - }, - ); - const contentType = response.headers.get("content-type"); - if (!contentType) return; - const isJSON = contentType.startsWith("application/json"); - return response[isJSON ? "json" : "text"](); -}; - -export class Client { - public constructor( - protected readonly implementation: Implementation = defaultImplementation, - ) {} - public provide( - request: K, - params: Input[K], - ctx?: T, - ): Promise { - const [method, path] = parseRequest(request); - return this.implementation(method, ...substitute(path, params), ctx); - } -} - -export class Subscription< - K extends Extract, - R extends Extract, -> { - public source: EventSource; - public constructor(request: K, params: Input[K]) { - const [path, rest] = substitute(parseRequest(request)[1], params); - const searchParams = \`?\${new URLSearchParams(rest)}\`; - this.source = new EventSource( - new URL(\`\${path}\${searchParams}\`, "https://example.com"), - ); - } - public on( - event: E, - handler: (data: Extract["data"]) => void | Promise, - ) { - this.source.addEventListener(event, (msg) => - handler(JSON.parse((msg as MessageEvent).data)), - ); - return this; - } -} - -// Usage example: -/* -const client = new Client(); -client.provide("get /v1/user/retrieve", { id: "10" }); -new Subscription("get /v1/events/stream", {}).on("time", (time) => {}); -*/ -" -`; - exports[`Integration > Feature #1470: Custom brands > should by handled accordingly 1`] = ` "type SomeOf = T[keyof T]; diff --git a/express-zod-api/tests/integration.spec.ts b/express-zod-api/tests/integration.spec.ts index c71ce30b5..c4a6088b4 100644 --- a/express-zod-api/tests/integration.spec.ts +++ b/express-zod-api/tests/integration.spec.ts @@ -66,30 +66,6 @@ describe("Integration", () => { expect(await client.printFormatted()).toMatchSnapshot(); }); - test.each([{ withQuestionMark: true }, { withUndefined: true }, {}])( - "Feature #945: should have configurable treatment of optionals %#", - async (optionalPropStyle) => { - const client = new Integration({ - optionalPropStyle, - routing: { - v1: { - "test-with-dashes": defaultEndpointsFactory.build({ - method: "post", - input: z.object({ - opt: z.string().optional(), - }), - output: z.object({ - similar: z.number().optional(), - }), - handler: async () => ({}), - }), - }, - }, - }); - expect(await client.printFormatted()).toMatchSnapshot(); - }, - ); - test("Should support multiple response schemas depending on status code", async () => { const factory = new EndpointsFactory( new ResultHandler({ From dfbba9c2cd1823d894cae06a863b6b0a49c2860d Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 21 Apr 2025 18:15:19 +0200 Subject: [PATCH 2/3] Cleanup from readme and tests. --- README.md | 1 - express-zod-api/tests/zts.spec.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/README.md b/README.md index 4c41b2bbe..22308a68c 100644 --- a/README.md +++ b/README.md @@ -1269,7 +1269,6 @@ import { Integration } from "express-zod-api"; const client = new Integration({ routing, variant: "client", // <— optional, see also "types" for a DIY solution - optionalPropStyle: { withQuestionMark: true, withUndefined: true }, // optional }); const prettierFormattedTypescriptCode = await client.printFormatted(); // or just .print() for unformatted diff --git a/express-zod-api/tests/zts.spec.ts b/express-zod-api/tests/zts.spec.ts index 002ef4350..2c263900c 100644 --- a/express-zod-api/tests/zts.spec.ts +++ b/express-zod-api/tests/zts.spec.ts @@ -11,7 +11,6 @@ describe("zod-to-ts", () => { const ctx: ZTSContext = { isResponse: false, makeAlias: vi.fn(() => f.createTypeReferenceNode("SomeType")), - optionalPropStyle: { withQuestionMark: true, withUndefined: true }, }; describe("z.array()", () => { From d29cd2588155247df73fc2f86437749b3245f3bb Mon Sep 17 00:00:00 2001 From: Robin Tail Date: Mon, 21 Apr 2025 18:18:13 +0200 Subject: [PATCH 3/3] Changelog: details into v24. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9553a1aa7..c0b8e604a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ - The `numericRange` option removed from `Documentation` class constructor argument; - The `brandHandling` should consist of postprocessing functions altering the depiction made by Zod 4; - The `Depicter` type changed to `Overrider` having different signature; +- The `optionalPropStyle` option removed from `Integration` class constructor: + - Use the new `z.interface()` schema to describe key-optional objects: https://v4.zod.dev/v4#zinterface. ## Version 23