diff --git a/lib/src/template-context.ts b/lib/src/template-context.ts index 48a61ea..e67c164 100644 --- a/lib/src/template-context.ts +++ b/lib/src/template-context.ts @@ -138,7 +138,20 @@ export const getZodClientTemplateContext = ( const addDependencyIfNeeded = (schemaName: string) => { if (!schemaName) return; - if (schemaName.startsWith("z.")) return; + // Responses can refer to both z chaining methods and models + // To be able to import them we need to extract them from the raw zod expression. + // E.g: `z.array(z.union([FooBar.and(Bar).and(Foo), z.union([FooBar, Bar, Foo])]))` + const isZodExpression = schemaName.includes("z.") + if (isZodExpression) { + for (const name in result.zodSchemaByName) { + // Matching whole word to avoid false positive e.g: avoid macthing `Foo` with `FooBar` + const regex = new RegExp(String.raw`\b(${name})\b`); + if (regex.test(schemaName)) { + dependencies.add(name); + } + } + return + } // Sometimes the schema includes a chain that should be removed from the dependency const [normalizedSchemaName] = schemaName.split("."); dependencies.add(normalizedSchemaName!); diff --git a/lib/tests/extract-common-schema-tag-group.test.ts b/lib/tests/extract-common-schema-tag-group.test.ts new file mode 100644 index 0000000..4e4ef9f --- /dev/null +++ b/lib/tests/extract-common-schema-tag-group.test.ts @@ -0,0 +1,704 @@ +import type { OpenAPIObject, ParameterObject, ReferenceObject, SchemaObject } from "openapi3-ts"; +import { describe, expect, test } from "vitest"; +import { generateZodClientFromOpenAPI } from "../src"; + +describe("Tag file group strategy resolve common schema import from zod expression responses", () => { + type GetMultiTagOpenApiDocArgs = { + responseSchema?: SchemaObject | ReferenceObject; + parameters?: Array; + }; + const getMultiTagOpenApiDoc = ({ parameters, responseSchema: schema }: GetMultiTagOpenApiDocArgs) => { + const responses = + schema != undefined + ? { + "200": { + description: "Success", + content: { + "application/json": { + schema, + }, + }, + }, + } + : undefined; + + const openApiDoc: OpenAPIObject = { + openapi: "3.0.0", + info: { title: "Foo bar api", version: "1.0.1" }, + paths: { + "/foo": { + put: { + summary: "Foo", + description: "Foo", + tags: ["controller-foo"], + responses, + parameters, + }, + }, + "/bar": { + put: { + summary: "bar", + description: "Bar", + tags: ["controller-bar"], + responses, + parameters, + }, + }, + }, + components: { + schemas: { + FooBar: { + type: "object", + properties: { + foo: { + type: "integer", + }, + bar: { + type: "number", + }, + }, + }, + BarFoo: { + type: "object", + properties: { + foo: { + type: "string", + }, + bar: { + type: "boolean", + }, + }, + }, + Bar: { + type: "object", + properties: { + bar: { + type: "string", + }, + }, + }, + Foo: { + type: "object", + properties: { + foo: { + type: "boolean", + }, + }, + }, + }, + }, + tags: [], + }; + return openApiDoc; + }; + + test("SchemaObject referring to ReferencedObject response body should import related common schema", async () => { + const openApiDoc = getMultiTagOpenApiDoc({ + responseSchema: { + type: "object", + properties: { + fooBar: { + $ref: "#/components/schemas/FooBar", + }, + }, + }, + }); + + const output = await generateZodClientFromOpenAPI({ + disableWriteToFile: true, + openApiDoc, + options: { groupStrategy: "tag-file" }, + }); + expect(output).toMatchInlineSnapshot(` + { + "__common": "import { z } from "zod"; + + export const FooBar = z + .object({ foo: z.number().int(), bar: z.number() }) + .partial() + .passthrough(); + ", + "__index": "export { Controller_fooApi } from "./controller_foo"; + export { Controller_barApi } from "./controller_bar"; + ", + "controller_bar": "import { makeApi, Zodios, type ZodiosOptions } from "@zodios/core"; + import { z } from "zod"; + + import { FooBar } from "./common"; + + const endpoints = makeApi([ + { + method: "put", + path: "/bar", + description: \`Bar\`, + requestFormat: "json", + response: z.object({ fooBar: FooBar }).partial().passthrough(), + }, + ]); + + export const Controller_barApi = new Zodios(endpoints); + + export function createApiClient(baseUrl: string, options?: ZodiosOptions) { + return new Zodios(baseUrl, endpoints, options); + } + ", + "controller_foo": "import { makeApi, Zodios, type ZodiosOptions } from "@zodios/core"; + import { z } from "zod"; + + import { FooBar } from "./common"; + + const endpoints = makeApi([ + { + method: "put", + path: "/foo", + description: \`Foo\`, + requestFormat: "json", + response: z.object({ fooBar: FooBar }).partial().passthrough(), + }, + ]); + + export const Controller_fooApi = new Zodios(endpoints); + + export function createApiClient(baseUrl: string, options?: ZodiosOptions) { + return new Zodios(baseUrl, endpoints, options); + } + ", + } + `); + }); + + test("Array of $refs response body should import related common schema", async () => { + const openApiDoc = getMultiTagOpenApiDoc({ + responseSchema: { + type: "array", + items: { + $ref: "#/components/schemas/FooBar", + }, + }, + }); + const output = await generateZodClientFromOpenAPI({ + disableWriteToFile: true, + openApiDoc, + options: { groupStrategy: "tag-file" }, + }); + expect(output).toMatchInlineSnapshot(` + { + "__common": "import { z } from "zod"; + + export const FooBar = z + .object({ foo: z.number().int(), bar: z.number() }) + .partial() + .passthrough(); + ", + "__index": "export { Controller_fooApi } from "./controller_foo"; + export { Controller_barApi } from "./controller_bar"; + ", + "controller_bar": "import { makeApi, Zodios, type ZodiosOptions } from "@zodios/core"; + import { z } from "zod"; + + import { FooBar } from "./common"; + + const endpoints = makeApi([ + { + method: "put", + path: "/bar", + description: \`Bar\`, + requestFormat: "json", + response: z.array(FooBar), + }, + ]); + + export const Controller_barApi = new Zodios(endpoints); + + export function createApiClient(baseUrl: string, options?: ZodiosOptions) { + return new Zodios(baseUrl, endpoints, options); + } + ", + "controller_foo": "import { makeApi, Zodios, type ZodiosOptions } from "@zodios/core"; + import { z } from "zod"; + + import { FooBar } from "./common"; + + const endpoints = makeApi([ + { + method: "put", + path: "/foo", + description: \`Foo\`, + requestFormat: "json", + response: z.array(FooBar), + }, + ]); + + export const Controller_fooApi = new Zodios(endpoints); + + export function createApiClient(baseUrl: string, options?: ZodiosOptions) { + return new Zodios(baseUrl, endpoints, options); + } + ", + } + `); + }); + + test("Complex nested intersections response body should import related common schema", async () => { + const openApiDoc = getMultiTagOpenApiDoc({ + responseSchema: { + type: "array", + items: { + oneOf: [ + { + allOf: [ + { + $ref: "#/components/schemas/FooBar", + }, + { + $ref: "#/components/schemas/Bar", + }, + { + $ref: "#/components/schemas/Foo", + }, + ], + }, + { + oneOf: [ + { + $ref: "#/components/schemas/FooBar", + }, + { + $ref: "#/components/schemas/Bar", + }, + { + $ref: "#/components/schemas/Foo", + }, + ], + }, + { + anyOf: [ + { + $ref: "#/components/schemas/FooBar", + }, + { + $ref: "#/components/schemas/Foo", + }, + ], + }, + ], + }, + }, + }); + + const output = await generateZodClientFromOpenAPI({ + disableWriteToFile: true, + openApiDoc, + options: { groupStrategy: "tag-file" }, + }); + expect(output).toMatchInlineSnapshot(` + { + "__common": "import { z } from "zod"; + + export const FooBar = z + .object({ foo: z.number().int(), bar: z.number() }) + .partial() + .passthrough(); + export const Bar = z.object({ bar: z.string() }).partial().passthrough(); + export const Foo = z.object({ foo: z.boolean() }).partial().passthrough(); + ", + "__index": "export { Controller_fooApi } from "./controller_foo"; + export { Controller_barApi } from "./controller_bar"; + ", + "controller_bar": "import { makeApi, Zodios, type ZodiosOptions } from "@zodios/core"; + import { z } from "zod"; + + import { FooBar } from "./common"; + import { Bar } from "./common"; + import { Foo } from "./common"; + + const endpoints = makeApi([ + { + method: "put", + path: "/bar", + description: \`Bar\`, + requestFormat: "json", + response: z.array( + z.union([ + FooBar.and(Bar).and(Foo), + z.union([FooBar, Bar, Foo]), + z.union([FooBar, Foo]), + ]) + ), + }, + ]); + + export const Controller_barApi = new Zodios(endpoints); + + export function createApiClient(baseUrl: string, options?: ZodiosOptions) { + return new Zodios(baseUrl, endpoints, options); + } + ", + "controller_foo": "import { makeApi, Zodios, type ZodiosOptions } from "@zodios/core"; + import { z } from "zod"; + + import { FooBar } from "./common"; + import { Bar } from "./common"; + import { Foo } from "./common"; + + const endpoints = makeApi([ + { + method: "put", + path: "/foo", + description: \`Foo\`, + requestFormat: "json", + response: z.array( + z.union([ + FooBar.and(Bar).and(Foo), + z.union([FooBar, Bar, Foo]), + z.union([FooBar, Foo]), + ]) + ), + }, + ]); + + export const Controller_fooApi = new Zodios(endpoints); + + export function createApiClient(baseUrl: string, options?: ZodiosOptions) { + return new Zodios(baseUrl, endpoints, options); + } + ", + } + `); + }); + + test("SchemaObject referring to ReferencedObject through parameters should import related common schema", async () => { + const openApiDoc = getMultiTagOpenApiDoc({ + parameters: [ + { + in: "query", + name: "array-ref-object", + schema: { + type: "array", + items: { $ref: "#/components/schemas/Bar" }, + default: [{ id: 1, name: "foo" }], + }, + }, + { + in: "query", + name: "array-ref-enum", + schema: { + type: "array", + items: { $ref: "#/components/schemas/FooBar" }, + default: ["one", "two"], + }, + }, + ], + }); + + const output = await generateZodClientFromOpenAPI({ + disableWriteToFile: true, + openApiDoc, + options: { groupStrategy: "tag-file" }, + }); + expect(output).toMatchInlineSnapshot(` + { + "__common": "import { z } from "zod"; + + export const Bar = z.object({ bar: z.string() }).partial().passthrough(); + export const FooBar = z + .object({ foo: z.number().int(), bar: z.number() }) + .partial() + .passthrough(); + ", + "__index": "export { Controller_fooApi } from "./controller_foo"; + export { Controller_barApi } from "./controller_bar"; + ", + "controller_bar": "import { makeApi, Zodios, type ZodiosOptions } from "@zodios/core"; + import { z } from "zod"; + + import { Bar } from "./common"; + import { FooBar } from "./common"; + + const endpoints = makeApi([ + { + method: "put", + path: "/bar", + description: \`Bar\`, + requestFormat: "json", + parameters: [ + { + name: "array-ref-object", + type: "Query", + schema: z + .array(Bar) + .optional() + .default([{ id: 1, name: "foo" }]), + }, + { + name: "array-ref-enum", + type: "Query", + schema: z.array(FooBar).optional().default(["one", "two"]), + }, + ], + response: z.void(), + }, + ]); + + export const Controller_barApi = new Zodios(endpoints); + + export function createApiClient(baseUrl: string, options?: ZodiosOptions) { + return new Zodios(baseUrl, endpoints, options); + } + ", + "controller_foo": "import { makeApi, Zodios, type ZodiosOptions } from "@zodios/core"; + import { z } from "zod"; + + import { Bar } from "./common"; + import { FooBar } from "./common"; + + const endpoints = makeApi([ + { + method: "put", + path: "/foo", + description: \`Foo\`, + requestFormat: "json", + parameters: [ + { + name: "array-ref-object", + type: "Query", + schema: z + .array(Bar) + .optional() + .default([{ id: 1, name: "foo" }]), + }, + { + name: "array-ref-enum", + type: "Query", + schema: z.array(FooBar).optional().default(["one", "two"]), + }, + ], + response: z.void(), + }, + ]); + + export const Controller_fooApi = new Zodios(endpoints); + + export function createApiClient(baseUrl: string, options?: ZodiosOptions) { + return new Zodios(baseUrl, endpoints, options); + } + ", + } + `); + }); + + test("Import only whole word matching common model from zod expression", async () => { + const openApiDoc: OpenAPIObject = { + openapi: "3.0.0", + info: { title: "Foo bar api", version: "1.0.1" }, + paths: { + "/bar": { + put: { + summary: "bar", + description: "Bar", + tags: ["controller-bar"], + responses: { + "200": { + description: "Success", + content: { + "application/json": { + schema: { + type: "array", + items: { + allOf: [ + { + $ref: "#/components/schemas/FooBar", + }, + { + $ref: "#/components/schemas/Bar", + }, + { + $ref: "#/components/schemas/Foo", + }, + ], + }, + }, + }, + }, + }, + }, + }, + get: { + summary: "bar", + description: "Bar", + tags: ["controller-bar"], + responses: { + "200": { + description: "Success", + content: { + "application/json": { + schema: { + type: "object", + oneOf: [ + { + $ref: "#/components/schemas/FooBar", + }, + { + $ref: "#/components/schemas/Bar", + }, + { + $ref: "#/components/schemas/Foo", + }, + ], + }, + }, + }, + }, + }, + }, + post: { + summary: "bar", + description: "Bar", + tags: ["should-only-import-foobar-and-foo"], + responses: { + "200": { + description: "Success", + content: { + "application/json": { + schema: { + type: "object", + oneOf: [ + { + $ref: "#/components/schemas/FooBar", + }, + { + $ref: "#/components/schemas/Foo", + }, + ], + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + FooBar: { + type: "object", + properties: { + foo: { + type: "integer", + }, + bar: { + type: "number", + }, + }, + }, + BarFoo: { + type: "object", + properties: { + foo: { + type: "string", + }, + bar: { + type: "boolean", + }, + }, + }, + Bar: { + type: "object", + properties: { + bar: { + type: "string", + }, + }, + }, + Foo: { + type: "object", + properties: { + foo: { + type: "boolean", + }, + }, + }, + }, + }, + tags: [], + }; + + const output = await generateZodClientFromOpenAPI({ + disableWriteToFile: true, + openApiDoc, + options: { groupStrategy: "tag-file" }, + }); + expect(output).toMatchInlineSnapshot(` + { + "__common": "import { z } from "zod"; + + export const FooBar = z + .object({ foo: z.number().int(), bar: z.number() }) + .partial() + .passthrough(); + export const Foo = z.object({ foo: z.boolean() }).partial().passthrough(); + ", + "__index": "export { Controller_barApi } from "./controller_bar"; + export { Should_only_import_foobar_and_fooApi } from "./should_only_import_foobar_and_foo"; + ", + "controller_bar": "import { makeApi, Zodios, type ZodiosOptions } from "@zodios/core"; + import { z } from "zod"; + + import { FooBar } from "./common"; + import { Foo } from "./common"; + + const Bar = z.object({ bar: z.string() }).partial().passthrough(); + + export const schemas = { + Bar, + }; + + const endpoints = makeApi([ + { + method: "put", + path: "/bar", + description: \`Bar\`, + requestFormat: "json", + response: z.array(FooBar.and(Bar).and(Foo)), + }, + { + method: "get", + path: "/bar", + description: \`Bar\`, + requestFormat: "json", + response: z.union([FooBar, Bar, Foo]), + }, + ]); + + export const Controller_barApi = new Zodios(endpoints); + + export function createApiClient(baseUrl: string, options?: ZodiosOptions) { + return new Zodios(baseUrl, endpoints, options); + } + ", + "should_only_import_foobar_and_foo": "import { makeApi, Zodios, type ZodiosOptions } from "@zodios/core"; + import { z } from "zod"; + + import { FooBar } from "./common"; + import { Foo } from "./common"; + + const endpoints = makeApi([ + { + method: "post", + path: "/bar", + description: \`Bar\`, + requestFormat: "json", + response: z.union([FooBar, Foo]), + }, + ]); + + export const Should_only_import_foobar_and_fooApi = new Zodios(endpoints); + + export function createApiClient(baseUrl: string, options?: ZodiosOptions) { + return new Zodios(baseUrl, endpoints, options); + } + ", + } + `); + }); +});