From 7221e318d9f425c776299011f44c3b69d79d4c82 Mon Sep 17 00:00:00 2001 From: Itelo Filho Date: Tue, 25 Jul 2023 12:36:10 -0300 Subject: [PATCH 1/2] add components schemas to openapi generator --- .../lib/middlewares/with-route-spec.ts | 13 ++++++++ apps/example-todo-app/lib/zod.ts | 8 +++++ apps/example-todo-app/package.json | 5 ++-- apps/example-todo-app/pages/api/todo/get.ts | 5 ++-- apps/example-todo-app/pages/api/todo/index.ts | 5 ++-- .../pages/api/todo/list-optional-ids.ts | 6 ++-- .../pages/api/todo/list-with-refine.ts | 7 ++--- apps/example-todo-app/pages/api/todo/list.ts | 7 ++--- .../{nextlove => nextlove-generators}/bin.js | 0 .../embed-schema-references.ts | 30 +++++++++++++++++++ .../src/generate-openapi/index.ts | 29 ++++++++++++++---- packages/nextlove/package.json | 5 +--- packages/nextlove/src/types/index.ts | 1 + .../nextlove/src/with-route-spec/index.ts | 1 + 14 files changed, 91 insertions(+), 31 deletions(-) create mode 100644 apps/example-todo-app/lib/zod.ts rename packages/{nextlove => nextlove-generators}/bin.js (100%) create mode 100644 packages/nextlove-generators/src/generate-openapi/embed-schema-references.ts diff --git a/apps/example-todo-app/lib/middlewares/with-route-spec.ts b/apps/example-todo-app/lib/middlewares/with-route-spec.ts index 8e40883dc..82aad8d60 100644 --- a/apps/example-todo-app/lib/middlewares/with-route-spec.ts +++ b/apps/example-todo-app/lib/middlewares/with-route-spec.ts @@ -1,6 +1,7 @@ import { createWithRouteSpec } from "nextlove" import { withAuthToken } from "./with-auth-token" export { checkRouteSpec } from "nextlove" +import * as ZT from "lib/zod" export const withRouteSpec = createWithRouteSpec({ authMiddlewareMap: { auth_token: withAuthToken }, @@ -8,6 +9,10 @@ export const withRouteSpec = createWithRouteSpec({ apiName: "TODO API", productionServerUrl: "https://example.com", shouldValidateResponses: true, + globalSchemas: { + todo: ZT.todo, + ok: ZT.ok + } } as const) export const withRouteSpecWithoutValidateGetRequestBody = createWithRouteSpec({ @@ -16,6 +21,10 @@ export const withRouteSpecWithoutValidateGetRequestBody = createWithRouteSpec({ apiName: "TODO API", productionServerUrl: "https://example.com", shouldValidateGetRequestBody: false, + globalSchemas: { + todo: ZT.todo, + ok: ZT.ok + } } as const) export const withRouteSpecWithoutValidateResponse = createWithRouteSpec({ @@ -23,4 +32,8 @@ export const withRouteSpecWithoutValidateResponse = createWithRouteSpec({ globalMiddlewares: [], apiName: "TODO API", productionServerUrl: "https://example.com", + globalSchemas: { + todo: ZT.todo, + ok: ZT.ok + } } as const) diff --git a/apps/example-todo-app/lib/zod.ts b/apps/example-todo-app/lib/zod.ts new file mode 100644 index 000000000..8caf5b753 --- /dev/null +++ b/apps/example-todo-app/lib/zod.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const todo = z +.object({ + id: z.string().uuid(), +}) + +export const ok = z.boolean() \ No newline at end of file diff --git a/apps/example-todo-app/package.json b/apps/example-todo-app/package.json index 14fb1af17..86a9fb792 100644 --- a/apps/example-todo-app/package.json +++ b/apps/example-todo-app/package.json @@ -5,8 +5,8 @@ "scripts": { "dev": "next dev", "build": "rimraf dist && nsm build && tsup ./index.ts --dts --sourcemap && cpy .next dist/.next", - "build:openapi": "nextlove generate-openapi --packageDir . --apiName 'Example TODO API'", - "build:type": "nextlove generate-route-types --packageDir .", + "build:openapi": "nextlove-generators generate-openapi --packageDir . --apiName 'Example TODO API'", + "build:type": "nextlove-generators generate-route-types --packageDir .", "start": "next start", "lint": "next lint", "test": "ava" @@ -18,6 +18,7 @@ "debug": "^4.3.4", "escape-string-regexp": "4.0.0", "eslint-plugin-nextlove": "*", + "nextlove-generators": "*", "glob-promise": "4.2.2", "micro": "9.3.4", "mkdirp": "1.0.4", diff --git a/apps/example-todo-app/pages/api/todo/get.ts b/apps/example-todo-app/pages/api/todo/get.ts index 24daf19a6..767e05c8d 100644 --- a/apps/example-todo-app/pages/api/todo/get.ts +++ b/apps/example-todo-app/pages/api/todo/get.ts @@ -2,6 +2,7 @@ import { checkRouteSpec, withRouteSpec } from "lib/middlewares" import { NotFoundException } from "nextlove" import { TODO_ID } from "tests/fixtures" import { z } from "zod" +import * as ZT from "lib/zod" export const queryParams = z.object({ id: z.string().uuid(), @@ -18,9 +19,7 @@ export const route_spec = checkRouteSpec({ queryParams, jsonResponse: z.object({ ok: z.boolean(), - todo: z.object({ - id: z.string().uuid(), - }), + todo: ZT.todo, error: z .object({ type: z.string(), diff --git a/apps/example-todo-app/pages/api/todo/index.ts b/apps/example-todo-app/pages/api/todo/index.ts index 24daf19a6..ea17ec41e 100644 --- a/apps/example-todo-app/pages/api/todo/index.ts +++ b/apps/example-todo-app/pages/api/todo/index.ts @@ -2,6 +2,7 @@ import { checkRouteSpec, withRouteSpec } from "lib/middlewares" import { NotFoundException } from "nextlove" import { TODO_ID } from "tests/fixtures" import { z } from "zod" +import * as ZT from 'lib/zod' export const queryParams = z.object({ id: z.string().uuid(), @@ -18,9 +19,7 @@ export const route_spec = checkRouteSpec({ queryParams, jsonResponse: z.object({ ok: z.boolean(), - todo: z.object({ - id: z.string().uuid(), - }), + todo: ZT.todo, error: z .object({ type: z.string(), diff --git a/apps/example-todo-app/pages/api/todo/list-optional-ids.ts b/apps/example-todo-app/pages/api/todo/list-optional-ids.ts index 1ce757027..55a76f935 100644 --- a/apps/example-todo-app/pages/api/todo/list-optional-ids.ts +++ b/apps/example-todo-app/pages/api/todo/list-optional-ids.ts @@ -1,5 +1,6 @@ import { checkRouteSpec, withRouteSpec } from "lib/middlewares" import { z } from "zod" +import * as ZT from 'lib/zod' export const commonParams = z.object({ ids: z.array(z.string().uuid()).optional(), @@ -12,10 +13,7 @@ export const route_spec = checkRouteSpec({ jsonResponse: z.object({ ok: z.boolean(), todos: z - .object({ - id: z.string().uuid(), - }) - .array(), + .array(ZT.todo), }), }) diff --git a/apps/example-todo-app/pages/api/todo/list-with-refine.ts b/apps/example-todo-app/pages/api/todo/list-with-refine.ts index 30fb9aa37..aee8d17ce 100644 --- a/apps/example-todo-app/pages/api/todo/list-with-refine.ts +++ b/apps/example-todo-app/pages/api/todo/list-with-refine.ts @@ -1,5 +1,6 @@ import { checkRouteSpec, withRouteSpec } from "lib/middlewares" import { z } from "zod" +import * as ZT from "lib/zod" export const commonParams = z .object({ @@ -29,11 +30,7 @@ export const route_spec = checkRouteSpec({ commonParams, jsonResponse: z.object({ ok: z.boolean(), - todos: z - .object({ - id: z.string().uuid(), - }) - .array(), + todos: z.array(ZT.todo), }), }) diff --git a/apps/example-todo-app/pages/api/todo/list.ts b/apps/example-todo-app/pages/api/todo/list.ts index bf0126557..89beaa1cc 100644 --- a/apps/example-todo-app/pages/api/todo/list.ts +++ b/apps/example-todo-app/pages/api/todo/list.ts @@ -1,5 +1,6 @@ import { checkRouteSpec, withRouteSpec } from "lib/middlewares" import { z } from "zod" +import * as ZT from "lib/zod" export const commonParams = z.object({ ids: z.array(z.string().uuid()), @@ -11,11 +12,7 @@ export const route_spec = checkRouteSpec({ commonParams, jsonResponse: z.object({ ok: z.boolean(), - todos: z - .object({ - id: z.string().uuid(), - }) - .array(), + todos: z.array(ZT.todo), }), }) diff --git a/packages/nextlove/bin.js b/packages/nextlove-generators/bin.js similarity index 100% rename from packages/nextlove/bin.js rename to packages/nextlove-generators/bin.js diff --git a/packages/nextlove-generators/src/generate-openapi/embed-schema-references.ts b/packages/nextlove-generators/src/generate-openapi/embed-schema-references.ts new file mode 100644 index 000000000..8d846342c --- /dev/null +++ b/packages/nextlove-generators/src/generate-openapi/embed-schema-references.ts @@ -0,0 +1,30 @@ +import _ from 'lodash'; + +function findKeyInObj(obj: any, value: any): string | null { + let resultKey: string | null = null; + _.forOwn(obj, (v, k) => { + if (_.isObject(v) && _.isMatch(v, value)) { + resultKey = k; + // Stop the iteration + return false; + } + }); + return resultKey; +} + +export function embedSchemaReferences(obj1: any, obj2: any): any { + return _.transform(obj1, (result, value, key) => { + if (_.isObject(value)) { + const matchingKey = findKeyInObj(obj2, value); + if (matchingKey) { + result[key] = { + '$ref': `#/components/schemas/${matchingKey}` + }; + } else { + result[key] = embedSchemaReferences(value, obj2); + } + } else { + result[key] = value; + } + }); +} diff --git a/packages/nextlove-generators/src/generate-openapi/index.ts b/packages/nextlove-generators/src/generate-openapi/index.ts index 2f784dda9..92096b31c 100644 --- a/packages/nextlove-generators/src/generate-openapi/index.ts +++ b/packages/nextlove-generators/src/generate-openapi/index.ts @@ -9,6 +9,7 @@ import { SetupParams } from "nextlove" import chalk from "chalk" import { z } from "zod" import { parseRoutesInPackage } from "../lib/parse-routes-in-package" +import { embedSchemaReferences } from "./embed-schema-references" function transformPathToOperationId(path: string): string { function replaceFirstCharToLowercase(str: string) { @@ -106,6 +107,11 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) { ] } + const globalSchemas = {} + for (const [schemaName, zodSchema] of Object.entries(globalSetupParams.globalSchemas ?? {})) { + globalSchemas[schemaName] = generateSchema(zodSchema) + } + // Build OpenAPI spec const builder = OpenApiBuilder.create({ openapi: "3.0.0", @@ -122,6 +128,7 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) { paths: {}, components: { securitySchemes, + schemas: globalSchemas }, }) @@ -226,14 +233,26 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) { const { jsonResponse } = routeSpec const { addOkStatus = true } = setupParams + if (jsonResponse) { + if (!jsonResponse._def || !jsonResponse._def.typeName || jsonResponse._def.typeName !== "ZodObject") { + console.warn( + chalk.yellow(`Skipping route ${routePath} because the response is not a ZodObject.`) + ) + continue + } + + const responseSchema = generateSchema( + addOkStatus + ? jsonResponse.extend({ ok: z.boolean() }) + : jsonResponse + ) + + const schemaWithReferences = embedSchemaReferences(responseSchema, globalSchemas) + route.responses[200].content = { "application/json": { - schema: generateSchema( - addOkStatus - ? jsonResponse.extend({ ok: z.boolean() }) - : jsonResponse - ), + schema: schemaWithReferences, }, } } diff --git a/packages/nextlove/package.json b/packages/nextlove/package.json index 29a3ec6a4..d9d465564 100644 --- a/packages/nextlove/package.json +++ b/packages/nextlove/package.json @@ -11,8 +11,7 @@ "registry": "https://registry.npmjs.org/" }, "bin": { - "nsm": "nsm-bin.js", - "nextlove": "bin.js" + "nsm": "nsm-bin.js" }, "main": "./dist/index.js", "scripts": { @@ -51,8 +50,6 @@ "eslint": "8.18.0", "eslint-config-next": "12.2.0", "expect-type": "^0.15.0", - "globby": "11.1.0", - "minimist": "^1.2.7", "next": "12.2.0", "nextjs-middleware-wrappers": "^1.3.0", "nextjs-server-modules": "^2.1.0", diff --git a/packages/nextlove/src/types/index.ts b/packages/nextlove/src/types/index.ts index 9781ce88b..30ccef4ca 100644 --- a/packages/nextlove/src/types/index.ts +++ b/packages/nextlove/src/types/index.ts @@ -68,6 +68,7 @@ export interface SetupParams< shouldValidateResponses?: boolean shouldValidateGetRequestBody?: boolean securitySchemas?: Record + globalSchemas?: Record } const defaultMiddlewareMap = { diff --git a/packages/nextlove/src/with-route-spec/index.ts b/packages/nextlove/src/with-route-spec/index.ts index b9533bda5..0c33b1ab6 100644 --- a/packages/nextlove/src/with-route-spec/index.ts +++ b/packages/nextlove/src/with-route-spec/index.ts @@ -62,6 +62,7 @@ export const createWithRouteSpec: CreateWithRouteSpecFunction = (( }, }, }) as any, + globalSchemas = {} } = setupParams const withRouteSpec = (spec: RouteSpec) => { From f9f0754e873d76db0bfcc043795642cc3dad8a1a Mon Sep 17 00:00:00 2001 From: Itelo Filho Date: Tue, 25 Jul 2023 12:42:26 -0300 Subject: [PATCH 2/2] prettier --- .../lib/middlewares/with-route-spec.ts | 12 ++++----- apps/example-todo-app/lib/zod.ts | 7 +++-- apps/example-todo-app/pages/api/todo/index.ts | 2 +- .../pages/api/todo/list-optional-ids.ts | 5 ++-- .../embed-schema-references.ts | 24 ++++++++--------- .../src/generate-openapi/index.ts | 27 ++++++++++++------- .../nextlove/src/with-route-spec/index.ts | 6 ++++- 7 files changed, 47 insertions(+), 36 deletions(-) diff --git a/apps/example-todo-app/lib/middlewares/with-route-spec.ts b/apps/example-todo-app/lib/middlewares/with-route-spec.ts index 82aad8d60..97652b6d8 100644 --- a/apps/example-todo-app/lib/middlewares/with-route-spec.ts +++ b/apps/example-todo-app/lib/middlewares/with-route-spec.ts @@ -11,8 +11,8 @@ export const withRouteSpec = createWithRouteSpec({ shouldValidateResponses: true, globalSchemas: { todo: ZT.todo, - ok: ZT.ok - } + ok: ZT.ok, + }, } as const) export const withRouteSpecWithoutValidateGetRequestBody = createWithRouteSpec({ @@ -23,8 +23,8 @@ export const withRouteSpecWithoutValidateGetRequestBody = createWithRouteSpec({ shouldValidateGetRequestBody: false, globalSchemas: { todo: ZT.todo, - ok: ZT.ok - } + ok: ZT.ok, + }, } as const) export const withRouteSpecWithoutValidateResponse = createWithRouteSpec({ @@ -34,6 +34,6 @@ export const withRouteSpecWithoutValidateResponse = createWithRouteSpec({ productionServerUrl: "https://example.com", globalSchemas: { todo: ZT.todo, - ok: ZT.ok - } + ok: ZT.ok, + }, } as const) diff --git a/apps/example-todo-app/lib/zod.ts b/apps/example-todo-app/lib/zod.ts index 8caf5b753..8b93d5300 100644 --- a/apps/example-todo-app/lib/zod.ts +++ b/apps/example-todo-app/lib/zod.ts @@ -1,8 +1,7 @@ -import { z } from "zod"; +import { z } from "zod" -export const todo = z -.object({ +export const todo = z.object({ id: z.string().uuid(), }) -export const ok = z.boolean() \ No newline at end of file +export const ok = z.boolean() diff --git a/apps/example-todo-app/pages/api/todo/index.ts b/apps/example-todo-app/pages/api/todo/index.ts index ea17ec41e..767e05c8d 100644 --- a/apps/example-todo-app/pages/api/todo/index.ts +++ b/apps/example-todo-app/pages/api/todo/index.ts @@ -2,7 +2,7 @@ import { checkRouteSpec, withRouteSpec } from "lib/middlewares" import { NotFoundException } from "nextlove" import { TODO_ID } from "tests/fixtures" import { z } from "zod" -import * as ZT from 'lib/zod' +import * as ZT from "lib/zod" export const queryParams = z.object({ id: z.string().uuid(), diff --git a/apps/example-todo-app/pages/api/todo/list-optional-ids.ts b/apps/example-todo-app/pages/api/todo/list-optional-ids.ts index 55a76f935..8066ff350 100644 --- a/apps/example-todo-app/pages/api/todo/list-optional-ids.ts +++ b/apps/example-todo-app/pages/api/todo/list-optional-ids.ts @@ -1,6 +1,6 @@ import { checkRouteSpec, withRouteSpec } from "lib/middlewares" import { z } from "zod" -import * as ZT from 'lib/zod' +import * as ZT from "lib/zod" export const commonParams = z.object({ ids: z.array(z.string().uuid()).optional(), @@ -12,8 +12,7 @@ export const route_spec = checkRouteSpec({ commonParams, jsonResponse: z.object({ ok: z.boolean(), - todos: z - .array(ZT.todo), + todos: z.array(ZT.todo), }), }) diff --git a/packages/nextlove-generators/src/generate-openapi/embed-schema-references.ts b/packages/nextlove-generators/src/generate-openapi/embed-schema-references.ts index 8d846342c..65c1a676e 100644 --- a/packages/nextlove-generators/src/generate-openapi/embed-schema-references.ts +++ b/packages/nextlove-generators/src/generate-openapi/embed-schema-references.ts @@ -1,30 +1,30 @@ -import _ from 'lodash'; +import _ from "lodash" function findKeyInObj(obj: any, value: any): string | null { - let resultKey: string | null = null; + let resultKey: string | null = null _.forOwn(obj, (v, k) => { if (_.isObject(v) && _.isMatch(v, value)) { - resultKey = k; + resultKey = k // Stop the iteration - return false; + return false } - }); - return resultKey; + }) + return resultKey } export function embedSchemaReferences(obj1: any, obj2: any): any { return _.transform(obj1, (result, value, key) => { if (_.isObject(value)) { - const matchingKey = findKeyInObj(obj2, value); + const matchingKey = findKeyInObj(obj2, value) if (matchingKey) { result[key] = { - '$ref': `#/components/schemas/${matchingKey}` - }; + $ref: `#/components/schemas/${matchingKey}`, + } } else { - result[key] = embedSchemaReferences(value, obj2); + result[key] = embedSchemaReferences(value, obj2) } } else { - result[key] = value; + result[key] = value } - }); + }) } diff --git a/packages/nextlove-generators/src/generate-openapi/index.ts b/packages/nextlove-generators/src/generate-openapi/index.ts index 92096b31c..fe9f15c23 100644 --- a/packages/nextlove-generators/src/generate-openapi/index.ts +++ b/packages/nextlove-generators/src/generate-openapi/index.ts @@ -108,7 +108,9 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) { } const globalSchemas = {} - for (const [schemaName, zodSchema] of Object.entries(globalSetupParams.globalSchemas ?? {})) { + for (const [schemaName, zodSchema] of Object.entries( + globalSetupParams.globalSchemas ?? {} + )) { globalSchemas[schemaName] = generateSchema(zodSchema) } @@ -128,7 +130,7 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) { paths: {}, components: { securitySchemes, - schemas: globalSchemas + schemas: globalSchemas, }, }) @@ -235,20 +237,27 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) { const { addOkStatus = true } = setupParams if (jsonResponse) { - if (!jsonResponse._def || !jsonResponse._def.typeName || jsonResponse._def.typeName !== "ZodObject") { + if ( + !jsonResponse._def || + !jsonResponse._def.typeName || + jsonResponse._def.typeName !== "ZodObject" + ) { console.warn( - chalk.yellow(`Skipping route ${routePath} because the response is not a ZodObject.`) + chalk.yellow( + `Skipping route ${routePath} because the response is not a ZodObject.` + ) ) continue } const responseSchema = generateSchema( - addOkStatus - ? jsonResponse.extend({ ok: z.boolean() }) - : jsonResponse + addOkStatus ? jsonResponse.extend({ ok: z.boolean() }) : jsonResponse + ) + + const schemaWithReferences = embedSchemaReferences( + responseSchema, + globalSchemas ) - - const schemaWithReferences = embedSchemaReferences(responseSchema, globalSchemas) route.responses[200].content = { "application/json": { diff --git a/packages/nextlove/src/with-route-spec/index.ts b/packages/nextlove/src/with-route-spec/index.ts index 0c33b1ab6..42e02d992 100644 --- a/packages/nextlove/src/with-route-spec/index.ts +++ b/packages/nextlove/src/with-route-spec/index.ts @@ -62,7 +62,11 @@ export const createWithRouteSpec: CreateWithRouteSpecFunction = (( }, }, }) as any, - globalSchemas = {} + globalSchemas = setupParams.addOkStatus + ? { + ok: z.boolean(), + } + : {}, } = setupParams const withRouteSpec = (spec: RouteSpec) => {