diff --git a/.changeset/shy-rocks-fix.md b/.changeset/shy-rocks-fix.md
new file mode 100644
index 00000000..70ec1d87
--- /dev/null
+++ b/.changeset/shy-rocks-fix.md
@@ -0,0 +1,5 @@
+---
+"openapi-zod-client": minor
+---
+
+Add `withDocs` option and `--with-docs` flag that adds JSDoc to generated code
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/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/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/template-context.ts b/lib/src/template-context.ts
index aced2cbd..48a61ea7 100644
--- a/lib/src/template-context.ts
+++ b/lib/src/template-context.ts
@@ -335,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
*
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);
+}
+"`);
+});