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 @@
11 {
22 "sources": {
33 "google-gmail": {
4− "kind": "google_discovery",
54 "name": "Gmail",
65 "namespace": "google.gmail",
76 "connection": {
87 "endpoint": "https://gmail.googleapis.com/$discovery/rest?version=v1"
98 },
9+ "kind": "google_discovery",
1010 "binding": {
1111 "service": "gmail",
1212 "version": "v1",
@@ -14,6 +14,17 @@ · 1 unmodified line
1414 "defaultHeaders": null,
1515 "scopes": []
1616 }
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+ }
1728 }
1829 }
1930 }
packages/codemode-openapi/src/index.ts+5−0
@@ -18,12 +18,17 @@ export { · 17 unmodified lines
1818 type OpenApiToolPresentation,
1919 } from "./openapi-tool-presentation";
2020 export {
21+ httpBodyModeFromContentType,
22+ isJsonMediaType,
23+ isTextMediaType,
2124 serializeOpenApiParameterValue,
2225 serializeOpenApiRequestBody,
2326 withSerializedQueryEntries,
27+ type HttpBodyDecodingMode,
2428 type OpenApiSerializableContent,
2529 type OpenApiSerializableParameter,
2630 type OpenApiSerializableRequestBody,
31+ type SerializedOpenApiRequestBody,
2732 type SerializedOpenApiParameterValue,
2833 type SerializedOpenApiQueryEntry,
2934 } from "./openapi-http-serialization";
packages/codemode-openapi/src/openapi-http-serialization.ts+106−5
@@ -18,6 +18,13 @@ export type OpenApiSerializableRequestBody = { · 17 unmodified lines
1818 contents?: ReadonlyArray<OpenApiSerializableContent>;
1919 };
2020
21+export type HttpBodyDecodingMode = "json" | "text" | "bytes";
22+
23+export type SerializedOpenApiRequestBody = {
24+ contentType: string;
25+ body: string | Uint8Array;
26+};
27+
2128 export type SerializedOpenApiQueryEntry = {
2229 name: string;
2330 value: string;
@@ -44,6 +51,15 @@ export type SerializedOpenApiParameterValue = · 20 unmodified lines
4451 const isRecord = (value: unknown): value is Record<string, unknown> =>
4552 value !== null && typeof value === "object" && !Array.isArray(value);
4653
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+
4763 const primitiveString = (value: unknown): string => {
4864 if (typeof value === "string") {
4965 return value;
@@ -68,6 +84,56 @@ const primitiveString = (value: unknown): string => { · 18 unmodified lines
6884 }
6985 };
7086
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+
71137 const objectEntries = (value: unknown): Array<[string, unknown]> =>
72138 Object.entries(isRecord(value) ? value : {})
73139 .sort(([left], [right]) => left.localeCompare(right));
@@ -142,6 +208,37 @@ const preferredContentType = (input: { · 68 unmodified lines
142208 ?? candidates[0];
143209 };
144210
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+
145242 const serializeContentValue = (value: unknown, mediaType: string): string => {
146243 const normalized = mediaType.toLowerCase();
147244 if (
@@ -502,14 +599,18 @@ export const withSerializedQueryEntries = ( · 354 unmodified lines
502599 export const serializeOpenApiRequestBody = (input: {
503600 requestBody: OpenApiSerializableRequestBody;
504601 body: unknown;
505−}): {
506− contentType: string;
507− bodyText: string;
508−} => {
602+}): SerializedOpenApiRequestBody => {
509603 const contentType = preferredContentType(input.requestBody) ?? "application/json";
510604
605+ if (httpBodyModeFromContentType(contentType) === "bytes") {
606+ return {
607+ contentType,
608+ body: serializeBinaryContentValue(input.body),
609+ };
610+ }
611+
511612 return {
512613 contentType,
513− bodyText: serializeContentValue(input.body, contentType),
614+ body: serializeContentValue(input.body, contentType),
514615 };
515616 };
packages/codemode-openapi/src/openapi-tools.test.ts+85−0
@@ -35,6 +35,11 @@ type TestServer = { · 34 unmodified lines
3535 close: () => Promise<void>;
3636 };
3737
38+type BinaryTestServer = {
39+ baseUrl: string;
40+ close: () => Promise<void>;
41+};
42+
3843 type EffectServerHandler = {
3944 handler: (nodeRequest: IncomingMessage, nodeResponse: ServerResponse) => void;
4045 dispose: () => Promise<void>;
@@ -212,6 +217,49 @@ const makeTestServer = Effect.acquireRelease( · 171 unmodified lines
212217 }).pipe(Effect.orDie),
213218 );
214219
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+
215263 const ownerParam = HttpApiSchema.param("owner", Schema.String);
216264 const repoParam = HttpApiSchema.param("repo", Schema.String);
217265
@@ -249,6 +297,19 @@ class GeneratedApi extends HttpApi.make("generated").add(GeneratedReposApi) {} · 31 unmodified lines
249297
250298 const generatedOpenApiSpec = OpenApi.fromApi(GeneratedApi);
251299
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+
252313 const resolveToolDefinition = (value: ToolInput): ToolDefinition =>
253314 typeof value === "object" && value !== null && "tool" in value
254315 ? value
@@ -635,6 +696,30 @@ describe("openapi-tools", () => { · 380 unmodified lines
635696 }),
636697 );
637698
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+
638723 it.scoped("executes extracted tools against HTTP endpoint", () =>
639724 Effect.gen(function* () {
640725 const server = yield* makeTestServer;
packages/codemode-openapi/src/openapi-tools.ts+30−10
@@ -23,6 +23,7 @@ import { · 22 unmodified lines
2323 type OpenApiToolDefinition,
2424 } from "./openapi-definitions";
2525 import {
26+ httpBodyModeFromContentType,
2627 serializeOpenApiParameterValue,
2728 serializeOpenApiRequestBody,
2829 withSerializedQueryEntries,
@@ -216,9 +217,12 @@ const decodeHttpClientResponseBody = ( · 187 unmodified lines
216217 ? Value
217218 : never,
218219 ): Effect.Effect<unknown, Error, never> => {
219− const contentType = response.headers["content-type"]?.toLowerCase() ?? "";
220+ if (response.status === 204) {
221+ return Effect.succeed(null);
222+ }
220223
221− if (contentType.includes("application/json")) {
224+ const bodyMode = httpBodyModeFromContentType(response.headers["content-type"]);
225+ if (bodyMode === "json") {
222226 return response.json.pipe(
223227 Effect.mapError((cause) =>
224228 cause instanceof Error ? cause : new Error(String(cause)),
@@ -226,6 +230,15 @@ const decodeHttpClientResponseBody = ( · 1 unmodified line
226230 );
227231 }
228232
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+
229242 return response.text.pipe(
230243 Effect.mapError((cause) =>
231244 cause instanceof Error ? cause : new Error(String(cause)),
@@ -320,7 +333,7 @@ const buildFetchRequest = (input: { · 88 unmodified lines
320333 url: string;
321334 method: string;
322335 headers: Record<string, string>;
323− body?: string;
336+ body?: string | Uint8Array;
324337 } => {
325338 const resolvedPath = replacePathTemplate(
326339 input.payload.pathTemplate,
@@ -371,7 +384,7 @@ const buildFetchRequest = (input: { · 44 unmodified lines
371384 headers.cookie = cookieParts.join("; ");
372385 }
373386
374− let body: string | undefined;
387+ let body: string | Uint8Array | undefined;
375388 const bodyValues = input.credentialPlacements.bodyValues ?? {};
376389 const hasCredentialBodyValues = Object.keys(bodyValues).length > 0;
377390
@@ -394,7 +407,7 @@ const buildFetchRequest = (input: { · 16 unmodified lines
394407 }),
395408 });
396409
397− body = serializedBody.bodyText;
410+ body = serializedBody.body;
398411 headers["content-type"] = serializedBody.contentType;
399412 }
400413 }
@@ -490,11 +503,18 @@ export const createOpenApiToolFromDefinition = ( · 89 unmodified lines
490503 });
491504
492505 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+ );
498518 }
499519
500520 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
44 applyJsonBodyPlacements,
55 } from "@executor/codemode-core";
66 import {
7+ httpBodyModeFromContentType,
78 serializeOpenApiParameterValue,
89 serializeOpenApiRequestBody,
910 withSerializedQueryEntries,
@@ -78,7 +79,7 @@ type HttpInvocationRequestPlan = { · 68 unmodified lines
7879 method: HttpExecutable["method"];
7980 url: string;
8081 headers: Record<string, string>;
81− body?: string;
82+ body?: string | Uint8Array;
8283 };
8384
8485 type GraphqlInvocationRequestPlan = {
@@ -112,13 +113,17 @@ type IrInvocationRequestPlan = · 27 unmodified lines
112113 | McpInvocationRequestPlan;
113114
114115 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− }
119116 if (response.status === 204) {
120117 return null;
121118 }
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+ }
122127 return response.text();
123128 };
124129
@@ -337,7 +342,7 @@ const planHttpInvocation = (input: { · 212 unmodified lines
337342 const bodySymbol = input.executable.requestBodyId
338343 ? input.catalog.symbols[input.executable.requestBodyId]
339344 : undefined;
340− let body: string | undefined;
345+ let body: string | Uint8Array | undefined;
341346 if (bodySymbol?.kind === "requestBody") {
342347 const bodyValue = argsRecord.body ?? argsRecord.input;
343348 if (bodyValue !== undefined) {
@@ -353,7 +358,7 @@ const planHttpInvocation = (input: { · 9 unmodified lines
353358 }),
354359 });
355360 headers["content-type"] = serializedBody.contentType;
356− body = serializedBody.bodyText;
361+ body = serializedBody.body;
357362 }
358363 }
359364
packages/control-plane/src/runtime/source-adapter-fixture-matrix.test.ts+155−0
@@ -1,6 +1,13 @@
11 import { readFileSync } from "node:fs";
22 import { createServer } from "node:http";
33
4+import {
5+ HttpApi,
6+ HttpApiEndpoint,
7+ HttpApiGroup,
8+ HttpApiSchema,
9+ OpenApi,
10+} from "@effect/platform";
411 import {
512 buildOpenApiToolPresentation,
613 compileOpenApiToolDefinitions,
@@ -26,6 +33,7 @@ import { · 19 unmodified lines
2633 WorkspaceIdSchema,
2734 } from "#schema";
2835 import * as Effect from "effect/Effect";
36+import { Schema } from "effect";
2937
3038 import { projectCatalogForAgentSdk } from "../ir/catalog";
3139 import type { CatalogSnapshotV1 } from "../ir/model";
@@ -290,6 +298,19 @@ const graphqlSnapshotFromFixture = (input: { · 258 unmodified lines
290298 };
291299 });
292300
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+
293314 describe("source adapter fixture matrix", () => {
294315 it.effect(
295316 "imports the raw recorded Vercel OpenAPI spec into IR and discover projections",
@@ -732,6 +753,140 @@ describe("source adapter fixture matrix", () => { · 436 unmodified lines
732753 120_000,
733754 );
734755
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+
735890 it.effect(
736891 "imports the raw recorded Google Sheets discovery document into IR and discover projections",
737892 () =>
packages/control-plane/src/runtime/source-catalog-snapshot.test.ts+126−0
@@ -559,6 +559,132 @@ describe("source-catalog-snapshot", () => { · 558 unmodified lines
559559 });
560560 });
561561
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+
562688 it("deduplicates structurally identical JSON schema shapes across operations", () => {
563689 const repeatedObjectSchema = {
564690 type: "object",
packages/control-plane/src/runtime/source-catalog-snapshot.ts+3−0
@@ -1843,6 +1843,7 @@ const createHttpCapabilityFromGoogleDiscovery = (input: { · 1842 unmodified lines
18431843 schemaShapeId: input.importer.importSchema(
18441844 parameterSchema,
18451845 `#/googleDiscovery/${input.operation.providerData.toolId}/parameter/${parameter.location}/${parameter.name}`,
1846+ inputSchema,
18461847 ),
18471848 synthetic: false,
18481849 provenance: provenanceFor(
@@ -1867,6 +1868,7 @@ const createHttpCapabilityFromGoogleDiscovery = (input: { · 18 unmodified lines
18671868 shapeId: input.importer.importSchema(
18681869 requestBodySchema,
18691870 `#/googleDiscovery/${input.operation.providerData.toolId}/requestBody`,
1871+ inputSchema,
18701872 ),
18711873 }],
18721874 synthetic: false,
@@ -1894,6 +1896,7 @@ const createHttpCapabilityFromGoogleDiscovery = (input: { · 21 unmodified lines
18941896 shapeId: input.importer.importSchema(
18951897 outputSchema,
18961898 `#/googleDiscovery/${input.operation.providerData.toolId}/response`,
1899+ outputSchema,
18971900 ),
18981901 }],
18991902 }

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