diff --git a/.dockerignore b/.dockerignore index ab0507d6..27078132 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,3 +15,4 @@ docker-compose.yml .env k8s .husky +gen diff --git a/.eslintignore b/.eslintignore index 38fd7e2b..d4296596 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,3 +2,4 @@ node_modules/ build/ dist/ coverage/ +gen/ diff --git a/.gitignore b/.gitignore index 05202096..4f50cbe6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ coverage/ pkg/ bin/ .env +gen/ diff --git a/.prettierignore b/.prettierignore index 38fd7e2b..d4296596 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,4 @@ node_modules/ build/ dist/ coverage/ +gen/ diff --git a/copy-other-required-files.ts b/copy-other-required-files.ts new file mode 100644 index 00000000..481aaea6 --- /dev/null +++ b/copy-other-required-files.ts @@ -0,0 +1,17 @@ +import { basename } from 'path'; +import { cp } from 'fs'; +import copydir from 'copy-dir'; + +cp('./open-api.json', './dist/open-api.json', (error) => { + if (error) { + throw error; + } +}); + +copydir('./node_modules/swagger-ui-dist', './dist/', { + filter: (_, filepath) => { + const filename = basename(filepath); + + return filename.startsWith('swagger-ui') || filename.startsWith('favicon'); + } +}); diff --git a/open-api.json b/open-api.json new file mode 100644 index 00000000..c80745a3 --- /dev/null +++ b/open-api.json @@ -0,0 +1,225 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Consents API", + "version": "0.1.0" + }, + "paths": { + "/users": { + "post": { + "tags": [ + "Users" + ], + "summary": "Creates a new user", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": true, + "properties": { + "email": { + "type": "string", + "description": "Unique email address of user", + "required": true + } + } + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Returns newly created user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/userResponse" + } + } + } + }, + "422": { + "description": "When input is not valid", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/errorsResponse" + } + } + } + } + } + } + }, + "/users/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "delete": { + "tags": [ + "Users" + ], + "summary": "Deletes the user", + "responses": { + "204": { + "description": "No content is returned upon success" + }, + "404": { + "description": "When user does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/errorsResponse" + } + } + } + } + } + }, + "get": { + "tags": [ + "Users" + ], + "summary": "Returns the user", + "responses": { + "200": { + "description": "Returns the matching user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/userResponse" + } + } + } + }, + "404": { + "description": "When user does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/errorsResponse" + } + } + } + } + } + } + }, + "/events": { + "post": { + "tags": [ + "Events" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": true, + "properties": { + "user": { + "type": "object", + "required": true, + "properties": { + "id": { + "type": "string", + "required": true + } + } + }, + "consents": { + "type": "array", + "required": true, + "items": { + "type": "object", + "required": true, + "properties": { + "id": { + "type": "string", + "required": true, + "enum": [ + "email_notifications", + "sms_notifications" + ] + }, + "enabled": { + "type": "boolean", + "required": true + } + } + } + } + } + } + } + } + }, + "summary": "Records consents of the given user", + "responses": { + "201": { + "description": "Returns nothing upon success" + }, + "422": { + "description": "When input is not valid", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/errorsResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "errorsResponse": { + "type": "object", + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "userResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "email": { + "type": "string" + }, + "consents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + } + } + } + } + } + } + } +} diff --git a/package-lock.json b/package-lock.json index 41af4b48..b2b733d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "pg": "^8.8.0", "pino": "^8.6.1", "reflect-metadata": "^0.1.13", + "swagger-ui-express": "^4.5.0", "tsyringe": "^4.7.0", "uuid": "^9.0.0" }, @@ -25,10 +26,12 @@ "@types/jest": "^29.1.1", "@types/node": "^18.7.23", "@types/pg": "^8.6.5", + "@types/swagger-ui-express": "^4.1.3", "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "^5.38.1", "@typescript-eslint/parser": "^5.38.1", "@vercel/ncc": "^0.34.0", + "copy-dir": "^1.3.0", "dotenv": "^16.0.3", "eslint": "^8.24.0", "eslint-config-prettier": "^8.5.0", @@ -1783,6 +1786,16 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.3.tgz", + "integrity": "sha512-jqCjGU/tGEaqIplPy3WyQg+Nrp6y80DCFnDEAvVKWkJyv0VivSSDCChkppHRHAablvInZe6pijDFMnavtN0vqA==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@types/uuid": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", @@ -2785,6 +2798,12 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "node_modules/copy-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/copy-dir/-/copy-dir-1.3.0.tgz", + "integrity": "sha512-Q4+qBFnN4bwGwvtXXzbp4P/4iNk0MaiGAzvQ8OiMtlLjkIKjmNN689uVzShSM0908q7GoFHXIPx4zi75ocoaHw==", + "dev": true + }, "node_modules/core-js-pure": { "version": "3.25.0", "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.25.0.tgz", @@ -7689,6 +7708,25 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/swagger-ui-dist": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.14.2.tgz", + "integrity": "sha512-kOIU7Ts3TrXDLb3/c9jRe4qGp8O3bRT19FFJA8wJfrRFkcK/4atPn3krhtBVJ57ZkNNofworXHxuYwmaisXBdg==" + }, + "node_modules/swagger-ui-express": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.5.0.tgz", + "integrity": "sha512-DHk3zFvsxrkcnurGvQlAcLuTDacAVN1JHKDgcba/gr2NFRE4HGwP1YeHIXMiGznkWR4AeS7X5vEblNn4QljuNA==", + "dependencies": { + "swagger-ui-dist": ">=4.11.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0" + } + }, "node_modules/terminal-link": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", @@ -9772,6 +9810,16 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "@types/swagger-ui-express": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.3.tgz", + "integrity": "sha512-jqCjGU/tGEaqIplPy3WyQg+Nrp6y80DCFnDEAvVKWkJyv0VivSSDCChkppHRHAablvInZe6pijDFMnavtN0vqA==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "@types/uuid": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", @@ -10486,6 +10534,12 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "copy-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/copy-dir/-/copy-dir-1.3.0.tgz", + "integrity": "sha512-Q4+qBFnN4bwGwvtXXzbp4P/4iNk0MaiGAzvQ8OiMtlLjkIKjmNN689uVzShSM0908q7GoFHXIPx4zi75ocoaHw==", + "dev": true + }, "core-js-pure": { "version": "3.25.0", "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.25.0.tgz", @@ -14169,6 +14223,19 @@ "integrity": "sha512-Bh05dSOnJBf3miNMqpsormfNtfidA/GxQVakhtn0T4DECWKeXQRQUceYjJ+OxYiiLdGe4Jo9iFV8wICFapFeIA==", "dev": true }, + "swagger-ui-dist": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.14.2.tgz", + "integrity": "sha512-kOIU7Ts3TrXDLb3/c9jRe4qGp8O3bRT19FFJA8wJfrRFkcK/4atPn3krhtBVJ57ZkNNofworXHxuYwmaisXBdg==" + }, + "swagger-ui-express": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.5.0.tgz", + "integrity": "sha512-DHk3zFvsxrkcnurGvQlAcLuTDacAVN1JHKDgcba/gr2NFRE4HGwP1YeHIXMiGznkWR4AeS7X5vEblNn4QljuNA==", + "requires": { + "swagger-ui-dist": ">=4.11.0" + } + }, "terminal-link": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", diff --git a/package.json b/package.json index 0bbd8d03..6a5f5083 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "coverage": "jest --coverage", "build": "rimraf build && tsc", "pack": "rimraf dist && ncc build --source-map", - "release": "npm run build && npm run pack", + "copy-other-required-files": "ts-node copy-other-required-files.ts", + "release": "npm run build && npm run pack && npm run copy-other-required-files", "start": "nodemon src/app.ts" }, "dependencies": { @@ -23,6 +24,7 @@ "pg": "^8.8.0", "pino": "^8.6.1", "reflect-metadata": "^0.1.13", + "swagger-ui-express": "^4.5.0", "tsyringe": "^4.7.0", "uuid": "^9.0.0" }, @@ -33,10 +35,12 @@ "@types/jest": "^29.1.1", "@types/node": "^18.7.23", "@types/pg": "^8.6.5", + "@types/swagger-ui-express": "^4.1.3", "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "^5.38.1", "@typescript-eslint/parser": "^5.38.1", "@vercel/ncc": "^0.34.0", + "copy-dir": "^1.3.0", "dotenv": "^16.0.3", "eslint": "^8.24.0", "eslint-config-prettier": "^8.5.0", diff --git a/src/app.ts b/src/app.ts index 274eda66..91d6a53c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,12 +9,13 @@ import { Client, Pool } from 'pg'; import { container } from 'tsyringe'; import PostgreSQL from './infrastructure/postgre-sql'; -import UsersController from './controllers/users-controller'; -import EventsController from './controllers/events-controller'; import userRouter from './routes/users-router'; +import UsersController from './controllers/users-controller'; import eventsRouter from './routes/events-router'; +import EventsController from './controllers/events-controller'; import healthRouter from './routes/health-router'; import HealthController from './controllers/health-controller'; +import openApiRouter from './routes/open-api-router'; const logger = Pino(); @@ -41,7 +42,8 @@ const app = express() .use(express.json()) .use('/users', userRouter(container.resolve(UsersController))) .use('/events', eventsRouter(container.resolve(EventsController))) - .use('/health', healthRouter(container.resolve(HealthController))); + .use('/health', healthRouter(container.resolve(HealthController))) + .use('/', openApiRouter()); // eslint-disable-next-line @typescript-eslint/no-floating-promises (async () => { diff --git a/src/controllers/users-controller.test.ts b/src/controllers/users-controller.test.ts index 552b9421..1aacf778 100644 --- a/src/controllers/users-controller.test.ts +++ b/src/controllers/users-controller.test.ts @@ -216,7 +216,7 @@ describe('UsersController', () => { }); describe('non-existent', () => { - let mockedResponseEnd: jest.Mock; + let mockedResponseJson: jest.Mock; let mockedResponseStatus: jest.Mock; let getRequest: UserGetRequest; @@ -237,8 +237,8 @@ describe('UsersController', () => { } }; - mockedResponseEnd = jest.fn(); - mockedResponseStatus = jest.fn(() => ({ end: mockedResponseEnd })); + mockedResponseJson = jest.fn(); + mockedResponseStatus = jest.fn(() => ({ json: mockedResponseJson })); const res = { status: mockedResponseStatus @@ -259,8 +259,8 @@ describe('UsersController', () => { expect(mockedResponseStatus).toHaveBeenCalledWith(404); }); - it('sends no response', () => { - expect(mockedResponseEnd).toHaveBeenCalled(); + it('sends error', () => { + expect(mockedResponseJson).toHaveBeenCalled(); }); }); }); diff --git a/src/controllers/users-controller.ts b/src/controllers/users-controller.ts index 76638033..aeddbf08 100644 --- a/src/controllers/users-controller.ts +++ b/src/controllers/users-controller.ts @@ -43,7 +43,9 @@ export default class UsersController { const result = await this._mediator.send(request); if (!result) { - res.status(404).end(); + res.status(404).json({ + errors: ['User does not exist!'] + }); return; } diff --git a/src/routes/open-api-router.ts b/src/routes/open-api-router.ts new file mode 100644 index 00000000..f190f78d --- /dev/null +++ b/src/routes/open-api-router.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'fs'; +import { join, resolve } from 'path'; + +import type { Router } from 'express'; +import express from 'express'; + +import type { JsonObject } from 'swagger-ui-express'; +import { serve, setup } from 'swagger-ui-express'; + +export default function openApiRouter(): Router { + const getDocument = (): JsonObject => { + const path = join(resolve(), 'open-api.json'); + const content = readFileSync(path, 'utf8'); + + return JSON.parse(content) as JsonObject; + }; + + const router = express.Router(); + + router.use(serve); + router.get('/', setup(getDocument())); + + return router; +}