Skip to content

Commit dce15d5

Browse files
authored
fix(openapi): extract success responses declared with wildcard 2XX status keys (#1275)
Microsoft Graph's OpenAPI spec declares every success response under the wildcard status key 2XX (13k+ occurrences, no numeric 200/201 keys at all), which the extractor's /^2\d\d$/ filter skipped entirely. No response body or file hint was ever extracted for Graph operations, so drive content downloads never took the binaryResponse ToolFile path and came back as lossy text. Accept 2XX between exact codes and default. Also normalize Microsoft Graph octet-stream success responses that carry a non-binary schema (report-style endpoints declare type: object there) to a binary string during spec build, and run full-graph selections through the same path-item transform. The Cloudflare extraction baseline gains one operation whose success response is declared as 2XX.
1 parent 8652c99 commit dce15d5

5 files changed

Lines changed: 494 additions & 15 deletions

File tree

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
import { expect, it } from "@effect/vitest";
2+
import { Effect, Option } from "effect";
3+
4+
import {
5+
parseEntry,
6+
streamOperationBindingsFromStructure,
7+
structuralSplit,
8+
} from "@executor-js/plugin-openapi";
9+
10+
import { microsoftGraphKeepPathItem } from "./graph";
11+
12+
// Mirrors the verbatim shape of the real Microsoft Graph v1.0 spec: every
13+
// success response uses the OpenAPI wildcard status key `2XX` (the real spec
14+
// has zero numeric 200/201 keys), drive content GET is already declared as a
15+
// binary octet-stream, PUT declares a binary octet-stream requestBody, error
16+
// responses are $refs, and path-level shared parameters carry
17+
// `x-ms-docs-key-type`.
18+
const driveContentFixture = `
19+
openapi: 3.0.4
20+
info:
21+
title: Microsoft Graph Fixture
22+
version: v1.0
23+
servers:
24+
- url: https://graph.microsoft.com/v1.0
25+
paths:
26+
/drives/{drive-id}/items/{driveItem-id}/content:
27+
get:
28+
tags:
29+
- drives.driveItem
30+
summary: Get content for the navigation property items from drives
31+
operationId: drives.GetItemsContent
32+
parameters:
33+
- name: $format
34+
in: query
35+
description: Format of the content
36+
style: form
37+
explode: false
38+
schema:
39+
type: string
40+
responses:
41+
2XX:
42+
description: Retrieved media content
43+
content:
44+
application/octet-stream:
45+
schema:
46+
type: string
47+
format: binary
48+
4XX:
49+
$ref: '#/components/responses/error'
50+
5XX:
51+
$ref: '#/components/responses/error'
52+
put:
53+
tags:
54+
- drives.driveItem
55+
summary: Update content for the navigation property items in drives
56+
operationId: drives.UpdateItemsContent
57+
requestBody:
58+
description: New media content.
59+
content:
60+
application/octet-stream:
61+
schema:
62+
type: string
63+
format: binary
64+
required: true
65+
responses:
66+
2XX:
67+
description: Success
68+
content:
69+
application/json:
70+
schema:
71+
$ref: '#/components/schemas/microsoft.graph.driveItem'
72+
4XX:
73+
$ref: '#/components/responses/error'
74+
5XX:
75+
$ref: '#/components/responses/error'
76+
delete:
77+
tags:
78+
- drives.driveItem
79+
summary: Delete content for the navigation property items in drives
80+
operationId: drives.DeleteItemsContent
81+
responses:
82+
'204':
83+
description: Success
84+
4XX:
85+
$ref: '#/components/responses/error'
86+
5XX:
87+
$ref: '#/components/responses/error'
88+
parameters:
89+
- name: drive-id
90+
in: path
91+
description: The unique identifier of drive
92+
required: true
93+
schema:
94+
type: string
95+
x-ms-docs-key-type: drive
96+
- name: driveItem-id
97+
in: path
98+
description: The unique identifier of driveItem
99+
required: true
100+
schema:
101+
type: string
102+
x-ms-docs-key-type: driveItem
103+
components:
104+
schemas:
105+
microsoft.graph.driveItem:
106+
type: object
107+
properties:
108+
id:
109+
type: string
110+
microsoft.graph.ODataErrors.ODataError:
111+
type: object
112+
properties:
113+
error:
114+
type: object
115+
responses:
116+
error:
117+
description: error
118+
content:
119+
application/json:
120+
schema:
121+
$ref: '#/components/schemas/microsoft.graph.ODataErrors.ODataError'
122+
`;
123+
124+
// Report-style Graph endpoints declare an octet-stream success response with an
125+
// object schema (a `value` wrapper) instead of a binary string.
126+
const reportContentFixture = `
127+
openapi: 3.0.4
128+
info:
129+
title: Microsoft Graph Fixture
130+
version: v1.0
131+
servers:
132+
- url: https://graph.microsoft.com/v1.0
133+
paths:
134+
/reports/getEmailActivityCounts(period={period}):
135+
get:
136+
tags:
137+
- reports.Functions
138+
summary: Invoke function getEmailActivityCounts
139+
operationId: reports.getEmailActivityCounts
140+
parameters:
141+
- name: period
142+
in: path
143+
required: true
144+
schema:
145+
type: string
146+
responses:
147+
2XX:
148+
description: Success
149+
content:
150+
application/octet-stream:
151+
schema:
152+
type: object
153+
properties:
154+
value:
155+
type: string
156+
format: base64url
157+
4XX:
158+
$ref: '#/components/responses/error'
159+
5XX:
160+
$ref: '#/components/responses/error'
161+
components:
162+
schemas:
163+
microsoft.graph.ODataErrors.ODataError:
164+
type: object
165+
properties:
166+
error:
167+
type: object
168+
responses:
169+
error:
170+
description: error
171+
content:
172+
application/json:
173+
schema:
174+
$ref: '#/components/schemas/microsoft.graph.ODataErrors.ODataError'
175+
`;
176+
177+
const fullGraphSelection = {
178+
coversFullGraph: true,
179+
presetIds: [],
180+
customScopes: [],
181+
exactPaths: [],
182+
pathPrefixes: [],
183+
tagPrefixes: [],
184+
} as const;
185+
186+
const keptPathItem = (fixture: string): Record<string, unknown> => {
187+
const structure = structuralSplit(fixture);
188+
expect(structure).not.toBeNull();
189+
const entry = parseEntry(structure!.text, structure!.pathItems[0]!, 2);
190+
expect(entry).not.toBeNull();
191+
const [path, rawPathItem] = entry!;
192+
const pathItem = microsoftGraphKeepPathItem(fullGraphSelection)(
193+
path,
194+
rawPathItem as Record<string, unknown>,
195+
);
196+
expect(pathItem).not.toBeNull();
197+
return pathItem as Record<string, unknown>;
198+
};
199+
200+
type StreamedBinding = {
201+
readonly binding: {
202+
readonly method: string;
203+
readonly pathTemplate: string;
204+
readonly responseBody: Option.Option<{
205+
readonly fileHint: Option.Option<{
206+
readonly kind: "binaryResponse" | "byteField";
207+
}>;
208+
}>;
209+
};
210+
};
211+
212+
const streamBindings = (fixture: string) =>
213+
Effect.gen(function* () {
214+
const structure = structuralSplit(fixture);
215+
expect(structure).not.toBeNull();
216+
const chunks: StreamedBinding[] = [];
217+
yield* streamOperationBindingsFromStructure(
218+
structure!,
219+
{ chunkSize: 10, keepPathItem: microsoftGraphKeepPathItem(fullGraphSelection) },
220+
(chunk) =>
221+
Effect.sync(() => {
222+
chunks.push(...chunk);
223+
}),
224+
);
225+
return chunks;
226+
});
227+
228+
const responseFileHintKind = (
229+
chunks: readonly StreamedBinding[],
230+
method: string,
231+
pathTemplate: string,
232+
): string | undefined => {
233+
const match = chunks.find(
234+
(chunk) => chunk.binding.method === method && chunk.binding.pathTemplate === pathTemplate,
235+
);
236+
expect(match).toBeDefined();
237+
const hint = Option.flatMap(match!.binding.responseBody, (body) => body.fileHint);
238+
return Option.getOrUndefined(hint)?.kind;
239+
};
240+
241+
it("keeps already-binary drive content responses untouched", () => {
242+
const pathItem = keptPathItem(driveContentFixture);
243+
244+
const get = pathItem.get as Record<string, unknown>;
245+
const getResponses = get.responses as Record<string, unknown>;
246+
expect(getResponses["2XX"]).toEqual({
247+
description: "Retrieved media content",
248+
content: {
249+
"application/octet-stream": {
250+
schema: { type: "string", format: "binary" },
251+
},
252+
},
253+
});
254+
expect(getResponses["4XX"]).toEqual({ $ref: "#/components/responses/error" });
255+
expect(getResponses["5XX"]).toEqual({ $ref: "#/components/responses/error" });
256+
257+
// The real spec already declares the PUT requestBody as binary; the
258+
// normalization must not touch request bodies.
259+
const put = pathItem.put as Record<string, unknown>;
260+
expect(put.requestBody).toEqual({
261+
description: "New media content.",
262+
content: {
263+
"application/octet-stream": {
264+
schema: { type: "string", format: "binary" },
265+
},
266+
},
267+
required: true,
268+
});
269+
const putResponses = put.responses as Record<string, unknown>;
270+
expect(putResponses["2XX"]).toMatchObject({
271+
content: {
272+
"application/json": {
273+
schema: { $ref: "#/components/schemas/microsoft.graph.driveItem" },
274+
},
275+
},
276+
});
277+
278+
// Path-level shared parameters survive the filter.
279+
expect(pathItem.parameters).toMatchObject([
280+
{ name: "drive-id", "x-ms-docs-key-type": "drive" },
281+
{ name: "driveItem-id", "x-ms-docs-key-type": "driveItem" },
282+
]);
283+
});
284+
285+
it("normalizes report-style octet-stream object schemas to binary strings", () => {
286+
const pathItem = keptPathItem(reportContentFixture);
287+
288+
const get = pathItem.get as Record<string, unknown>;
289+
const responses = get.responses as Record<string, unknown>;
290+
const success = responses["2XX"] as Record<string, unknown>;
291+
expect(success.description).toBe("Success");
292+
expect(success.content).toEqual({
293+
"application/octet-stream": {
294+
schema: { type: "string", format: "binary" },
295+
},
296+
});
297+
expect(responses["4XX"]).toEqual({ $ref: "#/components/responses/error" });
298+
});
299+
300+
it.effect("streams drive content download bindings with a binaryResponse file hint", () =>
301+
Effect.gen(function* () {
302+
const chunks = yield* streamBindings(driveContentFixture);
303+
expect(
304+
responseFileHintKind(chunks, "get", "/drives/{drive-id}/items/{driveItem-id}/content"),
305+
).toBe("binaryResponse");
306+
}),
307+
);
308+
309+
it.effect("streams report-style download bindings with a binaryResponse file hint", () =>
310+
Effect.gen(function* () {
311+
const chunks = yield* streamBindings(reportContentFixture);
312+
expect(
313+
responseFileHintKind(chunks, "get", "/reports/getEmailActivityCounts(period={period})"),
314+
).toBe("binaryResponse");
315+
}),
316+
);

0 commit comments

Comments
 (0)