diff --git a/packages/fmlrunner-mcp/src/index.ts b/packages/fmlrunner-mcp/src/index.ts index 6d676e1..dbebcfa 100644 --- a/packages/fmlrunner-mcp/src/index.ts +++ b/packages/fmlrunner-mcp/src/index.ts @@ -312,6 +312,20 @@ export class FmlRunnerMcp { }, required: ['valueSetRef', 'code'] } + }, + { + name: 'validate-fml-syntax', + description: 'Validate FML (FHIR Mapping Language) syntax without compiling to StructureMap', + inputSchema: { + type: 'object', + properties: { + fmlContent: { + type: 'string', + description: 'FML content to validate for syntax errors' + } + }, + required: ['fmlContent'] + } } ] }; @@ -349,6 +363,9 @@ export class FmlRunnerMcp { case 'validate-code': return await this.handleValidateCode(args); + case 'validate-fml-syntax': + return await this.handleValidateFmlSyntax(args); + default: throw new Error(`Unknown tool: ${name}`); } @@ -585,6 +602,54 @@ export class FmlRunnerMcp { }; } + private async handleValidateFmlSyntax(args: any): Promise { + const { fmlContent } = args; + + const result = this.fmlRunner.validateFmlSyntax(fmlContent); + + if (!result.success) { + return { + content: [ + { + type: 'text', + text: `Validation service error: ${result.errors?.join(', ') || 'Unknown error'}` + } + ], + isError: true + }; + } + + if (result.valid) { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + valid: true, + message: 'FML syntax is valid' + }, null, 2) + } + ] + }; + } else { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + valid: false, + errors: result.errors || [], + warnings: result.warnings || [], + lineNumber: result.lineNumber, + columnNumber: result.columnNumber, + errorLocation: result.errorLocation + }, null, 2) + } + ] + }; + } + } + /** * Start the MCP server */ diff --git a/packages/fmlrunner-rest/openapi.yaml b/packages/fmlrunner-rest/openapi.yaml index b038b48..ef146f4 100644 --- a/packages/fmlrunner-rest/openapi.yaml +++ b/packages/fmlrunner-rest/openapi.yaml @@ -256,6 +256,82 @@ paths: schema: $ref: '#/components/schemas/OperationOutcome' + /validate-fml-syntax: + post: + summary: Validate FML syntax + description: | + Validate the syntax of FHIR Mapping Language (FML) content without compiling to StructureMap. + Returns detailed error information including line numbers and error locations for invalid syntax. + tags: + - Validation + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + fmlContent: + type: string + description: FML content to validate for syntax errors + example: | + map "http://example.org/TestMapping" = "TestMapping" + + uses "http://hl7.org/fhir/StructureDefinition/QuestionnaireResponse" alias QR as source + uses "http://hl7.org/fhir/StructureDefinition/Patient" alias Patient as target + + group QuestionnaireResponse(source src : QR, target tgt : Patient) { + src.item as item -> tgt.gender = 'unknown'; + } + required: + - fmlContent + responses: + '200': + description: FML syntax is valid + content: + application/json: + schema: + $ref: '#/components/schemas/OperationOutcome' + example: + resourceType: OperationOutcome + issue: + - severity: information + code: informational + diagnostics: FML syntax is valid + '400': + description: FML syntax is invalid or request is malformed + content: + application/json: + schema: + $ref: '#/components/schemas/OperationOutcome' + examples: + syntax_error: + summary: Syntax validation error + value: + resourceType: OperationOutcome + issue: + - severity: error + code: invariant + diagnostics: "FML content must start with a map declaration (e.g., \"map \\\"url\\\" = \\\"name\\\")" + location: + - "Beginning of file" + expression: + - "line 1" + missing_content: + summary: Missing content error + value: + resourceType: OperationOutcome + issue: + - severity: error + code: invalid + diagnostics: fmlContent is required in request body + '500': + description: Internal server error during validation + content: + application/json: + schema: + $ref: '#/components/schemas/OperationOutcome' + components: schemas: StructureMap: @@ -517,4 +593,6 @@ tags: - name: StructureMap Operations description: FHIR StructureMap operations - name: Bundle - description: FHIR Bundle processing \ No newline at end of file + description: FHIR Bundle processing + - name: Validation + description: FML syntax and content validation \ No newline at end of file diff --git a/packages/fmlrunner-rest/src/api.ts b/packages/fmlrunner-rest/src/api.ts index 59e5146..4e268b0 100644 --- a/packages/fmlrunner-rest/src/api.ts +++ b/packages/fmlrunner-rest/src/api.ts @@ -90,6 +90,9 @@ export class FmlRunnerApi { // Validation endpoint apiRouter.post('/validate', this.validateResource.bind(this)); + // FML syntax validation endpoint + apiRouter.post('/validate-fml-syntax', this.validateFmlSyntax.bind(this)); + // Health check endpoint apiRouter.get('/health', this.healthCheck.bind(this)); @@ -743,6 +746,81 @@ export class FmlRunnerApi { } } + /** + * Validate FML syntax + */ + private async validateFmlSyntax(req: Request, res: Response): Promise { + try { + const { fmlContent } = req.body; + + if (!fmlContent) { + res.status(400).json({ + resourceType: 'OperationOutcome', + issue: [{ + severity: 'error', + code: 'invalid', + diagnostics: 'fmlContent is required in request body' + }] + }); + return; + } + + const validationResult = this.fmlRunner.validateFmlSyntax(fmlContent); + + if (!validationResult.success) { + res.status(500).json({ + resourceType: 'OperationOutcome', + issue: [{ + severity: 'error', + code: 'exception', + diagnostics: validationResult.errors?.join(', ') || 'Validation service failed' + }] + }); + return; + } + + if (validationResult.valid) { + res.json({ + resourceType: 'OperationOutcome', + issue: [{ + severity: 'information', + code: 'informational', + diagnostics: 'FML syntax is valid' + }] + }); + } else { + const issues = (validationResult.errors || []).map(error => ({ + severity: 'error' as const, + code: 'invariant' as const, + diagnostics: error, + location: validationResult.errorLocation ? [validationResult.errorLocation] : undefined, + expression: validationResult.lineNumber ? [`line ${validationResult.lineNumber}`] : undefined + })); + + // Add warnings if any + const warningIssues = (validationResult.warnings || []).map(warning => ({ + severity: 'warning' as const, + code: 'invariant' as const, + diagnostics: warning + })); + + res.status(400).json({ + resourceType: 'OperationOutcome', + issue: [...issues, ...warningIssues] + }); + } + } catch (error) { + res.status(500).json({ + resourceType: 'OperationOutcome', + issue: [{ + severity: 'error', + code: 'exception', + diagnostics: error instanceof Error ? error.message : 'Unknown error during FML syntax validation' + }] + }); + } + } + /** * Health check endpoint */ diff --git a/packages/fmlrunner/src/index.ts b/packages/fmlrunner/src/index.ts index ff3abd4..7c696d3 100644 --- a/packages/fmlrunner/src/index.ts +++ b/packages/fmlrunner/src/index.ts @@ -11,6 +11,7 @@ import { SchemaValidator } from './lib/schema-validator'; import { StructureMap, FmlCompilationResult, + FmlSyntaxValidationResult, ExecutionResult, EnhancedExecutionResult, ExecutionOptions, @@ -121,6 +122,23 @@ export class FmlRunner { return result; } + /** + * Validate FML syntax without generating StructureMap + */ + validateFmlSyntax(fmlContent: string): FmlSyntaxValidationResult { + this.logger.debug('Validating FML syntax', { contentLength: fmlContent.length }); + + const result = this.compiler.validateSyntax(fmlContent); + + this.logger.info('FML syntax validation completed', { + success: result.success, + valid: result.valid, + errorCount: result.errors?.length || 0 + }); + + return result; + } + /** * Execute StructureMap on input content with validation */ diff --git a/packages/fmlrunner/src/lib/fml-compiler.ts b/packages/fmlrunner/src/lib/fml-compiler.ts index 38d75dd..d7f0b38 100644 --- a/packages/fmlrunner/src/lib/fml-compiler.ts +++ b/packages/fmlrunner/src/lib/fml-compiler.ts @@ -1,4 +1,4 @@ -import { StructureMap, FmlCompilationResult, StructureMapGroup, StructureMapGroupInput, StructureMapGroupRule, StructureMapGroupRuleSource, StructureMapGroupRuleTarget } from '../types'; +import { StructureMap, FmlCompilationResult, FmlSyntaxValidationResult, StructureMapGroup, StructureMapGroupInput, StructureMapGroupRule, StructureMapGroupRuleSource, StructureMapGroupRuleTarget } from '../types'; import { Logger } from './logger'; /** @@ -727,6 +727,110 @@ export class FmlCompiler { } } + /** + * Validate FML syntax without generating StructureMap + * @param fmlContent The FML content to validate + * @returns Syntax validation result with detailed error information + */ + validateSyntax(fmlContent: string): FmlSyntaxValidationResult { + try { + // Basic validation + if (!fmlContent || fmlContent.trim().length === 0) { + return { + success: true, + valid: false, + errors: ['FML content cannot be empty'], + lineNumber: 1, + columnNumber: 1 + }; + } + + // Check for basic FML structure - must start with 'map' + const trimmedContent = fmlContent.trim(); + if (!trimmedContent.toLowerCase().startsWith('map ')) { + return { + success: true, + valid: false, + errors: ['FML content must start with a map declaration (e.g., "map \\"url\\" = \\"name\\")'], + lineNumber: 1, + columnNumber: 1, + errorLocation: 'Beginning of file' + }; + } + + // Tokenize the FML content + const tokenizer = new FmlTokenizer(fmlContent); + let tokens; + try { + tokens = tokenizer.tokenize(); + } catch (tokenError) { + const error = tokenError instanceof Error ? tokenError : new Error('Tokenization failed'); + return { + success: true, + valid: false, + errors: [`Tokenization error: ${error.message}`], + lineNumber: 1, + columnNumber: 1, + errorLocation: 'During tokenization' + }; + } + + // Parse tokens for syntax validation + const parser = new FmlParser(tokens); + try { + // Attempt to parse - this will throw if syntax is invalid + parser.parse(); + + return { + success: true, + valid: true, + errors: [], + warnings: [] + }; + } catch (parseError) { + const error = parseError instanceof Error ? parseError : new Error('Parse error'); + let lineNumber = 1; + let columnNumber = 1; + let errorLocation = 'Unknown location'; + + // Extract line/column information from error message if available + const errorMessage = error.message; + const lineMatch = errorMessage.match(/line (\d+)/); + const columnMatch = errorMessage.match(/column (\d+)/); + + if (lineMatch) { + lineNumber = parseInt(lineMatch[1], 10); + } + if (columnMatch) { + columnNumber = parseInt(columnMatch[1], 10); + } + + // Try to extract the location context from the error + if (errorMessage.includes('Expected') || errorMessage.includes('Got')) { + errorLocation = `Line ${lineNumber}, Column ${columnNumber}`; + } + + return { + success: true, + valid: false, + errors: [errorMessage], + lineNumber, + columnNumber, + errorLocation + }; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown validation error'; + this.logger.error('FML syntax validation failed', { error: errorMessage }); + + return { + success: false, + valid: false, + errors: [`Validation failed: ${errorMessage}`] + }; + } + } + /** * Legacy method for backwards compatibility - now uses the new parser * @deprecated Use compile() method instead diff --git a/packages/fmlrunner/src/types/index.ts b/packages/fmlrunner/src/types/index.ts index d03bbf7..b3ea2fa 100644 --- a/packages/fmlrunner/src/types/index.ts +++ b/packages/fmlrunner/src/types/index.ts @@ -63,6 +63,19 @@ export interface FmlCompilationResult { errors?: string[]; } +/** + * FML syntax validation result + */ +export interface FmlSyntaxValidationResult { + success: boolean; + valid: boolean; + errors?: string[]; + warnings?: string[]; + lineNumber?: number; + columnNumber?: number; + errorLocation?: string; +} + /** * StructureMap execution result */