diff --git a/lib/CHANGELOG.md b/lib/CHANGELOG.md index 28c9aff0..0a6cf0be 100644 --- a/lib/CHANGELOG.md +++ b/lib/CHANGELOG.md @@ -1,5 +1,35 @@ # openapi-zod-client +## 1.17.0 + +### Minor Changes + +- [#283](https://github.com/astahmer/openapi-zod-client/pull/283) [`3ec4915`](https://github.com/astahmer/openapi-zod-client/commit/3ec491572e56fc40e3b49cefb58cb6f08600190f) Thanks [@dgadelha](https://github.com/dgadelha)! - Add `schemaRefiner` option to allow refining the OpenAPI schema before its converted to a Zod schema + +## 1.16.4 + +### Patch Changes + +- [#279](https://github.com/astahmer/openapi-zod-client/pull/279) [`f3ee25e`](https://github.com/astahmer/openapi-zod-client/commit/f3ee25efc191d0be97231498924fe50fd977fb88) Thanks [@dgadelha](https://github.com/dgadelha)! - Fix multiline descriptions when `describe` is enabled + +## 1.16.3 + +### Patch Changes + +- [#276](https://github.com/astahmer/openapi-zod-client/pull/276) [`aa4c7a3`](https://github.com/astahmer/openapi-zod-client/commit/aa4c7a3668c6d96492bcd319ccd940f0b735b029) Thanks [@tankers746](https://github.com/tankers746)! - Fixed bug which was excluding falsy default values + +## 1.16.2 + +### Patch Changes + +- [#271](https://github.com/astahmer/openapi-zod-client/pull/271) [`197316b`](https://github.com/astahmer/openapi-zod-client/commit/197316b50b0b84cea977984ae82441f2ce108ea0) Thanks [@codingmatty](https://github.com/codingmatty)! - Fix invalid output when using array types as the endpoint body with minItems or maxItems and using the tag-file group-strategy. + +## 1.16.1 + +### Patch Changes + +- [#270](https://github.com/astahmer/openapi-zod-client/pull/270) [`04dd1b5`](https://github.com/astahmer/openapi-zod-client/commit/04dd1b549118c8b8e5a3b86f6dbed741f44770c8) Thanks [@codingmatty](https://github.com/codingmatty)! - Fix bug with `exportAllNamedSchemas` option where schemas will reuse last schema name with matching schema rather than it's own name that has already been used before. + ## 1.16.0 ### Minor Changes diff --git a/lib/package.json b/lib/package.json index 80a40b8e..d75a1e31 100644 --- a/lib/package.json +++ b/lib/package.json @@ -1,6 +1,6 @@ { "name": "openapi-zod-client", - "version": "1.16.0", + "version": "1.17.0", "repository": { "type": "git", "url": "https://github.com/astahmer/openapi-zod-client.git" diff --git a/lib/src/CodeMeta.ts b/lib/src/CodeMeta.ts index ed17305a..6c489655 100644 --- a/lib/src/CodeMeta.ts +++ b/lib/src/CodeMeta.ts @@ -8,6 +8,7 @@ export type ConversionTypeContext = { resolver: DocumentResolver; zodSchemaByName: Record; schemaByName: Record; + schemasByName?: Record; }; export type CodeMetaData = { diff --git a/lib/src/getZodiosEndpointDefinitionList.ts b/lib/src/getZodiosEndpointDefinitionList.ts index 5785851d..c572068f 100644 --- a/lib/src/getZodiosEndpointDefinitionList.ts +++ b/lib/src/getZodiosEndpointDefinitionList.ts @@ -65,6 +65,10 @@ export const getZodiosEndpointDefinitionList = (doc: OpenAPIObject, options?: Te .otherwise((fn) => fn); const ctx: ConversionTypeContext = { resolver, zodSchemaByName: {}, schemaByName: {} }; + if (options?.exportAllNamedSchemas) { + ctx.schemasByName = {}; + } + const complexityThreshold = options?.complexityThreshold ?? 4; const getZodVarName = (input: CodeMeta, fallbackName?: string) => { const result = input.toString(); @@ -95,8 +99,8 @@ export const getZodiosEndpointDefinitionList = (doc: OpenAPIObject, options?: Te let isVarNameAlreadyUsed = false; while ((isVarNameAlreadyUsed = Boolean(ctx.zodSchemaByName[formatedName]))) { if (isVarNameAlreadyUsed) { - if (options?.exportAllNamedSchemas && ctx.schemaByName[result]) { - return ctx.schemaByName[result]!; + if (options?.exportAllNamedSchemas && ctx.schemasByName?.[result]?.includes(formatedName)) { + return formatedName; } else if (ctx.zodSchemaByName[formatedName] === safeName) { return formatedName; } else { @@ -108,6 +112,11 @@ export const getZodiosEndpointDefinitionList = (doc: OpenAPIObject, options?: Te ctx.zodSchemaByName[formatedName] = result; ctx.schemaByName[result] = formatedName; + + if (options?.exportAllNamedSchemas && ctx.schemasByName) { + ctx.schemasByName[result] = (ctx.schemasByName[result] ?? []).concat(formatedName); + } + return formatedName; } @@ -240,7 +249,7 @@ export const getZodiosEndpointDefinitionList = (doc: OpenAPIObject, options?: Te } if (options?.withDescription && paramSchema) { - (paramSchema as SchemaObject).description = (paramItem.description ?? "")?.replace("\n", ""); + (paramSchema as SchemaObject).description = (paramItem.description ?? "").trim(); } // resolve ref if needed, and fallback to default (unknown) value if needed @@ -309,7 +318,11 @@ export const getZodiosEndpointDefinitionList = (doc: OpenAPIObject, options?: Te } if (endpointDefinition.responses !== undefined) { - endpointDefinition.responses.push({ statusCode, schema: schemaString ?? voidSchema, description: responseItem.description }); + endpointDefinition.responses.push({ + statusCode, + schema: schemaString ?? voidSchema, + description: responseItem.description, + }); } if (schemaString) { diff --git a/lib/src/openApiToZod.ts b/lib/src/openApiToZod.ts index 7ff69db7..2bd35d25 100644 --- a/lib/src/openApiToZod.ts +++ b/lib/src/openApiToZod.ts @@ -20,10 +20,12 @@ type ConversionArgs = { * @see https://github.com/colinhacks/zod */ // eslint-disable-next-line sonarjs/cognitive-complexity -export function getZodSchema({ schema, ctx, meta: inheritedMeta, options }: ConversionArgs): CodeMeta { - if (!schema) { +export function getZodSchema({ schema: $schema, ctx, meta: inheritedMeta, options }: ConversionArgs): CodeMeta { + if (!$schema) { throw new Error("Schema is required"); } + + const schema = options?.schemaRefiner?.($schema, inheritedMeta) ?? $schema; const code = new CodeMeta(schema, ctx, inheritedMeta); const meta = { parent: code.inherit(inheritedMeta?.parent), @@ -302,7 +304,11 @@ export const getZodChain = ({ schema, meta, options }: ZodChainArgs) => { .otherwise(() => void 0); if (typeof schema.description === "string" && schema.description !== "" && options?.withDescription) { - chains.push(`describe("${schema.description}")`); + if (["\n", "\r", "\r\n"].some((c) => String.prototype.includes.call(schema.description, c))) { + chains.push(`describe(\`${schema.description}\`)`); + } else { + chains.push(`describe("${schema.description}")`); + } } const output = chains @@ -341,7 +347,7 @@ const unwrapQuotesIfNeeded = (value: string | number) => { }; const getZodChainableDefault = (schema: SchemaObject) => { - if (schema.default) { + if (schema.default !== undefined) { const value = match(schema.type) .with("number", "integer", () => unwrapQuotesIfNeeded(schema.default)) .otherwise(() => JSON.stringify(schema.default)); diff --git a/lib/src/template-context.ts b/lib/src/template-context.ts index 3fc3585e..aced2cbd 100644 --- a/lib/src/template-context.ts +++ b/lib/src/template-context.ts @@ -1,4 +1,4 @@ -import type { OpenAPIObject, OperationObject, PathItemObject, SchemaObject } from "openapi3-ts"; +import type { OpenAPIObject, OperationObject, PathItemObject, ReferenceObject, SchemaObject } from "openapi3-ts"; import { sortBy, sortListFromRefArray, sortObjKeysFromArray } from "pastable/server"; import { ts } from "tanu"; import { match } from "ts-pattern"; @@ -11,6 +11,7 @@ import { getTypescriptFromOpenApi } from "./openApiToTypescript"; import { getZodSchema } from "./openApiToZod"; import { topologicalSort } from "./topologicalSort"; import { asComponentSchema, normalizeString } from "./utils"; +import type { CodeMetaData } from "./CodeMeta"; const file = ts.createSourceFile("", "", ts.ScriptTarget.ESNext, true); const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); @@ -138,7 +139,9 @@ export const getZodClientTemplateContext = ( const addDependencyIfNeeded = (schemaName: string) => { if (!schemaName) return; if (schemaName.startsWith("z.")) return; - dependencies.add(schemaName); + // Sometimes the schema includes a chain that should be removed from the dependency + const [normalizedSchemaName] = schemaName.split("."); + dependencies.add(normalizedSchemaName!); }; addDependencyIfNeeded(endpoint.response); @@ -394,11 +397,16 @@ export type TemplateContextOptions = { * When true, returns a "responses" array with all responses (both success and errors) */ withAllResponses?: boolean; - + /** * When true, prevents using the exact same name for the same type * For example, if 2 schemas have the same type, but different names, export each as separate schemas * If 2 schemas have the same name but different types, export subsequent names with numbers appended */ exportAllNamedSchemas?: boolean; + + /** + * A function that runs in the schema conversion process to refine the schema before it's converted to a Zod schema. + */ + schemaRefiner?: (schema: T, parentMeta?: CodeMetaData) => T; }; diff --git a/lib/tests/array-body-with-chains-tag-group-strategy.test.ts b/lib/tests/array-body-with-chains-tag-group-strategy.test.ts new file mode 100644 index 00000000..aa0d3676 --- /dev/null +++ b/lib/tests/array-body-with-chains-tag-group-strategy.test.ts @@ -0,0 +1,92 @@ +import type { OpenAPIObject } from "openapi3-ts"; +import { expect, test } from "vitest"; +import { generateZodClientFromOpenAPI } from "../src"; + +test("array-body-with-chains-tag-group-strategy", async () => { + const openApiDoc: OpenAPIObject = { + openapi: "3.0.0", + info: { title: "Test", version: "1.0.1" }, + paths: { + "/test": { + put: { + summary: "Test", + description: "Test", + tags: ["Test"], + requestBody: { + content: { + "application/json": { + schema: { + type: "array", + items: { + type: "object", + properties: { + testItem: { + type: "string", + }, + }, + additionalProperties: false, + }, + minItems: 1, + maxItems: 10, + }, + }, + }, + }, + parameters: [], + responses: { + "200": { + description: "Success", + content: { "application/json": {} }, + }, + }, + }, + }, + }, + components: {}, + tags: [], + }; + + const output = await generateZodClientFromOpenAPI({ + disableWriteToFile: true, + openApiDoc, + options: { groupStrategy: "tag-file" }, + }); + expect(output).toMatchInlineSnapshot(` + { + "Test": "import { makeApi, Zodios, type ZodiosOptions } from "@zodios/core"; + import { z } from "zod"; + + const putTest_Body = z.array(z.object({ testItem: z.string() }).partial()); + + export const schemas = { + putTest_Body, + }; + + const endpoints = makeApi([ + { + method: "put", + path: "/test", + description: \`Test\`, + requestFormat: "json", + parameters: [ + { + name: "body", + type: "Body", + schema: putTest_Body.min(1).max(10), + }, + ], + response: z.void(), + }, + ]); + + export const TestApi = new Zodios(endpoints); + + export function createApiClient(baseUrl: string, options?: ZodiosOptions) { + return new Zodios(baseUrl, endpoints, options); + } + ", + "__index": "export { TestApi } from "./Test"; + ", + } + `); +}); diff --git a/lib/tests/description-in-zod.test.ts b/lib/tests/description-in-zod.test.ts index 56344d03..31f3b400 100644 --- a/lib/tests/description-in-zod.test.ts +++ b/lib/tests/description-in-zod.test.ts @@ -31,6 +31,23 @@ test("description-in-zod", async () => { }, description: "bar description", }, + { + in: "query", + name: "baz", + schema: { + type: "number", + enum: [1.3, 34.1, -57.89], + }, + description: "baz\nmultiline\ndescription", + }, + { + in: "query", + name: "qux", + schema: { + type: "string", + }, + description: " ", // spaces only description + }, ], responses: { "200": { @@ -73,6 +90,21 @@ test("description-in-zod", async () => { .describe("bar description") .optional(), }, + { + name: "baz", + type: "Query", + schema: z + .union([z.literal(1.3), z.literal(34.1), z.literal(-57.89)]) + .describe( + \`baz\nmultiline\ndescription\` + ) + .optional(), + }, + { + name: "qux", + type: "Query", + schema: z.string().optional(), + }, ], response: z.void(), }, diff --git a/lib/tests/export-all-named-schemas.test.ts b/lib/tests/export-all-named-schemas.test.ts index 079df9f9..833869fc 100644 --- a/lib/tests/export-all-named-schemas.test.ts +++ b/lib/tests/export-all-named-schemas.test.ts @@ -58,6 +58,11 @@ test("export-all-named-schemas", async () => { }, }, parameters: [ + { + name: "sameSchemaDifferentName", + in: "query", + schema: { type: "string", enum: ["xxx", "yyy", "zzz"] }, + }, { name: "sameSchemaSameName", in: "query", @@ -128,6 +133,11 @@ test("export-all-named-schemas", async () => { "errors": [], "method": "delete", "parameters": [ + { + "name": "sameSchemaDifferentName", + "schema": "sameSchemaDifferentName", + "type": "Query", + }, { "name": "sameSchemaSameName", "schema": "sameSchemaSameName", @@ -160,6 +170,7 @@ test("export-all-named-schemas", async () => { "withAlias": false, }, "schemas": { + "sameSchemaDifferentName": "z.enum(["xxx", "yyy", "zzz"]).optional()", "sameSchemaSameName": "z.enum(["xxx", "yyy", "zzz"]).optional()", "schemaNameAlreadyUsed": "z.enum(["aaa", "bbb", "ccc"]).optional()", "schemaNameAlreadyUsed__2": "z.enum(["ggg", "hhh", "iii"]).optional()", @@ -180,11 +191,13 @@ test("export-all-named-schemas", async () => { const sameSchemaSameName = z.enum(["xxx", "yyy", "zzz"]).optional(); const schemaNameAlreadyUsed = z.enum(["aaa", "bbb", "ccc"]).optional(); + const sameSchemaDifferentName = z.enum(["xxx", "yyy", "zzz"]).optional(); const schemaNameAlreadyUsed__2 = z.enum(["ggg", "hhh", "iii"]).optional(); export const schemas = { sameSchemaSameName, schemaNameAlreadyUsed, + sameSchemaDifferentName, schemaNameAlreadyUsed__2, }; @@ -220,6 +233,11 @@ test("export-all-named-schemas", async () => { path: "/export-all-named-schemas", requestFormat: "json", parameters: [ + { + name: "sameSchemaDifferentName", + type: "Query", + schema: sameSchemaDifferentName, + }, { name: "sameSchemaSameName", type: "Query", diff --git a/lib/tests/samples.test.ts b/lib/tests/samples.test.ts index 8c71c5b0..fb1f207a 100644 --- a/lib/tests/samples.test.ts +++ b/lib/tests/samples.test.ts @@ -398,7 +398,7 @@ describe("samples-generator", async () => { const perform_search_Body = z .object({ criteria: z.string().default("*:*"), - start: z.number().int().optional(), + start: z.number().int().optional().default(0), rows: z.number().int().optional().default(100), }) .passthrough(); diff --git a/lib/tests/schema-refiner.test.ts b/lib/tests/schema-refiner.test.ts new file mode 100644 index 00000000..79b89b42 --- /dev/null +++ b/lib/tests/schema-refiner.test.ts @@ -0,0 +1,41 @@ +import { isReferenceObject } from "openapi3-ts"; +import { getZodSchema } from "../src/openApiToZod"; +import { test, expect } from "vitest"; + +test("schema-refiner", () => { + expect( + getZodSchema({ + schema: { + properties: { + name: { + type: "string", + }, + email: { + type: "string", + }, + }, + }, + options: { + schemaRefiner(schema) { + if (isReferenceObject(schema) || !schema.properties) { + return schema; + } + + if (!schema.required && schema.properties) { + for (const key in schema.properties) { + const prop = schema.properties[key]; + + if (!isReferenceObject(prop)) { + prop.nullable = true; + } + } + } + + return schema; + }, + }, + }) + ).toMatchInlineSnapshot( + '"z.object({ name: z.string().nullable(), email: z.string().nullable() }).partial().passthrough()"' + ); +});