import { Effect, Option, Schema } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { GraphqlIntrospectionError } from "./errors"; // --------------------------------------------------------------------------- // Introspection query — standard GraphQL introspection // --------------------------------------------------------------------------- const INTROSPECTION_QUERY = ` query IntrospectionQuery { __schema { queryType { name } mutationType { name } types { kind name description fields(includeDeprecated: false) { name description args { name description type { ...TypeRef } defaultValue } type { ...TypeRef } } inputFields { name description type { ...TypeRef } defaultValue } enumValues(includeDeprecated: false) { name description } } } } fragment TypeRef on __Type { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name } } } } } } } `; // --------------------------------------------------------------------------- // Introspection result types // --------------------------------------------------------------------------- const IntrospectionTypeRefLeaf = Schema.Struct({ kind: Schema.String, name: Schema.NullOr(Schema.String), ofType: Schema.optional(Schema.Null), }); const IntrospectionTypeRef5 = Schema.Struct({ kind: Schema.String, name: Schema.NullOr(Schema.String), ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRefLeaf)), }); const IntrospectionTypeRef4 = Schema.Struct({ kind: Schema.String, name: Schema.NullOr(Schema.String), ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef5)), }); const IntrospectionTypeRef3 = Schema.Struct({ kind: Schema.String, name: Schema.NullOr(Schema.String), ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef4)), }); const IntrospectionTypeRef2 = Schema.Struct({ kind: Schema.String, name: Schema.NullOr(Schema.String), ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef3)), }); const IntrospectionTypeRefSchema = Schema.Struct({ kind: Schema.String, name: Schema.NullOr(Schema.String), ofType: Schema.optional(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 UpstreamErrorResponseSchema = Schema.Struct({ message: Schema.optional(Schema.String), errors: Schema.optional( Schema.Array( Schema.Struct({ message: Schema.optional(Schema.String), }), ), ), }); const IntrospectionJsonSchema = Schema.Union([ Schema.Struct({ data: IntrospectionResultSchema }), IntrospectionResultSchema, ]); const JsonTextSchema = Schema.fromJsonString(Schema.Unknown); const decodeUpstreamErrorResponse = Schema.decodeUnknownOption(UpstreamErrorResponseSchema); 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; const firstUpstreamErrorMessage = (value: unknown): string | null => { const decoded = decodeUpstreamErrorResponse(value); return Option.match(decoded, { onNone: () => null, onSome: (response) => { if (response.message) return response.message; for (const entry of response.errors ?? []) { const message = entry.message; if (message) return message; } return null; }, }); }; const redactUpstreamBody = (body: string): string => body .replaceAll( /("(?:access_token|refresh_token|id_token|client_secret|token|authorization)"\s*:\s*")[^"]*(")/gi, "$1[redacted]$2", ) .replaceAll( /((?:access_token|refresh_token|id_token|client_secret|token|authorization)=)[^&\s]*/gi, "$1[redacted]", ) .replaceAll( /((?:authorization|access-token|refresh-token|id-token|client-secret|token)\s*:\s*)(?:bearer\s+)?[^\s,;]+/gi, "$1[redacted]", ); const upstreamTextMessage = (body: string): string | null => { const text = redactUpstreamBody(body.replaceAll(/\s+/g, " ").trim()); if (text.length === 0) return null; return text.length > 500 ? `${text.slice(0, 500)}...` : text; }; // --------------------------------------------------------------------------- // Introspect a GraphQL endpoint // --------------------------------------------------------------------------- export const introspect = Effect.fn("GraphQL.introspect")(function* ( endpoint: string, headers?: Record, queryParams?: Record, ) { const client = yield* HttpClient.HttpClient; const requestEndpoint = queryParams && Object.keys(queryParams).length > 0 ? (() => { const url = new URL(endpoint); for (const [name, value] of Object.entries(queryParams)) { url.searchParams.set(name, value); } return url.toString(); })() : endpoint; let request = HttpClientRequest.post(requestEndpoint).pipe( HttpClientRequest.setHeader("Content-Type", "application/json"), HttpClientRequest.setHeader("Accept", "application/json"), HttpClientRequest.setHeader("User-Agent", "executor-graphql"), HttpClientRequest.bodyJsonUnsafe({ query: INTROSPECTION_QUERY, }), ); if (headers) { for (const [k, v] of Object.entries(headers)) { request = HttpClientRequest.setHeader(request, k, v); } } const response = yield* client.execute(request).pipe( Effect.tapCause((cause) => Effect.logError("graphql introspection request failed", cause)), Effect.mapError( () => new GraphqlIntrospectionError({ message: "Failed to reach GraphQL endpoint", }), ), ); if (response.status !== 200) { const responseText = yield* response.text.pipe(Effect.catch(() => Effect.succeed(""))); const raw = responseText ? yield* Schema.decodeUnknownEffect(JsonTextSchema)(responseText).pipe( Effect.catch(() => Effect.succeed(null)), ) : null; const upstreamMessage = upstreamTextMessage( (raw === null ? null : firstUpstreamErrorMessage(raw)) ?? responseText, ); return yield* new GraphqlIntrospectionError({ message: upstreamMessage ? `Introspection failed with status ${response.status}: ${upstreamMessage}` : `Introspection failed with status ${response.status}`, }); } const raw = yield* response.json.pipe( Effect.tapCause((cause) => Effect.logError("graphql introspection JSON parse failed", cause)), Effect.mapError( () => new GraphqlIntrospectionError({ message: `Failed to parse introspection response as JSON`, }), ), ); 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) { const upstreamMessage = firstUpstreamErrorMessage(json); return yield* new GraphqlIntrospectionError({ message: upstreamMessage ? `Introspection returned ${json.errors.length} error(s): ${upstreamMessage}` : `Introspection returned ${json.errors.length} error(s)`, }); } if (!json.data?.__schema) { return yield* new GraphqlIntrospectionError({ message: "Introspection response missing __schema", }); } return json.data; }); // --------------------------------------------------------------------------- // Parse an introspection result from a JSON string (for offline/text input) // --------------------------------------------------------------------------- export const parseIntrospectionJson = ( text: string, ): Effect.Effect => 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", }), ), );