Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Features/#689 CLI Server #877

Merged
merged 15 commits into from
Feb 6, 2025
Merged
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
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