From 05698a4311d69e8dbac51da45e09283256b593ef Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 5 May 2026 19:29:30 -0700 Subject: [PATCH] Use typed plugin invocation boundaries --- .../google-discovery/src/sdk/invoke.ts | 55 +++--- .../plugins/graphql/src/sdk/introspect.ts | 175 +++++++++++------- packages/plugins/graphql/src/sdk/invoke.ts | 11 +- 3 files changed, 144 insertions(+), 97 deletions(-) diff --git a/packages/plugins/google-discovery/src/sdk/invoke.ts b/packages/plugins/google-discovery/src/sdk/invoke.ts index cac95144d..8291e1957 100644 --- a/packages/plugins/google-discovery/src/sdk/invoke.ts +++ b/packages/plugins/google-discovery/src/sdk/invoke.ts @@ -42,24 +42,33 @@ const stringValuesFromParameter = (value: unknown, repeated: boolean): string[] return [JSON.stringify(value)]; }; -const replacePathParameters = (input: { +const replacePathParameters = Effect.fn("GoogleDiscovery.replacePathParameters")(function* (input: { pathTemplate: string; args: Record; parameters: readonly GoogleDiscoveryParameter[]; -}): string => - input.pathTemplate.replaceAll(/\{([^}]+)\}/g, (_, name: string) => { +}) { + let resolved = input.pathTemplate; + for (const match of input.pathTemplate.matchAll(/\{([^}]+)\}/g)) { + const placeholder = match[0]; + const name = match[1]!; const parameter = input.parameters.find( (entry) => entry.location === "path" && entry.name === name, ); const values = stringValuesFromParameter(input.args[name], false); if (values.length === 0) { if (parameter?.required) { - throw new Error(`Missing required path parameter: ${name}`); + return yield* new GoogleDiscoveryInvocationError({ + message: `Missing required path parameter: ${name}`, + statusCode: Option.none(), + }); } - return ""; + resolved = resolved.replaceAll(placeholder, ""); + } else { + resolved = resolved.replaceAll(placeholder, encodeURIComponent(values[0]!)); } - return encodeURIComponent(values[0]!); - }); + } + return resolved; +}); const resolveBaseUrl = (source: GoogleDiscoveryStoredSourceData): string => new URL(source.servicePath || "", source.rootUrl).toString(); @@ -87,7 +96,7 @@ const performRequest = Effect.fn("GoogleDiscovery.invoke")(function* (input: { }) { const client = yield* HttpClient.HttpClient; - const resolvedPath = replacePathParameters({ + const resolvedPath = yield* replacePathParameters({ pathTemplate: input.pathTemplate, args: input.args, parameters: input.parameters, @@ -138,7 +147,7 @@ const performRequest = Effect.fn("GoogleDiscovery.invoke")(function* (input: { Effect.mapError( (err) => new GoogleDiscoveryInvocationError({ - message: `HTTP request failed: ${err.message}`, + message: "HTTP request failed", statusCode: Option.none(), cause: err, }), @@ -147,9 +156,9 @@ const performRequest = Effect.fn("GoogleDiscovery.invoke")(function* (input: { const contentType = response.headers["content-type"] ?? null; const mapBodyError = Effect.mapError( - (err: { readonly message?: string }) => + (err) => new GoogleDiscoveryInvocationError({ - message: `Failed to read response body: ${err.message ?? String(err)}`, + message: "Failed to read response body", statusCode: Option.some(response.status), cause: err, }), @@ -191,21 +200,17 @@ export const invokeGoogleDiscoveryTool = (input: { Effect.gen(function* () { const entry = yield* input.ctx.storage.getBinding(input.toolId, input.toolScope); if (!entry) { - return yield* Effect.fail( - new GoogleDiscoveryInvocationError({ - message: `No Google Discovery operation found for tool "${input.toolId}"`, - statusCode: Option.none(), - }), - ); + return yield* new GoogleDiscoveryInvocationError({ + message: `No Google Discovery operation found for tool "${input.toolId}"`, + statusCode: Option.none(), + }); } const stored = yield* input.ctx.storage.getSource(entry.namespace, input.toolScope); if (!stored) { - return yield* Effect.fail( - new GoogleDiscoveryInvocationError({ - message: `No Google Discovery source found for "${entry.namespace}"`, - statusCode: Option.none(), - }), - ); + return yield* new GoogleDiscoveryInvocationError({ + message: `No Google Discovery source found for "${entry.namespace}"`, + statusCode: Option.none(), + }); } const source = stored.config; @@ -213,9 +218,9 @@ export const invokeGoogleDiscoveryTool = (input: { source.auth.kind === "oauth2" ? `Bearer ${yield* input.ctx.connections.accessToken(source.auth.connectionId).pipe( Effect.mapError( - (err) => + () => new GoogleDiscoveryOAuthError({ - message: "message" in err ? (err as { message: string }).message : String(err), + message: "Failed to resolve Google Discovery OAuth access token", }), ), )}` diff --git a/packages/plugins/graphql/src/sdk/introspect.ts b/packages/plugins/graphql/src/sdk/introspect.ts index 589ceafd7..0ccbe6a56 100644 --- a/packages/plugins/graphql/src/sdk/introspect.ts +++ b/packages/plugins/graphql/src/sdk/introspect.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, Schema } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { GraphqlIntrospectionError } from "./errors"; @@ -81,49 +81,99 @@ const INTROSPECTION_QUERY = ` // Introspection result types // --------------------------------------------------------------------------- -export interface IntrospectionTypeRef { - readonly kind: string; - readonly name: string | null; - readonly ofType: IntrospectionTypeRef | null; -} - -export interface IntrospectionInputValue { - readonly name: string; - readonly description: string | null; - readonly type: IntrospectionTypeRef; - readonly defaultValue: string | null; -} - -export interface IntrospectionField { - readonly name: string; - readonly description: string | null; - readonly args: readonly IntrospectionInputValue[]; - readonly type: IntrospectionTypeRef; -} - -export interface IntrospectionEnumValue { - readonly name: string; - readonly description: string | null; -} - -export interface IntrospectionType { - readonly kind: string; - readonly name: string; - readonly description: string | null; - readonly fields: readonly IntrospectionField[] | null; - readonly inputFields: readonly IntrospectionInputValue[] | null; - readonly enumValues: readonly IntrospectionEnumValue[] | null; -} - -export interface IntrospectionSchema { - readonly queryType: { readonly name: string } | null; - readonly mutationType: { readonly name: string } | null; - readonly types: readonly IntrospectionType[]; -} - -export interface IntrospectionResult { - readonly __schema: IntrospectionSchema; -} +const IntrospectionTypeRefLeaf = Schema.Struct({ + kind: Schema.String, + name: Schema.NullOr(Schema.String), + ofType: Schema.Null, +}); + +const IntrospectionTypeRef5 = Schema.Struct({ + kind: Schema.String, + name: Schema.NullOr(Schema.String), + ofType: Schema.NullOr(IntrospectionTypeRefLeaf), +}); + +const IntrospectionTypeRef4 = Schema.Struct({ + kind: Schema.String, + name: Schema.NullOr(Schema.String), + ofType: Schema.NullOr(IntrospectionTypeRef5), +}); + +const IntrospectionTypeRef3 = Schema.Struct({ + kind: Schema.String, + name: Schema.NullOr(Schema.String), + ofType: Schema.NullOr(IntrospectionTypeRef4), +}); + +const IntrospectionTypeRef2 = Schema.Struct({ + kind: Schema.String, + name: Schema.NullOr(Schema.String), + ofType: Schema.NullOr(IntrospectionTypeRef3), +}); + +const IntrospectionTypeRefSchema = Schema.Struct({ + kind: Schema.String, + name: Schema.NullOr(Schema.String), + ofType: Schema.NullOr(IntrospectionTypeRef2), +}); + +const IntrospectionInputValueSchema = Schema.Struct({ + name: Schema.String, + description: Schema.NullOr(Schema.String), + type: IntrospectionTypeRefSchema, + defaultValue: Schema.NullOr(Schema.String), +}); + +const IntrospectionFieldSchema = Schema.Struct({ + name: Schema.String, + description: Schema.NullOr(Schema.String), + args: Schema.Array(IntrospectionInputValueSchema), + type: IntrospectionTypeRefSchema, +}); + +const IntrospectionTypeSchema = Schema.Struct({ + kind: Schema.String, + name: Schema.String, + description: Schema.NullOr(Schema.String), + fields: Schema.NullOr(Schema.Array(IntrospectionFieldSchema)), + inputFields: Schema.NullOr(Schema.Array(IntrospectionInputValueSchema)), + enumValues: Schema.NullOr( + Schema.Array( + Schema.Struct({ + name: Schema.String, + description: Schema.NullOr(Schema.String), + }), + ), + ), +}); + +const IntrospectionResultSchema = Schema.Struct({ + __schema: Schema.Struct({ + queryType: Schema.NullOr(Schema.Struct({ name: Schema.String })), + mutationType: Schema.NullOr(Schema.Struct({ name: Schema.String })), + types: Schema.Array(IntrospectionTypeSchema), + }), +}); + +const IntrospectionResponseSchema = Schema.Struct({ + data: Schema.optional(IntrospectionResultSchema), + errors: Schema.optional(Schema.Array(Schema.Unknown)), +}); + +const IntrospectionJsonSchema = Schema.Union([ + Schema.Struct({ data: IntrospectionResultSchema }), + IntrospectionResultSchema, +]); + +export type IntrospectionTypeRef = typeof IntrospectionTypeRefSchema.Type; +export type IntrospectionInputValue = typeof IntrospectionInputValueSchema.Type; +export type IntrospectionField = typeof IntrospectionFieldSchema.Type; +export type IntrospectionEnumValue = NonNullable< + (typeof IntrospectionTypeSchema.Type)["enumValues"] +>[number]; +export type IntrospectionType = typeof IntrospectionTypeSchema.Type; +export type IntrospectionSchema = (typeof IntrospectionResultSchema.Type)["__schema"]; +export type IntrospectionResult = typeof IntrospectionResultSchema.Type; // --------------------------------------------------------------------------- // Introspect a GraphQL endpoint @@ -162,9 +212,9 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( const response = yield* client.execute(request).pipe( Effect.tapCause((cause) => Effect.logError("graphql introspection request failed", cause)), Effect.mapError( - (err) => + () => new GraphqlIntrospectionError({ - message: `Failed to reach GraphQL endpoint: ${err.message}`, + message: "Failed to reach GraphQL endpoint", }), ), ); @@ -187,7 +237,14 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( ), ); - const json = raw as { data?: IntrospectionResult; errors?: unknown[] }; + const json = yield* Schema.decodeUnknownEffect(IntrospectionResponseSchema)(raw).pipe( + Effect.mapError( + () => + new GraphqlIntrospectionError({ + message: "Introspection response has an invalid shape", + }), + ), + ); if (json.errors && Array.isArray(json.errors) && json.errors.length > 0) { return yield* new GraphqlIntrospectionError({ @@ -211,18 +268,12 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( export const parseIntrospectionJson = ( text: string, ): Effect.Effect => - Effect.try({ - try: () => { - const parsed = JSON.parse(text); - // Accept both { data: { __schema } } and { __schema } formats - const result = parsed.data ?? parsed; - if (!result.__schema) { - throw new Error("Missing __schema in introspection JSON"); - } - return result as IntrospectionResult; - }, - catch: (err) => - new GraphqlIntrospectionError({ - message: `Failed to parse introspection JSON: ${err instanceof Error ? err.message : String(err)}`, - }), - }); + Schema.decodeUnknownEffect(Schema.fromJsonString(IntrospectionJsonSchema))(text).pipe( + Effect.map((parsed) => ("data" in parsed ? parsed.data : parsed)), + Effect.mapError( + () => + new GraphqlIntrospectionError({ + message: "Failed to parse introspection JSON", + }), + ), + ); diff --git a/packages/plugins/graphql/src/sdk/invoke.ts b/packages/plugins/graphql/src/sdk/invoke.ts index c8242453e..eb1fa3ee4 100644 --- a/packages/plugins/graphql/src/sdk/invoke.ts +++ b/packages/plugins/graphql/src/sdk/invoke.ts @@ -114,7 +114,7 @@ export const invoke = Effect.fn("GraphQL.invoke")(function* ( Effect.mapError( (err) => new GraphqlInvocationError({ - message: `GraphQL request failed: ${err.message}`, + message: "GraphQL request failed", statusCode: Option.none(), cause: err, }), @@ -159,15 +159,6 @@ export const invokeWithLayer = ( ) => invoke(operation, args, endpoint, resolvedHeaders, resolvedQueryParams).pipe( Effect.provide(httpClientLayer), - Effect.mapError((err) => - err instanceof GraphqlInvocationError - ? err - : new GraphqlInvocationError({ - message: err instanceof Error ? err.message : String(err), - statusCode: Option.none(), - cause: err, - }), - ), Effect.withSpan("plugin.graphql.invoke", { attributes: { "plugin.graphql.endpoint": endpoint,