Skip to content
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

feat: zod-to-openapi v3 support #11

Merged
merged 1 commit into from
Nov 21, 2024
Merged
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
## express-zod-openapi-autogen
# express-zod-openapi-autogen

This repository provides (relatively) un-opinionated utility methods for creating Express APIs that leverage Zod for request and response validation and auto-generate OpenAPI documentation.

Expand Down Expand Up @@ -61,7 +61,6 @@ try {
routers: publicAPIs,
schemaPaths: ["src/schemas"],
config: {
openapi: "3.0.0",
servers: [{ url: `https://server.com/api` }],
info: {
version: "1.0.0",
Expand All @@ -73,6 +72,7 @@ try {
401: "Unauthorized",
403: "Forbidden",
},
openApiVersion: "3.0.0",
});
app.get(`/openapi.json`, (req, res) => res.json(doc));
app.use(`/openapi`, swaggerUI.serve, swaggerUI.setup(doc));
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@
"test": "NODE_ENV=test mocha --exit"
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "^2.3.0"
"@asteasolutions/zod-to-openapi": "^3.4.0"
},
"peerDependencies": {
"express": "^5.0.0-beta.1",
"openapi3-ts": "^3.2.0",
"zod": "^3"
},
"devDependencies": {
Expand All @@ -39,6 +40,7 @@
"express": "^5.0.0-beta.1",
"husky": "^9.1.7",
"mocha": "^10.8.2",
"openapi3-ts": "^3.2.0",
"pinst": "^3.0.0",
"semantic-release": "^24.2.0",
"ts-node": "^10.9.2",
Expand Down
49 changes: 32 additions & 17 deletions src/openAPI.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,43 +8,58 @@ import { openAPIRoute } from "./openAPIRoute";
use(chaiSpies);

describe("buildOpenAPIDocument", () => {
const openApiVersion = "3.0.0";
afterEach(() => {
spy.restore();
});

it("should generate an OpenAPI document with the provided config", () => {
const config = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" } };
const config = { info: { title: "Test API", version: "1.0.0" } };
const routers: Router[] = [];
const schemaPaths: string[] = [];
const errors = { 401: "Unauthorized", 403: "Forbidden" };

const document = buildOpenAPIDocument({ config, routers, schemaPaths, errors });
const document = buildOpenAPIDocument({ config, routers, schemaPaths, errors, openApiVersion });

expect(document.openapi).to.equal("3.0.0");
expect(document.openapi).to.equal(openApiVersion);
expect(document.info.title).to.equal("Test API");
expect(document.info.version).to.equal("1.0.0");
});

it("should work with additional OpenAPI versions", () => {
const config = { info: { title: "Test API", version: "1.0.0" } };
const routers: Router[] = [];
const schemaPaths: string[] = [];
const errors = { 401: "Unauthorized", 403: "Forbidden" };
const version = "3.1.0";

const document = buildOpenAPIDocument({ config, routers, schemaPaths, errors, openApiVersion: version });

expect(document.openapi).to.equal(version);
expect(document.info.title).to.equal("Test API");
expect(document.info.version).to.equal("1.0.0");
});

it("should include security schemes if provided", () => {
const config = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" } };
const config = { info: { title: "Test API", version: "1.0.0" } };
const routers: Router[] = [];
const schemaPaths: string[] = [];
const errors = { 401: "Unauthorized", 403: "Forbidden" };
const securitySchemes = { bearerAuth: { type: "http" as const, scheme: "bearer" } };

const document = buildOpenAPIDocument({ config, routers, schemaPaths, errors, securitySchemes });
const document = buildOpenAPIDocument({ config, routers, schemaPaths, errors, securitySchemes, openApiVersion });

expect(document.components!.securitySchemes).to.have.property("bearerAuth");
expect(document.components!.securitySchemes!.bearerAuth).to.deep.equal({ type: "http", scheme: "bearer" });
});

it("should include zod schemas as schemas if provided", () => {
const config = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" } };
const config = { info: { title: "Test API", version: "1.0.0" } };
const routers: Router[] = [];
const schemaPaths: string[] = ["../mocks/schemas"];
const errors = { 401: "Unauthorized", 403: "Forbidden" };

const document = buildOpenAPIDocument({ config, routers, schemaPaths, errors });
const document = buildOpenAPIDocument({ config, routers, schemaPaths, errors, openApiVersion });

expect(document.components!.schemas).to.have.property("BodySchema");
expect(document.components!.schemas!.BodySchema).to.deep.equal({
Expand All @@ -55,7 +70,7 @@ describe("buildOpenAPIDocument", () => {
});

it("should register routes from routers", () => {
const config = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" } };
const config = { info: { title: "Test API", version: "1.0.0" } };
const router = Router();
router.get(
"/test",
Expand All @@ -73,19 +88,19 @@ describe("buildOpenAPIDocument", () => {
const schemaPaths: string[] = ["../mocks/schemas"];
const errors = { 401: "Unauthorized", 403: "Forbidden" };

const document = buildOpenAPIDocument({ config, routers, schemaPaths, errors });
const document = buildOpenAPIDocument({ config, routers, schemaPaths, errors, openApiVersion });

expect(document.paths).to.have.property("/test");
expect(document.paths["/test"]).to.have.property("get");
});

it("should include error responses if defined", () => {
const config = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" } };
const config = { info: { title: "Test API", version: "1.0.0" } };
const routers: Router[] = [];
const schemaPaths: string[] = [];
const errors = { 401: "Unauthorized", 403: "Forbidden" };

const document = buildOpenAPIDocument({ config, routers, schemaPaths, errors });
const document = buildOpenAPIDocument({ config, routers, schemaPaths, errors, openApiVersion });

expect(document.paths).to.be.an("object");
for (const path in document.paths) {
Expand All @@ -97,7 +112,7 @@ describe("buildOpenAPIDocument", () => {
});

it("should warn about optional path parameters", () => {
const config = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" } };
const config = { info: { title: "Test API", version: "1.0.0" } };
const router = Router();
router.get(
"/test/:optional",
Expand All @@ -118,12 +133,12 @@ describe("buildOpenAPIDocument", () => {

const consoleSpy = spy.on(console, "warn");

buildOpenAPIDocument({ config, routers, schemaPaths, errors });
buildOpenAPIDocument({ config, routers, schemaPaths, errors, openApiVersion });
expect(consoleSpy).to.have.been.called();
});

it("should create schema references for route responses when named", () => {
const config = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" } };
const config = { info: { title: "Test API", version: "1.0.0" } };
const router = Router();
router.get(
"/test",
Expand All @@ -141,14 +156,14 @@ describe("buildOpenAPIDocument", () => {
const schemaPaths: string[] = ["../mocks/schemas"];
const errors = { 401: "Unauthorized", 403: "Forbidden" };

const document = buildOpenAPIDocument({ config, routers, schemaPaths, errors });
const document = buildOpenAPIDocument({ config, routers, schemaPaths, errors, openApiVersion });
const responseSchema = document.paths["/test"].get.responses["200"].content["application/json"].schema;

expect(responseSchema.$ref.includes("ResponseSchema")).to.be.true;
});

it("should properly describe routes with request body", () => {
const config = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" } };
const config = { info: { title: "Test API", version: "1.0.0" } };
const router = Router();
router.get(
"/test",
Expand All @@ -166,7 +181,7 @@ describe("buildOpenAPIDocument", () => {
const schemaPaths: string[] = ["../mocks/schemas"];
const errors = { 401: "Unauthorized", 403: "Forbidden" };

const document = buildOpenAPIDocument({ config, routers, schemaPaths, errors });
const document = buildOpenAPIDocument({ config, routers, schemaPaths, errors, openApiVersion });
const requestBodySchema = document.paths["/test"].get.requestBody.content["application/json"].schema;

expect(requestBodySchema.$ref.includes("BodySchema")).to.be.true;
Expand Down
11 changes: 7 additions & 4 deletions src/openAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
RouteConfig,
} from "@asteasolutions/zod-to-openapi";
import { RequestHandler, Router } from "express";
import type { ComponentsObject } from "openapi3-ts";
import { z, ZodArray, ZodEffects, ZodObject } from "zod";
import { getSchemaOfOpenAPIRoute } from "./openAPIRoute";
import { ErrorResponse } from "./schemas";
Expand All @@ -15,15 +16,17 @@ extendZodWithOpenApi(z);
export type OpenAPIDocument = ReturnType<OpenAPIGenerator["generateDocument"]>;
export type OpenAPIComponents = ReturnType<OpenAPIGenerator["generateComponents"]>;
export type OpenAPIConfig = Parameters<OpenAPIGenerator["generateDocument"]>[0];
export type OpenApiVersion = ConstructorParameters<typeof OpenAPIGenerator>[1];

export function buildOpenAPIDocument(args: {
config: OpenAPIConfig;
routers: Router[];
schemaPaths: string[];
errors: { 401?: string; 403?: string };
securitySchemes?: OpenAPIComponents["securitySchemes"];
securitySchemes?: ComponentsObject["securitySchemes"];
openApiVersion: OpenApiVersion;
}): OpenAPIDocument {
const { config, routers, schemaPaths, securitySchemes, errors } = args;
const { config, routers, schemaPaths, securitySchemes, errors, openApiVersion } = args;
const registry = new OpenAPIRegistry();
// Attach all of the Zod schemas to the OpenAPI specification
// as components that can be referenced in the API definitions
Expand All @@ -41,7 +44,7 @@ export function buildOpenAPIDocument(args: {
return undefined;
}
if (type instanceof ZodEffects) {
const nonEffectedObj = schemas.find((s) => s.key === type._def.openapi?.refId);
const nonEffectedObj = schemas.find((s) => s.key === type._def.openapi?._internal?.refId);
if (nonEffectedObj) {
return nonEffectedObj.registered;
} else {
Expand Down Expand Up @@ -186,7 +189,7 @@ export function buildOpenAPIDocument(args: {
registry.registerPath(openapiRouteConfig);
});

const generator = new OpenAPIGenerator(registry.definitions);
const generator = new OpenAPIGenerator(registry.definitions, openApiVersion);
const openapiJSON = generator.generateDocument(config);

// Attach the security schemes provided
Expand Down
28 changes: 14 additions & 14 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
# yarn lockfile v1


"@asteasolutions/zod-to-openapi@^2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@asteasolutions/zod-to-openapi/-/zod-to-openapi-2.3.0.tgz#d911a23870a67b245b2d5ddd13febd7c62661410"
integrity sha512-8nVMqcMnfa9BHDSLVUt7AIKubwDLwj8k59OAYV6WbmF7EP3shPmXcXs8bOKjV5tzYLXI+D3HrdgMQMd+dLxpbg==
"@asteasolutions/zod-to-openapi@^3.4.0":
version "3.4.0"
resolved "https://registry.yarnpkg.com/@asteasolutions/zod-to-openapi/-/zod-to-openapi-3.4.0.tgz#7b74b1c32b102048a856b990577f1ebe1861aa18"
integrity sha512-xilC2RmsAoJoD0RqZrqArNuC8ByzBIkElIQWEIwreCwSGPHbv2my3d4mnY4x0qQWmSpVnpphEU3Cjl73MpOHjQ==
dependencies:
openapi3-ts "^2.0.2"
openapi3-ts "^3.1.1"

"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.22.13":
version "7.26.2"
Expand Down Expand Up @@ -3432,12 +3432,12 @@ onetime@^6.0.0:
dependencies:
mimic-fn "^4.0.0"

openapi3-ts@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/openapi3-ts/-/openapi3-ts-2.0.2.tgz#a200dd838bf24c9086c8eedcfeb380b7eb31e82a"
integrity sha512-TxhYBMoqx9frXyOgnRHufjQfPXomTIHYKhSKJ6jHfj13kS8OEIhvmE8CTuQyKtjjWttAjX5DPxM1vmalEpo8Qw==
openapi3-ts@^3.1.1, openapi3-ts@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/openapi3-ts/-/openapi3-ts-3.2.0.tgz#7e30d33c480e938e67e809ab16f419bc9beae3f8"
integrity sha512-/ykNWRV5Qs0Nwq7Pc0nJ78fgILvOT/60OxEmB3v7yQ8a8Bwcm43D4diaYazG/KBn6czA+52XYy931WFLMCUeSg==
dependencies:
yaml "^1.10.2"
yaml "^2.2.1"

p-each-series@^3.0.0:
version "3.0.0"
Expand Down Expand Up @@ -4804,10 +4804,10 @@ yallist@^5.0.0:
resolved "https://registry.yarnpkg.com/yallist/-/yallist-5.0.0.tgz#00e2de443639ed0d78fd87de0d27469fbcffb533"
integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==

yaml@^1.10.2:
version "1.10.2"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
yaml@^2.2.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.6.1.tgz#42f2b1ba89203f374609572d5349fb8686500773"
integrity sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==

yargs-parser@^20.2.2, yargs-parser@^20.2.9:
version "20.2.9"
Expand Down