Skip to content

Feature implementation from commits 89b41b5..494583f #2

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: feature-base-2
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions lib/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
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.16.0",
"version": "1.17.0",
"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
21 changes: 17 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 Down Expand Up @@ -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 {
Expand All @@ -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;
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
14 changes: 10 additions & 4 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 @@ -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
Expand Down Expand Up @@ -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));
Expand Down
14 changes: 11 additions & 3 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 @@ -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);
Expand Down Expand Up @@ -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?: <T extends SchemaObject | ReferenceObject>(schema: T, parentMeta?: CodeMetaData) => T;
};
92 changes: 92 additions & 0 deletions lib/tests/array-body-with-chains-tag-group-strategy.test.ts
Original file line number Diff line number Diff line change
@@ -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";
",
}
`);
});
32 changes: 32 additions & 0 deletions lib/tests/description-in-zod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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(),
},
Expand Down
18 changes: 18 additions & 0 deletions lib/tests/export-all-named-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -128,6 +133,11 @@ test("export-all-named-schemas", async () => {
"errors": [],
"method": "delete",
"parameters": [
{
"name": "sameSchemaDifferentName",
"schema": "sameSchemaDifferentName",
"type": "Query",
},
{
"name": "sameSchemaSameName",
"schema": "sameSchemaSameName",
Expand Down Expand Up @@ -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()",
Expand All @@ -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,
};

Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion lib/tests/samples.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading