Unified · Split
| @@ -1,12 +1,12 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "sources": { |
| 3 | 3 | "google-gmail": { |
| 4 | − "kind": "google_discovery", | |
| 5 | 4 | "name": "Gmail", |
| 6 | 5 | "namespace": "google.gmail", |
| 7 | 6 | "connection": { |
| 8 | 7 | "endpoint": "https://gmail.googleapis.com/$discovery/rest?version=v1" |
| 9 | 8 | }, |
| 9 | + "kind": "google_discovery", | |
| 10 | 10 | "binding": { |
| 11 | 11 | "service": "gmail", |
| 12 | 12 | "version": "v1", |
| @@ -14,6 +14,17 @@ · 1 unmodified line | ||
| 14 | 14 | "defaultHeaders": null, |
| 15 | 15 | "scopes": [] |
| 16 | 16 | } |
| 17 | + }, | |
| 18 | + "vercel": { | |
| 19 | + "kind": "openapi", | |
| 20 | + "name": "Vercel API", | |
| 21 | + "connection": { | |
| 22 | + "endpoint": "https://api.vercel.com/" | |
| 23 | + }, | |
| 24 | + "binding": { | |
| 25 | + "specUrl": "https://openapi.vercel.sh/", | |
| 26 | + "defaultHeaders": null | |
| 27 | + } | |
| 17 | 28 | } |
| 18 | 29 | } |
| 19 | 30 | } |
| @@ -18,12 +18,17 @@ export { · 17 unmodified lines | ||
| 18 | 18 | type OpenApiToolPresentation, |
| 19 | 19 | } from "./openapi-tool-presentation"; |
| 20 | 20 | export { |
| 21 | + httpBodyModeFromContentType, | |
| 22 | + isJsonMediaType, | |
| 23 | + isTextMediaType, | |
| 21 | 24 | serializeOpenApiParameterValue, |
| 22 | 25 | serializeOpenApiRequestBody, |
| 23 | 26 | withSerializedQueryEntries, |
| 27 | + type HttpBodyDecodingMode, | |
| 24 | 28 | type OpenApiSerializableContent, |
| 25 | 29 | type OpenApiSerializableParameter, |
| 26 | 30 | type OpenApiSerializableRequestBody, |
| 31 | + type SerializedOpenApiRequestBody, | |
| 27 | 32 | type SerializedOpenApiParameterValue, |
| 28 | 33 | type SerializedOpenApiQueryEntry, |
| 29 | 34 | } from "./openapi-http-serialization"; |
| @@ -18,6 +18,13 @@ export type OpenApiSerializableRequestBody = { · 17 unmodified lines | ||
| 18 | 18 | contents?: ReadonlyArray<OpenApiSerializableContent>; |
| 19 | 19 | }; |
| 20 | 20 | |
| 21 | +export type HttpBodyDecodingMode = "json" | "text" | "bytes"; | |
| 22 | + | |
| 23 | +export type SerializedOpenApiRequestBody = { | |
| 24 | + contentType: string; | |
| 25 | + body: string | Uint8Array; | |
| 26 | +}; | |
| 27 | + | |
| 21 | 28 | export type SerializedOpenApiQueryEntry = { |
| 22 | 29 | name: string; |
| 23 | 30 | value: string; |
| @@ -44,6 +51,15 @@ export type SerializedOpenApiParameterValue = · 20 unmodified lines | ||
| 44 | 51 | const isRecord = (value: unknown): value is Record<string, unknown> => |
| 45 | 52 | value !== null && typeof value === "object" && !Array.isArray(value); |
| 46 | 53 | |
| 54 | +const textEncoder = new TextEncoder(); | |
| 55 | + | |
| 56 | +const normalizeMediaType = (value: string | undefined | null): string => | |
| 57 | + (value ?? "") | |
| 58 | + .split(";", 1)[0] | |
| 59 | + ?.trim() | |
| 60 | + .toLowerCase() | |
| 61 | + ?? ""; | |
| 62 | + | |
| 47 | 63 | const primitiveString = (value: unknown): string => { |
| 48 | 64 | if (typeof value === "string") { |
| 49 | 65 | return value; |
| @@ -68,6 +84,56 @@ const primitiveString = (value: unknown): string => { · 18 unmodified lines | ||
| 68 | 84 | } |
| 69 | 85 | }; |
| 70 | 86 | |
| 87 | +export const isJsonMediaType = (mediaType: string | undefined | null): boolean => { | |
| 88 | + const normalized = normalizeMediaType(mediaType); | |
| 89 | + return normalized === "application/json" | |
| 90 | + || normalized.endsWith("+json") | |
| 91 | + || normalized.includes("json"); | |
| 92 | +}; | |
| 93 | + | |
| 94 | +export const isTextMediaType = (mediaType: string | undefined | null): boolean => { | |
| 95 | + const normalized = normalizeMediaType(mediaType); | |
| 96 | + if (normalized.length === 0) { | |
| 97 | + return false; | |
| 98 | + } | |
| 99 | + | |
| 100 | + return normalized.startsWith("text/") | |
| 101 | + || normalized === "application/xml" | |
| 102 | + || normalized.endsWith("+xml") | |
| 103 | + || normalized.endsWith("/xml") | |
| 104 | + || normalized === "application/x-www-form-urlencoded" | |
| 105 | + || normalized === "application/javascript" | |
| 106 | + || normalized === "application/ecmascript" | |
| 107 | + || normalized === "application/graphql" | |
| 108 | + || normalized === "application/sql" | |
| 109 | + || normalized === "application/x-yaml" | |
| 110 | + || normalized === "application/yaml" | |
| 111 | + || normalized === "application/toml" | |
| 112 | + || normalized === "application/csv" | |
| 113 | + || normalized === "image/svg+xml" | |
| 114 | + || normalized.endsWith("+yaml") | |
| 115 | + || normalized.endsWith("+toml"); | |
| 116 | +}; | |
| 117 | + | |
| 118 | +export const httpBodyModeFromContentType = ( | |
| 119 | + mediaType: string | undefined | null, | |
| 120 | +): HttpBodyDecodingMode => { | |
| 121 | + if (isJsonMediaType(mediaType)) { | |
| 122 | + return "json"; | |
| 123 | + } | |
| 124 | + | |
| 125 | + if (isTextMediaType(mediaType)) { | |
| 126 | + return "text"; | |
| 127 | + } | |
| 128 | + | |
| 129 | + const normalized = normalizeMediaType(mediaType); | |
| 130 | + if (normalized.length === 0) { | |
| 131 | + return "text"; | |
| 132 | + } | |
| 133 | + | |
| 134 | + return "bytes"; | |
| 135 | +}; | |
| 136 | + | |
| 71 | 137 | const objectEntries = (value: unknown): Array<[string, unknown]> => |
| 72 | 138 | Object.entries(isRecord(value) ? value : {}) |
| 73 | 139 | .sort(([left], [right]) => left.localeCompare(right)); |
| @@ -142,6 +208,37 @@ const preferredContentType = (input: { · 68 unmodified lines | ||
| 142 | 208 | ?? candidates[0]; |
| 143 | 209 | }; |
| 144 | 210 | |
| 211 | +const serializeBinaryContentValue = (value: unknown): Uint8Array => { | |
| 212 | + if (value instanceof Uint8Array) { | |
| 213 | + return value; | |
| 214 | + } | |
| 215 | + | |
| 216 | + if (value instanceof ArrayBuffer) { | |
| 217 | + return new Uint8Array(value); | |
| 218 | + } | |
| 219 | + | |
| 220 | + if (ArrayBuffer.isView(value)) { | |
| 221 | + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); | |
| 222 | + } | |
| 223 | + | |
| 224 | + if (typeof value === "string") { | |
| 225 | + return textEncoder.encode(value); | |
| 226 | + } | |
| 227 | + | |
| 228 | + if ( | |
| 229 | + Array.isArray(value) | |
| 230 | + && value.every((entry) => | |
| 231 | + typeof entry === "number" | |
| 232 | + && Number.isInteger(entry) | |
| 233 | + && entry >= 0 | |
| 234 | + && entry <= 255) | |
| 235 | + ) { | |
| 236 | + return Uint8Array.from(value); | |
| 237 | + } | |
| 238 | + | |
| 239 | + throw new Error("Binary OpenAPI request bodies must be bytes, a string, or an array of byte values"); | |
| 240 | +}; | |
| 241 | + | |
| 145 | 242 | const serializeContentValue = (value: unknown, mediaType: string): string => { |
| 146 | 243 | const normalized = mediaType.toLowerCase(); |
| 147 | 244 | if ( |
| @@ -502,14 +599,18 @@ export const withSerializedQueryEntries = ( · 354 unmodified lines | ||
| 502 | 599 | export const serializeOpenApiRequestBody = (input: { |
| 503 | 600 | requestBody: OpenApiSerializableRequestBody; |
| 504 | 601 | body: unknown; |
| 505 | −}): { | |
| 506 | − contentType: string; | |
| 507 | − bodyText: string; | |
| 508 | −} => { | |
| 602 | +}): SerializedOpenApiRequestBody => { | |
| 509 | 603 | const contentType = preferredContentType(input.requestBody) ?? "application/json"; |
| 510 | 604 | |
| 605 | + if (httpBodyModeFromContentType(contentType) === "bytes") { | |
| 606 | + return { | |
| 607 | + contentType, | |
| 608 | + body: serializeBinaryContentValue(input.body), | |
| 609 | + }; | |
| 610 | + } | |
| 611 | + | |
| 511 | 612 | return { |
| 512 | 613 | contentType, |
| 513 | − bodyText: serializeContentValue(input.body, contentType), | |
| 614 | + body: serializeContentValue(input.body, contentType), | |
| 514 | 615 | }; |
| 515 | 616 | }; |
| @@ -35,6 +35,11 @@ type TestServer = { · 34 unmodified lines | ||
| 35 | 35 | close: () => Promise<void>; |
| 36 | 36 | }; |
| 37 | 37 | |
| 38 | +type BinaryTestServer = { | |
| 39 | + baseUrl: string; | |
| 40 | + close: () => Promise<void>; | |
| 41 | +}; | |
| 42 | + | |
| 38 | 43 | type EffectServerHandler = { |
| 39 | 44 | handler: (nodeRequest: IncomingMessage, nodeResponse: ServerResponse) => void; |
| 40 | 45 | dispose: () => Promise<void>; |
| @@ -212,6 +217,49 @@ const makeTestServer = Effect.acquireRelease( · 171 unmodified lines | ||
| 212 | 217 | }).pipe(Effect.orDie), |
| 213 | 218 | ); |
| 214 | 219 | |
| 220 | +const makeBinaryTestServer = Effect.acquireRelease( | |
| 221 | + Effect.promise<BinaryTestServer>( | |
| 222 | + () => | |
| 223 | + new Promise<BinaryTestServer>((resolve, reject) => { | |
| 224 | + const responseBytes = Uint8Array.from([0x00, 0x7f, 0x80, 0xff]); | |
| 225 | + const server = createServer((_, response) => { | |
| 226 | + response.statusCode = 200; | |
| 227 | + response.setHeader("content-type", "application/octet-stream"); | |
| 228 | + response.end(Buffer.from(responseBytes)); | |
| 229 | + }); | |
| 230 | + | |
| 231 | + server.once("error", reject); | |
| 232 | + server.listen(0, "127.0.0.1", () => { | |
| 233 | + const address = server.address(); | |
| 234 | + if (!address || typeof address === "string") { | |
| 235 | + reject(new Error("Failed to bind binary OpenAPI test server")); | |
| 236 | + return; | |
| 237 | + } | |
| 238 | + | |
| 239 | + resolve({ | |
| 240 | + baseUrl: `http://127.0.0.1:${address.port}`, | |
| 241 | + close: () => | |
| 242 | + new Promise<void>((closeResolve, closeReject) => { | |
| 243 | + server.close((error) => { | |
| 244 | + if (error) { | |
| 245 | + closeReject(error); | |
| 246 | + return; | |
| 247 | + } | |
| 248 | + closeResolve(); | |
| 249 | + }); | |
| 250 | + }), | |
| 251 | + }); | |
| 252 | + }); | |
| 253 | + }), | |
| 254 | + ), | |
| 255 | + (server: BinaryTestServer) => | |
| 256 | + Effect.tryPromise({ | |
| 257 | + try: () => server.close(), | |
| 258 | + catch: (error: unknown) => | |
| 259 | + error instanceof Error ? error : new Error(String(error)), | |
| 260 | + }).pipe(Effect.orDie), | |
| 261 | +); | |
| 262 | + | |
| 215 | 263 | const ownerParam = HttpApiSchema.param("owner", Schema.String); |
| 216 | 264 | const repoParam = HttpApiSchema.param("repo", Schema.String); |
| 217 | 265 | |
| @@ -249,6 +297,19 @@ class GeneratedApi extends HttpApi.make("generated").add(GeneratedReposApi) {} · 31 unmodified lines | ||
| 249 | 297 | |
| 250 | 298 | const generatedOpenApiSpec = OpenApi.fromApi(GeneratedApi); |
| 251 | 299 | |
| 300 | +const binaryReportIdParam = HttpApiSchema.param("reportId", Schema.String); | |
| 301 | + | |
| 302 | +class GeneratedBinaryReportsApi extends HttpApiGroup.make("reports") | |
| 303 | + .add( | |
| 304 | + HttpApiEndpoint.get("getContent")`/reports/${binaryReportIdParam}/content` | |
| 305 | + .addSuccess(HttpApiSchema.Uint8Array()), | |
| 306 | + ) {} | |
| 307 | + | |
| 308 | +class GeneratedBinaryApi extends HttpApi.make("generatedBinary") | |
| 309 | + .add(GeneratedBinaryReportsApi) {} | |
| 310 | + | |
| 311 | +const generatedBinaryOpenApiSpec = OpenApi.fromApi(GeneratedBinaryApi); | |
| 312 | + | |
| 252 | 313 | const resolveToolDefinition = (value: ToolInput): ToolDefinition => |
| 253 | 314 | typeof value === "object" && value !== null && "tool" in value |
| 254 | 315 | ? value |
| @@ -635,6 +696,30 @@ describe("openapi-tools", () => { · 380 unmodified lines | ||
| 635 | 696 | }), |
| 636 | 697 | ); |
| 637 | 698 | |
| 699 | + it.scoped("decodes binary HTTP responses from generated OpenAPI specs as bytes", () => | |
| 700 | + Effect.gen(function* () { | |
| 701 | + const server = yield* makeBinaryTestServer; | |
| 702 | + | |
| 703 | + const extracted = yield* createOpenApiToolsFromSpec({ | |
| 704 | + sourceName: "generated-binary", | |
| 705 | + openApiSpec: generatedBinaryOpenApiSpec, | |
| 706 | + baseUrl: server.baseUrl, | |
| 707 | + namespace: "source.generatedBinary", | |
| 708 | + }); | |
| 709 | + | |
| 710 | + const getContent = resolveToolExecutor( | |
| 711 | + extracted.tools, | |
| 712 | + "source.generatedBinary.reports.getContent", | |
| 713 | + ); | |
| 714 | + const result = yield* Effect.promise(() => | |
| 715 | + getContent({ reportId: "report-123" }), | |
| 716 | + ); | |
| 717 | + | |
| 718 | + expect(result).toBeInstanceOf(Uint8Array); | |
| 719 | + expect(Array.from(result as Uint8Array)).toEqual([0x00, 0x7f, 0x80, 0xff]); | |
| 720 | + }), | |
| 721 | + ); | |
| 722 | + | |
| 638 | 723 | it.scoped("executes extracted tools against HTTP endpoint", () => |
| 639 | 724 | Effect.gen(function* () { |
| 640 | 725 | const server = yield* makeTestServer; |
| @@ -23,6 +23,7 @@ import { · 22 unmodified lines | ||
| 23 | 23 | type OpenApiToolDefinition, |
| 24 | 24 | } from "./openapi-definitions"; |
| 25 | 25 | import { |
| 26 | + httpBodyModeFromContentType, | |
| 26 | 27 | serializeOpenApiParameterValue, |
| 27 | 28 | serializeOpenApiRequestBody, |
| 28 | 29 | withSerializedQueryEntries, |
| @@ -216,9 +217,12 @@ const decodeHttpClientResponseBody = ( · 187 unmodified lines | ||
| 216 | 217 | ? Value |
| 217 | 218 | : never, |
| 218 | 219 | ): Effect.Effect<unknown, Error, never> => { |
| 219 | − const contentType = response.headers["content-type"]?.toLowerCase() ?? ""; | |
| 220 | + if (response.status === 204) { | |
| 221 | + return Effect.succeed(null); | |
| 222 | + } | |
| 220 | 223 | |
| 221 | − if (contentType.includes("application/json")) { | |
| 224 | + const bodyMode = httpBodyModeFromContentType(response.headers["content-type"]); | |
| 225 | + if (bodyMode === "json") { | |
| 222 | 226 | return response.json.pipe( |
| 223 | 227 | Effect.mapError((cause) => |
| 224 | 228 | cause instanceof Error ? cause : new Error(String(cause)), |
| @@ -226,6 +230,15 @@ const decodeHttpClientResponseBody = ( · 1 unmodified line | ||
| 226 | 230 | ); |
| 227 | 231 | } |
| 228 | 232 | |
| 233 | + if (bodyMode === "bytes") { | |
| 234 | + return response.arrayBuffer.pipe( | |
| 235 | + Effect.map((buffer) => new Uint8Array(buffer)), | |
| 236 | + Effect.mapError((cause) => | |
| 237 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 238 | + ), | |
| 239 | + ); | |
| 240 | + } | |
| 241 | + | |
| 229 | 242 | return response.text.pipe( |
| 230 | 243 | Effect.mapError((cause) => |
| 231 | 244 | cause instanceof Error ? cause : new Error(String(cause)), |
| @@ -320,7 +333,7 @@ const buildFetchRequest = (input: { · 88 unmodified lines | ||
| 320 | 333 | url: string; |
| 321 | 334 | method: string; |
| 322 | 335 | headers: Record<string, string>; |
| 323 | − body?: string; | |
| 336 | + body?: string | Uint8Array; | |
| 324 | 337 | } => { |
| 325 | 338 | const resolvedPath = replacePathTemplate( |
| 326 | 339 | input.payload.pathTemplate, |
| @@ -371,7 +384,7 @@ const buildFetchRequest = (input: { · 44 unmodified lines | ||
| 371 | 384 | headers.cookie = cookieParts.join("; "); |
| 372 | 385 | } |
| 373 | 386 | |
| 374 | − let body: string | undefined; | |
| 387 | + let body: string | Uint8Array | undefined; | |
| 375 | 388 | const bodyValues = input.credentialPlacements.bodyValues ?? {}; |
| 376 | 389 | const hasCredentialBodyValues = Object.keys(bodyValues).length > 0; |
| 377 | 390 | |
| @@ -394,7 +407,7 @@ const buildFetchRequest = (input: { · 16 unmodified lines | ||
| 394 | 407 | }), |
| 395 | 408 | }); |
| 396 | 409 | |
| 397 | − body = serializedBody.bodyText; | |
| 410 | + body = serializedBody.body; | |
| 398 | 411 | headers["content-type"] = serializedBody.contentType; |
| 399 | 412 | } |
| 400 | 413 | } |
| @@ -490,11 +503,18 @@ export const createOpenApiToolFromDefinition = ( · 89 unmodified lines | ||
| 490 | 503 | }); |
| 491 | 504 | |
| 492 | 505 | if (request.body !== undefined) { |
| 493 | − clientRequest = HttpClientRequest.bodyText( | |
| 494 | − clientRequest, | |
| 495 | − request.body, | |
| 496 | − request.headers["content-type"], | |
| 497 | − ); | |
| 506 | + clientRequest = | |
| 507 | + request.body instanceof Uint8Array | |
| 508 | + ? HttpClientRequest.bodyUint8Array( | |
| 509 | + clientRequest, | |
| 510 | + request.body, | |
| 511 | + request.headers["content-type"], | |
| 512 | + ) | |
| 513 | + : HttpClientRequest.bodyText( | |
| 514 | + clientRequest, | |
| 515 | + request.body, | |
| 516 | + request.headers["content-type"], | |
| 517 | + ); | |
| 498 | 518 | } |
| 499 | 519 | |
| 500 | 520 | const response = yield* client.execute(clientRequest).pipe( |
| @@ -4,6 +4,7 @@ import { · 3 unmodified lines | ||
| 4 | 4 | applyJsonBodyPlacements, |
| 5 | 5 | } from "@executor/codemode-core"; |
| 6 | 6 | import { |
| 7 | + httpBodyModeFromContentType, | |
| 7 | 8 | serializeOpenApiParameterValue, |
| 8 | 9 | serializeOpenApiRequestBody, |
| 9 | 10 | withSerializedQueryEntries, |
| @@ -78,7 +79,7 @@ type HttpInvocationRequestPlan = { · 68 unmodified lines | ||
| 78 | 79 | method: HttpExecutable["method"]; |
| 79 | 80 | url: string; |
| 80 | 81 | headers: Record<string, string>; |
| 81 | − body?: string; | |
| 82 | + body?: string | Uint8Array; | |
| 82 | 83 | }; |
| 83 | 84 | |
| 84 | 85 | type GraphqlInvocationRequestPlan = { |
| @@ -112,13 +113,17 @@ type IrInvocationRequestPlan = · 27 unmodified lines | ||
| 112 | 113 | | McpInvocationRequestPlan; |
| 113 | 114 | |
| 114 | 115 | const decodeFetchBody = async (response: Response): Promise<unknown> => { |
| 115 | − const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; | |
| 116 | − if (contentType.includes("application/json")) { | |
| 117 | − return response.json(); | |
| 118 | − } | |
| 119 | 116 | if (response.status === 204) { |
| 120 | 117 | return null; |
| 121 | 118 | } |
| 119 | + | |
| 120 | + const bodyMode = httpBodyModeFromContentType(response.headers.get("content-type")); | |
| 121 | + if (bodyMode === "json") { | |
| 122 | + return response.json(); | |
| 123 | + } | |
| 124 | + if (bodyMode === "bytes") { | |
| 125 | + return new Uint8Array(await response.arrayBuffer()); | |
| 126 | + } | |
| 122 | 127 | return response.text(); |
| 123 | 128 | }; |
| 124 | 129 | |
| @@ -337,7 +342,7 @@ const planHttpInvocation = (input: { · 212 unmodified lines | ||
| 337 | 342 | const bodySymbol = input.executable.requestBodyId |
| 338 | 343 | ? input.catalog.symbols[input.executable.requestBodyId] |
| 339 | 344 | : undefined; |
| 340 | − let body: string | undefined; | |
| 345 | + let body: string | Uint8Array | undefined; | |
| 341 | 346 | if (bodySymbol?.kind === "requestBody") { |
| 342 | 347 | const bodyValue = argsRecord.body ?? argsRecord.input; |
| 343 | 348 | if (bodyValue !== undefined) { |
| @@ -353,7 +358,7 @@ const planHttpInvocation = (input: { · 9 unmodified lines | ||
| 353 | 358 | }), |
| 354 | 359 | }); |
| 355 | 360 | headers["content-type"] = serializedBody.contentType; |
| 356 | − body = serializedBody.bodyText; | |
| 361 | + body = serializedBody.body; | |
| 357 | 362 | } |
| 358 | 363 | } |
| 359 | 364 | |
| @@ -1,6 +1,13 @@ | ||
| 1 | 1 | import { readFileSync } from "node:fs"; |
| 2 | 2 | import { createServer } from "node:http"; |
| 3 | 3 | |
| 4 | +import { | |
| 5 | + HttpApi, | |
| 6 | + HttpApiEndpoint, | |
| 7 | + HttpApiGroup, | |
| 8 | + HttpApiSchema, | |
| 9 | + OpenApi, | |
| 10 | +} from "@effect/platform"; | |
| 4 | 11 | import { |
| 5 | 12 | buildOpenApiToolPresentation, |
| 6 | 13 | compileOpenApiToolDefinitions, |
| @@ -26,6 +33,7 @@ import { · 19 unmodified lines | ||
| 26 | 33 | WorkspaceIdSchema, |
| 27 | 34 | } from "#schema"; |
| 28 | 35 | import * as Effect from "effect/Effect"; |
| 36 | +import { Schema } from "effect"; | |
| 29 | 37 | |
| 30 | 38 | import { projectCatalogForAgentSdk } from "../ir/catalog"; |
| 31 | 39 | import type { CatalogSnapshotV1 } from "../ir/model"; |
| @@ -290,6 +298,19 @@ const graphqlSnapshotFromFixture = (input: { · 258 unmodified lines | ||
| 290 | 298 | }; |
| 291 | 299 | }); |
| 292 | 300 | |
| 301 | +const binaryReportIdParam = HttpApiSchema.param("reportId", Schema.String); | |
| 302 | + | |
| 303 | +class BinaryExecutionReportsApi extends HttpApiGroup.make("reports") | |
| 304 | + .add( | |
| 305 | + HttpApiEndpoint.get("getContent")`/reports/${binaryReportIdParam}/content` | |
| 306 | + .addSuccess(HttpApiSchema.Uint8Array()), | |
| 307 | + ) {} | |
| 308 | + | |
| 309 | +class BinaryExecutionApi extends HttpApi.make("binaryExecution") | |
| 310 | + .add(BinaryExecutionReportsApi) {} | |
| 311 | + | |
| 312 | +const binaryExecutionOpenApiSpec = OpenApi.fromApi(BinaryExecutionApi); | |
| 313 | + | |
| 293 | 314 | describe("source adapter fixture matrix", () => { |
| 294 | 315 | it.effect( |
| 295 | 316 | "imports the raw recorded Vercel OpenAPI spec into IR and discover projections", |
| @@ -732,6 +753,140 @@ describe("source adapter fixture matrix", () => { · 436 unmodified lines | ||
| 732 | 753 | 120_000, |
| 733 | 754 | ); |
| 734 | 755 | |
| 756 | + it.effect( | |
| 757 | + "executes imported HTTP tools and preserves binary responses as bytes", | |
| 758 | + () => | |
| 759 | + Effect.tryPromise({ | |
| 760 | + try: async () => { | |
| 761 | + const binaryResponse = Uint8Array.from([0x00, 0x7f, 0x80, 0xff]); | |
| 762 | + const requests: Array<{ | |
| 763 | + method: string; | |
| 764 | + path: string; | |
| 765 | + query: string; | |
| 766 | + headers: Record<string, string | string[] | undefined>; | |
| 767 | + }> = []; | |
| 768 | + | |
| 769 | + const server = createServer((request, response) => { | |
| 770 | + const url = new URL(request.url ?? "/", "http://127.0.0.1"); | |
| 771 | + requests.push({ | |
| 772 | + method: request.method ?? "GET", | |
| 773 | + path: url.pathname, | |
| 774 | + query: url.search, | |
| 775 | + headers: request.headers, | |
| 776 | + }); | |
| 777 | + response.statusCode = 200; | |
| 778 | + response.setHeader("content-type", "application/octet-stream"); | |
| 779 | + response.setHeader("x-request-id", "req_binary_1"); | |
| 780 | + response.end(Buffer.from(binaryResponse)); | |
| 781 | + }); | |
| 782 | + | |
| 783 | + await new Promise<void>((resolve, reject) => { | |
| 784 | + server.listen(0, "127.0.0.1", (error?: Error) => { | |
| 785 | + if (error) { | |
| 786 | + reject(error); | |
| 787 | + return; | |
| 788 | + } | |
| 789 | + resolve(); | |
| 790 | + }); | |
| 791 | + }); | |
| 792 | + | |
| 793 | + try { | |
| 794 | + const address = server.address(); | |
| 795 | + if (!address || typeof address === "string") { | |
| 796 | + throw new Error("Failed to resolve binary execution fixture server"); | |
| 797 | + } | |
| 798 | + | |
| 799 | + const baseUrl = `http://127.0.0.1:${address.port}`; | |
| 800 | + const source = makeSource({ | |
| 801 | + id: "src_openapi_binary_execution_fixture", | |
| 802 | + name: "Binary API", | |
| 803 | + kind: "openapi", | |
| 804 | + endpoint: baseUrl, | |
| 805 | + namespace: "binaryfixture", | |
| 806 | + binding: { | |
| 807 | + specUrl: `${baseUrl}/openapi.json`, | |
| 808 | + defaultHeaders: null, | |
| 809 | + }, | |
| 810 | + }); | |
| 811 | + const specText = JSON.stringify(binaryExecutionOpenApiSpec); | |
| 812 | + | |
| 813 | + const { snapshot } = await Effect.runPromise( | |
| 814 | + openApiSnapshotFromFixture({ | |
| 815 | + source, | |
| 816 | + specText, | |
| 817 | + documentKey: `${baseUrl}/openapi.json`, | |
| 818 | + }), | |
| 819 | + ); | |
| 820 | + const loadedCatalog = makeLoadedCatalog({ | |
| 821 | + source, | |
| 822 | + snapshot, | |
| 823 | + }); | |
| 824 | + const tool = await Effect.runPromise( | |
| 825 | + expandCatalogToolByPath({ | |
| 826 | + catalogs: [loadedCatalog], | |
| 827 | + path: "binaryfixture.reports.getContent", | |
| 828 | + }), | |
| 829 | + ); | |
| 830 | + | |
| 831 | + if (!tool) { | |
| 832 | + throw new Error("Expected binary execution fixture tool to resolve"); | |
| 833 | + } | |
| 834 | + | |
| 835 | + const auth = { | |
| 836 | + placements: [], | |
| 837 | + headers: {}, | |
| 838 | + queryParams: {}, | |
| 839 | + cookies: {}, | |
| 840 | + bodyValues: {}, | |
| 841 | + expiresAt: null, | |
| 842 | + refreshAfter: null, | |
| 843 | + } as const; | |
| 844 | + | |
| 845 | + const result = await Effect.runPromise( | |
| 846 | + invokeIrTool({ | |
| 847 | + workspaceId: source.workspaceId, | |
| 848 | + accountId: "acct_fixture" as any, | |
| 849 | + tool, | |
| 850 | + auth, | |
| 851 | + args: { | |
| 852 | + reportId: "report-123", | |
| 853 | + }, | |
| 854 | + }), | |
| 855 | + ); | |
| 856 | + | |
| 857 | + expect(result).toMatchObject({ | |
| 858 | + error: null, | |
| 859 | + status: 200, | |
| 860 | + headers: { | |
| 861 | + "content-type": "application/octet-stream", | |
| 862 | + "x-request-id": "req_binary_1", | |
| 863 | + }, | |
| 864 | + }); | |
| 865 | + expect(result.data).toBeInstanceOf(Uint8Array); | |
| 866 | + expect(Array.from(result.data as Uint8Array)).toEqual( | |
| 867 | + Array.from(binaryResponse), | |
| 868 | + ); | |
| 869 | + expect(requests).toHaveLength(1); | |
| 870 | + expect(requests[0]?.method).toBe("GET"); | |
| 871 | + expect(requests[0]?.path).toBe("/reports/report-123/content"); | |
| 872 | + } finally { | |
| 873 | + await new Promise<void>((resolve, reject) => { | |
| 874 | + server.close((error) => { | |
| 875 | + if (error) { | |
| 876 | + reject(error); | |
| 877 | + return; | |
| 878 | + } | |
| 879 | + resolve(); | |
| 880 | + }); | |
| 881 | + }); | |
| 882 | + } | |
| 883 | + }, | |
| 884 | + catch: (cause) => | |
| 885 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 886 | + }), | |
| 887 | + 120_000, | |
| 888 | + ); | |
| 889 | + | |
| 735 | 890 | it.effect( |
| 736 | 891 | "imports the raw recorded Google Sheets discovery document into IR and discover projections", |
| 737 | 892 | () => |
| @@ -559,6 +559,132 @@ describe("source-catalog-snapshot", () => { · 558 unmodified lines | ||
| 559 | 559 | }); |
| 560 | 560 | }); |
| 561 | 561 | |
| 562 | + it("resolves Google Discovery request body refs against the materialized input schema root", () => { | |
| 563 | + const snapshot = createGoogleDiscoveryCatalogSnapshot({ | |
| 564 | + source: { | |
| 565 | + ...baseSource, | |
| 566 | + kind: "google_discovery", | |
| 567 | + namespace: "google.gmail", | |
| 568 | + }, | |
| 569 | + documents: [], | |
| 570 | + operations: [{ | |
| 571 | + toolId: "users.drafts.create", | |
| 572 | + title: "Create draft", | |
| 573 | + description: "Create a draft", | |
| 574 | + effect: "write", | |
| 575 | + inputSchema: { | |
| 576 | + type: "object", | |
| 577 | + properties: { | |
| 578 | + userId: { type: "string" }, | |
| 579 | + body: { | |
| 580 | + type: "object", | |
| 581 | + properties: { | |
| 582 | + message: { $ref: "#/$defs/google/Message" }, | |
| 583 | + }, | |
| 584 | + }, | |
| 585 | + }, | |
| 586 | + required: ["userId", "body"], | |
| 587 | + $defs: { | |
| 588 | + google: { | |
| 589 | + Message: { | |
| 590 | + type: "object", | |
| 591 | + properties: { | |
| 592 | + raw: { type: "string" }, | |
| 593 | + }, | |
| 594 | + }, | |
| 595 | + }, | |
| 596 | + }, | |
| 597 | + }, | |
| 598 | + outputSchema: { | |
| 599 | + type: "object", | |
| 600 | + properties: { | |
| 601 | + id: { type: "string" }, | |
| 602 | + }, | |
| 603 | + }, | |
| 604 | + providerData: { | |
| 605 | + kind: "google_discovery", | |
| 606 | + service: "gmail", | |
| 607 | + version: "v1", | |
| 608 | + toolId: "users.drafts.create", | |
| 609 | + rawToolId: "users.drafts.create", | |
| 610 | + methodId: "gmail.users.drafts.create", | |
| 611 | + group: "users.drafts", | |
| 612 | + leaf: "create", | |
| 613 | + invocation: { | |
| 614 | + method: "post", | |
| 615 | + path: "/gmail/v1/users/{userId}/drafts", | |
| 616 | + flatPath: null, | |
| 617 | + rootUrl: "https://gmail.googleapis.com/", | |
| 618 | + servicePath: "", | |
| 619 | + parameters: [{ | |
| 620 | + name: "userId", | |
| 621 | + location: "path", | |
| 622 | + required: true, | |
| 623 | + repeated: false, | |
| 624 | + description: "The user ID.", | |
| 625 | + type: "string", | |
| 626 | + }], | |
| 627 | + requestSchemaId: "Draft", | |
| 628 | + responseSchemaId: "Draft", | |
| 629 | + scopes: [], | |
| 630 | + scopeDescriptions: undefined, | |
| 631 | + supportsMediaUpload: false, | |
| 632 | + supportsMediaDownload: false, | |
| 633 | + }, | |
| 634 | + }, | |
| 635 | + }], | |
| 636 | + }); | |
| 637 | + | |
| 638 | + expect(Object.values(snapshot.catalog.diagnostics)).toEqual([]); | |
| 639 | + | |
| 640 | + const requestBody = Object.values(snapshot.catalog.symbols).find((symbol) => symbol.kind === "requestBody"); | |
| 641 | + expect(requestBody?.kind).toBe("requestBody"); | |
| 642 | + if (!requestBody || requestBody.kind !== "requestBody") { | |
| 643 | + throw new Error("Expected Google Discovery request body symbol"); | |
| 644 | + } | |
| 645 | + | |
| 646 | + const requestBodyShape = snapshot.catalog.symbols[requestBody.contents[0]!.shapeId]; | |
| 647 | + expect(requestBodyShape?.kind).toBe("shape"); | |
| 648 | + if (!requestBodyShape || requestBodyShape.kind !== "shape") { | |
| 649 | + throw new Error("Expected request body shape"); | |
| 650 | + } | |
| 651 | + | |
| 652 | + expect(requestBodyShape.node.type).toBe("object"); | |
| 653 | + if (requestBodyShape.node.type !== "object") { | |
| 654 | + throw new Error("Expected request body object shape"); | |
| 655 | + } | |
| 656 | + | |
| 657 | + const messageShapeId = requestBodyShape.node.fields.message?.shapeId; | |
| 658 | + expect(messageShapeId).toBeDefined(); | |
| 659 | + const messageShape = | |
| 660 | + messageShapeId === undefined | |
| 661 | + ? undefined | |
| 662 | + : snapshot.catalog.symbols[messageShapeId]; | |
| 663 | + | |
| 664 | + expect(messageShape?.kind).toBe("shape"); | |
| 665 | + if (!messageShape || messageShape.kind !== "shape") { | |
| 666 | + throw new Error("Expected message field shape"); | |
| 667 | + } | |
| 668 | + | |
| 669 | + expect(messageShape.node.type).toBe("ref"); | |
| 670 | + if (messageShape.node.type !== "ref") { | |
| 671 | + throw new Error("Expected message field ref shape"); | |
| 672 | + } | |
| 673 | + | |
| 674 | + const targetShape = snapshot.catalog.symbols[messageShape.node.target]; | |
| 675 | + expect(targetShape?.kind).toBe("shape"); | |
| 676 | + if (!targetShape || targetShape.kind !== "shape") { | |
| 677 | + throw new Error("Expected referenced Message shape"); | |
| 678 | + } | |
| 679 | + | |
| 680 | + expect(targetShape.node.type).toBe("object"); | |
| 681 | + if (targetShape.node.type !== "object") { | |
| 682 | + throw new Error("Expected referenced Message object shape"); | |
| 683 | + } | |
| 684 | + | |
| 685 | + expect(targetShape.node.fields.raw?.shapeId).toBeDefined(); | |
| 686 | + }); | |
| 687 | + | |
| 562 | 688 | it("deduplicates structurally identical JSON schema shapes across operations", () => { |
| 563 | 689 | const repeatedObjectSchema = { |
| 564 | 690 | type: "object", |
| @@ -1843,6 +1843,7 @@ const createHttpCapabilityFromGoogleDiscovery = (input: { · 1842 unmodified lines | ||
| 1843 | 1843 | schemaShapeId: input.importer.importSchema( |
| 1844 | 1844 | parameterSchema, |
| 1845 | 1845 | `#/googleDiscovery/${input.operation.providerData.toolId}/parameter/${parameter.location}/${parameter.name}`, |
| 1846 | + inputSchema, | |
| 1846 | 1847 | ), |
| 1847 | 1848 | synthetic: false, |
| 1848 | 1849 | provenance: provenanceFor( |
| @@ -1867,6 +1868,7 @@ const createHttpCapabilityFromGoogleDiscovery = (input: { · 18 unmodified lines | ||
| 1867 | 1868 | shapeId: input.importer.importSchema( |
| 1868 | 1869 | requestBodySchema, |
| 1869 | 1870 | `#/googleDiscovery/${input.operation.providerData.toolId}/requestBody`, |
| 1871 | + inputSchema, | |
| 1870 | 1872 | ), |
| 1871 | 1873 | }], |
| 1872 | 1874 | synthetic: false, |
| @@ -1894,6 +1896,7 @@ const createHttpCapabilityFromGoogleDiscovery = (input: { · 21 unmodified lines | ||
| 1894 | 1896 | shapeId: input.importer.importSchema( |
| 1895 | 1897 | outputSchema, |
| 1896 | 1898 | `#/googleDiscovery/${input.operation.providerData.toolId}/response`, |
| 1899 | + outputSchema, | |
| 1897 | 1900 | ), |
| 1898 | 1901 | }], |
| 1899 | 1902 | } |