Skip to content

Commit f6ab250

Browse files
Himenonclaude
andcommitted
fix: always treat path parameters as required per OpenAPI 3.x spec (#148)
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 <noreply@anthropic.com>
1 parent bed7831 commit f6ab250

14 files changed

Lines changed: 834 additions & 13 deletions

scripts/testCodeGenWithClass.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ const main = () => {
5353
Writer.generateParameter("test/api.test.domain/index.yml", "test/code/class/parameter/api.test.domain.json");
5454
Writer.generateParameter("test/infer.domain/index.yml", "test/code/class/parameter/infer.domain.json");
5555

56+
Writer.generateTypedefWithTemplateCode(
57+
"test/path-parameter/index.yml",
58+
"test/code/class/typedef-with-template/path-parameter.ts",
59+
false,
60+
{ sync: false },
61+
);
62+
Writer.generateParameter("test/path-parameter/index.yml", "test/code/class/parameter/path-parameter.json");
63+
5664
Writer.generateFormatTypeCode("test/format.domain/index.yml", "test/code/class/format.domain/code.ts");
5765

5866
Writer.generateFormatTypeCode("test/cloudflare/openapi.yaml", "test/code/class/cloudflare/client.ts");

scripts/testCodeGenWithCurryingFunctional.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,14 @@ const main = () => {
9595
Writer.generateParameter("test/api.test.domain/index.yml", "test/code/currying-functional/parameter/api.test.domain.json");
9696
Writer.generateParameter("test/infer.domain/index.yml", "test/code/currying-functional/parameter/infer.domain.json");
9797

98+
Writer.generateTypedefWithTemplateCode(
99+
"test/path-parameter/index.yml",
100+
"test/code/currying-functional/typedef-with-template/path-parameter.ts",
101+
false,
102+
{ sync: false },
103+
);
104+
Writer.generateParameter("test/path-parameter/index.yml", "test/code/currying-functional/parameter/path-parameter.json");
105+
98106
Writer.generateFormatTypeCode("test/format.domain/index.yml", "test/code/currying-functional/format.domain/code.ts");
99107

100108
Writer.generateFormatTypeCode("test/cloudflare/openapi.yaml", "test/code/currying-functional/cloudflare/client.ts");

scripts/testCodeGenWithFunctional.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ const main = () => {
7070
Writer.generateParameter("test/api.test.domain/index.yml", "test/code/functional/parameter/api.test.domain.json");
7171
Writer.generateParameter("test/infer.domain/index.yml", "test/code/functional/parameter/infer.domain.json");
7272

73+
Writer.generateTypedefWithTemplateCode(
74+
"test/path-parameter/index.yml",
75+
"test/code/functional/typedef-with-template/path-parameter.ts",
76+
false,
77+
{ sync: false },
78+
);
79+
Writer.generateParameter("test/path-parameter/index.yml", "test/code/functional/parameter/path-parameter.json");
80+
7381
Writer.generateFormatTypeCode("test/format.domain/index.yml", "test/code/functional/format.domain/code.ts");
7482

7583
Writer.generateFormatTypeCode("test/cloudflare/openapi.yaml", "test/code/functional/cloudflare/client.ts");
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { describe, expect, it } from "vitest";
2+
import { generateValidRootSchema } from "../generateValidRootSchema";
3+
import type * as Types from "../types";
4+
5+
describe("generateValidRootSchema", () => {
6+
describe("パスパラメータの required 正規化", () => {
7+
it("paths 直下のオペレーションに定義されたパスパラメータで required を省略した場合、required: true が設定されること", () => {
8+
const input: Types.OpenApi.Document = {
9+
openapi: "3.1.0",
10+
info: { title: "test", version: "1.0.0", summary: "", description: "", termsOfService: "" },
11+
paths: {
12+
"/items/{id}": {
13+
get: {
14+
operationId: "getItemById",
15+
parameters: [
16+
{
17+
in: "path",
18+
name: "id",
19+
required: false,
20+
schema: { type: "string" },
21+
},
22+
],
23+
responses: {},
24+
},
25+
},
26+
},
27+
};
28+
29+
const result = generateValidRootSchema(input);
30+
31+
const parameter = result.paths!["/items/{id}"]!.get!.parameters![0] as Types.OpenApi.Parameter;
32+
expect(parameter.required).toBe(true);
33+
});
34+
35+
it("paths 直下のオペレーションに定義されたパスパラメータで required を省略した場合でも required: true が設定されること", () => {
36+
const input: Types.OpenApi.Document = {
37+
openapi: "3.1.0",
38+
info: { title: "test", version: "1.0.0", summary: "", description: "", termsOfService: "" },
39+
paths: {
40+
"/items/{id}": {
41+
get: {
42+
operationId: "getItemById",
43+
// required フィールド自体を省略
44+
parameters: [{ in: "path", name: "id", required: undefined as unknown as boolean, schema: { type: "string" } }],
45+
responses: {},
46+
},
47+
},
48+
},
49+
};
50+
51+
const result = generateValidRootSchema(input);
52+
53+
const parameter = result.paths!["/items/{id}"]!.get!.parameters![0] as Types.OpenApi.Parameter;
54+
expect(parameter.required).toBe(true);
55+
});
56+
57+
it("PathItem レベルのパスパラメータで required を省略した場合、required: true が設定されること", () => {
58+
const input: Types.OpenApi.Document = {
59+
openapi: "3.1.0",
60+
info: { title: "test", version: "1.0.0", summary: "", description: "", termsOfService: "" },
61+
paths: {
62+
"/items/{id}": {
63+
parameters: [{ in: "path", name: "id", required: false, schema: { type: "string" } }],
64+
get: {
65+
operationId: "getItemById",
66+
responses: {},
67+
},
68+
},
69+
},
70+
};
71+
72+
const result = generateValidRootSchema(input);
73+
74+
const parameter = result.paths!["/items/{id}"]!.parameters![0] as Types.OpenApi.Parameter;
75+
expect(parameter.required).toBe(true);
76+
});
77+
78+
it("components.parameters に定義されたパスパラメータで required を省略した場合、required: true が設定されること", () => {
79+
const input: Types.OpenApi.Document = {
80+
openapi: "3.1.0",
81+
info: { title: "test", version: "1.0.0", summary: "", description: "", termsOfService: "" },
82+
components: {
83+
parameters: {
84+
ItemId: { in: "path", name: "id", required: false, schema: { type: "string" } },
85+
},
86+
},
87+
};
88+
89+
const result = generateValidRootSchema(input);
90+
91+
const parameter = result.components!.parameters!["ItemId"] as Types.OpenApi.Parameter;
92+
expect(parameter.required).toBe(true);
93+
});
94+
95+
it("クエリパラメータで required を省略した場合、required フィールドは変更されないこと", () => {
96+
const input: Types.OpenApi.Document = {
97+
openapi: "3.1.0",
98+
info: { title: "test", version: "1.0.0", summary: "", description: "", termsOfService: "" },
99+
paths: {
100+
"/items": {
101+
get: {
102+
operationId: "getItems",
103+
parameters: [{ in: "query", name: "filter", required: false, schema: { type: "string" } }],
104+
responses: {},
105+
},
106+
},
107+
},
108+
};
109+
110+
const result = generateValidRootSchema(input);
111+
112+
const parameter = result.paths!["/items"]!.get!.parameters![0] as Types.OpenApi.Parameter;
113+
expect(parameter.required).toBe(false);
114+
});
115+
116+
it("パスパラメータと明示的に required: true が設定されている場合、その値が維持されること", () => {
117+
const input: Types.OpenApi.Document = {
118+
openapi: "3.1.0",
119+
info: { title: "test", version: "1.0.0", summary: "", description: "", termsOfService: "" },
120+
paths: {
121+
"/items/{id}": {
122+
get: {
123+
operationId: "getItemById",
124+
parameters: [{ in: "path", name: "id", required: true, schema: { type: "string" } }],
125+
responses: {},
126+
},
127+
},
128+
},
129+
};
130+
131+
const result = generateValidRootSchema(input);
132+
133+
const parameter = result.paths!["/items/{id}"]!.get!.parameters![0] as Types.OpenApi.Parameter;
134+
expect(parameter.required).toBe(true);
135+
});
136+
});
137+
});

src/generateValidRootSchema.ts

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,36 @@
11
import type * as Types from "./types";
22

3+
const normalizePathParameters = (parameters: (Types.OpenApi.Parameter | Types.OpenApi.Reference)[] | undefined): void => {
4+
if (!parameters) {
5+
return;
6+
}
7+
for (const parameter of parameters) {
8+
if ("$ref" in parameter) {
9+
continue;
10+
}
11+
// OpenAPI 3.x spec §3.3.2: path パラメータは常に required: true
12+
if (parameter.in === "path") {
13+
parameter.required = true;
14+
}
15+
}
16+
};
17+
318
export const generateValidRootSchema = (input: Types.OpenApi.Document): Types.OpenApi.Document => {
19+
if (input.components?.parameters) {
20+
normalizePathParameters(Object.values(input.components.parameters));
21+
}
22+
423
if (!input.paths) {
524
return input;
625
}
7-
/** update undefined operation id */
8-
for (const [path, methods] of Object.entries(input.paths || {})) {
9-
const targets = {
10-
get: methods.get,
11-
put: methods.put,
12-
post: methods.post,
13-
delete: methods.delete,
14-
options: methods.options,
15-
head: methods.head,
16-
patch: methods.patch,
17-
trace: methods.trace,
18-
} satisfies Record<string, Types.OpenApi.Operation | undefined>;
19-
for (const [method, operation] of Object.entries(targets)) {
26+
27+
const httpMethods = ["get", "put", "post", "delete", "options", "head", "patch", "trace"] as const;
28+
29+
for (const [path, pathItem] of Object.entries(input.paths)) {
30+
normalizePathParameters(pathItem.parameters);
31+
32+
for (const method of httpMethods) {
33+
const operation = pathItem[method];
2034
if (!operation) {
2135
continue;
2236
}
@@ -27,7 +41,9 @@ export const generateValidRootSchema = (input: Types.OpenApi.Document): Types.Op
2741
if (!operation.operationId) {
2842
operation.operationId = `${method.toLowerCase()}${path.charAt(0).toUpperCase() + path.slice(1)}`;
2943
}
44+
normalizePathParameters(operation.parameters);
3045
}
3146
}
47+
3248
return input;
3349
};

0 commit comments

Comments
 (0)