|
1 | | -import { graphql, GraphQLSchema } from 'graphql'; |
2 | | -import { convertFixtureToQuery } from '../utils/convert-fixture-to-query.js'; |
| 1 | +import { visit, DocumentNode, Kind } from "graphql"; |
| 2 | +import { TypeInfo, visitWithTypeInfo, coerceInputValue } from "graphql"; |
| 3 | +import { |
| 4 | + isInputType, |
| 5 | + isListType, |
| 6 | + isNullableType, |
| 7 | + GraphQLSchema, |
| 8 | + getNullableType, |
| 9 | + isAbstractType, |
| 10 | + getNamedType, |
| 11 | +} from "graphql"; |
| 12 | +import { inlineNamedFragmentSpreads } from "../utils/inline-named-fragment-spreads.js"; |
3 | 13 |
|
4 | | -/** |
5 | | - * Interface for validation result |
6 | | - */ |
7 | | -export interface ValidationResult { |
| 14 | +export interface ValidateFixtureInputResult { |
8 | 15 | valid: boolean; |
9 | 16 | errors: string[]; |
10 | | - data: any; |
11 | | - query: string | null; |
12 | 17 | } |
13 | 18 |
|
14 | | -/** |
15 | | - * Validate input fixture data using the original schema with Query root |
16 | | - * |
17 | | - * Since input fixture data represents the result of running an input query |
18 | | - * against the schema, we can validate it by generating a query from the fixture |
19 | | - * structure and executing it against the original schema directly. |
20 | | - * |
21 | | - * This approach is simpler than building reduced schemas because: |
22 | | - * 1. The original schema already has a Query root type |
23 | | - * 2. No need to convert input types to output types |
24 | | - * 3. Uses the full schema context for validation |
25 | | - * 4. Leverages existing resolvers if any |
26 | | - * |
27 | | - * @param {Object} inputFixtureData - The input fixture data to validate |
28 | | - * @param {GraphQLSchema} originalSchema - The original GraphQL schema with Query root |
29 | | - * @returns {Promise<Object>} Validation result with structure: |
30 | | - * - valid: boolean - Whether the fixture data is valid |
31 | | - * - errors: string[] - Array of error messages (empty if valid) |
32 | | - * - data: Object|null - The resulting data from query execution |
33 | | - * - query: string|null - The GraphQL query generated from fixture structure |
34 | | - */ |
| 19 | +export function validateFixtureInput( |
| 20 | + queryAST: DocumentNode, |
| 21 | + schema: GraphQLSchema, |
| 22 | + value: any |
| 23 | +): ValidateFixtureInputResult { |
| 24 | + const inlineFragmentSpreadsAst = inlineNamedFragmentSpreads(queryAST); |
| 25 | + const typeInfo = new TypeInfo(schema); |
| 26 | + const valueStack: any[] = [[value]]; |
| 27 | + const errors: string[] = []; |
| 28 | + visit( |
| 29 | + inlineFragmentSpreadsAst, |
| 30 | + visitWithTypeInfo(typeInfo, { |
| 31 | + Field: { |
| 32 | + enter(node) { |
| 33 | + const currentValues = valueStack[valueStack.length - 1]; |
| 34 | + const nestedValues = []; |
35 | 35 |
|
36 | | -export async function validateFixtureInput( |
37 | | - inputFixtureData: Record<string, any>, |
38 | | - originalSchema: GraphQLSchema |
39 | | -): Promise<ValidationResult> { |
40 | | - try { |
41 | | - // Step 1: Convert fixture data structure to a GraphQL query |
42 | | - // The query directly matches the Input type structure (no field wrapper needed) |
43 | | - const query = convertFixtureToQuery(inputFixtureData, ''); |
44 | | - |
45 | | - // Step 2: Execute the query against the original schema |
46 | | - // The fixture data becomes the root value that resolvers will traverse |
47 | | - const result = await graphql({ |
48 | | - schema: originalSchema, |
49 | | - source: query, |
50 | | - rootValue: inputFixtureData |
51 | | - }); |
| 36 | + const responseKey = node.alias?.value || node.name.value; |
52 | 37 |
|
53 | | - if (result.errors && result.errors.length > 0) { |
54 | | - return { |
55 | | - valid: false, |
56 | | - errors: result.errors.map(err => err.message), |
57 | | - data: result.data || null, |
58 | | - query |
59 | | - }; |
60 | | - } |
| 38 | + const fieldDefinition = typeInfo.getFieldDef(); |
| 39 | + const fieldType = fieldDefinition?.type; |
61 | 40 |
|
62 | | - // If we successfully executed the query and got data back, |
63 | | - // it means the fixture structure matches what the schema expects |
64 | | - return { |
65 | | - valid: true, |
66 | | - errors: [], |
67 | | - data: result.data, |
68 | | - query |
69 | | - }; |
| 41 | + for (const currentValue of currentValues) { |
| 42 | + const valueForResponseKey = currentValue[responseKey]; |
70 | 43 |
|
71 | | - } catch (error) { |
72 | | - const errorMessage = error instanceof Error ? error.message : String(error); |
73 | | - return { |
74 | | - valid: false, |
75 | | - errors: [`Input fixture validation failed: ${errorMessage}`], |
76 | | - data: null, |
77 | | - query: null |
78 | | - }; |
79 | | - } |
80 | | -} |
| 44 | + if (valueForResponseKey === undefined) { |
| 45 | + errors.push(`Missing expected fixture data for ${responseKey}`); |
| 46 | + continue; |
| 47 | + } else if (isInputType(fieldType)) { |
| 48 | + coerceInputValue( |
| 49 | + valueForResponseKey, |
| 50 | + fieldType, |
| 51 | + (path, _invalidValue, error) => { |
| 52 | + errors.push(`${error.message} At "${path.join(".")}"`); |
| 53 | + } |
| 54 | + ); |
| 55 | + } else if ( |
| 56 | + isNullableType(fieldType) && |
| 57 | + valueForResponseKey === null |
| 58 | + ) { |
| 59 | + continue; |
| 60 | + } else if (isListType(getNullableType(fieldType))) { |
| 61 | + if (Array.isArray(valueForResponseKey)) { |
| 62 | + nestedValues.push(...valueForResponseKey); |
| 63 | + } else { |
| 64 | + errors.push( |
| 65 | + `Expected array for ${responseKey}, but got ${typeof valueForResponseKey}` |
| 66 | + ); |
| 67 | + } |
| 68 | + } else { |
| 69 | + if (typeof valueForResponseKey === "object") { |
| 70 | + nestedValues.push(valueForResponseKey); |
| 71 | + } else { |
| 72 | + errors.push( |
| 73 | + `Expected object for ${responseKey}, but got ${typeof valueForResponseKey}` |
| 74 | + ); |
| 75 | + } |
| 76 | + } |
| 77 | + } |
| 78 | + |
| 79 | + valueStack.push(nestedValues); |
| 80 | + }, |
| 81 | + leave() { |
| 82 | + valueStack.pop(); |
| 83 | + }, |
| 84 | + }, |
| 85 | + SelectionSet: { |
| 86 | + enter(node) { |
| 87 | + if (isAbstractType(getNamedType(typeInfo.getType()))) { |
| 88 | + const hasTypename = node.selections.some( |
| 89 | + (selection) => |
| 90 | + selection.kind == Kind.FIELD && |
| 91 | + selection.name.value == "__typename" |
| 92 | + ); |
81 | 93 |
|
| 94 | + const fragmentSpreadCount = node.selections.filter( |
| 95 | + (selection) => |
| 96 | + selection.kind == Kind.FRAGMENT_SPREAD || |
| 97 | + selection.kind == Kind.INLINE_FRAGMENT |
| 98 | + ).length; |
| 99 | + |
| 100 | + if (!hasTypename && fragmentSpreadCount > 1) { |
| 101 | + errors.push( |
| 102 | + `Missing __typename field for abstract type ${getNamedType(typeInfo.getType())?.name}` |
| 103 | + ); |
| 104 | + } |
| 105 | + } |
| 106 | + }, |
| 107 | + }, |
| 108 | + }) |
| 109 | + ); |
| 110 | + return { valid: errors.length === 0, errors }; |
| 111 | +} |
0 commit comments