diff --git a/README.md b/README.md index 66bb83a0f..f808acdaa 100644 --- a/README.md +++ b/README.md @@ -1 +1,29 @@ # e-commerce-audit-log + + +## MongoDB + +### Setup MongoDB + +Start the docker container using: `docker-compose up -d` + +Verify it is running: `docker ps` + +Expected output: + +--- + +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES + +1a3553ec3f25 mongo:latest "docker-entrypoint.s…" 5 minutes ago Up 4 minutes 0.0.0.0:27017->27017/tcp api_mongodb_container_1 + +--- + +### Connect to MongoDB + +Use client like for instance: MongoDB Compass + +Coonection String: `mongodb://root:rootpassword@0.0.0.0:27017` + + + diff --git a/api/.env-example b/api/.env-example new file mode 100644 index 000000000..017a039e7 --- /dev/null +++ b/api/.env-example @@ -0,0 +1,5 @@ +PORT=3000 +IOTA_NODE_URL=https://nodes.thetangle.org:443 +API_VERSION=v1 +DATABASE_URL=mongodb://:@0.0.0.0:27017 +DATABASE_NAME=e-commerce-audit-log \ No newline at end of file diff --git a/api/.eslintrc.js b/api/.eslintrc.js index fcc3869d2..2024cd561 100644 --- a/api/.eslintrc.js +++ b/api/.eslintrc.js @@ -2,5 +2,9 @@ module.exports = { root: true, parser: '@typescript-eslint/parser', plugins: ['@typescript-eslint', 'prettier'], - extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'] + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off' + } }; diff --git a/api/docker-compose.yml b/api/docker-compose.yml new file mode 100644 index 000000000..38d56b414 --- /dev/null +++ b/api/docker-compose.yml @@ -0,0 +1,14 @@ +version: '3.7' +services: + mongodb_container: + image: mongo:latest + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: rootpassword + ports: + - 27017:27017 + volumes: + - mongodb_data_container:/data/db + +volumes: + mongodb_data_container: \ No newline at end of file diff --git a/api/package-lock.json b/api/package-lock.json index db3cdc8ab..b2280cbe2 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1106,6 +1106,15 @@ "@types/node": "*" } }, + "@types/bson": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/bson/-/bson-4.0.3.tgz", + "integrity": "sha512-mVRvYnTOZJz3ccpxhr3wgxVmSeiYinW+zlzQz3SXWaJmD1DuL05Jeq7nKw3SnbKmbleW5qrLG5vdyWe/A9sXhw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/connect": { "version": "3.4.34", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz", @@ -1199,6 +1208,25 @@ "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", "dev": true }, + "@types/mongodb": { + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.6.5.tgz", + "integrity": "sha512-XbG9+2wNaEwUn5DlhgN4ogjUYYzvjIsH6gwPvXXoTgfiQqUNq41RNxOqO+lrdpCjlRKtt/Pv7ZgSl7paQ/GUjw==", + "dev": true, + "requires": { + "@types/bson": "*", + "@types/node": "*" + } + }, + "@types/morgan": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.2.tgz", + "integrity": "sha512-edtGMEdit146JwwIeyQeHHg9yID4WSolQPxpEorHmN3KuytuCHyn2ELNr5Uxy8SerniFbbkmgKMrGM933am5BQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/node": { "version": "14.14.22", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.22.tgz", @@ -1846,6 +1874,14 @@ } } }, + "basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "requires": { + "safe-buffer": "5.1.2" + } + }, "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -1861,6 +1897,15 @@ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", "dev": true }, + "bl": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz", + "integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==", + "requires": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, "body-parser": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", @@ -1988,6 +2033,11 @@ "node-int64": "^0.4.0" } }, + "bson": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.5.tgz", + "integrity": "sha512-kDuEzldR21lHciPQAIulLs1LZlCXdLziXI6Mb/TDkwXhb//UORJNPXgcRs2CuO4H0DcMkpfT3/ySsP3unoZjBg==" + }, "buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", @@ -2321,8 +2371,7 @@ "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", - "dev": true + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, "create-require": { "version": "1.1.1", @@ -2505,6 +2554,11 @@ "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", "dev": true }, + "denque": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.0.tgz", + "integrity": "sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ==" + }, "depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", @@ -2577,6 +2631,11 @@ "is-obj": "^2.0.0" } }, + "dotenv": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", + "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==" + }, "duplexer3": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", @@ -4120,8 +4179,7 @@ "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, "isexe": { "version": "2.0.0", @@ -5729,6 +5787,12 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" }, + "memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "optional": true + }, "memorystream": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", @@ -5842,6 +5906,43 @@ "minimist": "^1.2.5" } }, + "moment": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" + }, + "mongodb": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.6.4.tgz", + "integrity": "sha512-Y+Ki9iXE9jI+n9bVtbTOOdK0B95d6wVGSucwtBkvQ+HIvVdTCfpVRp01FDC24uhC/Q2WXQ8Lpq3/zwtB5Op9Qw==", + "requires": { + "bl": "^2.2.1", + "bson": "^1.1.4", + "denque": "^1.4.1", + "require_optional": "^1.0.1", + "safe-buffer": "^5.1.2", + "saslprep": "^1.0.0" + } + }, + "morgan": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", + "integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==", + "requires": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.0.2" + }, + "dependencies": { + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + } + } + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -6162,6 +6263,11 @@ "ee-first": "1.1.1" } }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==" + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -6446,6 +6552,11 @@ } } }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -6614,6 +6725,20 @@ } } }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "readdirp": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", @@ -6777,6 +6902,22 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "dev": true }, + "require_optional": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require_optional/-/require_optional-1.0.1.tgz", + "integrity": "sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g==", + "requires": { + "resolve-from": "^2.0.0", + "semver": "^5.1.0" + }, + "dependencies": { + "resolve-from": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz", + "integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c=" + } + } + }, "resolve": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz", @@ -7018,6 +7159,15 @@ } } }, + "saslprep": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz", + "integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==", + "optional": true, + "requires": { + "sparse-bitfield": "^3.0.3" + } + }, "saxes": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", @@ -7030,8 +7180,7 @@ "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" }, "semver-diff": { "version": "3.1.1", @@ -7359,6 +7508,15 @@ "integrity": "sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==", "dev": true }, + "sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=", + "optional": true, + "requires": { + "memory-pager": "^1.0.2" + } + }, "spdx-correct": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", @@ -7570,6 +7728,14 @@ "define-properties": "^1.1.3" } }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + }, "strip-ansi": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", @@ -8138,6 +8304,11 @@ "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", "dev": true }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, "utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/api/package.json b/api/package.json index daccf9af0..d508f2097 100644 --- a/api/package.json +++ b/api/package.json @@ -8,6 +8,7 @@ "build-lint": "eslint src --ext .js,.jsx,.ts,.tsx", "build": "npm-run-all build-tsc build-lint", "test": "jest", + "test-watch": "npm run test -- --watchAll", "start": "node dist/index.js", "build-watch": "tsc --watch", "serve-nodemon": "nodemon ./dist/index", @@ -32,9 +33,13 @@ }, "homepage": "https://github.com/iotaledger/e-commerce-audit-log#readme", "dependencies": { + "dotenv": "^8.2.0", "express": "^4.17.1", "http-status-codes": "^2.1.4", - "lodash": "^4.17.20" + "lodash": "^4.17.20", + "moment": "^2.29.1", + "mongodb": "^3.6.4", + "morgan": "^1.10.0" }, "jest": { "testEnvironment": "node" @@ -43,6 +48,8 @@ "@types/express": "^4.17.11", "@types/jest": "^26.0.20", "@types/lodash": "^4.14.168", + "@types/mongodb": "^3.6.5", + "@types/morgan": "^1.9.2", "@types/node": "^14.14.22", "@typescript-eslint/eslint-plugin": "^4.14.1", "@typescript-eslint/parser": "^4.14.1", @@ -58,4 +65,4 @@ "tslint": "^6.1.3", "typescript": "^4.1.3" } -} \ No newline at end of file +} diff --git a/api/src/config/index.ts b/api/src/config/index.ts new file mode 100644 index 000000000..c7c983be7 --- /dev/null +++ b/api/src/config/index.ts @@ -0,0 +1,22 @@ +import { Config } from '../models/config'; +import isEmpty from 'lodash/isEmpty'; + +export const CONFIG: Config = { + port: process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000, + nodeUrl: process.env.IOTA_NODE_URL, + apiVersion: process.env.API_VERSION, + databaseUrl: process.env.DATABASE_URL, + databaseName: process.env.DATABASE_NAME +}; + +const assertConfig = (config: Config) => { + Object.values(config).map((value, i) => { + if (isEmpty(value) && (isNaN(value) || value == null || value === '')) { + console.log('========================================================'); + console.error('Env var is missing or invalid:', Object.keys(config)[i]); + console.log('========================================================'); + } + }); +}; + +assertConfig(CONFIG); diff --git a/api/src/database/channel-info.ts b/api/src/database/channel-info.ts new file mode 100644 index 000000000..5be634782 --- /dev/null +++ b/api/src/database/channel-info.ts @@ -0,0 +1,42 @@ +import { CollectionNames } from './constants'; +import { MongoDbService } from '../services/mongodb-service'; +import { ChannelInfo } from '../models/data/channel-info'; +import { DeleteWriteOpResultObject, InsertOneWriteOpResult, UpdateWriteOpResult, WithId } from 'mongodb'; + +export const getChannelInfo = async (channelAddress: string): Promise => { + const query = { _id: channelAddress }; + const collectionName = CollectionNames.channelInfo; + return await MongoDbService.getDocument(collectionName, query); +}; + +export const addChannelInfo = async (channelInfo: ChannelInfo): Promise>> => { + const document = { + _id: channelInfo.channelAddress, + ...channelInfo, + created: new Date() + }; + + const collectionName = CollectionNames.channelInfo; + return MongoDbService.insertDocument(collectionName, document); +}; + +export const updateChannelInfo = async (channelInfo: ChannelInfo): Promise => { + const query = { + _id: channelInfo.channelAddress + }; + const { topics } = channelInfo; + const update = { + $set: { + topics + } + }; + + const collectionName = CollectionNames.channelInfo; + return MongoDbService.upsertDocument(collectionName, query, update); +}; + +export const deleteChannelInfo = async (channelAddress: string): Promise => { + const query = { _id: channelAddress }; + const collectionName = CollectionNames.channelInfo; + return MongoDbService.removeDocument(collectionName, query); +}; diff --git a/api/src/database/constants.ts b/api/src/database/constants.ts new file mode 100644 index 000000000..12bc29376 --- /dev/null +++ b/api/src/database/constants.ts @@ -0,0 +1,3 @@ +export const enum CollectionNames { + channelInfo = 'channel-info' +} diff --git a/api/src/index.ts b/api/src/index.ts index 306263ef5..56adfe7de 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,13 +1,23 @@ import express from 'express'; -import { loggerMiddleware } from './middlewares/logger'; +import * as dotenv from 'dotenv'; +dotenv.config(); import { errorMiddleware } from './middlewares/error'; import { channelInfoRouter } from './routes/router'; +import { MongoDbService } from './services/mongodb-service'; +import { CONFIG } from './config'; +import morgan from 'morgan'; const app = express(); -const port = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000; +const port = CONFIG.port; +const dbUrl = CONFIG.databaseUrl; +const dbName = CONFIG.databaseName; +const version = CONFIG.apiVersion; +const loggerMiddleware = morgan('combined'); -function useRouter(app: express.Express, path: string, router: express.Router) { - console.log(router.stack.map((r) => Object.keys(r.route.methods)[0].toUpperCase() + ' ' + path + r.route.path)); +function useRouter(app: express.Express, prefix: string, router: express.Router) { + const path = `/${version}${prefix}`; + + console.log(router.stack.map((r) => `${Object.keys(r?.route?.methods)?.[0].toUpperCase()} ${path}${r?.route?.path}`)); app.use(path, router); } @@ -15,10 +25,11 @@ app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ limit: '10mb', extended: true })); app.use(loggerMiddleware); -useRouter(app, '/channel-info-service', channelInfoRouter); +useRouter(app, '/channel-info', channelInfoRouter); app.use(errorMiddleware); -app.listen(port, () => { - console.log(`Started API Server on port ${port}`); +app.listen(port, async () => { + console.log(`Started API Server on port ${port}`); + await MongoDbService.connect(dbUrl, dbName); }); diff --git a/api/src/middlewares/error.ts b/api/src/middlewares/error.ts index 1cfd3ad51..ba85703f6 100644 --- a/api/src/middlewares/error.ts +++ b/api/src/middlewares/error.ts @@ -1,9 +1,7 @@ -import { NextFunction, Request, Response } from 'express'; +import { Request, Response, NextFunction } from 'express'; import { StatusCodes } from 'http-status-codes'; -export function errorMiddleware(err: Error, req: Request, res: Response, next: NextFunction): void { - console.error('Error:', err); - - res.status(StatusCodes.CONFLICT); +export const errorMiddleware = (err: Error, req: Request, res: Response, next: NextFunction): void => { + res.status(StatusCodes.PARTIAL_CONTENT); res.send({ error: err.message }); -} +}; diff --git a/api/src/middlewares/logger.ts b/api/src/middlewares/logger.ts deleted file mode 100644 index fde9e1f61..000000000 --- a/api/src/middlewares/logger.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { NextFunction, Request, Response } from 'express'; - -// TODO -export function loggerMiddleware(request: Request, response: Response, next: NextFunction): void { - console.log(`${request.method} ${request.path} ${JSON.stringify(request.query)}`); - next(); -} diff --git a/api/src/models/config/index.ts b/api/src/models/config/index.ts new file mode 100644 index 000000000..1c52f0b20 --- /dev/null +++ b/api/src/models/config/index.ts @@ -0,0 +1,7 @@ +export interface Config { + port: number; + apiVersion: string; + nodeUrl: string; + databaseUrl: string; + databaseName: string; +} diff --git a/api/src/models/data/channel-info.ts b/api/src/models/data/channel-info.ts index 0a8296488..eaaaab82c 100644 --- a/api/src/models/data/channel-info.ts +++ b/api/src/models/data/channel-info.ts @@ -12,7 +12,7 @@ export interface ChannelInfo { author: any; subscribers: any[]; topics: Topic[]; - created: Date; + created: Date | null; latestMessage?: Date; } diff --git a/api/src/routes/channel-info/index.test.ts b/api/src/routes/channel-info/index.test.ts index c4984ae63..e8739b3ca 100644 --- a/api/src/routes/channel-info/index.test.ts +++ b/api/src/routes/channel-info/index.test.ts @@ -1,5 +1,297 @@ -import { ChannelInfoDto } from '../../models/data/channel-info'; -import { getChannelInfoFromBody } from '.'; +import { ChannelInfo, ChannelInfoDto } from '../../models/data/channel-info'; +import { getChannelInfo, getChannelInfoDto, getChannelInfoFromBody, addChannelInfo, updateChannelInfo, deleteChannelInfo } from '.'; +import * as ChannelInfoDb from '../../database/channel-info'; +import moment from 'moment'; + +describe('test GET channelInfo', () => { + let sendMock: any, sendStatusMock: any, nextMock: any, res: any; + beforeEach(() => { + sendMock = jest.fn(); + sendStatusMock = jest.fn(); + nextMock = jest.fn(); + + res = { + send: sendMock, + sendStatus: sendStatusMock + }; + }); + + it('should return bad request if no address is given as parameter', async () => { + const req: any = { + params: {}, + body: null + }; + + await getChannelInfo(req, res, nextMock); + expect(sendStatusMock).toHaveBeenCalledWith(400); + }); + + it('should return expected channel info', async () => { + const channelInfo: ChannelInfo = { + created: moment('2021-02-09T00:00:00.000+01:00').toDate(), + author: 'test-author2', + subscribers: [], + topics: [ + { + source: 'test', + type: 'test-type' + } + ], + latestMessage: null, + channelAddress: 'test-address3' + }; + const getChannelInfoSpy = spyOn(ChannelInfoDb, 'getChannelInfo').and.returnValue(channelInfo); + const req: any = { + params: { channelAddress: 'test-address' }, + body: null + }; + + await getChannelInfo(req, res, nextMock); + + expect(getChannelInfoSpy).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledWith({ + author: 'test-author2', + channelAddress: 'test-address3', + created: '2021-02-09T00:00:00.000+01:00', + latestMessage: null, + subscribers: [], + topics: [{ source: 'test', type: 'test-type' }] + }); + }); + + it('should call next(err) if an error occurs', async () => { + const getChannelInfoSpy = spyOn(ChannelInfoDb, 'getChannelInfo').and.callFake(() => { + throw new Error('Test error'); + }); + const req: any = { + params: { channelAddress: 'test-address' }, + body: null + }; + + await getChannelInfo(req, res, nextMock); + + expect(getChannelInfoSpy).toHaveBeenCalledTimes(1); + expect(sendMock).not.toHaveBeenCalled(); + expect(nextMock).toHaveBeenCalledWith(new Error('Test error')); + }); +}); + +describe('test POST channelInfo', () => { + let sendMock: any, sendStatusMock: any, nextMock: any, res: any; + const validBody: ChannelInfoDto = { + author: 'test-author2', + channelAddress: 'test-address3', + created: '02-09-2021', + latestMessage: null, + subscribers: [], + topics: [{ source: 'test', type: 'test-type' }] + }; + + beforeEach(() => { + sendMock = jest.fn(); + sendStatusMock = jest.fn(); + nextMock = jest.fn(); + + res = { + send: sendMock, + sendStatus: sendStatusMock + }; + }); + + it('should return bad request if no valid body is given', async () => { + const req: any = { + params: {}, + body: null + }; + await addChannelInfo(req, res, nextMock); + expect(sendStatusMock).toHaveBeenCalledWith(400); + }); + + it('should return 404 since no channel added', async () => { + const addChannelInfoSpy = spyOn(ChannelInfoDb, 'addChannelInfo').and.returnValue({ result: { n: 0 } }); + + const req: any = { + params: {}, + body: validBody + }; + + const resUpdate = { + ...res, + status: jest.fn(), + send: jest.fn() + }; + + await addChannelInfo(req, resUpdate, nextMock); + + expect(addChannelInfoSpy).toHaveBeenCalledTimes(1); + expect(resUpdate.send).toHaveBeenCalledWith({ error: 'Could not add channel info' }); + expect(resUpdate.status).toHaveBeenCalledWith(404); + }); + + it('should add channel info', async () => { + const addChannelInfoSpy = spyOn(ChannelInfoDb, 'addChannelInfo').and.returnValue({ result: { n: 1 } }); + + const req: any = { + params: {}, + body: validBody + }; + + await addChannelInfo(req, res, nextMock); + + expect(addChannelInfoSpy).toHaveBeenCalledTimes(1); + expect(sendStatusMock).toHaveBeenCalledWith(201); + }); + + it('should call next(err) if an error occurs', async () => { + const addChannelInfoSpy = spyOn(ChannelInfoDb, 'addChannelInfo').and.callFake(() => { + throw new Error('Test error'); + }); + const req: any = { + params: {}, + body: validBody + }; + await addChannelInfo(req, res, nextMock); + + expect(addChannelInfoSpy).toHaveBeenCalledTimes(1); + expect(sendMock).not.toHaveBeenCalled(); + expect(nextMock).toHaveBeenCalledWith(new Error('Test error')); + }); +}); + +describe('test PUT channelInfo', () => { + let sendMock: any, sendStatusMock: any, nextMock: any, res: any; + const validBody: ChannelInfoDto = { + author: 'test-author2', + channelAddress: 'test-address3', + created: '02-09-2021', + latestMessage: null, + subscribers: [], + topics: [{ source: 'test', type: 'test-type' }] + }; + + beforeEach(() => { + sendMock = jest.fn(); + sendStatusMock = jest.fn(); + nextMock = jest.fn(); + + res = { + send: sendMock, + sendStatus: sendStatusMock + }; + }); + + it('should return bad request if no valid body is given', async () => { + const req: any = { + params: {}, + body: null + }; + await updateChannelInfo(req, res, nextMock); + expect(sendStatusMock).toHaveBeenCalledWith(400); + }); + + it('should return 404 since no channel updated', async () => { + const updateChannelInfoSpy = spyOn(ChannelInfoDb, 'updateChannelInfo').and.returnValue({ result: { n: 0 } }); + + const req: any = { + params: {}, + body: validBody + }; + + const resUpdate = { + ...res, + status: jest.fn(), + send: jest.fn() + }; + + await updateChannelInfo(req, resUpdate, nextMock); + + expect(updateChannelInfoSpy).toHaveBeenCalledTimes(1); + expect(resUpdate.send).toHaveBeenCalledWith({ error: 'No channel info found to update!' }); + expect(resUpdate.status).toHaveBeenCalledWith(404); + }); + + it('should return expected channel info', async () => { + const updateChannelInfoSpy = spyOn(ChannelInfoDb, 'updateChannelInfo').and.returnValue({ result: { n: 1 } }); + + const req: any = { + params: {}, + body: validBody + }; + + await updateChannelInfo(req, res, nextMock); + + expect(updateChannelInfoSpy).toHaveBeenCalledTimes(1); + expect(sendStatusMock).toHaveBeenCalledWith(200); + }); + + it('should call next(err) if an error occurs', async () => { + const updateChannelInfoSpy = spyOn(ChannelInfoDb, 'updateChannelInfo').and.callFake(() => { + throw new Error('Test error'); + }); + const req: any = { + params: {}, + body: validBody + }; + await updateChannelInfo(req, res, nextMock); + + expect(updateChannelInfoSpy).toHaveBeenCalledTimes(1); + expect(sendMock).not.toHaveBeenCalled(); + expect(nextMock).toHaveBeenCalledWith(new Error('Test error')); + }); +}); + +describe('test DELETE channelInfo', () => { + let sendMock: any, sendStatusMock: any, nextMock: any, res: any; + + beforeEach(() => { + sendMock = jest.fn(); + sendStatusMock = jest.fn(); + nextMock = jest.fn(); + + res = { + send: sendMock, + sendStatus: sendStatusMock + }; + }); + + it('should return bad request if no address is given as parameter', async () => { + const req: any = { + params: {}, + body: null + }; + await deleteChannelInfo(req, res, nextMock); + expect(sendStatusMock).toHaveBeenCalledWith(400); + }); + + it('should return expected channel info', async () => { + const deleteChannelInfoSpy = spyOn(ChannelInfoDb, 'deleteChannelInfo'); + + const req: any = { + params: { channelAddress: 'test-address' }, + body: null + }; + + await deleteChannelInfo(req, res, nextMock); + + expect(deleteChannelInfoSpy).toHaveBeenCalledTimes(1); + expect(sendStatusMock).toHaveBeenCalledWith(200); + }); + + it('should call next(err) if an error occurs', async () => { + const deleteChannelInfoSpy = spyOn(ChannelInfoDb, 'deleteChannelInfo').and.callFake(() => { + throw new Error('Test error'); + }); + const req: any = { + params: { channelAddress: 'test-address' }, + body: null + }; + await deleteChannelInfo(req, res, nextMock); + + expect(deleteChannelInfoSpy).toHaveBeenCalledTimes(1); + expect(sendMock).not.toHaveBeenCalled(); + expect(nextMock).toHaveBeenCalledWith(new Error('Test error')); + }); +}); describe('test getChannelInfoFromBody', () => { it('should not return null for valid dto', () => { @@ -43,3 +335,25 @@ describe('test getChannelInfoFromBody', () => { expect(getChannelInfoFromBody(validChannelInfoDto)).toBeNull(); }); }); + +describe('test getChannelInfoDto', () => { + it('should transform database object to transfer object', () => { + const validChannelInfo: ChannelInfo = { + created: new Date('2021-02-08T00:00:00.000+01:00'), + subscribers: [], + latestMessage: new Date('2021-02-08T00:00:00.000+01:00'), + author: 'test-author', + topics: [{ source: 'test', type: 'test-type' }], + channelAddress: 'test-address' + }; + const result = getChannelInfoDto(validChannelInfo); + + expect(result).not.toBeNull(); + expect(result.channelAddress).toEqual('test-address'); + expect(result.author).toEqual('test-author'); + expect(result.topics).toEqual([{ source: 'test', type: 'test-type' }]); + expect(result.created).toEqual('2021-02-08T00:00:00.000+01:00'); + expect(result.latestMessage).toEqual('2021-02-08T00:00:00.000+01:00'); + expect(result.subscribers).toEqual([]); + }); +}); diff --git a/api/src/routes/channel-info/index.ts b/api/src/routes/channel-info/index.ts index a8251dc89..bd6cef6a0 100644 --- a/api/src/routes/channel-info/index.ts +++ b/api/src/routes/channel-info/index.ts @@ -1,62 +1,117 @@ -import { Request, Response } from 'express'; +import { NextFunction, Request, Response } from 'express'; import { ChannelInfoDto, ChannelInfo } from '../../models/data/channel-info'; import * as service from '../../services/channel-info-service'; import * as _ from 'lodash'; import { StatusCodes } from 'http-status-codes'; +import { getDateFromString, getDateStringFromDate } from '../../utils/date'; -export const getChannelInfo = (req: Request, res: Response): void => { - console.log('Get user'); +export const getChannelInfo = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const channelAddress = _.get(req, 'params.channelAddress'); - const info = service.getChannelInfo(); - res.send(info); + if (_.isEmpty(channelAddress)) { + res.sendStatus(StatusCodes.BAD_REQUEST); + return; + } + + const channelInfo = await service.getChannelInfo(channelAddress); + const channelInfoDto = getChannelInfoDto(channelInfo); + res.send(channelInfoDto); + } catch (error) { + next(error); + } }; -export const addChannelInfo = (req: Request, res: Response): void => { - const channelInfo = getChannelInfoFromBody(req.body); +export const addChannelInfo = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const channelInfo = getChannelInfoFromBody(req.body); - if (channelInfo == null) { - res.sendStatus(StatusCodes.BAD_REQUEST); - return; - } + if (channelInfo == null) { + res.sendStatus(StatusCodes.BAD_REQUEST); + return; + } - service.addChannelInfo(channelInfo); - res.sendStatus(StatusCodes.CREATED); -}; + const result = await service.addChannelInfo(channelInfo); -export const updateChannelInfo = (req: Request, res: Response): void => { - const channelInfo = getChannelInfoFromBody(req.body); + if (result.result.n === 0) { + res.status(StatusCodes.NOT_FOUND); + res.send({ error: 'Could not add channel info' }); + return; + } - if (channelInfo == null) { - res.sendStatus(StatusCodes.BAD_REQUEST); - return; + res.sendStatus(StatusCodes.CREATED); + } catch (error) { + next(error); } - - service.updateChannelInfo(channelInfo); - res.sendStatus(StatusCodes.OK); }; -export const deleteChannelInfo = (req: Request, res: Response): void => { - const channelAddress = req.params['channelAddress']; +export const updateChannelInfo = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const channelInfo = getChannelInfoFromBody(req.body); - if (_.isElement(channelAddress)) { - res.sendStatus(StatusCodes.BAD_REQUEST); - return; + if (channelInfo == null) { + res.sendStatus(StatusCodes.BAD_REQUEST); + return; + } + + const result = await service.updateChannelInfo(channelInfo); + + if (result.result.n === 0) { + res.status(StatusCodes.NOT_FOUND); + res.send({ error: 'No channel info found to update!' }); + return; + } + + res.sendStatus(StatusCodes.OK); + } catch (error) { + next(error); } +}; + +export const deleteChannelInfo = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const channelAddress = _.get(req, 'params.channelAddress'); + if (_.isEmpty(channelAddress)) { + res.sendStatus(StatusCodes.BAD_REQUEST); + return; + } - service.deleteChannelInfo(channelAddress); - res.sendStatus(StatusCodes.OK); + await service.deleteChannelInfo(channelAddress); + res.sendStatus(StatusCodes.OK); + } catch (error) { + next(error); + } }; export const getChannelInfoFromBody = (dto: ChannelInfoDto): ChannelInfo | null => { + if (dto == null || _.isEmpty(dto.channelAddress) || _.isEmpty(dto.topics) || _.isEmpty(dto.author)) { + throw new Error('Error when parsing the body: channelAddress and author must be provided!'); + } + const channelInfo: ChannelInfo = { - created: new Date(), + created: dto.created ? getDateFromString(dto.created) : null, author: dto.author, subscribers: dto.subscribers || [], topics: dto.topics, - channelAddress: dto.channelAddress + channelAddress: dto.channelAddress, + latestMessage: dto.latestMessage && getDateFromString(dto.created) }; - if (_.isEmpty(channelInfo.channelAddress) || _.isEmpty(channelInfo.topics) || _.isEmpty(channelInfo.author)) { - return null; + + return channelInfo; +}; + +export const getChannelInfoDto = (c: ChannelInfo): ChannelInfoDto | null => { + if (c == null || _.isEmpty(c.channelAddress) || _.isEmpty(c.author)) { + throw new Error('Error when parsing the channelInfo, no channelAddress and/or author was found!'); } + + const channelInfo: ChannelInfoDto = { + created: getDateStringFromDate(c.created), + author: c.author, + subscribers: c.subscribers || [], + topics: c.topics, + latestMessage: c.latestMessage && getDateStringFromDate(c.latestMessage), + channelAddress: c.channelAddress + }; return channelInfo; }; diff --git a/api/src/routes/router.ts b/api/src/routes/router.ts index 1c4284514..ea35b2fe1 100644 --- a/api/src/routes/router.ts +++ b/api/src/routes/router.ts @@ -3,6 +3,6 @@ import { Router } from 'express'; export const channelInfoRouter = Router(); channelInfoRouter.get('/channel/:channelAddress', getChannelInfo); -channelInfoRouter.post('/channel/:channelAddress', addChannelInfo); -channelInfoRouter.put('/channel/:channelAddress', updateChannelInfo); +channelInfoRouter.post('/channel', addChannelInfo); +channelInfoRouter.put('/channel', updateChannelInfo); channelInfoRouter.delete('/channel/:channelAddress', deleteChannelInfo); diff --git a/api/src/services/channel-info-service.ts b/api/src/services/channel-info-service.ts index ba6217eed..d83eb6e03 100644 --- a/api/src/services/channel-info-service.ts +++ b/api/src/services/channel-info-service.ts @@ -1,27 +1,19 @@ -import { ChannelInfoDto, ChannelInfo } from '../models/data/channel-info'; +import { ChannelInfo } from '../models/data/channel-info'; +import * as ChannelInfoDb from '../database/channel-info'; +import { DeleteWriteOpResultObject, InsertOneWriteOpResult, UpdateWriteOpResult, WithId } from 'mongodb'; -export const getChannelInfo = (): ChannelInfoDto => { - console.log('Get user'); - // TODO get data from db - return { - created: new Date().toDateString(), - subscribers: [], - channelAddress: '', - topics: [{ source: 'device-kitchen', type: 'temperature' }], - latestMessage: new Date().toDateString(), - author: null - }; +export const getChannelInfo = async (channelAddress: string): Promise => { + return ChannelInfoDb.getChannelInfo(channelAddress); }; -export const addChannelInfo = (channelInfo: ChannelInfo): void => { - console.log('Add user'); +export const addChannelInfo = async (channelInfo: ChannelInfo): Promise>> => { + return ChannelInfoDb.addChannelInfo(channelInfo); }; -export const updateChannelInfo = (channelInfo: ChannelInfo): void => { - console.log('Delete user'); +export const updateChannelInfo = async (channelInfo: ChannelInfo): Promise => { + return ChannelInfoDb.updateChannelInfo(channelInfo); }; -export const deleteChannelInfo = (channelAddress: string): void => { - console.log('Delete user'); - throw new Error('YO DIS VRONG'); +export const deleteChannelInfo = async (channelAddress: string): Promise => { + return ChannelInfoDb.deleteChannelInfo(channelAddress); }; diff --git a/api/src/services/mongodb-service.ts b/api/src/services/mongodb-service.ts new file mode 100644 index 000000000..8440edf4a --- /dev/null +++ b/api/src/services/mongodb-service.ts @@ -0,0 +1,81 @@ +import { + Db, + MongoClient, + MongoClientOptions, + Collection, + UpdateWriteOpResult, + InsertOneWriteOpResult, + WithId, + DeleteWriteOpResultObject, + FilterQuery, + InsertWriteOpResult +} from 'mongodb'; + +export class MongoDbService { + public static client: MongoClient; + public static db: Db; + + private static getCollection(collectionName: string): Collection | null { + if (!MongoDbService.db) { + console.error(`Database not found!`); + return null; + } + return MongoDbService.db.collection(collectionName); + } + + static async getDocument(collectionName: string, query: FilterQuery): Promise { + const collection = MongoDbService.getCollection(collectionName); + return collection.findOne(query); + } + + static async getDocuments(collectionName: string, query: FilterQuery): Promise { + const collection = MongoDbService.getCollection(collectionName); + return collection.find(query).toArray(); + } + + static async insertDocument(collectionName: string, data: any): Promise> | null> { + const collection = MongoDbService.getCollection(collectionName); + return collection.insertOne(data); + } + + static async insertDocuments(collectionName: string, data: any): Promise> { + const collection = MongoDbService.getCollection(collectionName); + return collection.insertMany(data); + } + + static async upsertDocument(collectionName: string, query: any, update: any): Promise { + const collection = MongoDbService.getCollection(collectionName); + const options = {}; + return collection.updateOne(query, update, options); + } + + static async removeDocument(collectionName: string, query: any): Promise { + const collection = MongoDbService.getCollection(collectionName); + return collection.deleteOne(query); + } + + static async connect(url: string, dbName: string): Promise { + return new Promise((resolve, reject) => { + const options: MongoClientOptions = { + useUnifiedTopology: true + }; + + MongoClient.connect(url, options, function (err: Error, client: MongoClient) { + if (err != null) { + console.error('Could not connect to mongodb'); + reject(err); + return; + } + console.log('Successfully connected to mongodb'); + MongoDbService.client = client; + MongoDbService.db = client.db(dbName); + + resolve(client); + }); + }); + } + + public static disconnect(): void { + MongoDbService.client.close(); + } +} diff --git a/api/src/utils/date.ts b/api/src/utils/date.ts new file mode 100644 index 000000000..d53663568 --- /dev/null +++ b/api/src/utils/date.ts @@ -0,0 +1,9 @@ +import moment from 'moment'; + +export const getDateFromString = (dateString: string): Date | null => { + return dateString && moment(dateString, 'YYYY-MM-DDTHH:mm:ss.sssZ').toDate(); +}; + +export const getDateStringFromDate = (date: Date): string => { + return moment(date.toUTCString()).format('YYYY-MM-DDTHH:mm:ss.sssZ'); +};