diff --git a/server/.env b/server/.env new file mode 100644 index 0000000..6e54c4f --- /dev/null +++ b/server/.env @@ -0,0 +1,5 @@ +# Server Configuration +HOST=localhost +PORT=9000 + +# Other configuration variables can be added here as needed \ No newline at end of file diff --git a/server/.env_example b/server/.env_example new file mode 100644 index 0000000..6e54c4f --- /dev/null +++ b/server/.env_example @@ -0,0 +1,5 @@ +# Server Configuration +HOST=localhost +PORT=9000 + +# Other configuration variables can be added here as needed \ No newline at end of file diff --git a/server/index.ts b/server/index.ts index e400e69..b617a29 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,8 +1,13 @@ import 'source-map-support/register'; -import OpenAPIBackend, { Request } from 'openapi-backend'; +import OpenAPIBackend, { Request, Context } from 'openapi-backend'; import Express from 'express'; -import morgan from 'morgan' +import morgan from 'morgan'; import path from 'path'; +import dotenv from 'dotenv'; +import { TemplateStore, TemplateNotFoundError, DuplicateTemplateError } from './src/services/TemplateStore'; + +// Load environment variables from .env file +dotenv.config({ path: path.join(__dirname, '..', '.env') }); import { Request as ExpressReq, Response as ExpressRes } from 'express'; @@ -12,24 +17,85 @@ app.use(Express.json()); const openApiPath = path.join(__dirname, '..', '..', 'openapi.json'); console.log(openApiPath); +// Initialize template store +const templateStore = new TemplateStore(); + // define api const api = new OpenAPIBackend({ quick: true, // disabled validation of OpenAPI on load definition: openApiPath, handlers: { - listTemplates: async (c, req: Express.Request, res: Express.Response) => - res.status(200).json([]), - createTemplate: async (c, req: Express.Request, res: Express.Response) => - res.status(200).json({}), - getTemplate: async (c, req: Express.Request, res: Express.Response) => - res.status(200).json({}), - replaceTemplate: async (c, req: Express.Request, res: Express.Response) => - res.status(200).json({}), - deleteTemplate: async (c, req: Express.Request, res: Express.Response) => - res.status(200).json({}), - validationFail: async (c, req: ExpressReq, res: ExpressRes) => res.status(400).json({ err: c.validation.errors }), - notFound: async (c, req: ExpressReq, res: ExpressRes) => res.status(404).json({ err: 'not found' }), - notImplemented: async (c, req: ExpressReq, res: ExpressRes) => { + listTemplates: async (c: Context, req: Express.Request, res: Express.Response) => { + try { + const templates = await templateStore.listTemplates(); + res.status(200).json(templates); + } catch (error) { + console.error('Error listing templates:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }, + createTemplate: async (c: Context, req: Express.Request, res: Express.Response) => { + try { + const template = await templateStore.createTemplate(req.body); + res.status(201).json(template); + } catch (error) { + if (error instanceof DuplicateTemplateError) { + res.status(409).json({ error: error.message }); + } else { + console.error('Error creating template:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } + }, + getTemplate: async (c: Context, req: Express.Request, res: Express.Response) => { + try { + const id = Array.isArray(c.request.params.id) ? c.request.params.id[0] : c.request.params.id; + const template = await templateStore.getTemplate(id); + res.status(200).json(template); + } catch (error) { + if (error instanceof TemplateNotFoundError) { + res.status(404).json({ error: error.message }); + } else { + console.error('Error getting template:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } + }, + replaceTemplate: async (c: Context, req: Express.Request, res: Express.Response) => { + try { + const id = Array.isArray(c.request.params.id) ? c.request.params.id[0] : c.request.params.id; + const template = await templateStore.updateTemplate(id, req.body); + res.status(200).json(template); + } catch (error) { + if (error instanceof TemplateNotFoundError) { + res.status(404).json({ error: error.message }); + } else if (error instanceof DuplicateTemplateError) { + res.status(409).json({ error: error.message }); + } else { + console.error('Error updating template:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } + }, + deleteTemplate: async (c: Context, req: Express.Request, res: Express.Response) => { + try { + const id = Array.isArray(c.request.params.id) ? c.request.params.id[0] : c.request.params.id; + await templateStore.deleteTemplate(id); + res.status(204).send(); + } catch (error) { + if (error instanceof TemplateNotFoundError) { + res.status(404).json({ error: error.message }); + } else { + console.error('Error deleting template:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } + }, + validationFail: async (c: Context, req: ExpressReq, res: ExpressRes) => + res.status(400).json({ error: c.validation.errors }), + notFound: async (c: Context, req: ExpressReq, res: ExpressRes) => + res.status(404).json({ error: 'not found' }), + notImplemented: async (c: Context, req: ExpressReq, res: ExpressRes) => { const { status, mock } = c.api.mockResponseForOperation(c.operation.operationId); return res.status(status).json(mock); }, @@ -42,7 +108,12 @@ api.init(); app.use(morgan('combined')); // use as express middleware -app.use((req, res) => api.handleRequest(req as Request, req, res)); +app.use((req: Express.Request, res: Express.Response) => api.handleRequest(req as Request, req, res)); + +const HOST = process.env.HOST || 'localhost'; +const PORT = parseInt(process.env.PORT || '9000', 10); // start server -app.listen(9000, () => console.info('api listening at http://localhost:9000')); \ No newline at end of file +app.listen(PORT, HOST, () => { + console.info(`API listening at http://${HOST}:${PORT}`); +}); \ No newline at end of file diff --git a/server/jest.config.js b/server/jest.config.js index cdbf3cc..f9f5976 100644 --- a/server/jest.config.js +++ b/server/jest.config.js @@ -1,5 +1,9 @@ module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: [ '**/?(*.)+(spec|test).ts?(x)' ] - }; \ No newline at end of file + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.tsx?$': 'ts-jest' + } +}; \ No newline at end of file diff --git a/server/package-lock.json b/server/package-lock.json index 6988b9e..3908971 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -10,23 +10,26 @@ "hasInstallScript": true, "license": "Apache-2", "dependencies": { - "express": "^4.16.4", - "morgan": "^1.9.1", + "@types/uuid": "^10.0.0", + "dotenv": "^16.4.5", + "express": "^4.17.1", + "morgan": "^1.10.0", "openapi-backend": "^5.2.0", - "source-map-support": "^0.5.10" + "source-map-support": "^0.5.19", + "uuid": "^11.1.0" }, "devDependencies": { - "@types/express": "^4.17.13", + "@types/express": "^4.17.21", "@types/jest": "^29.2.5", - "@types/morgan": "^1.7.35", - "@types/node": "^10.12.26", + "@types/morgan": "^1.9.9", + "@types/node": "^20.11.24", "axios": "^1.6.0", "concurrently": "^6.2.0", "jest": "^29.3.1", "nodemon": "^1.18.10", "ts-jest": "^29.0.3", "tslint": "^5.12.1", - "typescript": "^4.3.2", + "typescript": "^5.3.3", "wait-on": "^3.2.0" } }, @@ -1172,13 +1175,13 @@ } }, "node_modules/@types/express": { - "version": "4.17.16", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.16.tgz", - "integrity": "sha512-LkKpqRZ7zqXJuvoELakaFYuETHjZkSol8EV6cNnyishutDBCCdv6+dsKPbKkCcIk57qRphOLY5sEgClw1bO3gA==", + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", "dev": true, "dependencies": { "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.31", + "@types/express-serve-static-core": "^4.17.33", "@types/qs": "*", "@types/serve-static": "*" } @@ -1249,19 +1252,22 @@ "dev": true }, "node_modules/@types/morgan": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.4.tgz", - "integrity": "sha512-cXoc4k+6+YAllH3ZHmx4hf7La1dzUk6keTR4bF4b4Sc0mZxU/zK4wO7l+ZzezXm/jkYj/qC+uYGZrarZdIVvyQ==", + "version": "1.9.9", + "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.9.tgz", + "integrity": "sha512-iRYSDKVaC6FkGSpEVVIvrRGw0DfJMiQzIn3qr2G5B3C//AWkulhXgaBd7tS9/J79GWSYMTHGs7PfI5b3Y8m+RQ==", "dev": true, "dependencies": { "@types/node": "*" } }, "node_modules/@types/node": { - "version": "10.17.60", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", - "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", - "dev": true + "version": "20.17.23", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.23.tgz", + "integrity": "sha512-8PCGZ1ZJbEZuYNTMqywO+Sj4vSKjSjT6Ua+6RFOYlEvIvKQABPtrNkoVSLSKDb4obYcMhspVKmsw8Cm10NFRUg==", + "dev": true, + "dependencies": { + "undici-types": "~6.19.2" + } }, "node_modules/@types/prettier": { "version": "2.7.2", @@ -1297,6 +1303,11 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==" + }, "node_modules/@types/yargs": { "version": "17.0.20", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.20.tgz", @@ -2856,6 +2867,17 @@ "node": ">=4" } }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/duplexer3": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.5.tgz", @@ -6407,6 +6429,16 @@ "node": ">=0.6" } }, + "node_modules/request/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "dev": true, + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7696,16 +7728,16 @@ } }, "node_modules/typescript": { - "version": "4.9.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", - "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/undefsafe": { @@ -7714,6 +7746,12 @@ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", "dev": true }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true + }, "node_modules/union-value": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", @@ -7985,13 +8023,15 @@ } }, "node_modules/uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", - "dev": true, + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { - "uuid": "bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/v8-to-istanbul": { diff --git a/server/package.json b/server/package.json index 2311c76..bfa6047 100644 --- a/server/package.json +++ b/server/package.json @@ -1,7 +1,7 @@ { "name": "apap-server", "version": "1.0.0", - "description": "", + "description": "APAP Reference Implementation", "author": "Dan Selman", "license": "Apache-2", "keywords": [], @@ -9,30 +9,33 @@ "postinstall": "npm run build", "build": "tsc", "watch-build": "tsc -w", - "start": "node dist/index.js", + "start": "ts-node index.ts", "watch-start": "nodemon --delay 2 -w dist/ -x 'npm run start'", "dev": "concurrently -k -p '[{name}]' -n 'typescript,api' -c 'yellow.bold,cyan.bold' npm:watch-build npm:watch-start", "lint": "tslint --format prose --project .", "test": "jest" }, "dependencies": { - "express": "^4.16.4", - "morgan": "^1.9.1", + "@types/uuid": "^10.0.0", + "dotenv": "^16.4.5", + "express": "^4.17.1", + "morgan": "^1.10.0", "openapi-backend": "^5.2.0", - "source-map-support": "^0.5.10" + "source-map-support": "^0.5.19", + "uuid": "^11.1.0" }, "devDependencies": { - "@types/express": "^4.17.13", + "@types/express": "^4.17.21", "@types/jest": "^29.2.5", - "@types/morgan": "^1.7.35", - "@types/node": "^10.12.26", + "@types/morgan": "^1.9.9", + "@types/node": "^20.11.24", "axios": "^1.6.0", "concurrently": "^6.2.0", "jest": "^29.3.1", "nodemon": "^1.18.10", "ts-jest": "^29.0.3", "tslint": "^5.12.1", - "typescript": "^4.3.2", + "typescript": "^5.3.3", "wait-on": "^3.2.0" } -} \ No newline at end of file +} diff --git a/server/src/services/TemplateStore.test.ts b/server/src/services/TemplateStore.test.ts new file mode 100644 index 0000000..dfb68fb --- /dev/null +++ b/server/src/services/TemplateStore.test.ts @@ -0,0 +1,145 @@ +import { TemplateStore, TemplateNotFoundError, DuplicateTemplateError } from './TemplateStore'; + +describe('TemplateStore', () => { + let store: TemplateStore; + + beforeEach(() => { + store = new TemplateStore(); + }); + + describe('createTemplate', () => { + it('should create a new template', async () => { + const template = await store.createTemplate({ + name: 'Test Template', + content: 'Test Content', + metadata: { version: '1.0' } + }); + + expect(template.id).toBeDefined(); + expect(template.name).toBe('Test Template'); + expect(template.content).toBe('Test Content'); + expect(template.metadata).toEqual({ version: '1.0' }); + expect(template.createdAt).toBeInstanceOf(Date); + expect(template.updatedAt).toBeInstanceOf(Date); + }); + + it('should throw DuplicateTemplateError for duplicate names', async () => { + await store.createTemplate({ + name: 'Test Template', + content: 'Test Content' + }); + + await expect(store.createTemplate({ + name: 'Test Template', + content: 'Different Content' + })).rejects.toThrow(DuplicateTemplateError); + }); + }); + + describe('getTemplate', () => { + it('should retrieve an existing template', async () => { + const created = await store.createTemplate({ + name: 'Test Template', + content: 'Test Content' + }); + + const retrieved = await store.getTemplate(created.id); + expect(retrieved).toEqual(created); + }); + + it('should throw TemplateNotFoundError for non-existent template', async () => { + await expect(store.getTemplate('non-existent-id')) + .rejects.toThrow(TemplateNotFoundError); + }); + }); + + describe('updateTemplate', () => { + it('should update an existing template', async () => { + const created = await store.createTemplate({ + name: 'Test Template', + content: 'Test Content' + }); + + // Add a small delay to ensure updatedAt will be different + await new Promise(resolve => setTimeout(resolve, 1)); + + const updated = await store.updateTemplate(created.id, { + name: 'Updated Template', + content: 'Updated Content' + }); + + expect(updated.id).toBe(created.id); + expect(updated.name).toBe('Updated Template'); + expect(updated.content).toBe('Updated Content'); + expect(updated.createdAt).toEqual(created.createdAt); + expect(updated.updatedAt).toBeInstanceOf(Date); + expect(updated.updatedAt.getTime()).toBeGreaterThan(created.updatedAt.getTime()); + }); + + it('should throw TemplateNotFoundError for non-existent template', async () => { + await expect(store.updateTemplate('non-existent-id', { + name: 'Test', + content: 'Test' + })).rejects.toThrow(TemplateNotFoundError); + }); + + it('should throw DuplicateTemplateError when updating to existing name', async () => { + await store.createTemplate({ + name: 'Template 1', + content: 'Content 1' + }); + + const template2 = await store.createTemplate({ + name: 'Template 2', + content: 'Content 2' + }); + + await expect(store.updateTemplate(template2.id, { + name: 'Template 1', + content: 'Updated Content' + })).rejects.toThrow(DuplicateTemplateError); + }); + }); + + describe('deleteTemplate', () => { + it('should delete an existing template', async () => { + const created = await store.createTemplate({ + name: 'Test Template', + content: 'Test Content' + }); + + await store.deleteTemplate(created.id); + await expect(store.getTemplate(created.id)) + .rejects.toThrow(TemplateNotFoundError); + }); + + it('should throw TemplateNotFoundError for non-existent template', async () => { + await expect(store.deleteTemplate('non-existent-id')) + .rejects.toThrow(TemplateNotFoundError); + }); + }); + + describe('listTemplates', () => { + it('should return empty array when no templates exist', async () => { + const templates = await store.listTemplates(); + expect(templates).toEqual([]); + }); + + it('should return all created templates', async () => { + const template1 = await store.createTemplate({ + name: 'Template 1', + content: 'Content 1' + }); + + const template2 = await store.createTemplate({ + name: 'Template 2', + content: 'Content 2' + }); + + const templates = await store.listTemplates(); + expect(templates).toHaveLength(2); + expect(templates).toContainEqual(template1); + expect(templates).toContainEqual(template2); + }); + }); +}); \ No newline at end of file diff --git a/server/src/services/TemplateStore.ts b/server/src/services/TemplateStore.ts new file mode 100644 index 0000000..8aa0ca6 --- /dev/null +++ b/server/src/services/TemplateStore.ts @@ -0,0 +1,86 @@ +import { Template } from '../types/Template'; +import { v4 as uuidv4 } from 'uuid'; + +export class TemplateNotFoundError extends Error { + constructor(id: string) { + super(`Template with id ${id} not found`); + this.name = 'TemplateNotFoundError'; + } +} + +export class DuplicateTemplateError extends Error { + constructor(name: string) { + super(`Template with name ${name} already exists`); + this.name = 'DuplicateTemplateError'; + } +} + +export class TemplateStore { + private templates: Map; + + constructor() { + this.templates = new Map(); + } + + async listTemplates(): Promise { + return Array.from(this.templates.values()); + } + + async createTemplate(template: Omit): Promise