UNPKG

graphql-mandatory-validator

Version:

A GraphQL schema validator for mandatory fields with default values and composite type validation

661 lines 32 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 match = line.match(/@@ -\d+,?\d* \+(\d+),?\d* @@/); if (match) { currentLineNumber = parseInt(match[1], 10) - 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 */ parseGraphQLSchema(files) { const schemaContext = { types: new Map(), enums: new Map(), mandatoryCompositeFields: [] }; 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 a single GraphQL file using GraphQL AST parser */ parseFileSchema(content, file, schemaContext) { try { // First pass: collect all enum definitions using Parser class const document = parseGraphqlFile(content); const lines = content.split('\n'); // First, collect all 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); } }); // Second pass: process type definitions document.definitions.forEach(definition => { if (definition.kind === graphql_1.Kind.OBJECT_TYPE_DEFINITION) { 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 && valueArg.value.kind === graphql_1.Kind.STRING) { defaultValue = valueArg.value.value; } } } // If no directive found on the field, check the next lines in the source if (!hasDefaultValue && fieldDef.loc) { const startLine = fieldDef.loc.startToken.line - 1; // Convert to 0-based index const endLine = Math.min(startLine + 3, lines.length); // Check next 3 lines for (let i = startLine; i < endLine; i++) { const line = lines[i]; if (line && line.includes('@defaultValue')) { const match = line.match(/@defaultValue\(\s*value\s*:\s*"([^"]+)"\s*\)/); if (match) { hasDefaultValue = true; defaultValue = match[1]; break; } } } } 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); 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; } /** * Fallback regex-based parsing for when AST parsing fails */ parseFileSchemeFallback(content, file, schemaContext) { const lines = content.split('\n'); let currentType = null; let inTypeDefinition = false; const saveCurrentType = () => { if (currentType) { schemaContext.types.set(currentType.name, currentType); } }; lines.forEach((line, index) => { const lineNumber = index + 1; const trimmedLine = line.trim(); // Start of type definition const typeMatch = trimmedLine.match(/^type\s+(\w+)\s*\{?/); if (typeMatch) { saveCurrentType(); currentType = { name: typeMatch[1], fields: [], file, startLine: lineNumber }; inTypeDefinition = true; return; } // End of type definition if (trimmedLine === '}' && inTypeDefinition) { saveCurrentType(); currentType = null; inTypeDefinition = false; return; } // Field definition within a type if (inTypeDefinition && currentType && trimmedLine) { const fieldMatch = trimmedLine.match(/(\w+):\s*(\w+)(!)?(.*)/); if (fieldMatch) { const fieldName = fieldMatch[1]; const fieldType = fieldMatch[2]; const isMandatory = !!fieldMatch[3]; const rest = fieldMatch[4] || ''; // Check for @defaultValue on same line or next line let hasDefaultValue = rest.includes('@defaultValue'); let defaultValue; if (hasDefaultValue) { const match = rest.match(/@defaultValue\(\s*value\s*:\s*"([^"]+)"\s*\)/); if (match) { defaultValue = match[1]; } } else if (index + 1 < lines.length) { // Check next line const nextLine = lines[index + 1]; if (nextLine && nextLine.includes('@defaultValue')) { const match = nextLine.match(/@defaultValue\(\s*value\s*:\s*"([^"]+)"\s*\)/); if (match) { hasDefaultValue = true; defaultValue = match[1]; } } } const field = { name: fieldName, type: fieldType, isMandatory, hasDefaultValue, defaultValue, line: lineNumber }; currentType.fields.push(field); // Track mandatory composite fields (non-scalar types) if (isMandatory && !this.options.scalarDefaults[fieldType]) { schemaContext.mandatoryCompositeFields.push({ fieldName, fieldType, file, line: lineNumber }); } } } }); // Save the last type if any saveCurrentType(); } /** * 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(); const mandatoryFieldMatch = trimmedContent.match(/(\w+): (\w+)!/); if (mandatoryFieldMatch) { const fieldName = mandatoryFieldMatch[1]; const fieldType = mandatoryFieldMatch[2]; // 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 a list type const isListType = field.type.startsWith('[') && field.type.endsWith(']'); if (isListType) { // List types don't need default values - skip validation 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 defined in the same file, validate them else if (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) } // 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 = /^[A-Z]/.test(field.type); 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