@hestia-earth/data-api 0.0.2-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.dockerignore +25 -0
  2. package/.env.test +7 -0
  3. package/.eslintignore +7 -0
  4. package/.eslintrc.js +11 -0
  5. package/.gitlab-ci.yml +125 -0
  6. package/.mocharc.js +8 -0
  7. package/.nvrm +1 -0
  8. package/.nycrc +15 -0
  9. package/Dockerfile +17 -0
  10. package/cleanup-docker.sh +4 -0
  11. package/commitlint.config.js +1 -0
  12. package/database/index.ts +76 -0
  13. package/database/migrations/001.do.init.sql +53 -0
  14. package/database/migrations/002.do.add-aggregated-sites.sql +16 -0
  15. package/database/migrations/003.do.add-generated-period-cols.sql +7 -0
  16. package/database/migrations/index.ts +36 -0
  17. package/database/seed/common.ts +7 -0
  18. package/database/seed/index.ts +55 -0
  19. package/database/seed/local/index.ts +28 -0
  20. package/database/seed/production/index.ts +3 -0
  21. package/database/seed/staging/index.ts +5 -0
  22. package/database/seed/test/index.ts +28 -0
  23. package/dev.ts +3 -0
  24. package/dist/aggregated-nodes/model/index.d.ts +25 -0
  25. package/dist/aggregated-nodes/model/index.js +11 -0
  26. package/dist/models.d.ts +1 -0
  27. package/dist/models.js +17 -0
  28. package/docker-compose.yml +42 -0
  29. package/envs/.master.env +7 -0
  30. package/envs/.staging.env +7 -0
  31. package/index.js +3 -0
  32. package/package.json +105 -0
  33. package/run-docker.sh +14 -0
  34. package/run-test.sh +5 -0
  35. package/scripts/run-lambda.ts +10 -0
  36. package/scripts/run-migrations.ts +18 -0
  37. package/scripts/run-resetdb.ts +18 -0
  38. package/scripts/run-seed.ts +18 -0
  39. package/serverless.yml +76 -0
  40. package/src/aggregated-nodes/model/index.ts +37 -0
  41. package/src/aggregated-nodes/routes/pg-get-filters.ts +44 -0
  42. package/src/aggregated-nodes/routes/pg-get.ts +50 -0
  43. package/src/aggregated-nodes/routes.spec.ts +242 -0
  44. package/src/aggregated-nodes/routes.ts +56 -0
  45. package/src/aggregated-nodes/services/pg-get-filters.ts +52 -0
  46. package/src/aggregated-nodes/services/pg-get.ts +77 -0
  47. package/src/app.spec.ts +34 -0
  48. package/src/app.ts +59 -0
  49. package/src/config.ts +21 -0
  50. package/src/cors.spec.ts +32 -0
  51. package/src/cors.ts +7 -0
  52. package/src/errors.spec.ts +114 -0
  53. package/src/errors.ts +121 -0
  54. package/src/index.spec.ts +94 -0
  55. package/src/index.ts +14 -0
  56. package/src/lambdas/sentry.ts +12 -0
  57. package/src/lambdas/update-aggregated-nodes/handler.spec.ts +86 -0
  58. package/src/lambdas/update-aggregated-nodes/handler.ts +141 -0
  59. package/src/logger.spec.ts +20 -0
  60. package/src/logger.ts +45 -0
  61. package/src/maintenance.spec.ts +76 -0
  62. package/src/maintenance.ts +19 -0
  63. package/src/models.ts +1 -0
  64. package/src/routes.spec.ts +33 -0
  65. package/src/routes.ts +9 -0
  66. package/src/settings/model/index.ts +21 -0
  67. package/src/settings/routes/get.spec.ts +33 -0
  68. package/src/settings/routes/get.ts +3 -0
  69. package/src/settings/routes/update.spec.ts +33 -0
  70. package/src/settings/routes/update.ts +5 -0
  71. package/src/settings/routes.spec.ts +75 -0
  72. package/src/settings/routes.ts +21 -0
  73. package/src/settings/services/get.spec.ts +62 -0
  74. package/src/settings/services/get.ts +18 -0
  75. package/src/settings/services/update.spec.ts +118 -0
  76. package/src/settings/services/update.ts +47 -0
  77. package/src/slack.spec.ts +42 -0
  78. package/src/slack.ts +17 -0
  79. package/src/swagger/routes.ts +57 -0
  80. package/src/types/async-express-errors/index.d.ts +1 -0
  81. package/src/types/express/index.d.ts +10 -0
  82. package/src/utils/endpoint-wrapper.spec.ts +80 -0
  83. package/src/utils/endpoint-wrapper.ts +16 -0
  84. package/src/utils/middleware.spec.ts +154 -0
  85. package/src/utils/middleware.ts +33 -0
  86. package/test/Dockerfile +13 -0
  87. package/test/docker-compose.yml +40 -0
  88. package/test/fixtures/aggregated-nodes/get.ts +184 -0
  89. package/test/fixtures/update-aggregated-nodes/abyssinianKaleSeedWhole-cycle_pivoted.csv +5 -0
  90. package/test/fixtures/update-aggregated-nodes/abyssinianKaleSeedWhole-cycle_pivoted.csv.cycle.json +458 -0
  91. package/test/fixtures/update-aggregated-nodes/abyssinianKaleSeedWhole-cycle_pivoted.csv.site.json +182 -0
  92. package/test/fixtures/update-aggregated-nodes/abyssinianKaleSeedWhole-impactassessment_pivoted.csv +3 -0
  93. package/test/fixtures/update-aggregated-nodes/abyssinianKaleSeedWhole-impactassessment_pivoted.csv.impactAssessment.json +988 -0
  94. package/test/fixtures/update-aggregated-nodes/abyssinianKaleStraw-impactassessment_pivoted.csv +3 -0
  95. package/test/fixtures/update-aggregated-nodes/cycle-missing-impactassessment_pivoted.csv +3 -0
  96. package/test/fixtures/update-aggregated-nodes/tomatoFruit-cycle_pivoted.csv +5 -0
  97. package/test/fixtures/update-aggregated-nodes/tomatoFruit-cycle_pivoted.csv.cycle.json +584 -0
  98. package/test/fixtures/update-aggregated-nodes/tomatoFruit-cycle_pivoted.csv.site.json +212 -0
  99. package/test/fixtures/update-aggregated-nodes/tomatoFruit-impactassessment_pivoted.csv +3 -0
  100. package/test/fixtures/update-aggregated-nodes/tomatoFruit-impactassessment_pivoted.csv.impactAssessment.json +1002 -0
  101. package/test/prepare.ts +15 -0
  102. package/test/utils.ts +33 -0
  103. package/tsconfig.build.json +13 -0
  104. package/tsconfig.dist.json +14 -0
  105. package/tsconfig.json +37 -0
  106. package/tsconfig.lambdas.json +13 -0
