Skip to content

Commit

Permalink
Merge branch 'main' into fix-type-gen
Browse files Browse the repository at this point in the history
  • Loading branch information
C-ra-ZY authored Sep 2, 2024
2 parents 07e0659 + 76d86f6 commit 10e89d5
Show file tree
Hide file tree
Showing 21 changed files with 857 additions and 21 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions lib/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,59 @@
# openapi-zod-client

## 1.18.2

### Patch Changes

- [#294](https://github.com/astahmer/openapi-zod-client/pull/294) [`ca81272`](https://github.com/astahmer/openapi-zod-client/commit/ca812725e369d7e04405a8aa88004e39ccbd6b14) Thanks [@QuentinJanuel](https://github.com/QuentinJanuel)! - Fix format validation for string items in arrays. Previously, format specifications (such as `date-time`) were ignored for array items, resulting in loose validation. This change ensures that the specified formats are now correctly applied to all string items within arrays.

## 1.18.1

### Patch Changes

- [#288](https://github.com/astahmer/openapi-zod-client/pull/288) [`3799ae3`](https://github.com/astahmer/openapi-zod-client/commit/3799ae33b9cbee028918e92d49e7d8bed68bfb5e) Thanks [@mjperrone](https://github.com/mjperrone)! - Fix escaping of forward slash `/` in pattern regex so the output code will be `/\//`. This change also breaks escaping of unnecessarily escaped forward slash `\/` in pattern regex, such that the output code will be `/\\//`.

## 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

- [#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

- [#268](https://github.com/astahmer/openapi-zod-client/pull/268) [`f62be48`](https://github.com/astahmer/openapi-zod-client/commit/f62be48c9d66fb432b5b68570f8de4755644d1d5) Thanks [@codingmatty](https://github.com/codingmatty)! - Add `exportAllNamedSchemas` option to allow exporting duplicate schemas with different names.

## 1.15.1

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion lib/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "openapi-zod-client",
"version": "1.15.1",
"version": "1.18.2",
"repository": {
"type": "git",
"url": "https://github.com/astahmer/openapi-zod-client.git"
Expand Down
1 change: 1 addition & 0 deletions lib/src/CodeMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type ConversionTypeContext = {
resolver: DocumentResolver;
zodSchemaByName: Record<string, string>;
schemaByName: Record<string, string>;
schemasByName?: Record<string, string[]>;
};

export type CodeMetaData = {
Expand Down
2 changes: 2 additions & 0 deletions lib/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ cli.command("<input>", "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'"
Expand Down Expand Up @@ -85,6 +86,7 @@ cli.command("<input>", "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,
Expand Down
45 changes: 45 additions & 0 deletions lib/src/generateJSDocArray.ts
Original file line number Diff line number Diff line change
@@ -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;
}
23 changes: 19 additions & 4 deletions lib/src/getZodiosEndpointDefinitionList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -83,7 +87,7 @@ export const getZodiosEndpointDefinitionList = (doc: OpenAPIObject, options?: Te
const safeName = normalizeString(fallbackName);

// if schema is already assigned to a variable, re-use that variable name
if (ctx.schemaByName[result]) {
if (!options?.exportAllNamedSchemas && ctx.schemaByName[result]) {
return ctx.schemaByName[result]!;
}

Expand All @@ -95,7 +99,9 @@ export const getZodiosEndpointDefinitionList = (doc: OpenAPIObject, options?: Te
let isVarNameAlreadyUsed = false;
while ((isVarNameAlreadyUsed = Boolean(ctx.zodSchemaByName[formatedName]))) {
if (isVarNameAlreadyUsed) {
if (ctx.zodSchemaByName[formatedName] === safeName) {
if (options?.exportAllNamedSchemas && ctx.schemasByName?.[result]?.includes(formatedName)) {
return formatedName;
} else if (ctx.zodSchemaByName[formatedName] === safeName) {
return formatedName;
} else {
reuseCount += 1;
Expand All @@ -106,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;
}

Expand Down Expand Up @@ -238,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
Expand Down Expand Up @@ -307,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) {
Expand Down
26 changes: 22 additions & 4 deletions lib/src/openApiToTypescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand All @@ -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,
Expand All @@ -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);
}

Expand Down Expand Up @@ -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)]));
}
Expand All @@ -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;
Expand Down
24 changes: 19 additions & 5 deletions lib/src/openApiToZod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -211,7 +213,15 @@ export function getZodSchema({ schema, ctx, meta: inheritedMeta, options }: Conv
if (schemaType === "array") {
if (schema.items) {
return code.assign(
`z.array(${getZodSchema({ schema: schema.items, ctx, meta, options }).toString()})${readonly}`
`z.array(${
getZodSchema({ schema: schema.items, ctx, meta, options }).toString()
}${
getZodChain({
schema: schema.items as SchemaObject,
meta: { ...meta, isRequired: true },
options,
})
})${readonly}`
);
}

Expand Down Expand Up @@ -302,7 +312,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
Expand Down Expand Up @@ -341,7 +355,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));
Expand Down
24 changes: 22 additions & 2 deletions lib/src/template-context.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 });
Expand Down Expand Up @@ -176,7 +177,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);
Expand Down Expand Up @@ -370,6 +373,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
*
Expand Down Expand Up @@ -432,4 +440,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?: <T extends SchemaObject | ReferenceObject>(schema: T, parentMeta?: CodeMetaData) => T;
};
3 changes: 2 additions & 1 deletion lib/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ export const escapeControlCharacters = (str: string): string => {
if (dec <= 0xff) return `\\x${`00${hex}`.slice(-2)}`;
// eslint-disable-next-line sonarjs/no-nested-template-literals
return `\\u${`0000${hex}`.slice(-4)}`;
});
})
.replace(/\//g, "\\/");
};

export const toBoolean = (value: undefined | string | boolean, defaultValue: boolean) => match(value)
Expand Down
Loading

0 comments on commit 10e89d5

Please sign in to comment.