Skip to content

Commit fe6803d

Browse files
committed
Support zod-openapi
1 parent 0575358 commit fe6803d

File tree

9 files changed

+340
-3
lines changed

9 files changed

+340
-3
lines changed
File renamed without changes.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import express from "express";
2+
import cors from "cors";
3+
import { OpenAPIV3_1 } from "openapi-types";
4+
import z from "zod";
5+
import { toOpenApiDoc } from "@notainc/typed-api-spec/zod/openapi";
6+
import { ZodOpenApiEndpoints } from "@notainc/typed-api-spec/zod/openapi";
7+
8+
const openapiBaseDoc: Omit<OpenAPIV3_1.Document, "paths"> = {
9+
openapi: "3.1.0",
10+
servers: [{ url: "http://locahost:3000" }],
11+
info: {
12+
title: "typed-api-spec OpenAPI Example",
13+
version: "1",
14+
description:
15+
"This is a sample Pet Store Server based on the OpenAPI 3.1 specification.",
16+
},
17+
tags: [{ name: "pets", description: "Everything about your Pets" }],
18+
};
19+
20+
const apiEndpoints = {
21+
"/pets/:petId": {
22+
get: {
23+
summary: "Find pet by ID",
24+
description: "Returns a single pet",
25+
tags: ["pets"],
26+
params: z.object({ petId: z.string() }),
27+
query: z.object({ page: z.string() }),
28+
responses: {
29+
200: {
30+
body: z.object({ name: z.string() }),
31+
description: "List of pets",
32+
},
33+
},
34+
},
35+
},
36+
"/pets": {
37+
post: {
38+
description: "Add new pet",
39+
body: z.object({ name: z.string() }),
40+
responses: {
41+
200: {
42+
body: z.object({ message: z.string() }),
43+
description: "Created pet",
44+
},
45+
},
46+
},
47+
},
48+
} satisfies ZodOpenApiEndpoints;
49+
50+
const newApp = () => {
51+
const app = express();
52+
app.use(express.json());
53+
app.use(cors());
54+
// const wApp = asAsync(typed(apiEndpoints, app));
55+
app.get("/openapi", (req, res) => {
56+
const openapi = toOpenApiDoc(openapiBaseDoc, apiEndpoints);
57+
res.status(200).json(openapi);
58+
});
59+
return app;
60+
};
61+
62+
const main = async () => {
63+
const app = newApp();
64+
const port = 3000;
65+
app.listen(port, () => {
66+
console.log(`Example app listening on port ${port}`);
67+
});
68+
};
69+
70+
main();

examples/misc/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
"test:type-check": "tsc --noEmit",
99
"ex:express:zod:server": "tsx express/zod/express.ts",
1010
"ex:express:zod:client": "tsx express/zod/fetch.ts",
11-
"ex:express:openapi": "tsx express/openapi/index.ts",
11+
"ex:express:zod:openapi": "tsx express/zod/openapi/index.ts",
1212
"ex:express:valibot:server": "tsx express/valibot/express.ts",
1313
"ex:express:valibot:client": "tsx express/valibot/fetch.ts",
14+
"ex:express:valibot:openapi": "tsx express/valibot/openapi/index.ts",
1415
"ex:fastify:zod:server": "tsx fastify/zod/fastify.ts",
1516
"ex:fasitify:zod:client": "tsx fastify/zod/fetch.ts",
1617
"ex:withValidation": "tsx simple/withValidation.ts"

package-lock.json

Lines changed: 16 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkgs/typed-api-spec/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@
118118
"require": "./dist/zod/index.js",
119119
"import": "./dist/zod/index.mjs"
120120
},
121+
"./zod/openapi": {
122+
"types": "./dist/zod/openapi.d.ts",
123+
"require": "./dist/zod/openapi.js",
124+
"import": "./dist/zod/openapi.mjs"
125+
},
121126
"./valibot": {
122127
"types": "./dist/valibot/index.d.ts",
123128
"require": "./dist/valibot/index.js",
@@ -137,5 +142,8 @@
137142
"json-schema": "^0.4.0",
138143
"json-schema-walker": "^2.0.0",
139144
"path-to-regexp": "^8.2.0"
145+
},
146+
"optionalDependencies": {
147+
"zod-openapi": "^4.2.2"
140148
}
141149
}

