diff --git a/package-lock.json b/package-lock.json index 09efa7a..4f85927 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,11 @@ "@types/node": "*" } }, + "@types/swagger-schema-official": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/@types/swagger-schema-official/-/swagger-schema-official-2.0.18.tgz", + "integrity": "sha512-zQsYKjtPf7Hvnp6KR4DTypXZ12tlH7k670d4QXuxzkofefBy9e2GwRKyV06lgTi3qw/OH/JFTDYe/x3uQmnusQ==" + }, "abstract-logging": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-1.0.0.tgz", @@ -116,7 +121,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, "requires": { "sprintf-js": "~1.0.2" } @@ -162,14 +166,12 @@ "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -321,8 +323,7 @@ "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, "contains-path": { "version": "0.1.0", @@ -432,6 +433,16 @@ "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", "dev": true }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, "diff": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", @@ -447,12 +458,22 @@ "esutils": "^2.0.2" } }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, "emoji-regex": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", "dev": true }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + }, "end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -500,6 +521,11 @@ "is-symbol": "^1.0.2" } }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -817,8 +843,7 @@ "esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" }, "esquery": { "version": "1.0.1", @@ -850,6 +875,11 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" + }, "execa": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", @@ -943,6 +973,58 @@ "tiny-lru": "^6.0.1" } }, + "fastify-plugin": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-1.6.0.tgz", + "integrity": "sha512-lFa9txg8LZx4tljj33oG53nUXhVg0baZxtP9Pxi0dJmI0NQxzkDk5DS9kr3D7iMalUAp3mvIq16OQumc7eIvLA==", + "requires": { + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, + "fastify-static": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/fastify-static/-/fastify-static-2.5.0.tgz", + "integrity": "sha512-j0H+izfYHzlXtrMJG/3d7Z2SRhTm17HDiOZll/kWKF26GcsRTJttZneAJaF07XW/uQY9HrBRmSEwmlNG2+0b3A==", + "requires": { + "fastify-plugin": "^1.6.0", + "glob": "^7.1.4", + "readable-stream": "^3.4.0", + "send": "^0.16.0" + }, + "dependencies": { + "glob": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", + "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + } + } + }, + "fastify-swagger": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/fastify-swagger/-/fastify-swagger-2.4.0.tgz", + "integrity": "sha512-NK9tfSx8w1bM+H+gm5n+IZMuwr9pUoaw3ugDQ1DFHPrCXaXrFTU+6mwNPzuXkqTnloRUKkRh2rrRbCbexy+BAg==", + "requires": { + "@types/swagger-schema-official": "^2.0.15", + "fastify-plugin": "^1.5.0", + "fastify-static": "^2.3.4", + "js-yaml": "^3.12.1" + } + }, "fastq": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.6.0.tgz", @@ -1047,11 +1129,15 @@ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "function-bind": { "version": "1.1.1", @@ -1166,6 +1252,24 @@ "integrity": "sha512-pzXIvANXEFrc5oFFXRMkbLPQ2rXRoDERwDLyrcUxGhaZhgP54BBSl9Oheh7Vv0T090cszWBxPjkQQ5Sq1PbBRQ==", "dev": true }, + "http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "dependencies": { + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + } + } + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -1201,7 +1305,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, "requires": { "once": "^1.3.0", "wrappy": "1" @@ -1373,7 +1476,6 @@ "version": "3.13.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", - "dev": true, "requires": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -1546,7 +1648,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dev": true, "requires": { "brace-expansion": "^1.1.7" } @@ -1750,11 +1851,18 @@ "has": "^1.0.3" } }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "requires": { + "ee-first": "1.1.1" + } + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, "requires": { "wrappy": "1" } @@ -1876,8 +1984,7 @@ "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, "path-key": { "version": "2.0.1", @@ -2105,6 +2212,11 @@ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-3.0.2.tgz", "integrity": "sha512-FXTaCkwvpIlkdKeGDNgcq07SXWS383noQUuZjvdE1QcTt+eLuqof6/BDiEPqB59FWLie/l91+HtlJSw7iCViSA==" }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, "react-is": { "version": "16.9.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.9.0.tgz", @@ -2313,12 +2425,57 @@ "resolved": "https://registry.npmjs.org/semver-store/-/semver-store-0.3.0.tgz", "integrity": "sha512-TcZvGMMy9vodEFSse30lWinkj+JgOBvPn8wRItpQRSayhc+4ssDs335uklkfvQQJgL/WvmHLVj4Ycv2s7QCQMg==" }, + "send": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.16.2.tgz", + "integrity": "sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==", + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.6.2", + "mime": "1.4.1", + "ms": "2.0.0", + "on-finished": "~2.3.0", + "range-parser": "~1.2.0", + "statuses": "~1.4.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "mime": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz", + "integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==" + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, "set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", "dev": true }, + "setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" + }, "shebang-command": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", @@ -2394,8 +2551,7 @@ "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", - "dev": true + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" }, "standard": { "version": "14.3.1", @@ -2434,6 +2590,11 @@ } } }, + "statuses": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", + "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" + }, "string-width": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", @@ -2775,8 +2936,7 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "write": { "version": "1.0.3", diff --git a/package.json b/package.json index 673525e..ea42053 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ }, "homepage": "https://github.com/moonad/Unilog#readme", "dependencies": { - "fastify": "^2.8.0" + "fastify": "^2.8.0", + "fastify-swagger": "^2.4.0" }, "devDependencies": { "chai": "^4.2.0", diff --git a/src/config/swagger.js b/src/config/swagger.js new file mode 100644 index 0000000..a70a809 --- /dev/null +++ b/src/config/swagger.js @@ -0,0 +1,22 @@ +// API information +const api_title = 'Unilog API' +const api_description = 'Simple event log server' +const api_version = '0.0.1' +const api_host = 'localhost' + +// Swagger Options +exports.options = { + routePrefix: '/docs', + exposeRoute: true, + swagger: { + info: { + title: api_title, + description: api_description, + version: api_version + }, + host: api_host, + schemes: ['http'], + consumes: ['application/json'], + produces: ['application/json'] + } +} diff --git a/src/event_storage/in-memory.js b/src/event_storage/in-memory.js new file mode 100644 index 0000000..95d5b79 --- /dev/null +++ b/src/event_storage/in-memory.js @@ -0,0 +1,89 @@ +// Dummy data - For testing only +// TODO: Use a proper database (Postgre) instead of hardcoded JSON + +var id0 = Buffer.from('0') +var id1 = Buffer.from('1') + +var data00 = Buffer.from('TEST_DATA: STREAM 0; EVENT 0') +var data01 = Buffer.from('TEST_DATA: STREAM 0; EVENT 1') +var data10 = Buffer.from('TEST_DATA: STREAM 1; EVENT 0') + +var dummy = +{ + MQ: { + length: 2, + events: + [ + { index: 0, data: data00.toString('base64') }, + { index: 1, data: data01.toString('base64') } + ] + }, + + Mg: { + length: 1, + events: + [ + { index: 0, data: data10.toString('base64') } + ] + } +} + +// Data access functions +// TODO: transfer these functions to a separate file + +// function length +// description: Returns length property of a given stream +// inputs: stream_id:Int +// returns: Int +async function length (stream_id) { + var len = 0 + if (dummy[stream_id] !== undefined) { + len = dummy[stream_id].length + } + return len +} + +// function load +// description: Returns an interval of events in a given stream +// inputs: stream_id:Int, from:Int, to:Int +// returns: [Object] +async function load (stream_id, from, to) { + var result = [] + if (dummy[stream_id] !== undefined) { + var events = dummy[stream_id].events + if (from >= 0 && to >= from && from < events.length && to < events.length) { + result = events.slice(from, to+1) + } + } + return result +} + +// function push +// description: Include an event in a given stream +// inputs: stream_id:Int, data:String +// returns: Object -- success/failure indicator +async function push (stream_id, data) { + var success = false + const base64_data = data.toString('base64') + if (dummy[stream_id] === undefined) { + dummy[stream_id] = { + length: 1, + events: + [{ index: 0, data: base64_data }] + } + } else { + var len = dummy[stream_id].length + dummy[stream_id].events[len] = { index: len, data: base64_data } + dummy[stream_id].length += 1 + success = true + } + return success +} + + +module.exports = { + dummy: dummy, + length: length, + load: load, + push: push, +} diff --git a/src/routes/api-routes.js b/src/routes/api-routes.js new file mode 100644 index 0000000..8180cbd --- /dev/null +++ b/src/routes/api-routes.js @@ -0,0 +1,217 @@ +const utils = require('../utils/utils') +const isSizeValid = utils.isSizeValid +const isBase64 = utils.isBase64 + +const in_memory = require('../event_storage/in-memory') +const dummy = in_memory.dummy +const length = in_memory.length +const load = in_memory.load +const push = in_memory.push + +const api_base_url = '/api'; + +// max size in bytes +const stream_id_max_size = 16 +const data_max_size = 128 + +// =============== Route URLs =============== // +const get_stream_url = api_base_url + '/streams/:stream_id' +const get_stream_events_url = api_base_url + '/streams/:stream_id/events*' +const push_event_url = api_base_url + '/streams/:stream_id/events' + +// =============== Route Schemas =============== // +const get_stream_schema = +{ + response: { + // Correct ID formatting + 200: { + type: 'object', + properties: { + stream_id: { type: 'string' }, + length: { type: 'integer' }, + }, + }, + // Validation errors + 400: { + type: 'object', + properties: { + error: { type: 'string' }, + } + } + } +} + +const get_stream_events_schema = +{ + response: { + 200: { + type: 'object', + properties: { + stream_id: { type: 'string' }, + events: { + type: 'array', + items: { + type: 'object', + properties: { + index: {type: 'integer'}, + data: {type: 'string'}, + }, + }, + }, + }, + }, + + 400: { + type: 'object', + properties: { + error: {type: 'string'} + }, + }, + }, +} + +const push_event_schema = +{ + response: { + 200: { + type: 'object', + }, + 400: { + type: 'object', + properties: { + error: {type: 'string'}, + }, + }, + }, +} + +// =============== Route Handlers =============== // +async function get_stream_handler (req, res) { + const stream_id = req.params.stream_id + var msg = "" + + if (!isBase64(stream_id)) { + msg = "stream_id is malformed" + } + else if (!isSizeValid(stream_id, stream_id_max_size)){ + msg = "stream_id is too big" + } + else { + const len = await length(stream_id) + + const resp = + { + stream_id: stream_id, + length: len, + } + + res + .code(200) + .header('Content-Type', 'application/json; charset=utf-8') + .send(resp) + } + + res + .code(400) + .header('Content-Type', 'application/json; charset=utf-8') + .send({ error: msg }) +} + +async function get_stream_events_handler (req, res) { + const stream_id = req.params.stream_id + const from = req.query.from + const to = req.query.to + + if (!isBase64(stream_id)) { + msg = "stream_id is malformed" + } + else if (!isSizeValid(stream_id, stream_id_max_size)){ + msg = "stream_id is too big" + } + else { + const events_array = await load(stream_id, from, to) + + const resp = { + stream_id: stream_id, + events: events_array, + } + + res + .code(200) + .header('Content-Type', 'application/json; charset=utf-8') + .send(resp) + } + + res + .code(400) + .header('Content-Type', 'application/json; charset=utf-8') + .send({ error: msg }) +} + +async function push_event_handler (req, res) { + const stream_id = req.params.stream_id + const data = req.body.data + + var is_successful = false + var msg = "" + + // verify inputs + var stream_id_size_valid = isSizeValid(stream_id, stream_id_max_size) + var data_size_valid = isSizeValid(data, data_max_size) + + var stream_id_encoding_valid = isBase64(stream_id) + var data_encoding_valid = isBase64(data) + + if (!stream_id_size_valid || !data_size_valid) { + msg = (data_size_valid ? "stream_id" : "data") + " is too long" + } + else if (!stream_id_encoding_valid || !data_encoding_valid) { + msg = (data_encoding_valid ? "stream_id" : "data") + " is malformed" + } + else { + is_successful = await push(stream_id, data) + msg = "" + } + + if (is_successful) { + res + .code(200) + .header('Content-Type', 'application/json; charset=utf-8') + .send({}) + } + else { + res + .code(400) + .header('Content-Type', 'application/json; charset=utf-8') + .send({ error: msg }) + } +} + +// =============== Routes =============== // +const routes = [ + // get_stream + { + method: 'GET', + url: get_stream_url, + schema: get_stream_schema, + handler: get_stream_handler + }, + + // get_stream_events + { + method: 'GET', + url: get_stream_events_url, + schema: get_stream_events_schema, + handler: get_stream_events_handler + }, + + // push_event + { + method: 'POST', + url: push_event_url, + schema: push_event_schema, + handler: push_event_handler + } +] + +module.exports = routes diff --git a/src/routes/test-route.js b/src/routes/test-route.js deleted file mode 100644 index 1e7ec1c..0000000 --- a/src/routes/test-route.js +++ /dev/null @@ -1,11 +0,0 @@ -async function routes (fastify, options) { - fastify.get('/', async (req, res) => { - return { hello: 'world' } - }) - - fastify.get('/other_route', async (req, res) => { - return { hello: 'kitty' } - }) -} - -module.exports = routes diff --git a/src/server.js b/src/server.js index c604bec..ce66227 100644 --- a/src/server.js +++ b/src/server.js @@ -1,11 +1,20 @@ -const app = require('fastify')({ logger: true }) +const fastify = require('fastify')({ logger: true }) +const routes = require('./routes/api-routes') +const swagger = require('./config/swagger') -app.register(require('./routes/test-route')) +fastify.register(require('fastify-swagger'), swagger.options) +routes.forEach((route, index) => { + fastify.route(route) +}) -module.exports = app.listen(3000, function (err, addr) { - if (err) { - app.log.error(err) +const start = async () => { + try { + await fastify.listen(3000, '0.0.0.0') + fastify.swagger() + fastify.log.info(`server listening on ${fastify.server.address().port}`) + } catch (err) { + fastify.log.error(err) process.exit(1) } - app.log.info(`server listening on ${addr}`) -}) +} +start() diff --git a/src/utils/utils.js b/src/utils/utils.js new file mode 100644 index 0000000..d603736 --- /dev/null +++ b/src/utils/utils.js @@ -0,0 +1,25 @@ +exports.base64_url_encode = function(buffer) { + return buffer.toString('base64').replace('+', '-').replace('/', '_').replace(/=+$/, '') +} + +exports.base64_url_decode = function(base64_url_encoded) { + let base64_encoded = base64_url_encoded.replace('-', '+').replace('_', '/') + + // Ensure padding is on + while (base64_encoded.length % 4) + base64_encoded += '='; + + // Decode from base64 + return Buffer.from(base64_encoded, 'base64') +} + +// Checks if base64 string is well formed +exports.isBase64 = function (base64_str) { + return Buffer.from(base64_str, 'base64').toString('base64') + .replace('+', '-').replace('/', '_').replace(/=+$/, '') == base64_str +} + +// checks if input size is valid +exports.isSizeValid = function (base64_str, max_size) { + return (Buffer.from(base64_str, 'base64').length) <= max_size +} diff --git a/test/test.js b/test/test.js index 1a14654..b2e2852 100644 --- a/test/test.js +++ b/test/test.js @@ -1,31 +1,325 @@ const chai = require('chai') const chaiHttp = require('chai-http') const app = require('../src/server') +const utils = require('../src/utils/utils') +const im = require('../src/event_storage/in-memory') -const url = ('http://localhost:3000') +const url = ('http://localhost:3000/api') const should = chai.should() +const expect = chai.expect chai.use(chaiHttp) -describe('Unilog Server', () => { - describe('GET /', () => { - it('should return {hello: "world"}', (done) => { +describe('Unilog Tests', () => { + // =============== Utils Tests =============== // + describe('base64_url_encode: input with padding', () => { + it('should return correct URL base64 encoding', () => { + const original = 'testInput1' // base64 is dGVzdElucHV0MQ== + const encoded = utils.base64_url_encode(Buffer.from(original)) + expect(encoded === 'dGVzdElucHV0MQ').to.be.true + }) + }) + + describe('base64_url_decode: input with padding', () => { + it('should return correct string from URL base64', () => { + const original = 'dGVzdElucHV0MQ' + const decoded = utils.base64_url_decode(original) // base64 would be dGVzdElucHV0MQ== + expect(decoded.toString('ascii') === 'testInput1').to.be.true + }) + }) + + + describe('isBase64: ascii input', () => { + it('should return true', () => { + const original = 'test' + const encoded = utils.base64_url_encode(Buffer.from(original)) + expect(utils.isBase64(encoded)).to.be.true + }) + }) + + describe('isBase64: non-ascii input', () => { + it('should return true', () => { + const original = 'téçt' + const encoded = utils.base64_url_encode(Buffer.from(original)) // URL safe + expect(utils.isBase64(encoded)).to.be.true + }) + }) + + describe('isSizeValid: size < max_size', () => { + it('should return true', () => { + const original = 'test' + const encoded = utils.base64_url_encode(Buffer.from(original)) // URL safe + expect(utils.isSizeValid(encoded, 8)).to.be.true + }) + }) + + describe('isSizeValid: size = max_size', () => { + it('should return true', () => { + const original = 'test' + const encoded = utils.base64_url_encode(Buffer.from(original)) // URL safe + expect(utils.isSizeValid(encoded, 4)).to.be.true + }) + }) + + describe('isSizeValid: size > max_size', () => { + it('should return false', () => { + const original = 'test' + const encoded = utils.base64_url_encode(Buffer.from(original)) // URL safe + expect(utils.isSizeValid(encoded, 3)).to.be.false + }) + }) + + // =============== In Memory Storage Tests =============== // + describe('In Memory length: existing stream_id', () => { + it('should return 2', async () => { + expect(await im.length('MQ')).to.equal(2) + }) + }) + + describe('In Memory length: non-existing stream_id', () => { + it('should return 0', async () => { + expect(await im.length('non-existing')).to.equal(0) + }) + }) + + describe('In Memory load: existing stream_id', () => { + it('should return 2 events', async () => { + expect((await im.load('MQ', 0, 1)).length).to.equal(2) + }) + }) + + describe('In Memory load: non-existing stream_id', () => { + it('should return 0 events', async () => { + expect(await im.load('non-existing', 0, 1)).to.be.empty + }) + }) + + describe('In Memory load: \"to\" out of bounds', () => { + it('should return 0 events', async () => { + console.log("\n\n" + JSON.stringify(await im.load('MQ', 0, 999)) + "\n\n") + expect((await im.load('MQ', 0, 999))).to.be.empty + }) + }) + + describe('In Memory load: \"from\" & \"to\" out of bounds', () => { + it('should return 0 events', async () => { + expect(await im.load('MQ', 900, 999)).to.be.empty + }) + }) + + describe('In Memory push', async () => { + it('should include new stream and event', async () => { + expect(await im.length('Mw')).to.equal(0) + expect(await im.load('Mw', 0, 0)).to.be.empty + await im.push('Mw', 'test data') + expect(await im.length('Mw')).to.equal(1) + expect(((await im.load('Mw', 0, 0))[0].data)).to.equal('test data') + }) + }) + + // =============== length(stream_id) Tests =============== // + describe('GET get_stream: valid stream_id', () => { + it('should return stream_id length correctly', (done) => { chai.request(url) - .get('/') + .get('/streams/MQ') .end((err, res) => { res.should.have.status(200) - res.body.should.be.a('object') + res.body.should.be.an('object') + expect(res.header["content-type"] == 'application/json; charset=utf-8').to.be.true + + const resp = JSON.parse(res.text) + expect(resp.length >= 0).to.be.true + done() + }) + }) + }) + + describe('GET get_stream: malformed stream_id', () => { + it('should return stream_id malformed error message', (done) => { + chai.request(url) + .get('/streams/malformed_stream_id') + .end((err, res) => { + res.should.have.status(400) + res.body.should.be.an('object') + expect(res.header["content-type"] == 'application/json; charset=utf-8').to.be.true + + const resp = JSON.parse(res.text) + resp.error.should.not.be.empty + expect(resp.error.includes("malformed")).to.be.true + done() + }) + }) + }) + + describe('GET get_stream: big stream_id', () => { + it('should return stream_id too long error message', (done) => { + chai.request(url) + .get('/streams/WW91Q2FtZVRvU2VlV2hhdEl0TWVhbnNSaWdodD9Zb3VBcmVBQ3VyaW91c0ZlbGxvdy4uLg') + .end((err, res) => { + res.should.have.status(400) + res.body.should.be.an('object') + expect(res.header["content-type"] == 'application/json; charset=utf-8').to.be.true + + const resp = JSON.parse(res.text) + resp.error.should.not.be.empty + expect(resp.error.includes("too")).to.be.true + done() + }) + }) + }) + + // =============== load(stream_id, from, to) Tests =============== // + describe('GET get_stream_events: valid inputs', () => { + it('should return event list of stream_id', (done) => { + chai.request(url) + .get('/streams/MQ/events?from=0&to=1') + .end((err, res) => { + res.should.have.status(200) + res.body.should.be.an('object') + expect(res.header["content-type"] == 'application/json; charset=utf-8').to.be.true + + const resp = JSON.parse(res.text) + expect(resp.events.length === 2).to.be.true + resp.events[0].should.be.an('object') + resp.events[1].should.be.an('object') + done() + }) + }) + }) + + describe('GET get_stream_events: from == to', () => { + it('should return a single event', (done) => { + chai.request(url) + .get('/streams/MQ/events?from=0&to=0') + .end((err, res) => { + res.should.have.status(200) + res.body.should.be.an('object') + expect(res.header["content-type"] == 'application/json; charset=utf-8').to.be.true + + const resp = JSON.parse(res.text) + expect(resp.events.length === 1).to.be.true + done() + }) + }) + }) + + describe('GET get_stream_events: from > to', () => { + it('should return an empty array', (done) => { + chai.request(url) + .get('/streams/MQ/events?from=1&to=0') + .end((err, res) => { + res.should.have.status(200) + res.body.should.be.an('object') + expect(res.header["content-type"] == 'application/json; charset=utf-8').to.be.true + + const resp = JSON.parse(res.text) + expect(resp.events).to.be.empty done() }) }) }) - describe('GET /other_route', () => { - it('should return {hello: "kitty"}', (done) => { + describe('GET get_stream_events: from < 0', () => { + it('should return an empty array', (done) => { chai.request(url) - .get('/other_route') + .get('/streams/MQ/events?from=-1&to=0') + .end((err, res) => { + res.should.have.status(200) + res.body.should.be.an('object') + expect(res.header["content-type"] == 'application/json; charset=utf-8').to.be.true + + const resp = JSON.parse(res.text) + expect(resp.events).to.be.empty + done() + }) + }) + }) + + describe('GET get_stream_events: malformed stream_id', () => { + it('should return malformed message', (done) => { + chai.request(url) + .get('/streams/malformed_stream_id/events?from=0&to=1') + .end((err, res) => { + res.should.have.status(400) + res.body.should.be.an('object') + expect(res.header["content-type"] == 'application/json; charset=utf-8').to.be.true + + const resp = JSON.parse(res.text) + resp.error.should.not.be.empty + expect(resp.error.includes("malformed")).to.be.true + done() + }) + }) + }) + + describe('GET get_stream_events: stream_id too long', () => { + it('should return an empty array', (done) => { + chai.request(url) + .get('/streams/WW91Q2FtZVRvU2VlV2hhdEl0TWVhbnNSaWdodD9Zb3VBcmVBQ3VyaW91c0ZlbGxvdy4uLg/events?from=0&to=1') + .end((err, res) => { + res.should.have.status(400) + res.body.should.be.an('object') + expect(res.header["content-type"] == 'application/json; charset=utf-8').to.be.true + + const resp = JSON.parse(res.text) + resp.error.should.not.be.empty + expect(resp.error.includes("too")).to.be.true + done() + }) + }) + }) + + // =============== push(stream_id, data) Tests =============== // + describe('POST push_event: valid stream_id', () => { + it('should return 200', (done) => { + chai.request(url) + .post('/streams/MQ/events') + .send({ + data: 'WW91SnVzdENhbnRTdGFuZEJlaW5nQ3VyaW91cw' + }) .end((err, res) => { res.should.have.status(200) res.body.should.be.a('object') + expect(res.header["content-type"] == 'application/json; charset=utf-8').to.be.true + done() + }) + }) + }) + + describe('POST push_event: malformed stream_id', () => { + it('should return stream_id malformed error message', (done) => { + chai.request(url) + .post('/streams/malformed_stream_id/events') + .send({ + data: 'WW91SnVzdENhbnRTdGFuZEJlaW5nQ3VyaW91cw' + }) + .end((err, res) => { + res.should.have.status(400) + res.body.should.be.a('object') + expect(res.header["content-type"] == 'application/json; charset=utf-8').to.be.true + + const resp = JSON.parse(res.text) + resp.error.should.not.be.empty + expect(resp.error.includes("malformed")).to.be.true + done() + }) + }) + }) + + describe('POST push_event: big stream_id', () => { + it('should return stream_id too big error message', (done) => { + chai.request(url) + .post('/streams/WW91Q2FtZVRvU2VlV2hhdEl0TWVhbnNSaWdodD9Zb3VBcmVBQ3VyaW91c0ZlbGxvdy4uLg/events') + .send({ + data: 'WW91SnVzdENhbnRTdGFuZEJlaW5nQ3VyaW91cw' + }) + .end((err, res) => { + res.should.have.status(400) + res.body.should.be.a('object') + expect(res.header["content-type"] == 'application/json; charset=utf-8').to.be.true + + const resp = JSON.parse(res.text) + resp.error.should.not.be.empty + expect(resp.error.includes("too")).to.be.true done() }) })