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
17 changes: 15 additions & 2 deletions calm-server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ The calm-server provides:

- **Bundled CALM Schemas** - All CALM schemas (release and draft) are bundled during build
- **Health Check Endpoint** (`/health`) - Status endpoint for monitoring
- **Validation Endpoint** (`/calm/validate`) - POST endpoint for validating CALM architectures
- **Validation Endpoint** (`/calm/validate`) - POST endpoint for validating CALM architectures against the pre-loaded patterns
- **Validation Endpoint With Pattern** (`/calm/validate/with-pattern`) - POST endpoint for validating CALM architectures against a pattern provided at runtime. The pattern must include a `$id` field that matches the architecture's `$schema` field
- **Rate Limiting** - Protects against abuse with 100 requests per 15 minutes per IP

## Project Structure
Expand All @@ -22,7 +23,7 @@ calm-server/
│ │ └── routes/
│ │ ├── routes.ts # Router setup
│ │ ├── health-route.ts # Health check endpoint
│ │ └── validation-route.ts # Architecture validation endpoint
│ │ └── validation-route.ts # Architecture validation endpoints (/calm/validate, /calm/validate/with-pattern)
│ └── *.spec.ts # Unit tests
├── dist/ # (generated) build output
│ ├── index.js # Compiled executable
Expand Down Expand Up @@ -124,6 +125,18 @@ curl -X POST http://localhost:3000/calm/validate \
-d '{"architecture": "{\"$schema\": \"https://...\"...}"}'
```

### Test the validation endpoint with pattern
```bash
# With a CALM architecture and pattern JSON
# The architecture's $schema must match the pattern's $id
curl -X POST http://localhost:3000/calm/validate/with-pattern \
-H "Content-Type: application/json" \
-d '{
"architecture": "{\"$schema\": \"https://example.com/schema\", \"nodes\": []}",
"pattern": "{\"$id\": \"https://example.com/schema\", \"type\": \"object\"}"
}'
```

## Dependencies

- `@finos/calm-shared` - Shared utilities, validation logic, schema handling
Expand Down
10 changes: 10 additions & 0 deletions calm-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ curl -X POST http://localhost:3000/calm/validate \
}'
```

### Validate Architecture against a Pattern

Validate a CALM architecture document against a pattern:

```bash
curl -X POST http://localhost:3000/calm/validate/with-pattern \
-H "Content-Type: application/json" \
-d @calm-server/test_fixtures/validation_route/valid_instantiation_with_pattern.json
```

