From a0f3a13160d965574e5fcafd59acb570b3086d98 Mon Sep 17 00:00:00 2001 From: Russell Anderson Date: Thu, 21 Nov 2024 16:23:58 -0600 Subject: [PATCH] feat: zod-to-openapi v3 support (#11) 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. --- README.md | 4 ++-- package.json | 4 +++- src/openAPI.test.ts | 49 +++++++++++++++++++++++++++++---------------- src/openAPI.ts | 11 ++++++---- yarn.lock | 28 +++++++++++++------------- 5 files changed, 58 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index fa18d80..7514457 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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", @@ -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)); diff --git a/package.json b/package.json index 274b264..6e5fc8c 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -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", diff --git a/src/openAPI.test.ts b/src/openAPI.test.ts index c10e1e1..d8d7f14 100644 --- a/src/openAPI.test.ts +++ b/src/openAPI.test.ts @@ -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({ @@ -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", @@ -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) { @@ -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", @@ -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", @@ -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", @@ -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; diff --git a/src/openAPI.ts b/src/openAPI.ts index 3652832..ec7d30b 100644 --- a/src/openAPI.ts +++ b/src/openAPI.ts @@ -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"; @@ -15,15 +16,17 @@ extendZodWithOpenApi(z); export type OpenAPIDocument = ReturnType; export type OpenAPIComponents = ReturnType; export type OpenAPIConfig = Parameters[0]; +export type OpenApiVersion = ConstructorParameters[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 @@ -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 { @@ -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 diff --git a/yarn.lock b/yarn.lock index 0b3c7aa..b06a1c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -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" @@ -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"