Skip to content

Commit 6c6f311

Browse files
chrisradekChristopher Radek
and
Christopher Radek
authored
tsp-openapi3: support path-level parameters (#4708)
Fixes #4455 Previously, the parameters defined as a child of a `path` were ignored. Now they are added to the operations defined in each of the path's methods. The spec states that duplicates are not allowed in the final operation-level parameter list, and the more specific (operation level) replaces the less specific (path level) if there is a collision. A combination of the location and name uniquely identify a parameter for these purposes. Example: ```yml openapi: 3.0.0 info: title: (title) version: 0.0.0 tags: [] paths: /widgets/{id}: get: operationId: Widgets_read responses: "200": description: The request has succeeded. content: application/json: schema: $ref: "#/components/schemas/Widget" delete: operationId: Widgets_delete responses: "204": description: "There is no content to send for this request, but the headers may be useful. " parameters: - name: id in: path required: true schema: type: string components: schemas: Widget: type: object required: - id properties: id: type: string ``` --------- Co-authored-by: Christopher Radek <Christopher.Radek@microsoft.com>
1 parent da9a31f commit 6c6f311

File tree

6 files changed

+365
-3
lines changed

6 files changed

+365
-3
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: fix
3+
packages:
4+
- "@typespec/openapi3"
5+
---
6+
7+
Updates tsp-openapi3 to include path-level parameters in generated typespec operations.

packages/openapi3/src/cli/actions/convert/interfaces.ts

+1
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export interface TypeSpecOperation extends TypeSpecDeclaration {
112112

113113
export interface TypeSpecOperationParameter {
114114
name: string;
115+
in: string;
115116
doc?: string;
116117
decorators: TypeSpecDecorator[];
117118
isOptional: boolean;

packages/openapi3/src/cli/actions/convert/transforms/transform-paths.ts

+27-1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export function transformPaths(
2929
const operations: TypeSpecOperation[] = [];
3030

3131
for (const route of Object.keys(paths)) {
32+
const routeParameters = paths[route].parameters?.map(transformOperationParameter) ?? [];
3233
const path = paths[route];
3334
for (const verb of supportedHttpMethods) {
3435
const operation = path[verb];
@@ -48,7 +49,7 @@ export function transformPaths(
4849
{ name: "route", args: [route] },
4950
{ name: verb, args: [] },
5051
],
51-
parameters,
52+
parameters: dedupeParameters([...routeParameters, ...parameters]),
5253
doc: operation.description,
5354
operationId: operation.operationId,
5455
requestBodies: transformRequestBodies(operation.requestBody),
@@ -61,6 +62,30 @@ export function transformPaths(
6162
return operations;
6263
}
6364

65+
function dedupeParameters(
66+
parameters: Refable<TypeSpecOperationParameter>[],
67+
): Refable<TypeSpecOperationParameter>[] {
68+
const seen = new Set<string>();
69+
const dedupeList: Refable<TypeSpecOperationParameter>[] = [];
70+
71+
// iterate in reverse since more specific-scoped parameters are added last
72+
for (let i = parameters.length - 1; i >= 0; i--) {
73+
// ignore resolving the $ref for now, unlikely to be able to resolve
74+
// issues without user intervention if a duplicate is present except in
75+
// very simple cases.
76+
const param = parameters[i];
77+
78+
const identifier = "$ref" in param ? param.$ref : `${param.in}.${param.name}`;
79+
80+
if (seen.has(identifier)) continue;
81+
seen.add(identifier);
82+
83+
dedupeList.unshift(param);
84+
}
85+
86+
return dedupeList;
87+
}
88+
6489
function transformOperationParameter(
6590
parameter: Refable<OpenAPI3Parameter>,
6691
): Refable<TypeSpecOperationParameter> {
@@ -70,6 +95,7 @@ function transformOperationParameter(
7095

7196
return {
7297
name: printIdentifier(parameter.name),
98+
in: parameter.in,
7399
doc: parameter.description,
74100
decorators: getParameterDecorators(parameter),
75101
isOptional: !parameter.required,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import assert from "assert";
2+
import { expect, it } from "vitest";
3+
import { OpenAPI3Response } from "../../src/types.js";
4+
import { expectDecorators } from "./utils/expect.js";
5+
import { tspForOpenAPI3 } from "./utils/tsp-for-openapi3.js";
6+
7+
const response: OpenAPI3Response = {
8+
description: "test response",
9+
content: {
10+
"application/json": {
11+
schema: {
12+
type: "object",
13+
properties: {
14+
message: { type: "string" },
15+
},
16+
},
17+
},
18+
},
19+
};
20+
21+
it("generates operations with no params", async () => {
22+
const serviceNamespace = await tspForOpenAPI3({
23+
paths: {
24+
"/": {
25+
get: {
26+
operationId: "rootGet",
27+
parameters: [],
28+
responses: {
29+
"200": response,
30+
},
31+
},
32+
},
33+
},
34+
});
35+
36+
const operations = serviceNamespace.operations;
37+
38+
expect(operations.size).toBe(1);
39+
40+
/* @route("/") @get op rootGet(): rootGet200ApplicationJsonResponse; */
41+
const rootGet = operations.get("rootGet");
42+
assert(rootGet, "rootGet operation not found");
43+
44+
/* @get @route("/") */
45+
expectDecorators(rootGet.decorators, [{ name: "get" }, { name: "route", args: ["/"] }]);
46+
// verify no operation parameters
47+
expect(rootGet.parameters.properties.size).toBe(0);
48+
assert(rootGet.returnType.kind === "Model", "Expected model return type");
49+
expect(rootGet.returnType.name).toBe("rootGet200ApplicationJsonResponse");
50+
});
51+
52+
it("generates operations without common params", async () => {
53+
const serviceNamespace = await tspForOpenAPI3({
54+
paths: {
55+
"/{id}": {
56+
get: {
57+
operationId: "idGet",
58+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
59+
responses: {
60+
"200": response,
61+
},
62+
},
63+
},
64+
},
65+
});
66+
67+
const operations = serviceNamespace.operations;
68+
69+
expect(operations.size).toBe(1);
70+
71+
/* @route("/{id}") @get op idGet(@path id: string): idGet200ApplicationJsonResponse; */
72+
const idGet = operations.get("idGet");
73+
assert(idGet, "idGet operation not found");
74+
75+
/* @get @route("/{id}") */
76+
expectDecorators(idGet.decorators, [{ name: "get" }, { name: "route", args: ["/{id}"] }]);
77+
78+
expect(idGet.parameters.properties.size).toBe(1);
79+
const idParam = idGet.parameters.properties.get("id")!;
80+
expect(idParam).toMatchObject({
81+
optional: false,
82+
type: { kind: "Scalar", name: "string" },
83+
});
84+
expectDecorators(idParam.decorators, [{ name: "path" }]);
85+
86+
assert(idGet.returnType.kind === "Model", "Expected model return type");
87+
expect(idGet.returnType.name).toBe("idGet200ApplicationJsonResponse");
88+
});
89+
90+
it("generates operations with common params", async () => {
91+
const serviceNamespace = await tspForOpenAPI3({
92+
paths: {
93+
"/{id}": {
94+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
95+
get: {
96+
operationId: "idGet",
97+
parameters: [],
98+
responses: {
99+
"200": response,
100+
},
101+
},
102+
},
103+
},
104+
});
105+
106+
const operations = serviceNamespace.operations;
107+
108+
expect(operations.size).toBe(1);
109+
110+
/* @route("/{id}") @get op idGet(@path id: string): idGet200ApplicationJsonResponse; */
111+
const idGet = operations.get("idGet");
112+
assert(idGet, "idGet operation not found");
113+
114+
/* @get @route("/{id}") */
115+
expectDecorators(idGet.decorators, [{ name: "get" }, { name: "route", args: ["/{id}"] }]);
116+
117+
expect(idGet.parameters.properties.size).toBe(1);
118+
const idParam = idGet.parameters.properties.get("id")!;
119+
expect(idParam).toMatchObject({
120+
optional: false,
121+
type: { kind: "Scalar", name: "string" },
122+
});
123+
expectDecorators(idParam.decorators, [{ name: "path" }]);
124+
125+
assert(idGet.returnType.kind === "Model", "Expected model return type");
126+
expect(idGet.returnType.name).toBe("idGet200ApplicationJsonResponse");
127+
});
128+
129+
it("generates operations with common and specific params", async () => {
130+
const serviceNamespace = await tspForOpenAPI3({
131+
paths: {
132+
"/{id}": {
133+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
134+
get: {
135+
operationId: "idGet",
136+
parameters: [{ name: "foo", in: "query", schema: { type: "string" } }],
137+
responses: {
138+
"200": response,
139+
},
140+
},
141+
},
142+
},
143+
});
144+
145+
const operations = serviceNamespace.operations;
146+
147+
expect(operations.size).toBe(1);
148+
149+
/* @route("/{id}") @get op idGet(@path id: string, @query foo?: string): idGet200ApplicationJsonResponse; */
150+
const idGet = operations.get("idGet");
151+
assert(idGet, "idGet operation not found");
152+
153+
/* @get @route("/{id}") */
154+
expectDecorators(idGet.decorators, [{ name: "get" }, { name: "route", args: ["/{id}"] }]);
155+
156+
/* (@path id: string, @query foo?: string) */
157+
expect(idGet.parameters.properties.size).toBe(2);
158+
const idParam = idGet.parameters.properties.get("id")!;
159+
expect(idParam).toMatchObject({
160+
optional: false,
161+
type: { kind: "Scalar", name: "string" },
162+
});
163+
expectDecorators(idParam.decorators, { name: "path" });
164+
165+
const fooParam = idGet.parameters.properties.get("foo")!;
166+
expect(fooParam).toMatchObject({
167+
optional: true,
168+
type: { kind: "Scalar", name: "string" },
169+
});
170+
expectDecorators(fooParam.decorators, { name: "query" });
171+
172+
assert(idGet.returnType.kind === "Model", "Expected model return type");
173+
expect(idGet.returnType.name).toBe("idGet200ApplicationJsonResponse");
174+
});
175+
176+
it("supports overriding common params with operation params", async () => {
177+
const serviceNamespace = await tspForOpenAPI3({
178+
paths: {
179+
"/{id}": {
180+
parameters: [
181+
{ name: "id", in: "path", required: true, schema: { type: "string" } },
182+
{ name: "x-header", in: "header", required: false, schema: { type: "string" } },
183+
],
184+
get: {
185+
operationId: "idGet",
186+
parameters: [
187+
{ name: "foo", in: "query", schema: { type: "string" } },
188+
{ name: "x-header", in: "header", required: true, schema: { type: "string" } },
189+
],
190+
responses: {
191+
"200": response,
192+
},
193+
},
194+
put: {
195+
operationId: "idPut",
196+
parameters: [],
197+
responses: {
198+
"200": response,
199+
},
200+
},
201+
},
202+
},
203+
});
204+
205+
const operations = serviceNamespace.operations;
206+
207+
expect(operations.size).toBe(2);
208+
209+
// `idGet` overrides the common `x-header` parameter with it's own, making it required
210+
/* @route("/{id}") @get op idGet(@path id: string, @query foo?: string, @header `x-header`: string): idGet200ApplicationJsonResponse; */
211+
const idGet = operations.get("idGet");
212+
assert(idGet, "idGet operation not found");
213+
214+
/* @get @route("/{id}") */
215+
expectDecorators(idGet.decorators, [{ name: "get" }, { name: "route", args: ["/{id}"] }]);
216+
217+
/* (@path id: string, @query foo?: string, @header `x-header`: string) */
218+
expect(idGet.parameters.properties.size).toBe(3);
219+
const idParam = idGet.parameters.properties.get("id")!;
220+
expect(idParam).toMatchObject({
221+
optional: false,
222+
type: { kind: "Scalar", name: "string" },
223+
});
224+
expectDecorators(idParam.decorators, { name: "path" });
225+
226+
const fooParam = idGet.parameters.properties.get("foo")!;
227+
expect(fooParam).toMatchObject({
228+
optional: true,
229+
type: { kind: "Scalar", name: "string" },
230+
});
231+
expectDecorators(fooParam.decorators, { name: "query" });
232+
233+
const xHeaderParam = idGet.parameters.properties.get("x-header")!;
234+
expect(xHeaderParam).toMatchObject({
235+
optional: false,
236+
type: { kind: "Scalar", name: "string" },
237+
});
238+
expectDecorators(xHeaderParam.decorators, { name: "header" });
239+
240+
assert(idGet.returnType.kind === "Model", "Expected model return type");
241+
expect(idGet.returnType.name).toBe("idGet200ApplicationJsonResponse");
242+
243+
// `idPut` uses the common `x-header` parameter, which is marked optional
244+
/* @route("/{id}") @put op idPut(@path id: string, @header `x-header`: string): idPut200ApplicationJsonResponse; */
245+
const idPut = operations.get("idPut");
246+
assert(idPut, "idPut operation not found");
247+
248+
/* @put @route("/{id}") */
249+
expectDecorators(idPut.decorators, [{ name: "put" }, { name: "route", args: ["/{id}"] }]);
250+
251+
/* (@path id: string, @header `x-header`?: string) */
252+
expect(idPut.parameters.properties.size).toBe(2);
253+
const idPutParam = idPut.parameters.properties.get("id")!;
254+
expect(idPutParam).toMatchObject({
255+
optional: false,
256+
type: { kind: "Scalar", name: "string" },
257+
});
258+
expectDecorators(idPutParam.decorators, [{ name: "path" }]);
259+
260+
const xHeaderSharedParam = idPut.parameters.properties.get("x-header")!;
261+
expect(xHeaderSharedParam).toMatchObject({
262+
optional: true,
263+
type: { kind: "Scalar", name: "string" },
264+
});
265+
expectDecorators(xHeaderSharedParam.decorators, { name: "header" });
266+
267+
assert(idPut.returnType.kind === "Model", "Expected model return type");
268+
expect(idPut.returnType.name).toBe("idPut200ApplicationJsonResponse");
269+
});

0 commit comments

Comments
 (0)