Response (success):
```json
{
Expand Down
209 changes: 194 additions & 15 deletions calm-server/src/server/routes/validation-route.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,29 @@ const schemaDirectoryPath: string = __dirname + '/../../../../calm/release';
const apiGatewayPatternPath: string =
__dirname + '/../../../test_fixtures/api-gateway';

function createValidationApp() {
const app = express();
app.use(express.json());

const router: express.Router = express.Router();
new ValidationRouter(
router,
new SchemaDirectory(
new FileSystemDocumentLoader(
[schemaDirectoryPath, apiGatewayPatternPath],
false
)
)
);
app.use('/calm/validate', router);
return app;
}

describe('ValidationRouter', () => {
let app: Application;

beforeEach(() => {
app = express();
app.use(express.json());

const router: express.Router = express.Router();
new ValidationRouter(
router,
new SchemaDirectory(
new FileSystemDocumentLoader(
[schemaDirectoryPath, apiGatewayPatternPath],
false
)
)
);
app.use('/calm/validate', router);
app = createValidationApp();
});

test('should return 400 when $schema is not specified', async () => {
Expand Down Expand Up @@ -134,6 +139,30 @@ describe('ValidationRouter', () => {
expect(response.body.error).toContain('Failed to load schema');
});

test('should return 500 when validation throws an error', async () => {
app = express();
app.use(express.json());

const router: express.Router = express.Router();
const mockSchemaDirectory = {
loadSchemas: vi.fn().mockResolvedValueOnce(undefined),
getSchema: vi.fn().mockResolvedValueOnce({ type: 'object' })
} as unknown as SchemaDirectory;

new ValidationRouter(router, mockSchemaDirectory);
app.use('/calm/validate', router);

// A malformed architecture that will cause the validation function to throw an error.
const response = await request(app)
.post('/calm/validate')
.send({
architecture: JSON.stringify({ $schema: 'https://example.com/schema', relationships: 'not-an-array' })
});

expect(response.status).toBe(500);
expect(response.body.error).toContain('Failed to validate architecture');
});

test('should return 201 when the schema is valid', async () => {
const expectedFilePath = path.join(
__dirname,
Expand All @@ -151,5 +180,155 @@ describe('ValidationRouter', () => {
expect(response.body).toHaveProperty('spectralSchemaValidationOutputs');
expect(response.body).toHaveProperty('hasErrors');
expect(response.body).toHaveProperty('hasWarnings');
expect(response.body.hasErrors).toBe(false);
expect(response.body.hasWarnings).toBe(false);
});
});

describe('ValidationRouter - /with-pattern', () => {
let app: Application;

beforeEach(() => {
app = createValidationApp();
});

test('should return 400 when architecture JSON is invalid', async () => {
const response = await request(app)
.post('/calm/validate/with-pattern')
.send({ architecture: 'not valid json {' });

expect(response.status).toBe(400);
expect(response.body).toEqual({
error: 'Invalid JSON format for architecture'
});
});

test('should return 400 when $schema is missing from the architecture', async () => {
const response = await request(app)
.post('/calm/validate/with-pattern')
.send({ architecture: '{}' , pattern: '{}' });

expect(response.status).toBe(400);
expect(response.body).toEqual({
error: 'The "$schema" field is missing from the request body'
});
});

test('should return 400 when pattern JSON is invalid', async () => {
const response = await request(app)
.post('/calm/validate/with-pattern')
.send({ architecture: JSON.stringify({ $schema: 'https://example.com/schema'}), pattern: 'not valid json {' });

expect(response.status).toBe(400);
expect(response.body).toEqual({
error: 'Invalid JSON format for pattern'
});
});

test('should return 400 when $id is missing from the pattern', async () => {
const response = await request(app)
.post('/calm/validate/with-pattern')
.send({ architecture: JSON.stringify({ $schema: 'https://example.com/schema'}), pattern: '{}' });

expect(response.status).toBe(400);
expect(response.body).toEqual({
error: 'The "$id" field is missing from the provided pattern'
});
});

test('should return 400 when $schema in architecture does not match $id in pattern', async () => {
const response = await request(app)
.post('/calm/validate/with-pattern')
.send({ architecture: JSON.stringify({ $schema: 'https://example.com/schema'}), pattern: JSON.stringify({ $id: 'https://example.com/different-schema' }) });

expect(response.status).toBe(400);
expect(response.body).toEqual({
error: 'The "$schema" field (https://example.com/schema) in the architecture does not match the "$id" field (https://example.com/different-schema) in the pattern'
});
});


test('should return 500 when schema load throws an error', async () => {
app = express();
app.use(express.json());

const router: express.Router = express.Router();
const mockSchemaDirectory = {
loadSchemas: vi.fn().mockRejectedValueOnce(new Error('Load error')),
getSchema: vi.fn()
} as unknown as SchemaDirectory;

new ValidationRouter(router, mockSchemaDirectory);
app.use('/calm/validate', router);

const response = await request(app)
.post('/calm/validate/with-pattern')
.send({ architecture: JSON.stringify({ $schema: 'https://example.com/schema'}), pattern: JSON.stringify({ $id: 'https://example.com/schema' }) });

expect(response.status).toBe(500);
expect(response.body).toEqual({
error: 'Failed to load schemas'
});
});

test('should return 500 when validation against pattern throws an error', async () => {
app = express();
app.use(express.json());

const router: express.Router = express.Router();
const mockSchemaDirectory = {
loadSchemas: vi.fn().mockResolvedValue(undefined),
getSchema: vi.fn()
} as unknown as SchemaDirectory;

new ValidationRouter(router, mockSchemaDirectory);
app.use('/calm/validate', router);

// A malformed architecture(relationships is not an array) that will cause the validation function to throw an error.
const response = await request(app)
.post('/calm/validate/with-pattern')
.send({ architecture: JSON.stringify({ $schema: 'https://example.com/schema', relationships: 'not-an-array' }), pattern: JSON.stringify({ $id: 'https://example.com/schema', type: 'object' }) });

expect(response.status).toBe(500);
expect(response.body).toEqual({
error: 'Failed to validate architecture against pattern'
});
});

test('should return 201 when the architecture and pattern are valid', async () => {
const fixturePath = path.join(
__dirname,
'../../../test_fixtures/validation_route/valid_instantiation_with_pattern.json'
);
const requestBody = JSON.parse(fs.readFileSync(fixturePath, 'utf-8'));

const response = await request(app)
.post('/calm/validate/with-pattern')
.send(requestBody);

expect(response.status).toBe(201);
expect(response.body).toHaveProperty('jsonSchemaValidationOutputs');
expect(response.body).toHaveProperty('spectralSchemaValidationOutputs');
expect(response.body).toHaveProperty('hasErrors');
expect(response.body).toHaveProperty('hasWarnings');
expect(response.body.hasErrors).toBe(false);
expect(response.body.hasWarnings).toBe(false);
});

test('should return 201 with errors when architecture does not conform to the provided pattern', async () => {
const architecture = JSON.stringify({ '$schema': 'example schema', nodes :[], relationships: [] });
const pattern = JSON.stringify({
$id: 'example schema',
type: 'object',
properties: { nodes: { type: 'array', minItems: 99} },
required: ['nodes']
});

const response = await request(app)
.post('/calm/validate/with-pattern')
.send({ architecture, pattern });

expect(response.status).toBe(201);
expect(response.body.hasErrors).toBe(true);
});
});
62 changes: 60 additions & 2 deletions calm-server/src/server/routes/validation-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export class ValidationRouter {

private initializeRoutes(router: Router) {
router.post('/', this.validateSchema);
router.post('/with-pattern', this.validateWithPattern);
}

private async ensureSchemasLoaded() {
Expand Down Expand Up @@ -78,7 +79,59 @@ export class ValidationRouter {
const outcome = await validate(architecture, foundSchema, undefined, this.schemaDirectory, true);
return res.status(201).type('json').send(outcome);
} catch (error) {
return res.status(500).type('json').send(new ErrorResponse(error.message));
this.logger.error('Failed to validate architecture: ' + error);
return res.status(500).type('json').send(new ErrorResponse('Failed to validate architecture'));
}
};

private validateWithPattern = async (
req: Request<Record<string, never>, ValidationOutcome | ErrorResponse, ValidationWithPatternRequest>,
res: Response<ValidationOutcome | ErrorResponse>
) => {
let architecture;
try {
architecture = JSON.parse(req.body.architecture);
} catch (error) {
this.logger.error('Invalid JSON format for architecture ' + error);
return res.status(400).type('json').send(new ErrorResponse('Invalid JSON format for architecture'));
}

let pattern;
try {
pattern = JSON.parse(req.body.pattern);
} catch (error) {
this.logger.error('Invalid JSON format for pattern ' + error);
return res.status(400).type('json').send(new ErrorResponse('Invalid JSON format for pattern'));
}

const schema = architecture['$schema'];
if (!schema) {
return res.status(400).type('json').send(new ErrorResponse('The "$schema" field is missing from the request body'));
}

const patternId = pattern['$id'];
if (!patternId) {
return res.status(400).type('json').send(new ErrorResponse('The "$id" field is missing from the provided pattern'));
}

if (schema !== patternId) {
this.logger.error(`The "$schema" field (${schema}) in the architecture does not match the "$id" field (${patternId}) in the pattern`);
return res.status(400).type('json').send(new ErrorResponse(`The "$schema" field (${schema}) in the architecture does not match the "$id" field (${patternId}) in the pattern`));
}

try {
await this.ensureSchemasLoaded();
} catch (error) {
this.logger.error('Failed to load schemas: ' + error);
return res.status(500).type('json').send(new ErrorResponse('Failed to load schemas'));
}

try {
const outcome = await validate(architecture, pattern, undefined, this.schemaDirectory, true);
return res.status(201).type('json').send(outcome);
} catch (error) {
this.logger.error('Failed to validate architecture against pattern: ' + error);
return res.status(500).type('json').send(new ErrorResponse('Failed to validate architecture against pattern'));
}
};
}
Expand All @@ -91,5 +144,10 @@ class ErrorResponse {
}

class ValidationRequest {
architecture: string;
architecture!: string;
}

class ValidationWithPatternRequest {
architecture!: string;
pattern!: string;
}
Loading
Loading