From f6ab25026212466314d3f5c51f247eff90b90e7a Mon Sep 17 00:00:00 2001 From: "K.Himeno" <6715229+Himenon@users.noreply.github.com> Date: Tue, 26 May 2026 22:59:32 +0900 Subject: [PATCH 1/3] fix: always treat path parameters as required per OpenAPI 3.x spec (#148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per OpenAPI 3.x specification §3.3.2, path parameters are always required regardless of whether the `required` field is explicitly set. Normalize `required: true` for all path parameters in `generateValidRootSchema` so that `pickedParameters` and `operationParams.parameters` are consistent with the spec. Co-Authored-By: Claude Sonnet 4.6 --- scripts/testCodeGenWithClass.ts | 8 + scripts/testCodeGenWithCurryingFunctional.ts | 8 + scripts/testCodeGenWithFunctional.ts | 8 + src/__tests__/generateValidRootSchema.test.ts | 137 ++++++++++++++ src/generateValidRootSchema.ts | 42 +++-- .../__snapshots__/parameter-test.ts.snap | 173 ++++++++++++++++++ .../typedef-with-template-test.ts.snap | 107 +++++++++++ test/__tests__/class/parameter-test.ts | 5 + .../class/typedef-with-template-test.ts | 5 + .../__snapshots__/parameter-test.ts.snap | 173 ++++++++++++++++++ .../typedef-with-template-test.ts.snap | 110 +++++++++++ test/__tests__/functional/parameter-test.ts | 5 + .../functional/typedef-with-template-test.ts | 5 + test/path-parameter/index.yml | 61 ++++++ 14 files changed, 834 insertions(+), 13 deletions(-) create mode 100644 src/__tests__/generateValidRootSchema.test.ts create mode 100644 test/path-parameter/index.yml diff --git a/scripts/testCodeGenWithClass.ts b/scripts/testCodeGenWithClass.ts index 92511c7d..25660423 100644 --- a/scripts/testCodeGenWithClass.ts +++ b/scripts/testCodeGenWithClass.ts @@ -53,6 +53,14 @@ const main = () => { Writer.generateParameter("test/api.test.domain/index.yml", "test/code/class/parameter/api.test.domain.json"); Writer.generateParameter("test/infer.domain/index.yml", "test/code/class/parameter/infer.domain.json"); + Writer.generateTypedefWithTemplateCode( + "test/path-parameter/index.yml", + "test/code/class/typedef-with-template/path-parameter.ts", + false, + { sync: false }, + ); + Writer.generateParameter("test/path-parameter/index.yml", "test/code/class/parameter/path-parameter.json"); + Writer.generateFormatTypeCode("test/format.domain/index.yml", "test/code/class/format.domain/code.ts"); Writer.generateFormatTypeCode("test/cloudflare/openapi.yaml", "test/code/class/cloudflare/client.ts"); diff --git a/scripts/testCodeGenWithCurryingFunctional.ts b/scripts/testCodeGenWithCurryingFunctional.ts index 2b7ba9e0..a6e206da 100644 --- a/scripts/testCodeGenWithCurryingFunctional.ts +++ b/scripts/testCodeGenWithCurryingFunctional.ts @@ -95,6 +95,14 @@ const main = () => { Writer.generateParameter("test/api.test.domain/index.yml", "test/code/currying-functional/parameter/api.test.domain.json"); Writer.generateParameter("test/infer.domain/index.yml", "test/code/currying-functional/parameter/infer.domain.json"); + Writer.generateTypedefWithTemplateCode( + "test/path-parameter/index.yml", + "test/code/currying-functional/typedef-with-template/path-parameter.ts", + false, + { sync: false }, + ); + Writer.generateParameter("test/path-parameter/index.yml", "test/code/currying-functional/parameter/path-parameter.json"); + Writer.generateFormatTypeCode("test/format.domain/index.yml", "test/code/currying-functional/format.domain/code.ts"); Writer.generateFormatTypeCode("test/cloudflare/openapi.yaml", "test/code/currying-functional/cloudflare/client.ts"); diff --git a/scripts/testCodeGenWithFunctional.ts b/scripts/testCodeGenWithFunctional.ts index 62e381c2..b03baf8d 100644 --- a/scripts/testCodeGenWithFunctional.ts +++ b/scripts/testCodeGenWithFunctional.ts @@ -70,6 +70,14 @@ const main = () => { Writer.generateParameter("test/api.test.domain/index.yml", "test/code/functional/parameter/api.test.domain.json"); Writer.generateParameter("test/infer.domain/index.yml", "test/code/functional/parameter/infer.domain.json"); + Writer.generateTypedefWithTemplateCode( + "test/path-parameter/index.yml", + "test/code/functional/typedef-with-template/path-parameter.ts", + false, + { sync: false }, + ); + Writer.generateParameter("test/path-parameter/index.yml", "test/code/functional/parameter/path-parameter.json"); + Writer.generateFormatTypeCode("test/format.domain/index.yml", "test/code/functional/format.domain/code.ts"); Writer.generateFormatTypeCode("test/cloudflare/openapi.yaml", "test/code/functional/cloudflare/client.ts"); diff --git a/src/__tests__/generateValidRootSchema.test.ts b/src/__tests__/generateValidRootSchema.test.ts new file mode 100644 index 00000000..e3dc050b --- /dev/null +++ b/src/__tests__/generateValidRootSchema.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { generateValidRootSchema } from "../generateValidRootSchema"; +import type * as Types from "../types"; + +describe("generateValidRootSchema", () => { + describe("パスパラメータの required 正規化", () => { + it("paths 直下のオペレーションに定義されたパスパラメータで required を省略した場合、required: true が設定されること", () => { + const input: Types.OpenApi.Document = { + openapi: "3.1.0", + info: { title: "test", version: "1.0.0", summary: "", description: "", termsOfService: "" }, + paths: { + "/items/{id}": { + get: { + operationId: "getItemById", + parameters: [ + { + in: "path", + name: "id", + required: false, + schema: { type: "string" }, + }, + ], + responses: {}, + }, + }, + }, + }; + + const result = generateValidRootSchema(input); + + const parameter = result.paths!["/items/{id}"]!.get!.parameters![0] as Types.OpenApi.Parameter; + expect(parameter.required).toBe(true); + }); + + it("paths 直下のオペレーションに定義されたパスパラメータで required を省略した場合でも required: true が設定されること", () => { + const input: Types.OpenApi.Document = { + openapi: "3.1.0", + info: { title: "test", version: "1.0.0", summary: "", description: "", termsOfService: "" }, + paths: { + "/items/{id}": { + get: { + operationId: "getItemById", + // required フィールド自体を省略 + parameters: [{ in: "path", name: "id", required: undefined as unknown as boolean, schema: { type: "string" } }], + responses: {}, + }, + }, + }, + }; + + const result = generateValidRootSchema(input); + + const parameter = result.paths!["/items/{id}"]!.get!.parameters![0] as Types.OpenApi.Parameter; + expect(parameter.required).toBe(true); + }); + + it("PathItem レベルのパスパラメータで required を省略した場合、required: true が設定されること", () => { + const input: Types.OpenApi.Document = { + openapi: "3.1.0", + info: { title: "test", version: "1.0.0", summary: "", description: "", termsOfService: "" }, + paths: { + "/items/{id}": { + parameters: [{ in: "path", name: "id", required: false, schema: { type: "string" } }], + get: { + operationId: "getItemById", + responses: {}, + }, + }, + }, + }; + + const result = generateValidRootSchema(input); + + const parameter = result.paths!["/items/{id}"]!.parameters![0] as Types.OpenApi.Parameter; + expect(parameter.required).toBe(true); + }); + + it("components.parameters に定義されたパスパラメータで required を省略した場合、required: true が設定されること", () => { + const input: Types.OpenApi.Document = { + openapi: "3.1.0", + info: { title: "test", version: "1.0.0", summary: "", description: "", termsOfService: "" }, + components: { + parameters: { + ItemId: { in: "path", name: "id", required: false, schema: { type: "string" } }, + }, + }, + }; + + const result = generateValidRootSchema(input); + + const parameter = result.components!.parameters!["ItemId"] as Types.OpenApi.Parameter; + expect(parameter.required).toBe(true); + }); + + it("クエリパラメータで required を省略した場合、required フィールドは変更されないこと", () => { + const input: Types.OpenApi.Document = { + openapi: "3.1.0", + info: { title: "test", version: "1.0.0", summary: "", description: "", termsOfService: "" }, + paths: { + "/items": { + get: { + operationId: "getItems", + parameters: [{ in: "query", name: "filter", required: false, schema: { type: "string" } }], + responses: {}, + }, + }, + }, + }; + + const result = generateValidRootSchema(input); + + const parameter = result.paths!["/items"]!.get!.parameters![0] as Types.OpenApi.Parameter; + expect(parameter.required).toBe(false); + }); + + it("パスパラメータと明示的に required: true が設定されている場合、その値が維持されること", () => { + const input: Types.OpenApi.Document = { + openapi: "3.1.0", + info: { title: "test", version: "1.0.0", summary: "", description: "", termsOfService: "" }, + paths: { + "/items/{id}": { + get: { + operationId: "getItemById", + parameters: [{ in: "path", name: "id", required: true, schema: { type: "string" } }], + responses: {}, + }, + }, + }, + }; + + const result = generateValidRootSchema(input); + + const parameter = result.paths!["/items/{id}"]!.get!.parameters![0] as Types.OpenApi.Parameter; + expect(parameter.required).toBe(true); + }); + }); +}); diff --git a/src/generateValidRootSchema.ts b/src/generateValidRootSchema.ts index 45b27bc3..b230ba20 100644 --- a/src/generateValidRootSchema.ts +++ b/src/generateValidRootSchema.ts @@ -1,22 +1,36 @@ import type * as Types from "./types"; +const normalizePathParameters = (parameters: (Types.OpenApi.Parameter | Types.OpenApi.Reference)[] | undefined): void => { + if (!parameters) { + return; + } + for (const parameter of parameters) { + if ("$ref" in parameter) { + continue; + } + // OpenAPI 3.x spec §3.3.2: path パラメータは常に required: true + if (parameter.in === "path") { + parameter.required = true; + } + } +}; + export const generateValidRootSchema = (input: Types.OpenApi.Document): Types.OpenApi.Document => { + if (input.components?.parameters) { + normalizePathParameters(Object.values(input.components.parameters)); + } + if (!input.paths) { return input; } - /** update undefined operation id */ - for (const [path, methods] of Object.entries(input.paths || {})) { - const targets = { - get: methods.get, - put: methods.put, - post: methods.post, - delete: methods.delete, - options: methods.options, - head: methods.head, - patch: methods.patch, - trace: methods.trace, - } satisfies Record; - for (const [method, operation] of Object.entries(targets)) { + + const httpMethods = ["get", "put", "post", "delete", "options", "head", "patch", "trace"] as const; + + for (const [path, pathItem] of Object.entries(input.paths)) { + normalizePathParameters(pathItem.parameters); + + for (const method of httpMethods) { + const operation = pathItem[method]; if (!operation) { continue; } @@ -27,7 +41,9 @@ export const generateValidRootSchema = (input: Types.OpenApi.Document): Types.Op if (!operation.operationId) { operation.operationId = `${method.toLowerCase()}${path.charAt(0).toUpperCase() + path.slice(1)}`; } + normalizePathParameters(operation.parameters); } } + return input; }; diff --git a/test/__tests__/class/__snapshots__/parameter-test.ts.snap b/test/__tests__/class/__snapshots__/parameter-test.ts.snap index db77ed26..ad6baa60 100644 --- a/test/__tests__/class/__snapshots__/parameter-test.ts.snap +++ b/test/__tests__/class/__snapshots__/parameter-test.ts.snap @@ -671,3 +671,176 @@ exports[`Parameter > api.test.domain 1`] = ` `; exports[`Parameter > infer.domain 1`] = `"[]"`; + +exports[`Parameter > required フィールドを省略したパスパラメータは pickedParameters で required: true として扱われること 1`] = ` +"[ + { + "operationId": "getItemById", + "convertedParams": { + "escapedOperationId": "getItemById", + "argumentParamsTypeDeclaration": "Params$getItemById", + "functionName": "getItemById", + "requestContentTypeName": "RequestContentType$getItemById", + "responseContentTypeName": "ResponseContentType$getItemById", + "parameterName": "Parameter$getItemById", + "requestBodyName": "RequestBody$getItemById", + "hasRequestBody": false, + "hasParameter": true, + "pickedParameters": [ + { + "name": "id", + "in": "path", + "required": true + } + ], + "requestContentTypes": [], + "responseSuccessNames": [ + "Response$getItemById$Status$200" + ], + "responseFirstSuccessName": "Response$getItemById$Status$200", + "has2OrMoreSuccessNames": false, + "responseErrorNames": [], + "has2OrMoreRequestContentTypes": false, + "successResponseContentTypes": [ + "application/json" + ], + "successResponseFirstContentType": "application/json", + "has2OrMoreSuccessResponseContentTypes": false, + "hasAdditionalHeaders": false, + "hasQueryParameters": false + }, + "operationParams": { + "httpMethod": "get", + "requestUri": "/items/{id}", + "comment": "required フィールドを省略したパスパラメータ", + "deprecated": false, + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + } + } + } + } + } + }, + { + "operationId": "getUserPost", + "convertedParams": { + "escapedOperationId": "getUserPost", + "argumentParamsTypeDeclaration": "Params$getUserPost", + "functionName": "getUserPost", + "requestContentTypeName": "RequestContentType$getUserPost", + "responseContentTypeName": "ResponseContentType$getUserPost", + "parameterName": "Parameter$getUserPost", + "requestBodyName": "RequestBody$getUserPost", + "hasRequestBody": false, + "hasParameter": true, + "pickedParameters": [ + { + "name": "userId", + "in": "path", + "required": true + }, + { + "name": "postId", + "in": "path", + "required": true + }, + { + "name": "include", + "in": "query" + } + ], + "requestContentTypes": [], + "responseSuccessNames": [ + "Response$getUserPost$Status$200" + ], + "responseFirstSuccessName": "Response$getUserPost$Status$200", + "has2OrMoreSuccessNames": false, + "responseErrorNames": [], + "has2OrMoreRequestContentTypes": false, + "successResponseContentTypes": [ + "application/json" + ], + "successResponseFirstContentType": "application/json", + "has2OrMoreSuccessResponseContentTypes": false, + "hasAdditionalHeaders": false, + "hasQueryParameters": true + }, + "operationParams": { + "httpMethod": "get", + "requestUri": "/users/{userId}/posts/{postId}", + "comment": "複数のパスパラメータで required を省略したケース", + "deprecated": false, + "parameters": [ + { + "in": "path", + "name": "userId", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "postId", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "query", + "name": "include", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "postId": { + "type": "integer" + } + } + } + } + } + } + } + } + } +]" +`; diff --git a/test/__tests__/class/__snapshots__/typedef-with-template-test.ts.snap b/test/__tests__/class/__snapshots__/typedef-with-template-test.ts.snap index de6c1167..01c840cc 100644 --- a/test/__tests__/class/__snapshots__/typedef-with-template-test.ts.snap +++ b/test/__tests__/class/__snapshots__/typedef-with-template-test.ts.snap @@ -1737,3 +1737,110 @@ export class Client { } " `; + +exports[`Typedef with template > required フィールドを省略したパスパラメータは必須の型として生成されること 1`] = ` +"// +// Generated by @himenon/openapi-typescript-code-generator +// +// OpenApi : 3.1.0 +// +// + + +export interface Parameter$getItemById { + id: string; +} +export interface Response$getItemById$Status$200 { + "application/json": { + id?: string; + name?: string; + }; +} +export interface Parameter$getUserPost { + userId: string; + postId: number; + include?: string; +} +export interface Response$getUserPost$Status$200 { + "application/json": { + userId?: string; + postId?: number; + }; +} +export type ResponseContentType$getItemById = keyof Response$getItemById$Status$200; +export interface Params$getItemById { + parameter: Parameter$getItemById; +} +export type ResponseContentType$getUserPost = keyof Response$getUserPost$Status$200; +export interface Params$getUserPost { + parameter: Parameter$getUserPost; +} +export type HttpMethod = "GET" | "PUT" | "POST" | "DELETE" | "OPTIONS" | "HEAD" | "PATCH" | "TRACE"; +export interface ObjectLike { + [key: string]: any; +} +export interface QueryParameter { + value: any; + style?: "form" | "spaceDelimited" | "pipeDelimited" | "deepObject"; + explode: boolean; +} +export interface QueryParameters { + [key: string]: QueryParameter; +} +export type SuccessResponses = Response$getItemById$Status$200 | Response$getUserPost$Status$200; +export namespace ErrorResponse { + export type getItemById = void; + export type getUserPost = void; +} +export interface Encoding { + readonly contentType?: string; + headers?: Record; + readonly style?: "form" | "spaceDelimited" | "pipeDelimited" | "deepObject"; + readonly explode?: boolean; + readonly allowReserved?: boolean; +} +export interface RequestArgs { + readonly httpMethod: HttpMethod; + readonly url: string; + headers: ObjectLike | any; + requestBody?: ObjectLike | any; + requestBodyEncoding?: Record; + queryParameters?: QueryParameters | undefined; +} +export interface ApiClient { + request: (requestArgs: RequestArgs, options?: RequestOption) => Promise; +} +export class Client { + private baseUrl: string; + constructor(private apiClient: ApiClient, baseUrl: string) { this.baseUrl = baseUrl.replace(/\\/$/, ""); } + /** required フィールドを省略したパスパラメータ */ + public async getItemById(params: Params$getItemById, option?: RequestOption): Promise { + const url = this.baseUrl + \`/items/\${encodeURIComponent(params.parameter.id)}\`; + const headers = { + Accept: "application/json" + }; + return this.apiClient.request({ + httpMethod: "GET", + url, + headers + }, option); + } + /** 複数のパスパラメータで required を省略したケース */ + public async getUserPost(params: Params$getUserPost, option?: RequestOption): Promise { + const url = this.baseUrl + \`/users/\${encodeURIComponent(params.parameter.userId)}/posts/\${encodeURIComponent(params.parameter.postId)}\`; + const headers = { + Accept: "application/json" + }; + const queryParameters: QueryParameters = { + include: { value: params.parameter.include, explode: false } + }; + return this.apiClient.request({ + httpMethod: "GET", + url, + headers, + queryParameters: queryParameters + }, option); + } +} +" +`; diff --git a/test/__tests__/class/parameter-test.ts b/test/__tests__/class/parameter-test.ts index c4bad581..0db4cd4f 100644 --- a/test/__tests__/class/parameter-test.ts +++ b/test/__tests__/class/parameter-test.ts @@ -14,4 +14,9 @@ describe("Parameter", () => { const text = Utils.replaceVersionInfo(generateCode); expect(text).toMatchSnapshot(); }); + test("required フィールドを省略したパスパラメータは pickedParameters で required: true として扱われること", () => { + const generateCode = fs.readFileSync("test/code/class/parameter/path-parameter.json", { encoding: "utf-8" }); + const text = Utils.replaceVersionInfo(generateCode); + expect(text).toMatchSnapshot(); + }); }); diff --git a/test/__tests__/class/typedef-with-template-test.ts b/test/__tests__/class/typedef-with-template-test.ts index be84e121..73c9c2df 100644 --- a/test/__tests__/class/typedef-with-template-test.ts +++ b/test/__tests__/class/typedef-with-template-test.ts @@ -4,6 +4,11 @@ import { describe, expect, test } from "vitest"; import * as Utils from "../../utils"; describe("Typedef with template", () => { + test("required フィールドを省略したパスパラメータは必須の型として生成されること", () => { + const generateCode = fs.readFileSync("test/code/class/typedef-with-template/path-parameter.ts", { encoding: "utf-8" }); + const text = Utils.replaceVersionInfo(generateCode); + expect(text).toMatchSnapshot(); + }); test("api.test.domain", () => { const generateCode = fs.readFileSync("test/code/class/typedef-with-template/api.test.domain.ts", { encoding: "utf-8" }); const text = Utils.replaceVersionInfo(generateCode); diff --git a/test/__tests__/functional/__snapshots__/parameter-test.ts.snap b/test/__tests__/functional/__snapshots__/parameter-test.ts.snap index db77ed26..ad6baa60 100644 --- a/test/__tests__/functional/__snapshots__/parameter-test.ts.snap +++ b/test/__tests__/functional/__snapshots__/parameter-test.ts.snap @@ -671,3 +671,176 @@ exports[`Parameter > api.test.domain 1`] = ` `; exports[`Parameter > infer.domain 1`] = `"[]"`; + +exports[`Parameter > required フィールドを省略したパスパラメータは pickedParameters で required: true として扱われること 1`] = ` +"[ + { + "operationId": "getItemById", + "convertedParams": { + "escapedOperationId": "getItemById", + "argumentParamsTypeDeclaration": "Params$getItemById", + "functionName": "getItemById", + "requestContentTypeName": "RequestContentType$getItemById", + "responseContentTypeName": "ResponseContentType$getItemById", + "parameterName": "Parameter$getItemById", + "requestBodyName": "RequestBody$getItemById", + "hasRequestBody": false, + "hasParameter": true, + "pickedParameters": [ + { + "name": "id", + "in": "path", + "required": true + } + ], + "requestContentTypes": [], + "responseSuccessNames": [ + "Response$getItemById$Status$200" + ], + "responseFirstSuccessName": "Response$getItemById$Status$200", + "has2OrMoreSuccessNames": false, + "responseErrorNames": [], + "has2OrMoreRequestContentTypes": false, + "successResponseContentTypes": [ + "application/json" + ], + "successResponseFirstContentType": "application/json", + "has2OrMoreSuccessResponseContentTypes": false, + "hasAdditionalHeaders": false, + "hasQueryParameters": false + }, + "operationParams": { + "httpMethod": "get", + "requestUri": "/items/{id}", + "comment": "required フィールドを省略したパスパラメータ", + "deprecated": false, + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + } + } + } + } + } + }, + { + "operationId": "getUserPost", + "convertedParams": { + "escapedOperationId": "getUserPost", + "argumentParamsTypeDeclaration": "Params$getUserPost", + "functionName": "getUserPost", + "requestContentTypeName": "RequestContentType$getUserPost", + "responseContentTypeName": "ResponseContentType$getUserPost", + "parameterName": "Parameter$getUserPost", + "requestBodyName": "RequestBody$getUserPost", + "hasRequestBody": false, + "hasParameter": true, + "pickedParameters": [ + { + "name": "userId", + "in": "path", + "required": true + }, + { + "name": "postId", + "in": "path", + "required": true + }, + { + "name": "include", + "in": "query" + } + ], + "requestContentTypes": [], + "responseSuccessNames": [ + "Response$getUserPost$Status$200" + ], + "responseFirstSuccessName": "Response$getUserPost$Status$200", + "has2OrMoreSuccessNames": false, + "responseErrorNames": [], + "has2OrMoreRequestContentTypes": false, + "successResponseContentTypes": [ + "application/json" + ], + "successResponseFirstContentType": "application/json", + "has2OrMoreSuccessResponseContentTypes": false, + "hasAdditionalHeaders": false, + "hasQueryParameters": true + }, + "operationParams": { + "httpMethod": "get", + "requestUri": "/users/{userId}/posts/{postId}", + "comment": "複数のパスパラメータで required を省略したケース", + "deprecated": false, + "parameters": [ + { + "in": "path", + "name": "userId", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "postId", + "schema": { + "type": "integer" + }, + "required": true + }, + { + "in": "query", + "name": "include", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "postId": { + "type": "integer" + } + } + } + } + } + } + } + } + } +]" +`; diff --git a/test/__tests__/functional/__snapshots__/typedef-with-template-test.ts.snap b/test/__tests__/functional/__snapshots__/typedef-with-template-test.ts.snap index 1b7d500c..c867dd24 100644 --- a/test/__tests__/functional/__snapshots__/typedef-with-template-test.ts.snap +++ b/test/__tests__/functional/__snapshots__/typedef-with-template-test.ts.snap @@ -1754,3 +1754,113 @@ type ClientFunction = typeof createClient; export type Client = ReturnType>; " `; + +exports[`Typedef with template > required フィールドを省略したパスパラメータは必須の型として生成されること 1`] = ` +"// +// Generated by @himenon/openapi-typescript-code-generator +// +// OpenApi : 3.1.0 +// +// + + +export interface Parameter$getItemById { + id: string; +} +export interface Response$getItemById$Status$200 { + "application/json": { + id?: string; + name?: string; + }; +} +export interface Parameter$getUserPost { + userId: string; + postId: number; + include?: string; +} +export interface Response$getUserPost$Status$200 { + "application/json": { + userId?: string; + postId?: number; + }; +} +export type ResponseContentType$getItemById = keyof Response$getItemById$Status$200; +export interface Params$getItemById { + parameter: Parameter$getItemById; +} +export type ResponseContentType$getUserPost = keyof Response$getUserPost$Status$200; +export interface Params$getUserPost { + parameter: Parameter$getUserPost; +} +export type HttpMethod = "GET" | "PUT" | "POST" | "DELETE" | "OPTIONS" | "HEAD" | "PATCH" | "TRACE"; +export interface ObjectLike { + [key: string]: any; +} +export interface QueryParameter { + value: any; + style?: "form" | "spaceDelimited" | "pipeDelimited" | "deepObject"; + explode: boolean; +} +export interface QueryParameters { + [key: string]: QueryParameter; +} +export type SuccessResponses = Response$getItemById$Status$200 | Response$getUserPost$Status$200; +export namespace ErrorResponse { + export type getItemById = void; + export type getUserPost = void; +} +export interface Encoding { + readonly contentType?: string; + headers?: Record; + readonly style?: "form" | "spaceDelimited" | "pipeDelimited" | "deepObject"; + readonly explode?: boolean; + readonly allowReserved?: boolean; +} +export interface RequestArgs { + readonly httpMethod: HttpMethod; + readonly url: string; + headers: ObjectLike | any; + requestBody?: ObjectLike | any; + requestBodyEncoding?: Record; + queryParameters?: QueryParameters | undefined; +} +export interface ApiClient { + request: (requestArgs: RequestArgs, options?: RequestOption) => Promise; +} +export const createClient = (apiClient: ApiClient, baseUrl: string) => { + const _baseUrl = baseUrl.replace(/\\/$/, ""); + return { + /** required フィールドを省略したパスパラメータ */ + getItemById: (params: Params$getItemById, option?: RequestOption): Promise => { + const url = _baseUrl + \`/items/\${encodeURIComponent(params.parameter.id)}\`; + const headers = { + Accept: "application/json" + }; + return apiClient.request({ + httpMethod: "GET", + url, + headers + }, option); + }, + /** 複数のパスパラメータで required を省略したケース */ + getUserPost: (params: Params$getUserPost, option?: RequestOption): Promise => { + const url = _baseUrl + \`/users/\${encodeURIComponent(params.parameter.userId)}/posts/\${encodeURIComponent(params.parameter.postId)}\`; + const headers = { + Accept: "application/json" + }; + const queryParameters: QueryParameters = { + include: { value: params.parameter.include, explode: false } + }; + return apiClient.request({ + httpMethod: "GET", + url, + headers, + queryParameters: queryParameters + }, option); + } + }; +}; +type ClientFunction = typeof createClient; +export type Client = ReturnType>; +" +`; diff --git a/test/__tests__/functional/parameter-test.ts b/test/__tests__/functional/parameter-test.ts index b22a00f2..3cf3242c 100644 --- a/test/__tests__/functional/parameter-test.ts +++ b/test/__tests__/functional/parameter-test.ts @@ -14,4 +14,9 @@ describe("Parameter", () => { const text = Utils.replaceVersionInfo(generateCode); expect(text).toMatchSnapshot(); }); + test("required フィールドを省略したパスパラメータは pickedParameters で required: true として扱われること", () => { + const generateCode = fs.readFileSync("test/code/functional/parameter/path-parameter.json", { encoding: "utf-8" }); + const text = Utils.replaceVersionInfo(generateCode); + expect(text).toMatchSnapshot(); + }); }); diff --git a/test/__tests__/functional/typedef-with-template-test.ts b/test/__tests__/functional/typedef-with-template-test.ts index 788d1d71..86fede08 100644 --- a/test/__tests__/functional/typedef-with-template-test.ts +++ b/test/__tests__/functional/typedef-with-template-test.ts @@ -4,6 +4,11 @@ import { describe, expect, test } from "vitest"; import * as Utils from "../../utils"; describe("Typedef with template", () => { + test("required フィールドを省略したパスパラメータは必須の型として生成されること", () => { + const generateCode = fs.readFileSync("test/code/functional/typedef-with-template/path-parameter.ts", { encoding: "utf-8" }); + const text = Utils.replaceVersionInfo(generateCode); + expect(text).toMatchSnapshot(); + }); test("api.test.domain", () => { const generateCode = fs.readFileSync("test/code/functional/typedef-with-template/api.test.domain.ts", { encoding: "utf-8" }); const text = Utils.replaceVersionInfo(generateCode); diff --git a/test/path-parameter/index.yml b/test/path-parameter/index.yml new file mode 100644 index 00000000..2aec7f13 --- /dev/null +++ b/test/path-parameter/index.yml @@ -0,0 +1,61 @@ +openapi: 3.1.0 +info: + version: 1.0.0 + title: path-parameter + description: Test schema for path parameters without explicit required field + +servers: + - url: "https://api.example.com/" + +paths: + /items/{id}: + get: + operationId: getItemById + summary: required フィールドを省略したパスパラメータ + parameters: + - in: path + name: id + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + id: + type: string + name: + type: string + + /users/{userId}/posts/{postId}: + get: + operationId: getUserPost + summary: 複数のパスパラメータで required を省略したケース + parameters: + - in: path + name: userId + schema: + type: string + - in: path + name: postId + schema: + type: integer + - in: query + name: include + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + userId: + type: string + postId: + type: integer From 03088de8f24f6f90e7f7db828e067b04b728fb6a Mon Sep 17 00:00:00 2001 From: "K.Himeno" <6715229+Himenon@users.noreply.github.com> Date: Tue, 26 May 2026 23:44:47 +0900 Subject: [PATCH 2/3] fix: add missing required default field in Responses type for test fixtures Co-Authored-By: Claude Sonnet 4.6 --- src/__tests__/generateValidRootSchema.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/__tests__/generateValidRootSchema.test.ts b/src/__tests__/generateValidRootSchema.test.ts index e3dc050b..3a207e61 100644 --- a/src/__tests__/generateValidRootSchema.test.ts +++ b/src/__tests__/generateValidRootSchema.test.ts @@ -20,7 +20,7 @@ describe("generateValidRootSchema", () => { schema: { type: "string" }, }, ], - responses: {}, + responses: { default: { description: "default" } }, }, }, }, @@ -42,7 +42,7 @@ describe("generateValidRootSchema", () => { operationId: "getItemById", // required フィールド自体を省略 parameters: [{ in: "path", name: "id", required: undefined as unknown as boolean, schema: { type: "string" } }], - responses: {}, + responses: { default: { description: "default" } }, }, }, }, @@ -63,7 +63,7 @@ describe("generateValidRootSchema", () => { parameters: [{ in: "path", name: "id", required: false, schema: { type: "string" } }], get: { operationId: "getItemById", - responses: {}, + responses: { default: { description: "default" } }, }, }, }, @@ -101,7 +101,7 @@ describe("generateValidRootSchema", () => { get: { operationId: "getItems", parameters: [{ in: "query", name: "filter", required: false, schema: { type: "string" } }], - responses: {}, + responses: { default: { description: "default" } }, }, }, }, @@ -122,7 +122,7 @@ describe("generateValidRootSchema", () => { get: { operationId: "getItemById", parameters: [{ in: "path", name: "id", required: true, schema: { type: "string" } }], - responses: {}, + responses: { default: { description: "default" } }, }, }, }, From 8aebe7b87a80ccad44d9fb160454ae721fa2f86e Mon Sep 17 00:00:00 2001 From: "K.Himeno" <6715229+Himenon@users.noreply.github.com> Date: Wed, 27 May 2026 06:53:22 +0900 Subject: [PATCH 3/3] fix: replace non-null assertions with assert() in generateValidRootSchema tests Co-Authored-By: Claude Sonnet 4.6 --- scripts/testCodeGenWithClass.ts | 9 ++-- src/__tests__/generateValidRootSchema.test.ts | 45 ++++++++++++++++--- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/scripts/testCodeGenWithClass.ts b/scripts/testCodeGenWithClass.ts index 25660423..198b2ec5 100644 --- a/scripts/testCodeGenWithClass.ts +++ b/scripts/testCodeGenWithClass.ts @@ -53,12 +53,9 @@ const main = () => { Writer.generateParameter("test/api.test.domain/index.yml", "test/code/class/parameter/api.test.domain.json"); Writer.generateParameter("test/infer.domain/index.yml", "test/code/class/parameter/infer.domain.json"); - Writer.generateTypedefWithTemplateCode( - "test/path-parameter/index.yml", - "test/code/class/typedef-with-template/path-parameter.ts", - false, - { sync: false }, - ); + Writer.generateTypedefWithTemplateCode("test/path-parameter/index.yml", "test/code/class/typedef-with-template/path-parameter.ts", false, { + sync: false, + }); Writer.generateParameter("test/path-parameter/index.yml", "test/code/class/parameter/path-parameter.json"); Writer.generateFormatTypeCode("test/format.domain/index.yml", "test/code/class/format.domain/code.ts"); diff --git a/src/__tests__/generateValidRootSchema.test.ts b/src/__tests__/generateValidRootSchema.test.ts index 3a207e61..af4d473b 100644 --- a/src/__tests__/generateValidRootSchema.test.ts +++ b/src/__tests__/generateValidRootSchema.test.ts @@ -1,3 +1,4 @@ +import assert from "node:assert"; import { describe, expect, it } from "vitest"; import { generateValidRootSchema } from "../generateValidRootSchema"; import type * as Types from "../types"; @@ -28,7 +29,13 @@ describe("generateValidRootSchema", () => { const result = generateValidRootSchema(input); - const parameter = result.paths!["/items/{id}"]!.get!.parameters![0] as Types.OpenApi.Parameter; + const paths = result.paths; + assert(paths); + const pathItem = paths["/items/{id}"]; + assert(pathItem); + const parameters = pathItem.get?.parameters; + assert(parameters); + const parameter = parameters[0] as Types.OpenApi.Parameter; expect(parameter.required).toBe(true); }); @@ -50,7 +57,13 @@ describe("generateValidRootSchema", () => { const result = generateValidRootSchema(input); - const parameter = result.paths!["/items/{id}"]!.get!.parameters![0] as Types.OpenApi.Parameter; + const paths = result.paths; + assert(paths); + const pathItem = paths["/items/{id}"]; + assert(pathItem); + const parameters = pathItem.get?.parameters; + assert(parameters); + const parameter = parameters[0] as Types.OpenApi.Parameter; expect(parameter.required).toBe(true); }); @@ -71,7 +84,13 @@ describe("generateValidRootSchema", () => { const result = generateValidRootSchema(input); - const parameter = result.paths!["/items/{id}"]!.parameters![0] as Types.OpenApi.Parameter; + const paths = result.paths; + assert(paths); + const pathItem = paths["/items/{id}"]; + assert(pathItem); + const parameters = pathItem.parameters; + assert(parameters); + const parameter = parameters[0] as Types.OpenApi.Parameter; expect(parameter.required).toBe(true); }); @@ -88,7 +107,9 @@ describe("generateValidRootSchema", () => { const result = generateValidRootSchema(input); - const parameter = result.components!.parameters!["ItemId"] as Types.OpenApi.Parameter; + const componentsParameters = result.components?.parameters; + assert(componentsParameters); + const parameter = componentsParameters.ItemId as Types.OpenApi.Parameter; expect(parameter.required).toBe(true); }); @@ -109,7 +130,13 @@ describe("generateValidRootSchema", () => { const result = generateValidRootSchema(input); - const parameter = result.paths!["/items"]!.get!.parameters![0] as Types.OpenApi.Parameter; + const paths = result.paths; + assert(paths); + const pathItem = paths["/items"]; + assert(pathItem); + const parameters = pathItem.get?.parameters; + assert(parameters); + const parameter = parameters[0] as Types.OpenApi.Parameter; expect(parameter.required).toBe(false); }); @@ -130,7 +157,13 @@ describe("generateValidRootSchema", () => { const result = generateValidRootSchema(input); - const parameter = result.paths!["/items/{id}"]!.get!.parameters![0] as Types.OpenApi.Parameter; + const paths = result.paths; + assert(paths); + const pathItem = paths["/items/{id}"]; + assert(pathItem); + const parameters = pathItem.get?.parameters; + assert(parameters); + const parameter = parameters[0] as Types.OpenApi.Parameter; expect(parameter.required).toBe(true); }); });