local-web-services-javascript-sdk 0.1.0 → 0.1.2
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 +132 -0
- package/package.json +1 -1
- package/src/discovery/hcl.js +149 -0
- package/src/session.js +38 -2
package/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# local-web-services-javascript-sdk
|
|
2
|
+
|
|
3
|
+
JavaScript testing SDK for [local-web-services](https://github.com/local-web-services/local-web-services) — spawns `ldk dev` in a subprocess and provides pre-configured AWS SDK v3 clients for testing.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
Install `local-web-services`:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install local-web-services
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install local-web-services-javascript-sdk
|
|
17
|
+
# or
|
|
18
|
+
pnpm add local-web-services-javascript-sdk
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
const { LwsSession } = require('local-web-services-javascript-sdk');
|
|
25
|
+
|
|
26
|
+
// Auto-discover from a CDK project (runs ldk dev against cdk.out/)
|
|
27
|
+
const session = await LwsSession.fromCdk('../my-cdk-project');
|
|
28
|
+
|
|
29
|
+
// Auto-discover from a Terraform project
|
|
30
|
+
const session = await LwsSession.fromHcl('../my-terraform-project');
|
|
31
|
+
|
|
32
|
+
// Explicit resource declaration
|
|
33
|
+
const session = await LwsSession.create({
|
|
34
|
+
tables: [{ name: 'Orders', partitionKey: 'id' }],
|
|
35
|
+
queues: ['OrderQueue'],
|
|
36
|
+
buckets: ['ReceiptsBucket'],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Get a fully-configured AWS SDK v3 client
|
|
40
|
+
const dynamodb = session.client('dynamodb');
|
|
41
|
+
|
|
42
|
+
// Use the helper API
|
|
43
|
+
const table = session.dynamodb('Orders');
|
|
44
|
+
await table.put({ id: { S: '1' }, status: { S: 'pending' } });
|
|
45
|
+
const items = await table.scan();
|
|
46
|
+
console.log(items.length); // 1
|
|
47
|
+
|
|
48
|
+
// Always close the session when done
|
|
49
|
+
await session.close();
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Jest example
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
// jest.config.js
|
|
56
|
+
module.exports = {
|
|
57
|
+
testEnvironment: 'node',
|
|
58
|
+
testTimeout: 60000, // ldk dev needs time to start
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// orders.test.js
|
|
62
|
+
const { LwsSession } = require('local-web-services-javascript-sdk');
|
|
63
|
+
|
|
64
|
+
let session;
|
|
65
|
+
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
session = await LwsSession.create({
|
|
68
|
+
tables: [{ name: 'Orders', partitionKey: 'id' }],
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterAll(async () => {
|
|
73
|
+
await session.close();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
beforeEach(async () => {
|
|
77
|
+
await session.reset();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('creates an order', async () => {
|
|
81
|
+
const table = session.dynamodb('Orders');
|
|
82
|
+
await table.put({ id: { S: '42' }, status: { S: 'pending' } });
|
|
83
|
+
|
|
84
|
+
const item = await table.assertItemExists({ id: { S: '42' } });
|
|
85
|
+
expect(item.status.S).toBe('pending');
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Drop-in AWS endpoint redirection
|
|
90
|
+
|
|
91
|
+
When a session starts, `AWS_ENDPOINT_URL_*` environment variables are automatically set for all supported services. Any AWS SDK v3 client created after the session starts — including clients in your production code — will hit the local LWS services without any code changes:
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
// production code
|
|
95
|
+
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
|
|
96
|
+
const client = new DynamoDBClient({}); // picks up AWS_ENDPOINT_URL_DYNAMODB automatically
|
|
97
|
+
|
|
98
|
+
// test code — no endpoint configuration needed
|
|
99
|
+
const session = await LwsSession.create({ tables: [{ name: 'Orders', partitionKey: 'id' }] });
|
|
100
|
+
// production client now talks to LWS
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Env vars are restored when `session.close()` is called.
|
|
104
|
+
|
|
105
|
+
## API
|
|
106
|
+
|
|
107
|
+
### `LwsSession`
|
|
108
|
+
|
|
109
|
+
| Method | Description |
|
|
110
|
+
|--------|-------------|
|
|
111
|
+
| `LwsSession.create(spec)` | Start with explicit resource spec |
|
|
112
|
+
| `LwsSession.fromCdk(projectDir)` | Auto-discover from CDK cloud assembly |
|
|
113
|
+
| `LwsSession.fromHcl(projectDir)` | Auto-discover from Terraform `.tf` files |
|
|
114
|
+
| `session.client(service)` | Get AWS SDK v3 client |
|
|
115
|
+
| `session.dynamodb(tableName)` | Get `DynamoDBHelper` |
|
|
116
|
+
| `session.sqs(queueName)` | Get `SQSHelper` |
|
|
117
|
+
| `session.s3(bucketName)` | Get `S3Helper` |
|
|
118
|
+
| `session.reset()` | Clear all state (use in `beforeEach`) |
|
|
119
|
+
| `session.close()` | Stop `ldk dev` process |
|
|
120
|
+
| `session.queueUrl(queueName)` | Get local SQS queue URL |
|
|
121
|
+
| `session.portFor(service)` | Get port number for a service |
|
|
122
|
+
| `session.mock(service)` | Get `MockBuilder` for service |
|
|
123
|
+
| `session.chaos(service)` | Get `ChaosBuilder` for service |
|
|
124
|
+
| `session.iam` | Get `IamBuilder` |
|
|
125
|
+
|
|
126
|
+
### Supported services
|
|
127
|
+
|
|
128
|
+
`dynamodb`, `s3`, `sqs`, `sns`, `ssm`, `secretsmanager`, `stepfunctions`
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT
|
package/package.json
CHANGED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal Terraform HCL parser for discovering AWS resources in .tf files.
|
|
5
|
+
*
|
|
6
|
+
* Supports the subset of HCL needed for common AWS resource types, including
|
|
7
|
+
* heredoc strings (<<MARKER ... MARKER) used for multi-line JSON definitions.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
|
|
13
|
+
const RESOURCE_HEADER = /resource\s+"(?<type>[^"]+)"\s+"(?<logical>[^"]+)"\s*\{/;
|
|
14
|
+
const ATTR_STR = /^\s*(?<key>\w+)\s*=\s*"(?<value>[^"]*)"/;
|
|
15
|
+
const ATTR_BARE = /^\s*(?<key>\w+)\s*=\s*(?<value>\S+)/;
|
|
16
|
+
const ATTR_HEREDOC = /^\s*(?<key>\w+)\s*=\s*<<(?<marker>\w+)\s*$/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse all .tf files in `projectDir` and return a resource spec object with
|
|
20
|
+
* discovered AWS resources ready for use with LwsSession.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} projectDir
|
|
23
|
+
* @returns {{ stateMachines?: Array<{ name: string, definition?: string, roleArn?: string }> }}
|
|
24
|
+
*/
|
|
25
|
+
function discoverHcl(projectDir) {
|
|
26
|
+
const tfFiles = [];
|
|
27
|
+
|
|
28
|
+
function findTfFiles(dir) {
|
|
29
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
30
|
+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
31
|
+
findTfFiles(path.join(dir, entry.name));
|
|
32
|
+
} else if (entry.name.endsWith(".tf")) {
|
|
33
|
+
tfFiles.push(path.join(dir, entry.name));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
findTfFiles(projectDir);
|
|
39
|
+
|
|
40
|
+
if (tfFiles.length === 0) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`No .tf files found in ${projectDir}. ` +
|
|
43
|
+
"Make sure you point to the directory containing your Terraform files."
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const resources = [];
|
|
48
|
+
for (const tfFile of tfFiles) {
|
|
49
|
+
resources.push(...parseTfFile(tfFile));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return classifyResources(resources);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseTfFile(filePath) {
|
|
56
|
+
const text = fs.readFileSync(filePath, "utf-8");
|
|
57
|
+
const lines = text.split("\n");
|
|
58
|
+
const results = [];
|
|
59
|
+
let i = 0;
|
|
60
|
+
|
|
61
|
+
while (i < lines.length) {
|
|
62
|
+
const m = RESOURCE_HEADER.exec(lines[i]);
|
|
63
|
+
if (m?.groups) {
|
|
64
|
+
const { type, logical } = m.groups;
|
|
65
|
+
const [attrs, nextI] = collectBlock(lines, i + 1);
|
|
66
|
+
results.push({ type, logical, attrs });
|
|
67
|
+
i = nextI;
|
|
68
|
+
} else {
|
|
69
|
+
i++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return results;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function collectBlock(lines, start) {
|
|
77
|
+
const attrs = {};
|
|
78
|
+
let depth = 1;
|
|
79
|
+
let i = start;
|
|
80
|
+
|
|
81
|
+
while (i < lines.length && depth > 0) {
|
|
82
|
+
const line = lines[i];
|
|
83
|
+
|
|
84
|
+
// Handle heredoc (key = <<MARKER ... MARKER) before adjusting depth so
|
|
85
|
+
// JSON content inside the heredoc does not affect brace counting.
|
|
86
|
+
if (depth === 1) {
|
|
87
|
+
const mHeredoc = ATTR_HEREDOC.exec(line);
|
|
88
|
+
if (mHeredoc?.groups) {
|
|
89
|
+
const { key, marker } = mHeredoc.groups;
|
|
90
|
+
i++;
|
|
91
|
+
const heredocLines = [];
|
|
92
|
+
while (i < lines.length && lines[i].trimEnd() !== marker) {
|
|
93
|
+
heredocLines.push(lines[i]);
|
|
94
|
+
i++;
|
|
95
|
+
}
|
|
96
|
+
attrs[key] = heredocLines.join("\n");
|
|
97
|
+
i++; // skip the closing marker line
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const opens = (line.match(/\{/g) ?? []).length;
|
|
103
|
+
const closes = (line.match(/\}/g) ?? []).length;
|
|
104
|
+
depth += opens - closes;
|
|
105
|
+
|
|
106
|
+
if (depth > 1) {
|
|
107
|
+
i++;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (depth === 0) break;
|
|
111
|
+
|
|
112
|
+
const mStr = ATTR_STR.exec(line);
|
|
113
|
+
if (mStr?.groups) {
|
|
114
|
+
attrs[mStr.groups.key] = mStr.groups.value;
|
|
115
|
+
} else {
|
|
116
|
+
const mBare = ATTR_BARE.exec(line);
|
|
117
|
+
if (mBare?.groups) {
|
|
118
|
+
attrs[mBare.groups.key] = mBare.groups.value;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
i++;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return [attrs, i + 1];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function classifyResources(resources) {
|
|
128
|
+
const spec = { stateMachines: [] };
|
|
129
|
+
|
|
130
|
+
for (const { type, attrs } of resources) {
|
|
131
|
+
if (type === "aws_sfn_state_machine") {
|
|
132
|
+
const sm = buildStateMachine(attrs);
|
|
133
|
+
if (sm) spec.stateMachines.push(sm);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return spec;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function buildStateMachine(attrs) {
|
|
141
|
+
if (!attrs["name"]) return null;
|
|
142
|
+
return {
|
|
143
|
+
name: attrs["name"],
|
|
144
|
+
definition: attrs["definition"] ?? "{}",
|
|
145
|
+
roleArn: attrs["role_arn"],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { discoverHcl };
|
package/src/session.js
CHANGED
|
@@ -181,13 +181,49 @@ class LwsSession {
|
|
|
181
181
|
/**
|
|
182
182
|
* Create a session by discovering resources from a Terraform / HCL project.
|
|
183
183
|
*
|
|
184
|
+
* Reads `.tf` files in `projectDir`, discovers AWS resources, starts ldk,
|
|
185
|
+
* and pre-creates the discovered resources so they are immediately available.
|
|
186
|
+
*
|
|
184
187
|
* @param {string} [projectDir="."]
|
|
185
188
|
* @returns {Promise<LwsSession>}
|
|
186
189
|
*/
|
|
187
190
|
static async fromHcl(projectDir = ".") {
|
|
191
|
+
const { discoverHcl } = require("./discovery/hcl");
|
|
192
|
+
|
|
193
|
+
const resolvedDir = path.resolve(projectDir);
|
|
194
|
+
const spec = discoverHcl(resolvedDir);
|
|
195
|
+
|
|
188
196
|
const basePort = await freePort();
|
|
189
|
-
const
|
|
190
|
-
await
|
|
197
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "lws-testing-"));
|
|
198
|
+
await generateTerraformConfig(tempDir, spec);
|
|
199
|
+
|
|
200
|
+
const session = new LwsSession(basePort, tempDir, spec);
|
|
201
|
+
session._tempDir = tempDir;
|
|
202
|
+
await session._start();
|
|
203
|
+
|
|
204
|
+
// Pre-create state machines discovered from the HCL files.
|
|
205
|
+
const sfnPort = basePort + SERVICE_OFFSETS["stepfunctions"];
|
|
206
|
+
for (const sm of spec.stateMachines ?? []) {
|
|
207
|
+
const definition =
|
|
208
|
+
typeof sm.definition === "object"
|
|
209
|
+
? JSON.stringify(sm.definition)
|
|
210
|
+
: (sm.definition ?? "{}");
|
|
211
|
+
await fetch(`http://127.0.0.1:${sfnPort}`, {
|
|
212
|
+
method: "POST",
|
|
213
|
+
headers: {
|
|
214
|
+
"Content-Type": "application/x-amz-json-1.0",
|
|
215
|
+
"X-Amz-Target": "AWSStepFunctions.CreateStateMachine",
|
|
216
|
+
},
|
|
217
|
+
body: JSON.stringify({
|
|
218
|
+
name: sm.name,
|
|
219
|
+
definition,
|
|
220
|
+
roleArn:
|
|
221
|
+
sm.roleArn ?? "arn:aws:iam::000000000000:role/StepFunctionsRole",
|
|
222
|
+
type: "STANDARD",
|
|
223
|
+
}),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
191
227
|
return session;
|
|
192
228
|
}
|
|
193
229
|
|