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); +} +"`); +});