diff --git a/cli/README.md b/cli/README.md index cd12c1811..d33ff6c8c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -161,6 +161,30 @@ You would get an output which includes a warning like this: which is just letting you know that you have left in some placeholder values which might have been generated with the generate command. This isn't a full break, but it implies that you've forgotten to fill out a detail in your architecture. +## Calm CLI server (Experimental) + +It may be required to have the operations of the CALM CLI available over rest. +The `validate` command has been made available over an API + +```shell +calm server --schemaDirectory calm +``` + +```shell +curl http://127.0.0.1:3000/health + +# Missing schema key +curl -H "Content-Type: application/json" -X POST http://127.0.0.1:3000/calm/validate --data @cli/test_fixtures/validation_route/invalid_api_gateway_instantiation_missing_schema_key.json + +# Schema value is invalid +curl -H "Content-Type: application/json" -X POST http://127.0.0.1:3000/calm/validate --data @cli/test_fixtures/validation_route/invalid_api_gateway_instantiation_schema_points_to_missing_schema.json + +# instantiation is valid +curl -H "Content-Type: application/json" -X POST http://127.0.0.1:3000/calm/validate --data @cli/test_fixtures/validation_route/valid_instantiation.json + + +``` + ## Coding for the CLI The CLI module has its logic split into two modules, `cli` and `shared`. Both are managed by [npm workspaces](https://docs.npmjs.com/cli/v8/using-npm/workspaces). @@ -182,7 +206,7 @@ npm run build # Step 3: Link the workspace locally for testing npm run link:cli -# Step 4 : Run `watch` to check for changes automatically and re-bundle. This watching is via `chokidar` and isn't instant - give it a second or two to propogate changes. +# Step 4 : Run `watch` to check for changes automatically and re-bundle. This watching is via `chokidar` and isn't instant - give it a second or two to propagate changes. npm run watch ``` diff --git a/cli/package.json b/cli/package.json index 15987c0be..207c36a40 100644 --- a/cli/package.json +++ b/cli/package.json @@ -30,9 +30,13 @@ "dependencies": { "commander": "^13.0.0", "copyfiles": "^2.4.1", + "mkdirp": "^3.0.1" }, "devDependencies": { + "@types/supertest": "^6.0.2", + "jest-environment-node": "^29.7.0", + "axios": "^1.7.9", "@jest/globals": "^29.7.0", "@types/jest": "^29.5.14", "@types/json-pointer": "^1.0.34", @@ -46,6 +50,7 @@ "eslint": "^9.13.0", "globals": "^15.12.0", "jest": "^29.7.0", + "supertest": "^7.0.0", "link": "^2.1.1", "ts-jest": "^29.2.5", "ts-node": "10.9.2", diff --git a/cli/src/cli.spec.ts b/cli/src/cli.spec.ts index 207dcbc67..8f876b1ea 100644 --- a/cli/src/cli.spec.ts +++ b/cli/src/cli.spec.ts @@ -4,6 +4,8 @@ import * as fs from 'fs'; import * as os from 'os'; import { parseStringPromise } from 'xml2js'; import util from 'util'; +import axios from 'axios'; + const execPromise = util.promisify(exec); @@ -182,6 +184,18 @@ describe('CLI Integration Tests', () => { }); }); + test('example server command - starts server and responds to requests', async () => { + const serverCommand = 'calm server -p 3001 --schemaDirectory ../../dist/calm/'; + const serverProcess = exec(serverCommand); + // Give the server some time to start + await new Promise(resolve => setTimeout(resolve, 5 * millisPerSecond)); + try { + const response = await axios.get('http://127.0.0.1:3001/health'); + expect(response.status).toBe(200); + } finally { + serverProcess.kill(); + } + }); }); diff --git a/cli/src/index.ts b/cli/src/index.ts index 3454eca65..35b1b87dd 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -7,6 +7,7 @@ import { mkdirp } from 'mkdirp'; import { writeFileSync } from 'fs'; import { version } from '../package.json'; import { initLogger } from '@finos/calm-shared/commands/helper'; +import { startServer } from './server/cli-server'; const FORMAT_OPTION = '-f, --format '; const ARCHITECTURE_OPTION = '-a, --architecture '; @@ -17,6 +18,8 @@ const SCHEMAS_OPTION = '-s, --schemaDirectory '; const STRICT_OPTION = '--strict'; const VERBOSE_OPTION = '-v, --verbose'; + + program .name('calm') .version(version) @@ -75,6 +78,16 @@ async function runValidate(options) { } } +program + .command('server') + .description('Start a HTTP server to proxy CLI commands. (experimental)') + .option('-p, --port ', 'Port to run the server on', '3000') + .requiredOption(SCHEMAS_OPTION, 'Path to the directory containing the meta schemas to use.') + .option(VERBOSE_OPTION, 'Enable verbose logging.', false) + .action((options) => { + startServer(options); + }); + function writeOutputFile(output: string, validationsOutput: string) { if (output) { const dirname = path.dirname(output); diff --git a/cli/src/server/cli-server.ts b/cli/src/server/cli-server.ts new file mode 100644 index 000000000..16512367c --- /dev/null +++ b/cli/src/server/cli-server.ts @@ -0,0 +1,19 @@ +import express, { Application } from 'express'; +import { CLIServerRoutes } from './routes/routes'; +import { initLogger } from '@finos/calm-shared'; + +export function startServer(options: { port: string, schemaDirectory: string, verbose: boolean }) { + const app: Application = express(); + const cliServerRoutesInstance = new CLIServerRoutes(options.schemaDirectory, options.verbose); + const allRoutes = cliServerRoutesInstance.router; + + app.use(express.json()); + app.use('/', allRoutes); + + const port = options.port; + + app.listen(port, () => { + const logger = initLogger(options.verbose); + logger.info(`CALM Server is running on http://localhost:${port}`); + }); +} \ No newline at end of file diff --git a/cli/src/server/routes/health-route.spec.ts b/cli/src/server/routes/health-route.spec.ts new file mode 100644 index 000000000..4a7f3b70f --- /dev/null +++ b/cli/src/server/routes/health-route.spec.ts @@ -0,0 +1,27 @@ +import request from 'supertest'; + +import express, { Application } from 'express'; +import { HealthRouter } from './health-route'; + +describe('HealthRouter', () => { + let app: Application; + + beforeEach(() => { + app = express(); + app.use(express.json()); + + const router: express.Router = express.Router(); + app.use('/health', router); + new HealthRouter(router); + + }); + + test('should return 200 for health check', async () => { + const response = await request(app) + .get('/health'); + expect(response.status).toBe(200); + expect(response.body).toEqual({ status: 'OK' }); + }); +}); + +// }); \ No newline at end of file diff --git a/cli/src/server/routes/health-route.ts b/cli/src/server/routes/health-route.ts new file mode 100644 index 000000000..fdeff3c14 --- /dev/null +++ b/cli/src/server/routes/health-route.ts @@ -0,0 +1,22 @@ +import { Router, Request, Response } from 'express'; + + +export class HealthRouter { + + constructor(router: Router) { + router.get('/', this.healthCheck); + } + + private healthCheck(_req: Request, res: Response) { + res.status(200).type('json').send(new StatusResponse('OK')); + } + +} + +class StatusResponse { + status: string; + + constructor(status: string) { + this.status = status; + } +} \ No newline at end of file diff --git a/cli/src/server/routes/routes.spec.ts b/cli/src/server/routes/routes.spec.ts new file mode 100644 index 000000000..ca68b347a --- /dev/null +++ b/cli/src/server/routes/routes.spec.ts @@ -0,0 +1,51 @@ +import { Router } from 'express'; +import { CLIServerRoutes } from './routes'; +import { ValidationRouter } from './validation-route'; +import { HealthRouter } from './health-route'; + +const mockUse = jest.fn(); +const mockRouter = { + use: mockUse +}; + +jest.mock('express', () => ({ + Router: jest.fn(() => mockRouter) +})); + +jest.mock('./validation-route', () => { + return { + ValidationRouter: jest.fn() + }; +}); + +jest.mock('./health-route', () => { + return { + HealthRouter: jest.fn() + }; +}); + +describe('CLIServerRoutes', () => { + let schemaDirectoryPath: string; + let cliServerRoutes: CLIServerRoutes; + let mockRouter: Router; + + beforeEach(() => { + schemaDirectoryPath = '/path/to/schema'; + cliServerRoutes = new CLIServerRoutes(schemaDirectoryPath); + mockRouter = cliServerRoutes.router; + }); + + it('should initialize router', () => { + expect(Router).toHaveBeenCalled(); + }); + + it('should set up validate route', () => { + expect(mockRouter.use).toHaveBeenCalledWith('/calm/validate', mockRouter); + expect(ValidationRouter).toHaveBeenCalled(); + }); + + it('should set up health route', () => { + expect(mockRouter.use).toHaveBeenCalledWith('/health', mockRouter); + expect(HealthRouter).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/cli/src/server/routes/routes.ts b/cli/src/server/routes/routes.ts new file mode 100644 index 000000000..f5fae7519 --- /dev/null +++ b/cli/src/server/routes/routes.ts @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { ValidationRouter } from './validation-route'; +import { HealthRouter } from './health-route'; + +const HEALTH_ROUTE_PATH = '/health'; +const VALIDATE_ROUTE_PATH = '/calm/validate'; + +export class CLIServerRoutes { + router: Router; + + constructor(schemaDirectoryPath: string, debug: boolean = false) { + this.router = Router(); + const validateRoute = this.router.use(VALIDATE_ROUTE_PATH, this.router); + new ValidationRouter(validateRoute, schemaDirectoryPath, debug); + const healthRoute = this.router.use(HEALTH_ROUTE_PATH, this.router); + new HealthRouter(healthRoute); + } +} \ No newline at end of file diff --git a/cli/src/server/routes/validation-route.spec.ts b/cli/src/server/routes/validation-route.spec.ts new file mode 100644 index 000000000..374f2cd31 --- /dev/null +++ b/cli/src/server/routes/validation-route.spec.ts @@ -0,0 +1,66 @@ +import request from 'supertest'; +import * as fs from 'fs'; + +import express, { Application } from 'express'; +import { ValidationRouter } from './validation-route'; +import path from 'path'; + +const schemaDirectory : string = __dirname + '/../../../../calm'; + +describe('ValidationRouter', () => { + let app: Application; + + beforeEach(() => { + app = express(); + app.use(express.json()); + + const router: express.Router = express.Router(); + new ValidationRouter(router, schemaDirectory); + app.use('/calm/validate', router); + }); + + test('should return 400 when $schema is not specified', async () => { + const expectedFilePath = path.join(__dirname, '../../../test_fixtures/validation_route/invalid_api_gateway_instantiation_missing_schema_key.json'); + const invalidArchitectureMissingSchema = JSON.parse( + fs.readFileSync(expectedFilePath, 'utf-8') + ); + const response = await request(app) + .post('/calm/validate') + .send(invalidArchitectureMissingSchema); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'The "$schema" field is missing from the request body' }); + }); + + test('should return 400 when the $schema specified in the instantiation is not found', async () => { + const expectedFilePath = path.join(__dirname, '../../../test_fixtures/validation_route/invalid_api_gateway_instantiation_schema_points_to_missing_schema.json'); + const invalidArchitectureMissingSchema = JSON.parse( + fs.readFileSync(expectedFilePath, 'utf-8') + ); + const response = await request(app) + .post('/calm/validate') + .send(invalidArchitectureMissingSchema); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'The "$schema" field referenced is not available to the server' }); + }); + + test('should return 201 when the schema is valid', async () => { + const expectedFilePath = path.join(__dirname, '../../../test_fixtures/validation_route/valid_instantiation.json'); + const validArchitecture = JSON.parse( + fs.readFileSync(expectedFilePath, 'utf-8') + ); + const response = await request(app) + .post('/calm/validate') + .send(validArchitecture); + + 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'); + }); + +}); + +// }); \ No newline at end of file diff --git a/cli/src/server/routes/validation-route.ts b/cli/src/server/routes/validation-route.ts new file mode 100644 index 000000000..5f1e0310c --- /dev/null +++ b/cli/src/server/routes/validation-route.ts @@ -0,0 +1,83 @@ +import { validate, SchemaDirectory, initLogger } from '@finos/calm-shared'; +import { Router, Request, Response } from 'express'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { v4 as uuidv4 } from 'uuid'; +import winston from 'winston'; +import { ValidationOutcome } from '@finos/calm-shared/commands/validate/validation.output'; + +export class ValidationRouter { + + private schemaDirectoryPath: string; + private schemaDirectory: SchemaDirectory; + private logger: winston.Logger; + + constructor(router: Router, schemaDirectoryPath: string, debug: boolean = false) { + this.schemaDirectory = new SchemaDirectory(true); + this.schemaDirectoryPath = schemaDirectoryPath; + this.logger = initLogger(debug); + this.initializeRoutes(router); + } + + private initializeRoutes(router: Router) { + router.post('/', this.validateSchema); + } + + private validateSchema = async (req: Request, 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')); + } + + const schema = architecture['$schema']; + if (!schema) { + return res.status(400).type('json').send(new ErrorResponse('The "$schema" field is missing from the request body')); + } + this.logger.info('Path loading schemas is ' + this.schemaDirectoryPath); + + await this.schemaDirectory.loadSchemas(this.schemaDirectoryPath); + const foundSchema = this.schemaDirectory.getSchema(schema); + if (!foundSchema) { + return res.status(400).type('json').send(new ErrorResponse('The "$schema" field referenced is not available to the server')); + } + const tempInstantiation = await createTemporaryFile(); + const tempPattern = await createTemporaryFile(); + try { + + await fs.writeFile(tempInstantiation, JSON.stringify(architecture, null, 4), { mode: 0o600 }); + await fs.writeFile(tempPattern, JSON.stringify(foundSchema, null, 4), { mode: 0o600 }); + const outcome = await validate(tempInstantiation, tempPattern, this.schemaDirectoryPath, true); + return res.status(201).type('json').send(outcome); + } catch (error) { + return res.status(500).type('json').send(new ErrorResponse(error.message)); + } finally { + [tempInstantiation, tempPattern].forEach(element => { + fs.unlink(element).catch(() => { + this.logger.warn('Failed to delete temporary file ' + element); + }); + }); + } + }; +} + +async function createTemporaryFile(): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'calm-')); + const tempFilePath = path.join(tempDir, `calm-instantiation-${uuidv4()}.json`); + return tempFilePath; +} + + +class ErrorResponse { + error: string; + constructor(error: string) { + this.error = error; + } +}; + +class ValidationRequest { + architecture: string; +} diff --git a/cli/test_fixtures/validation_route/invalid_api_gateway_instantiation_missing_schema_key.json b/cli/test_fixtures/validation_route/invalid_api_gateway_instantiation_missing_schema_key.json new file mode 100644 index 000000000..06b31bfd1 --- /dev/null +++ b/cli/test_fixtures/validation_route/invalid_api_gateway_instantiation_missing_schema_key.json @@ -0,0 +1,3 @@ +{ + "architecture": "{\"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\": -1}]},{\"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\"}]}" +} \ No newline at end of file diff --git a/cli/test_fixtures/validation_route/invalid_api_gateway_instantiation_schema_points_to_missing_schema.json b/cli/test_fixtures/validation_route/invalid_api_gateway_instantiation_schema_points_to_missing_schema.json new file mode 100644 index 000000000..0c0122456 --- /dev/null +++ b/cli/test_fixtures/validation_route/invalid_api_gateway_instantiation_schema_points_to_missing_schema.json @@ -0,0 +1,3 @@ +{ +"architecture" : "{\"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\": -1}]},{\"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\"}],\"$schema\": \"https://raw.githubusercontent.com/finos/architecture-as-code/main/calm/secure-service-pattern.json\"}" +} \ No newline at end of file diff --git a/cli/test_fixtures/validation_route/valid_instantiation.json b/cli/test_fixtures/validation_route/valid_instantiation.json new file mode 100644 index 000000000..d6389094f --- /dev/null +++ b/cli/test_fixtures/validation_route/valid_instantiation.json @@ -0,0 +1,3 @@ +{ + "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\"}]}" +} \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index f893eb06d..4d39f74a9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,4 +1,3 @@ # Formal Documentation https://calm.finos.org/ - diff --git a/package-lock.json b/package-lock.json index aefc81345..a20363562 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,16 +77,20 @@ "@types/jest": "^29.5.14", "@types/json-pointer": "^1.0.34", "@types/junit-report-builder": "^3.0.2", - "@types/lodash": "^4.17.15", + "@types/lodash": "^4.17.0", "@types/node": "^22.10.0", + "@types/supertest": "^6.0.2", "@types/xml2js": "^0.4.14", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.15.0", + "axios": "^1.7.9", "chokidar": "^4.0.1", "eslint": "^9.13.0", "globals": "^15.12.0", "jest": "^29.7.0", + "jest-environment-node": "^29.7.0", "link": "^2.1.1", + "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-node": "10.9.2", "tsup": "^8.0.0", @@ -8060,6 +8064,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cytoscape": { "version": "3.21.8", "resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.21.8.tgz", @@ -8318,6 +8329,13 @@ "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -8505,6 +8523,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.2.tgz", + "integrity": "sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -9901,6 +9943,13 @@ "printable-characters": "^1.0.42" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -10006,6 +10055,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", + "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -11216,6 +11277,16 @@ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "license": "MIT" }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -11386,6 +11457,13 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/copy-text-to-clipboard": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.0.tgz", @@ -12718,6 +12796,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -14045,6 +14134,13 @@ "integrity": "sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==", "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", @@ -14760,6 +14856,21 @@ "node": ">=0.4.x" } }, + "node_modules/formidable": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.2.tgz", + "integrity": "sha512-Jqc1btCy3QzRbJaICGwKcBfGWuLADRerLzDqi2NwSt/UkXLsHJw2TVResiaoBufHVHy9aSgClOHCeJsSsFLTbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dezalgo": "^1.0.4", + "hexoid": "^2.0.0", + "once": "^1.4.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -15667,6 +15778,16 @@ "he": "bin/he" } }, + "node_modules/hexoid": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-2.0.0.tgz", + "integrity": "sha512-qlspKUK7IlSQv2o+5I7yhUd7TxlOG2Vr5LTa3ve2XSNVKAL/n/u/7KLvKmFNimomDIKvZFXWHv0T12mv7rT8Aw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/history": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", @@ -23833,6 +23954,13 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -26481,6 +26609,54 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/superagent": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-9.0.2.tgz", + "integrity": "sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.4", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^3.5.1", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.0.0.tgz", + "integrity": "sha512-qlsr7fIC0lSddmA3tzojvzubYxvlGtzumcdHgPwbFWMISQwL22MhM2Y3LNt+6w9Yyx7559VW5ab70dgphm8qQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^9.0.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -31027,7 +31203,7 @@ "@types/js-yaml": "^4.0.9", "@types/json-pointer": "^1.0.34", "@types/junit-report-builder": "^3.0.2", - "@types/lodash": "^4.17.15", + "@types/lodash": "^4.17.0", "@types/node": "^22.10.0", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.15.0", diff --git a/shared/src/index.ts b/shared/src/index.ts index 041a3ed6b..e62d991b4 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -7,4 +7,6 @@ export { OutputFormat } from './commands/validate/validate.js'; export { runGenerate } from './commands/generate/generate.js'; export { ValidationOutput } from './commands/validate/validation.output.js'; export { CALM_META_SCHEMA_DIRECTORY } from './consts.js'; +export { SchemaDirectory } from './schema-directory.js'; +export { initLogger } from './commands/helper.js'; export * from './types'; \ No newline at end of file diff --git a/shared/src/schema-directory.ts b/shared/src/schema-directory.ts index c3fd4405a..f6ab6db7e 100644 --- a/shared/src/schema-directory.ts +++ b/shared/src/schema-directory.ts @@ -30,6 +30,7 @@ export class SchemaDirectory { /** * Load the schemas from the configured directory path. * Subsequent loads could overwrite schemas if they have the same ID. + * Throws an error if any schema fails to load. */ public async loadSchemas(dir: string): Promise { try { @@ -38,12 +39,13 @@ export class SchemaDirectory { this.logger.debug('Loading schemas from ' + dir); const files = await readdir(dir, { recursive: true }); - const schemaPaths = files.filter(str => str.match(/^.*(json|yaml|yml)$/)) + const schemaPaths = files.filter(str => str.match(/^.*(json)$/)) .map(schemaPath => join(dir, schemaPath)); - for (const schemaPath of schemaPaths) { const schema = await this.loadSchema(schemaPath); - map.set(schema['$id'], schema); + if (schema){ + map.set(schema['$id'], schema); + } } map.forEach((val, key) => this.schemas.set(key, val)); @@ -175,8 +177,6 @@ export class SchemaDirectory { if (!parsed['$schema']) { this.logger.warn('Warning, loaded schema does not have $schema set and therefore may be invalid. Path: ', schemaPath); } - - this.logger.debug('Loaded schema with $id: ' + schemaId); return parsed; }