diff --git a/calm-server/AGENTS.md b/calm-server/AGENTS.md index c15615d35..cb5327fc5 100644 --- a/calm-server/AGENTS.md +++ b/calm-server/AGENTS.md @@ -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 @@ -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 @@ -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 diff --git a/calm-server/README.md b/calm-server/README.md index b570bba7b..dae5b47cc 100644 --- a/calm-server/README.md +++ b/calm-server/README.md @@ -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 { diff --git a/calm-server/src/server/routes/validation-route.spec.ts b/calm-server/src/server/routes/validation-route.spec.ts index 005bae31b..e31a6034e 100644 --- a/calm-server/src/server/routes/validation-route.spec.ts +++ b/calm-server/src/server/routes/validation-route.spec.ts @@ -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 () => { @@ -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, @@ -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); }); }); diff --git a/calm-server/src/server/routes/validation-route.ts b/calm-server/src/server/routes/validation-route.ts index 3e0debb66..d45ea155e 100644 --- a/calm-server/src/server/routes/validation-route.ts +++ b/calm-server/src/server/routes/validation-route.ts @@ -27,6 +27,7 @@ export class ValidationRouter { private initializeRoutes(router: Router) { router.post('/', this.validateSchema); + router.post('/with-pattern', this.validateWithPattern); } private async ensureSchemasLoaded() { @@ -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, ValidationOutcome | ErrorResponse, ValidationWithPatternRequest>, + res: Response + ) => { + 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')); } }; } @@ -91,5 +144,10 @@ class ErrorResponse { } class ValidationRequest { - architecture: string; + architecture!: string; } + +class ValidationWithPatternRequest { + architecture!: string; + pattern!: string; +} \ No newline at end of file diff --git a/calm-server/test_fixtures/validation_route/valid_instantiation_with_pattern.json b/calm-server/test_fixtures/validation_route/valid_instantiation_with_pattern.json new file mode 100644 index 000000000..e6ca87ba5 --- /dev/null +++ b/calm-server/test_fixtures/validation_route/valid_instantiation_with_pattern.json @@ -0,0 +1,4 @@ +{ + "architecture": "{\"$schema\": \"https://raw.githubusercontent.com/finos/architecture-as-code/main/calm/pattern/api-gateway\",\"nodes\": [{\"unique-id\": \"api-gateway\",\"node-type\": \"system\",\"name\": \"API Gateway\",\"description\": \"The API Gateway used to verify authorization and access to downstream system\",\"interfaces\": [{\"unique-id\": \"api-gateway-ingress\",\"host\": \"https://api-gateway-ingress.example.com\",\"port\": 6000}],\"well-known-endpoint\": \"/api\"},{\"unique-id\": \"api-consumer\",\"node-type\": \"system\",\"name\": \"API Consumer\",\"description\": \"The API Consumer making an authenticated and authorized request\",\"interfaces\": []},{\"unique-id\": \"api-producer\",\"node-type\": \"system\",\"name\": \"API Producer\",\"description\": \"The API Producer serving content\",\"interfaces\": [{\"unique-id\": \"producer-ingress\",\"host\": \"https://api-producer.example.com\",\"port\": 1000}]},{\"unique-id\": \"idp\",\"node-type\": \"system\",\"name\": \"Identity Provider\",\"description\": \"The Identity Provider used to verify the bearer token\",\"interfaces\": []}],\"relationships\": [{\"unique-id\": \"api-consumer-api-gateway\",\"description\": \"Issue calculation request\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-consumer\"},\"destination\": {\"node\": \"api-gateway\",\"interfaces\": [\"api-gateway-ingress\"]}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-gateway-idp\",\"description\": \"Validate bearer token\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-gateway\"},\"destination\": {\"node\": \"idp\"}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-gateway-api-producer\",\"description\": \"Forward request\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-gateway\"},\"destination\": {\"node\": \"api-producer\",\"interfaces\": [\"producer-ingress\"]}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-consumer-idp\",\"description\": \"Acquire a bearer token\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-consumer\"},\"destination\": {\"node\": \"idp\"}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"}]}", + "pattern": "{\"$schema\":\"https://calm.finos.org/release/1.2/meta/calm.json\",\"$id\":\"https://raw.githubusercontent.com/finos/architecture-as-code/main/calm/pattern/api-gateway\",\"title\":\"API Gateway Pattern\",\"type\":\"object\",\"properties\":{\"nodes\":{\"type\":\"array\",\"minItems\":4,\"prefixItems\":[{\"$ref\":\"https://calm.finos.org/release/1.2/meta/core.json#/defs/node\",\"properties\":{\"well-known-endpoint\":{\"type\":\"string\"},\"description\":{\"const\":\"The API Gateway used to verify authorization and access to downstream system\"},\"node-type\":{\"const\":\"system\"},\"name\":{\"const\":\"API Gateway\"},\"unique-id\":{\"const\":\"api-gateway\"},\"interfaces\":{\"type\":\"array\",\"minItems\":1,\"prefixItems\":[{\"$ref\":\"https://calm.finos.org/release/1.0-rc1/meta/interface.json#/defs/host-port-interface\",\"properties\":{\"unique-id\":{\"const\":\"api-gateway-ingress\"}}}]}},\"required\":[\"well-known-endpoint\",\"interfaces\"]},{\"$ref\":\"https://calm.finos.org/release/1.2/meta/core.json#/defs/node\",\"properties\":{\"description\":{\"const\":\"The API Consumer making an authenticated and authorized request\"},\"node-type\":{\"const\":\"system\"},\"name\":{\"const\":\"API Consumer\"},\"unique-id\":{\"const\":\"api-consumer\"}}},{\"$ref\":\"https://calm.finos.org/release/1.2/meta/core.json#/defs/node\",\"properties\":{\"description\":{\"const\":\"The API Producer serving content\"},\"node-type\":{\"const\":\"system\"},\"name\":{\"const\":\"API Producer\"},\"unique-id\":{\"const\":\"api-producer\"},\"interfaces\":{\"type\":\"array\",\"minItems\":1,\"prefixItems\":[{\"$ref\":\"https://calm.finos.org/release/1.2/meta/interface.json#/defs/interface-type\",\"properties\":{\"unique-id\":{\"const\":\"producer-ingress\"},\"host\":{\"type\":\"string\"},\"port\":{\"type\":\"integer\"}},\"required\":[\"host\",\"port\"]}]}},\"required\":[\"interfaces\"]},{\"$ref\":\"https://calm.finos.org/release/1.2/meta/core.json#/defs/node\",\"properties\":{\"description\":{\"const\":\"The Identity Provider used to verify the bearer token\"},\"node-type\":{\"const\":\"system\"},\"name\":{\"const\":\"Identity Provider\"},\"unique-id\":{\"const\":\"idp\"}}}]},\"relationships\":{\"type\":\"array\",\"minItems\":4,\"prefixItems\":[{\"$ref\":\"https://calm.finos.org/release/1.2/meta/core.json#/defs/relationship\",\"properties\":{\"unique-id\":{\"const\":\"api-consumer-api-gateway\"},\"description\":{\"const\":\"Issue calculation request\"},\"relationship-type\":{\"const\":{\"connects\":{\"source\":{\"node\":\"api-consumer\"},\"destination\":{\"node\":\"api-gateway\",\"interfaces\":[\"api-gateway-ingress\"]}}}},\"parties\":{},\"protocol\":{\"const\":\"HTTPS\"},\"authentication\":{\"const\":\"OAuth2\"}}},{\"$ref\":\"https://calm.finos.org/release/1.2/meta/core.json#/defs/relationship\",\"properties\":{\"unique-id\":{\"const\":\"api-gateway-idp\"},\"description\":{\"const\":\"Validate bearer token\"},\"relationship-type\":{\"const\":{\"connects\":{\"source\":{\"node\":\"api-gateway\"},\"destination\":{\"node\":\"idp\"}}}},\"protocol\":{\"const\":\"HTTPS\"}}},{\"$ref\":\"https://calm.finos.org/release/1.2/meta/core.json#/defs/relationship\",\"properties\":{\"unique-id\":{\"const\":\"api-gateway-api-producer\"},\"description\":{\"const\":\"Forward request\"},\"relationship-type\":{\"const\":{\"connects\":{\"source\":{\"node\":\"api-gateway\"},\"destination\":{\"node\":\"api-producer\",\"interfaces\":[\"producer-ingress\"]}}}},\"protocol\":{\"const\":\"HTTPS\"}}},{\"$ref\":\"https://calm.finos.org/release/1.2/meta/core.json#/defs/relationship\",\"properties\":{\"unique-id\":{\"const\":\"api-consumer-idp\"},\"description\":{\"const\":\"Acquire a bearer token\"},\"relationship-type\":{\"const\":{\"connects\":{\"source\":{\"node\":\"api-consumer\"},\"destination\":{\"node\":\"idp\"}}}},\"protocol\":{\"const\":\"HTTPS\"}}}]}},\"required\":[\"nodes\",\"relationships\"]}" +} \ No newline at end of file