Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 13 additions & 242 deletions package-lock.json

Large diffs are not rendered by default.

70 changes: 70 additions & 0 deletions packages/fmlrunner-mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,21 @@ export class FmlRunnerMcp {
};
this.ajv.addSchema(fmlCompilationInputSchema, 'fml-compilation-input');

// FML Syntax Validation Input Schema
const fmlSyntaxValidationInputSchema = {
type: 'object',
properties: {
fmlContent: {
type: 'string',
minLength: 1,
description: 'FHIR Mapping Language (FML) content to validate'
}
},
required: ['fmlContent'],
additionalProperties: false
};
this.ajv.addSchema(fmlSyntaxValidationInputSchema, 'fml-syntax-validation-input');

// StructureMap Execution Input Schema
const structureMapExecutionInputSchema = {
type: 'object',
Expand Down Expand Up @@ -194,6 +209,20 @@ export class FmlRunnerMcp {
required: ['fmlContent']
}
},
{
name: 'validate-fml-syntax',
description: 'Validate FML syntax without compilation - provides detailed error reporting with line/column positions',
inputSchema: {
type: 'object',
properties: {
fmlContent: {
type: 'string',
description: 'FML content to validate (must start with map declaration)'
}
},
required: ['fmlContent']
}
},
{
name: 'execute-structuremap',
description: 'Execute a StructureMap transformation on input data',
Expand Down Expand Up @@ -328,6 +357,9 @@ export class FmlRunnerMcp {
case 'compile-fml':
return await this.handleCompileFml(args);

case 'validate-fml-syntax':
return await this.handleValidateFmlSyntax(args);

case 'execute-structuremap':
return await this.handleExecuteStructureMap(args);

Expand Down Expand Up @@ -390,6 +422,44 @@ export class FmlRunnerMcp {
};
}

private async handleValidateFmlSyntax(args: any): Promise<CallToolResult> {
// Validate input
const validate = this.ajv.getSchema('fml-syntax-validation-input');
if (!validate || !validate(args)) {
throw new Error(`Invalid input: ${validate?.errors?.map(e => e.message).join(', ')}`);
}

const result = this.fmlRunner.validateFmlSyntax(args.fmlContent);

return {
content: [
{
type: 'text',
text: JSON.stringify({
valid: result.valid,
errors: result.errors.map(error => ({
message: error.message,
line: error.line,
column: error.column,
severity: error.severity,
code: error.code
})),
warnings: result.warnings.map(warning => ({
message: warning.message,
line: warning.line,
column: warning.column,
severity: warning.severity,
code: warning.code
})),
summary: result.valid
? 'FML syntax is valid'
: `Found ${result.errors.length} error(s) and ${result.warnings.length} warning(s)`
}, null, 2)
}
]
};
}

private async handleExecuteStructureMap(args: any): Promise<CallToolResult> {
// Validate input
const validate = this.ajv.getSchema('structuremap-execution-input');
Expand Down
85 changes: 85 additions & 0 deletions packages/fmlrunner-rest/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ export class FmlRunnerApi {
apiRouter.post('/execute', this.executeStructureMap.bind(this));
apiRouter.get('/structuremap/:reference', this.getStructureMap.bind(this));

// FML syntax validation endpoint
apiRouter.post('/validate-syntax', this.validateFmlSyntax.bind(this));

// FHIR Bundle processing endpoint
apiRouter.post('/Bundle', this.processBundle.bind(this));
apiRouter.get('/Bundle/summary', this.getBundleSummary.bind(this));
Expand Down Expand Up @@ -129,6 +132,88 @@ export class FmlRunnerApi {
}
}

/**
* Validate FML syntax without compilation
*/
private async validateFmlSyntax(req: Request, res: Response): Promise<void> {
try {
const { fmlContent } = req.body;

if (!fmlContent) {
res.status(400).json({
resourceType: 'OperationOutcome',
issue: [{
severity: 'error',
code: 'invalid',
diagnostics: 'Request body must include fmlContent property'
}]
});
return;
}

const result = this.fmlRunner.validateFmlSyntax(fmlContent);

// Convert to FHIR OperationOutcome format
const issues: Array<{
severity: 'error' | 'warning' | 'information';
code: string;
diagnostics: string;
location?: string[];
extension?: Array<{ url: string; valueString: string }>;
}> = [
...result.errors.map(error => ({
severity: 'error' as const,
code: 'syntax',
diagnostics: error.message,
location: [`line ${error.line}, column ${error.column}`],
extension: error.code ? [{
url: 'http://hl7.org/fhir/StructureDefinition/operationoutcome-issue-code',
valueString: error.code
}] : undefined
})),
...result.warnings.map(warning => ({
severity: 'warning' as const,
code: 'informational',
diagnostics: warning.message,
location: [`line ${warning.line}, column ${warning.column}`],
extension: warning.code ? [{
url: 'http://hl7.org/fhir/StructureDefinition/operationoutcome-issue-code',
valueString: warning.code
}] : undefined
}))
];

// Include success information for valid syntax
if (result.valid) {
issues.unshift({
severity: 'information',
code: 'informational',
diagnostics: 'FML syntax is valid'
});
}

const operationOutcome = {
resourceType: 'OperationOutcome',
issue: issues
};

if (result.valid) {
res.json(operationOutcome);
} else {
res.status(400).json(operationOutcome);
}
} catch (error) {
res.status(500).json({
resourceType: 'OperationOutcome',
issue: [{
severity: 'error',
code: 'exception',
diagnostics: error instanceof Error ? error.message : 'Unknown error'
}]
});
}
}

/**
* Execute StructureMap transformation
*/
Expand Down
188 changes: 188 additions & 0 deletions packages/fmlrunner-rest/tests/syntax-validation-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import request from 'supertest';
import { FmlRunnerApi } from '../src/api';

describe('FML Syntax Validation REST API', () => {
let api: FmlRunnerApi;
let app: any;

beforeEach(() => {
api = new FmlRunnerApi();
app = api.getApp();
});

describe('POST /api/v1/validate-syntax', () => {
test('should validate correct FML syntax', async () => {
const validFml = `map "http://example.org/StructureMap/Test" = "TestMap"

group main(source src, target tgt) {
src.name -> tgt.fullName;
}`;

const response = await request(app)
.post('/api/v1/validate-syntax')
.send({ fmlContent: validFml })
.expect(200);

expect(response.body.resourceType).toBe('OperationOutcome');
expect(response.body.issue).toBeDefined();
expect(response.body.issue[0].severity).toBe('information');
expect(response.body.issue[0].diagnostics).toBe('FML syntax is valid');
});

test('should detect syntax errors with proper FHIR format', async () => {
const invalidFml = `map = "TestMap"`; // Missing URL

const response = await request(app)
.post('/api/v1/validate-syntax')
.send({ fmlContent: invalidFml })
.expect(400);

expect(response.body.resourceType).toBe('OperationOutcome');
expect(response.body.issue).toBeDefined();

const errorIssues = response.body.issue.filter((issue: any) => issue.severity === 'error');
expect(errorIssues.length).toBeGreaterThan(0);

// Check that error issues have proper structure
errorIssues.forEach((issue: any) => {
expect(issue.code).toBeDefined();
expect(issue.diagnostics).toBeDefined();
expect(issue.location).toBeDefined();
expect(issue.location[0]).toMatch(/line \d+, column \d+/);
});
});

test('should handle warnings properly', async () => {
const fmlWithWarnings = `map "not-a-valid-url" = "TestMap"

group main(source src, target tgt) {
src.name -> tgt.fullName;
}`;

const response = await request(app)
.post('/api/v1/validate-syntax')
.send({ fmlContent: fmlWithWarnings })
.expect(200); // Should be OK since it's just warnings

expect(response.body.resourceType).toBe('OperationOutcome');

const warningIssues = response.body.issue.filter((issue: any) => issue.severity === 'warning');
expect(warningIssues.length).toBeGreaterThan(0);
});

test('should return 400 for missing fmlContent', async () => {
const response = await request(app)
.post('/api/v1/validate-syntax')
.send({})
.expect(400);

expect(response.body.resourceType).toBe('OperationOutcome');
expect(response.body.issue[0].severity).toBe('error');
expect(response.body.issue[0].code).toBe('invalid');
expect(response.body.issue[0].diagnostics).toContain('fmlContent');
});

test('should handle empty content gracefully', async () => {
const response = await request(app)
.post('/api/v1/validate-syntax')
.send({ fmlContent: ' ' }) // Send space instead of empty string to bypass input validation
.expect(400);

expect(response.body.resourceType).toBe('OperationOutcome');

const errorIssues = response.body.issue.filter((issue: any) => issue.severity === 'error');
expect(errorIssues.length).toBeGreaterThan(0);
// The error will be about missing map declaration
expect(errorIssues[0].diagnostics).toContain('map');
});

test('should provide detailed location information', async () => {
const multiLineFml = `map "http://example.org/StructureMap/Test" = "TestMap"

group main(source src, target tgt) {
src.name -> tgt.fullName;
}
// Line 7: syntax error here
group invalid(source src, target tgt {
src.name -> tgt.fullName;
}`;

const response = await request(app)
.post('/api/v1/validate-syntax')
.send({ fmlContent: multiLineFml })
.expect(400);

expect(response.body.resourceType).toBe('OperationOutcome');

const errorIssues = response.body.issue.filter((issue: any) => issue.severity === 'error');
expect(errorIssues.length).toBeGreaterThan(0);

// Check that at least one error has location information pointing to a reasonable line
const hasGoodLocation = errorIssues.some((issue: any) => {
const locationMatch = issue.location?.[0]?.match(/line (\d+)/);
return locationMatch && parseInt(locationMatch[1]) >= 7; // Error should be around line 7-8
});
expect(hasGoodLocation).toBe(true);
});

test('should include error codes in extensions', async () => {
const invalidFml = `invalid content`;

const response = await request(app)
.post('/api/v1/validate-syntax')
.send({ fmlContent: invalidFml })
.expect(400);

expect(response.body.resourceType).toBe('OperationOutcome');

const errorIssues = response.body.issue.filter((issue: any) => issue.severity === 'error');
expect(errorIssues.length).toBeGreaterThan(0);

// Check that at least one error has an extension with error code
const hasErrorCode = errorIssues.some((issue: any) =>
issue.extension &&
issue.extension.some((ext: any) =>
ext.url === 'http://hl7.org/fhir/StructureDefinition/operationoutcome-issue-code' &&
ext.valueString
)
);
expect(hasErrorCode).toBe(true);
});

test('should handle complex FML structures', async () => {
const complexFml = `map "http://example.org/StructureMap/Test" = "TestMap"

uses "http://hl7.org/fhir/StructureDefinition/Patient" alias Patient as source
uses "http://example.org/StructureDefinition/MyPatient" alias MyPatient as target

imports "http://example.org/other-map"

prefix system = "http://example.org/system"

conceptmap "http://example.org/ConceptMap/test" {
prefix s = "http://source.system"
prefix t = "http://target.system"

s:code1 -> t:mappedCode1
}

group main(source src : Patient, target tgt : MyPatient) {
src.name -> tgt.fullName;
src.gender -> tgt.sex;
}

group secondary(source src, target tgt) {
src.birthDate -> tgt.dateOfBirth;
}`;

const response = await request(app)
.post('/api/v1/validate-syntax')
.send({ fmlContent: complexFml })
.expect(200);

expect(response.body.resourceType).toBe('OperationOutcome');
expect(response.body.issue[0].severity).toBe('information');
expect(response.body.issue[0].diagnostics).toBe('FML syntax is valid');
});
});
});
Loading