402 lines · 13.3 KB · TypeScript
RawDownload
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<string, string>,  queryParams?: Record<string, string>,) {  const client = yield* HttpClient.HttpClient;  // Hand `post` a URL OBJECT rather than a string, deliberately.  //  // `HttpClientRequest.setUrl` keeps a string verbatim as `request.url`, and  // every `HttpClientError` renders `${method} ${request.url}` into its  // `message` getter. The `query` carrier is a supported credential placement,  // so an endpoint reached with `?token=…` put that secret inside the error  // message — and the `Effect.logError(…, cause)` below writes the message  // straight to the log on any transport failure or non-JSON response.  //  // Given a URL object, `setUrl` moves the query into `request.urlParams` and  // clears it from `request.url`, so the same failure logs the bare endpoint.  // The query still reaches the upstream: the client recombines url + urlParams  // when it executes the request. Handling the endpoint's OWN query the same  // way (not just the `queryParams` argument) matters — a configured endpoint  // can carry a credential in its query string too.  //  // The split is NOT byte-transparent, and deliberately so. Recombination  // appends each pair through `URLSearchParams`, whose form-urlencoded  // serializer writes a space as `+` rather than `%20`, percent-encodes `~` and  // `!'()`, and gives a valueless `?flag` a trailing `=`. Key order, duplicate  // keys and already-encoded reserved characters survive unchanged. No request  // shape avoids this: the raw query bytes only survive inside `request.url`,  // which is the one field every error message renders, so byte-transparency  // and keeping the credential out of the log cannot both hold. Log safety  // wins. `introspect-request-url.test.ts` pins the exact resulting URLs.  //  // An endpoint that does not parse has no such split available: it would go  // verbatim into `request.url` — query, credential and all — and `queryParams`  // could not be applied to it at all. Reject it instead of dialing a request  // that silently omits the credential the caller asked us to send. The  // endpoint is deliberately left out of the message, since it may be carrying  // the secret.  if (!URL.canParse(endpoint)) {    return yield* new GraphqlIntrospectionError({      message: "GraphQL endpoint is not a valid URL",      reason: "invalid-endpoint",    });  }   const requestUrl = new URL(endpoint);   // Userinfo (`https://user:pass@host/…`) is a credential placement with no  // split of its own: `URL` keeps it in the origin, so it stays in  // `request.url` and renders into every `HttpClientError` message exactly the  // way a query-carried secret used to. Reject it rather than log it, with the  // same constant message that echoes no part of the endpoint.  if (requestUrl.username !== "" || requestUrl.password !== "") {    return yield* new GraphqlIntrospectionError({      message: "GraphQL endpoint must not embed credentials in the URL",      reason: "invalid-endpoint",    });  }   for (const [name, value] of Object.entries(queryParams ?? {})) {    requestUrl.searchParams.set(name, value);  }   let request = HttpClientRequest.post(requestUrl).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",          reason: "network",        }),    ),  );   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}`,      status: response.status,      reason: "http",      ...(upstreamMessage ? { upstreamMessage } : {}),    });  }   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`,          status: response.status,          reason: "invalid-json",        }),    ),  );   const json = yield* Schema.decodeUnknownEffect(IntrospectionResponseSchema)(raw).pipe(    Effect.mapError(      () =>        new GraphqlIntrospectionError({          message: "Introspection response has an invalid shape",          status: response.status,          reason: "invalid-shape",        }),    ),  );   if (json.errors && Array.isArray(json.errors) && json.errors.length > 0) {    const upstreamMessage = upstreamTextMessage(firstUpstreamErrorMessage(json) ?? "");    return yield* new GraphqlIntrospectionError({      message: upstreamMessage        ? `Introspection returned ${json.errors.length} error(s): ${upstreamMessage}`        : `Introspection returned ${json.errors.length} error(s)`,      status: response.status,      reason: "graphql-errors",      ...(upstreamMessage ? { upstreamMessage } : {}),    });  }   if (!json.data?.__schema) {    return yield* new GraphqlIntrospectionError({      message: "Introspection response missing __schema",      status: response.status,      reason: "missing-schema",    });  }   return json.data;}); // ---------------------------------------------------------------------------// Parse an introspection result from a JSON string (for offline/text input)// --------------------------------------------------------------------------- export const parseIntrospectionJson = (  text: string,): Effect.Effect<IntrospectionResult, GraphqlIntrospectionError> =>  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",        }),    ),  );