From 2ea1ab067a05240bad09e13144b4685e51966219 Mon Sep 17 00:00:00 2001 From: Mike Wu Date: Thu, 19 Jun 2025 00:13:58 +0900 Subject: [PATCH 1/4] fix: allow generating GET and POST openapi spec --- apps/example-todo-app/pages/api/todo/list.ts | 2 +- .../openapi-generation.test.ts | 53 ++++ packages/nextlove/bin.js | 4 +- .../src/generators/generate-openapi/index.ts | 246 ++++++++++-------- 4 files changed, 194 insertions(+), 111 deletions(-) diff --git a/apps/example-todo-app/pages/api/todo/list.ts b/apps/example-todo-app/pages/api/todo/list.ts index 9401e5f51..20abd0214 100644 --- a/apps/example-todo-app/pages/api/todo/list.ts +++ b/apps/example-todo-app/pages/api/todo/list.ts @@ -8,7 +8,7 @@ export const commonParams = z.object({ }) export const route_spec = checkRouteSpec({ - methods: ["GET"], + methods: ["GET", "POST"], auth: "auth_token", commonParams, jsonResponse: z.object({ diff --git a/apps/example-todo-app/tests/openapi-generation/openapi-generation.test.ts b/apps/example-todo-app/tests/openapi-generation/openapi-generation.test.ts index f7664e6ca..c98d1f9a6 100644 --- a/apps/example-todo-app/tests/openapi-generation/openapi-generation.test.ts +++ b/apps/example-todo-app/tests/openapi-generation/openapi-generation.test.ts @@ -119,3 +119,56 @@ test("generateOpenAPI correctly parses nested object description", async (t) => t.is(testArrayDescription.items.description, "This is an object.") t.is(testArrayDescription.items["x-title"], "Nested Object Description") }) + +test("generateOpenAPI includes GET even when POST is defined", async (t) => { + const openapiJson = JSON.parse( + await generateOpenAPI({ + packageDir: ".", + }) + ) + + const methods = Object.keys(openapiJson.paths["/api/todo/list"]) + t.is(2, methods.length) + + // GET includes parameters + t.deepEqual( + { + name: "ids", + in: "query", + required: true, + schema: { + items: { + format: "uuid", + type: "string", + }, + type: "array", + }, + }, + openapiJson.paths["/api/todo/list"].get.parameters[0] + ) + + t.falsy(openapiJson.paths["/api/todo/list"].get.requestBody) + + // POST route has request body + + t.deepEqual( + { + properties: { + ids: { + items: { + format: "uuid", + type: "string", + }, + type: "array", + }, + }, + required: ["ids"], + type: "object", + }, + openapiJson.paths["/api/todo/list"].post.requestBody.content[ + "application/json" + ].schema + ) + + t.falsy(openapiJson.paths["/api/todo/list"].post.parameters) +}) diff --git a/packages/nextlove/bin.js b/packages/nextlove/bin.js index e820192c7..cf228b030 100755 --- a/packages/nextlove/bin.js +++ b/packages/nextlove/bin.js @@ -64,7 +64,9 @@ if (argv._[0] === "generate-openapi") { if (!argv["packageDir"]) throw new Error("Missing --packageDir") if (argv["allowed-import-patterns"]) { - argv.allowedImportPatterns = Array.isArray(argv["allowed-import-patterns"]) ? argv["allowed-import-patterns"] : [argv["allowed-import-patterns"]] + argv.allowedImportPatterns = Array.isArray(argv["allowed-import-patterns"]) + ? argv["allowed-import-patterns"] + : [argv["allowed-import-patterns"]] } require("./dist/generators") diff --git a/packages/nextlove/src/generators/generate-openapi/index.ts b/packages/nextlove/src/generators/generate-openapi/index.ts index dbacf80f6..f1d953a50 100644 --- a/packages/nextlove/src/generators/generate-openapi/index.ts +++ b/packages/nextlove/src/generators/generate-openapi/index.ts @@ -176,17 +176,7 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) { } } - // DELETE and GET cannot have a body - let methods = routeSpec.methods - - if (routeSpec.methods.includes("DELETE") && body_to_generate_schema) { - methods = methods.filter((m) => m !== "DELETE") - } - - if (routeSpec.methods.includes("GET") && body_to_generate_schema) { - methods = methods.filter((m) => m !== "GET") - } - + const methods = routeSpec.methods if (methods.length === 0) { console.warn( chalk.yellow(`Skipping route ${routePath} because it has no methods.`) @@ -216,123 +206,161 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) { dashifyObjectKeys(descriptionMetadata) ) - const route: OperationObject = { - ...routeSpec.openApiMetadata, - ...formattedDescriptionMetadata, - summary: routePath, - ...(description && { description }), - responses: { - 200: { - description: "OK", - }, - 400: { - description: "Bad Request", - }, - 401: { - description: "Unauthorized", - }, - }, - security: Array.isArray(routeSpec.auth) - ? routeSpec.auth - .map((authType) => securityObjectsForAuthType[authType]) - .flat() - : securityObjectsForAuthType[routeSpec.auth], - } + // Create a map to store method-specific route objects + const methodRoutes: Record = {} + + // Loop through each method and create method-specific route objects + for (const method of methods) { + const isPostOrPutOrPatch = ["POST", "PUT", "PATCH"].includes(method) + + // Calculate body schema for this specific method + let body_to_generate_schema + if (isPostOrPutOrPatch) { + body_to_generate_schema = routeSpec.jsonBody ?? routeSpec.commonParams - if (body_to_generate_schema) { - route.requestBody = { - content: { - "application/json": { - schema: generateSchema(body_to_generate_schema as any), + if (routeSpec.jsonBody && routeSpec.commonParams) { + body_to_generate_schema = routeSpec.jsonBody.merge( + routeSpec.commonParams + ) + } + } else { + body_to_generate_schema = routeSpec.jsonBody + } + + // Calculate query schema for this specific method + let query_to_generate_schema + if (isPostOrPutOrPatch) { + query_to_generate_schema = routeSpec.queryParams + } else { + query_to_generate_schema = + routeSpec.queryParams ?? routeSpec.commonParams + + if (routeSpec.queryParams && routeSpec.commonParams) { + query_to_generate_schema = routeSpec.queryParams.merge( + routeSpec.commonParams + ) + } + } + + // Create base route object for this method + const route: OperationObject = { + ...routeSpec.openApiMetadata, + ...formattedDescriptionMetadata, + summary: routePath, + ...(description && { description }), + operationId: `${transformPathToOperationId(routePath)}${pascalCase( + method + )}`, + responses: { + 200: { + description: "OK", + }, + 400: { + description: "Bad Request", + }, + 401: { + description: "Unauthorized", }, }, + security: Array.isArray(routeSpec.auth) + ? routeSpec.auth + .map((authType) => securityObjectsForAuthType[authType]) + .flat() + : securityObjectsForAuthType[routeSpec.auth], } - } - if (query_to_generate_schema) { - const schema = generateSchema(query_to_generate_schema as any) - if (schema.properties) { - const parameters: ParameterObject[] = Object.keys( - schema.properties as any - ).map((name) => { - return { - name, - in: "query", - schema: schema.properties![name], - required: schema.required?.includes(name), - } - }) + // Add request body if applicable for this method + if (body_to_generate_schema) { + route.requestBody = { + content: { + "application/json": { + schema: generateSchema(body_to_generate_schema as any), + }, + }, + } + } - route.parameters = parameters + // Add parameters if applicable for this method + if (query_to_generate_schema) { + const schema = generateSchema(query_to_generate_schema as any) + if (schema.properties) { + const parameters: ParameterObject[] = Object.keys( + schema.properties as any + ).map((name) => { + return { + name, + in: "query", + schema: schema.properties![name], + required: schema.required?.includes(name), + } + }) + + route.parameters = parameters + } } - } - 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.` + // Handle JSON response + 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 - } + continue + } - const responseSchema = generateSchema( - addOkStatus && jsonResponse instanceof z.ZodObject - ? jsonResponse.extend({ ok: z.boolean() }) - : jsonResponse - ) + const responseSchema = generateSchema( + addOkStatus && jsonResponse instanceof z.ZodObject + ? jsonResponse.extend({ ok: z.boolean() }) + : jsonResponse + ) - const schemaWithReferences = embedSchemaReferences( - responseSchema, - globalSchemas - ) + const schemaWithReferences = embedSchemaReferences( + responseSchema, + globalSchemas + ) - // TODO: we should not hardcode 200 here - if (route.responses != null) { - route.responses[200].content = { - "application/json": { - schema: schemaWithReferences, - }, + if (route.responses != null) { + route.responses[200].content = { + "application/json": { + schema: schemaWithReferences, + }, + } } } - } - route.tags = [] - for (const tag of tags) { - if (tag.doesRouteHaveTag && tag.doesRouteHaveTag(route.summary || "")) { - route.tags.push(tag.name) + // Add tags + route.tags = [] + for (const tag of tags) { + if (tag.doesRouteHaveTag && tag.doesRouteHaveTag(route.summary || "")) { + route.tags.push(tag.name) + } } - } - const methodsMappedToFernSdkMetadata = await mapMethodsToFernSdkMetadata({ - methods, - path: routePath, - sdkReturnValue: - descriptionMetadata?.response_key ?? routeSpec.sdkReturnValue, - }) + // Get Fern SDK metadata for this specific method + const methodsMappedToFernSdkMetadata = await mapMethodsToFernSdkMetadata({ + methods: [method], // Only pass this specific method + path: routePath, + sdkReturnValue: + descriptionMetadata?.response_key ?? routeSpec.sdkReturnValue, + }) + + // Apply method-specific metadata + Object.assign(route, methodsMappedToFernSdkMetadata[method]) + // Store the route for this method + methodRoutes[method.toLowerCase()] = route + } // Some routes accept multiple methods - builder.addPath(routePath, { - ...methods - .map((method) => ({ - [method.toLowerCase()]: { - ...methodsMappedToFernSdkMetadata[method], - ...route, - operationId: `${transformPathToOperationId(routePath)}${pascalCase( - method - )}`, - }, - })) - .reduceRight((acc, cur) => ({ ...acc, ...cur }), {}), - }) + builder.addPath(routePath, methodRoutes) } if (outputFile) { From 2fe0b0b63c10c439e61a90a233b9d6273ab80bdb Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Wed, 18 Jun 2025 15:01:22 -0700 Subject: [PATCH 2/4] Use ubuntu-latest --- .github/workflows/npm-lint.yml | 2 +- .github/workflows/npm-semantic-release.yml | 2 +- .github/workflows/npm-test.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/npm-lint.yml b/.github/workflows/npm-lint.yml index d5e189e3c..f577497ef 100644 --- a/.github/workflows/npm-lint.yml +++ b/.github/workflows/npm-lint.yml @@ -4,7 +4,7 @@ jobs: npm_test: if: "!contains(github.event.head_commit.message, 'skip ci')" name: Run NPM lint - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v1 diff --git a/.github/workflows/npm-semantic-release.yml b/.github/workflows/npm-semantic-release.yml index f975a4921..1fa0b4477 100644 --- a/.github/workflows/npm-semantic-release.yml +++ b/.github/workflows/npm-semantic-release.yml @@ -7,7 +7,7 @@ jobs: publish: if: "!contains(github.event.head_commit.message, 'skip ci')" name: Release - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v1 diff --git a/.github/workflows/npm-test.yml b/.github/workflows/npm-test.yml index 9bb0c415d..1ed1e3503 100644 --- a/.github/workflows/npm-test.yml +++ b/.github/workflows/npm-test.yml @@ -4,7 +4,7 @@ jobs: npm_test: if: "!contains(github.event.head_commit.message, 'skip ci')" name: Run NPM Test - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v1 From eda7d6d583ae8c00a213b7c51296d2a4fb88ba6f Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Wed, 18 Jun 2025 15:06:37 -0700 Subject: [PATCH 3/4] Update action versions --- .github/workflows/npm-lint.yml | 4 ++-- .github/workflows/npm-semantic-release.yml | 4 ++-- .github/workflows/npm-test.yml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/npm-lint.yml b/.github/workflows/npm-lint.yml index f577497ef..21a698967 100644 --- a/.github/workflows/npm-lint.yml +++ b/.github/workflows/npm-lint.yml @@ -7,9 +7,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v1 + uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: 16 cache: "yarn" diff --git a/.github/workflows/npm-semantic-release.yml b/.github/workflows/npm-semantic-release.yml index 1fa0b4477..90ca40299 100644 --- a/.github/workflows/npm-semantic-release.yml +++ b/.github/workflows/npm-semantic-release.yml @@ -10,9 +10,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v1 + uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: "16.x" registry-url: "https://registry.npmjs.org" diff --git a/.github/workflows/npm-test.yml b/.github/workflows/npm-test.yml index 1ed1e3503..2976c2d91 100644 --- a/.github/workflows/npm-test.yml +++ b/.github/workflows/npm-test.yml @@ -7,9 +7,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v1 + uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: 16 cache: "yarn" From 355143a904f85e14e4e901f4a54e9339fa3df977 Mon Sep 17 00:00:00 2001 From: Mike Wu Date: Thu, 19 Jun 2025 07:26:37 +0900 Subject: [PATCH 4/4] cleanup --- .../src/generators/generate-openapi/index.ts | 50 ++----------------- 1 file changed, 3 insertions(+), 47 deletions(-) diff --git a/packages/nextlove/src/generators/generate-openapi/index.ts b/packages/nextlove/src/generators/generate-openapi/index.ts index f1d953a50..309fa671f 100644 --- a/packages/nextlove/src/generators/generate-openapi/index.ts +++ b/packages/nextlove/src/generators/generate-openapi/index.ts @@ -143,39 +143,6 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) { file_path, { setupParams, routeSpec, route: routePath }, ] of filepathToRouteFn) { - const isPostOrPutOrPatch = ["POST", "PUT", "PATCH"].some((method) => - routeSpec.methods.includes(method) - ) - // TODO: support multipart/form-data - - // handle body - let body_to_generate_schema - if (isPostOrPutOrPatch) { - body_to_generate_schema = routeSpec.jsonBody ?? routeSpec.commonParams - - if (routeSpec.jsonBody && routeSpec.commonParams) { - body_to_generate_schema = routeSpec.jsonBody.merge( - routeSpec.commonParams - ) - } - } else { - body_to_generate_schema = routeSpec.jsonBody - } - - // handle query - let query_to_generate_schema - if (isPostOrPutOrPatch) { - query_to_generate_schema = routeSpec.queryParams - } else { - query_to_generate_schema = routeSpec.queryParams ?? routeSpec.commonParams - - if (routeSpec.queryParams && routeSpec.commonParams) { - query_to_generate_schema = routeSpec.queryParams.merge( - routeSpec.commonParams - ) - } - } - const methods = routeSpec.methods if (methods.length === 0) { console.warn( @@ -206,14 +173,12 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) { dashifyObjectKeys(descriptionMetadata) ) - // Create a map to store method-specific route objects const methodRoutes: Record = {} - // Loop through each method and create method-specific route objects for (const method of methods) { const isPostOrPutOrPatch = ["POST", "PUT", "PATCH"].includes(method) - // Calculate body schema for this specific method + // TODO: support multipart/form-data let body_to_generate_schema if (isPostOrPutOrPatch) { body_to_generate_schema = routeSpec.jsonBody ?? routeSpec.commonParams @@ -227,7 +192,6 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) { body_to_generate_schema = routeSpec.jsonBody } - // Calculate query schema for this specific method let query_to_generate_schema if (isPostOrPutOrPatch) { query_to_generate_schema = routeSpec.queryParams @@ -242,7 +206,6 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) { } } - // Create base route object for this method const route: OperationObject = { ...routeSpec.openApiMetadata, ...formattedDescriptionMetadata, @@ -269,7 +232,6 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) { : securityObjectsForAuthType[routeSpec.auth], } - // Add request body if applicable for this method if (body_to_generate_schema) { route.requestBody = { content: { @@ -280,7 +242,6 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) { } } - // Add parameters if applicable for this method if (query_to_generate_schema) { const schema = generateSchema(query_to_generate_schema as any) if (schema.properties) { @@ -299,7 +260,6 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) { } } - // Handle JSON response const { jsonResponse } = routeSpec const { addOkStatus = true } = setupParams @@ -329,6 +289,7 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) { ) if (route.responses != null) { + // TODO: we should not hardcode 200 here route.responses[200].content = { "application/json": { schema: schemaWithReferences, @@ -337,7 +298,6 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) { } } - // Add tags route.tags = [] for (const tag of tags) { if (tag.doesRouteHaveTag && tag.doesRouteHaveTag(route.summary || "")) { @@ -345,7 +305,6 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) { } } - // Get Fern SDK metadata for this specific method const methodsMappedToFernSdkMetadata = await mapMethodsToFernSdkMetadata({ methods: [method], // Only pass this specific method path: routePath, @@ -353,13 +312,10 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) { descriptionMetadata?.response_key ?? routeSpec.sdkReturnValue, }) - // Apply method-specific metadata Object.assign(route, methodsMappedToFernSdkMetadata[method]) - - // Store the route for this method methodRoutes[method.toLowerCase()] = route } - // Some routes accept multiple methods + builder.addPath(routePath, methodRoutes) }