diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 87c20e89f..58a88597f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,6 +4,8 @@ /calm-hub/ @jpgough-ms @rocketstack-matt @grahampacker-ms @Thels @markscott-ms +/calm-server/ @rocketstack-matt @markscott-ms + /cli/ @aidanm3341 @lbulanti-ms @willosborne @grahampacker-ms @jpgough-ms @rocketstack-matt @Thels @LeighFinegold @markscott-ms /shared/ @aidanm3341 @lbulanti-ms @willosborne @grahampacker-ms @jpgough-ms @rocketstack-matt @Thels @LeighFinegold @markscott-ms diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e4e1a08a2..b290a6e35 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -16,11 +16,14 @@ ## Affected Components - [ ] CLI (`cli/`) -- [ ] Shared (`shared/`) -- [ ] CALM Widgets (`calm-widgets/`) +- [ ] Schema (`calm/`) +- [ ] CALM AI (`calm-ai/`) - [ ] CALM Hub (`calm-hub/`) - [ ] CALM Hub UI (`calm-hub-ui/`) +- [ ] CALM Server (`calm-server/`) +- [ ] CALM Widgets (`calm-widgets/`) - [ ] Documentation (`docs/`) +- [ ] Shared (`shared/`) - [ ] VS Code Extension (`calm-plugins/vscode/`) - [ ] Dependencies - [ ] CI/CD diff --git a/.github/workflows/build-calm-server.yml b/.github/workflows/build-calm-server.yml new file mode 100644 index 000000000..7fcb5d26f --- /dev/null +++ b/.github/workflows/build-calm-server.yml @@ -0,0 +1,40 @@ +name: Build CALM Server + +permissions: + contents: read + +on: + pull_request: + branches: + - 'main' + - 'release*' + push: + branches: + - 'main' + - 'release*' + +jobs: + calm-server: + name: Build, Test, and Lint CALM Server Module + runs-on: ubuntu-latest + + steps: + - name: Checkout PR Branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Setup Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 + with: + node-version: v22 + + - name: Install workspace + run: npm ci + + - name: Lint CALM Server Module + run: npm run lint --workspace=calm-server + + - name: Build workspace + run: npm run build:calm-server + + - name: Run tests with coverage for CALM Server + run: npm run test:calm-server diff --git a/README.md b/README.md index 2b7525f0f..44709b7f1 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Ready to get started? Check out the [CALM tutorials](https://calm.finos.org/tuto | [CALM AI](./calm-ai) | [@rocketstack-matt](https://github.com/rocketstack-matt) | No build, prompt tools only, managed separately to CLI for easier maintenance and broader reuse. | | [CALM Hub](./calm-hub) | [@jpgough-ms](https://github.com/jpgough-ms), [@grahampacker-ms](https://github.com/grahampacker-ms) | [![Build Calm Hub](https://github.com/finos/architecture-as-code/actions/workflows/build-calm-hub.yml/badge.svg)](https://github.com/finos/architecture-as-code/actions/workflows/build-calm-hub.yml) | | [CALM Hub UI](./calm-hub-ui) | [@aidanm3341](https://github.com/aidanm3341), [@aamanrebello](https://github.com/aamanrebello), [@yoofitt96](https://github.com/YoofiTT96) | [![Build CALM Hub UI](https://github.com/finos/architecture-as-code/actions/workflows/build-calm-hub-ui.yml/badge.svg)](https://github.com/finos/architecture-as-code/actions/workflows/build-calm-hub-ui.yml) | +| [CALM-Server](./calm-server) | [@rocketstack-matt](https://github.com/rocketstack-matt), [@markscott-ms](https://github.com/markscott-ms) | [![Build CALM Server](https://github.com/finos/architecture-as-code/actions/workflows/build-calm-server.yml/badge.svg)](https://github.com/finos/architecture-as-code/actions/workflows/build-calm-server.yml) | | [Docs](./docs) | [@rocketstack-matt](https://github.com/rocketstack-matt) | [![Sync Docs to S3](https://github.com/finos/architecture-as-code/actions/workflows/s3-docs-sync.yml/badge.svg)](https://github.com/finos/architecture-as-code/actions/workflows/s3-docs-sync.yml) [![Build Docs](https://github.com/finos/architecture-as-code/actions/workflows/build-docs.yml/badge.svg)](https://github.com/finos/architecture-as-code/actions/workflows/build-docs.yml) | | [CALM VSCode Plugin](./calm-plugins/vscode) | [@LeighFinegold](https://github.com/LeighFinegold), [@rocketstack-matt](https://github.com/rocketstack-matt), [@markscott-ms](https://github.com/markscott-ms) | ![Build VS Code Extension](https://github.com/finos/architecture-as-code/workflows/Build%20VS%20Code%20Extension/badge.svg) | diff --git a/calm-server/AGENTS.md b/calm-server/AGENTS.md new file mode 100644 index 000000000..aee6d18a0 --- /dev/null +++ b/calm-server/AGENTS.md @@ -0,0 +1,168 @@ +# @finos/calm-server + +The `calm-server` executable provides a standalone HTTP server implementation of CALM validation functionality. + +## Architecture + +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 +- **Rate Limiting** - Protects against abuse with 100 requests per 15 minutes per IP + +## Project Structure + +``` +calm-server/ +├── src/ +│ ├── index.ts # Entry point, CLI argument parsing +│ ├── server/ +│ │ ├── cli-server.ts # Express server startup +│ │ └── routes/ +│ │ ├── routes.ts # Router setup +│ │ ├── health-route.ts # Health check endpoint +│ │ └── validation-route.ts # Architecture validation endpoint +│ └── *.spec.ts # Unit tests +├── dist/ +│ ├── index.js # Compiled executable +│ └── calm/ # Bundled CALM schemas +│ ├── release/ # Released schema versions +│ └── draft/ # Draft schema versions +├── package.json +├── tsconfig.json +├── tsconfig.build.json +├── tsup.config.ts # Build configuration +├── vitest.config.ts # Test configuration +└── eslint.config.mjs # Linting configuration +``` + +## Building & Running + +### Build the package +```bash +npm run build:calm-server +``` + +This builds the TypeScript code and copies all CALM schemas from `calm/release` and `calm/draft` into `dist/calm/`. + +### Run the server locally +```bash +# Using bundled schemas (default) +./calm-server/dist/index.js --port 3000 --verbose + +# Or with custom schemas +./calm-server/dist/index.js -s ../calm/release --port 3000 --verbose + +# Or using npm +npm run build:calm-server +node calm-server/dist/index.js --port 3000 +``` + +### Global installation (for development) +```bash +npm run link:calm-server + +# Use bundled schemas (default) +calm-server --port 3000 + +# Or provide custom schemas +calm-server -s /path/to/schemas --port 3000 +``` + +## Command-Line Options + +``` +Usage: calm-server [options] + +CALM Server - A server implementation for the Common Architecture Language Model + +Options: + -V, --version output the version number + --port Port to run the server on (default: "3000") + --host Host to bind the server to (default: "127.0.0.1") + -s, --schema-directory Path to the directory containing the meta schemas to use. + (default: bundled schemas in dist/calm) + -v, --verbose Enable verbose logging (default: false) + -c, --calm-hub-url URL to CALMHub instance + --rate-limit-window Rate limit window in milliseconds (default: 900000 = 15 minutes) + --rate-limit-max Max requests per IP within the rate limit window (default: 100) + -h, --help display help for command +``` + +### Security Notes + +- **Default Host**: The server binds to `127.0.0.1` (localhost) by default +- **No Authentication**: The server has **NO authentication or authorization controls** +- **Network Exposure Warning**: If you bind to a non-localhost host (e.g., `0.0.0.0`, `::`, public IP), a warning will be logged. Only do this in trusted network environments + +## Testing + +### Run tests +```bash +npm run test:calm-server +``` + +### Test the health endpoint +```bash +# Start the server (uses bundled schemas) +node calm-server/dist/index.js & +SERVER_PID=$! + +# Test health +curl http://localhost:3000/health + +# Clean up +kill $SERVER_PID +``` + +### Test the validation endpoint +```bash +# With a CALM architecture JSON +curl -X POST http://localhost:3000/calm/validate \ + -H "Content-Type: application/json" \ + -d '{"architecture": "{\"$schema\": \"https://...\"...}"}' +``` + +## Dependencies + +- `@finos/calm-shared` - Shared utilities, validation logic, schema handling +- `express` - HTTP server framework +- `express-rate-limit` - Rate limiting middleware +- `commander` - CLI argument parsing + +## Development Notes + +### Copying from CLI + +The calm-server implementation mirrors the server functionality from the CLI package (`cli/src/server/`): + +- `src/server/cli-server.ts` - Express server setup +- `src/server/routes/routes.ts` - Route configuration +- `src/server/routes/health-route.ts` - Health check +- `src/server/routes/validation-route.ts` - Architecture validation + +Both implementations share the same core logic for validation and schema handling through the `@finos/calm-shared` package. + +### Build Configuration + +The tsup configuration: +- Bundles shared packages (`@finos/calm-shared`, `@finos/calm-models`, `@finos/calm-widgets`) +- Adds Node.js shebang via banner for executable +- Outputs CommonJS format with tree-shaking enabled +- Marks external `node_modules` as external (not bundled) + +## Linking for Development + +After building, you can link the executable globally: + +```bash +npm run link:calm-server +calm-server --help +``` + +This allows testing the executable without needing to build or reference the dist directory. + +## License + +Apache-2.0 diff --git a/calm-server/README.md b/calm-server/README.md new file mode 100644 index 000000000..b570bba7b --- /dev/null +++ b/calm-server/README.md @@ -0,0 +1,142 @@ +# @finos/calm-server + +A standalone HTTP server for the Common Architecture Language Model (CALM) validation functionality. + +The `calm-server` executable provides HTTP endpoints for CALM architecture validation. + +## Features + +- **Bundled CALM Schemas** - All CALM schemas (release and draft) are bundled with the executable +- **Health Check Endpoint** (`GET /health`) - Status endpoint for monitoring +- **Validation Endpoint** (`POST /calm/validate`) - Validate CALM architectures against schemas + +## Usage + +### Starting the Server + +```bash +# Basic usage (uses bundled schemas by default) +calm-server + +# With custom port +calm-server --port 8080 + +# With verbose logging +calm-server --port 3000 --verbose + +# Or provide a custom schema directory +calm-server -s /path/to/calm/schemas --port 3000 +``` + +### Command-Line Options + +``` +Usage: calm-server [options] + +Options: + -V, --version output the version number + --port Port to run the server on (default: "3000") + --host Host to bind the server to (default: "127.0.0.1") + -s, --schema-directory Path to the directory containing the meta schemas + (default: bundled schemas in dist/calm) + -v, --verbose Enable verbose logging (default: false) + -c, --calm-hub-url URL to CALMHub instance + --rate-limit-window Rate limit window in milliseconds (default: 900000 = 15 minutes) + --rate-limit-max Max requests per IP within the rate limit window (default: 100) + -h, --help display help for command +``` + +### Security Considerations + +- **Default Host is Localhost**: By default, the server binds to `127.0.0.1` for security +- **No Built-in Authentication**: This server has no authentication or authorization controls +- **Network Exposure**: When binding to non-localhost addresses, a security warning is logged. Only expose to the network in trusted environments + +## API Endpoints + +### Health Check + +Check if the server is running: + +```bash +curl http://localhost:3000/health +``` + +Response: +```json +{ + "status": "OK" +} +``` + +### Validate Architecture + +Validate a CALM architecture document: + +```bash +curl -X POST http://localhost:3000/calm/validate \ + -H "Content-Type: application/json" \ + -d '{ + "architecture": "{\"$schema\":\"https://calm.finos.org/release/1.2/meta/calm.json\",\"nodes\":[]}" + }' +``` + +Response (success): +```json +{ + "jsonSchemaValidationOutputs":[], + "spectralSchemaValidationOutputs":[], + "hasErrors":false, + "hasWarnings":false +} +``` + +Response (validation errors): +```json +{ + "jsonSchemaValidationOutputs":[], + "spectralSchemaValidationOutputs":[...], + "hasErrors":true, + "hasWarnings":false +} +``` + +## Development + +### Building + +```bash +# From repository root +npm run build:calm-server + +# Or from calm-server directory +cd calm-server +npm run build +``` + +### Testing + +```bash +# From repository root +npm run test:calm-server + +# Or from calm-server directory +cd calm-server +npm test + +# With coverage +npm test -- --coverage +``` + +### Linting + +```bash +# From calm-server directory +npm run lint +npm run lint-fix +``` + +## License + +Apache-2.0 + diff --git a/calm-server/eslint.config.mjs b/calm-server/eslint.config.mjs new file mode 100644 index 000000000..0cff31321 --- /dev/null +++ b/calm-server/eslint.config.mjs @@ -0,0 +1,79 @@ +import typescriptEslint from "@typescript-eslint/eslint-plugin"; +import globals from "globals"; +import tsParser from "@typescript-eslint/parser"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import js from "@eslint/js"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all +}); + +export default [ + ...compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended"), + { + ignores: ['dist/', 'node_modules/', 'coverage/', 'test_fixtures/'], + }, + { + files: ['src/**/*.ts'], + plugins: { + "@typescript-eslint": typescriptEslint, + }, + + languageOptions: { + globals: { + ...globals.node, + }, + + parser: tsParser, + ecmaVersion: "latest", + sourceType: "module", + }, + + rules: { + indent: ["error", 4], + "linebreak-style": ["error", "unix"], + quotes: ["error", "single"], + semi: ["error", "always"], + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], + "@typescript-eslint/no-unused-expressions": [ + "error", + { + "allowShortCircuit": true, + "allowTernary": true, + "allowTaggedTemplates": true + } + ] + } + }, + { + files: ['src/**/*.spec.ts'], + languageOptions: { + globals: { + ...globals.node, + describe: 'readonly', + it: 'readonly', + test: 'readonly', + beforeEach: 'readonly', + afterEach: 'readonly', + beforeAll: 'readonly', + afterAll: 'readonly', + expect: 'readonly', + vi: 'readonly', + }, + }, + }, +]; diff --git a/calm-server/package.json b/calm-server/package.json new file mode 100644 index 000000000..e25ea211a --- /dev/null +++ b/calm-server/package.json @@ -0,0 +1,51 @@ +{ + "name": "@finos/calm-server", + "version": "0.1.0", + "description": "CALM Server - A server implementation for the Common Architecture Language Model", + "homepage": "https://calm.finos.org", + "repository": { + "type": "git", + "url": "https://github.com/finos/architecture-as-code.git" + }, + "main": "dist/index.js", + "files": [ + "dist/" + ], + "bin": { + "calm-server": "dist/index.js" + }, + "scripts": { + "build": "tsup && npm run copy-calm-schema", + "copy-calm-schema": "copyfiles \"../calm/release/**/meta/*\" \"../calm/draft/**/meta/*\" dist/calm/", + "test": "vitest run", + "lint": "eslint src", + "lint-fix": "eslint src --fix" + }, + "keywords": [ + "calm", + "server", + "architecture" + ], + "author": "", + "license": "Apache-2.0", + "dependencies": { + "@finos/calm-shared": "file:../shared", + "commander": "^14.0.0", + "copyfiles": "^2.4.1", + "express": "^4.18.2", + "express-rate-limit": "^8.0.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.15.0", + "@types/supertest": "^6.0.3", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^9.0.0", + "node-fetch": "^2.7.0", + "supertest": "^7.0.0", + "tsup": "^8.3.5", + "typescript": "^5.8.2", + "vitest": "^3.1.1" + } +} \ No newline at end of file diff --git a/calm-server/src/index.ts b/calm-server/src/index.ts new file mode 100644 index 000000000..3ae38830b --- /dev/null +++ b/calm-server/src/index.ts @@ -0,0 +1,92 @@ +/** + * CALM Server - A server implementation for the Common Architecture Language Model + */ + +import { Command } from 'commander'; +import { version } from '../package.json'; +import { startServer } from './server/server'; +import { SchemaDirectory, initLogger, buildDocumentLoader, DocumentLoader, DocumentLoaderOptions } from '@finos/calm-shared'; +import path from 'path'; + +const BUNDLED_SCHEMA_PATH = path.join(__dirname, 'calm'); + +const PORT_OPTION = '--port '; +const HOST_OPTION = '--host '; +const SCHEMAS_OPTION = '-s, --schema-directory '; +const VERBOSE_OPTION = '-v, --verbose'; +const CALMHUB_URL_OPTION = '-c, --calm-hub-url '; +const RATE_LIMIT_WINDOW_OPTION = '--rate-limit-window '; +const RATE_LIMIT_MAX_OPTION = '--rate-limit-max '; + +interface ParseDocumentLoaderOptions { + verbose?: boolean; + calmHubUrl?: string; + schemaDirectory?: string; + rateLimitWindow?: number; + rateLimitMax?: number; +} + +async function parseDocumentLoaderConfig( + options: ParseDocumentLoaderOptions, + urlToLocalMap?: Map, + basePath?: string +): Promise { + const docLoaderOpts: DocumentLoaderOptions = { + calmHubUrl: options.calmHubUrl, + schemaDirectoryPath: options.schemaDirectory, + urlToLocalMap: urlToLocalMap, + basePath: basePath, + debug: !!options.verbose + }; + + return docLoaderOpts; +} + +async function buildSchemaDirectory(docLoader: DocumentLoader, debug: boolean): Promise { + return new SchemaDirectory(docLoader, debug); +} + +const program = new Command(); + +program + .name('calm-server') + .version(version) + .description('CALM Server - A server implementation for the Common Architecture Language Model') + .option(PORT_OPTION, 'Port to run the server on', '3000') + .option(HOST_OPTION, 'Host to bind the server to', '127.0.0.1') + .option(SCHEMAS_OPTION, 'Path to the directory containing the meta schemas to use.', BUNDLED_SCHEMA_PATH) + .option(VERBOSE_OPTION, 'Enable verbose logging.', false) + .option(CALMHUB_URL_OPTION, 'URL to CALMHub instance') + .option(RATE_LIMIT_WINDOW_OPTION, 'Rate limit window in milliseconds (default: 900000 = 15 minutes)', '900000') + .option(RATE_LIMIT_MAX_OPTION, 'Max requests per IP within the rate limit window (default: 100)', '100') + .action(async (options) => { + try { + const debug = !!options.verbose; + const logger = initLogger(debug, 'calm-server'); + + // Warn if host is explicitly provided (not default) + if (options.host && options.host !== '127.0.0.1') { + logger.warn('⚠️ WARNING: Server is configured to listen on ' + options.host); + logger.warn('⚠️ This server has NO authentication or authorization controls.'); + logger.warn('⚠️ Only bind to non-localhost addresses in trusted network environments.'); + } + + const docLoaderOpts = await parseDocumentLoaderConfig(options); + const docLoader = buildDocumentLoader(docLoaderOpts); + const schemaDirectory = await buildSchemaDirectory(docLoader, debug); + startServer( + options.port, + options.host, + schemaDirectory, + debug, + parseInt(options.rateLimitWindow), + parseInt(options.rateLimitMax) + ); + } catch (error) { + console.error('Fatal error:', error); + process.exit(1); + } + }); + +program.parse(process.argv); + diff --git a/calm-server/src/server/routes/health-route.spec.ts b/calm-server/src/server/routes/health-route.spec.ts new file mode 100644 index 000000000..09ce23b37 --- /dev/null +++ b/calm-server/src/server/routes/health-route.spec.ts @@ -0,0 +1,25 @@ +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' }); + }); +}); diff --git a/calm-server/src/server/routes/health-route.ts b/calm-server/src/server/routes/health-route.ts new file mode 100644 index 000000000..4e96c9a99 --- /dev/null +++ b/calm-server/src/server/routes/health-route.ts @@ -0,0 +1,19 @@ +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; + } +} diff --git a/calm-server/src/server/routes/routes.spec.ts b/calm-server/src/server/routes/routes.spec.ts new file mode 100644 index 000000000..dfb834643 --- /dev/null +++ b/calm-server/src/server/routes/routes.spec.ts @@ -0,0 +1,62 @@ +import { Router } from 'express'; +import { ServerRoutes } from './routes'; +import { ValidationRouter } from './validation-route'; +import { HealthRouter } from './health-route'; +import { SchemaDirectory } from '@finos/calm-shared'; + +vi.mock('express', () => ({ + Router: vi.fn() +})); + +vi.mock('./validation-route', () => { + return { + ValidationRouter: vi.fn() + }; +}); + +vi.mock('./health-route', () => { + return { + HealthRouter: vi.fn() + }; +}); + +vi.mock('@finos/calm-shared', () => { + return { + SchemaDirectory: vi.fn() + }; +}); +describe('ServerRoutes', () => { + let schemaDirectory: SchemaDirectory; + let serverRoutes: ServerRoutes; + let mainRouter: Router; + let validateRouter: Router; + let healthRouter: Router; + + beforeEach(() => { + mainRouter = { use: vi.fn() } as unknown as Router; + validateRouter = { use: vi.fn() } as unknown as Router; + healthRouter = { use: vi.fn() } as unknown as Router; + const routerMock = Router as unknown as vi.Mock; + routerMock.mockReset(); + routerMock + .mockImplementationOnce(() => mainRouter) + .mockImplementationOnce(() => validateRouter) + .mockImplementationOnce(() => healthRouter); + serverRoutes = new ServerRoutes(schemaDirectory); + void serverRoutes; + }); + + it('should initialize router', () => { + expect(Router).toHaveBeenCalled(); + }); + + it('should set up validate route', () => { + expect(mainRouter.use).toHaveBeenCalledWith('/calm/validate', validateRouter); + expect(ValidationRouter).toHaveBeenCalledWith(validateRouter, schemaDirectory, false, 900000, 100); + }); + + it('should set up health route', () => { + expect(mainRouter.use).toHaveBeenCalledWith('/health', healthRouter); + expect(HealthRouter).toHaveBeenCalledWith(healthRouter); + }); +}); diff --git a/calm-server/src/server/routes/routes.ts b/calm-server/src/server/routes/routes.ts new file mode 100644 index 000000000..e63149514 --- /dev/null +++ b/calm-server/src/server/routes/routes.ts @@ -0,0 +1,27 @@ +import { Router } from 'express'; +import { ValidationRouter } from './validation-route'; +import { HealthRouter } from './health-route'; +import { SchemaDirectory } from '@finos/calm-shared'; + +const HEALTH_ROUTE_PATH = '/health'; +const VALIDATE_ROUTE_PATH = '/calm/validate'; + +export class ServerRoutes { + router: Router; + + constructor( + schemaDirectory: SchemaDirectory, + debug: boolean = false, + rateLimitWindowMs: number = 900000, // 15 minutes + rateLimitMaxRequests: number = 100 + ) { + this.router = Router(); + const validateRouter = Router(); + this.router.use(VALIDATE_ROUTE_PATH, validateRouter); + new ValidationRouter(validateRouter, schemaDirectory, debug, rateLimitWindowMs, rateLimitMaxRequests); + + const healthRouter = Router(); + this.router.use(HEALTH_ROUTE_PATH, healthRouter); + new HealthRouter(healthRouter); + } +} diff --git a/calm-server/src/server/routes/validation-route.spec.ts b/calm-server/src/server/routes/validation-route.spec.ts new file mode 100644 index 000000000..005bae31b --- /dev/null +++ b/calm-server/src/server/routes/validation-route.spec.ts @@ -0,0 +1,155 @@ +import request from 'supertest'; +import * as fs from 'fs'; + +import express, { Application } from 'express'; +import { ValidationRouter } from './validation-route'; +import path from 'path'; +import { FileSystemDocumentLoader, SchemaDirectory } from '@finos/calm-shared'; +import { vi } from 'vitest'; + +const schemaDirectoryPath: string = __dirname + '/../../../../calm/release'; +const apiGatewayPatternPath: string = + __dirname + '/../../../test_fixtures/api-gateway'; + +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); + }); + + 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 architecture JSON is invalid', async () => { + const requestBody = { + architecture: 'not valid json {' + }; + + const response = await request(app) + .post('/calm/validate') + .send(requestBody); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + error: 'Invalid JSON format for architecture' + }); + }); + + 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 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 requestBody = { + architecture: JSON.stringify({ $schema: 'https://example.com/schema' }) + }; + + const response = await request(app) + .post('/calm/validate') + .send(requestBody); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ + error: 'Failed to load schemas' + }); + }); + + test('should return 500 when getSchema 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().mockRejectedValueOnce(new Error('Schema retrieval error')) + } as unknown as SchemaDirectory; + + new ValidationRouter(router, mockSchemaDirectory); + app.use('/calm/validate', router); + + const requestBody = { + architecture: JSON.stringify({ $schema: 'https://example.com/schema' }) + }; + + const response = await request(app) + .post('/calm/validate') + .send(requestBody); + + expect(response.status).toBe(500); + expect(response.body.error).toContain('Failed to load schema'); + }); + + 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'); + }); +}); diff --git a/calm-server/src/server/routes/validation-route.ts b/calm-server/src/server/routes/validation-route.ts new file mode 100644 index 000000000..3e0debb66 --- /dev/null +++ b/calm-server/src/server/routes/validation-route.ts @@ -0,0 +1,95 @@ +import { SchemaDirectory, validate, ValidationOutcome, initLogger } from '@finos/calm-shared'; +import type { Logger } from '@finos/calm-shared'; +import { Router, Request, Response } from 'express'; +import rateLimit from 'express-rate-limit'; + +export class ValidationRouter { + private schemaDirectory: SchemaDirectory; + private logger: Logger; + private schemaLoadPromise: Promise | null = null; + + constructor( + router: Router, + schemaDirectory: SchemaDirectory, + debug: boolean = false, + rateLimitWindowMs: number = 900000, // 15 minutes + rateLimitMaxRequests: number = 100 + ) { + const limiter = rateLimit({ + windowMs: rateLimitWindowMs, + max: rateLimitMaxRequests, + }); + this.schemaDirectory = schemaDirectory; + this.logger = initLogger(debug, 'calm-server'); + router.use(limiter); + this.initializeRoutes(router); + } + + private initializeRoutes(router: Router) { + router.post('/', this.validateSchema); + } + + private async ensureSchemasLoaded() { + if (!this.schemaLoadPromise) { + this.schemaLoadPromise = this.schemaDirectory.loadSchemas().catch((error) => { + this.schemaLoadPromise = null; + throw error; + }); + } + + await this.schemaLoadPromise; + } + + private validateSchema = async ( + req: Request, ValidationOutcome | ErrorResponse, ValidationRequest>, + 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')); + } + + 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')); + } + let foundSchema; + try { + foundSchema = await this.schemaDirectory.getSchema(schema); + if (!foundSchema) { + this.logger.error('Schema with $id ' + schema + ' not found'); + return res.status(400).type('json').send(new ErrorResponse('The "$schema" field referenced is not available to the server')); + } + } catch (err) { + this.logger.error('Failed to load schema: ' + err); + return res.status(500).type('json').send(new ErrorResponse('Failed to load schema: ' + err)); + } + try { + 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)); + } + }; +} + +class ErrorResponse { + error: string; + constructor(error: string) { + this.error = error; + } +} + +class ValidationRequest { + architecture: string; +} diff --git a/calm-server/src/server/server.spec.ts b/calm-server/src/server/server.spec.ts new file mode 100644 index 000000000..acf8e513b --- /dev/null +++ b/calm-server/src/server/server.spec.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { startServer } from './server'; +import { SchemaDirectory } from '@finos/calm-shared'; +import fetch from 'node-fetch'; +import { Server } from 'http'; +import { vi } from 'vitest'; + +describe('startServer', () => { + let serverInstance: Server; + const port = '3001'; + const host = '127.0.0.1'; + const schemaDirectory = { + loadSchemas: vi.fn(), + getSchema: vi.fn(), + } as unknown as SchemaDirectory; + + afterEach(() => { + if (serverInstance) { + serverInstance.close(); + } + }); + + it('should start the server and respond to /health', async () => { + serverInstance = startServer(port, host, schemaDirectory, false, 900000, 100); + + // Wait for server to be ready + await new Promise((resolve) => setTimeout(resolve, 100)); + + const response = await fetch(`http://${host}:${port}/health`); + expect(response.status).toBe(200); + const data = (await response.json()) as { status: string }; + expect(data.status).toBe('OK'); + }); +}); diff --git a/calm-server/src/server/server.ts b/calm-server/src/server/server.ts new file mode 100644 index 000000000..9779128c5 --- /dev/null +++ b/calm-server/src/server/server.ts @@ -0,0 +1,30 @@ +import express, { Application } from 'express'; +import { ServerRoutes } from './routes/routes'; +import { initLogger, SchemaDirectory } from '@finos/calm-shared'; +import { Server } from 'http'; + +export function startServer( + port: string, + host: string, + schemaDirectory: SchemaDirectory, + verbose: boolean, + rateLimitWindowMs: number = 900000, // 15 minutes + rateLimitMaxRequests: number = 100 +): Server { + const app: Application = express(); + const serverRoutesInstance = new ServerRoutes( + schemaDirectory, + verbose, + rateLimitWindowMs, + rateLimitMaxRequests + ); + const allRoutes = serverRoutesInstance.router; + + app.use(express.json()); + app.use('/', allRoutes); + + return app.listen(parseInt(port), host, () => { + const logger = initLogger(verbose, 'calm-server'); + logger.info(`CALM Server is running on http://${host}:${port}`); + }); +} diff --git a/calm-server/test_fixtures/api-gateway/api-gateway.json b/calm-server/test_fixtures/api-gateway/api-gateway.json new file mode 100644 index 000000000..b265ecc56 --- /dev/null +++ b/calm-server/test_fixtures/api-gateway/api-gateway.json @@ -0,0 +1,254 @@ +{ + "$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 diff --git a/calm-server/test_fixtures/validation_route/invalid_api_gateway_instantiation_missing_schema_key.json b/calm-server/test_fixtures/validation_route/invalid_api_gateway_instantiation_missing_schema_key.json new file mode 100644 index 000000000..390cd0800 --- /dev/null +++ b/calm-server/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/calm-server/test_fixtures/validation_route/invalid_api_gateway_instantiation_schema_points_to_missing_schema.json b/calm-server/test_fixtures/validation_route/invalid_api_gateway_instantiation_schema_points_to_missing_schema.json new file mode 100644 index 000000000..58367a646 --- /dev/null +++ b/calm-server/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/calm-server/test_fixtures/validation_route/valid_instantiation.json b/calm-server/test_fixtures/validation_route/valid_instantiation.json new file mode 100644 index 000000000..ef16095ec --- /dev/null +++ b/calm-server/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/calm-server/tsconfig.build.json b/calm-server/tsconfig.build.json new file mode 100644 index 000000000..8c0f74bb0 --- /dev/null +++ b/calm-server/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["**/*.spec.ts"] +} diff --git a/calm-server/tsconfig.json b/calm-server/tsconfig.json new file mode 100644 index 000000000..05da2b641 --- /dev/null +++ b/calm-server/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "module": "Preserve", + "moduleResolution": "bundler", + "lib": [ + "esnext" + ], + "strict": true + }, + "include": ["src", "../vitest-globals.d.ts"] +} diff --git a/calm-server/tsup.config.ts b/calm-server/tsup.config.ts new file mode 100644 index 000000000..ed0322a76 --- /dev/null +++ b/calm-server/tsup.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs'], + sourcemap: false, + clean: true, + external: ['canvas', 'fsevents', '@apidevtools/json-schema-ref-parser', /node_modules/, 'ts-node'], + noExternal: ['@finos/calm-shared', '@finos/calm-widgets', '@finos/calm-models', /tsup/], + bundle: true, + splitting: false, + minify: false, + shims: true, + target: 'es2021', + treeshake: true, + banner: ({ format }) => { + if (format === 'cjs') { + return { + js: '#!/usr/bin/env node' + }; + } + } +}); diff --git a/calm-server/vitest.config.ts b/calm-server/vitest.config.ts new file mode 100644 index 000000000..2a94d2682 --- /dev/null +++ b/calm-server/vitest.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'vitest/config'; +import { CoverageV8Options } from "vitest/node"; + +const v8CoverageSettings: CoverageV8Options = { + enabled: true, + reporter: ['text', 'json', 'html'], + thresholds: { + branches: 85, + functions: 75, + lines: 75, + statements: 75 + }, + exclude: [ + 'test_fixtures/**', + '*.config.ts', + 'src/index.ts' // CLI boilerplate, tested via integration tests + ], + include: ['**/*.ts'] +} + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + coverage: { + provider: 'v8', + ...v8CoverageSettings + }, + }, +}); diff --git a/commitlint.config.js b/commitlint.config.js index c9954212a..4e661eb15 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -9,6 +9,7 @@ module.exports = { [ 'cli', 'shared', + 'calm-server', 'calm-widgets', 'calm-hub', 'calm-hub-ui', diff --git a/package-lock.json b/package-lock.json index b83dea656..d9e1ea3c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "workspaces": [ "calm-models", + "calm-server", "calm-widgets", "calm-ai", "shared", @@ -214,6 +215,301 @@ } } }, + "calm-server": { + "name": "@finos/calm-server", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@finos/calm-shared": "file:../shared", + "commander": "^14.0.0", + "copyfiles": "^2.4.1", + "express": "^4.18.2", + "express-rate-limit": "^8.0.0" + }, + "bin": { + "calm-server": "dist/index.js" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.15.0", + "@types/supertest": "^6.0.3", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^9.0.0", + "node-fetch": "^2.7.0", + "supertest": "^7.0.0", + "tsup": "^8.3.5", + "typescript": "^5.8.2", + "vitest": "^3.1.1" + } + }, + "calm-server/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "calm-server/node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "calm-server/node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "calm-server/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "calm-server/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "calm-server/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "calm-server/node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "calm-server/node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "calm-server/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "calm-server/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "calm-server/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "calm-server/node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "calm-server/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "calm-server/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "calm-server/node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "calm-server/node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "calm-server/node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "calm-server/node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "calm-server/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "calm-widgets": { "name": "@finos/calm-widgets", "version": "1.0.0", @@ -6371,6 +6667,10 @@ "resolved": "calm-models", "link": true }, + "node_modules/@finos/calm-server": { + "resolved": "calm-server", + "link": true + }, "node_modules/@finos/calm-shared": { "resolved": "shared", "link": true diff --git a/package.json b/package.json index 0fbac114a..295799749 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ }, "workspaces": [ "calm-models", + "calm-server", "calm-widgets", "calm-ai", "shared", @@ -17,11 +18,13 @@ ], "scripts": { "build": "npm run build --workspaces --if-present", + "build:calm-server": "npm run build --workspace calm-models --workspace calm-widgets --workspace shared --workspace calm-server", "build:cli": "npm run build --workspace calm-models --workspace calm-widgets --workspace shared --workspace cli", "build:shared": "npm run build --workspace calm-models --workspace calm-widgets --workspace shared", "build:calm-widgets": "npm run build --workspace calm-models --workspace calm-widgets", "build:docs": "npm run build --workspace docs", "test": "npm run test --workspaces --if-present", + "test:calm-server": "npm run build:calm-server && npm run test --workspace calm-server", "test:cli": "npm run build:cli && npm run test --workspace cli", "test:shared": "npm run build:shared && npm run test --workspace shared", "test:models": "npm run build:calm-models && npm run test --workspace calm-models", @@ -35,6 +38,7 @@ "watch:shared": "npm run watch --workspace shared", "watch:models": "npm run watch --workspace calm-models", "watch:calm-widgets": "npm run watch --workspace calm-widgets", + "link:calm-server": "npm link --workspace calm-server", "link:cli": "npm link --workspace cli", "calm-hub-ui:run": "npm run start --workspace calm-hub-ui", "calm-hub-ui:prod": "npm run prod --workspace calm-hub-ui", diff --git a/shared/src/index.ts b/shared/src/index.ts index 5256d2ddc..a7021f2d4 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -15,6 +15,7 @@ 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 './logger.js'; +export type { Logger } from './logger.js'; export { TemplateProcessor, TemplateProcessingMode } from './template/template-processor.js'; export * from './template/types.js'; export { @@ -33,6 +34,8 @@ export { CalmRelationshipGraph } from './docify/graphing/relationship-graph.js'; export { ValidationOutcome } from './commands/validate/validation.output'; export * from './test/file-comparison.js'; export { setWidgetLogger, type WidgetLogger } from '@finos/calm-widgets'; +export { buildDocumentLoader, DocumentLoader, DocumentLoaderOptions } from './document-loader/document-loader'; +export { FileSystemDocumentLoader } from './document-loader/file-system-document-loader'; export * from './document-loader/loading-helpers.js'; export { hasArchitectureExtension,