@bifravst/http-api-mock 1.1.6 → 1.2.0

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.
package/README.md CHANGED
@@ -21,7 +21,6 @@ npx @bifravst/http-api-mock
21
21
  {
22
22
  "stackName": "http-api-mock-69c2c4b9",
23
23
  "responsesTableName": "http-api-mock-69c2c4b9-httpapimockresponses562FCFC7-C80OCULJKYFE",
24
- "httpapimockapiEndpointF8DBD534": "https://liv73h149l.execute-api.eu-west-1.amazonaws.com/prod/",
25
24
  "apiURL": "https://liv73h149l.execute-api.eu-west-1.amazonaws.com/prod/",
26
25
  "requestsTableName": "http-api-mock-69c2c4b9-httpapimockrequests2216D487-608PM7EHETW4"
27
26
  }
@@ -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,17 @@ 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'
18
17
 
19
18
  const db = new DynamoDBClient({})
20
- const log = logger('httpApiMock')
21
19
 
22
20
  export const handler = async (
23
21
  event: APIGatewayEvent,
24
22
  context: Context,
25
23
  ): Promise<APIGatewayProxyResult> => {
24
+ console.log(JSON.stringify({ event }))
26
25
  const query =
27
26
  event.queryStringParameters !== null &&
28
27
  event.queryStringParameters !== undefined
@@ -80,7 +79,7 @@ export const handler = async (
80
79
  )
81
80
 
82
81
  // Check if response exists
83
- log.info(
82
+ console.debug(
84
83
  `Checking if response exists for ${event.httpMethod} ${pathWithQuery}...`,
85
84
  )
86
85
  // Query using httpMethod and path only
@@ -90,13 +89,13 @@ export const handler = async (
90
89
  KeyConditionExpression: 'methodPathQuery = :methodPathQuery',
91
90
  ExpressionAttributeValues: {
92
91
  [':methodPathQuery']: {
93
- S: `${event.httpMethod} ${event.path.replace(/^\//, '')}`,
92
+ S: `${event.httpMethod} ${pathWithQuery}`,
94
93
  },
95
94
  },
96
95
  ScanIndexForward: false,
97
96
  }),
98
97
  )
99
- log.debug(`Found response items: ${Items?.length}`)
98
+ console.debug(`Found response items: ${Items?.length}`)
100
99
 
101
100
  let res: APIGatewayProxyResult | undefined
102
101
  for (const Item of Items ?? []) {
@@ -106,7 +105,6 @@ export const handler = async (
106
105
  ? checkMatchingQueryParams(
107
106
  event.queryStringParameters,
108
107
  objItem.queryParams,
109
- log,
110
108
  )
111
109
  : true
112
110
  if (matchedQueryParams === false) continue
@@ -144,7 +142,7 @@ export const handler = async (
144
142
  : body,
145
143
  isBase64Encoded: isBinary,
146
144
  }
147
- log.info(`Return response`, { response: res })
145
+ console.debug(`Return response`, JSON.stringify({ response: res }))
148
146
 
149
147
  break
150
148
  }
@@ -153,6 +151,6 @@ export const handler = async (
153
151
  return res
154
152
  }
155
153
 
156
- log.warn('No responses found')
154
+ console.debug('No responses found')
157
155
  return { statusCode: 404, body: '' }
158
156
  }
@@ -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,12 @@
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';
8
7
  const db = new DynamoDBClient({});
9
- const log = logger('httpApiMock');
10
8
  export const handler = async (event, context) => {
9
+ console.log(JSON.stringify({ event }));
11
10
  const query = event.queryStringParameters !== null &&
12
11
  event.queryStringParameters !== undefined
13
12
  ? new URLSearchParams(event.queryStringParameters)
@@ -50,25 +49,25 @@ export const handler = async (event, context) => {
50
49
  },
51
50
  }));
52
51
  // Check if response exists
53
- log.info(`Checking if response exists for ${event.httpMethod} ${pathWithQuery}...`);
52
+ console.debug(`Checking if response exists for ${event.httpMethod} ${pathWithQuery}...`);
54
53
  // Query using httpMethod and path only
55
54
  const { Items } = await db.send(new QueryCommand({
56
55
  TableName: process.env.RESPONSES_TABLE_NAME,
57
56
  KeyConditionExpression: 'methodPathQuery = :methodPathQuery',
58
57
  ExpressionAttributeValues: {
59
58
  [':methodPathQuery']: {
60
- S: `${event.httpMethod} ${event.path.replace(/^\//, '')}`,
59
+ S: `${event.httpMethod} ${pathWithQuery}`,
61
60
  },
62
61
  },
63
62
  ScanIndexForward: false,
64
63
  }));
65
- log.debug(`Found response items: ${Items?.length}`);
64
+ console.debug(`Found response items: ${Items?.length}`);
66
65
  let res;
67
66
  for (const Item of Items ?? []) {
68
67
  const objItem = unmarshall(Item);
69
68
  const hasExpectedQueryParams = 'queryParams' in objItem;
70
69
  const matchedQueryParams = hasExpectedQueryParams
71
- ? checkMatchingQueryParams(event.queryStringParameters, objItem.queryParams, log)
70
+ ? checkMatchingQueryParams(event.queryStringParameters, objItem.queryParams)
72
71
  : true;
73
72
  if (matchedQueryParams === false)
74
73
  continue;
@@ -99,12 +98,12 @@ export const handler = async (event, context) => {
99
98
  : body,
100
99
  isBase64Encoded: isBinary,
101
100
  };
102
- log.info(`Return response`, { response: res });
101
+ console.debug(`Return response`, JSON.stringify({ response: res }));
103
102
  break;
104
103
  }
105
104
  if (res !== undefined) {
106
105
  return res;
107
106
  }
108
- log.warn('No responses found');
107
+ console.debug('No responses found');
109
108
  return { statusCode: 404, body: '' };
110
109
  };
@@ -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
+ });
@@ -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.6",
3
+ "version": "1.2.0",
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",
@@ -82,7 +73,6 @@
82
73
  "access": "public"
83
74
  },
84
75
  "files": [
85
- "package-lock.json",
86
76
  "dist/cdk",
87
77
  "dist/src",
88
78
  "cdk",
@@ -92,21 +82,27 @@
92
82
  "README.md"
93
83
  ],
94
84
  "prettier": "@bifravst/prettier-config",
95
- "peerDependencies": {
96
- "@aws-sdk/client-cloudformation": "^3.552.0",
97
- "@aws-sdk/client-sts": "^3.552.0",
85
+ "dependencies": {
86
+ "@aws-sdk/client-cloudformation": "^3.554.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",
98
91
  "@bifravst/run": "^1.2.0",
99
92
  "@nordicsemiconductor/cloudformation-helpers": "^9.0.3",
93
+ "@nordicsemiconductor/from-env": "^3.0.1",
100
94
  "aws-cdk-lib": "^2.137.0",
101
95
  "cdk": "^2.137.0",
102
96
  "chalk": "^5.3.0",
103
97
  "tsx": "^4.7.2"
104
98
  },
105
- "dependencies": {
106
- "@aws-sdk/client-dynamodb": "^3.552.0",
107
- "@aws-sdk/util-dynamodb": "^3.552.0",
108
- "@bifravst/aws-cdk-lambda-helpers": "^1.3.3",
109
- "@hello.nrfcloud.com/lambda-helpers": "^1.0.1",
110
- "@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"
111
107
  }
112
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/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
  }