UNPKG

polen

Version:

A framework for delightful GraphQL developer portals

416 lines (374 loc) 12.5 kB
import { type ArgumentNode, type ASTNode, type DirectiveNode, type DocumentNode, type FieldNode, type FragmentDefinitionNode, type GraphQLError, type GraphQLSchema, type OperationDefinitionNode, parse, validate, type VariableDefinitionNode, visit, } from 'graphql' import type { AnalysisConfig, AnalysisError, AnalysisResult, GraphQLAnalyzer, Identifier, IdentifierContext, IdentifierMap, } from './types.js' /** * Default GraphQL document analyzer implementation */ export class DefaultGraphQLAnalyzer implements GraphQLAnalyzer { /** * Parse a GraphQL document string into an AST */ parse(source: string): DocumentNode { try { return parse(source, { noLocation: false, // We need location info for positioning }) } catch (error) { throw new Error(`Failed to parse GraphQL document: ${error instanceof Error ? error.message : `Unknown error`}`) } } /** * Validate a GraphQL document against a schema */ validateAgainstSchema(ast: DocumentNode, schema: GraphQLSchema): GraphQLError[] { return [...validate(schema, ast)] } /** * Extract all identifiers from a GraphQL AST */ extractIdentifiers(ast: DocumentNode, config: AnalysisConfig = {}): IdentifierMap { const identifiers: Identifier[] = [] const errors: AnalysisError[] = [] const schema = config.schema // Context tracking during traversal let currentOperationType: `query` | `mutation` | `subscription` | undefined let currentOperationName: string | undefined let currentFragment: string | undefined let selectionPath: string[] = [] let parentTypes: string[] = [] // Helper function to create context const createContext = (): IdentifierContext => { return { operationType: currentOperationType, operationName: currentOperationName, inFragment: currentFragment, selectionPath: [...selectionPath], } } visit(ast, { OperationDefinition: { enter: (node: OperationDefinitionNode) => { currentOperationType = node.operation currentOperationName = node.name?.value selectionPath = [] // Set the root type based on operation type if (schema) { if (node.operation === `query` && schema.getQueryType()) { parentTypes = [schema.getQueryType()!.name] } else if (node.operation === `mutation` && schema.getMutationType()) { parentTypes = [schema.getMutationType()!.name] } else if (node.operation === `subscription` && schema.getSubscriptionType()) { parentTypes = [schema.getSubscriptionType()!.name] } else { parentTypes = [] } } else { // Fallback to default root type names if (node.operation === `query`) { parentTypes = [`Query`] } else if (node.operation === `mutation`) { parentTypes = [`Mutation`] } else if (node.operation === `subscription`) { parentTypes = [`Subscription`] } else { parentTypes = [] } } }, leave: () => { currentOperationType = undefined currentOperationName = undefined parentTypes = [] }, }, FragmentDefinition: { enter: (node: FragmentDefinitionNode) => { currentFragment = node.name.value selectionPath = [] parentTypes = [node.typeCondition.name.value] // Add fragment name as identifier this.addIdentifier(identifiers, { name: node.name.value, kind: `Fragment`, position: this.getPosition(node.name), schemaPath: [node.name.value], context: createContext(), }) // Add type condition as identifier this.addIdentifier(identifiers, { name: node.typeCondition.name.value, kind: `Type`, position: this.getPosition(node.typeCondition.name), schemaPath: [node.typeCondition.name.value], context: createContext(), }) }, leave: () => { currentFragment = undefined parentTypes = [] }, }, Field: { enter: (node: FieldNode) => { const fieldName = node.name.value const parentType = parentTypes[parentTypes.length - 1] selectionPath.push(fieldName) this.addIdentifier(identifiers, { name: fieldName, kind: `Field`, position: this.getPosition(node.name), parentType, schemaPath: parentType ? [parentType, fieldName] : [fieldName], context: createContext(), }) // Track parent type for nested selections // Resolve the field's return type from schema if available let pushedType = false if (schema && parentType) { const type = schema.getType(parentType) if (type && (`getFields` in type)) { const fields = (type as any).getFields() const field = fields[fieldName] if (field) { // Get the base type name (unwrap NonNull and List wrappers) let fieldType = field.type while (fieldType.ofType) { fieldType = fieldType.ofType } if (fieldType.name) { parentTypes.push(fieldType.name) pushedType = true } } } } else if (this.isObjectField(fieldName)) { // Fallback to inference if no schema const inferredType = this.inferReturnType(parentType, fieldName) parentTypes.push(inferredType) pushedType = true } // Store whether we pushed a type for this field ;(node as any)._pushedType = pushedType }, leave: (node: FieldNode) => { selectionPath.pop() // Remove parent type if we added one in enter if ((node as any)._pushedType) { parentTypes.pop() } }, }, Argument: { enter: (node: ArgumentNode) => { const argName = node.name.value const parentType = parentTypes[parentTypes.length - 1] const fieldName = selectionPath[selectionPath.length - 1] this.addIdentifier(identifiers, { name: argName, kind: `Argument`, position: this.getPosition(node.name), parentType, schemaPath: parentType && fieldName ? [parentType, fieldName, argName] : [argName], context: createContext(), }) }, }, VariableDefinition: { enter: (node: VariableDefinitionNode) => { this.addIdentifier(identifiers, { name: node.variable.name.value, kind: `Variable`, position: this.getPosition(node.variable.name), schemaPath: [node.variable.name.value], context: createContext(), }) // Also add the type reference const typeName = this.extractTypeName(node.type) if (typeName) { this.addIdentifier(identifiers, { name: typeName, kind: `Type`, position: this.getTypePosition(node.type), schemaPath: [typeName], context: createContext(), }) } }, }, Directive: { enter: (node: DirectiveNode) => { this.addIdentifier(identifiers, { name: node.name.value, kind: `Directive`, position: this.getPosition(node.name), schemaPath: [node.name.value], context: createContext(), }) }, }, }) return this.createIdentifierMap(identifiers, errors) } /** * Perform complete analysis of a GraphQL document */ analyze(source: string, config: AnalysisConfig = {}): AnalysisResult { try { const ast = this.parse(source) const identifiers = this.extractIdentifiers(ast, config) let validationErrors: GraphQLError[] = [] if (config.validateAgainstSchema && config.schema) { validationErrors = this.validateAgainstSchema(ast, config.schema) } return { ast, identifiers, isValid: validationErrors.length === 0, errors: validationErrors, } } catch (error) { return { ast: { kind: `Document`, definitions: [] } as DocumentNode, identifiers: this.createIdentifierMap([], [{ message: error instanceof Error ? error.message : `Unknown parsing error`, severity: `error`, }]), isValid: false, errors: [], } } } // Private helper methods private addIdentifier(identifiers: Identifier[], identifier: Identifier): void { identifiers.push(identifier) } private getPosition(node: ASTNode): Identifier[`position`] { const loc = node.loc if (!loc) { return { start: 0, end: 0, line: 1, column: 1 } } return { start: loc.start, end: loc.end, line: loc.startToken.line, column: loc.startToken.column, } } private getTypePosition(node: ASTNode): Identifier[`position`] { // For type nodes, we need to extract the base type name position // This is a simplified implementation return this.getPosition(node) } private extractTypeName(typeNode: any): string | null { // Recursively extract the base type name from wrapped types (NonNull, List) if (typeNode.kind === `NamedType`) { return typeNode.name.value } if (typeNode.kind === `NonNullType` || typeNode.kind === `ListType`) { return this.extractTypeName(typeNode.type) } return null } private isObjectField(fieldName: string): boolean { // This is only used as a fallback when no schema is available // Common patterns for object-returning fields const objectFieldPatterns = [ `user`, `users`, `post`, `posts`, `comment`, `comments`, `profile`, `settings`, `organization`, `project`, `target`, `member`, `members`, `node`, `nodes`, `edge`, `edges`, ] return objectFieldPatterns.some(pattern => fieldName.toLowerCase().includes(pattern)) } private inferReturnType(parentType: string | undefined, fieldName: string): string { // Simplified type inference - in real implementation would use schema if (fieldName === `user`) return `User` if (fieldName === `posts`) return `Post` if (fieldName === `comments`) return `Comment` return fieldName.charAt(0).toUpperCase() + fieldName.slice(1) } private createContext( operationType?: `query` | `mutation` | `subscription`, operationName?: string, inFragment?: string, selectionPath: string[] = [], ): IdentifierContext { return { operationType, operationName, inFragment, selectionPath: [...selectionPath], } } private createIdentifierMap(identifiers: Identifier[], errors: AnalysisError[]): IdentifierMap { const byPosition = new Map<number, Identifier>() const byKind = new Map<Identifier[`kind`], Identifier[]>() for (const identifier of identifiers) { // Index by position byPosition.set(identifier.position.start, identifier) // Group by kind if (!byKind.has(identifier.kind)) { byKind.set(identifier.kind, []) } byKind.get(identifier.kind)!.push(identifier) } return { byPosition, byKind, errors, all: identifiers, } } } /** * Default analyzer instance */ export const analyzer = new DefaultGraphQLAnalyzer() /** * Convenience function to analyze a GraphQL document */ export const analyze = (source: string, config?: AnalysisConfig): AnalysisResult => { return analyzer.analyze(source, config) } /** * Convenience function to extract identifiers from a GraphQL document */ export const extractIdentifiers = (source: string, config?: AnalysisConfig): IdentifierMap => { const ast = analyzer.parse(source) return analyzer.extractIdentifiers(ast, config) }