diff --git a/README.md b/README.md
index 912400fe..7a5169c6 100644
--- a/README.md
+++ b/README.md
@@ -71,6 +71,7 @@ Options:
--implicit-required When true, will make all properties of an object required by default (rather than the current opposite), unless an explicitly `required` array is set
--with-deprecated when true, will keep deprecated endpoints in the api output
--with-description when true, will add z.describe(xxx)
+ --with-docs when true, will add jsdoc comments to generated types
--group-strategy groups endpoints by a given strategy, possible values are: 'none' | 'tag' | 'method' | 'tag-file' | 'method-file'
--complexity-threshold schema complexity threshold to determine which one (using less than `<` operator) should be assigned to a variable
--default-status when defined as `auto-correct`, will automatically use `default` as fallback for `response` when no status code was declared
diff --git a/lib/CHANGELOG.md b/lib/CHANGELOG.md
index dc098d92..ffcd2302 100644
--- a/lib/CHANGELOG.md
+++ b/lib/CHANGELOG.md
@@ -1,5 +1,29 @@
# openapi-zod-client
+## 1.18.0
+
+### Minor Changes
+
+- [#275](https://github.com/astahmer/openapi-zod-client/pull/275) [`ed50076`](https://github.com/astahmer/openapi-zod-client/commit/ed500762c6998fb2976e8ad43a88a3a09d928f2c) Thanks [@senecolas](https://github.com/senecolas)! - Add `withDocs` option and `--with-docs` flag that adds JSDoc to generated code
+
+## 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
diff --git a/lib/package.json b/lib/package.json
index cdcc376c..1f981cfb 100644
--- a/lib/package.json
+++ b/lib/package.json
@@ -1,6 +1,6 @@
{
"name": "openapi-zod-client",
- "version": "1.16.2",
+ "version": "1.18.0",
"repository": {
"type": "git",
"url": "https://github.com/astahmer/openapi-zod-client.git"
diff --git a/lib/src/cli.ts b/lib/src/cli.ts
index c0f8c7a2..206ed2a0 100644
--- a/lib/src/cli.ts
+++ b/lib/src/cli.ts
@@ -37,6 +37,7 @@ cli.command("", "path/url to OpenAPI/Swagger document as json/yaml")
)
.option("--with-deprecated", "when true, will keep deprecated endpoints in the api output")
.option("--with-description", "when true, will add z.describe(xxx)")
+ .option("--with-docs", "when true, will add jsdoc comments to generated types")
.option(
"--group-strategy",
"groups endpoints by a given strategy, possible values are: 'none' | 'tag' | 'method' | 'tag-file' | 'method-file'"
@@ -85,6 +86,7 @@ cli.command("", "path/url to OpenAPI/Swagger document as json/yaml")
isMediaTypeAllowed: options.mediaTypeExpr,
withImplicitRequiredProps: options.implicitRequired,
withDeprecatedEndpoints: options.withDeprecated,
+ withDocs: options.withDocs,
groupStrategy: options.groupStrategy,
complexityThreshold: options.complexityThreshold,
defaultStatusBehavior: options.defaultStatus,
diff --git a/lib/src/generateJSDocArray.ts b/lib/src/generateJSDocArray.ts
new file mode 100644
index 00000000..60ed63e8
--- /dev/null
+++ b/lib/src/generateJSDocArray.ts
@@ -0,0 +1,45 @@
+import type { SchemaObject } from "openapi3-ts";
+
+export default function generateJSDocArray(schema: SchemaObject, withTypesAndFormat = false): string[] {
+ const comments: string[] = [];
+
+ const mapping = {
+ description: (value: string) => `${value}`,
+ example: (value: any) => `@example ${JSON.stringify(value)}`,
+ examples: (value: any[]) =>
+ value.map((example, index) => `@example Example ${index + 1}: ${JSON.stringify(example)}`),
+ deprecated: (value: boolean) => (value ? "@deprecated" : ""),
+ default: (value: any) => `@default ${JSON.stringify(value)}`,
+ externalDocs: (value: { url: string }) => `@see ${value.url}`,
+ // Additional attributes that depend on `withTypesAndFormat`
+ type: withTypesAndFormat
+ ? (value: string | string[]) => `@type {${Array.isArray(value) ? value.join("|") : value}}`
+ : undefined,
+ format: withTypesAndFormat ? (value: string) => `@format ${value}` : undefined,
+ minimum: (value: number) => `@minimum ${value}`,
+ maximum: (value: number) => `@maximum ${value}`,
+ minLength: (value: number) => `@minLength ${value}`,
+ maxLength: (value: number) => `@maxLength ${value}`,
+ pattern: (value: string) => `@pattern ${value}`,
+ enum: (value: string[]) => `@enum ${value.join(", ")}`,
+ };
+
+ Object.entries(mapping).forEach(([key, mappingFunction]) => {
+ const schemaValue = schema[key as keyof SchemaObject];
+ if (schemaValue !== undefined && mappingFunction) {
+ const result = mappingFunction(schemaValue);
+ if (Array.isArray(result)) {
+ result.forEach((subResult) => comments.push(subResult));
+ } else if (result) {
+ comments.push(result);
+ }
+ }
+ });
+
+ // Add a space line after description if there are other comments
+ if (comments.length > 1 && !!schema.description) {
+ comments.splice(1, 0, "");
+ }
+
+ return comments;
+}
diff --git a/lib/src/getZodiosEndpointDefinitionList.ts b/lib/src/getZodiosEndpointDefinitionList.ts
index 40dbbe75..c572068f 100644
--- a/lib/src/getZodiosEndpointDefinitionList.ts
+++ b/lib/src/getZodiosEndpointDefinitionList.ts
@@ -249,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
diff --git a/lib/src/openApiToTypescript.ts b/lib/src/openApiToTypescript.ts
index bd945fd2..cb7a09b0 100644
--- a/lib/src/openApiToTypescript.ts
+++ b/lib/src/openApiToTypescript.ts
@@ -7,6 +7,7 @@ import type { DocumentResolver } from "./makeSchemaResolver";
import type { TemplateContext } from "./template-context";
import { wrapWithQuotesIfNeeded } from "./utils";
import { inferRequiredSchema } from "./inferRequiredOnly";
+import generateJSDocArray from "./generateJSDocArray";
type TsConversionArgs = {
schema: SchemaObject | ReferenceObject;
@@ -155,6 +156,7 @@ TsConversionArgs): ts.Node | TypeDefinitionObject | string => {
if (schema.allOf.length === 1) {
return getTypescriptFromOpenApi({ schema: schema.allOf[0]!, ctx, meta, options });
}
+
const { patchRequiredSchemaInLoop, noRequiredOnlyAllof, composedRequiredSchema } =
inferRequiredSchema(schema);
@@ -164,7 +166,7 @@ TsConversionArgs): ts.Node | TypeDefinitionObject | string => {
return type;
});
- if (Object.keys(composedRequiredSchema.properties).length) {
+ if (Object.keys(composedRequiredSchema.properties).length > 0) {
types.push(
getTypescriptFromOpenApi({
schema: composedRequiredSchema,
@@ -174,6 +176,7 @@ TsConversionArgs): ts.Node | TypeDefinitionObject | string => {
}) as TypeDefinition
);
}
+
return schema.nullable ? t.union([t.intersection(types), t.reference("null")]) : t.intersection(types);
}
@@ -294,8 +297,9 @@ TsConversionArgs): ts.Node | TypeDefinitionObject | string => {
throw new Error("Name is required to convert an object schema to a type reference");
}
- const base = t.type(inheritedMeta.name, doWrapReadOnly(objectType));
- if (!isPartial) return base;
+ if (!isPartial) {
+ return t.type(inheritedMeta.name, doWrapReadOnly(objectType));
+ }
return t.type(inheritedMeta.name, t.reference("Partial", [doWrapReadOnly(objectType)]));
}
@@ -305,7 +309,21 @@ TsConversionArgs): ts.Node | TypeDefinitionObject | string => {
throw new Error(`Unsupported schema type: ${schemaType}`);
};
- const tsResult = getTs();
+ let tsResult = getTs();
+
+ // Add JSDoc comments
+ if (options?.withDocs && !isReferenceObject(schema)) {
+ const jsDocComments = generateJSDocArray(schema);
+
+ if (
+ jsDocComments.length > 0 &&
+ typeof tsResult === "object" &&
+ tsResult.kind !== ts.SyntaxKind.TypeAliasDeclaration
+ ) {
+ tsResult = t.comment(tsResult, jsDocComments);
+ }
+ }
+
return canBeWrapped
? wrapTypeIfInline({ isInline, name: inheritedMeta?.name, typeDef: tsResult as TypeDefinition })
: tsResult;
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 03f34d14..48a61ea7 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 });
@@ -334,6 +335,11 @@ export type TemplateContextOptions = {
* @default false
*/
withDeprecatedEndpoints?: boolean;
+ /**
+ * when true, will add jsdoc comments to generated types
+ * @default false
+ */
+ withDocs?: boolean;
/**
* groups endpoints by a given strategy
*
@@ -403,4 +409,9 @@ export type TemplateContextOptions = {
* 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/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/jsdoc.test.ts b/lib/tests/jsdoc.test.ts
new file mode 100644
index 00000000..ba91812e
--- /dev/null
+++ b/lib/tests/jsdoc.test.ts
@@ -0,0 +1,202 @@
+import { OpenAPIObject } from "openapi3-ts";
+import { test, expect } from "vitest";
+import { generateZodClientFromOpenAPI } from "../src";
+
+test("jsdoc", async () => {
+ const openApiDoc: OpenAPIObject = {
+ openapi: "3.0.3",
+ info: { version: "1", title: "Example API" },
+ paths: {
+ "/test": {
+ get: {
+ operationId: "123_example",
+ responses: {
+ "200": {
+ content: { "application/json": { schema: { $ref: "#/components/schemas/ComplexObject" } } },
+ },
+ },
+ },
+ },
+ },
+ components: {
+ schemas: {
+ SimpleObject: {
+ type: "object",
+ properties: {
+ str: { type: "string" },
+ },
+ },
+ ComplexObject: {
+ type: "object",
+ properties: {
+ example: {
+ type: "string",
+ description: "A string with example tag",
+ example: "example",
+ },
+ examples: {
+ type: "string",
+ description: "A string with examples tag",
+ examples: ["example1", "example2"],
+ },
+ manyTagsStr: {
+ type: "string",
+ description: "A string with many tags",
+ minLength: 1,
+ maxLength: 10,
+ pattern: "^[a-z]*$",
+ enum: ["a", "b", "c"],
+ },
+ numMin: {
+ type: "number",
+ description: "A number with minimum tag",
+ minimum: 0,
+ },
+ numMax: {
+ type: "number",
+ description: "A number with maximum tag",
+ maximum: 10,
+ },
+ manyTagsNum: {
+ type: "number",
+ description: "A number with many tags",
+ minimum: 0,
+ maximum: 10,
+ default: 5,
+ example: 3,
+ deprecated: true,
+ externalDocs: { url: "https://example.com" },
+ },
+ bool: {
+ type: "boolean",
+ description: "A boolean",
+ default: true,
+ },
+ ref: { $ref: "#/components/schemas/SimpleObject" },
+ refArray: {
+ type: "array",
+ description: "An array of SimpleObject",
+ items: {
+ $ref: "#/components/schemas/SimpleObject",
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+
+ const output = await generateZodClientFromOpenAPI({
+ disableWriteToFile: true,
+ openApiDoc,
+ options: {
+ withDocs: true,
+ shouldExportAllTypes: true,
+ },
+ });
+
+ expect(output).toMatchInlineSnapshot(`"import { makeApi, Zodios, type ZodiosOptions } from "@zodios/core";
+import { z } from "zod";
+
+type ComplexObject = Partial<{
+ /**
+ * A string with example tag
+ *
+ * @example "example"
+ */
+ example: string;
+ /**
+ * A string with examples tag
+ *
+ * @example Example 1: "example1"
+ * @example Example 2: "example2"
+ */
+ examples: string;
+ /**
+ * A string with many tags
+ *
+ * @minLength 1
+ * @maxLength 10
+ * @pattern ^[a-z]*$
+ * @enum a, b, c
+ */
+ manyTagsStr: "a" | "b" | "c";
+ /**
+ * A number with minimum tag
+ *
+ * @minimum 0
+ */
+ numMin: number;
+ /**
+ * A number with maximum tag
+ *
+ * @maximum 10
+ */
+ numMax: number;
+ /**
+ * A number with many tags
+ *
+ * @example 3
+ * @deprecated
+ * @default 5
+ * @see https://example.com
+ * @minimum 0
+ * @maximum 10
+ */
+ manyTagsNum: number;
+ /**
+ * A boolean
+ *
+ * @default true
+ */
+ bool: boolean;
+ ref: SimpleObject;
+ /**
+ * An array of SimpleObject
+ */
+ refArray: Array;
+}>;
+type SimpleObject = Partial<{
+ str: string;
+}>;
+
+const SimpleObject: z.ZodType = z
+ .object({ str: z.string() })
+ .partial()
+ .passthrough();
+const ComplexObject: z.ZodType = z
+ .object({
+ example: z.string(),
+ examples: z.string(),
+ manyTagsStr: z.enum(["a", "b", "c"]).regex(/^[a-z]*$/),
+ numMin: z.number().gte(0),
+ numMax: z.number().lte(10),
+ manyTagsNum: z.number().gte(0).lte(10).default(5),
+ bool: z.boolean().default(true),
+ ref: SimpleObject,
+ refArray: z.array(SimpleObject),
+ })
+ .partial()
+ .passthrough();
+
+export const schemas = {
+ SimpleObject,
+ ComplexObject,
+};
+
+const endpoints = makeApi([
+ {
+ method: "get",
+ path: "/test",
+ requestFormat: "json",
+ response: ComplexObject,
+ },
+]);
+
+export const api = new Zodios(endpoints);
+
+export function createApiClient(baseUrl: string, options?: ZodiosOptions) {
+ return new Zodios(baseUrl, endpoints, options);
+}
+"`);
+});
diff --git a/lib/tests/regex-with-escapes.test.ts b/lib/tests/regex-with-escapes.test.ts
index 69c75fc7..4278c296 100644
--- a/lib/tests/regex-with-escapes.test.ts
+++ b/lib/tests/regex-with-escapes.test.ts
@@ -8,11 +8,11 @@ test("regex-with-escapes", () => {
properties: {
str: {
type: "string",
- pattern: "^\/$"
+ pattern: "^/$"
},
}
}})
).toMatchInlineSnapshot(
- '"z.object({ str: z.string().regex(/^\/$/) }).partial().passthrough()"'
+ '"z.object({ str: z.string().regex(/^\\/$/) }).partial().passthrough()"'
);
});
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()"'
+ );
+});