From 83b84eb70882dbe0715c211c4b58f22edfffe2a7 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 5 May 2026 22:38:48 -0700 Subject: [PATCH] Use typed GraphQL invocation boundaries --- .../plugins/graphql/src/sdk/introspect.ts | 175 +++++++++++------- packages/plugins/graphql/src/sdk/invoke.ts | 11 +- 2 files changed, 114 insertions(+), 72 deletions(-) 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,