@bifravst/http-api-mock 3.0.4 → 3.0.6

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 (44) hide show
  1. package/npm/cdk/App.d.ts +11 -0
  2. package/npm/cdk/App.js +4 -7
  3. package/npm/cdk/Stack.d.ts +20 -0
  4. package/npm/cdk/Stack.js +11 -16
  5. package/npm/cdk/http-api-mock.d.ts +1 -0
  6. package/npm/cdk/http-api-mock.js +20 -15
  7. package/npm/cdk/resources/HttpApiMock.d.ts +14 -0
  8. package/npm/cdk/resources/HttpApiMock.js +17 -19
  9. package/npm/cdk/resources/checkMatchingQueryParams.d.ts +5 -0
  10. package/npm/cdk/resources/checkMatchingQueryParams.js +18 -16
  11. package/npm/cdk/resources/checkMatchingQueryParams.spec.d.ts +1 -0
  12. package/npm/cdk/resources/checkMatchingQueryParams.spec.js +57 -0
  13. package/npm/cdk/resources/http-api-mock-lambda.d.ts +2 -0
  14. package/npm/cdk/resources/http-api-mock-lambda.js +41 -33
  15. package/npm/cdk/resources/splitMockResponse.d.ts +4 -0
  16. package/npm/cdk/resources/splitMockResponse.js +16 -11
  17. package/npm/cdk/resources/splitMockResponse.spec.d.ts +1 -0
  18. package/npm/cdk/resources/splitMockResponse.spec.js +13 -0
  19. package/npm/e2e.spec.d.ts +1 -0
  20. package/npm/e2e.spec.js +51 -0
  21. package/npm/package.json +105 -0
  22. package/npm/src/mock.d.ts +15 -0
  23. package/npm/src/mock.js +28 -24
  24. package/npm/src/mock.spec.d.ts +1 -0
  25. package/npm/src/mock.spec.js +36 -0
  26. package/npm/src/parseMockRequest.d.ts +7 -0
  27. package/npm/src/parseMockRequest.js +10 -8
  28. package/npm/src/parseMockRequest.spec.d.ts +1 -0
  29. package/npm/src/parseMockRequest.spec.js +23 -0
  30. package/npm/src/parseMockResponse.d.ts +6 -0
  31. package/npm/src/parseMockResponse.js +10 -8
  32. package/npm/src/parseMockResponse.spec.d.ts +1 -0
  33. package/npm/src/parseMockResponse.spec.js +20 -0
  34. package/npm/src/randomString.d.ts +1 -0
  35. package/npm/src/randomString.js +4 -1
  36. package/npm/src/requests.d.ts +13 -0
  37. package/npm/src/requests.js +9 -9
  38. package/npm/src/responses.d.ts +16 -0
  39. package/npm/src/responses.js +6 -6
  40. package/npm/src/sortQueryString.d.ts +3 -0
  41. package/npm/src/sortQueryString.js +10 -11
  42. package/npm/src/sortQueryString.spec.d.ts +1 -0
  43. package/npm/src/sortQueryString.spec.js +18 -0
  44. package/package.json +9 -9
@@ -0,0 +1,11 @@
1
+ import type { PackedLambda } from '@bifravst/aws-cdk-lambda-helpers';
2
+ import type { PackedLayer } from '@bifravst/aws-cdk-lambda-helpers/layer';
3
+ import { App } from 'aws-cdk-lib';
4
+ export declare class HTTPAPIMockApp extends App {
5
+ constructor(stackName: string, { lambdaSources, layer, }: {
6
+ lambdaSources: {
7
+ httpApiMock: PackedLambda;
8
+ };
9
+ layer: PackedLayer;
10
+ });
11
+ }
package/npm/cdk/App.js CHANGED
@@ -1,15 +1,12 @@
1
1
  import { App } from 'aws-cdk-lib';
2
2
  import { HTTPAPIMockStack } from './Stack.js';
3
3
  export class HTTPAPIMockApp extends App {
4
- constructor(stackName, { lambdaSources, layer }){
4
+ constructor(stackName, { lambdaSources, layer, }) {
5
5
  super({
6
6
  context: {
7
- isTest: true
8
- }
9
- });
10
- new HTTPAPIMockStack(this, stackName, {
11
- lambdaSources,
12
- layer
7
+ isTest: true,
8
+ },
13
9
  });
10
+ new HTTPAPIMockStack(this, stackName, { lambdaSources, layer });
14
11
  }
15
12
  }
@@ -0,0 +1,20 @@
1
+ import type { PackedLambda } from '@bifravst/aws-cdk-lambda-helpers';
2
+ import type { PackedLayer } from '@bifravst/aws-cdk-lambda-helpers/layer';
3
+ import type { App } from 'aws-cdk-lib';
4
+ import { Stack } from 'aws-cdk-lib';
5
+ /**
6
+ * This is CloudFormation stack sets up a dummy HTTP API which stores all requests in SQS for inspection
7
+ */
8
+ export declare class HTTPAPIMockStack extends Stack {
9
+ constructor(parent: App, stackName: string, { lambdaSources, layer, }: {
10
+ lambdaSources: {
11
+ httpApiMock: PackedLambda;
12
+ };
13
+ layer: PackedLayer;
14
+ });
15
+ }
16
+ export type StackOutputs = {
17
+ apiURL: string;
18
+ requestsTableName: string;
19
+ responsesTableName: string;
20
+ };
package/npm/cdk/Stack.js CHANGED
@@ -3,43 +3,38 @@ import { CfnOutput, aws_lambda as Lambda, Stack } from 'aws-cdk-lib';
3
3
  import { HttpApiMock } from './resources/HttpApiMock.js';
