diff --git a/.circleci/config.yml b/.circleci/config.yml index 6bee292..8ccb561 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -70,10 +70,9 @@ jobs: working_directory: ./<< parameters.project >> environment: MOCHA_FILE: ./test-results.xml - MOCHA_ARGS: --forbid-pending --forbid-only --reporter mocha-junit-reporter command: | cp -v ../.c8rc.json ../.mocharc.json ./ - npm test + npm test -- --forbid-pending --forbid-only --reporter mocha-junit-reporter - run: name: Tar coverage command: tar -vcf << parameters.project >>-coverage.tar << parameters.project >>/coverage/ @@ -337,7 +336,7 @@ workflows: - docker/hadolint: name: validate-dockerfiles context: default - dockerfiles: "common/Dockerfile:zine-generator/Dockerfile:template/Dockerfile" + dockerfiles: "common/Dockerfile:int-test/Dockerfile:template/Dockerfile:zine-generator/Dockerfile" - build-docker: context: default project: common diff --git a/common/package-lock.json b/common/package-lock.json index d61bde5..2203f19 100644 --- a/common/package-lock.json +++ b/common/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "license": "Apache-2.0", "dependencies": { - "uuid": "^8.3.2" + "uuid": "^8.3.2", + "validate.js": "^0.13.1" }, "devDependencies": { "c8": "^7.10.0", @@ -471,20 +472,6 @@ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1241,6 +1228,11 @@ "node": ">=10.12.0" } }, + "node_modules/validate.js": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/validate.js/-/validate.js-0.13.1.tgz", + "integrity": "sha512-PnFM3xiZ+kYmLyTiMgTYmU7ZHkjBZz2/+F0DaALc/uUtVzdCt1wAosvYJ5hFQi/hz8O4zb52FQhHZRC+uVkJ+g==" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -1701,13 +1693,6 @@ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true }, - "fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "optional": true - }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2254,6 +2239,11 @@ "source-map": "^0.7.3" } }, + "validate.js": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/validate.js/-/validate.js-0.13.1.tgz", + "integrity": "sha512-PnFM3xiZ+kYmLyTiMgTYmU7ZHkjBZz2/+F0DaALc/uUtVzdCt1wAosvYJ5hFQi/hz8O4zb52FQhHZRC+uVkJ+g==" + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/common/package.json b/common/package.json index 2a544ad..74e29aa 100644 --- a/common/package.json +++ b/common/package.json @@ -20,6 +20,7 @@ "mocha-junit-reporter": "^2.0.2" }, "dependencies": { - "uuid": "^8.3.2" + "uuid": "^8.3.2", + "validate.js": "^0.13.1" } } diff --git a/common/src/index.js b/common/src/index.js index 4ccc4e8..17f57c8 100644 --- a/common/src/index.js +++ b/common/src/index.js @@ -1,4 +1,5 @@ import process from 'node:process'; +import validatejs from 'validate.js'; import {v4 as uuidv4} from 'uuid'; export function errorHandler(error, req, res, next) { @@ -41,4 +42,28 @@ export class RouteNotFoundError extends NotFoundError { } } -// Export default {errorHandler, RootError, NotFoundError, RouteNotFoundError}; +export class ValidationError extends RootError { + constructor(result) { + super('/errors/VALIDATION_ERROR', 'Validation Error', 400, result); + } +} + +export function validate(object, constraints) { + const result = validatejs(object, constraints); + if (result) { + throw new ValidationError(result); + } +} + +export function getVersionObject(string) { + if (string) { + const splt = string.split('.'); + return { + major: splt[0] ? Number.parseInt(splt[0], 10) : undefined, + minor: splt[1] ? Number.parseInt(splt[1], 10) : undefined, + patch: splt[2] ? Number.parseInt(splt[2], 10) : undefined, + }; + } + + return {}; +} diff --git a/common/test/validation-test.js b/common/test/validation-test.js new file mode 100644 index 0000000..5aa91e4 --- /dev/null +++ b/common/test/validation-test.js @@ -0,0 +1,249 @@ +import chai from 'chai'; +import {validate, ValidationError} from '../src/index.js'; + +const {expect} = chai; + +const everything = {s: 'a', b: true, i: 1, d: 1.1, a: [1, 2, 3], o: {name: 'Object'}}; + +describe('Basic Validation', function () { + it('Empty rules', function () { + validate(everything, {}); + }); + + it('More attributes than expected', function () { + validate(everything, { + z: {presence: false, type: 'string'}, + }); + }); + + it('Required attributes', function () { + validate(everything, { + s: {presence: true}, + }); + }); + + describe('Sting Validations', function () { + it('String', function () { + expect(function () { + validate(everything, { + s: {presence: false, type: 'string'}, + }); + }).to.not.throw(ValidationError); + }); + it('Bool', function () { + expect(function () { + validate(everything, { + b: {presence: false, type: 'string'}, + }); + }).to.throw(ValidationError); + }); + it('Integer', function () { + expect(function () { + validate(everything, { + i: {presence: false, type: 'string'}, + }); + }).to.throw(ValidationError); + }); + it('Decimal', function () { + expect(function () { + validate(everything, { + d: {presence: false, type: 'string'}, + }); + }).to.throw(ValidationError); + }); + it('Array', function () { + expect(function () { + validate(everything, { + a: {presence: false, type: 'string'}, + }); + }).to.throw(ValidationError); + }); + it('Object', function () { + expect(function () { + validate(everything, { + o: {presence: false, type: 'string'}, + }); + }).to.throw(ValidationError); + }); + }); + + describe('Boolean Validations', function () { + it('String', function () { + expect(function () { + validate(everything, { + s: {presence: false, type: 'boolean'}, + }); + }).to.throw(ValidationError); + }); + it('Bool', function () { + expect(function () { + validate(everything, { + b: {presence: false, type: 'boolean'}, + }); + }).to.not.throw(ValidationError); + }); + it('Integer', function () { + expect(function () { + validate(everything, { + i: {presence: false, type: 'boolean'}, + }); + }).to.throw(ValidationError); + }); + it('Decimal', function () { + expect(function () { + validate(everything, { + d: {presence: false, type: 'boolean'}, + }); + }).to.throw(ValidationError); + }); + it('Array', function () { + expect(function () { + validate(everything, { + a: {presence: false, type: 'boolean'}, + }); + }).to.throw(ValidationError); + }); + it('Object', function () { + expect(function () { + validate(everything, { + o: {presence: false, type: 'boolean'}, + }); + }).to.throw(ValidationError); + }); + }); + + describe('Integer Validations', function () { + it('String', function () { + expect(function () { + validate(everything, { + s: {presence: false, type: 'integer'}, + }); + }).to.throw(ValidationError); + }); + it('Bool', function () { + expect(function () { + validate(everything, { + b: {presence: false, type: 'integer'}, + }); + }).to.throw(ValidationError); + }); + it('Integer', function () { + expect(function () { + validate(everything, { + i: {presence: false, type: 'integer'}, + }); + }).to.not.throw(ValidationError); + }); + it('Decimal', function () { + expect(function () { + validate(everything, { + d: {presence: false, type: 'integer'}, + }); + }).to.throw(ValidationError); + }); + it('Array', function () { + expect(function () { + validate(everything, { + a: {presence: false, type: 'integer'}, + }); + }).to.throw(ValidationError); + }); + it('Object', function () { + expect(function () { + validate(everything, { + o: {presence: false, type: 'integer'}, + }); + }).to.throw(ValidationError); + }); + }); + + describe('Decimal Validations', function () { + it('String', function () { + expect(function () { + validate(everything, { + s: {presence: false, type: 'number'}, + }); + }).to.throw(ValidationError); + }); + it('Bool', function () { + expect(function () { + validate(everything, { + b: {presence: false, type: 'number'}, + }); + }).to.throw(ValidationError); + }); + it('Integer', function () { + expect(function () { + validate(everything, { + i: {presence: false, type: 'number'}, + }); + }).to.not.throw(ValidationError); + }); + it('Decimal', function () { + expect(function () { + validate(everything, { + d: {presence: false, type: 'number'}, + }); + }).to.not.throw(ValidationError); + }); + it('Array', function () { + expect(function () { + validate(everything, { + a: {presence: false, type: 'number'}, + }); + }).to.throw(ValidationError); + }); + it('Object', function () { + expect(function () { + validate(everything, { + o: {presence: false, type: 'number'}, + }); + }).to.throw(ValidationError); + }); + }); + + describe('Arrray Validations', function () { + it('String', function () { + expect(function () { + validate(everything, { + s: {presence: false, type: 'array'}, + }); + }).to.throw(ValidationError); + }); + it('Bool', function () { + expect(function () { + validate(everything, { + b: {presence: false, type: 'array'}, + }); + }).to.throw(ValidationError); + }); + it('Integer', function () { + expect(function () { + validate(everything, { + i: {presence: false, type: 'array'}, + }); + }).to.throw(ValidationError); + }); + it('Decimal', function () { + expect(function () { + validate(everything, { + d: {presence: false, type: 'array'}, + }); + }).to.throw(ValidationError); + }); + it('Array', function () { + expect(function () { + validate(everything, { + a: {presence: false, type: 'array'}, + }); + }).to.not.throw(ValidationError); + }); + it('Object', function () { + expect(function () { + validate(everything, { + o: {presence: false, type: 'array'}, + }); + }).to.throw(ValidationError); + }); + }); +}); diff --git a/common/test/version-test.js b/common/test/version-test.js new file mode 100644 index 0000000..83eb8d2 --- /dev/null +++ b/common/test/version-test.js @@ -0,0 +1,42 @@ +import chai from 'chai'; +import {getVersionObject} from '../src/index.js'; + +const {expect} = chai; + +describe('Version Object', function () { + it('Only Major', function () { + expect(getVersionObject('11')).to.deep.equal({ + major: 11, + minor: undefined, + patch: undefined, + }); + }); + it('Major.Minor', function () { + expect(getVersionObject('11.22')).to.deep.equal({ + major: 11, + minor: 22, + patch: undefined, + }); + }); + it('Major.Minor.Patch', function () { + expect(getVersionObject('11.22.33')).to.deep.equal({ + major: 11, + minor: 22, + patch: 33, + }); + }); + it('Extra long', function () { + expect(getVersionObject('11.22.33.44')).to.deep.equal({ + major: 11, + minor: 22, + patch: 33, + }); + }); + it('None', function () { + expect(getVersionObject('string')).to.deep.equal({ + major: Number.NaN, + minor: undefined, + patch: undefined, + }); + }); +}); diff --git a/template/package-lock.json b/template/package-lock.json index e2884a3..1866f63 100644 --- a/template/package-lock.json +++ b/template/package-lock.json @@ -17,8 +17,7 @@ "morgan": "^1.10.0", "node-cache": "^5.1.2", "swagger-jsdoc": "^6.1.0", - "swagger-ui-express": "^4.1.6", - "validate.js": "^0.13.1" + "swagger-ui-express": "^4.1.6" }, "devDependencies": { "c8": "^7.9.0", @@ -31,15 +30,18 @@ } }, "../common": { + "name": "@superflyxxi/common", "version": "0.0.0", "license": "Apache-2.0", "dependencies": { - "uuid": "^8.3.2" + "uuid": "^8.3.2", + "validate.js": "^0.13.1" }, "devDependencies": { "c8": "^7.10.0", "chai": "^4.3.4", - "mocha": "^9.1.3" + "mocha": "^9.1.3", + "mocha-junit-reporter": "^2.0.2" } }, "node_modules/@apidevtools/json-schema-ref-parser": { @@ -2695,11 +2697,6 @@ "node": ">= 8" } }, - "node_modules/validate.js": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/validate.js/-/validate.js-0.13.1.tgz", - "integrity": "sha512-PnFM3xiZ+kYmLyTiMgTYmU7ZHkjBZz2/+F0DaALc/uUtVzdCt1wAosvYJ5hFQi/hz8O4zb52FQhHZRC+uVkJ+g==" - }, "node_modules/validator": { "version": "12.2.0", "resolved": "https://registry.npmjs.org/validator/-/validator-12.2.0.tgz", @@ -3102,7 +3099,9 @@ "c8": "^7.10.0", "chai": "^4.3.4", "mocha": "^9.1.3", - "uuid": "^8.3.2" + "mocha-junit-reporter": "^2.0.2", + "uuid": "^8.3.2", + "validate.js": "^0.13.1" } }, "@tootallnate/once": { @@ -5126,11 +5125,6 @@ } } }, - "validate.js": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/validate.js/-/validate.js-0.13.1.tgz", - "integrity": "sha512-PnFM3xiZ+kYmLyTiMgTYmU7ZHkjBZz2/+F0DaALc/uUtVzdCt1wAosvYJ5hFQi/hz8O4zb52FQhHZRC+uVkJ+g==" - }, "validator": { "version": "12.2.0", "resolved": "https://registry.npmjs.org/validator/-/validator-12.2.0.tgz", diff --git a/template/package.json b/template/package.json index 50e99e6..e25246a 100644 --- a/template/package.json +++ b/template/package.json @@ -5,8 +5,8 @@ "type": "module", "scripts": { "start": "node src/index.js", - "test": "c8 mocha ${MOCHA_ARGS}", - "int-test": "mocha ${MOCHA_ARGS} int-test/" + "test": "c8 mocha", + "xo": "xo" }, "author": "SuperFlyXXI ", "license": "Apache-2.0", @@ -20,8 +20,7 @@ "morgan": "^1.10.0", "node-cache": "^5.1.2", "swagger-jsdoc": "^6.1.0", - "swagger-ui-express": "^4.1.6", - "validate.js": "^0.13.1" + "swagger-ui-express": "^4.1.6" }, "devDependencies": { "c8": "^7.9.0", diff --git a/template/src/index.js b/template/src/index.js index d6d51e4..5c54765 100644 --- a/template/src/index.js +++ b/template/src/index.js @@ -1,23 +1,22 @@ import express from 'express'; import morgan from 'morgan'; import {RouteNotFoundError, errorHandler} from '@superflyxxi/common'; -import apiDocsRouter from './routers/api-docs/index.js'; +import apiDocsRouter from './routers/api-docs.js'; import {server} from './config/index.js'; const app = express(); app.use(express.json()); app.disable('x-powered-by'); - -// eslint-disable-next-line no-unused-vars -app.use((req, res, next) => { - throw new RouteNotFoundError(req); -}); app.use(morgan('short')); -app.use(errorHandler); // APIs app.use('/api-docs', apiDocsRouter); +// Errors +app.use((req, res, next) => { + next(new RouteNotFoundError(req)); +}); +app.use(errorHandler); app.listen(server.port, () => { console.log('Started version', server.version, 'listening on', server.port); }); diff --git a/zine-generator/src/routers/api-docs/index.js b/template/src/routers/api-docs.js similarity index 57% rename from zine-generator/src/routers/api-docs/index.js rename to template/src/routers/api-docs.js index db80824..4cb6362 100644 --- a/zine-generator/src/routers/api-docs/index.js +++ b/template/src/routers/api-docs.js @@ -2,9 +2,9 @@ import swaggerUi from 'swagger-ui-express'; import express from 'express'; import swaggerJsdoc from 'swagger-jsdoc'; -import {server} from '../../config/index.js'; +import {server} from '../config/index.js'; -const router = express.Router(); +const apiDocs = express.Router(); const openapispec = swaggerJsdoc({ swaggerDefinition: { @@ -17,7 +17,7 @@ const openapispec = swaggerJsdoc({ apis: ['./src/routers/**/*.js'], }); -router.get('/json', (req, res) => res.send(openapispec)); -router.use('/', swaggerUi.serve, swaggerUi.setup(openapispec)); +apiDocs.get('/json', (req, res) => res.send(openapispec)); +apiDocs.use('/', swaggerUi.serve, swaggerUi.setup(openapispec)); -export default router; +export default apiDocs; diff --git a/template/src/routers/api-docs/index.js b/template/src/routers/api-docs/index.js deleted file mode 100644 index c22c912..0000000 --- a/template/src/routers/api-docs/index.js +++ /dev/null @@ -1,23 +0,0 @@ -import swaggerUi from 'swagger-ui-express'; -import express from 'express'; -import swaggerJsdoc from 'swagger-jsdoc'; - -import {server} from '../../config/index.js'; - -const router = express.Router(); - -const openapispec = swaggerJsdoc({ - swaggerDefinition: { - openapi: '3.0.0', - info: { - title: 'Template', - version: server.version, - }, - }, - apis: ['./src/routers/**/*.js', './src/error-handler/*.js'], -}); - -router.get('/json', (req, res) => res.send(openapispec)); -router.use('/', swaggerUi.serve, swaggerUi.setup(openapispec)); - -export default router; diff --git a/template/test/basic-test.js b/template/test/basic-test.js new file mode 100644 index 0000000..d10a6f6 --- /dev/null +++ b/template/test/basic-test.js @@ -0,0 +1,51 @@ +import chai from 'chai'; +import chaiHttp from 'chai-http'; +import app from '../../src/index.js'; + +const {expect} = chai; + +chai.use(chaiHttp); + +describe('Basic test', () => { + it('Root', (done) => { + chai + .request(app) + .get('/') + .end((error, res) => { + expect(res).to.have.status(404); + expect(res.body).to.deep.include({ + type: '/errors/NOT_FOUND', + title: 'Not Found', + status: res.status, + detail: 'GET / not a valid API.', + }); + expect(res.body).to.have.property('instance'); + done(); + }); + }); + it('API Docs', (done) => { + chai + .request(app) + .get('/api-docs/') + .end((error, res) => { + expect(res).to.have.status(200); + done(); + }); + }); + it('API Docs JSON', (done) => { + chai + .request(app) + .get('/api-docs/json') + .end((error, res) => { + expect(res).to.have.status(200); + expect(res.body).to.deep.include({ + openapi: '3.0.0', + info: { + title: 'Template', + version: '', + }, + }); + done(); + }); + }); +}); diff --git a/zine-generator/package-lock.json b/zine-generator/package-lock.json index f9752a0..d4d7ff3 100644 --- a/zine-generator/package-lock.json +++ b/zine-generator/package-lock.json @@ -18,8 +18,7 @@ "node-cache": "^5.1.2", "swagger-jsdoc": "^6.1.0", "swagger-ui-express": "^4.1.6", - "uuid": "^8.3.2", - "validate.js": "^0.13.1" + "uuid": "^8.3.2" }, "devDependencies": { "c8": "^7.9.0", @@ -36,12 +35,14 @@ "version": "0.0.0", "license": "Apache-2.0", "dependencies": { - "uuid": "^8.3.2" + "uuid": "^8.3.2", + "validate.js": "^0.13.1" }, "devDependencies": { "c8": "^7.10.0", "chai": "^4.3.4", - "mocha": "^9.1.3" + "mocha": "^9.1.3", + "mocha-junit-reporter": "^2.0.2" } }, "node_modules/@apidevtools/json-schema-ref-parser": { @@ -2705,11 +2706,6 @@ "node": ">= 8" } }, - "node_modules/validate.js": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/validate.js/-/validate.js-0.13.1.tgz", - "integrity": "sha512-PnFM3xiZ+kYmLyTiMgTYmU7ZHkjBZz2/+F0DaALc/uUtVzdCt1wAosvYJ5hFQi/hz8O4zb52FQhHZRC+uVkJ+g==" - }, "node_modules/validator": { "version": "12.2.0", "resolved": "https://registry.npmjs.org/validator/-/validator-12.2.0.tgz", @@ -3112,7 +3108,9 @@ "c8": "^7.10.0", "chai": "^4.3.4", "mocha": "^9.1.3", - "uuid": "^8.3.2" + "mocha-junit-reporter": "^2.0.2", + "uuid": "^8.3.2", + "validate.js": "^0.13.1" } }, "@tootallnate/once": { @@ -5141,11 +5139,6 @@ } } }, - "validate.js": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/validate.js/-/validate.js-0.13.1.tgz", - "integrity": "sha512-PnFM3xiZ+kYmLyTiMgTYmU7ZHkjBZz2/+F0DaALc/uUtVzdCt1wAosvYJ5hFQi/hz8O4zb52FQhHZRC+uVkJ+g==" - }, "validator": { "version": "12.2.0", "resolved": "https://registry.npmjs.org/validator/-/validator-12.2.0.tgz", diff --git a/zine-generator/package.json b/zine-generator/package.json index 7380387..bdaa346 100644 --- a/zine-generator/package.json +++ b/zine-generator/package.json @@ -5,10 +5,8 @@ "type": "module", "scripts": { "start": "node src/index.js", - "test": "c8 mocha ${MOCHA_ARGS}", - "int-test": "mocha ${MOCHA_ARGS} int-test/", - "xo-fix": "xo --fix", - "xo-verify": "xo" + "test": "c8 mocha ", + "xo": "xo" }, "author": "SuperFlyXXI ", "license": "Apache-2.0", @@ -23,8 +21,7 @@ "node-cache": "^5.1.2", "swagger-jsdoc": "^6.1.0", "swagger-ui-express": "^4.1.6", - "uuid": "^8.3.2", - "validate.js": "^0.13.1" + "uuid": "^8.3.2" }, "devDependencies": { "c8": "^7.9.0", diff --git a/zine-generator/src/config/index.js b/zine-generator/src/config/index.js index be04794..4a568ed 100644 --- a/zine-generator/src/config/index.js +++ b/zine-generator/src/config/index.js @@ -1,6 +1,6 @@ import fs from 'node:fs'; -const server = { +export const server = { port: 3000, version: getVersion(), }; @@ -9,4 +9,13 @@ function getVersion() { return fs.readFileSync('./src/version.txt', {encoding: 'utf-8'}).trim(); } -export {server}; +export const rankRules = { + comments: { + type: 'number', + scoreMethod: 'PREFER_HIGH', + }, + likes: { + type: 'number', + scoreMethod: 'PREFER_HIGH', + }, +}; diff --git a/zine-generator/src/controllers/generate.js b/zine-generator/src/controllers/generate.js new file mode 100644 index 0000000..f7b0fab --- /dev/null +++ b/zine-generator/src/controllers/generate.js @@ -0,0 +1,28 @@ +import process from 'node:process'; +import axios from 'axios'; +import {validate} from '@superflyxxi/common'; +import {rankRules} from '../config/index.js'; +import rank from '../services/rank.js'; + +const USER_POSTS_BASE_URL = process.env.USER_POSTS_BASE_URL ?? 'http://localhost'; + +export default async function generate(req, res) { + validate(req.body, { + startDate: {presence: false, type: 'datetime'}, + endDate: {presence: false, type: 'datetime'}, + }); + + const items = await axios.get(USER_POSTS_BASE_URL + '/v1/users/' + req.params.user_id + '/posts', { + params: { + startDate: req.body.startDate, + endDate: req.body.endDate, + }, + }).data; + // Const ttl = res.headers['cache-control'] ? res.headers['cache-control'].match(/max-age=(\d+)/i)[1] : 600; + const ranking = ['comments', 'likes', 'date']; + const rankedList = await rank(rankRules, ranking, items); + // Res.set('cache-control', 'public, max-age=2419200').send({ + res.send({ + zine: rankedList, + }); +} diff --git a/zine-generator/src/index.js b/zine-generator/src/index.js index d6d51e4..5c54765 100644 --- a/zine-generator/src/index.js +++ b/zine-generator/src/index.js @@ -1,23 +1,22 @@ import express from 'express'; import morgan from 'morgan'; import {RouteNotFoundError, errorHandler} from '@superflyxxi/common'; -import apiDocsRouter from './routers/api-docs/index.js'; +import apiDocsRouter from './routers/api-docs.js'; import {server} from './config/index.js'; const app = express(); app.use(express.json()); app.disable('x-powered-by'); - -// eslint-disable-next-line no-unused-vars -app.use((req, res, next) => { - throw new RouteNotFoundError(req); -}); app.use(morgan('short')); -app.use(errorHandler); // APIs app.use('/api-docs', apiDocsRouter); +// Errors +app.use((req, res, next) => { + next(new RouteNotFoundError(req)); +}); +app.use(errorHandler); app.listen(server.port, () => { console.log('Started version', server.version, 'listening on', server.port); }); diff --git a/zine-generator/src/routers/api-docs.js b/zine-generator/src/routers/api-docs.js new file mode 100644 index 0000000..4cb6362 --- /dev/null +++ b/zine-generator/src/routers/api-docs.js @@ -0,0 +1,23 @@ +import swaggerUi from 'swagger-ui-express'; +import express from 'express'; +import swaggerJsdoc from 'swagger-jsdoc'; + +import {server} from '../config/index.js'; + +const apiDocs = express.Router(); + +const openapispec = swaggerJsdoc({ + swaggerDefinition: { + openapi: '3.0.0', + info: { + title: 'Template', + version: server.version, + }, + }, + apis: ['./src/routers/**/*.js'], +}); + +apiDocs.get('/json', (req, res) => res.send(openapispec)); +apiDocs.use('/', swaggerUi.serve, swaggerUi.setup(openapispec)); + +export default apiDocs; diff --git a/zine-generator/src/routers/zine.js b/zine-generator/src/routers/zine.js new file mode 100644 index 0000000..291a8a6 --- /dev/null +++ b/zine-generator/src/routers/zine.js @@ -0,0 +1,76 @@ +import express from 'express'; +import asyncHandler from 'express-async-handler'; +import generate from '../controllers/generate.js'; + +const zine = express.Router(); + +/** + * @openapi + * components: + * schemas: + * Posts: + * - type: object + * properties: + * href: + * type: string + * format: uri + * description: Refernce to the phone object for details on what was used to calcualte score. + * score: + * type: number + * description: The score given to this phone. + * example: 100.0 + * + * Zine: + * type: object + * properties: + * zine: + * type: array + * description: An array of posts ranked. + * items: + * $ref: '#/components/schemas/Posts' + */ + +/** + * @openapi + * /v1/users/{user_id}/zines: + * get: + * summary: Get the latest zine + * description: | + * Gets the latest zine. + * parameters: + * - in: path + * name: user_id + * description: The user id to fetch zines. + * schema: + * type: string + * - in: query + * name: startDate + * description: Optional start date. If not provided, defaults to If-Modified-Since header. + * schema: + * type: string + * format: date-time + * - in: query + * name: endDate + * description: Optional end date. If not provided, defaults to current date-time. + * schema: + * type: string + * format: date-time + * produces: + * - application/json + * responses: + * '200': + * description: Success + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Zine' + * default: + * description: All other errors + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ +zine.get('/v1/users/:user_id/zines', asyncHandler(generate)); + +export default zine; diff --git a/zine-generator/src/services/rank.js b/zine-generator/src/services/rank.js new file mode 100644 index 0000000..179cfb7 --- /dev/null +++ b/zine-generator/src/services/rank.js @@ -0,0 +1,194 @@ +import lodash from 'lodash'; +import {getVersionObject} from '@superflyxxi/common'; + +export default async function rank(rankRules, ranking, items) { + const itemScoreList = []; + for (const item of items) { + itemScoreList.push({item}); + } + + const rankScale = await generateScoreScale(rankRules, ranking, itemScoreList); + await scoreAndSortItems(rankRules, itemScoreList, rankScale); + return itemScoreList; +} + +async function scoreAndSortItems(rankRules, itemScoreList, rankScale) { + const promises = []; + for (const itemScore of itemScoreList) { + promises.push(getFinalScore(rankRules, rankScale, itemScore)); + } + + await Promise.all(promises); + + itemScoreList.sort((alpha, beta) => beta.score - alpha.score); +} + +/** + * Generates an object that describes how each ranked property should be scored on a scale. + * For example, if the min height in the item list is 130 and the max height is 150, then for + * each mm, it would be equal to X points. If the min height is 140 and the max is 145, then + * each mm is worth Y points, where Y > X. + */ +async function generateScoreScale(rankRules, rankList, itemScoreList) { + const scales = {}; + + for (const r of rankList) { + scales[r] = initScoreScaleForRank(r, rankRules[r], itemScoreList); + } + + let i = rankList.length; + for (const r of rankList) { + populateScoreScaleForRank(scales[r], 2 ** i--, rankRules[r]); + } + + return scales; +} + +function scoreNumber(value, rankRule, rankScale) { + if (rankRule.scoreMethod === 'PREFER_LOW') { + return (rankScale.values.max - value) * rankScale.multiplier; + } + + if (rankRule.scoreMethod === 'PREFER_HIGH') { + return (value - rankScale.values.min) * rankScale.multiplier; + } + + return 0; +} + +function scoreBoolean(value, rankRule, rankScale) { + if (value && rankRule.scoreMethod === 'PREFER_TRUE') { + return rankScale.multiplier; + } + + return 0; +} + +function scoreVersion(value, rankRule, rankScale) { + const version = getVersionObject(value); + const semantic = rankScale.semantic; + if (version[semantic] && rankRule.scoreMethod === 'PREFER_HIGH') { + return (version[semantic] - rankScale[semantic].min) * rankScale.multiplier; + } + + if (version[semantic] && rankRule.scoreMethod === 'PREFER_LOW') { + return (rankScale[semantic].max - version[semantic]) * rankScale.multiplier; + } + + return 0; +} + +async function getFinalScore(rankRules, rankScale, itemScore) { + itemScore.scoreBreakdown = {}; + itemScore.score = 0; + // Set identifiers + for (const attribute in rankRules) { + if (rankRules[attribute].type === 'identifier') { + itemScore[attribute] = lodash.get(itemScore.item, attribute); + } + } + + // Set scores + for (const r in rankScale) { + const value = lodash.get(itemScore.item, r); + let score = 0; + if (rankRules[r]) { + switch (rankRules[r].type) { + case 'number': + score = scoreNumber(value, rankRules[r], rankScale[r]); + break; + + case 'boolean': + score = scoreBoolean(value, rankRules[r], rankScale[r]); + break; + + case 'version': + score = scoreVersion(value, rankRules[r], rankScale[r]); + break; + + default: + console.error('Invalid type configured: rank=', r, 'type=', rankRules[r].type); + break; + } + } + + itemScore.scoreBreakdown[r] = score; + itemScore.score += score; + } + + delete itemScore.item; +} + +function initScoreScaleForRank(r, rankRule, itemScoreList) { + const result = {}; + if (rankRule) { + const mapValues = {}; + switch (rankRule.type) { + case 'number': + mapValues.values = []; + break; + case 'version': + mapValues.major = []; + mapValues.minor = []; + mapValues.patch = []; + break; + default: + // Skip any types that don't need counting + return result; + } + + for (const itemScore of itemScoreList) { + const value = lodash.get(itemScore.item, r); + + if (value) { + let version; + switch (rankRule.type) { + case 'number': + mapValues.values.push(value); + break; + case 'version': + version = getVersionObject(value); + if (version?.major !== null) mapValues.major.push(version.major); + if (version?.minor !== null) mapValues.minor.push(version.minor); + if (version?.patch !== null) mapValues.patch.push(version.patch); + break; + default: + // Should never get here + } + } + } + + for (const item of Object.keys(mapValues)) { + result[item] = {}; + result[item].max = Math.max(...mapValues[item]); + result[item].min = Math.min(...mapValues[item]); + } + } + + return result; +} + +function populateScoreScaleForRank(scoreScale, maxPoints, rankRule) { + if (rankRule) { + let temporarySemantic; + let semantic = 'major'; + switch (rankRule.type) { + case 'number': + scoreScale.multiplier = maxPoints / (scoreScale.values.max - scoreScale.values.min); + break; + case 'version': + for (temporarySemantic of ['major', 'minor', 'patch']) { + if (scoreScale[temporarySemantic]?.max !== scoreScale[temporarySemantic]?.min) { + semantic = temporarySemantic; + break; + } + } + + scoreScale.semantic = semantic; + scoreScale.multiplier = maxPoints / (scoreScale[semantic].max - scoreScale[semantic].min); + break; + default: + scoreScale.multiplier = maxPoints; + } + } +} diff --git a/zine-generator/test/routers/basic-test.js b/zine-generator/test/routers/basic-test.js new file mode 100644 index 0000000..d10a6f6 --- /dev/null +++ b/zine-generator/test/routers/basic-test.js @@ -0,0 +1,51 @@ +import chai from 'chai'; +import chaiHttp from 'chai-http'; +import app from '../../src/index.js'; + +const {expect} = chai; + +chai.use(chaiHttp); + +describe('Basic test', () => { + it('Root', (done) => { + chai + .request(app) + .get('/') + .end((error, res) => { + expect(res).to.have.status(404); + expect(res.body).to.deep.include({ + type: '/errors/NOT_FOUND', + title: 'Not Found', + status: res.status, + detail: 'GET / not a valid API.', + }); + expect(res.body).to.have.property('instance'); + done(); + }); + }); + it('API Docs', (done) => { + chai + .request(app) + .get('/api-docs/') + .end((error, res) => { + expect(res).to.have.status(200); + done(); + }); + }); + it('API Docs JSON', (done) => { + chai + .request(app) + .get('/api-docs/json') + .end((error, res) => { + expect(res).to.have.status(200); + expect(res.body).to.deep.include({ + openapi: '3.0.0', + info: { + title: 'Template', + version: '', + }, + }); + done(); + }); + }); +}); diff --git a/zine-generator/test/routers/root-test.js b/zine-generator/test/routers/root-test.js deleted file mode 100644 index 74686a1..0000000 --- a/zine-generator/test/routers/root-test.js +++ /dev/null @@ -1,26 +0,0 @@ -import chai from 'chai'; -import chaiHttp from 'chai-http'; -import app from '../../src/index.js'; - -const {expect} = chai; - -chai.use(chaiHttp); - -describe('Root test', () => { - it('Should get 404', (done) => { - chai - .request(app) - .get('/') - .end((error, res) => { - expect(res).to.have.status(404); - expect(res.body).to.deep.include({ - type: '/errors/NOT_FOUND', - title: 'Not Found', - status: res.status, - detail: 'GET / not a valid API.', - }); - expect(res.body).to.have.property('instance'); - done(); - }); - }); -}); diff --git a/zine-generator/test/services/rank-test.js b/zine-generator/test/services/rank-test.js new file mode 100644 index 0000000..77a3219 --- /dev/null +++ b/zine-generator/test/services/rank-test.js @@ -0,0 +1,162 @@ +import {strict as assert} from 'node:assert'; +import rank from '../../src/services/rank.js'; + +describe('Rank Score Tests', function () { + it('Rank empty items', async function () { + const res = await rank({}, [], []); + assert.deepEqual(res, []); + }); + + it('Rank no rules', async function () { + const res = await rank({}, ['attr1'], [{attr1: 1}, {attr1: 2}]); + assert.deepEqual(res, [ + {score: 0, scoreBreakdown: {attr1: 0}}, + {score: 0, scoreBreakdown: {attr1: 0}}, + ]); + }); + + it('Rank empty ranklist', async function () { + const res = await rank({}, [], [{attr1: 1}, {attr1: 2}]); + assert.deepEqual(res, [ + {score: 0, scoreBreakdown: {}}, + {score: 0, scoreBreakdown: {}}, + ]); + }); + + it('Rank single', async function () { + const res = await rank( + { + id: { + type: 'identifier', + }, + attr1: { + type: 'number', + scoreMethod: 'PREFER_HIGH', + }, + }, + ['attr1'], + [{id: 1, attr1: 1}], + ); + assert.deepEqual(res, [{id: 1, score: Number.NaN, scoreBreakdown: {attr1: Number.NaN}}]); + }); + + describe('Rank numbers', function () { + it('Prefer higher', async function () { + const res = await rank( + { + id: { + type: 'identifier', + }, + attr1: { + type: 'number', + scoreMethod: 'PREFER_HIGH', + }, + attr2: { + type: 'number', + scoreMethod: 'PREFER_HIGH', + }, + }, + ['attr1', 'attr2'], + [ + {id: 'test1', attr1: 1, attr2: 11}, + {id: 'testa', attr1: 2, attr2: 22}, + ], + ); + assert.deepEqual(res, [ + {id: 'testa', score: 6, scoreBreakdown: {attr1: 4, attr2: 2}}, + {id: 'test1', score: 0, scoreBreakdown: {attr1: 0, attr2: 0}}, + ]); + }); + + it('Prefer lower', async function () { + const res = await rank( + { + id: { + type: 'identifier', + }, + attr1: { + type: 'number', + scoreMethod: 'PREFER_LOW', + }, + attr2: { + type: 'number', + scoreMethod: 'PREFER_LOW', + }, + }, + ['attr1', 'attr2'], + [ + {id: 'test1', attr1: 1, attr2: 11}, + {id: 'testa', attr1: 2, attr2: 22}, + ], + ); + assert.deepEqual(res, [ + {id: 'test1', score: 6, scoreBreakdown: {attr1: 4, attr2: 2}}, + {id: 'testa', score: 0, scoreBreakdown: {attr1: 0, attr2: 0}}, + ]); + }); + }); + + describe('Rank version', function () { + it('Prefer higher same levels', async function () { + const res = await rank( + { + id: { + type: 'identifier', + }, + attr1: { + type: 'version', + scoreMethod: 'PREFER_HIGH', + }, + attr2: { + type: 'version', + scoreMethod: 'PREFER_HIGH', + }, + attr3: { + type: 'version', + scoreMethod: 'PREFER_HIGH', + }, + }, + ['attr1', 'attr2', 'attr3'], + [ + {id: 'test1', attr1: '1', attr2: '1.1', attr3: '1.1.1'}, + {id: 'testa', attr1: '2', attr2: '1.2', attr3: '1.1.2'}, + ], + ); + assert.deepEqual(res, [ + {id: 'testa', score: 14, scoreBreakdown: {attr1: 8, attr2: 4, attr3: 2}}, + {id: 'test1', score: 0, scoreBreakdown: {attr1: 0, attr2: 0, attr3: 0}}, + ]); + }); + + it('Prefer lower different levels', async function () { + const res = await rank( + { + id: { + type: 'identifier', + }, + attr1: { + type: 'version', + scoreMethod: 'PREFER_LOW', + }, + attr2: { + type: 'version', + scoreMethod: 'PREFER_LOW', + }, + attr3: { + type: 'version', + scoreMethod: 'PREFER_LOW', + }, + }, + ['attr1', 'attr2', 'attr3'], + [ + {id: 'test1', attr1: '2.1', attr2: '2.3.1', attr3: '2.0.2'}, + {id: 'testa', attr1: '1.1', attr2: '2.2.2', attr3: '2.0.1'}, + ], + ); + assert.deepEqual(res, [ + {id: 'testa', score: 14, scoreBreakdown: {attr1: 8, attr2: 4, attr3: 2}}, + {id: 'test1', score: 0, scoreBreakdown: {attr1: 0, attr2: 0, attr3: 0}}, + ]); + }); + }); +});