252 lines · 8.2 KB · TypeScript
RawDownload
import { Effect, Match, Option } from "effect"; import { GraphqlExtractionError } from "./errors";import type {  IntrospectionResult,  IntrospectionSchema,  IntrospectionType,  IntrospectionTypeRef,  IntrospectionInputValue,} from "./introspect";import {  ExtractedField,  ExtractionResult,  GraphqlArgument,  type GraphqlOperationKind,} from "./types"; // ---------------------------------------------------------------------------// Type ref helpers// --------------------------------------------------------------------------- /** Unwrap NON_NULL / LIST wrappers to get the leaf type name */const unwrapTypeName = (ref: IntrospectionTypeRef): string => {  if (ref.name) return ref.name;  if (ref.ofType) return unwrapTypeName(ref.ofType);  return "Unknown";}; /** Check if a type ref is non-null (required) */const isNonNull = (ref: IntrospectionTypeRef): boolean => ref.kind === "NON_NULL"; // ---------------------------------------------------------------------------// Build shared definitions from all INPUT_OBJECT and ENUM types// --------------------------------------------------------------------------- const buildDefinitions = (  types: ReadonlyMap<string, IntrospectionType>,): Record<string, unknown> => {  const defs: Record<string, unknown> = {};   for (const [name, type] of types) {    // Skip internal types    if (name.startsWith("__")) continue;     if (type.kind === "INPUT_OBJECT" && type.inputFields) {      const properties: Record<string, unknown> = {};      const required: string[] = [];       for (const field of type.inputFields) {        const schema = typeRefToJsonSchema(field.type, types);        if (field.description) {          (schema as Record<string, unknown>).description = field.description;        }        properties[field.name] = schema;        if (isNonNull(field.type)) {          required.push(field.name);        }      }       const def: Record<string, unknown> = { type: "object", properties };      if (required.length > 0) def.required = required;      if (type.description) def.description = type.description;      defs[name] = def;    }     if (type.kind === "ENUM" && type.enumValues) {      defs[name] = {        type: "string",        enum: type.enumValues.map((v) => v.name),        ...(type.description ? { description: type.description } : {}),      };    }  }   return defs;}; // ---------------------------------------------------------------------------// Convert a type ref to JSON Schema using $ref for complex types// --------------------------------------------------------------------------- const typeRefToJsonSchema = (  ref: IntrospectionTypeRef,  // oxlint-disable-next-line only-used-in-recursion  types: ReadonlyMap<string, IntrospectionType>,): Record<string, unknown> =>  Match.value(ref.kind).pipe(    Match.when(      "NON_NULL",      (): Record<string, unknown> => (ref.ofType ? typeRefToJsonSchema(ref.ofType, types) : {}),    ),    Match.when(      "LIST",      (): Record<string, unknown> => ({        type: "array",        items: ref.ofType ? typeRefToJsonSchema(ref.ofType, types) : {},      }),    ),    Match.when("SCALAR", (): Record<string, unknown> => scalarToJsonSchema(ref.name ?? "String")),    Match.when(      "ENUM",      (): Record<string, unknown> =>        ref.name ? { $ref: `#/$defs/${ref.name}` } : { type: "string" },    ),    Match.when(      "INPUT_OBJECT",      (): Record<string, unknown> =>        ref.name ? { $ref: `#/$defs/${ref.name}` } : { type: "object" },    ),    Match.whenOr(      "OBJECT",      "INTERFACE",      "UNION",      (): Record<string, unknown> => ({ type: "object" }),    ),    Match.option,    Option.getOrElse((): Record<string, unknown> => ({})),  ); const scalarToJsonSchema = (name: string): Record<string, unknown> =>  Match.value(name).pipe(    Match.whenOr("String", "ID", (): Record<string, unknown> => ({ type: "string" })),    Match.when("Int", (): Record<string, unknown> => ({ type: "integer" })),    Match.when("Float", (): Record<string, unknown> => ({ type: "number" })),    Match.when("Boolean", (): Record<string, unknown> => ({ type: "boolean" })),    Match.option,    Option.getOrElse(      (): Record<string, unknown> => ({ type: "string", description: `Custom scalar: ${name}` }),    ),  ); // ---------------------------------------------------------------------------// Build input JSON Schema from field arguments// --------------------------------------------------------------------------- const buildInputSchema = (  args: readonly IntrospectionInputValue[],  types: ReadonlyMap<string, IntrospectionType>,): Record<string, unknown> | undefined => {  if (args.length === 0) return undefined;   const properties: Record<string, unknown> = {};  const required: string[] = [];   for (const arg of args) {    const schema = typeRefToJsonSchema(arg.type, types);    if (arg.description) {      (schema as Record<string, unknown>).description = arg.description;    }    properties[arg.name] = schema;    if (isNonNull(arg.type)) {      required.push(arg.name);    }  }   const inputSchema: Record<string, unknown> = {    type: "object",    properties,  };  if (required.length > 0) inputSchema.required = required;  return inputSchema;}; /** Format a type ref back to GraphQL type notation (e.g. "[String!]!") */const formatTypeRef = (ref: IntrospectionTypeRef): string =>  Match.value(ref.kind).pipe(    Match.when("NON_NULL", () => (ref.ofType ? `${formatTypeRef(ref.ofType)}!` : "Unknown!")),    Match.when("LIST", () => (ref.ofType ? `[${formatTypeRef(ref.ofType)}]` : "[Unknown]")),    Match.option,    Option.getOrElse(() => ref.name ?? "Unknown"),  ); // ---------------------------------------------------------------------------// Extract fields from schema// --------------------------------------------------------------------------- const extractFields = (  _schema: IntrospectionSchema,  kind: GraphqlOperationKind,  typeName: string | null | undefined,  types: ReadonlyMap<string, IntrospectionType>,): ExtractedField[] => {  if (!typeName) return [];   const type = types.get(typeName);  if (!type?.fields) return [];   return type.fields    .filter((f) => !f.name.startsWith("__"))    .map((field) => {      const args = field.args.map((arg) =>        GraphqlArgument.make({          name: arg.name,          typeName: formatTypeRef(arg.type),          required: isNonNull(arg.type),          description: arg.description ? Option.some(arg.description) : Option.none(),        }),      );       const inputSchema = buildInputSchema(field.args, types);       return ExtractedField.make({        fieldName: field.name,        kind,        description: field.description ? Option.some(field.description) : Option.none(),        arguments: args,        inputSchema: inputSchema ? Option.some(inputSchema) : Option.none(),        returnTypeName: unwrapTypeName(field.type),      });    });}; // ---------------------------------------------------------------------------// Public API// --------------------------------------------------------------------------- export interface ExtractionOutput {  readonly result: ExtractionResult;  /** Shared JSON Schema definitions for INPUT_OBJECT and ENUM types.   *  Tool input schemas use `$ref` pointers into these. */  readonly definitions: Record<string, unknown>;} export const extract = (  introspection: IntrospectionResult,): Effect.Effect<ExtractionOutput, GraphqlExtractionError> =>  Effect.try({    try: () => {      const schema = introspection.__schema;      const typeMap = new Map<string, IntrospectionType>();      for (const t of schema.types) {        typeMap.set(t.name, t);      }       const definitions = buildDefinitions(typeMap);       const queryFields = extractFields(schema, "query", schema.queryType?.name, typeMap);      const mutationFields = extractFields(schema, "mutation", schema.mutationType?.name, typeMap);       return {        result: ExtractionResult.make({          schemaName: Option.none(),          fields: [...queryFields, ...mutationFields],        }),        definitions,      };    },    catch: () =>      new GraphqlExtractionError({        message: "Failed to extract GraphQL schema",      }),  });