History

support return types

Rhys Sullivan committed 8 months ago

Commit bdf54a61 parent eafb8ea9 files · +534 −23Browse files

Unified · Split

9 files
  1. .executor/executor.jsonc +12 −1
  2. packages/codemode-openapi/src/index.ts +5 −0
  3. packages/codemode-openapi/src/openapi-http-serialization.ts +106 −5
  4. packages/codemode-openapi/src/openapi-tools.test.ts +85 −0
  5. packages/codemode-openapi/src/openapi-tools.ts +30 −10
  6. packages/control-plane/src/runtime/ir-execution.ts +12 −7
  7. packages/control-plane/src/runtime/source-adapter-fixture-matrix.test.ts +155 −0
  8. packages/control-plane/src/runtime/source-catalog-snapshot.test.ts +126 −0
  9. packages/control-plane/src/runtime/source-catalog-snapshot.ts +3 −0
.executor/executor.jsonc+12−1
@@ -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}
packages/codemode-openapi/src/index.ts+5−0
@@ -18,12 +18,17 @@ export { · 17 unmodified lines
18 type OpenApiToolPresentation,18 type OpenApiToolPresentation,
19} from "./openapi-tool-presentation";19} from "./openapi-tool-presentation";
20export {20export {
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";
packages/codemode-openapi/src/openapi-http-serialization.ts+106−5
@@ -18,6 +18,13 @@ export type OpenApiSerializableRequestBody = { · 17 unmodified lines
18 contents?: ReadonlyArray<OpenApiSerializableContent>;18 contents?: ReadonlyArray<OpenApiSerializableContent>;
19};19};
20 20
21export type HttpBodyDecodingMode = "json" | "text" | "bytes";
22
23export type SerializedOpenApiRequestBody = {
24 contentType: string;
25 body: string | Uint8Array;
26};
27
21export type SerializedOpenApiQueryEntry = {28export type SerializedOpenApiQueryEntry = {
22 name: string;29 name: string;
23 value: string;30 value: string;
@@ -44,6 +51,15 @@ export type SerializedOpenApiParameterValue = · 20 unmodified lines
44const isRecord = (value: unknown): value is Record<string, unknown> =>51const 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
54const textEncoder = new TextEncoder();
55
56const normalizeMediaType = (value: string | undefined | null): string =>
57 (value ?? "")
58 .split(";", 1)[0]
59 ?.trim()
60 .toLowerCase()
61 ?? "";
62
47const primitiveString = (value: unknown): string => {63const 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
87export 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
94export 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
118export 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
71const objectEntries = (value: unknown): Array<[string, unknown]> =>137const 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
211const 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
145const serializeContentValue = (value: unknown, mediaType: string): string => {242const 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
502export const serializeOpenApiRequestBody = (input: {599export 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};
packages/codemode-openapi/src/openapi-tools.test.ts+85−0
@@ -35,6 +35,11 @@ type TestServer = { · 34 unmodified lines
35 close: () => Promise<void>;35 close: () => Promise<void>;
36};36};
37 37
38type BinaryTestServer = {
39 baseUrl: string;
40 close: () => Promise<void>;
41};
42
38type EffectServerHandler = {43type 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
220const 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
215const ownerParam = HttpApiSchema.param("owner", Schema.String);263const ownerParam = HttpApiSchema.param("owner", Schema.String);
216const repoParam = HttpApiSchema.param("repo", Schema.String);264const 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
250const generatedOpenApiSpec = OpenApi.fromApi(GeneratedApi);298const generatedOpenApiSpec = OpenApi.fromApi(GeneratedApi);
251 299
300const binaryReportIdParam = HttpApiSchema.param("reportId", Schema.String);
301
302class GeneratedBinaryReportsApi extends HttpApiGroup.make("reports")
303 .add(
304 HttpApiEndpoint.get("getContent")`/reports/${binaryReportIdParam}/content`
305 .addSuccess(HttpApiSchema.Uint8Array()),
306 ) {}
307
308class GeneratedBinaryApi extends HttpApi.make("generatedBinary")
309 .add(GeneratedBinaryReportsApi) {}
310
311const generatedBinaryOpenApiSpec = OpenApi.fromApi(GeneratedBinaryApi);
312
252const resolveToolDefinition = (value: ToolInput): ToolDefinition =>313const resolveToolDefinition = (value: ToolInput): ToolDefinition =>
253 typeof value === "object" && value !== null && "tool" in value314 typeof value === "object" && value !== null && "tool" in value
254 ? value315 ? 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;
packages/codemode-openapi/src/openapi-tools.ts+30−10
@@ -23,6 +23,7 @@ import { · 22 unmodified lines
23 type OpenApiToolDefinition,23 type OpenApiToolDefinition,
24} from "./openapi-definitions";24} from "./openapi-definitions";
25import {25import {
26 httpBodyModeFromContentType,
26 serializeOpenApiParameterValue,27 serializeOpenApiParameterValue,
27 serializeOpenApiRequestBody,28 serializeOpenApiRequestBody,
28 withSerializedQueryEntries,29 withSerializedQueryEntries,
@@ -216,9 +217,12 @@ const decodeHttpClientResponseBody = ( · 187 unmodified lines
216 ? Value217 ? 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(
packages/control-plane/src/runtime/ir-execution.ts+12−7
@@ -4,6 +4,7 @@ import { · 3 unmodified lines
4 applyJsonBodyPlacements,4 applyJsonBodyPlacements,
5} from "@executor/codemode-core";5} from "@executor/codemode-core";
6import {6import {
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
84type GraphqlInvocationRequestPlan = {85type GraphqlInvocationRequestPlan = {
@@ -112,13 +113,17 @@ type IrInvocationRequestPlan = · 27 unmodified lines
112 | McpInvocationRequestPlan;113 | McpInvocationRequestPlan;
113 114
114const decodeFetchBody = async (response: Response): Promise<unknown> => {115const 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.requestBodyId342 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
packages/control-plane/src/runtime/source-adapter-fixture-matrix.test.ts+155−0
@@ -1,6 +1,13 @@
1import { readFileSync } from "node:fs";1import { readFileSync } from "node:fs";
2import { createServer } from "node:http";2import { createServer } from "node:http";
3 3
4import {
5 HttpApi,
6 HttpApiEndpoint,
7 HttpApiGroup,
8 HttpApiSchema,
9 OpenApi,
10} from "@effect/platform";
4import {11import {
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";
28import * as Effect from "effect/Effect";35import * as Effect from "effect/Effect";
36import { Schema } from "effect";
29 37
30import { projectCatalogForAgentSdk } from "../ir/catalog";38import { projectCatalogForAgentSdk } from "../ir/catalog";
31import type { CatalogSnapshotV1 } from "../ir/model";39import type { CatalogSnapshotV1 } from "../ir/model";
@@ -290,6 +298,19 @@ const graphqlSnapshotFromFixture = (input: { · 258 unmodified lines
290 };298 };
291 });299 });
292 300
301const binaryReportIdParam = HttpApiSchema.param("reportId", Schema.String);
302
303class BinaryExecutionReportsApi extends HttpApiGroup.make("reports")
304 .add(
305 HttpApiEndpoint.get("getContent")`/reports/${binaryReportIdParam}/content`
306 .addSuccess(HttpApiSchema.Uint8Array()),
307 ) {}
308
309class BinaryExecutionApi extends HttpApi.make("binaryExecution")
310 .add(BinaryExecutionReportsApi) {}
311
312const binaryExecutionOpenApiSpec = OpenApi.fromApi(BinaryExecutionApi);
313
293describe("source adapter fixture matrix", () => {314describe("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 () =>
packages/control-plane/src/runtime/source-catalog-snapshot.test.ts+126−0
@@ -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",
packages/control-plane/src/runtime/source-catalog-snapshot.ts+3−0
@@ -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 }

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t