@bifravst/http-api-mock 1.1.7 → 1.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.
@@ -6,6 +6,7 @@ import path from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
7
  import fs from 'node:fs/promises'
8
8
  import os from 'node:os'
9
+ import pJSON from '../package.json'
9
10
 
10
11
  const { stackName } = fromEnv({ stackName: 'HTTP_API_MOCK_STACK_NAME' })(
11
12
  process.env,
@@ -18,6 +19,10 @@ await fs.mkdir(lambdasDir)
18
19
  const layersDir = path.join(distDir, 'layers')
19
20
  await fs.mkdir(layersDir)
20
21
 
22
+ const dependencies: Array<keyof (typeof pJSON)['dependencies']> = [
23
+ '@nordicsemiconductor/from-env',
24
+ ]
25
+
21
26
  new HTTPAPIMockApp(stackName, {
22
27
  lambdaSources: {
23
28
  httpApiMock: await packLambdaFromPath(
@@ -30,11 +35,7 @@ new HTTPAPIMockApp(stackName, {
30
35
  },
31
36
  layer: await packLayer({
32
37
  id: 'testResources',
33
- dependencies: [
34
- '@aws-sdk/client-dynamodb',
35
- '@nordicsemiconductor/from-env',
36
- '@hello.nrfcloud.com/lambda-helpers',
37
- ],
38
+ dependencies,
38
39
  baseDir,
39
40
  distDir: layersDir,
40
41
  }),
@@ -11,18 +11,18 @@ import type {
11
11
  Context,
12
12
  } from 'aws-lambda'
13
13
  import { URLSearchParams } from 'url'
14
- import { logger } from '@hello.nrfcloud.com/lambda-helpers/logger'
15
14
  import { checkMatchingQueryParams } from './checkMatchingQueryParams.js'
16
15
  import { splitMockResponse } from './splitMockResponse.js'
17
16
  import { sortQueryString } from '../../src/sortQueryString.js'
17
+ import { URLSearchParamsToObject } from '../../src/URLSearchParamsToObject.js'
18
18
 
19
19
  const db = new DynamoDBClient({})
20
- const log = logger('httpApiMock')
21
20
 
22
21
  export const handler = async (
23
22
  event: APIGatewayEvent,
24
23
  context: Context,
25
24
  ): Promise<APIGatewayProxyResult> => {
25
+ console.log(JSON.stringify({ event }))
26
26
  const query =
27
27
  event.queryStringParameters !== null &&
28
28
  event.queryStringParameters !== undefined
@@ -38,49 +38,22 @@ export const handler = async (
38
38
  await db.send(
39
39
  new PutItemCommand({
40
40
  TableName: process.env.REQUESTS_TABLE_NAME,
41
- Item: {
42
- methodPathQuery: {
43
- S: `${event.httpMethod} ${pathWithQuery}`,
44
- },
45
- timestamp: {
46
- S: new Date().toISOString(),
47
- },
48
- requestId: {
49
- S: context.awsRequestId,
50
- },
51
- method: {
52
- S: event.httpMethod,
53
- },
54
- path: {
55
- S: pathWithQuery,
56
- },
57
- resource: { S: path },
58
- query:
59
- query === undefined
60
- ? { NULL: true }
61
- : {
62
- M: marshall(
63
- [...query.entries()].reduce(
64
- (o, [k, v]) => ({ ...o, [k]: v }),
65
- {},
66
- ),
67
- ),
68
- },
69
- body: {
70
- S: event.body ?? '{}',
71
- },
72
- headers: {
73
- S: JSON.stringify(event.headers),
74
- },
75
- ttl: {
76
- N: `${Math.round(Date.now() / 1000) + 5 * 60}`,
77
- },
78
- },
41
+ Item: marshall({
42
+ methodPathQuery: `${event.httpMethod} ${pathWithQuery}`,
43
+ timestamp: new Date().toISOString(),
44
+ requestId: context.awsRequestId,
45
+ method: event.httpMethod,
46
+ path,
47
+ query: query === undefined ? null : URLSearchParamsToObject(query),
48
+ body: event.body ?? '{}',
49
+ headers: JSON.stringify(event.headers),
50
+ ttl: Math.round(Date.now() / 1000) + 5 * 60,
51
+ }),
79
52
  }),
80
53
  )
81
54
 
82
55
  // Check if response exists
83
- log.info(
56
+ console.debug(
84
57
  `Checking if response exists for ${event.httpMethod} ${pathWithQuery}...`,
85
58
  )
86
59
  // Query using httpMethod and path only
@@ -90,13 +63,13 @@ export const handler = async (
90
63
  KeyConditionExpression: 'methodPathQuery = :methodPathQuery',
91
64
  ExpressionAttributeValues: {
92
65
  [':methodPathQuery']: {
93
- S: `${event.httpMethod} ${event.path.replace(/^\//, '')}`,
66
+ S: `${event.httpMethod} ${pathWithQuery}`,
94
67
  },
95
68
  },
96
69
  ScanIndexForward: false,
97
70
  }),
98
71
  )
99
- log.debug(`Found response items: ${Items?.length}`)
72
+ console.debug(`Found response items: ${Items?.length}`)
100
73
 
101
74
  let res: APIGatewayProxyResult | undefined
102
75
  for (const Item of Items ?? []) {
@@ -106,7 +79,6 @@ export const handler = async (
106
79
  ? checkMatchingQueryParams(
107
80
  event.queryStringParameters,
108
81
  objItem.queryParams,
109
- log,
110
82
  )
111
83
  : true
112
84
  if (matchedQueryParams === false) continue
@@ -144,7 +116,7 @@ export const handler = async (
144
116
  : body,
145
117
  isBase64Encoded: isBinary,
146
118
  }
147
- log.info(`Return response`, { response: res })
119
+ console.debug(`Return response`, JSON.stringify({ response: res }))
148
120
 
149
121
  break
150
122
  }
@@ -153,6 +125,6 @@ export const handler = async (
153
125
  return res
154
126
  }
155
127
 
156
- log.warn('No responses found')
128
+ console.debug('No responses found')
157
129
  return { statusCode: 404, body: '' }
158
130
  }
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import fs from 'node:fs/promises';
8
8
  import os from 'node:os';
9
+ import pJSON from '../package.json';
9
10
  const { stackName } = fromEnv({ stackName: 'HTTP_API_MOCK_STACK_NAME' })(process.env);
10
11
  const baseDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
11
12
  const distDir = await fs.mkdtemp(path.join(os.tmpdir(), 'temp-'));
@@ -13,17 +14,16 @@ const lambdasDir = path.join(distDir, 'lambdas');
13
14
  await fs.mkdir(lambdasDir);
14
15
  const layersDir = path.join(distDir, 'layers');
15
16
  await fs.mkdir(layersDir);
17
+ const dependencies = [
18
+ '@nordicsemiconductor/from-env',
19
+ ];
16
20
  new HTTPAPIMockApp(stackName, {
17
21
  lambdaSources: {
18
22
  httpApiMock: await packLambdaFromPath('httpApiMock', 'cdk/resources/http-api-mock-lambda.ts', undefined, baseDir, lambdasDir),
19
23
  },
20
24
  layer: await packLayer({
21
25
  id: 'testResources',
22
- dependencies: [
23
- '@aws-sdk/client-dynamodb',
24
- '@nordicsemiconductor/from-env',
25
- '@hello.nrfcloud.com/lambda-helpers',
26
- ],
26
+ dependencies,
27
27
  baseDir,
28
28
  distDir: layersDir,
29
29
  }),
@@ -1,13 +1,13 @@
1
1
  import { DeleteItemCommand, DynamoDBClient, PutItemCommand, QueryCommand, } from '@aws-sdk/client-dynamodb';
2
2
  import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
3
3
  import { URLSearchParams } from 'url';
4
- import { logger } from '@hello.nrfcloud.com/lambda-helpers/logger';
5
4
  import { checkMatchingQueryParams } from './checkMatchingQueryParams.js';
6
5
  import { splitMockResponse } from './splitMockResponse.js';
7
6
  import { sortQueryString } from '../../src/sortQueryString.js';
7
+ import { URLSearchParamsToObject } from '../../src/URLSearchParamsToObject.js';
8
8
  const db = new DynamoDBClient({});
9
- const log = logger('httpApiMock');
10
9
  export const handler = async (event, context) => {
10
+ console.log(JSON.stringify({ event }));
11
11
  const query = event.queryStringParameters !== null &&
12
12
  event.queryStringParameters !== undefined
13
13
  ? new URLSearchParams(event.queryStringParameters)
@@ -16,59 +16,38 @@ export const handler = async (event, context) => {
16
16
  const pathWithQuery = sortQueryString(`${path}${query !== undefined ? `?${query.toString()}` : ''}`);
17
17
  await db.send(new PutItemCommand({
18
18
  TableName: process.env.REQUESTS_TABLE_NAME,
19
- Item: {
20
- methodPathQuery: {
21
- S: `${event.httpMethod} ${pathWithQuery}`,
22
- },
23
- timestamp: {
24
- S: new Date().toISOString(),
25
- },
26
- requestId: {
27
- S: context.awsRequestId,
28
- },
29
- method: {
30
- S: event.httpMethod,
31
- },
32
- path: {
33
- S: pathWithQuery,
34
- },
35
- resource: { S: path },
36
- query: query === undefined
37
- ? { NULL: true }
38
- : {
39
- M: marshall([...query.entries()].reduce((o, [k, v]) => ({ ...o, [k]: v }), {})),
40
- },
41
- body: {
42
- S: event.body ?? '{}',
43
- },
44
- headers: {
45
- S: JSON.stringify(event.headers),
46
- },
47
- ttl: {
48
- N: `${Math.round(Date.now() / 1000) + 5 * 60}`,
49
- },
50
- },
19
+ Item: marshall({
20
+ methodPathQuery: `${event.httpMethod} ${pathWithQuery}`,
21
+ timestamp: new Date().toISOString(),
22
+ requestId: context.awsRequestId,
23
+ method: event.httpMethod,
24
+ path,
25
+ query: query === undefined ? null : URLSearchParamsToObject(query),
26
+ body: event.body ?? '{}',
27
+ headers: JSON.stringify(event.headers),
28
+ ttl: Math.round(Date.now() / 1000) + 5 * 60,
29
+ }),
51
30
  }));
52
31
  // Check if response exists
53
- log.info(`Checking if response exists for ${event.httpMethod} ${pathWithQuery}...`);
32
+ console.debug(`Checking if response exists for ${event.httpMethod} ${pathWithQuery}...`);
54
33
  // Query using httpMethod and path only
55
34
  const { Items } = await db.send(new QueryCommand({
56
35
  TableName: process.env.RESPONSES_TABLE_NAME,
57
36
  KeyConditionExpression: 'methodPathQuery = :methodPathQuery',
58
37
  ExpressionAttributeValues: {
59
38
  [':methodPathQuery']: {
60
- S: `${event.httpMethod} ${event.path.replace(/^\//, '')}`,
39
+ S: `${event.httpMethod} ${pathWithQuery}`,
61
40
  },
62
41
  },
63
42
  ScanIndexForward: false,
64
43
  }));
65
- log.debug(`Found response items: ${Items?.length}`);
44
+ console.debug(`Found response items: ${Items?.length}`);
66
45
  let res;
67
46
  for (const Item of Items ?? []) {
68
47
  const objItem = unmarshall(Item);
69
48
  const hasExpectedQueryParams = 'queryParams' in objItem;
70
49
  const matchedQueryParams = hasExpectedQueryParams
71
- ? checkMatchingQueryParams(event.queryStringParameters, objItem.queryParams, log)
50
+ ? checkMatchingQueryParams(event.queryStringParameters, objItem.queryParams)
72
51
  : true;
73
52
  if (matchedQueryParams === false)
74
53
  continue;
@@ -99,12 +78,12 @@ export const handler = async (event, context) => {
99
78
  : body,
100
79
  isBase64Encoded: isBinary,
101
80
  };
102
- log.info(`Return response`, { response: res });
81
+ console.debug(`Return response`, JSON.stringify({ response: res }));
103
82
  break;
104
83
  }
105
84
  if (res !== undefined) {
106
85
  return res;
107
86
  }
108
- log.warn('No responses found');
87
+ console.debug('No responses found');
109
88
  return { statusCode: 404, body: '' };
110
89
  };
@@ -0,0 +1 @@
1
+ export declare const URLSearchParamsToObject: (params: URLSearchParams) => Record<string, string>;
@@ -0,0 +1,7 @@
1
+ export const URLSearchParamsToObject = (params) => {
2
+ const result = {};
3
+ for (const [key, value] of params.entries()) {
4
+ result[key] = value;
5
+ }
6
+ return result;
7
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { URLSearchParamsToObject } from './URLSearchParamsToObject.js';
4
+ void describe('URLSearchParamsToObject()', () => {
5
+ void it('should convert URLSearchParams to a plain object', () => assert.deepEqual(URLSearchParamsToObject(new URLSearchParams('eci=73393515&tac=132&requestType=custom&mcc=397&mnc=73&customTypes=2')), {
6
+ eci: '73393515',
7
+ tac: '132',
8
+ requestType: 'custom',
9
+ mcc: '397',
10
+ mnc: '73',
11
+ customTypes: '2',
12
+ }));
13
+ });
@@ -0,0 +1,15 @@
1
+ import type { DynamoDBClient } from '@aws-sdk/client-dynamodb';
2
+ type MockResponseFn = (methodPathQuery: string, response: Partial<{
3
+ headers: Headers;
4
+ status: number;
5
+ body: string;
6
+ }>) => Promise<void>;
7
+ export declare const mockResponse: (db: DynamoDBClient, responsesTable: string) => MockResponseFn;
8
+ export type HttpAPIMock = {
9
+ response: MockResponseFn;
10
+ };
11
+ export declare const mock: ({ db, responsesTable, }: {
12
+ db: DynamoDBClient;
13
+ responsesTable: string;
14
+ }) => HttpAPIMock;
15
+ export {};
@@ -0,0 +1,30 @@
1
+ import { registerResponse } from './responses.js';
2
+ export const mockResponse = (db, responsesTable) => async (methodPathQuery, response) => {
3
+ const [method, pathWithQuery] = methodPathQuery.split(' ', 2);
4
+ if (!/^[A-Z]+$/.test(method ?? ''))
5
+ throw new Error(`Invalid method ${method} in ${methodPathQuery}!`);
6
+ if (pathWithQuery === undefined)
7
+ throw new Error(`Missing path in ${methodPathQuery}!`);
8
+ const [path, query] = pathWithQuery.split('?', 2);
9
+ if (path.startsWith('/'))
10
+ throw new Error(`Path ${path} must not start with /!`);
11
+ const bodyParts = [];
12
+ if (response.headers !== undefined) {
13
+ for (const [k, v] of response.headers.entries()) {
14
+ bodyParts.push(`${k}: ${v}`);
15
+ }
16
+ bodyParts.push('');
17
+ }
18
+ if (response.body !== undefined)
19
+ bodyParts.push(response.body);
20
+ await registerResponse(db, responsesTable, {
21
+ path,
22
+ method: method ?? 'GET',
23
+ queryParams: new URLSearchParams(query),
24
+ body: bodyParts.length > 0 ? bodyParts.join('\n') : undefined,
25
+ statusCode: response.status,
26
+ });
27
+ };
28
+ export const mock = ({ db, responsesTable, }) => ({
29
+ response: mockResponse(db, responsesTable),
30
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ import { describe, it, mock as testMock } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mock } from './mock.js';
4
+ import { unmarshall } from '@aws-sdk/util-dynamodb';
5
+ void describe('mock()', () => {
6
+ void it('should register a response', async () => {
7
+ const db = {
8
+ send: testMock.fn(async () => Promise.resolve(undefined)),
9
+ };
10
+ const httpApiMock = mock({
11
+ db: db,
12
+ responsesTable: 'response-table',
13
+ });
14
+ await httpApiMock.response(`GET foo/bar?k=v`, {
15
+ status: 200,
16
+ headers: new Headers({
17
+ 'content-type': 'application/json; charset=utf-8',
18
+ }),
19
+ body: JSON.stringify({
20
+ result: 'some-value',
21
+ }),
22
+ });
23
+ assert.equal(db.send.mock.callCount(), 1);
24
+ const [{ input: args }] = db.send.mock.calls[0]?.arguments;
25
+ assert.equal(args.TableName, 'response-table');
26
+ const { methodPathQuery, statusCode, body, queryParams } = unmarshall(args.Item);
27
+ assert.equal(statusCode, 200);
28
+ assert.equal(methodPathQuery, 'GET foo/bar?k=v');
29
+ assert.equal(body, [
30
+ `content-type: application/json; charset=utf-8`,
31
+ ``,
32
+ JSON.stringify({ result: 'some-value' }),
33
+ ].join('\n'));
34
+ assert.deepEqual(queryParams, { k: 'v' });
35
+ });
36
+ });
@@ -6,7 +6,6 @@ export type Request = {
6
6
  ttl: string;
7
7
  headers: Record<string, string>;
8
8
  method: string;
9
- resource: string;
10
9
  requestId: string;
11
10
  body: string;
12
11
  methodPathQuery: string;
@@ -2,8 +2,13 @@ import { type DynamoDBClient } from '@aws-sdk/client-dynamodb';
2
2
  export type Response = {
3
3
  method: string;
4
4
  path: string;
5
- queryParams?: Record<string, string>;
5
+ queryParams?: URLSearchParams;
6
6
  statusCode?: number;
7
+ /**
8
+ * Header + Body
9
+ *
10
+ * @see splitMockResponse
11
+ */
7
12
  body?: string;
8
13
  ttl?: number;
9
14
  keep?: boolean;
@@ -1,15 +1,18 @@
1
1
  import { PutItemCommand } from '@aws-sdk/client-dynamodb';
2
2
  import { marshall } from '@aws-sdk/util-dynamodb';
3
- import { sortQueryString } from './sortQueryString.js';
3
+ import { sortQuery } from './sortQueryString.js';
4
+ import { URLSearchParamsToObject } from './URLSearchParamsToObject.js';
4
5
  export const registerResponse = async (db, responsesTable, response) => {
5
6
  await db.send(new PutItemCommand({
6
7
  TableName: responsesTable,
7
8
  Item: marshall({
8
- methodPathQuery: `${response.method} ${sortQueryString(response.path)}`,
9
+ methodPathQuery: `${response.method} ${response.path}${response.queryParams !== undefined ? `?${sortQuery(response.queryParams)}` : ``}`,
9
10
  timestamp: new Date().toISOString(),
10
11
  statusCode: response.statusCode,
11
12
  body: response.body,
12
- queryParams: response.queryParams,
13
+ queryParams: response.queryParams !== undefined
14
+ ? URLSearchParamsToObject(response.queryParams)
15
+ : undefined,
13
16
  ttl: response.ttl,
14
17
  keep: response.ttl,
15
18
  }, { removeUndefinedValues: true }),
@@ -1 +1,2 @@
1
1
  export declare const sortQueryString: (mockUrl: string) => string;
2
+ export declare const sortQuery: (query: URLSearchParams | Record<string, string>) => string;
@@ -1,15 +1,23 @@
1
1
  export const sortQueryString = (mockUrl) => {
2
2
  const [host, query] = mockUrl.split('?', 2);
3
- if ((query?.length ?? 0) === 0)
3
+ if (query === undefined || (query?.length ?? 0) === 0)
4
4
  return host;
5
+ return `${host}?${sortQuery(new URLSearchParams(query))}`;
6
+ };
7
+ export const sortQuery = (query) => {
5
8
  const params = [];
6
- new URLSearchParams(query).forEach((v, k) => {
7
- params.push([k, v]);
8
- });
9
+ if (query instanceof URLSearchParams) {
10
+ query.forEach((v, k) => {
11
+ params.push([k, v]);
12
+ });
13
+ }
14
+ else {
15
+ params.push(...Object.entries(query));
16
+ }
9
17
  params.sort(([k1], [k2]) => (k1 ?? '').localeCompare(k2 ?? ''));
10
18
  const sortedParams = new URLSearchParams();
11
19
  for (const [k, v] of params) {
12
20
  sortedParams.append(k, v);
13
21
  }
14
- return `${host}?${sortedParams.toString()}`;
22
+ return sortedParams.toString();
15
23
  };
@@ -1,6 +1,18 @@
1
1
  import assert from 'node:assert';
2
2
  import { describe, test as it } from 'node:test';
3
- import { sortQueryString } from './sortQueryString.js';
3
+ import { sortQuery, sortQueryString } from './sortQueryString.js';
4
+ import { URLSearchParams } from 'node:url';
4
5
  void describe('sortQueryString', () => {
5
6
  void it('should sort the query part of a mock URL', () => assert.deepStrictEqual(sortQueryString('api.nrfcloud.com/v1/location/agps?eci=73393515&tac=132&requestType=custom&mcc=397&mnc=73&customTypes=2'), 'api.nrfcloud.com/v1/location/agps?customTypes=2&eci=73393515&mcc=397&mnc=73&requestType=custom&tac=132'));
6
7
  });
8
+ void describe('sortQuery', () => {
9
+ void it('should sort URLSearchParams', () => assert.equal(sortQuery(new URLSearchParams('eci=73393515&tac=132&requestType=custom&mcc=397&mnc=73&customTypes=2')), 'customTypes=2&eci=73393515&mcc=397&mnc=73&requestType=custom&tac=132'));
10
+ void it('should sort a Record', () => assert.equal(sortQuery({
11
+ eci: '73393515',
12
+ tac: '132',
13
+ requestType: 'custom',
14
+ mcc: '397',
15
+ mnc: '73',
16
+ customTypes: '2',
17
+ }), 'customTypes=2&eci=73393515&mcc=397&mnc=73&requestType=custom&tac=132'));
18
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bifravst/http-api-mock",
3
- "version": "1.1.7",
3
+ "version": "1.2.1",
4
4
  "description": "Helper functions for AWS lambdas written in TypeScript.",
5
5
  "exports": {
6
6
  "./*": {
@@ -34,15 +34,6 @@
34
34
  ],
35
35
  "author": "Nordic Semiconductor ASA | nordicsemi.no",
36
36
  "license": "BSD-3-Clause",
37
- "devDependencies": {
38
- "@bifravst/eslint-config-typescript": "6.0.22",
39
- "@bifravst/prettier-config": "1.0.0",
40
- "@commitlint/config-conventional": "19.1.0",
41
- "@types/aws-lambda": "8.10.137",
42
- "@types/node": "20.12.7",
43
- "globstar": "1.0.0",
44
- "husky": "9.0.11"
45
- },
46
37
  "lint-staged": {
47
38
  "*.ts": [
48
39
  "prettier --write",
@@ -91,21 +82,27 @@
91
82
  "README.md"
92
83
  ],
93
84
  "prettier": "@bifravst/prettier-config",
94
- "peerDependencies": {
95
- "@aws-sdk/client-cloudformation": "^3.552.0",
96
- "@aws-sdk/client-sts": "^3.552.0",
85
+ "dependencies": {
86
+ "@aws-sdk/client-cloudformation": "^3.555.0",
87
+ "@aws-sdk/client-dynamodb": "^3.554.0",
88
+ "@aws-sdk/client-sts": "^3.554.0",
89
+ "@aws-sdk/util-dynamodb": "^3.554.0",
90
+ "@bifravst/aws-cdk-lambda-helpers": "^1.4.0",
97
91
  "@bifravst/run": "^1.2.0",
98
92
  "@nordicsemiconductor/cloudformation-helpers": "^9.0.3",
93
+ "@nordicsemiconductor/from-env": "^3.0.1",
99
94
  "aws-cdk-lib": "^2.137.0",
100
95
  "cdk": "^2.137.0",
101
96
  "chalk": "^5.3.0",
102
97
  "tsx": "^4.7.2"
103
98
  },
104
- "dependencies": {
105
- "@aws-sdk/client-dynamodb": "^3.552.0",
106
- "@aws-sdk/util-dynamodb": "^3.552.0",
107
- "@bifravst/aws-cdk-lambda-helpers": "^1.4.0",
108
- "@hello.nrfcloud.com/lambda-helpers": "^1.0.1",
109
- "@nordicsemiconductor/from-env": "^3.0.1"
99
+ "devDependencies": {
100
+ "@bifravst/eslint-config-typescript": "6.0.22",
101
+ "@bifravst/prettier-config": "1.0.0",
102
+ "@commitlint/config-conventional": "19.1.0",
103
+ "@types/aws-lambda": "8.10.137",
104
+ "@types/node": "20.12.7",
105
+ "globstar": "1.0.0",
106
+ "husky": "9.0.11"
110
107
  }
111
108
  }
@@ -0,0 +1,22 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { URLSearchParamsToObject } from './URLSearchParamsToObject.js'
4
+
5
+ void describe('URLSearchParamsToObject()', () => {
6
+ void it('should convert URLSearchParams to a plain object', () =>
7
+ assert.deepEqual(
8
+ URLSearchParamsToObject(
9
+ new URLSearchParams(
10
+ 'eci=73393515&tac=132&requestType=custom&mcc=397&mnc=73&customTypes=2',
11
+ ),
12
+ ),
13
+ {
14
+ eci: '73393515',
15
+ tac: '132',
16
+ requestType: 'custom',
17
+ mcc: '397',
18
+ mnc: '73',
19
+ customTypes: '2',
20
+ },
21
+ ))
22
+ })
@@ -0,0 +1,9 @@
1
+ export const URLSearchParamsToObject = (
2
+ params: URLSearchParams,
3
+ ): Record<string, string> => {
4
+ const result: Record<string, string> = {}
5
+ for (const [key, value] of params.entries()) {
6
+ result[key] = value
7
+ }
8
+ return result
9
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, it, mock as testMock } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mock } from './mock.js'
4
+ import type { AttributeValue, DynamoDBClient } from '@aws-sdk/client-dynamodb'
5
+ import { unmarshall } from '@aws-sdk/util-dynamodb'
6
+
7
+ void describe('mock()', () => {
8
+ void it('should register a response', async () => {
9
+ const db = {
10
+ send: testMock.fn(async () => Promise.resolve(undefined)),
11
+ }
12
+ const httpApiMock = mock({
13
+ db: db as unknown as DynamoDBClient,
14
+ responsesTable: 'response-table',
15
+ })
16
+
17
+ await httpApiMock.response(`GET foo/bar?k=v`, {
18
+ status: 200,
19
+ headers: new Headers({
20
+ 'content-type': 'application/json; charset=utf-8',
21
+ }),
22
+ body: JSON.stringify({
23
+ result: 'some-value',
24
+ }),
25
+ })
26
+
27
+ assert.equal(db.send.mock.callCount(), 1)
28
+ const [{ input: args }] = db.send.mock.calls[0]?.arguments as unknown as [
29
+ {
30
+ input: {
31
+ TableName: string
32
+ Item: Record<string, AttributeValue>
33
+ }
34
+ },
35
+ ]
36
+ assert.equal(args.TableName, 'response-table')
37
+ const { methodPathQuery, statusCode, body, queryParams } = unmarshall(
38
+ args.Item,
39
+ )
40
+ assert.equal(statusCode, 200)
41
+ assert.equal(methodPathQuery, 'GET foo/bar?k=v')
42
+ assert.equal(
43
+ body,
44
+ [
45
+ `content-type: application/json; charset=utf-8`,
46
+ ``,
47
+ JSON.stringify({ result: 'some-value' }),
48
+ ].join('\n'),
49
+ )
50
+ assert.deepEqual(queryParams, { k: 'v' })
51
+ })
52
+ })
package/src/mock.ts ADDED
@@ -0,0 +1,59 @@
1
+ import type { DynamoDBClient } from '@aws-sdk/client-dynamodb'
2
+ import { registerResponse } from './responses.js'
3
+
4
+ type MockResponseFn = (
5
+ // The expected request in the form 'GET resource/subresource?query=value
6
+ methodPathQuery: string,
7
+ // The response
8
+ response: Partial<{
9
+ headers: Headers
10
+ status: number
11
+ body: string
12
+ }>,
13
+ ) => Promise<void>
14
+
15
+ export const mockResponse =
16
+ (db: DynamoDBClient, responsesTable: string): MockResponseFn =>
17
+ async (methodPathQuery, response) => {
18
+ const [method, pathWithQuery] = methodPathQuery.split(' ', 2)
19
+ if (!/^[A-Z]+$/.test(method ?? ''))
20
+ throw new Error(`Invalid method ${method} in ${methodPathQuery}!`)
21
+ if (pathWithQuery === undefined)
22
+ throw new Error(`Missing path in ${methodPathQuery}!`)
23
+ const [path, query] = pathWithQuery.split('?', 2) as [
24
+ string,
25
+ string | undefined,
26
+ ]
27
+ if (path.startsWith('/'))
28
+ throw new Error(`Path ${path} must not start with /!`)
29
+
30
+ const bodyParts = []
31
+ if (response.headers !== undefined) {
32
+ for (const [k, v] of response.headers.entries()) {
33
+ bodyParts.push(`${k}: ${v}`)
34
+ }
35
+ bodyParts.push('')
36
+ }
37
+ if (response.body !== undefined) bodyParts.push(response.body)
38
+ await registerResponse(db, responsesTable, {
39
+ path,
40
+ method: method ?? 'GET',
41
+ queryParams: new URLSearchParams(query),
42
+ body: bodyParts.length > 0 ? bodyParts.join('\n') : undefined,
43
+ statusCode: response.status,
44
+ })
45
+ }
46
+
47
+ export type HttpAPIMock = {
48
+ response: MockResponseFn
49
+ }
50
+
51
+ export const mock = ({
52
+ db,
53
+ responsesTable,
54
+ }: {
55
+ db: DynamoDBClient
56
+ responsesTable: string
57
+ }): HttpAPIMock => ({
58
+ response: mockResponse(db, responsesTable),
59
+ })
package/src/requests.ts CHANGED
@@ -8,7 +8,6 @@ export type Request = {
8
8
  ttl: string //e.g. 1712322374
9
9
  headers: Record<string, string> //e.g. '{"Accept":"*/*","Accept-Encoding":"br, gzip, deflate","Accept-Language":"*","CloudFront-Forwarded-Proto":"https","CloudFront-Is-Desktop-Viewer":"true","CloudFront-Is-Mobile-Viewer":"false","CloudFront-Is-SmartTV-Viewer":"false","CloudFront-Is-Tablet-Viewer":"false","CloudFront-Viewer-ASN":"2116","CloudFront-Viewer-Country":"NO","Host":"idj1fffo0k.execute-api.eu-west-1.amazonaws.com","sec-fetch-mode":"cors","User-Agent":"node","Via":"1.1 b053873243f91b1bb6dc406ce0c67db4.cloudfront.net (CloudFront)","X-Amz-Cf-Id":"_vJIGo6Z89QxDzoqOZL4G0PQqPFWGesVXVan4ND934_Urqn2ifSOsQ==","X-Amzn-Trace-Id":"Root=1-660ff61a-25e1219a7f153e1b0c768358","X-Forwarded-For":"194.19.86.146, 130.176.182.18","X-Forwarded-Port":"443","X-Forwarded-Proto":"https"}'
10
10
  method: string //e.g.'GET'
11
- resource: string //e.g. '555c3960-2092-438b-b2b0-f28eebd1f5bb'
12
11
  requestId: string //e.g.'f34b042b-e9a2-4089-97a2-241516d40d64'
13
12
  body: string //e.g.'{}'
14
13
  methodPathQuery: string //e.g.'GET 555c3960-2092-438b-b2b0-f28eebd1f5bb'
package/src/responses.ts CHANGED
@@ -1,14 +1,20 @@
1
1
  import { PutItemCommand, type DynamoDBClient } from '@aws-sdk/client-dynamodb'
2
2
  import { marshall } from '@aws-sdk/util-dynamodb'
3
- import { sortQueryString } from './sortQueryString.js'
3
+ import { sortQuery } from './sortQueryString.js'
4
+ import { URLSearchParamsToObject } from './URLSearchParamsToObject.js'
4
5
 
5
6
  export type Response = {
6
7
  // e.g. 'GET'
7
8
  method: string
8
9
  // without leading slash
9
10
  path: string
10
- queryParams?: Record<string, string>
11
+ queryParams?: URLSearchParams
11
12
  statusCode?: number
13
+ /**
14
+ * Header + Body
15
+ *
16
+ * @see splitMockResponse
17
+ */
12
18
  body?: string
13
19
  ttl?: number
14
20
  // Whether to delete the message after sending it
@@ -25,11 +31,14 @@ export const registerResponse = async (
25
31
  TableName: responsesTable,
26
32
  Item: marshall(
27
33
  {
28
- methodPathQuery: `${response.method} ${sortQueryString(response.path)}`,
34
+ methodPathQuery: `${response.method} ${response.path}${response.queryParams !== undefined ? `?${sortQuery(response.queryParams)}` : ``}`,
29
35
  timestamp: new Date().toISOString(),
30
36
  statusCode: response.statusCode,
31
37
  body: response.body,
32
- queryParams: response.queryParams,
38
+ queryParams:
39
+ response.queryParams !== undefined
40
+ ? URLSearchParamsToObject(response.queryParams)
41
+ : undefined,
33
42
  ttl: response.ttl,
34
43
  keep: response.ttl,
35
44
  },
@@ -1,6 +1,7 @@
1
1
  import assert from 'node:assert'
2
2
  import { describe, test as it } from 'node:test'
3
- import { sortQueryString } from './sortQueryString.js'
3
+ import { sortQuery, sortQueryString } from './sortQueryString.js'
4
+ import { URLSearchParams } from 'node:url'
4
5
 
5
6
  void describe('sortQueryString', () => {
6
7
  void it('should sort the query part of a mock URL', () =>
@@ -11,3 +12,27 @@ void describe('sortQueryString', () => {
11
12
  'api.nrfcloud.com/v1/location/agps?customTypes=2&eci=73393515&mcc=397&mnc=73&requestType=custom&tac=132',
12
13
  ))
13
14
  })
15
+
16
+ void describe('sortQuery', () => {
17
+ void it('should sort URLSearchParams', () =>
18
+ assert.equal(
19
+ sortQuery(
20
+ new URLSearchParams(
21
+ 'eci=73393515&tac=132&requestType=custom&mcc=397&mnc=73&customTypes=2',
22
+ ),
23
+ ),
24
+ 'customTypes=2&eci=73393515&mcc=397&mnc=73&requestType=custom&tac=132',
25
+ ))
26
+ void it('should sort a Record', () =>
27
+ assert.equal(
28
+ sortQuery({
29
+ eci: '73393515',
30
+ tac: '132',
31
+ requestType: 'custom',
32
+ mcc: '397',
33
+ mnc: '73',
34
+ customTypes: '2',
35
+ }),
36
+ 'customTypes=2&eci=73393515&mcc=397&mnc=73&requestType=custom&tac=132',
37
+ ))
38
+ })
@@ -1,14 +1,24 @@
1
1
  export const sortQueryString = (mockUrl: string): string => {
2
2
  const [host, query] = mockUrl.split('?', 2) as [string, string | undefined]
3
- if ((query?.length ?? 0) === 0) return host
3
+ if (query === undefined || (query?.length ?? 0) === 0) return host
4
+ return `${host}?${sortQuery(new URLSearchParams(query))}`
5
+ }
6
+
7
+ export const sortQuery = (
8
+ query: URLSearchParams | Record<string, string>,
9
+ ): string => {
4
10
  const params: string[][] = []
5
- new URLSearchParams(query).forEach((v, k) => {
6
- params.push([k, v])
7
- })
11
+ if (query instanceof URLSearchParams) {
12
+ query.forEach((v, k) => {
13
+ params.push([k, v])
14
+ })
15
+ } else {
16
+ params.push(...Object.entries(query))
17
+ }
8
18
  params.sort(([k1], [k2]) => (k1 ?? '').localeCompare(k2 ?? ''))
9
19
  const sortedParams = new URLSearchParams()
10
20
  for (const [k, v] of params) {
11
21
  sortedParams.append(k as string, v as string)
12
22
  }
13
- return `${host}?${sortedParams.toString()}`
23
+ return sortedParams.toString()
14
24
  }