Skip to content

Commit 106fddd

Browse files
Himenonclaude
andauthored
fix: always treat path parameters as required per OpenAPI 3.x spec (#149)
## Summary - Per [OpenAPI 3.x spec §3.3.2](https://spec.openapis.org/oas/v3.1.1#parameter-object), path parameters (`in: path`) are always required — the `required` field is **REQUIRED** and its value MUST be `true`. - Added `normalizePathParameters` in `generateValidRootSchema.ts` to unconditionally set `required: true` on all path parameters, covering three locations: `components.parameters`, PathItem-level `parameters`, and operation-level `parameters`. - Without this fix, path parameters that omit `required: true` in the spec would appear as `required: undefined` in `pickedParameters` and `operationParams.parameters`, violating the OpenAPI contract. ## Test plan - [x] Added unit tests for `generateValidRootSchema` covering: operation-level path params, PathItem-level path params, `components.parameters` path params, query params (unchanged), and already-explicit `required: true` (unchanged). - [x] Added OpenAPI fixture `test/path-parameter/index.yml` with path parameters that omit `required: true`. - [x] Added snapshot tests (class + functional) verifying the generated `Parameter` interface has non-optional properties for path parameters (`id: string`, not `id?: string`), and `pickedParameters` carries `required: true`. - [ ] All existing tests pass (`pnpm test:vitest` 40/40, `pnpm test:snapshot` 53/53). Closes #148 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bed7831 commit 106fddd

14 files changed

Lines changed: 864 additions & 13 deletions

scripts/testCodeGenWithClass.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ 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("test/path-parameter/index.yml", "test/code/class/typedef-with-template/path-parameter.ts", false, {
57+
sync: false,
58+
});
59+
Writer.generateParameter("test/path-parameter/index.yml", "test/code/class/parameter/path-parameter.json");
60+
5661
Writer.generateFormatTypeCode("test/format.domain/index.yml", "test/code/class/format.domain/code.ts");
5762

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

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)