Unified · Split
| @@ -1,12 +1,12 @@ | |||
| 1 | { | 1 | { |
| 2 | "sources": { | 2 | "sources": { |
| 3 | "google-gmail": { | 3 | "google-gmail": { |
| 4 | "kind": "google_discovery", | ||
| 5 | "name": "Gmail", | 4 | "name": "Gmail", |
| 6 | "namespace": "google.gmail", | 5 | "namespace": "google.gmail", |
| 7 | "connection": { | 6 | "connection": { |
| 8 | "endpoint": "https://gmail.googleapis.com/$discovery/rest?version=v1" | 7 | "endpoint": "https://gmail.googleapis.com/$discovery/rest?version=v1" |
| 9 | }, | 8 | }, |
| 9 | "kind": "google_discovery", | ||
| 10 | "binding": { | 10 | "binding": { |
| 11 | "service": "gmail", | 11 | "service": "gmail", |
| 12 | "version": "v1", | 12 | "version": "v1", |
| @@ -14,6 +14,17 @@ · 1 unmodified line | |||
| 14 | "defaultHeaders": null, | 14 | "defaultHeaders": null, |
| 15 | "scopes": [] | 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 | type OpenApiToolPresentation, | 18 | type OpenApiToolPresentation, |
| 19 | } from "./openapi-tool-presentation"; | 19 | } from "./openapi-tool-presentation"; |
| 20 | export { | 20 | export { |
| 21 | httpBodyModeFromContentType, | ||
| 22 | isJsonMediaType, | ||
| 23 | isTextMediaType, | ||
| 21 | serializeOpenApiParameterValue, | 24 | serializeOpenApiParameterValue, |
| 22 | serializeOpenApiRequestBody, | 25 | serializeOpenApiRequestBody, |
| 23 | withSerializedQueryEntries, | 26 | withSerializedQueryEntries, |
| 27 | type HttpBodyDecodingMode, | ||
| 24 | type OpenApiSerializableContent, | 28 | type OpenApiSerializableContent, |
| 25 | type OpenApiSerializableParameter, | 29 | type OpenApiSerializableParameter, |
| 26 | type OpenApiSerializableRequestBody, | 30 | type OpenApiSerializableRequestBody, |
| 31 | type SerializedOpenApiRequestBody, | ||
| 27 | type SerializedOpenApiParameterValue, | 32 | type SerializedOpenApiParameterValue, |
| 28 | type SerializedOpenApiQueryEntry, | 33 | type SerializedOpenApiQueryEntry, |
| 29 | } from "./openapi-http-serialization"; | 34 | } from "./openapi-http-serialization"; |
| @@ -18,6 +18,13 @@ export type OpenApiSerializableRequestBody = { · 17 unmodified lines | |||
| 18 | contents?: ReadonlyArray<OpenApiSerializableContent>; | 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 | export type SerializedOpenApiQueryEntry = { | 28 | export type SerializedOpenApiQueryEntry = { |
| 22 | name: string; | 29 | name: string; |
| 23 | value: string; | 30 | value: string; |
| @@ -44,6 +51,15 @@ export type SerializedOpenApiParameterValue = · 20 unmodified lines | |||
| 44 | const isRecord = (value: unknown): value is Record<string, unknown> => | 51 | const isRecord = (value: unknown): value is Record<string, unknown> => |
| 45 | value !== null && typeof value === "object" && !Array.isArray(value); | 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 | const primitiveString = (value: unknown): string => { | 63 | const primitiveString = (value: unknown): string => { |
| 48 | if (typeof value === "string") { | 64 | if (typeof value === "string") { |
| 49 | return value; | 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 | const objectEntries = (value: unknown): Array<[string, unknown]> => | 137 | const objectEntries = (value: unknown): Array<[string, unknown]> => |
| 72 | Object.entries(isRecord(value) ? value : {}) | 138 | Object.entries(isRecord(value) ? value : {}) |
| 73 | .sort(([left], [right]) => left.localeCompare(right)); | 139 | .sort(([left], [right]) => left.localeCompare(right)); |
| @@ -142,6 +208,37 @@ const preferredContentType = (input: { · 68 unmodified lines | |||
| 142 | ?? candidates[0]; | 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 | const serializeContentValue = (value: unknown, mediaType: string): string => { | 242 | const serializeContentValue = (value: unknown, mediaType: string): string => { |
| 146 | const normalized = mediaType.toLowerCase(); | 243 | const normalized = mediaType.toLowerCase(); |
| 147 | if ( | 244 | if ( |
| @@ -502,14 +599,18 @@ export const withSerializedQueryEntries = ( · 354 unmodified lines | |||
| 502 | export const serializeOpenApiRequestBody = (input: { | 599 | export const serializeOpenApiRequestBody = (input: { |
| 503 | requestBody: OpenApiSerializableRequestBody; | 600 | requestBody: OpenApiSerializableRequestBody; |
| 504 | body: unknown; | 601 | body: unknown; |
| 505 | }): { | 602 | }): SerializedOpenApiRequestBody => { |
| 506 | contentType: string; | ||
| 507 | bodyText: string; | ||
| 508 | } => { | ||
| 509 | const contentType = preferredContentType(input.requestBody) ?? "application/json"; | 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 | return { | 612 | return { |
| 512 | contentType, | 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 | close: () => Promise<void>; | 35 | close: () => Promise<void>; |
| 36 | }; | 36 | }; |
| 37 | 37 | ||
| 38 | type BinaryTestServer = { | ||
| 39 | baseUrl: string; | ||
| 40 | close: () => Promise<void>; | ||
| 41 | }; | ||
| 42 | |||
| 38 | type EffectServerHandler = { | 43 | type EffectServerHandler = { |
| 39 | handler: (nodeRequest: IncomingMessage, nodeResponse: ServerResponse) => void; | 44 | handler: (nodeRequest: IncomingMessage, nodeResponse: ServerResponse) => void; |
| 40 | dispose: () => Promise<void>; | 45 | dispose: () => Promise<void>; |
| @@ -212,6 +217,49 @@ const makeTestServer = Effect.acquireRelease( · 171 unmodified lines | |||
| 212 | }).pipe(Effect.orDie), | 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 | const ownerParam = HttpApiSchema.param("owner", Schema.String); | 263 | const ownerParam = HttpApiSchema.param("owner", Schema.String); |
| 216 | const repoParam = HttpApiSchema.param("repo", Schema.String); | 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 | const generatedOpenApiSpec = OpenApi.fromApi(GeneratedApi); | 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 | const resolveToolDefinition = (value: ToolInput): ToolDefinition => | 313 | const resolveToolDefinition = (value: ToolInput): ToolDefinition => |
| 253 | typeof value === "object" && value !== null && "tool" in value | 314 | typeof value === "object" && value !== null && "tool" in value |
| 254 | ? value | 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 | it.scoped("executes extracted tools against HTTP endpoint", () => | 723 | it.scoped("executes extracted tools against HTTP endpoint", () => |
| 639 | Effect.gen(function* () { | 724 | Effect.gen(function* () { |
| 640 | const server = yield* makeTestServer; | 725 | const server = yield* makeTestServer; |
| @@ -23,6 +23,7 @@ import { · 22 unmodified lines | |||
| 23 | type OpenApiToolDefinition, | 23 | type OpenApiToolDefinition, |
| 24 | } from "./openapi-definitions"; | 24 | } from "./openapi-definitions"; |
| 25 | import { | 25 | import { |
| 26 | httpBodyModeFromContentType, | ||
| 26 | serializeOpenApiParameterValue, | 27 | serializeOpenApiParameterValue, |
| 27 | serializeOpenApiRequestBody, | 28 | serializeOpenApiRequestBody, |
| 28 | withSerializedQueryEntries, | 29 | withSerializedQueryEntries, |
| @@ -216,9 +217,12 @@ const decodeHttpClientResponseBody = ( · 187 unmodified lines | |||
| 216 | ? Value | 217 | ? Value |
| 217 | : never, | 218 | : never, |
| 218 | ): Effect.Effect<unknown, Error, never> => { | 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 | return response.json.pipe( | 226 | return response.json.pipe( |
| 223 | Effect.mapError((cause) => | 227 | Effect.mapError((cause) => |
| 224 | cause instanceof Error ? cause : new Error(String(cause)), | 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 | return response.text.pipe( | 242 | return response.text.pipe( |
| 230 | Effect.mapError((cause) => | 243 | Effect.mapError((cause) => |
| 231 | cause instanceof Error ? cause : new Error(String(cause)), | 244 | cause instanceof Error ? cause : new Error(String(cause)), |
| @@ -320,7 +333,7 @@ const buildFetchRequest = (input: { · 88 unmodified lines | |||
| 320 | url: string; | 333 | url: string; |
| 321 | method: string; | 334 | method: string; |
| 322 | headers: Record<string, string>; | 335 | headers: Record<string, string>; |
| 323 | body?: string; | 336 | body?: string | Uint8Array; |
| 324 | } => { | 337 | } => { |
| 325 | const resolvedPath = replacePathTemplate( | 338 | const resolvedPath = replacePathTemplate( |
| 326 | input.payload.pathTemplate, | 339 | input.payload.pathTemplate, |
| @@ -371,7 +384,7 @@ const buildFetchRequest = (input: { · 44 unmodified lines | |||
| 371 | headers.cookie = cookieParts.join("; "); | 384 | headers.cookie = cookieParts.join("; "); |
| 372 | } | 385 | } |
| 373 | 386 | ||
| 374 | let body: string | undefined; | 387 | let body: string | Uint8Array | undefined; |
| 375 | const bodyValues = input.credentialPlacements.bodyValues ?? {}; | 388 | const bodyValues = input.credentialPlacements.bodyValues ?? {}; |
| 376 | const hasCredentialBodyValues = Object.keys(bodyValues).length > 0; | 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 | headers["content-type"] = serializedBody.contentType; | 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 | if (request.body !== undefined) { | 505 | if (request.body !== undefined) { |
| 493 | clientRequest = HttpClientRequest.bodyText( | 506 | clientRequest = |
| 494 | clientRequest, | 507 | request.body instanceof Uint8Array |
| 495 | request.body, | 508 | ? HttpClientRequest.bodyUint8Array( |
| 496 | request.headers["content-type"], | 509 | clientRequest, |
| 497 | ); | 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 | const response = yield* client.execute(clientRequest).pipe( | 520 | const response = yield* client.execute(clientRequest).pipe( |
| @@ -4,6 +4,7 @@ import { · 3 unmodified lines | |||
| 4 | applyJsonBodyPlacements, | 4 | applyJsonBodyPlacements, |
| 5 | } from "@executor/codemode-core"; | 5 | } from "@executor/codemode-core"; |
| 6 | import { | 6 | import { |
| 7 | httpBodyModeFromContentType, | ||
| 7 | serializeOpenApiParameterValue, | 8 | serializeOpenApiParameterValue, |
| 8 | serializeOpenApiRequestBody, | 9 | serializeOpenApiRequestBody, |
| 9 | withSerializedQueryEntries, | 10 | withSerializedQueryEntries, |
| @@ -78,7 +79,7 @@ type HttpInvocationRequestPlan = { · 68 unmodified lines | |||
| 78 | method: HttpExecutable["method"]; | 79 | method: HttpExecutable["method"]; |
| 79 | url: string; | 80 | url: string; |
| 80 | headers: Record<string, string>; | 81 | headers: Record<string, string>; |
| 81 | body?: string; | 82 | body?: string | Uint8Array; |
| 82 | }; | 83 | }; |
| 83 | 84 | ||
| 84 | type GraphqlInvocationRequestPlan = { | 85 | type GraphqlInvocationRequestPlan = { |
| @@ -112,13 +113,17 @@ type IrInvocationRequestPlan = · 27 unmodified lines | |||
| 112 | | McpInvocationRequestPlan; | 113 | | McpInvocationRequestPlan; |
| 113 | 114 | ||
| 114 | const decodeFetchBody = async (response: Response): Promise<unknown> => { | 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 | if (response.status === 204) { | 116 | if (response.status === 204) { |
| 120 | return null; | 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 | return response.text(); | 127 | return response.text(); |
| 123 | }; | 128 | }; |
| 124 | 129 | ||
| @@ -337,7 +342,7 @@ const planHttpInvocation = (input: { · 212 unmodified lines | |||
| 337 | const bodySymbol = input.executable.requestBodyId | 342 | const bodySymbol = input.executable.requestBodyId |
| 338 | ? input.catalog.symbols[input.executable.requestBodyId] | 343 | ? input.catalog.symbols[input.executable.requestBodyId] |
| 339 | : undefined; | 344 | : undefined; |
| 340 | let body: string | undefined; | 345 | let body: string | Uint8Array | undefined; |
| 341 | if (bodySymbol?.kind === "requestBody") { | 346 | if (bodySymbol?.kind === "requestBody") { |
| 342 | const bodyValue = argsRecord.body ?? argsRecord.input; | 347 | const bodyValue = argsRecord.body ?? argsRecord.input; |
| 343 | if (bodyValue !== undefined) { | 348 | if (bodyValue !== undefined) { |
| @@ -353,7 +358,7 @@ const planHttpInvocation = (input: { · 9 unmodified lines | |||
| 353 | }), | 358 | }), |
| 354 | }); | 359 | }); |
| 355 | headers["content-type"] = serializedBody.contentType; | 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 | import { readFileSync } from "node:fs"; | 1 | import { readFileSync } from "node:fs"; |
| 2 | import { createServer } from "node:http"; | 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 | import { | 11 | import { |
| 5 | buildOpenApiToolPresentation, | 12 | buildOpenApiToolPresentation, |
| 6 | compileOpenApiToolDefinitions, | 13 | compileOpenApiToolDefinitions, |
| @@ -26,6 +33,7 @@ import { · 19 unmodified lines | |||
| 26 | WorkspaceIdSchema, | 33 | WorkspaceIdSchema, |
| 27 | } from "#schema"; | 34 | } from "#schema"; |
| 28 | import * as Effect from "effect/Effect"; | 35 | import * as Effect from "effect/Effect"; |
| 36 | import { Schema } from "effect"; | ||
| 29 | 37 | ||
| 30 | import { projectCatalogForAgentSdk } from "../ir/catalog"; | 38 | import { projectCatalogForAgentSdk } from "../ir/catalog"; |
| 31 | import type { CatalogSnapshotV1 } from "../ir/model"; | 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 | describe("source adapter fixture matrix", () => { | 314 | describe("source adapter fixture matrix", () => { |
| 294 | it.effect( | 315 | it.effect( |
| 295 | "imports the raw recorded Vercel OpenAPI spec into IR and discover projections", | 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 | 120_000, | 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 | it.effect( | 890 | it.effect( |
| 736 | "imports the raw recorded Google Sheets discovery document into IR and discover projections", | 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 | it("deduplicates structurally identical JSON schema shapes across operations", () => { | 688 | it("deduplicates structurally identical JSON schema shapes across operations", () => { |
| 563 | const repeatedObjectSchema = { | 689 | const repeatedObjectSchema = { |
| 564 | type: "object", | 690 | type: "object", |
| @@ -1843,6 +1843,7 @@ const createHttpCapabilityFromGoogleDiscovery = (input: { · 1842 unmodified lines | |||
| 1843 | schemaShapeId: input.importer.importSchema( | 1843 | schemaShapeId: input.importer.importSchema( |
| 1844 | parameterSchema, | 1844 | parameterSchema, |
| 1845 | `#/googleDiscovery/${input.operation.providerData.toolId}/parameter/${parameter.location}/${parameter.name}`, | 1845 | `#/googleDiscovery/${input.operation.providerData.toolId}/parameter/${parameter.location}/${parameter.name}`, |
| 1846 | inputSchema, | ||
| 1846 | ), | 1847 | ), |
| 1847 | synthetic: false, | 1848 | synthetic: false, |
| 1848 | provenance: provenanceFor( | 1849 | provenance: provenanceFor( |
| @@ -1867,6 +1868,7 @@ const createHttpCapabilityFromGoogleDiscovery = (input: { · 18 unmodified lines | |||
| 1867 | shapeId: input.importer.importSchema( | 1868 | shapeId: input.importer.importSchema( |
| 1868 | requestBodySchema, | 1869 | requestBodySchema, |
| 1869 | `#/googleDiscovery/${input.operation.providerData.toolId}/requestBody`, | 1870 | `#/googleDiscovery/${input.operation.providerData.toolId}/requestBody`, |
| 1871 | inputSchema, | ||
| 1870 | ), | 1872 | ), |
| 1871 | }], | 1873 | }], |
| 1872 | synthetic: false, | 1874 | synthetic: false, |
| @@ -1894,6 +1896,7 @@ const createHttpCapabilityFromGoogleDiscovery = (input: { · 21 unmodified lines | |||
| 1894 | shapeId: input.importer.importSchema( | 1896 | shapeId: input.importer.importSchema( |
| 1895 | outputSchema, | 1897 | outputSchema, |
| 1896 | `#/googleDiscovery/${input.operation.providerData.toolId}/response`, | 1898 | `#/googleDiscovery/${input.operation.providerData.toolId}/response`, |
| 1899 | outputSchema, | ||
| 1897 | ), | 1900 | ), |
| 1898 | }], | 1901 | }], |
| 1899 | } | 1902 | } |