Skip to content

Commit 9be6023

Browse files
Copilotbaywet
andauthored
feat(openapi3): Add paging link decorator import from x-ms-list-*-link extensions (#9627)
The OpenAPI3 importer was not generating TypeSpec paging link decorators (`@prevLink`, `@nextLink`, `@firstLink`, `@lastLink`) when encountering the corresponding `x-ms-list-*-link` extensions. ## Changes - **Added `getPagingLinkDecorators()`** in `decorators.ts` to map x-ms-list-*-link extensions to their TypeSpec paging decorators - Only emits decorator when extension value is `true` - Supports all four link types: prev, next, first, last - **Test coverage** for all four decorator types and value validation ## Example Given OpenAPI schema: ```yaml WidgetList: properties: prevLink: type: string x-ms-list-prev-link: true ``` Now generates: ```typespec model WidgetList { @extension("x-ms-list-prev-link", true) @prevLink prevLink: string; } ``` The importer emits both the `@extension` decorator (preserving the original extension) and the semantic `@prevLink` decorator (enabling TypeSpec's paging analysis). > [!WARNING] > > <details> > <summary>Firewall rules blocked me from connecting to one or more addresses (expand for details)</summary> > > #### I tried to connect to the following addresses, but was blocked by firewall rules: > > - `telemetry.astro.build` > - Triggering command: `/home/REDACTED/work/_temp/ghcca-node/node/bin/node node /home/REDACTED/work/typespec/typespec/website/node_modules/.bin/../astro/astro.js build sh s/.b�� e/petstore --war--llmstxt node _modules/pnpm/dist/node-gyp-bin/node import @typespecnode --production reams/reference sh nts/�� tsc -p tsconfig.build.json dotnet /node_modules/.bin/sh --no-emit Release ents/reference sh` (dns block) > > If you need me to access, download, or install something from one of these locations, you can either: > > - Configure [Actions setup steps](https://gh.io/copilot/actions-setup-steps) to set up my environment, which run before the firewall is enabled > - Add the appropriate URLs or hosts to the custom allowlist in this repository's [Copilot coding agent settings](https://github.com/microsoft/typespec/settings/copilot/coding_agent) (admins only) > > </details> <!-- START COPILOT ORIGINAL PROMPT --> <details> <summary>Original prompt</summary> ---- *This section details on the original issue you should resolve* <issue_title>Add support for importing the prevLink decorator based on the relevant OpenAPI extension</issue_title> <issue_description>### Clear and concise description of the problem Related microsoft/openai-openapi-pr#568 Based on the following OpenAPI description, we should get the following TypeSpec definition imported ```yaml openapi: 3.0.0 info: title: Widget Service version: 0.0.0 tags: - name: Widgets paths: /widgets: get: operationId: Widgets_list description: List widgets parameters: [] responses: '200': description: The request has succeeded. content: application/json: schema: $ref: '#/components/schemas/WidgetList' default: description: An unexpected error response. content: application/json: schema: $ref: '#/components/schemas/Error' tags: - Widgets post: operationId: Widgets_create description: Create a widget parameters: [] responses: '200': description: The request has succeeded. content: application/json: schema: $ref: '#/components/schemas/Widget' default: description: An unexpected error response. content: application/json: schema: $ref: '#/components/schemas/Error' tags: - Widgets requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/Widget' /widgets/{id}: get: operationId: Widgets_read description: Read widgets parameters: - name: id in: path required: true schema: type: string readOnly: true responses: '200': description: The request has succeeded. content: application/json: schema: $ref: '#/components/schemas/Widget' default: description: An unexpected error response. content: application/json: schema: $ref: '#/components/schemas/Error' tags: - Widgets patch: operationId: Widgets_update description: Update a widget parameters: - name: id in: path required: true schema: type: string readOnly: true responses: '200': description: The request has succeeded. content: application/json: schema: $ref: '#/components/schemas/Widget' default: description: An unexpected error response. content: application/json: schema: $ref: '#/components/schemas/Error' tags: - Widgets requestBody: required: true content: application/merge-patch+json: schema: $ref: '#/components/schemas/WidgetMergePatchUpdate' delete: operationId: Widgets_delete description: Delete a widget parameters: - name: id in: path required: true schema: type: string readOnly: true responses: '204': description: 'There is no content to send for this request, but the headers may be useful. ' default: description: An unexpected error response. content: application/json: schema: $ref: '#/components/schemas/Error' tags: - Widgets /widgets/{id}/analyze: post: operationId: Widgets_analyze description: Analyze a widget parameters: - name: id in: path required: true schema: type: string readOnly: true responses: '200': description: The request has succeeded. content: text/plain: schema: type: string default: description: An unexpected error response. content: application/json: schema: $ref: '#/components/schemas/Error' tags: - Widgets components: schemas: Error: type: object required: - code - message properties: code: type: integer format: int32 message: type: string Widget: type: object required: - id - weight - colo... </details> <!-- START COPILOT CODING AGENT SUFFIX --> - Fixes #9622 <!-- START COPILOT CODING AGENT TIPS --> --- ✨ Let Copilot coding agent [set things up for you](https://github.com/microsoft/typespec/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> Co-authored-by: Vincent Biret <vibiret@microsoft.com>
1 parent 087abac commit 9be6023

File tree

3 files changed

+249
-0
lines changed

3 files changed

+249
-0
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: feature
3+
packages:
4+
- "@typespec/openapi3"
5+
---
6+
7+
importer - Add support for importing paging link decorators (`@prevLink`, `@nextLink`, `@firstLink`, `@lastLink`) based on x-ms-list-*-link OpenAPI extensions

packages/openapi3/src/cli/actions/convert/utils/decorators.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,31 @@ export function getExtensions(element: Extensions): TypeSpecDecorator[] {
2929

3030
return decorators;
3131
}
32+
33+
function getPagingLinkDecorators(schema: OpenAPI3Schema | OpenAPISchema3_1 | OpenAPISchema3_2) {
34+
const decorators: TypeSpecDecorator[] = [];
35+
36+
// Map of x-ms-list-*-link extensions to their corresponding TypeSpec decorators
37+
const linkExtensions = {
38+
"x-ms-list-prev-link": "prevLink",
39+
"x-ms-list-next-link": "nextLink",
40+
"x-ms-list-first-link": "firstLink",
41+
"x-ms-list-last-link": "lastLink",
42+
} as const;
43+
44+
for (const [extensionKey, decoratorName] of Object.entries(linkExtensions)) {
45+
const extensionValue = (schema as any)[extensionKey];
46+
if (extensionValue === true) {
47+
decorators.push({
48+
name: decoratorName,
49+
args: [],
50+
});
51+
}
52+
}
53+
54+
return decorators;
55+
}
56+
3257
function normalizeObjectValue(source: unknown): string | number | object | TSValue {
3358
if (source !== null && typeof source === "object") {
3459
const result = createTSValueFromObjectValue(source);
@@ -181,6 +206,9 @@ export function getDecoratorsForSchema(
181206

182207
decorators.push(...getExtensions(schema));
183208

209+
// Handle x-ms-list-*-link extensions
210+
decorators.push(...getPagingLinkDecorators(schema));
211+
184212
// Handle OpenAPI 3.1 type arrays like ["integer", "null"]
185213
// Extract the non-null type to determine which decorators to apply
186214
const effectiveType = Array.isArray(schema.type)
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { describe, expect, it } from "vitest";
2+
import { expectDecorators } from "./utils/expect.js";
3+
import { renderTypeSpecForOpenAPI3, tspForOpenAPI3 } from "./utils/tsp-for-openapi3.js";
4+
5+
describe("converts paging link extensions", () => {
6+
it("handles x-ms-list-prev-link extension", async () => {
7+
const serviceNamespace = await tspForOpenAPI3({
8+
schemas: {
9+
WidgetList: {
10+
type: "object",
11+
required: ["value", "prevLink"],
12+
properties: {
13+
value: {
14+
type: "array",
15+
items: {
16+
type: "string",
17+
},
18+
},
19+
prevLink: {
20+
type: "string",
21+
"x-ms-list-prev-link": true,
22+
},
23+
},
24+
},
25+
},
26+
});
27+
28+
const widgetList = serviceNamespace.models.get("WidgetList");
29+
expect(widgetList).toBeDefined();
30+
31+
const prevLinkProp = widgetList?.properties.get("prevLink");
32+
expect(prevLinkProp).toBeDefined();
33+
34+
// Should have both @extension and @prevLink decorators
35+
// Note: TypeSpec compiler may reorder decorators during parsing
36+
expectDecorators(prevLinkProp!.decorators, [
37+
{ name: "prevLink", args: [] },
38+
{ name: "extension", args: ["x-ms-list-prev-link", true] },
39+
]);
40+
});
41+
42+
it("handles x-ms-list-next-link extension", async () => {
43+
const serviceNamespace = await tspForOpenAPI3({
44+
schemas: {
45+
WidgetList: {
46+
type: "object",
47+
required: ["value", "nextLink"],
48+
properties: {
49+
value: {
50+
type: "array",
51+
items: {
52+
type: "string",
53+
},
54+
},
55+
nextLink: {
56+
type: "string",
57+
"x-ms-list-next-link": true,
58+
},
59+
},
60+
},
61+
},
62+
});
63+
64+
const widgetList = serviceNamespace.models.get("WidgetList");
65+
expect(widgetList).toBeDefined();
66+
67+
const nextLinkProp = widgetList?.properties.get("nextLink");
68+
expect(nextLinkProp).toBeDefined();
69+
70+
// Should have both @extension and @nextLink decorators
71+
// Note: TypeSpec compiler may reorder decorators during parsing
72+
expectDecorators(nextLinkProp!.decorators, [
73+
{ name: "nextLink", args: [] },
74+
{ name: "extension", args: ["x-ms-list-next-link", true] },
75+
]);
76+
});
77+
78+
it("handles x-ms-list-first-link extension", async () => {
79+
const serviceNamespace = await tspForOpenAPI3({
80+
schemas: {
81+
WidgetList: {
82+
type: "object",
83+
required: ["value", "firstLink"],
84+
properties: {
85+
value: {
86+
type: "array",
87+
items: {
88+
type: "string",
89+
},
90+
},
91+
firstLink: {
92+
type: "string",
93+
"x-ms-list-first-link": true,
94+
},
95+
},
96+
},
97+
},
98+
});
99+
100+
const widgetList = serviceNamespace.models.get("WidgetList");
101+
expect(widgetList).toBeDefined();
102+
103+
const firstLinkProp = widgetList?.properties.get("firstLink");
104+
expect(firstLinkProp).toBeDefined();
105+
106+
// Should have both @extension and @firstLink decorators
107+
// Note: TypeSpec compiler may reorder decorators during parsing
108+
expectDecorators(firstLinkProp!.decorators, [
109+
{ name: "firstLink", args: [] },
110+
{ name: "extension", args: ["x-ms-list-first-link", true] },
111+
]);
112+
});
113+
114+
it("handles x-ms-list-last-link extension", async () => {
115+
const serviceNamespace = await tspForOpenAPI3({
116+
schemas: {
117+
WidgetList: {
118+
type: "object",
119+
required: ["value", "lastLink"],
120+
properties: {
121+
value: {
122+
type: "array",
123+
items: {
124+
type: "string",
125+
},
126+
},
127+
lastLink: {
128+
type: "string",
129+
"x-ms-list-last-link": true,
130+
},
131+
},
132+
},
133+
},
134+
});
135+
136+
const widgetList = serviceNamespace.models.get("WidgetList");
137+
expect(widgetList).toBeDefined();
138+
139+
const lastLinkProp = widgetList?.properties.get("lastLink");
140+
expect(lastLinkProp).toBeDefined();
141+
142+
// Should have both @extension and @lastLink decorators
143+
// Note: TypeSpec compiler may reorder decorators during parsing
144+
expectDecorators(lastLinkProp!.decorators, [
145+
{ name: "lastLink", args: [] },
146+
{ name: "extension", args: ["x-ms-list-last-link", true] },
147+
]);
148+
});
149+
150+
it("only adds link decorator when extension value is true", async () => {
151+
const serviceNamespace = await tspForOpenAPI3({
152+
schemas: {
153+
WidgetList: {
154+
type: "object",
155+
required: ["value", "prevLink"],
156+
properties: {
157+
value: {
158+
type: "array",
159+
items: {
160+
type: "string",
161+
},
162+
},
163+
prevLink: {
164+
type: "string",
165+
"x-ms-list-prev-link": false,
166+
},
167+
},
168+
},
169+
},
170+
});
171+
172+
const widgetList = serviceNamespace.models.get("WidgetList");
173+
expect(widgetList).toBeDefined();
174+
175+
const prevLinkProp = widgetList?.properties.get("prevLink");
176+
expect(prevLinkProp).toBeDefined();
177+
178+
// Should only have @extension decorator, not @prevLink
179+
expectDecorators(prevLinkProp!.decorators, [
180+
{ name: "extension", args: ["x-ms-list-prev-link", false] },
181+
]);
182+
});
183+
184+
it("renders TypeSpec with correct imports for prevLink", async () => {
185+
const tsp = await renderTypeSpecForOpenAPI3({
186+
schemas: {
187+
WidgetList: {
188+
type: "object",
189+
required: ["value", "prevLink"],
190+
properties: {
191+
value: {
192+
type: "array",
193+
items: {
194+
type: "string",
195+
},
196+
},
197+
prevLink: {
198+
type: "string",
199+
"x-ms-list-prev-link": true,
200+
},
201+
},
202+
},
203+
},
204+
});
205+
206+
// Should import @typespec/openapi
207+
expect(tsp).toContain('import "@typespec/openapi";');
208+
// Should have using OpenAPI
209+
expect(tsp).toContain("using OpenAPI;");
210+
// Should have both decorators
211+
expect(tsp).toContain('@extension("x-ms-list-prev-link", true)');
212+
expect(tsp).toContain("@prevLink");
213+
});
214+
});

0 commit comments

Comments
 (0)