UNPKG

graphql-mandatory-validator

Version:

A GraphQL schema validator using AST-only parsing for mandatory fields with default values, array validation, and composite type validation

751 lines 37.8 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.GraphQLValidator = void 0; const child_process_1 = require("child_process"); const path = __importStar(require("path")); const fs = __importStar(require("fs")); const graphql_1 = require("graphql"); const parser_1 = require("graphql/language/parser"); /** * Parse GraphQL file contents using Parser class */ function parseGraphqlFile(graphqlFileContents) { const parser = new parser_1.Parser(graphqlFileContents); return parser.parseDocument(); } class GraphQLValidator { constructor(options = {}) { this.options = { baseDir: options.baseDir || 'src/type-defs', scalarDefaults: options.scalarDefaults || { String: '@defaultValue(value: "")', Int: '@defaultValue(value: 0)', Float: '@defaultValue(value: 0.0)', Boolean: '@defaultValue(value: false)', ID: '@defaultValue(value: "")', }, colorOutput: options.colorOutput !== false, exitOnError: options.exitOnError !== false, }; } /** * Get staged .graphql files from git */ getStagedGraphqlFiles() { try { const output = (0, child_process_1.execSync)('git diff --cached --name-only', { encoding: 'utf-8' }); return output .split('\n') .filter((file) => file.trim() && file.endsWith('.graphql')) .filter((file) => file.includes(this.options.baseDir)) .map((file) => path.resolve(file)); } catch (error) { console.warn('Warning: Could not get staged files from git. Not in a git repository?'); return []; } } /** * Get all .graphql files in the base directory */ getAllGraphqlFiles(projectRoot = process.cwd()) { try { const searchDir = path.join(projectRoot, this.options.baseDir); const output = (0, child_process_1.execSync)(`find "${searchDir}" -name "*.graphql" -type f`, { encoding: 'utf-8' }); return output .split('\n') .filter((file) => file.trim()) .map((file) => path.resolve(file)); } catch (error) { console.warn(`Warning: Could not find GraphQL files in ${this.options.baseDir}`); return []; } } /** * Validate newly added mandatory fields in staged files */ async validateStagedFiles() { const graphqlFiles = this.getStagedGraphqlFiles(); if (graphqlFiles.length === 0) { console.log('No staged .graphql files to check.'); return { success: true, errors: [], filesChecked: 0 }; } return this.validateFiles(graphqlFiles, true); } /** * Validate all GraphQL files in the project */ async validateProject(projectRoot = process.cwd()) { const graphqlFiles = this.getAllGraphqlFiles(projectRoot); if (graphqlFiles.length === 0) { console.log(`No .graphql files found in ${this.options.baseDir}`); return { success: true, errors: [], filesChecked: 0 }; } return this.validateFiles(graphqlFiles, false); } /** * Parse git diff to extract added lines with correct line numbers */ parseGitDiffForAddedLines(diff) { const lines = diff.split('\n'); const addedLines = []; let currentLineNumber = 0; for (const line of lines) { if (line.startsWith('@@')) { // Parse hunk header to get starting line number // Format: @@ -oldStart,oldCount +newStart,newCount @@ const parts = line.split(' '); if (parts.length >= 3 && parts[2].startsWith('+')) { const newStartPart = parts[2].substring(1); // Remove '+' prefix const newStart = newStartPart.includes(',') ? parseInt(newStartPart.split(',')[0], 10) : parseInt(newStartPart, 10); if (!isNaN(newStart)) { currentLineNumber = newStart - 1; // -1 because we increment before processing } } } else if (line.startsWith('+') && !line.startsWith('+++')) { // This is an added line currentLineNumber++; addedLines.push({ content: line.slice(1), // Remove the '+' prefix lineNumber: currentLineNumber }); } else if (!line.startsWith('-') && !line.startsWith('\\')) { // This is a context line (unchanged), increment line number currentLineNumber++; } // For deleted lines (start with '-'), we don't increment currentLineNumber } return addedLines; } /** * Parse GraphQL schema from files to understand type definitions * For enum validation, we need to parse ALL files in the project to get complete enum registry */ parseGraphQLSchema(files) { const schemaContext = { types: new Map(), enums: new Map(), mandatoryCompositeFields: [] }; // Get all GraphQL files in the project for complete enum registry const allGraphQLFiles = this.getAllGraphqlFiles(); // First pass: Parse ALL files to build complete enum registry allGraphQLFiles.forEach(file => { try { const content = fs.readFileSync(file, 'utf-8'); this.parseEnumsFromFile(content, file, schemaContext); } catch (error) { console.warn(`Warning: Could not parse enums from ${file}:`, error); } }); // Second pass: Parse only the files being validated for types and fields files.forEach(file => { try { const content = fs.readFileSync(file, 'utf-8'); this.parseFileSchema(content, file, schemaContext); } catch (error) { console.warn(`Warning: Could not parse schema from ${file}:`, error); } }); return schemaContext; } /** * Parse enums from a single GraphQL file to build complete enum registry */ parseEnumsFromFile(content, file, schemaContext) { try { const document = parseGraphqlFile(content); // Only collect enum definitions document.definitions.forEach(definition => { if (definition.kind === graphql_1.Kind.ENUM_TYPE_DEFINITION) { const enumDef = definition; const enumName = enumDef.name.value; const enumValues = enumDef.values?.map(v => v.name.value) || []; schemaContext.enums.set(enumName, enumValues); } }); } catch (error) { // Silently skip files that can't be parsed for enums // This ensures we don't break validation for files with syntax errors } } /** * Parse a single GraphQL file using GraphQL AST parser */ parseFileSchema(content, file, schemaContext) { try { // Parse type definitions (enums are already parsed in first pass) const document = parseGraphqlFile(content); const lines = content.split('\n'); // Process type definitions (both regular types and extended types) document.definitions.forEach(definition => { if (definition.kind === graphql_1.Kind.OBJECT_TYPE_DEFINITION || definition.kind === graphql_1.Kind.OBJECT_TYPE_EXTENSION) { const typeDef = definition; const typeName = typeDef.name.value; const fields = []; if (typeDef.fields) { typeDef.fields.forEach(fieldDef => { const fieldName = fieldDef.name.value; const fieldType = this.extractTypeFromAST(fieldDef.type); const isMandatory = fieldDef.type.kind === graphql_1.Kind.NON_NULL_TYPE; // Check for @defaultValue directive - handle both inline and on next line let hasDefaultValue = false; let defaultValue; // Check for directive on the field definition if (fieldDef.directives) { const defaultDirective = fieldDef.directives.find(directive => directive.name.value === 'defaultValue'); if (defaultDirective) { hasDefaultValue = true; const valueArg = defaultDirective.arguments?.find(arg => arg.name.value === 'value'); if (valueArg) { if (valueArg.value.kind === graphql_1.Kind.STRING) { defaultValue = valueArg.value.value; } else if (valueArg.value.kind === graphql_1.Kind.LIST) { // Handle array default values like @defaultValue(value: []) defaultValue = '[]'; } else if (valueArg.value.kind === graphql_1.Kind.ENUM) { // Handle unquoted enum values like @defaultValue(value: True) defaultValue = valueArg.value.value; } } } } // We now rely solely on AST parsing for directives // No regex-based fallback for directive detection const fieldLine = this.getLineNumberFromLocation(fieldDef.loc, lines); const field = { name: fieldName, type: fieldType, isMandatory, hasDefaultValue, defaultValue, line: fieldLine, astNode: fieldDef }; fields.push(field); // Track mandatory composite fields (non-scalar types) if (isMandatory && !this.options.scalarDefaults[fieldType]) { schemaContext.mandatoryCompositeFields.push({ fieldName, fieldType, file, line: fieldLine }); } }); } const typeStartLine = this.getLineNumberFromLocation(typeDef.loc, lines); // For extend types, merge with existing type if it exists, otherwise create new const existingType = schemaContext.types.get(typeName); if (existingType && definition.kind === graphql_1.Kind.OBJECT_TYPE_EXTENSION) { // Merge fields from extension into existing type existingType.fields.push(...fields); // Update mandatory composite fields tracking fields.forEach(field => { if (field.isMandatory && !this.options.scalarDefaults[field.type]) { schemaContext.mandatoryCompositeFields.push({ fieldName: field.name, fieldType: field.type, file, line: field.line }); } }); } else { // Create new type entry const graphqlType = { name: typeName, fields, file, startLine: typeStartLine, astNode: typeDef }; schemaContext.types.set(typeName, graphqlType); } } }); } catch (error) { console.warn(`Warning: Could not parse GraphQL file ${file}:`, error); // Fallback to the old regex-based parsing if AST parsing fails this.parseFileSchemeFallback(content, file, schemaContext); } } /** * Extract type name from GraphQL AST type node */ extractTypeFromAST(typeNode) { if (typeNode.kind === graphql_1.Kind.NON_NULL_TYPE) { return this.extractTypeFromAST(typeNode.type); } if (typeNode.kind === graphql_1.Kind.LIST_TYPE) { return `[${this.extractTypeFromAST(typeNode.type)}]`; } if (typeNode.kind === graphql_1.Kind.NAMED_TYPE) { return typeNode.name.value; } return 'Unknown'; } /** * Get line number from GraphQL AST location */ getLineNumberFromLocation(loc, lines) { if (!loc) return 1; return loc.startToken.line; } /** * Check if a type is an array/list type based on AST parsing */ isArrayType(type) { return type.startsWith('[') && type.endsWith(']'); } /** * Check if a string is a valid GraphQL identifier without using regex */ isValidIdentifier(identifier) { if (!identifier || identifier.length === 0) return false; // First character must be letter or underscore const firstChar = identifier.charAt(0); if (!((firstChar >= 'A' && firstChar <= 'Z') || (firstChar >= 'a' && firstChar <= 'z') || firstChar === '_')) { return false; } // Remaining characters must be letters, digits, or underscores for (let i = 1; i < identifier.length; i++) { const char = identifier.charAt(i); if (!((char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char === '_')) { return false; } } return true; } /** * Fallback AST-based parsing for when primary AST parsing fails * This is a simplified version that only extracts basic type information */ parseFileSchemeFallback(content, file, schemaContext) { try { // Try to parse with AST again but with more lenient error handling const document = parseGraphqlFile(content); const lines = content.split('\n'); // Enums are already parsed in the first pass, skip enum processing here // Process type definitions (both regular types and extended types) document.definitions.forEach(definition => { if (definition.kind === graphql_1.Kind.OBJECT_TYPE_DEFINITION || definition.kind === graphql_1.Kind.OBJECT_TYPE_EXTENSION) { const typeDef = definition; const typeName = typeDef.name.value; const fields = []; if (typeDef.fields) { typeDef.fields.forEach(fieldDef => { const fieldName = fieldDef.name.value; const fieldType = this.extractTypeFromAST(fieldDef.type); const isMandatory = fieldDef.type.kind === graphql_1.Kind.NON_NULL_TYPE; // Check for @defaultValue directive - AST only let hasDefaultValue = false; let defaultValue; if (fieldDef.directives) { const defaultDirective = fieldDef.directives.find(d => d.name.value === 'defaultValue'); if (defaultDirective) { hasDefaultValue = true; const valueArg = defaultDirective.arguments?.find(arg => arg.name.value === 'value'); if (valueArg) { if (valueArg.value.kind === graphql_1.Kind.STRING) { defaultValue = valueArg.value.value; } else if (valueArg.value.kind === graphql_1.Kind.LIST) { defaultValue = '[]'; } } } } const fieldLine = this.getLineNumberFromLocation(fieldDef.loc, lines); fields.push({ name: fieldName, type: fieldType, isMandatory, hasDefaultValue, defaultValue, line: fieldLine, astNode: fieldDef }); // Track mandatory composite fields (non-scalar types) if (isMandatory && !this.options.scalarDefaults[fieldType]) { schemaContext.mandatoryCompositeFields.push({ fieldName, fieldType, file, line: fieldLine }); } }); } const typeStartLine = this.getLineNumberFromLocation(typeDef.loc, lines); // For extend types, merge with existing type if it exists, otherwise create new const existingType = schemaContext.types.get(typeName); if (existingType && definition.kind === graphql_1.Kind.OBJECT_TYPE_EXTENSION) { // Merge fields from extension into existing type existingType.fields.push(...fields); } else { // Create new type entry schemaContext.types.set(typeName, { name: typeName, fields, file, startLine: typeStartLine, astNode: typeDef }); } } }); } catch (error) { console.warn(`Warning: Complete fallback parsing failed for ${file}:`, error); // If even the fallback AST parsing fails, we'll have an empty schema context for this file } } /** * Validate composite fields using AST-based approach with line filtering */ validateCompositeFields(schemaContext, linesToValidate, onlyDiffChanges) { const errors = []; schemaContext.types.forEach((graphqlType) => { graphqlType.fields.forEach((field) => { const fieldKey = `${graphqlType.file}:${field.line}`; // Skip this field if we're in staged mode and this line wasn't added if (onlyDiffChanges && !linesToValidate.has(fieldKey)) { return; } if (field.isMandatory) { // Check if this is a composite type (not a scalar, not a list) const isScalar = !!this.options.scalarDefaults[field.type]; const isListType = field.type.startsWith('[') && field.type.endsWith(']'); const isCompositeType = schemaContext.types.has(field.type); if (!isScalar && !isListType && isCompositeType) { const compositeType = schemaContext.types.get(field.type); // Only validate if the composite type is defined in the same file if (compositeType && compositeType.file === graphqlType.file) { const hasMandatoryFields = compositeType.fields.some(f => f.isMandatory); if (!hasMandatoryFields) { errors.push({ file: graphqlType.file, line: field.line, fieldName: field.name, fieldType: field.type, message: `Mandatory composite field "${field.name}" of type "${field.type}!" references a type with no mandatory fields. Type "${field.type}" should have at least one mandatory field.`, errorType: 'empty-composite' }); } } // If composite type is in a different file, skip validation (let it pass) } } }); }); return errors; } /** * Validate that mandatory composite types have at least one mandatory field * Only checks newly added fields when onlyDiffChanges is true */ validateCompositeTypes(schemaContext, files, onlyDiffChanges) { const errors = []; if (onlyDiffChanges) { // Only check newly added mandatory composite fields files.forEach(file => { try { const diff = (0, child_process_1.execSync)(`git diff --cached "${file}"`, { encoding: 'utf-8' }); const addedLines = this.parseGitDiffForAddedLines(diff); addedLines.forEach(({ content, lineNumber }) => { // Check if this line contains a newly added mandatory composite field const trimmedContent = content.trim(); // Parse field definition: fieldName: FieldType! if (trimmedContent.includes(':') && trimmedContent.includes('!')) { const colonIndex = trimmedContent.indexOf(':'); const exclamationIndex = trimmedContent.indexOf('!'); if (colonIndex > 0 && exclamationIndex > colonIndex) { const fieldName = trimmedContent.substring(0, colonIndex).trim(); const typeSection = trimmedContent.substring(colonIndex + 1, exclamationIndex).trim(); const fieldType = typeSection; // Simple validation: field name should be valid identifier, type should not be empty if (fieldName && fieldType && this.isValidIdentifier(fieldName) && fieldType.length > 0) { // Check if this is a composite type (not a scalar) if (!this.options.scalarDefaults[fieldType]) { const compositeType = schemaContext.types.get(fieldType); if (compositeType) { const hasMandatoryFields = compositeType.fields.some(field => field.isMandatory); if (!hasMandatoryFields) { errors.push({ file, line: lineNumber, fieldName, fieldType, message: `Mandatory composite field "${fieldName}" of type "${fieldType}!" references a type with no mandatory fields. Type "${fieldType}" should have at least one mandatory field.`, errorType: 'empty-composite' }); } } } } } } }); } catch (error) { console.error(`Failed to get diff for file ${file}:`, error); } }); } else { // Check all mandatory composite fields (full validation mode) schemaContext.mandatoryCompositeFields.forEach(mandatoryField => { const compositeType = schemaContext.types.get(mandatoryField.fieldType); if (compositeType) { const hasMandatoryFields = compositeType.fields.some(field => field.isMandatory); if (!hasMandatoryFields) { errors.push({ file: mandatoryField.file, line: mandatoryField.line, fieldName: mandatoryField.fieldName, fieldType: mandatoryField.fieldType, message: `Mandatory composite field "${mandatoryField.fieldName}" of type "${mandatoryField.fieldType}!" references a type with no mandatory fields. Type "${mandatoryField.fieldType}" should have at least one mandatory field.`, errorType: 'empty-composite' }); } } }); } return errors; } /** * Validate specific GraphQL files using AST-based approach */ async validateFiles(files, onlyDiffChanges) { const errors = []; // Parse schema to understand type definitions const schemaContext = this.parseGraphQLSchema(files); // Get lines that should be validated (all lines or only added lines) const linesToValidate = new Set(); if (onlyDiffChanges) { // For staged mode, collect all line numbers that were added files.forEach(file => { try { const diff = (0, child_process_1.execSync)(`git diff --cached "${file}"`, { encoding: 'utf-8' }); const addedLines = this.parseGitDiffForAddedLines(diff); addedLines.forEach(({ lineNumber }) => { linesToValidate.add(`${file}:${lineNumber}`); }); } catch (error) { console.warn(`Warning: Could not get git diff for ${file}:`, error); } }); } // Validate composite fields using AST-based approach const compositeErrors = this.validateCompositeFields(schemaContext, linesToValidate, onlyDiffChanges); errors.push(...compositeErrors); // Validate all types and fields using AST data schemaContext.types.forEach((graphqlType) => { graphqlType.fields.forEach((field) => { const fieldKey = `${graphqlType.file}:${field.line}`; // Skip this field if we're in staged mode and this line wasn't added if (onlyDiffChanges && !linesToValidate.has(fieldKey)) { return; } if (field.isMandatory) { const expectedScalarDefault = this.options.scalarDefaults[field.type]; if (expectedScalarDefault) { // Handle scalar types if (!field.hasDefaultValue) { const error = { file: graphqlType.file, line: field.line, fieldName: field.name, fieldType: field.type, expectedDefault: expectedScalarDefault, message: `Mandatory field "${field.name}" of type "${field.type}!" must have a default value ${expectedScalarDefault}`, errorType: 'missing-default' }; errors.push(error); } } else { // Check if it's an array/list type const isArrayType = this.isArrayType(field.type); if (isArrayType) { // Array types require @defaultValue(value: []) for mandatory fields if (!field.hasDefaultValue) { errors.push({ file: graphqlType.file, line: field.line, fieldName: field.name, fieldType: field.type, expectedDefault: '@defaultValue(value: [])', message: `Mandatory array field "${field.name}" of type "${field.type}!" must have a default value @defaultValue(value: [])`, errorType: 'missing-array-default' }); } else if (field.defaultValue !== '[]') { errors.push({ file: graphqlType.file, line: field.line, fieldName: field.name, fieldType: field.type, expectedDefault: '@defaultValue(value: [])', message: `Mandatory array field "${field.name}" of type "${field.type}!" must have default value [] but got "${field.defaultValue}"`, errorType: 'invalid-array-default' }); } return; } // Check if it's a composite type or enum (defined in schema context) const isCompositeType = schemaContext.types.has(field.type); const isEnumType = schemaContext.enums.has(field.type); // For enums, check if the default value is one of the enum values if (isEnumType) { const enumValues = schemaContext.enums.get(field.type) || []; if (field.hasDefaultValue && field.defaultValue && !enumValues.includes(field.defaultValue)) { errors.push({ file: graphqlType.file, line: field.line, fieldName: field.name, fieldType: field.type, message: `Invalid default value "${field.defaultValue}" for enum field "${field.name}" of type "${field.type}!". Must be one of: ${enumValues.join(', ')}`, errorType: 'invalid-enum-default' }); } else if (!field.hasDefaultValue) { errors.push({ file: graphqlType.file, line: field.line, fieldName: field.name, fieldType: field.type, message: `Mandatory enum field "${field.name}" of type "${field.type}!" must have a default value. Must be one of: ${enumValues.join(', ')}`, errorType: 'missing-enum-default' }); } } // For composite types, skip here as they are handled by validateCompositeFields // to avoid duplicate validation // For unknown types (not scalar, not enum, not composite), only validate if no default value // This handles cases where types might be defined in other files or are built-in types else if (!field.hasDefaultValue) { // Check if this looks like a custom type (starts with uppercase) vs built-in type const isCustomType = field.type.length > 0 && field.type.charAt(0) >= 'A' && field.type.charAt(0) <= 'Z'; if (isCustomType) { // This is likely a composite type defined in another file - skip validation // to avoid false positives for cross-file dependencies return; } else { // This might be an unknown scalar or enum type const error = { file: graphqlType.file, line: field.line, fieldName: field.name, fieldType: field.type, message: `Mandatory field "${field.name}" of type "${field.type}!" must have a default value.`, errorType: 'missing-enum-default' }; errors.push(error); } } } } }); }); const result = { success: errors.length === 0, errors, filesChecked: files.length, }; this.reportResults(result); return result; } /** * Report validation results */ reportResults(result) { if (result.success) { const color = this.options.colorOutput ? '\x1b[32;1m' : ''; const reset = this.options.colorOutput ? '\x1b[0m' : ''; console.log(`${color}✓ GraphQL validation passed for ${result.filesChecked} files${reset}`); } else { const errorColor = this.options.colorOutput ? '\x1b[31;1m' : ''; const reset = this.options.colorOutput ? '\x1b[0m' : ''; console.error(`${errorColor}✗ GraphQL validation failed with ${result.errors.length} errors:${reset}`); result.errors.forEach((error) => { console.error(`${errorColor}Error in ${error.file} at line ${error.line}: ${error.message}${reset}`); }); if (this.options.exitOnError) { process.exit(1); } } } /** * Run validation (convenience method for CLI usage) */ async run(mode = 'staged', projectRoot) { if (mode === 'staged') { return this.validateStagedFiles(); } else { return this.validateProject(projectRoot); } } } exports.GraphQLValidator = GraphQLValidator; // Default export for convenience exports.default = GraphQLValidator; //# sourceMappingURL=index.js.map