Skip to content
Merged
2 changes: 1 addition & 1 deletion protographic/src/abstract-selection-rewriter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,7 @@ export class AbstractSelectionRewriter {
// the wrong inline fragments.
const fieldsToAdd = fields
.filter((field) => !existingFields.has(field.name.value))
.filter((field) => inlineFragmentType.getFields()[field.name.value]);
.filter((field) => field.name.value === '__typename' || inlineFragmentType.getFields()[field.name.value]);

// Add the interface fields to the fragment. We always prepend them for now.
// TODO: Check if fields should be inserted in the order of appearance in the selection set.
Expand Down
2 changes: 1 addition & 1 deletion protographic/src/required-fields-visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ export class RequiredFieldsVisitor {
* @throws Error if the field definition is not found on the current type
*/
private onEnterField(ctx: VisitContext<FieldNode>): void {
if (!this.current) {
if (!this.current || ctx.node.name.value === '__typename') {
return;
}

Expand Down
69 changes: 67 additions & 2 deletions protographic/src/sdl-validation-visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@ import {
NamedTypeNode,
GraphQLID,
ConstArgumentNode,
GraphQLObjectType,
GraphQLSchema,
buildASTSchema,
} from 'graphql';
import { CONNECT_FIELD_RESOLVER, CONTEXT } from './string-constants.js';
import { safeParse } from '@wundergraph/composition';
import { CONNECT_FIELD_RESOLVER, CONTEXT, FIELDS, REQUIRES_DIRECTIVE_NAME } from './string-constants.js';
import { SelectionSetValidationVisitor } from './selection-set-validation-visitor.js';

/**
* Type mapping from Kind enum values to their corresponding AST node types
Expand Down Expand Up @@ -89,6 +94,7 @@ export class SDLValidationVisitor {
private readonly validationResult: ValidationResult;
private lintingRules: LintingRule<any>[] = [];
private visitor: ASTVisitor;
private parsedSchema: GraphQLSchema;

/**
* Creates a new SDL validation visitor for the given GraphQL schema
Expand All @@ -101,6 +107,8 @@ export class SDLValidationVisitor {
warnings: [],
};

this.parsedSchema = buildASTSchema(parse(schema), { assumeValid: true, assumeValidSDL: true });

this.initializeLintingRules();
this.visitor = this.createASTVisitor();
}
Expand Down Expand Up @@ -143,7 +151,64 @@ export class SDLValidationVisitor {
validationFunction: (ctx) => this.validateInvalidResolverContext(ctx),
};

this.lintingRules = [objectTypeRule, listTypeRule, providesRule, resolverContextRule];
const compositeTypeReflectionRule: LintingRule<Kind.FIELD_DEFINITION> = {
name: 'use-of-typename',
description: 'Validates usage of __typename field which is not supported',
enabled: true,
nodeKind: Kind.FIELD_DEFINITION,
validationFunction: (ctx) => this.validateCompositeTypeReflection(ctx),
};

this.lintingRules = [objectTypeRule, listTypeRule, providesRule, resolverContextRule, compositeTypeReflectionRule];
}

private validateCompositeTypeReflection(ctx: VisitContext<FieldDefinitionNode>): void {
const directive = ctx.node.directives?.find((directive) => directive.name.value === REQUIRES_DIRECTIVE_NAME);
if (!directive) {
return;
}

const fieldSet = directive.arguments?.find((arg) => arg.name.value === FIELDS);
if (!fieldSet) {
return;
}

if (fieldSet.value.kind !== Kind.STRING) {
this.addError('Invalid @requires directive: fields argument must be a string', fieldSet.loc);
return;
}

const fieldSetValue = fieldSet.value.value;
const { error, documentNode } = safeParse('{' + fieldSetValue + '}');
if (error || !documentNode) {
this.addError('Invalid @requires directive: fields argument must be a valid GraphQL selection set', fieldSet.loc);
return;
}

const parentNode = ctx.ancestors.at(-1);
if (!parentNode || !this.isASTObjectTypeNode(parentNode)) {
this.addError('Invalid @requires directive: fields argument must be a valid GraphQL selection set', fieldSet.loc);
return;
}

// Get the object type from the field set. This is the parent type name of the current field.
Comment thread
endigma marked this conversation as resolved.
Outdated
const visitor = new SelectionSetValidationVisitor(
documentNode,
this.parsedSchema.getType(parentNode.name.value) as GraphQLObjectType,
this.parsedSchema,
false,
);
Comment thread
Noroth marked this conversation as resolved.
Outdated

visitor.visit();

const { errors, warnings } = visitor.getValidationResult();
for (const error of errors) {
this.addError(error, fieldSet.loc);
}

for (const warning of warnings) {
this.addWarning(warning, fieldSet.loc);
}
}

/**
Expand Down
269 changes: 269 additions & 0 deletions protographic/src/selection-set-validation-visitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
import {
ASTNode,
ASTVisitor,
DocumentNode,
FieldNode,
GraphQLField,
GraphQLNamedType,
GraphQLObjectType,
GraphQLSchema,
GraphQLType,
InlineFragmentNode,
isInterfaceType,
isNamedType,
isUnionType,
Kind,
SelectionSetNode,
visit,
print,
} from 'graphql';
import { VisitContext } from './types.js';
import { ValidationResult } from './sdl-validation-visitor.js';
import { AbstractSelectionRewriter } from './abstract-selection-rewriter.js';

/**
* Validates selection sets within @requires directive field sets.
*
* This visitor traverses a parsed field set document and enforces constraints
* specific to @requires directives:
* - Abstract types (interfaces, unions) are not allowed
* - Inline fragments are not allowed
*
* @example
* ```typescript
* const doc = parse('{ address { street city } }');
* const visitor = new SelectionSetValidationVisitor(doc, ProductType);
* visitor.visit();
* const result = visitor.getValidationResult();
* if (result.errors.length > 0) {
* console.error('Validation failed:', result.errors);
* }
* ```
*/
export class SelectionSetValidationVisitor {
private currentType: GraphQLObjectType;
private currentFieldSelectionSet: SelectionSetNode | undefined;
private fieldSelectionSetStack: SelectionSetNode[] = [];
private readonly operationDocument: DocumentNode;

private readonly schema: GraphQLSchema;
private readonly fix: boolean = false;
Comment thread
endigma marked this conversation as resolved.
Outdated

private validationResult: ValidationResult = {
errors: [],
warnings: [],
};

/**
* Creates a new SelectionSetValidationVisitor.
*
* @param operationDocument - The parsed GraphQL document representing the field set
* @param objectType - The root GraphQL object type to validate against
*/
constructor(operationDocument: DocumentNode, objectType: GraphQLObjectType, schema: GraphQLSchema, fix: boolean) {
this.operationDocument = operationDocument;
this.currentType = objectType;
this.schema = schema;
this.fix = fix;

this.normalizeSelectionSet();
}

/**
* Executes the validation by traversing the operation document.
* After calling this method, use `getValidationResult()` to retrieve any errors or warnings.
*/
public visit(): void {
visit(this.operationDocument, this.createASTVisitor());
}

/**
* Returns the validation result containing any errors and warnings found during traversal.
*
* @returns The validation result with errors and warnings arrays
*/
public getValidationResult(): ValidationResult {
return this.validationResult;
}

public getFixedSelection(): string {
return print(this.operationDocument);
}

/**
* Normalizes the parsed field set operation by rewriting abstract selections.
* This ensures consistent handling of interface and union type selections.
*/
private normalizeSelectionSet(): void {
const visitor = new AbstractSelectionRewriter(this.operationDocument, this.schema, this.currentType);
visitor.normalize();
}

/**
* Creates the AST visitor configuration for traversing the document.
*
* @returns An ASTVisitor object with handlers for Field and SelectionSet nodes
*/
private createASTVisitor(): ASTVisitor {
return {
SelectionSet: {
enter: (node, key, parent, path, ancestors) => {
return this.onEnterSelectionSet({ node, key, parent, path, ancestors });
},
leave: (node, key, parent, path, ancestors) => {
this.onLeaveSelectionSet({ node, key, parent, path, ancestors });
},
},
};
}

/**
* Handles entering a selection set node during traversal.
*
* @param ctx - The visit context containing the selection set node and its parent
*/
private onEnterSelectionSet(ctx: VisitContext<SelectionSetNode>): void {
// When we have no parent, we are at the root of the selection set.
if (!ctx.parent) {
return;
}

// We store the stack for field selection sets. We ignore the root selection set and the inline fragments in the stack
if (this.isFieldNode(ctx.parent)) {
this.currentFieldSelectionSet = ctx.node;
this.fieldSelectionSetStack.push(ctx.node);
return;
}

// We currently only check for inline fragments.
if (!this.isInlineFragment(ctx.parent)) {
return;
}

// either the selection set of the inline fragment or the parent selection set must contain __typename.
if (
!this.selectionSetContainsTypename(ctx.node) &&
!this.selectionSetContainsTypename(this.currentFieldSelectionSet)
) {
if (!this.fix) {
this.validationResult.errors.push(
`Selection set must contain __typename for inline fragment ${ctx.parent.typeCondition?.name.value}`,
);
return;
}

this.ensureTypenameInSelection(ctx.node);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private ensureTypenameInSelection(selectionSet: SelectionSetNode): void {
selectionSet.selections = [
Comment thread
endigma marked this conversation as resolved.
Outdated
{
kind: Kind.FIELD,
name: { kind: Kind.NAME, value: '__typename' },
},
...selectionSet.selections,
];
}

private selectionSetContainsTypename(selectionSet: SelectionSetNode | undefined): boolean {
if (!selectionSet) {
return false;
}

return selectionSet.selections.some(
(selection) => selection.kind === Kind.FIELD && selection.name.value === '__typename',
);
}

/**
* Handles leaving a selection set node during traversal.
* Restores the previous type context when ascending back up the tree.
*
* @param ctx - The visit context containing the selection set node and its parent
*/
private onLeaveSelectionSet(ctx: VisitContext<SelectionSetNode>): void {
if (!ctx.parent) {
return;
}

if (this.isFieldNode(ctx.parent)) {
this.currentFieldSelectionSet = this.fieldSelectionSetStack.pop();
}
Comment thread
Noroth marked this conversation as resolved.
}

/**
* Unwraps a GraphQL type to get its underlying named type.
* Strips NonNull and List wrappers to get the base type.
*
* @param type - The GraphQL type to unwrap
* @returns The underlying named type
*/
private getUnderlyingType(type: GraphQLType): GraphQLNamedType {
while (!isNamedType(type)) {
type = type.ofType;
}

return type;
}

/**
* Retrieves the field definition for a field node from the current type.
* If the field is not found, a validation error is recorded and null is returned.
*
* @param node - The field node to look up
* @returns The GraphQL field definition, or null if not found
*/
private getFieldDefinition(node: FieldNode): GraphQLField<any, any> | null {
const fieldDef = this.currentType.getFields()[node.name.value];
if (!fieldDef) {
this.validationResult.errors.push(`Field '${node.name.value}' not found on type '${this.currentType.name}'`);
return null;
}
return fieldDef;
}

/**
* Type guard to check if a node is an InlineFragmentNode.
*
* @param node - The AST node or array of nodes to check
* @returns True if the node is an InlineFragmentNode
*/
private isInlineFragment(node: ASTNode | readonly ASTNode[]): node is InlineFragmentNode {
if (Array.isArray(node)) {
return false;
}

return (node as ASTNode).kind === Kind.INLINE_FRAGMENT;
}

private isSelectionSet(node: ASTNode | ReadonlyArray<ASTNode>): node is SelectionSetNode {
if (Array.isArray(node)) {
return false;
}
return (node as ASTNode).kind === Kind.SELECTION_SET;
}

/**
* Type guard to check if a node is a FieldNode.
*
* @param node - The AST node or array of nodes to check
* @returns True if the node is a FieldNode
*/
private isFieldNode(node: ASTNode | ReadonlyArray<ASTNode>): node is FieldNode {
if (Array.isArray(node)) {
return false;
}
return (node as ASTNode).kind === Kind.FIELD;
}

/**
* Checks if a named type is an abstract type (interface or union).
*
* @param node - The GraphQL named type to check
* @returns True if the type is an interface or union type
*/
private isAbstractType(node: GraphQLNamedType): boolean {
return isInterfaceType(node) || isUnionType(node);
}
}
Loading
Loading