@@ -0,0 +1,62 @@
1
+ import chai from 'chai';
2
+ import chaiAsPromised from 'chai-as-promised';
3
+ chai.use(chaiAsPromised);
4
+ const { expect } = chai;
5
+
6
+ import * as sinon from 'sinon';
7
+ import 'mocha';
8
+
9
+ import { fixtures, initDb } from '../../../test/utils';
10
+
11
+ import { get, isEnabled } from './get';
12
+ import * as specs from './get';
13
+ import { SettingKey } from '../model';
14
+
15
+ let stubs: sinon.SinonStub[] = [];
16
+
17
+ describe('settings > services', () => {
18
+ beforeEach(() => {
19
+ stubs = [];
20
+ });
21
+
22
+ afterEach(() => {
23
+ stubs.forEach((stub) => stub.restore());
24
+ });
25
+
26
+ describe('get', () => {
27
+ beforeEach(async () => {
28
+ await initDb();
29
+ });
30
+
31
+ describe('get', () => {
32
+ describe('setting not found', () => {
33
+ it('should throw an error', () => {
34
+ return expect(get('random_key' as any)).to.be.rejected;
35
+ });
36
+ });
37
+
38
+ describe('setting found', () => {
39
+ it('should return the setting', async () => {
40
+ const {
41
+ rows: [setting]
42
+ } = await get(SettingKey.maintenanceEnabled);
43
+ expect(setting).to.deep.equal(fixtures.settings[0]);
44
+ });
45
+ });
46
+ });
47
+
48
+ describe('isEnabled', () => {
49
+ const active = true;
50
+
51
+ describe('setting exists', () => {
52
+ beforeEach(() => {
53
+ stubs.push(sinon.stub(specs, 'get').resolves({ rows: [{ setting: 'key', active }] } as any));
54
+ });
55
+
56
+ it('should return the setting value', async () => {
57
+ expect(await isEnabled('key' as any)).to.equal(active);
58
+ });
59
+ });
60
+ });
61
+ });
62
+ });
@@ -0,0 +1,18 @@
1
+ import { SettingKey, getSetting, getAllSettings } from '../model/';
2
+
3
+ const loadAllSettings = async () => {
4
+ const { rows } = await getAllSettings();
5
+ const value = rows.reduce((prev, setting) => ({ ...prev, [setting.setting]: setting.active }), {});
6
+ return value;
7
+ };
8
+
9
+ export const getAll = () => loadAllSettings();
10
+
11
+ export const get = (key: SettingKey) => getSetting(key);
12
+
13
+ export const isEnabled = async (key: SettingKey) => {
14
+ const {
15
+ rows: [setting]
16
+ } = await get(key);
17
+ return setting?.active;
18
+ };
@@ -0,0 +1,118 @@
1
+ import { expect } from 'chai';
2
+ import * as sinon from 'sinon';
3
+ import 'mocha';
4
+
5
+ import * as slack from '../../slack';
6
+ import { postMessage } from './update';
7
+ import * as model from '../model';
8
+ import * as servicesGet from './get';
9
+
10
+ let stubs: sinon.SinonStub[] = [];
11
+
12
+ describe('settings > services', () => {
13
+ beforeEach(() => {
14
+ stubs = [];
15
+ });
16
+
17
+ afterEach(() => {
18
+ stubs.forEach((stub) => stub.restore());
19
+ });
20
+
21
+ describe('update', () => {
22
+ describe('postMessage', () => {
23
+ const slackThreadTs = 'thread';
24
+ let updateStub: sinon.SinonStub;
25
+ let sendMessageStub: sinon.SinonStub;
26
+ let replyMessageStub: sinon.SinonStub;
27
+
28
+ beforeEach(() => {
29
+ stubs.push((updateStub = sinon.stub(model, 'setSetting').resolves()));
30
+ stubs.push((sendMessageStub = sinon.stub(slack, 'sendMessage').resolves(slackThreadTs)));
31
+ stubs.push((replyMessageStub = sinon.stub(slack, 'replyMessage').resolves(slackThreadTs)));
32
+ });
33
+
34
+ describe('turning setting on', () => {
35
+ const key = model.SettingKey.maintenanceEnabled;
36
+ const value = true;
37
+
38
+ beforeEach(() => {
39
+ stubs.push(
40
+ sinon.stub(servicesGet, 'get').resolves({
41
+ rows: [{ setting: key, active: value, metadata: {} }]
42
+ } as any)
43
+ );
44
+ });
45
+
46
+ it('should send the message', async () => {
47
+ await postMessage(key, value);
48
+ expect(sendMessageStub.called).to.equal(true);
49
+ });
50
+
51
+ it('should not reply to the message', async () => {
52
+ await postMessage(key, value);
53
+ expect(replyMessageStub.called).to.equal(false);
54
+ });
55
+
56
+ it('should set the thread', async () => {
57
+ await postMessage(key, value);
58
+ expect(updateStub.calledWith({ key, value, metadata: { slackThreadTs } })).to.equal(true);
59
+ });
60
+ });
61
+
62
+ describe('turning setting off', () => {
63
+ const key = model.SettingKey.maintenanceEnabled;
64
+ const value = false;
65
+
66
+ describe('existing thread', () => {
67
+ beforeEach(() => {
68
+ stubs.push(
69
+ sinon.stub(servicesGet, 'get').resolves({
70
+ rows: [{ setting: key, active: value, metadata: { slackThreadTs } }]
71
+ } as any)
72
+ );
73
+ });
74
+
75
+ it('should not send the message', async () => {
76
+ await postMessage(key, value);
77
+ expect(sendMessageStub.called).to.equal(false);
78
+ });
79
+
80
+ it('should reply the message', async () => {
81
+ await postMessage(key, value);
82
+ expect(replyMessageStub.called).to.equal(true);
83
+ });
84
+
85
+ it('should remove the thread', async () => {
86
+ await postMessage(key, value);
87
+ expect(updateStub.calledWith({ key, value, metadata: { slackThreadTs: null } })).to.equal(true);
88
+ });
89
+ });
90
+
91
+ describe('non-existing thread', () => {
92
+ beforeEach(() => {
93
+ stubs.push(
94
+ sinon.stub(servicesGet, 'get').resolves({
95
+ rows: [{ setting: key, active: value, metadata: {} }]
96
+ } as any)
97
+ );
98
+ });
99
+
100
+ it('should not send the message', async () => {
101
+ await postMessage(key, value);
102
+ expect(sendMessageStub.called).to.equal(false);
103
+ });
104
+
105
+ it('should not reply to the message', async () => {
106
+ await postMessage(key, value);
107
+ expect(replyMessageStub.called).to.equal(false);
108
+ });
109
+
110
+ it('should remove the thread', async () => {
111
+ await postMessage(key, value);
112
+ expect(updateStub.calledWith({ key, value, metadata: { slackThreadTs: null } })).to.equal(true);
113
+ });
114
+ });
115
+ });
116
+ });
117
+ });
118
+ });
@@ -0,0 +1,47 @@
1
+ import { apiUrl, webappUrl } from '../../config';
2
+ import { sendMessage, replyMessage } from '../../slack';
3
+ import { get } from './get';
4
+ import { SettingKey, setSetting } from '../model/';
5
+
6
+ type settingData = { [key in SettingKey]?: any };
7
+
8
+ const slackChannel = 'product-team';
9
+
10
+ const messageByKey: {
11
+ [key in SettingKey]?: (value: any) => string;
12
+ } = {
13
+ [SettingKey.maintenanceEnabled]: (value: boolean) =>
14
+ value ? 'Data API is under maintenance, please do not use the data explorer' : 'Maintenance ended'
15
+ };
16
+
17
+ export const postMessage = async (key: SettingKey, value: boolean) => {
18
+ const { rows } = await get(key);
19
+ const setting = rows?.[0];
20
+ const message = messageByKey[key](value);
21
+ const fullMessage = `
22
+ ${messageByKey[key](value)}
23
+
24
+ *API*: ${apiUrl}
25
+ *Website*: ${webappUrl}
26
+ `.trim();
27
+ const existingThread = setting ? setting.metadata?.slackThreadTs : null;
28
+ const ts = value
29
+ ? // setting is turned on, create new thread
30
+ await sendMessage({ channel: slackChannel, text: fullMessage })
31
+ : existingThread
32
+ ? // setting is turned off and thread exists, reply to it
33
+ await replyMessage(existingThread, { channel: slackChannel, text: message })
34
+ : // setting is turned off and no thread exists, ignore
35
+ null;
36
+
37
+ return setSetting({ key, value, metadata: { slackThreadTs: value ? ts : null } });
38
+ };
39
+
40
+ export const updateSingle = async (key: SettingKey, value: any) => {
41
+ await setSetting({ key, value });
42
+ Object.keys(messageByKey).includes(key) && (await postMessage(key, value));
43
+ };
44
+
45
+ export const updateAll = (value: settingData) => {
46
+ return Promise.all(Object.entries(value).map(([key, v]) => updateSingle(key as SettingKey, v)));
47
+ };
@@ -0,0 +1,42 @@
1
+ import { expect } from 'chai';
2
+ import * as sinon from 'sinon';
3
+ import 'mocha';
4
+ import * as slackApi from '@slack/web-api/dist/WebClient';
5
+ import { sendMessage, replyMessage } from './slack';
6
+
7
+ let stubs: sinon.SinonStub[] = [];
8
+ const postMessageStub: sinon.SinonStub = sinon.stub().returns({ ts: 'ts' });
9
+
10
+ const SlackWebClientStub = sinon.createStubInstance(slackApi.WebClient);
11
+ (SlackWebClientStub as any).chat = { postMessage: postMessageStub };
12
+
13
+ describe('slack', () => {
14
+ beforeEach(() => {
15
+ stubs = [];
16
+ stubs.push(sinon.stub(slackApi, 'WebClient').returns(SlackWebClientStub));
17
+ });
18
+
19
+ afterEach(() => {
20
+ stubs.forEach((stub) => stub.restore());
21
+ postMessageStub.resetHistory();
22
+ });
23
+
24
+ describe('sendMessage', () => {
25
+ it('should send a message to a channel', async () => {
26
+ await sendMessage({});
27
+ expect(postMessageStub.args).to.deep.equal([[{ channel: process.env.SLACK_CHANNEL, link_names: true }]]);
28
+ });
29
+ });
30
+
31
+ describe('replyMessage', () => {
32
+ describe('with thread', () => {
33
+ it('should send a message to a thread', async () => {
34
+ await replyMessage('thread_ts', {});
35
+
36
+ expect(postMessageStub.args).to.deep.equal([
37
+ [{ channel: process.env.SLACK_CHANNEL, thread_ts: 'thread_ts', link_names: true }]
38
+ ]);
39
+ });
40
+ });
41
+ });
42
+ });
package/src/slack.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { WebClient, ChatPostMessageArguments } from '@slack/web-api';
2
+
3
+ import { slackConfig } from './config';
4
+
5
+ const channel = slackConfig.channel;
6
+
7
+ export const sendMessage = async (args: Partial<ChatPostMessageArguments>) =>
8
+ (
9
+ await new WebClient(slackConfig.token).chat.postMessage({
10
+ channel: args.channel || channel,
11
+ link_names: true,
12
+ ...args
13
+ })
14
+ ).ts;
15
+
16
+ export const replyMessage = async (thread_ts: string, args: Omit<ChatPostMessageArguments, 'thread_ts'>) =>
17
+ thread_ts ? await sendMessage({ thread_ts, ...args }) : null;
@@ -0,0 +1,57 @@
1
+ import { Router } from 'express';
2
+ import { serve, setup } from 'swagger-ui-express';
3
+ import swaggerJSDoc from 'swagger-jsdoc';
4
+
5
+ import { apiUrl } from '../config';
6
+ import pkg from '../../package.json';
7
+
8
+ const options = {
9
+ swaggerDefinition: {
10
+ openapi: '3.0.1',
11
+ info: {
12
+ title: pkg.name,
13
+ version: pkg.version,
14
+ description: 'Hestia Data API documentation.',
15
+ license: {
16
+ name: 'GPL-3.0-or-later',
17
+ url: 'https://choosealicense.com/licenses/gpl-3.0/'
18
+ }
19
+ },
20
+ components: {
21
+ securitySchemes: {
22
+ AccessToken: {
23
+ type: 'apiKey',
24
+ in: 'header',
25
+ name: 'x-access-token'
26
+ }
27
+ }
28
+ },
29
+ servers: [
30
+ {
31
+ url: apiUrl
32
+ }
33
+ ]
34
+ },
35
+ apis: ['src/*.ts', 'src/**/*.ts']
36
+ };
37
+
38
+ const swaggerSpec = swaggerJSDoc(options);
39
+ const swaggerOptions = {
40
+ customCss: `
41
+ .swagger-ui .topbar {
42
+ display: none;
43
+ }
44
+ `,
45
+ customSiteTitle: 'Hestia Data API Explorer',
46
+ url: '/swagger.json'
47
+ };
48
+
49
+ export default () => {
50
+ const router = Router();
51
+
52
+ router.use('/', serve);
53
+ router.get('/', setup(swaggerSpec, swaggerOptions));
54
+ router.get('/swagger.json', (req, res) => res.json(swaggerSpec));
55
+
56
+ return router;
57
+ };
@@ -0,0 +1 @@
1
+ declare module 'express-async-errors';
@@ -0,0 +1,10 @@
1
+ import { IFilter } from '../../aggregated-nodes/model';
2
+
3
+ declare global {
4
+ namespace Express {
5
+ // eslint-disable-next-line @typescript-eslint/no-empty-interface
6
+ interface Request {
7
+ filter: IFilter;
8
+ }
9
+ }
10
+ }
@@ -0,0 +1,80 @@
1
+ import { expect } from 'chai';
2
+ import * as sinon from 'sinon';
3
+ import 'mocha';
4
+
5
+ import { endpointWrapper } from './endpoint-wrapper';
6
+
7
+ class Response {
8
+ status(_status: number) {
9
+ return this;
10
+ }
11
+
12
+ json() {}
13
+ }
14
+
15
+ let stubs: sinon.SinonStub[] = [];
16
+
17
+ describe('utils > endpoint-wrapper', () => {
18
+ beforeEach(() => {
19
+ stubs = [];
20
+ });
21
+
22
+ afterEach(() => {
23
+ stubs.forEach((stub) => stub.restore());
24
+ });
25
+
26
+ describe('endpointWrapper', () => {
27
+ const response = new Response();
28
+ let fakeFunction: Function;
29
+ let jsonStub: sinon.SinonStub;
30
+ let nextStub: sinon.SinonStub;
31
+
32
+ beforeEach(() => {
33
+ stubs.push((jsonStub = sinon.stub(response, 'json')));
34
+ nextStub = sinon.stub();
35
+ });
36
+
37
+ describe('function fail', () => {
38
+ const err = new Error('error');
39
+
40
+ beforeEach(() => {
41
+ fakeFunction = () => {
42
+ throw err;
43
+ };
44
+ });
45
+
46
+ it('should reject', async () => {
47
+ const result = endpointWrapper(fakeFunction);
48
+ await result(null, response as any, nextStub);
49
+ expect(jsonStub.called).to.equal(false);
50
+ expect(nextStub.calledWith(err)).to.equal(true);
51
+ });
52
+ });
53
+
54
+ describe('function success', () => {
55
+ beforeEach(() => {
56
+ fakeFunction = () => 'success';
57
+ });
58
+
59
+ it('should resolve', async () => {
60
+ const result = endpointWrapper(fakeFunction);
61
+ await result(null, response as any, nextStub);
62
+ expect(jsonStub.calledWith('success')).to.equal(true);
63
+ expect(nextStub.called).to.equal(false);
64
+ });
65
+ });
66
+
67
+ describe('without a function', () => {
68
+ beforeEach(() => {
69
+ fakeFunction = undefined;
70
+ });
71
+
72
+ it('should resolve', async () => {
73
+ const result = endpointWrapper(fakeFunction);
74
+ await result(null, response as any, nextStub);
75
+ expect(jsonStub.calledWith({})).to.equal(true);
76
+ expect(nextStub.called).to.equal(false);
77
+ });
78
+ });
79
+ });
80
+ });
@@ -0,0 +1,16 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+
3
+ // Until express v5 promises must be coerced to void
4
+ // See https://github.com/davidbanham/express-async-errors/issues/36#issuecomment-944954003
5
+ export const endpointWrapper =
6
+ (originalFunction: Function = () => ({})) =>
7
+ (req: Request, res: Response, next: NextFunction) =>
8
+ void (async function () {
9
+ try {
10
+ const output = await Promise.resolve(originalFunction(req, res, next));
11
+ return res.status(200).json(output);
12
+ }
13
+ catch (err) {
14
+ return next(err);
15
+ }
16
+ })();
@@ -0,0 +1,154 @@
1
+ import { expect } from 'chai';
2
+ import * as sinon from 'sinon';
3
+ import 'mocha';
4
+
5
+ import { Errors } from '../errors';
6
+ import { requireQueryParams, parseArrayQueryParams, parseBooleanQueryParams } from './middleware';
7
+ import * as errors from '../errors';
8
+
9
+ let stubs: sinon.SinonStub[] = [];
10
+
11
+ describe('utils', () => {
12
+ beforeEach(() => {
13
+ stubs = [];
14
+ });
15
+
16
+ afterEach(() => {
17
+ stubs.forEach((stub) => stub.restore());
18
+ });
19
+
20
+ describe('middleware', () => {
21
+ let nextStub: sinon.SinonStub;
22
+ let throwErrorStub: sinon.SinonStub;
23
+
24
+ beforeEach(() => {
25
+ nextStub = sinon.stub();
26
+ stubs.push((throwErrorStub = sinon.stub(errors, 'throwError')));
27
+ });
28
+
29
+ describe('requireQueryParams', () => {
30
+ const req: any = { query: {} };
31
+
32
+ describe('with missing params', () => {
33
+ beforeEach(() => {
34
+ req.query.a = 1;
35
+ req.query.b = {};
36
+ req.query.c = '';
37
+ });
38
+
39
+ it('should throw an error', () => {
40
+ requireQueryParams('a', 'b', 'c')(req, null, nextStub);
41
+ expect(throwErrorStub.calledWith(Errors.MissingQueryParam, { params: ['b', 'c'] })).to.equal(true);
42
+ });
43
+
44
+ it('should NOT call next', () => {
45
+ requireQueryParams('a', 'b', 'c')(req, null, nextStub);
46
+ expect(nextStub.called).to.equal(false);
47
+ });
48
+ });
49
+
50
+ describe('with all params', () => {
51
+ beforeEach(() => {
52
+ req.query.a = 1;
53
+ req.query.b = { value: true };
54
+ req.query.c = 'test';
55
+ });
56
+
57
+ it('should NOT throw an error', () => {
58
+ requireQueryParams('a', 'b', 'c')(req, null, nextStub);
59
+ expect(throwErrorStub.called).to.equal(false);
60
+ });
61
+
62
+ it('should call next', () => {
63
+ requireQueryParams('a', 'b', 'c')(req, null, nextStub);
64
+ expect(nextStub.called).to.equal(true);
65
+ });
66
+ });
67
+ });
68
+
69
+ describe('parseArrayQueryParams', () => {
70
+ const req: any = { query: {} };
71
+
72
+ describe('specified param is a string', () => {
73
+ beforeEach(() => {
74
+ req.query.stringParam = 'param1,param2';
75
+ });
76
+
77
+ it('should split the search params', () => {
78
+ parseArrayQueryParams('stringParam')(req, null, nextStub);
79
+ expect(req.query.stringParam).to.deep.equal(['param1', 'param2']);
80
+ });
81
+
82
+ it('should call next', () => {
83
+ parseArrayQueryParams()(req, null, nextStub);
84
+ expect(nextStub.called).to.equal(true);
85
+ });
86
+ });
87
+
88
+ describe('specified param is an array', () => {
89
+ beforeEach(() => {
90
+ req.query.arrayParam = ['param1', 'param2'];
91
+ });
92
+
93
+ it('should keep the search params', () => {
94
+ parseArrayQueryParams('arrayParam')(req, null, nextStub);
95
+ expect(req.query.arrayParam).to.deep.equal(['param1', 'param2']);
96
+ });
97
+
98
+ it('should call next', () => {
99
+ parseArrayQueryParams()(req, null, nextStub);
100
+ expect(nextStub.called).to.equal(true);
101
+ });
102
+ });
103
+ });
104
+
105
+ describe('parseBooleanQueryParams', () => {
106
+ let req: any;
107
+
108
+ describe('param as "true"', () => {
109
+ beforeEach(() => {
110
+ req = {
111
+ query: {
112
+ booleanParam: 'true'
113
+ }
114
+ };
115
+ });
116
+
117
+ it('should return true', () => {
118
+ parseBooleanQueryParams('booleanParam')(req, null, nextStub);
119
+ expect(req.query.booleanParam).to.equal(true);
120
+ });
121
+ });
122
+
123
+ describe('param as "false"', () => {
124
+ beforeEach(() => {
125
+ req = {
126
+ query: {
127
+ booleanParam: 'false'
128
+ }
129
+ };
130
+ });
131
+
132
+ it('should return false', () => {
133
+ parseBooleanQueryParams('booleanParam')(req, null, nextStub);
134
+ expect(req.query.booleanParam).to.equal(false);
135
+ });
136
+ });
137
+
138
+ describe('with empty query string', () => {
139
+ beforeEach(() => {
140
+ req = {
141
+ query: {
142
+ booleanParam: ''
143
+ }
144
+ };
145
+ });
146
+
147
+ it('should return false', () => {
148
+ parseBooleanQueryParams('booleanParam')(req, null, nextStub);
149
+ expect(req.query.booleanParam).to.equal(false);
150
+ });
151
+ });
152
+ });
153
+ });
154
+ });
@@ -0,0 +1,33 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+
3
+ import { Errors, throwError } from '../errors';
4
+
5
+ const undefinedObject = <T>(value: T) => typeof value === 'object' && !Object.keys(value).length;
6
+
7
+ const undefinedString = <T>(value: T) => typeof value === 'string' && !value;
8
+
9
+ const isUndefined = <T>(value: T) =>
10
+ value === null || typeof value === 'undefined' || undefinedObject(value) || undefinedString(value);
11
+
12
+ export const requireQueryParams =
13
+ (...params: string[]) =>
14
+ ({ query }: Request, _r: Response, next: NextFunction) => {
15
+ const missingParams = params.filter((param) => isUndefined(query[param]));
16
+ return missingParams.length === 0 ? next() : throwError(Errors.MissingQueryParam, { params: missingParams });
17
+ };
18
+
19
+ export const parseArrayQueryParams =
20
+ (...fields: string[]) =>
21
+ ({ query }: Request, _r: Response, next: NextFunction) => {
22
+ fields.forEach((field) => {
23
+ query[field] = typeof query[field] === 'string' ? (query[field] as string).split(',') : query[field];
24
+ });
25
+ next();
26
+ };
27
+
28
+ export const parseBooleanQueryParams =
29
+ (...fields: string[]) =>
30
+ (req: Request, _res: Response, next: NextFunction) => {
31
+ fields.forEach((field) => ((req.query as any)[field] = req.query[field] === 'true'));
32
+ next();
33
+ };
@@ -0,0 +1,13 @@
1
+ FROM node:18-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY package.json .
6
+ COPY package-lock.json .
7
+
8
+ RUN npm ci
9
+
10
+ # copy source from context
11
+ ADD . .
12
+
13
+ CMD npm test