4
4
  /**
5
5
  * This is CloudFormation stack sets up a dummy HTTP API which stores all requests in SQS for inspection
6
- */ export class HTTPAPIMockStack extends Stack {
7
- constructor(parent, stackName, { lambdaSources, layer }){
6
+ */
7
+ export class HTTPAPIMockStack extends Stack {
8
+ constructor(parent, stackName, { lambdaSources, layer, }) {
8
9
  super(parent, stackName, {
9
- description: 'Provides a mock HTTP API for testing third-party API integrations.'
10
+ description: 'Provides a mock HTTP API for testing third-party API integrations.',
10
11
  });
11
12
  const baseLayer = new Lambda.LayerVersion(this, 'baseLayer', {
12
13
  layerVersionName: `${Stack.of(this).stackName}-baseLayer`,
13
14
  code: new LambdaSource(this, {
14
15
  id: 'baseLayer',
15
16
  zipFilePath: layer.layerZipFilePath,
16
- hash: layer.hash
17
+ hash: layer.hash,
17
18
  }).code,
18
- compatibleArchitectures: [
19
- Lambda.Architecture.ARM_64
20
- ],
21
- compatibleRuntimes: [
22
- Lambda.Runtime.NODEJS_24_X
23
- ]
19
+ compatibleArchitectures: [Lambda.Architecture.ARM_64],
20
+ compatibleRuntimes: [Lambda.Runtime.NODEJS_24_X],
24
21
  });
25
22
  const httpMockApi = new HttpApiMock(this, {
26
23
  lambdaSources,
27
- layers: [
28
- baseLayer
29
- ]
24
+ layers: [baseLayer],
30
25
  });
31
26
  // Export these so the test runner can use them
32
27
  new CfnOutput(this, 'apiURL', {
33
28
  value: httpMockApi.api.url,
34
- exportName: `${this.stackName}:apiURL`
29
+ exportName: `${this.stackName}:apiURL`,
35
30
  });
36
31
  new CfnOutput(this, 'responsesTableName', {
37
32
  value: httpMockApi.responsesTable.tableName,
38
- exportName: `${this.stackName}:responsesTableName`
33
+ exportName: `${this.stackName}:responsesTableName`,
39
34
  });
40
35
  new CfnOutput(this, 'requestsTableName', {
41
36
  value: httpMockApi.requestsTable.tableName,
42
- exportName: `${this.stackName}:requestsTableName`
37
+ exportName: `${this.stackName}:requestsTableName`,
43
38
  });
44
39
  }
45
40
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -9,33 +9,37 @@ import fs from 'node:fs/promises';
9
9
  import os from 'node:os';
10
10
  import path from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
+ import pJSON from '../package.json' with { type: 'json' };
12
13
  import { randomString } from '../src/randomString.js';
13
14
  import { HTTPAPIMockApp } from './App.js';
14
15
  const options = commandLineArgs([
15
16
  {
16
17
  name: 'config',
17
18
  type: Boolean,
18
- defaultValue: false
19
+ defaultValue: false,
19
20
  },
20
21
  {
21
22
  name: 'destroy',
22
23
  type: Boolean,
23
- defaultValue: false
24
- }
24
+ defaultValue: false,
25
+ },
25
26
  ]);
26
- const loadConfig = async ()=>{
27
+ const loadConfig = async () => {
27
28
  try {
28
29
  const config = JSON.parse(await fs.readFile(path.join(process.cwd(), 'http-api-mock.json'), 'utf-8'));
29
30
  return config;
30
- } catch {
31
+ }
32
+ catch {
31
33
  return {};
32
34
  }
33
35
  };
34
- const stackName = process.env.HTTP_API_MOCK_STACK_NAME ?? (await loadConfig())?.stackName ?? `http-api-mock-${randomString()}`;
35
- const saveConfig = async ()=>{
36
+ const stackName = process.env.HTTP_API_MOCK_STACK_NAME ??
37
+ (await loadConfig())?.stackName ??
38
+ `http-api-mock-${randomString()}`;
39
+ const saveConfig = async () => {
36
40
  writeFileSync(path.join(process.cwd(), 'http-api-mock.json'), JSON.stringify({
37
41
  stackName,
38
- ...await stackOutput(new CloudFormationClient({}))(stackName)
42
+ ...(await stackOutput(new CloudFormationClient({}))(stackName)),
39
43
  }, null, 2));
40
44
  };
41
45
  if (options.config === true) {
@@ -49,7 +53,7 @@ await fs.mkdir(lambdasDir);
49
53
  const layersDir = path.join(distDir, 'layers');
50
54
  await fs.mkdir(layersDir);
51
55
  const dependencies = [
52
- '@bifravst/from-env'
56
+ '@bifravst/from-env',
53
57
  ];
54
58
  const app = new HTTPAPIMockApp(stackName, {
55
59
  lambdaSources: {
@@ -57,21 +61,22 @@ const app = new HTTPAPIMockApp(stackName, {
57
61
  id: 'httpApiMock',
58
62
  sourceFilePath: 'cdk/resources/http-api-mock-lambda.ts',
59
63
  baseDir,
60
- distDir: lambdasDir
61
- })
64
+ distDir: lambdasDir,
65
+ }),
62
66
  },
63
67
  layer: await packLayer({
64
68
  id: 'testResources',
65
69
  dependencies,
66
70
  baseDir,
67
- distDir: layersDir
68
- })
71
+ distDir: layersDir,
72
+ }),
69
73
  });
70
74
  const cdk = new Toolkit();
71
- const cx = await cdk.fromAssemblyBuilder(async ()=>app.synth());
75
+ const cx = await cdk.fromAssemblyBuilder(async () => app.synth());
72
76
  if (options.destroy === true) {
73
77
  await cdk.destroy(cx);
74
- } else {
78
+ }
79
+ else {
75
80
  await cdk.deploy(cx);
76
81
  await saveConfig();
77
82
  }
@@ -0,0 +1,14 @@
1
+ import type { PackedLambda } from '@bifravst/aws-cdk-lambda-helpers';
2
+ import { aws_apigateway as ApiGateway, aws_dynamodb as DynamoDB, aws_lambda as Lambda, Resource } from 'aws-cdk-lib';
3
+ import type { Construct } from 'constructs';
4
+ export declare class HttpApiMock extends Resource {
5
+ readonly api: ApiGateway.RestApi;
6
+ readonly requestsTable: DynamoDB.Table;
7
+ readonly responsesTable: DynamoDB.Table;
8
+ constructor(parent: Construct, { lambdaSources, layers, }: {
9
+ lambdaSources: {
10
+ httpApiMock: PackedLambda;
11
+ };
12
+ layers: Lambda.ILayerVersion[];
13
+ });
14
+ }
@@ -1,53 +1,53 @@
1
- import { LambdaLogGroup, LambdaSource } from '@bifravst/aws-cdk-lambda-helpers/cdk';
2
- import { aws_apigateway as ApiGateway, Duration, aws_dynamodb as DynamoDB, aws_iam as IAM, aws_lambda as Lambda, aws_logs as Logs, RemovalPolicy, Resource } from 'aws-cdk-lib';
1
+ import { LambdaLogGroup, LambdaSource, } from '@bifravst/aws-cdk-lambda-helpers/cdk';
2
+ import { aws_apigateway as ApiGateway, Duration, aws_dynamodb as DynamoDB, aws_iam as IAM, aws_lambda as Lambda, aws_logs as Logs, RemovalPolicy, Resource, } from 'aws-cdk-lib';
3
3
  export class HttpApiMock extends Resource {
4
4
  api;
5
5
  requestsTable;
6
6
  responsesTable;
7
- constructor(parent, { lambdaSources, layers }){
7
+ constructor(parent, { lambdaSources, layers, }) {
8
8
  super(parent, 'http-api-mock');
9
9
  // This table will store all the requests made to the API Gateway
10
10
  this.requestsTable = new DynamoDB.Table(this, 'requests', {
11
11
  billingMode: DynamoDB.BillingMode.PAY_PER_REQUEST,
12
12
  partitionKey: {
13
13
  name: 'requestId',
14
- type: DynamoDB.AttributeType.STRING
14
+ type: DynamoDB.AttributeType.STRING,
15
15
  },
16
16
  sortKey: {
17
17
  name: 'timestamp',
18
- type: DynamoDB.AttributeType.STRING
18
+ type: DynamoDB.AttributeType.STRING,
19
19
  },
20
- removalPolicy: RemovalPolicy.DESTROY
20
+ removalPolicy: RemovalPolicy.DESTROY,
21
21
  });
22
22
  this.requestsTable.addGlobalSecondaryIndex({
23
23
  indexName: 'methodPathQuery',
24
24
  partitionKey: {
25
25
  name: 'methodPathQuery',
26
- type: DynamoDB.AttributeType.STRING
26
+ type: DynamoDB.AttributeType.STRING,
27
27
  },
28
- projectionType: DynamoDB.ProjectionType.ALL
28
+ projectionType: DynamoDB.ProjectionType.ALL,
29
29
  });
30
30
  // This table will store optional responses to be sent
31
31
  this.responsesTable = new DynamoDB.Table(this, 'responses', {
32
32
  billingMode: DynamoDB.BillingMode.PAY_PER_REQUEST,
33
33
  partitionKey: {
34
34
  name: 'responseId',
35
- type: DynamoDB.AttributeType.STRING
35
+ type: DynamoDB.AttributeType.STRING,
36
36
  },
37
37
  sortKey: {
38
38
  name: 'timestamp',
39
- type: DynamoDB.AttributeType.STRING
39
+ type: DynamoDB.AttributeType.STRING,
40
40
  },
41
41
  removalPolicy: RemovalPolicy.DESTROY,
42
- timeToLiveAttribute: 'ttl'
42
+ timeToLiveAttribute: 'ttl',
43
43
  });
44
44
  this.responsesTable.addGlobalSecondaryIndex({
45
45
  indexName: 'methodPathQuery',
46
46
  partitionKey: {
47
47
  name: 'methodPathQuery',
48
- type: DynamoDB.AttributeType.STRING
48
+ type: DynamoDB.AttributeType.STRING,
49
49
  },
50
- projectionType: DynamoDB.ProjectionType.ALL
50
+ projectionType: DynamoDB.ProjectionType.ALL,
51
51
  });
52
52
  // This lambda will publish all requests made to the API Gateway in the queue
53
53
  const lambda = new Lambda.Function(this, 'Lambda', {
@@ -62,9 +62,9 @@ export class HttpApiMock extends Resource {
62
62
  REQUESTS_TABLE_NAME: this.requestsTable.tableName,
63
63
  RESPONSES_TABLE_NAME: this.responsesTable.tableName,
64
64
  LOG_LEVEL: this.node.tryGetContext('logLevel'),
65
- NODE_NO_WARNINGS: '1'
65
+ NODE_NO_WARNINGS: '1',
66
66
  },
67
- ...new LambdaLogGroup(this, 'LambdaLogs', Logs.RetentionDays.ONE_DAY)
67
+ ...new LambdaLogGroup(this, 'LambdaLogs', Logs.RetentionDays.ONE_DAY),
68
68
  });
69
69
  this.responsesTable.grantReadWriteData(lambda);
70
70
  this.requestsTable.grantReadWriteData(lambda);
@@ -72,16 +72,14 @@ export class HttpApiMock extends Resource {
72
72
  this.api = new ApiGateway.RestApi(this, 'api', {
73
73
  restApiName: `HTTP Mock API for testing`,
74
74
  description: 'API Gateway to test outgoing requests',
75
- binaryMediaTypes: [
76
- 'application/octet-stream'
77
- ]
75
+ binaryMediaTypes: ['application/octet-stream'],
78
76
  });
79
77
  const proxyResource = this.api.root.addResource('{proxy+}');
80
78
  proxyResource.addMethod('ANY', new ApiGateway.LambdaIntegration(lambda));
81
79
  // API Gateway needs to be able to call the lambda
82
80
  lambda.addPermission('InvokeByApiGateway', {
83
81
  principal: new IAM.ServicePrincipal('apigateway.amazonaws.com'),
84
- sourceArn: this.api.arnForExecuteApi()
82
+ sourceArn: this.api.arnForExecuteApi(),
85
83
  });
86
84
  }
87
85
  }
@@ -0,0 +1,5 @@
1
+ type Logger = {
2
+ debug: (...arg: any) => void;
3
+ };
4
+ export declare const checkMatchingQueryParams: (actual: Record<string, unknown> | null, expected: Record<string, unknown>, log?: Logger) => boolean;
5
+ export {};
@@ -1,30 +1,32 @@
1
1
  const matchRegex = /^\/(?<re>.+)\/(?<option>[gi])?$/;
2
- export const checkMatchingQueryParams = (actual, expected, log)=>{
3
- log?.debug('checkMatchingQueryParams', {
4
- actual,
5
- expected
6
- });
7
- if (actual === null) return false;
2
+ export const checkMatchingQueryParams = (actual, expected, log) => {
3
+ log?.debug('checkMatchingQueryParams', { actual, expected });
4
+ if (actual === null)
5
+ return false;
8
6
  // Check whether expected query parameters is subset of actual query parameters
9
- for(const prop in expected){
7
+ for (const prop in expected) {
10
8
  const expectedValue = expected[prop];
11
9
  const actualValue = actual?.[prop];
12
- if (actualValue === undefined) return false;
10
+ if (actualValue === undefined)
11
+ return false;
13
12
  if (typeof expectedValue === 'string') {
14
13
  const match = matchRegex.exec(expectedValue);
15
14
  if (match !== null) {
16
- log?.debug('Compare using regex', {
17
- expectedValue
18
- });
15
+ log?.debug('Compare using regex', { expectedValue });
19
16
  // Expect is regex
20
17
  const check = new RegExp(match?.groups?.re ?? '', match?.groups?.option).test(String(actualValue));
21
- if (check === false) return false;
22
- } else {
23
- if (actualValue !== expectedValue) return false;
18
+ if (check === false)
19
+ return false;
24
20
  }
25
- } else {
21
+ else {
22
+ if (actualValue !== expectedValue)
23
+ return false;
24
+ }
25
+ }
26
+ else {
26
27
  // All query parameters are string
27
- if (actualValue !== String(expectedValue)) return false;
28
+ if (actualValue !== String(expectedValue))
29
+ return false;
28
30
  }
29
31
  }
30
32
  return true;
@@ -0,0 +1,57 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import { checkMatchingQueryParams } from './checkMatchingQueryParams.js';
4
+ void describe('checkMatchingQueryParams', () => {
5
+ void it('should return true when expected is subset of actual parameters', () => {
6
+ const actual = {
7
+ param1: 'value1',
8
+ param2: 'value2',
9
+ };
10
+ const expected = {
11
+ param1: 'value1',
12
+ };
13
+ const result = checkMatchingQueryParams(actual, expected);
14
+ assert.equal(result, true);
15
+ });
16
+ void it('should return true when expected contains regular expression and it matches', () => {
17
+ const actual = {
18
+ param1: 'value1,value2,value3',
19
+ };
20
+ const expected = {
21
+ param1: '/\\bvalue2\\b/',
22
+ };
23
+ const result = checkMatchingQueryParams(actual, expected);
24
+ assert.equal(result, true);
25
+ });
26
+ void it('should return false when expected does not match actual parameters', () => {
27
+ const actual = {
28
+ param1: 'value1',
29
+ param2: 'value2',
30
+ };
31
+ const expected = {
32
+ param1: 'value2',
33
+ };
34
+ const result = checkMatchingQueryParams(actual, expected);
35
+ assert.equal(result, false);
36
+ });
37
+ void it('should return false when actual is null', () => {
38
+ const actual = null;
39
+ const expected = {
40
+ param1: 'value1',
41
+ };
42
+ const result = checkMatchingQueryParams(actual, expected);
43
+ assert.equal(result, false);
44
+ });
45
+ void it('should return true when expected parameters having number or boolean', () => {
46
+ const actual = {
47
+ param1: 'true',
48
+ param2: '1',
49
+ };
50
+ const expected = {
51
+ param1: true,
52
+ param2: 1,
53
+ };
54
+ const result = checkMatchingQueryParams(actual, expected);
55
+ assert.equal(result, true);
56
+ });
57
+ });
@@ -0,0 +1,2 @@
1
+ import type { APIGatewayEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
2
+ export declare const handler: (event: APIGatewayEvent, context: Context) => Promise<APIGatewayProxyResult>;
@@ -1,15 +1,16 @@
1
- import { DeleteItemCommand, DynamoDBClient, PutItemCommand, ScanCommand } from '@aws-sdk/client-dynamodb';
1
+ import { DeleteItemCommand, DynamoDBClient, PutItemCommand, ScanCommand, } from '@aws-sdk/client-dynamodb';
2
2
  import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
3
3
  import { URLSearchParams } from 'url';
4
4
  import { sortQueryString } from '../../src/sortQueryString.js';
5
5
  import { checkMatchingQueryParams } from './checkMatchingQueryParams.js';
6
6
  import { splitMockResponse } from './splitMockResponse.js';
7
7
  const db = new DynamoDBClient({});
8
- export const handler = async (event, context)=>{
9
- console.log(JSON.stringify({
10
- event
11
- }));
12
- const query = event.queryStringParameters !== null && event.queryStringParameters !== undefined ? new URLSearchParams(event.queryStringParameters) : undefined;
8
+ export const handler = async (event, context) => {
9
+ console.log(JSON.stringify({ event }));
10
+ const query = event.queryStringParameters !== null &&
11
+ event.queryStringParameters !== undefined
12
+ ? new URLSearchParams(event.queryStringParameters)
13
+ : undefined;
13
14
  const path = event.path.replace(/^\//, '');
14
15
  const pathWithQuery = sortQueryString(`${path}${query !== undefined ? `?${query.toString()}` : ''}`);
15
16
  const request = {
@@ -20,7 +21,7 @@ export const handler = async (event, context)=>{
20
21
  path,
21
22
  query: query === undefined ? null : Object.fromEntries(query),
22
23
  body: event.body ?? '{}',
23
- headers: JSON.stringify(event.headers)
24
+ headers: JSON.stringify(event.headers),
24
25
  };
25
26
  // Check if response exists
26
27
  console.debug(`Checking if response exists for ${event.httpMethod} ${pathWithQuery}...`);
@@ -30,31 +31,36 @@ export const handler = async (event, context)=>{
30
31
  FilterExpression: 'begins_with(methodPathQuery, :methodPath)',
31
32
  ExpressionAttributeValues: {
32
33
  [':methodPath']: {
33
- S: `${event.httpMethod} ${path}`
34
- }
35
- }
34
+ S: `${event.httpMethod} ${path}`,
35
+ },
36
+ },
36
37
  }));
37
38
  console.debug(`Found response items beginning with same path: ${Items?.length}`);
38
39
  // use newest response first
39
- const itemsByTimestampDesc = (Items ?? []).map((Item)=>unmarshall(Item)).sort((a, b)=>b.timestamp.localeCompare(a.timestamp));
40
+ const itemsByTimestampDesc = (Items ?? [])
41
+ .map((Item) => unmarshall(Item))
42
+ .sort((a, b) => b.timestamp.localeCompare(a.timestamp));
40
43
  let res = {
41
44
  statusCode: 404,
42
- body: 'No responses found'
45
+ body: 'No responses found',
43
46
  };
44
- for (const objItem of itemsByTimestampDesc){
47
+ for (const objItem of itemsByTimestampDesc) {
45
48
  const hasExpectedQueryParams = 'queryParams' in objItem || query !== undefined;
46
- const matchedQueryParams = hasExpectedQueryParams ? checkMatchingQueryParams(event.queryStringParameters, objItem.queryParams) : true;
47
- if (matchedQueryParams === false) continue;
48
- console.debug(`Matched response`, JSON.stringify({
49
- response: objItem
50
- }));
51
- if (objItem?.requestId !== undefined && objItem?.timestamp !== undefined && objItem?.keep !== true) {
49
+ const matchedQueryParams = hasExpectedQueryParams
50
+ ? checkMatchingQueryParams(event.queryStringParameters, objItem.queryParams)
51
+ : true;
52
+ if (matchedQueryParams === false)
53
+ continue;
54
+ console.debug(`Matched response`, JSON.stringify({ response: objItem }));
55
+ if (objItem?.requestId !== undefined &&
56
+ objItem?.timestamp !== undefined &&
57
+ objItem?.keep !== true) {
52
58
  await db.send(new DeleteItemCommand({
53
59
  TableName: process.env.RESPONSES_TABLE_NAME,
54
60
  Key: marshall({
55
61
  requestId: objItem.requestId,
56
- timestamp: objItem.timestamp
57
- })
62
+ timestamp: objItem.timestamp,
63
+ }),
58
64
  }));
59
65
  }
60
66
  const { body, headers } = splitMockResponse(objItem.body ?? '');
@@ -62,18 +68,20 @@ export const handler = async (event, context)=>{
62
68
  const isBinary = /^[0-9a-f]+$/.test(body);
63
69
  res = {
64
70
  statusCode: objItem.statusCode ?? 200,
65
- headers: isBinary ? {
66
- ...headers,
67
- 'Content-Type': 'application/octet-stream'
68
- } : headers,
69
- body: isBinary ? /* body is HEX encoded */ Buffer.from(body, 'hex').toString('base64') : body,
70
- isBase64Encoded: isBinary
71
+ headers: isBinary
72
+ ? {
73
+ ...headers,
74
+ 'Content-Type': 'application/octet-stream',
75
+ }
76
+ : headers,
77
+ body: isBinary
78
+ ? /* body is HEX encoded */ Buffer.from(body, 'hex').toString('base64')
79
+ : body,
80
+ isBase64Encoded: isBinary,
71
81
  };
72
82
  break;
73
83
  }
74
- console.debug(`Return response`, JSON.stringify({
75
- response: res
76
- }));
84
+ console.debug(`Return response`, JSON.stringify({ response: res }));
77
85
  await db.send(new PutItemCommand({
78
86
  TableName: process.env.REQUESTS_TABLE_NAME,
79
87
  Item: marshall({
@@ -81,10 +89,10 @@ export const handler = async (event, context)=>{
81
89
  responseStatusCode: res.statusCode,
82
90
  responseHeaders: res.headers,
83
91
  responseBody: res.body,
84
- responseIsBase64Encoded: res.isBase64Encoded
92
+ responseIsBase64Encoded: res.isBase64Encoded,
85
93
  }, {
86
- removeUndefinedValues: true
87
- })
94
+ removeUndefinedValues: true,
95
+ }),
88
96
  }));
89
97
  return res;
90
98
  };
@@ -0,0 +1,4 @@
1
+ export declare const splitMockResponse: (r: string) => {
2
+ headers: Record<string, string>;
3
+ body: string;
4
+ };
@@ -1,15 +1,20 @@
1
- export const splitMockResponse = (r)=>{
2
- const trimmedLines = r.split('\n').map((s)=>s.trim()).join('\n');
1
+ export const splitMockResponse = (r) => {
2
+ const trimmedLines = r
3
+ .split('\n')
4
+ .map((s) => s.trim())
5
+ .join('\n');
3
6
  const blankLineLocation = trimmedLines.indexOf('\n\n');
4
- if (blankLineLocation === -1) return {
5
- headers: {},
6
- body: trimmedLines
7
- };
7
+ if (blankLineLocation === -1)
8
+ return {
9
+ headers: {},
10
+ body: trimmedLines,
11
+ };
8
12
  return {
9
- headers: trimmedLines.slice(0, blankLineLocation).split('\n').map((s)=>s.split(':', 2)).reduce((headers, [k, v])=>({
10
- ...headers,
11
- [k]: v?.trim()
12
- }), {}),
13
- body: trimmedLines.slice(blankLineLocation + 2)
13
+ headers: trimmedLines
14
+ .slice(0, blankLineLocation)
15
+ .split('\n')
16
+ .map((s) => s.split(':', 2))
17
+ .reduce((headers, [k, v]) => ({ ...headers, [k]: v?.trim() }), {}),
18
+ body: trimmedLines.slice(blankLineLocation + 2),
14
19
  };
15
20
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'node:test';
3
+ import { splitMockResponse } from './splitMockResponse.js';
4
+ void describe('split mock response', () => {
5
+ void it('should parse headers and body', () => assert.deepEqual(splitMockResponse(`Content-Type: application/octet-stream
6
+
7
+ (binary A-GNSS data) other types`), {
8
+ headers: {
9
+ 'Content-Type': 'application/octet-stream',
10
+ },
11
+ body: '(binary A-GNSS data) other types',
12
+ }));
13
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,51 @@
1
+ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
2
+ import assert from 'node:assert/strict';
3
+ import { readFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { describe, it } from 'node:test';
6
+ import { listRequests } from './src/requests.js';
7
+ import { registerResponse } from './src/responses.js';
8
+ const { responsesTableName, apiURL, requestsTableName } = JSON.parse(await readFile(path.join(process.cwd(), 'http-api-mock.json'), 'utf-8'));
9
+ const db = new DynamoDBClient({});
10
+ void describe('end-to-end tests', () => {
11
+ void it('should respond with 404 if no response is configured', async () => {
12
+ const res = await fetch(new URL('/prod/foo', apiURL), {
13
+ method: 'POST',
14
+ body: JSON.stringify({ some: 'data' }),
15
+ headers: {
16
+ 'content-type': 'application/json; charset=utf-8',
17
+ },
18
+ });
19
+ assert.equal(res.ok, false);
20
+ assert.equal(res.status, 404);
21
+ });
22
+ void it('should store all requests', async () => {
23
+ const pathSegment = crypto.randomUUID();
24
+ await fetch(new URL(`/prod/${pathSegment}`, apiURL));
25
+ const request = (await listRequests(db, requestsTableName)).find(({ path }) => path === pathSegment);
26
+ assert.notEqual(request, undefined);
27
+ });
28
+ void it('should return a configured response', async () => {
29
+ const pathSegment = crypto.randomUUID();
30
+ await registerResponse(db, responsesTableName, {
31
+ method: 'PUT',
32
+ path: pathSegment,
33
+ queryParams: new URLSearchParams({
34
+ foo: 'bar',
35
+ }),
36
+ body: [
37
+ `Content-Type: application/json`,
38
+ '',
39
+ JSON.stringify({ success: true }),
40
+ ].join('\n'),
41
+ statusCode: 201,
42
+ });
43
+ const res = await fetch(new URL(`/prod/${pathSegment}?${new URLSearchParams({ foo: 'bar' }).toString()}`, apiURL), {
44
+ method: 'PUT',
45
+ });
46
+ assert.equal(res.ok, true);
47
+ assert.equal(res.status, 201);
48
+ assert.equal(res.headers.get('Content-Type'), 'application/json');
49
+ assert.deepEqual(await res.json(), { success: true });
50
+ });
51
+ });
@@ -0,0 +1,105 @@
1
+ {
2
+ "name": "@bifravst/http-api-mock",
3
+ "version": "3.0.6",
4
+ "description": "Helper functions for AWS lambdas written in TypeScript.",
5
+ "exports": {
6
+ "./*": {
7
+ "import": {
8
+ "default": "./npm/src/*.js",
9
+ "types": "./npm/src/*.d.ts"
10
+ }
11
+ }
12
+ },
13
+ "type": "module",
14
+ "scripts": {
15
+ "prepare": "husky && case \"$npm_command\" in install|ci) check-node-version --package ;; esac",
16
+ "prepublishOnly": "npx tsc --noEmit false --outDir ./npm -d && cp -rv cdk/* npm/cdk && cp package.json npm/package.json",
17
+ "test": "node --no-warnings --experimental-transform-types --test \"!(node_modules|e2e-tests|integration-tests)/**/*.spec.ts\""
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/bifravst/http-api-mock.git"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/bifravst/http-api-mock/issues"
25
+ },
26
+ "homepage": "https://github.com/bifravst/http-api-mock",
27
+ "keywords": [
28
+ "aws",
29
+ "lambda",
30
+ "typescript"
31
+ ],
32
+ "author": "Nordic Semiconductor ASA | nordicsemi.no",
33
+ "license": "BSD-3-Clause",
34
+ "lint-staged": {
35
+ "*.ts": [
36
+ "prettier --write",
37
+ "eslint"
38
+ ],
39
+ "*.{md,json,yaml,yml}": [
40
+ "prettier --write"
41
+ ]
42
+ },
43
+ "engines": {
44
+ "node": ">=24.19.0 <25",
45
+ "npm": ">=12.0.2 <13"
46
+ },
47
+ "release": {
48
+ "branches": [
49
+ "saga"
50
+ ],
51
+ "remoteTags": true,
52
+ "plugins": [
53
+ "@semantic-release/commit-analyzer",
54
+ "@semantic-release/release-notes-generator",
55
+ "@semantic-release/npm",
56
+ [
57
+ "@semantic-release/github",
58
+ {
59
+ "successCommentCondition": false,
60
+ "failTitle": false
61
+ }
62
+ ]
63
+ ]
64
+ },
65
+ "publishConfig": {
66
+ "access": "public"
67
+ },
68
+ "files": [
69
+ "npm",
70
+ "cdk",
71
+ "src",
72
+ "LICENSE",
73
+ "README.md"
74
+ ],
75
+ "prettier": "@bifravst/prettier-config",
76
+ "dependencies": {
77
+ "@aws-sdk/client-cloudformation": "3.1111.0",
78
+ "@aws-sdk/client-dynamodb": "3.1111.0",
79
+ "@aws-sdk/client-sts": "3.1111.0",
80
+ "@aws-cdk/toolkit-lib": "1.38.2",
81
+ "@aws-sdk/util-dynamodb": "3.996.9",
82
+ "@bifravst/aws-cdk-lambda-helpers": "5.0.4",
83
+ "@bifravst/cloudformation-helpers": "10.0.1",
84
+ "@bifravst/from-env": "4.0.0",
85
+ "@bifravst/run": "2.0.0",
86
+ "aws-cdk-lib": "2.265.0",
87
+ "cdk": "2.1136.0",
88
+ "chalk": "6.0.0",
89
+ "command-line-args": "6.0.2"
90
+ },
91
+ "devDependencies": {
92
+ "@bifravst/eslint-config-typescript": "8.0.0",
93
+ "@bifravst/prettier-config": "2.0.0",
94
+ "@commitlint/config-conventional": "21.2.2",
95
+ "@types/aws-lambda": "8.10.162",
96
+ "@types/command-line-args": "5.2.3",
97
+ "@types/node": "26.2.0",
98
+ "@typescript/native": "npm:typescript@7.0.2",
99
+ "check-node-version": "4.2.1",
100
+ "commitlint": "21.2.2",
101
+ "husky": "9.1.7",
102
+ "lint-staged": "17.3.0",
103
+ "typescript": "npm:@typescript/typescript6@6.0.2"
104
+ }
105
+ }
@@ -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
+ }>, keep?: boolean) => 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 {};
package/npm/src/mock.js CHANGED
@@ -1,27 +1,31 @@
1
1
  import { registerResponse } from './responses.js';
2
- export const mockResponse = (db, responsesTable)=>async (methodPathQuery, response, keep)=>{
3
- const [method, pathWithQuery] = methodPathQuery.split(' ', 2);
4
- if (!/^[A-Z]+$/.test(method ?? '')) throw new Error(`Invalid method ${method} in ${methodPathQuery}!`);
5
- if (pathWithQuery === undefined) throw new Error(`Missing path in ${methodPathQuery}!`);
6
- const [path, query] = pathWithQuery.split('?', 2);
7
- if (path.startsWith('/')) throw new Error(`Path ${path} must not start with /!`);
8
- const bodyParts = [];
9
- if (response.headers !== undefined) {
10
- for (const [k, v] of response.headers.entries()){
11
- bodyParts.push(`${k}: ${v}`);
12
- }
13
- bodyParts.push('');
2
+ export const mockResponse = (db, responsesTable) => async (methodPathQuery, response, keep) => {
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}`);
14
15
  }
15
- if (response.body !== undefined) bodyParts.push(response.body);
16
- await registerResponse(db, responsesTable, {
17
- path,
18
- method: method ?? 'GET',
19
- queryParams: new URLSearchParams(query),
20
- body: bodyParts.length > 0 ? bodyParts.join('\n') : undefined,
21
- statusCode: response.status,
22
- keep
23
- });
24
- };
25
- export const mock = ({ db, responsesTable })=>({
26
- response: mockResponse(db, responsesTable)
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
+ keep,
27
27
  });
28
+ };
29
+ export const mock = ({ db, responsesTable, }) => ({
30
+ response: mockResponse(db, responsesTable),
31
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ import { unmarshall } from '@aws-sdk/util-dynamodb';
2
+ import assert from 'node:assert/strict';
3
+ import { describe, it, mock as testMock } from 'node:test';
4
+ import { mock } from './mock.js';
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
+ });
@@ -0,0 +1,7 @@
1
+ export declare const parseMockRequest: (r: string) => {
2
+ method: string;
3
+ resource: string;
4
+ protocol: string;
5
+ headers: Record<string, string>;
6
+ body: string;
7
+ };
@@ -1,17 +1,19 @@
1
- export const parseMockRequest = (r)=>{
1
+ export const parseMockRequest = (r) => {
2
2
  const lines = r.split('\n');
3
3
  const methodResourceProtol = lines.shift();
4
4
  const blankLineLocation = lines.indexOf('');
5
5
  const headerLines = blankLineLocation === -1 ? lines : lines.slice(0, blankLineLocation);
6
- const body = blankLineLocation === -1 ? '' : lines.slice(blankLineLocation + 1).join('\n');
6
+ const body = blankLineLocation === -1
7
+ ? ''
8
+ : lines.slice(blankLineLocation + 1).join('\n');
7
9
  const requestInfo = /^(?<method>[A-Z]+) (?<resource>[^ ]+) (?<protocol>HTTP\/[0-9.]+)/.exec(methodResourceProtol ?? '')?.groups;
8
- if (requestInfo === null) throw new Error(`Invalid request info: ${methodResourceProtol}`);
10
+ if (requestInfo === null)
11
+ throw new Error(`Invalid request info: ${methodResourceProtol}`);
9
12
  return {
10
13
  ...requestInfo,
11
- headers: headerLines.map((s)=>s.split(':', 2)).reduce((headers, [k, v])=>({
12
- ...headers,
13
- [k ?? '']: v?.trim()
14
- }), {}),
15
- body
14
+ headers: headerLines
15
+ .map((s) => s.split(':', 2))
16
+ .reduce((headers, [k, v]) => ({ ...headers, [k ?? '']: v?.trim() }), {}),
17
+ body,
16
18
  };
17
19
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import { parseMockRequest } from './parseMockRequest.js';
4
+ void describe('parseMockRequest()', () => {
5
+ void it('should parse method, resource, protocol, headers and body', () => assert.deepEqual(parseMockRequest([
6
+ `PATCH /v1/devices/foo/state HTTP/1.1`,
7
+ `Content-Length: 36`,
8
+ `Content-Type: application/json`,
9
+ `If-Match: 8835`,
10
+ ``,
11
+ `{"desired":{"config":{"nod":null}}}`,
12
+ ].join('\n')), {
13
+ method: 'PATCH',
14
+ resource: '/v1/devices/foo/state',
15
+ protocol: 'HTTP/1.1',
16
+ headers: {
17
+ 'Content-Length': '36',
18
+ 'Content-Type': 'application/json',
19
+ 'If-Match': '8835',
20
+ },
21
+ body: '{"desired":{"config":{"nod":null}}}',
22
+ }));
23
+ });
@@ -0,0 +1,6 @@
1
+ export declare const parseMockResponse: (r: string) => {
2
+ statusCode: number;
3
+ protocol: string;
4
+ headers: Record<string, string>;
5
+ body: string;
6
+ };
@@ -1,18 +1,20 @@
1
- export const parseMockResponse = (r)=>{
1
+ export const parseMockResponse = (r) => {
2
2
  const lines = r.split('\n');
3
3
  const protocolStatusCode = lines.shift();
4
4
  const blankLineLocation = lines.indexOf('');
5
5
  const headerLines = blankLineLocation === -1 ? lines : lines.slice(0, blankLineLocation);
6
- const body = blankLineLocation === -1 ? '' : lines.slice(blankLineLocation + 1).join('\n');
6
+ const body = blankLineLocation === -1
7
+ ? ''
8
+ : lines.slice(blankLineLocation + 1).join('\n');
7
9
  const responseInfo = /^(?<protocol>HTTP\/[0-9.]+) (?<statusCode>[0-9]+) /.exec(protocolStatusCode ?? '')?.groups;
8
- if (responseInfo === null) throw new Error(`Invalid request info: ${protocolStatusCode}`);
10
+ if (responseInfo === null)
11
+ throw new Error(`Invalid request info: ${protocolStatusCode}`);
9
12
  return {
10
13
  statusCode: parseInt(responseInfo.statusCode, 10),
11
14
  protocol: responseInfo.protocol,
12
- headers: headerLines.map((s)=>s.split(':', 2)).reduce((headers, [k, v])=>({
13
- ...headers,
14
- [k ?? '']: v?.trim()
15
- }), {}),
16
- body
15
+ headers: headerLines
16
+ .map((s) => s.split(':', 2))
17
+ .reduce((headers, [k, v]) => ({ ...headers, [k ?? '']: v?.trim() }), {}),
18
+ body,
17
19
  };
18
20
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,20 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import { parseMockResponse } from './parseMockResponse.js';
4
+ void describe('parseMockResponse()', () => {
5
+ void it('should parse protocol, statusCode, headers and body', () => assert.deepEqual(parseMockResponse([
6
+ `HTTP/1.1 202 Accepted`,
7
+ `Content-Length: 36`,
8
+ `Content-Type: application/json`,
9
+ ``,
10
+ `{"desired":{"config":{"nod":null}}}`,
11
+ ].join('\n')), {
12
+ statusCode: 202,
13
+ protocol: 'HTTP/1.1',
14
+ headers: {
15
+ 'Content-Length': '36',
16
+ 'Content-Type': 'application/json',
17
+ },
18
+ body: '{"desired":{"config":{"nod":null}}}',
19
+ }));
20
+ });
@@ -0,0 +1 @@
1
+ export declare const randomString: () => string;
@@ -1,2 +1,5 @@
1
1
  import crypto from 'node:crypto';
2
- export const randomString = ()=>crypto.randomBytes(Math.ceil(8 * 0.5)).toString('hex').slice(0, 8);
2
+ export const randomString = () => crypto
3
+ .randomBytes(Math.ceil(8 * 0.5))
4
+ .toString('hex')
5
+ .slice(0, 8);
@@ -0,0 +1,13 @@
1
+ import { type DynamoDBClient } from '@aws-sdk/client-dynamodb';
2
+ export type Request = {
3
+ path: string;
4
+ query: null;
5
+ timestamp: string;
6
+ ttl: string;
7
+ headers: Record<string, string>;
8
+ method: string;
9
+ requestId: string;
10
+ body: string;
11
+ methodPathQuery: string;
12
+ };
13
+ export declare const listRequests: (db: DynamoDBClient, requestsTable: string) => Promise<Array<Request>>;
@@ -1,11 +1,11 @@
1
1
  import { ScanCommand } from '@aws-sdk/client-dynamodb';
2
2
  import { unmarshall } from '@aws-sdk/util-dynamodb';
3
- export const listRequests = async (db, requestsTable)=>((await db.send(new ScanCommand({
4
- TableName: requestsTable
5
- }))).Items ?? []).map((item)=>{
6
- const i = unmarshall(item);
7
- return {
8
- ...i,
9
- headers: JSON.parse(i.headers)
10
- };
11
- }).sort((i1, i2)=>i1.timestamp.localeCompare(i2.timestamp));
3
+ export const listRequests = async (db, requestsTable) => ((await db.send(new ScanCommand({ TableName: requestsTable }))).Items ?? [])
4
+ .map((item) => {
5
+ const i = unmarshall(item);
6
+ return {
7
+ ...i,
8
+ headers: JSON.parse(i.headers),
9
+ };
10
+ })
11
+ .sort((i1, i2) => i1.timestamp.localeCompare(i2.timestamp));
@@ -0,0 +1,16 @@
1
+ import { type DynamoDBClient } from '@aws-sdk/client-dynamodb';
2
+ export type Response = {
3
+ method: string;
4
+ path: string;
5
+ queryParams?: URLSearchParams;
6
+ statusCode?: number;
7
+ /**
8
+ * Header + Body
9
+ *
10
+ * @see splitMockResponse
11
+ */
12
+ body?: string;
13
+ ttl?: number;
14
+ keep?: boolean;
15
+ };
16
+ export declare const registerResponse: (db: DynamoDBClient, responsesTable: string, response: Response) => Promise<void>;
@@ -2,7 +2,7 @@ import { PutItemCommand } from '@aws-sdk/client-dynamodb';
2
2
  import { marshall } from '@aws-sdk/util-dynamodb';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { sortQuery } from './sortQueryString.js';
5
- export const registerResponse = async (db, responsesTable, response)=>{
5
+ export const registerResponse = async (db, responsesTable, response) => {
6
6
  await db.send(new PutItemCommand({
7
7
  TableName: responsesTable,
8
8
  Item: marshall({
@@ -11,11 +11,11 @@ export const registerResponse = async (db, responsesTable, response)=>{
11
11
  timestamp: new Date().toISOString(),
12
12
  statusCode: response.statusCode,
13
13
  body: response.body,
14
- queryParams: response.queryParams !== undefined ? Object.fromEntries(response.queryParams) : undefined,
14
+ queryParams: response.queryParams !== undefined
15
+ ? Object.fromEntries(response.queryParams)
16
+ : undefined,
15
17
  ttl: response.ttl,
16
- keep: response.keep
17
- }, {
18
- removeUndefinedValues: true
19
- })
18
+ keep: response.keep,
19
+ }, { removeUndefinedValues: true }),
20
20
  }));
21
21
  };
@@ -0,0 +1,3 @@
1
+ import { URLSearchParams } from 'node:url';
2
+ export declare const sortQueryString: (mockUrl: string) => string;
3
+ export declare const sortQuery: (query: URLSearchParams | Record<string, string>) => string;
@@ -1,24 +1,23 @@
1
1
  import { URLSearchParams } from 'node:url';
2
- export const sortQueryString = (mockUrl)=>{
2
+ export const sortQueryString = (mockUrl) => {
3
3
  const [host, query] = mockUrl.split('?', 2);
4
- if (query === undefined || (query?.length ?? 0) === 0) return host;
4
+ if (query === undefined || (query?.length ?? 0) === 0)
5
+ return host;
5
6
  return `${host}?${sortQuery(new URLSearchParams(query))}`;
6
7
  };
7
- export const sortQuery = (query)=>{
8
+ export const sortQuery = (query) => {
8
9
  const params = [];
9
10
  if (query instanceof URLSearchParams) {
10
- query.forEach((v, k)=>{
11
- params.push([
12
- k,
13
- v
14
- ]);
11
+ query.forEach((v, k) => {
12
+ params.push([k, v]);
15
13
  });
16
- } else {
14
+ }
15
+ else {
17
16
  params.push(...Object.entries(query));
18
17
  }
19
- params.sort(([k1], [k2])=>(k1 ?? '').localeCompare(k2 ?? ''));
18
+ params.sort(([k1], [k2]) => (k1 ?? '').localeCompare(k2 ?? ''));
20
19
  const sortedParams = new URLSearchParams();
21
- for (const [k, v] of params){
20
+ for (const [k, v] of params) {
22
21
  sortedParams.append(k, v);
23
22
  }
24
23
  return sortedParams.toString();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ import assert from 'node:assert';
2
+ import { describe, test as it } from 'node:test';
3
+ import { URLSearchParams } from 'node:url';
4
+ import { sortQuery, sortQueryString } from './sortQueryString.js';
5
+ void describe('sortQueryString', () => {
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'));
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,19 +1,19 @@
1
1
  {
2
2
  "name": "@bifravst/http-api-mock",
3
- "version": "3.0.4",
3
+ "version": "3.0.6",
4
4
  "description": "Helper functions for AWS lambdas written in TypeScript.",
5
5
  "exports": {
6
6
  "./*": {
7
7
  "import": {
8
8
  "default": "./npm/src/*.js",
9
- "types": "./src/*.ts"
9
+ "types": "./npm/src/*.d.ts"
10
10
  }
11
11
  }
12
12
  },
13
13
  "type": "module",
14
14
  "scripts": {
15
15
  "prepare": "husky && case \"$npm_command\" in install|ci) check-node-version --package ;; esac",
16
- "prepublishOnly": "node --no-warnings --experimental-transform-types ./.npm/compile.ts && cp -rv cdk/* npm/cdk",
16
+ "prepublishOnly": "npx tsc --noEmit false --outDir ./npm -d && cp -rv cdk/* npm/cdk && cp package.json npm/package.json",
17
17
  "test": "node --no-warnings --experimental-transform-types --test \"!(node_modules|e2e-tests|integration-tests)/**/*.spec.ts\""
18
18
  },
19
19
  "repository": {
@@ -79,18 +79,18 @@
79
79
  "@aws-sdk/client-sts": "3.1111.0",
80
80
  "@aws-cdk/toolkit-lib": "1.38.2",
81
81
  "@aws-sdk/util-dynamodb": "3.996.9",
82
- "@bifravst/aws-cdk-lambda-helpers": "4.0.96",
83
- "@bifravst/cloudformation-helpers": "9.1.1",
84
- "@bifravst/from-env": "3.0.2",
85
- "@bifravst/run": "1.2.0",
82
+ "@bifravst/aws-cdk-lambda-helpers": "5.0.4",
83
+ "@bifravst/cloudformation-helpers": "10.0.1",
84
+ "@bifravst/from-env": "4.0.0",
85
+ "@bifravst/run": "2.0.0",
86
86
  "aws-cdk-lib": "2.265.0",
87
87
  "cdk": "2.1136.0",
88
88
  "chalk": "6.0.0",
89
89
  "command-line-args": "6.0.2"
90
90
  },
91
91
  "devDependencies": {
92
- "@bifravst/eslint-config-typescript": "7.0.34",
93
- "@bifravst/prettier-config": "1.1.17",
92
+ "@bifravst/eslint-config-typescript": "8.0.0",
93
+ "@bifravst/prettier-config": "2.0.0",
94
94
  "@commitlint/config-conventional": "21.2.2",
95
95
  "@types/aws-lambda": "8.10.162",
96
96
  "@types/command-line-args": "5.2.3",