pkgs/typed-api-spec/src/zod/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export type ZodApiSpec<
100100
RequestHeaders extends z.ZodTypeAny = z.ZodTypeAny,
101101
Responses extends ZodAnyApiResponses = ZodAnyApiResponses,
102102
> = BaseApiSpec<Params, Query, Body, RequestHeaders, Responses>;
103-
type ZodAnyApiResponse = DefineResponse<z.ZodTypeAny, z.ZodTypeAny>;
103+
export type ZodAnyApiResponse = DefineResponse<z.ZodTypeAny, z.ZodTypeAny>;
104104
export type ZodAnyApiResponses = DefineApiResponses<ZodAnyApiResponse>;
105105

106106
// -- converter --
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import {
2+
apiSpecRequestKeys,
3+
extractExtraApiSpecProps,
4+
extractExtraResponseProps,
5+
JsonSchemaApiEndpoints,
6+
JsonSchemaApiResponses,
7+
JsonSchemaApiSpec,
8+
Method,
9+
StatusCode,
10+
} from "../core";
11+
import {
12+
ZodAnyApiResponses,
13+
ZodApiEndpoint,
14+
ZodApiEndpoints,
15+
ZodApiSpec,
16+
} from "./index";
17+
import { createSchema } from "zod-openapi";
18+
import { JSONSchema7 } from "json-schema";
19+
import { z } from "zod";
20+
21+
export const toJsonSchemaApiEndpoints = <E extends ZodApiEndpoints>(
22+
endpoints: E,
23+
): JsonSchemaApiEndpoints => {
24+
const ret: JsonSchemaApiEndpoints = {};
25+
for (const path of Object.keys(endpoints)) {
26+
ret[path] = toJsonSchemaEndpoint(endpoints[path]);
27+
}
28+
return ret;
29+
};
30+
31+
export const toJsonSchemaEndpoint = <Endpoint extends ZodApiEndpoint>(
32+
endpoint: Endpoint,
33+
) => {
34+
const ret: Partial<Record<Method, JsonSchemaApiSpec>> = {};
35+
for (const method of Method) {
36+
const spec = endpoint[method];
37+
if (spec) {
38+
ret[method] = toJsonSchemaApiSpec(spec);
39+
}
40+
}
41+
return ret;
42+
};
43+
44+
export const toJsonSchemaApiSpec = <Spec extends ZodApiSpec>(
45+
spec: Spec,
46+
): JsonSchemaApiSpec => {
47+
const extraProps = extractExtraApiSpecProps(spec);
48+
const ret: JsonSchemaApiSpec = {
49+
responses: toJsonSchemaResponses(spec.responses),
50+
};
51+
for (const key of apiSpecRequestKeys) {
52+
if (spec[key]) {
53+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
54+
ret[key] = toSchema(spec[key]);
55+
}
56+
}
57+
return { ...extraProps, ...ret };
58+
};
59+
60+
const toJsonSchemaResponses = (
61+
responses: ZodAnyApiResponses,
62+
): JsonSchemaApiResponses => {
63+
const statusCodes = Object.keys(responses).map(Number) as StatusCode[];
64+
const ret: JsonSchemaApiResponses = {};
65+
for (const statusCode of statusCodes) {
66+
const r = responses[statusCode];
67+
if (!r) {
68+
continue;
69+
}
70+
ret[statusCode] = {
71+
...extractExtraResponseProps(r),
72+
body: toSchema(r.body),
73+
headers: r.headers ? toSchema(r.headers) : undefined,
74+
};
75+
}
76+
return ret;
77+
};
78+
79+
const toSchema = (s: z.ZodTypeAny) => {
80+
return createSchema(s).schema as JSONSchema7;
81+
};
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { describe, it, expect } from "vitest";
2+
import { OpenAPIV3_1 } from "openapi-types";
3+
import { toOpenApiDoc, ZodOpenApiEndpoints } from "./openapi";
4+
import z from "zod";
5+
6+
describe("openapi", () => {
7+
const endpoints = {
8+
"/pets": {
9+
get: {
10+
description: "Get pet",
11+
query: z.object({ page: z.string() }),
12+
responses: {
13+
200: {
14+
description: "List of pets",
15+
body: z.array(z.object({ message: z.string() })),
16+
},
17+
},
18+
},
19+
post: {
20+
description: "Add pet",
21+
body: z.object({ name: z.string() }),
22+
responses: {
23+
200: {
24+
description: "Added pet",
25+
body: z.array(z.object({ message: z.string() })),
26+
},
27+
},
28+
},
29+
},
30+
} satisfies ZodOpenApiEndpoints;
31+
32+
const expectGetPathObject = {
33+
description: "Get pet",
34+
parameters: [
35+
{
36+
content: { "application/json": { schema: { type: "string" } } },
37+
in: "query",
38+
name: "page",
39+
},
40+
],
41+
responses: {
42+
"200": {
43+
content: {
44+
"application/json": {
45+
schema: {
46+
$schema: "http://json-schema.org/draft-07/schema#",
47+
items: {
48+
properties: {
49+
message: {
50+
type: "string",
51+
},
52+
},
53+
required: ["message"],
54+
type: "object",
55+
},
56+
type: "array",
57+
},
58+
},
59+
},
60+
description: "List of pets",
61+
},
62+
},
63+
};
64+
65+
const expectPostPathObject = {
66+
description: "Add pet",
67+
requestBody: {
68+
content: {
69+
"application/json": {
70+
schema: {
71+
$schema: "http://json-schema.org/draft-07/schema#",
72+
properties: {
73+
name: {
74+
type: "string",
75+
},
76+
},
77+
required: ["name"],
78+
type: "object",
79+
},
80+
},
81+
},
82+
},
83+
parameters: [],
84+
responses: {
85+
"200": {
86+
content: {
87+
"application/json": {
88+
schema: {
89+
$schema: "http://json-schema.org/draft-07/schema#",
90+
items: {
91+
properties: {
92+
message: {
93+
type: "string",
94+
},
95+
},
96+
required: ["message"],
97+
type: "object",
98+
},
99+
type: "array",
100+
},
101+
},
102+
},
103+
description: "Added pet",
104+
},
105+
},
106+
};
107+
108+
it("toOpenApiDoc", () => {
109+
const baseDoc: Omit<OpenAPIV3_1.Document, "paths"> = {
110+
openapi: "3.1.0",
111+
info: { title: "title", version: "1" },
112+
security: [],
113+
servers: [],
114+
components: {},
115+
};
116+
const doc = toOpenApiDoc(baseDoc, endpoints);
117+
expect(doc).toEqual({
118+
...baseDoc,
119+
paths: {
120+
"/pets": { get: expectGetPathObject, post: expectPostPathObject },
121+
},
122+
});
123+
});
124+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { OpenAPIV3_1 } from "openapi-types";
2+
import { toJsonSchemaApiEndpoints } from "./jsonschema";
3+
import { toOpenApiDoc as toOpenApiDocOrg } from "../core";
4+
import {
5+
BaseOpenApiSpec,
6+
DefineOpenApiEndpoint,
7+
DefineOpenApiResponses,
8+
JsonSchemaOpenApiEndpoints,
9+
ToOpenApiResponse,
10+
} from "../core/openapi/spec";
11+
import { z } from "zod";
12+
import { ZodAnyApiResponse } from "./index";
13+
14+
export const toOpenApiDoc = <E extends ZodOpenApiEndpoints>(
15+
doc: Omit<OpenAPIV3_1.Document, "paths">,
16+
endpoints: E,
17+
): OpenAPIV3_1.Document => {
18+
const e = toJsonSchemaApiEndpoints(endpoints);
19+
return toOpenApiDocOrg(doc, e as JsonSchemaOpenApiEndpoints);
20+
};
21+
22+
export type ZodOpenApiEndpoints = {
23+
[Path in string]: ZodOpenApiEndpoint;
24+
};
25+
export type ZodOpenApiEndpoint = DefineOpenApiEndpoint<ZodOpenApiSpec>;
26+
export type ZodAnyOpenApiResponse = ToOpenApiResponse<ZodAnyApiResponse>;
27+
export type ZodAnyOpenApiResponses =
28+
DefineOpenApiResponses<ZodAnyOpenApiResponse>;
29+
30+
export type ZodOpenApiSpec<
31+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
32+
ParamKeys extends string = string,
33+
Params extends z.ZodTypeAny = z.ZodTypeAny,
34+
Query extends z.ZodTypeAny = z.ZodTypeAny,
35+
Body extends z.ZodTypeAny = z.ZodTypeAny,
36+
RequestHeaders extends z.ZodTypeAny = z.ZodTypeAny,
37+
Responses extends ZodAnyOpenApiResponses = ZodAnyOpenApiResponses,
38+
> = BaseOpenApiSpec<Params, Query, Body, RequestHeaders, Responses>;

0 commit comments

Comments
 (0)