/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ /* eslint-disable @typescript-eslint/no-explicit-any -- graphiti traverses untyped schema/introspection JSON; see follow-up to replace with `unknown` + narrowing */ import { type GraphQLSchema, type GraphQLInputObjectType, parse, validate, isInputObjectType, isEnumType, isNonNullType, getNamedType, } from "graphql"; // ── Full query validation ──────────────────────────────────────────────────── export interface ValidationError { message: string; locations?: { line: number; column: number }[]; } /** * Validates a complete GraphQL query string against a schema. * Uses graphql-js's parse() + validate() for comprehensive checking. */ export function validateQuery(schema: GraphQLSchema, queryString: string): ValidationError[] { let document; try { document = parse(queryString); } catch (err: any) { return [{ message: `Parse error: ${err.message}` }]; } const errors = validate(schema, document); return errors.map((e) => ({ message: e.message, locations: e.locations?.map((l) => ({ line: l.line, column: l.column })), })); } // ── Input type validation ──────────────────────────────────────────────────── export interface InputValidationError { path: string; message: string; } /** * Validates a JSON value against a named input type from the schema. * Recursively checks field names, nested types, and basic type compatibility. */ export function validateInputValue( schema: GraphQLSchema, typeName: string, value: unknown, ): InputValidationError[] { const type = schema.getType(typeName); if (!type) { return [{ path: "", message: `Type "${typeName}" not found in schema` }]; } if (!isInputObjectType(type)) { return [ { path: "", message: `"${typeName}" is not an input type (it's a ${type.astNode?.kind ?? "unknown"})`, }, ]; } const errors: InputValidationError[] = []; validateInputObject(schema, type, value, "", errors); return errors; } function validateInputObject( schema: GraphQLSchema, type: GraphQLInputObjectType, value: unknown, path: string, errors: InputValidationError[], ): void { if (typeof value !== "object" || value === null || Array.isArray(value)) { errors.push({ path: path || "(root)", message: `Expected an object for ${type.name}, got ${typeof value}`, }); return; } const fields = type.getFields(); const valueObj = value as Record; for (const key of Object.keys(valueObj)) { if (!fields[key]) { const available = Object.keys(fields).slice(0, 10).join(", "); errors.push({ path: path ? `${path}.${key}` : key, message: `Unknown field "${key}" on ${type.name}. Available: ${available}${Object.keys(fields).length > 10 ? "..." : ""}`, }); } } for (const [fieldName, field] of Object.entries(fields)) { const fieldPath = path ? `${path}.${fieldName}` : fieldName; const fieldValue = valueObj[fieldName]; if (fieldValue === undefined) { if (isNonNullType(field.type) && field.defaultValue === undefined) { errors.push({ path: fieldPath, message: `Required field "${fieldName}" is missing` }); } continue; } const namedType = getNamedType(field.type); if ( namedType && isInputObjectType(namedType) && typeof fieldValue === "object" && fieldValue !== null ) { if (Array.isArray(fieldValue)) { fieldValue.forEach((item, i) => { validateInputObject(schema, namedType, item, `${fieldPath}[${i}]`, errors); }); } else { validateInputObject(schema, namedType, fieldValue, fieldPath, errors); } } else if (namedType && isEnumType(namedType) && typeof fieldValue === "string") { const validValues = namedType.getValues().map((v) => v.name); if (!validValues.includes(fieldValue)) { errors.push({ path: fieldPath, message: `Invalid enum value "${fieldValue}" for ${namedType.name}. Valid: ${validValues.join(", ")}`, }); } } } }