Skip to content

Commit

Permalink
feat: zod-to-openapi v3 support
Browse files Browse the repository at this point in the history
Upgrades the @asteasolutions/zod-to-openapi dependency to v3. Requires
manually passing in the version to the generator, and its no longer a
part of their default config object. Creating an additional parameter to
set this which will be a breaking change. Also, needed to read the refId
from deeper in the openapi object in their special Zod type.

BREAKING CHANGE: openApiVersion will need to be passed as a separate
argument.
  • Loading branch information
vacas5 committed Nov 21, 2024
1 parent e8fb44d commit 1a69465
Show file tree
Hide file tree
Showing 5 changed files with 58 additions and 38 deletions.
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

0 comments on commit 1a69465

Please sign in to comment.