Skip to content

Commit

Permalink
Merge pull request #877 from Budlee/features/#689
Browse files Browse the repository at this point in the history
Features/#689 CLI Server
  • Loading branch information
Budlee authored Feb 6, 2025
2 parents 4c6f5b4 + af3d4fc commit 7918628
Show file tree
Hide file tree
Showing 18 changed files with 535 additions and 7 deletions.
26 changes: 25 additions & 1 deletion cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
```
Expand Down
5 changes: 5 additions & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions cli/src/cli.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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();
}
});
});


Expand Down
13 changes: 13 additions & 0 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <format>';
const ARCHITECTURE_OPTION = '-a, --architecture <file>';
Expand All @@ -17,6 +18,8 @@ const SCHEMAS_OPTION = '-s, --schemaDirectory <path>';
const STRICT_OPTION = '--strict';
const VERBOSE_OPTION = '-v, --verbose';



program
.name('calm')
.version(version)
Expand Down Expand Up @@ -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>', '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);
Expand Down
19 changes: 19 additions & 0 deletions cli/src/server/cli-server.ts
Original file line number Diff line number Diff line change
@@ -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}`);
});
}
27 changes: 27 additions & 0 deletions cli/src/server/routes/health-route.spec.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});

// });
22 changes: 22 additions & 0 deletions cli/src/server/routes/health-route.ts
Original file line number Diff line number Diff line change
@@ -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<StatusResponse>) {
res.status(200).type('json').send(new StatusResponse('OK'));
}

}

class StatusResponse {
status: string;

constructor(status: string) {
this.status = status;
}
}
51 changes: 51 additions & 0 deletions cli/src/server/routes/routes.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
18 changes: 18 additions & 0 deletions cli/src/server/routes/routes.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
66 changes: 66 additions & 0 deletions cli/src/server/routes/validation-route.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});

});

// });
Loading

0 comments on commit 7918628

Please sign in to comment.