Skip to content

Commit 4e02066

Browse files
authored
Adds ability to start lambdas without API (#7)
1 parent ce93d8d commit 4e02066

19 files changed

+3295
-233
lines changed

.github/workflows/CI.yml

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
9+
test:
10+
runs-on: ubuntu-latest
11+
env:
12+
SAM_CLI_TELEMETRY: 0
13+
# Don't worry these are fake AWS credentials for AWS SAM
14+
AWS_ACCESS_KEY_ID: ABIAZLJNBT8I3KFOU4NO
15+
AWS_SECRET_ACCESS_KEY: 4Xt3Rbx4DO21MhK1IHXZXRvVRDuqaQ0Wo5lILA/h
16+
17+
steps:
18+
- name: Setup AWS SAM
19+
run: |
20+
brew tap aws/tap
21+
brew install aws-sam-cli
22+
sam --version
23+
24+
- uses: actions/checkout@v2
25+
26+
- uses: actions/setup-node@v2
27+
with:
28+
node-version: '14'
29+
cache: 'yarn'
30+
31+
- name: Install dependencies
32+
run: yarn install --frozen-lockfile --check-files
33+
34+
- name: Run tests
35+
run: yarn test --runInBand

.prettierignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Build output
2+
dist

README.md

+46
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,52 @@ npm i -D @dealmore/sammy # npm
99
yarn add -D @dealmore/sammy # or yarn
1010
```
1111

12+
Assuming you have a Lambda function with the following content:
13+
14+
```js
15+
// handler.js
16+
exports.handler = async function (event, context) {
17+
return {
18+
isBase64Encoded: false,
19+
statusCode: 200,
20+
body: 'Hello World!',
21+
headers: {
22+
'content-type': 'application/json',
23+
},
24+
};
25+
};
26+
```
27+
28+
which is packaged into a compressed zip file called `lambda.zip`.
29+
30+
### Run lambda locally with API-Gateway
31+
32+
You can now start the Lambda function locally and access it through an API-Endpoint:
33+
34+
```ts
35+
import * as path from 'path';
36+
37+
import type { APISAMGenerator } from '@dealmore/sammy';
38+
import { generateAPISAM } from '@dealmore/sammy';
39+
40+
const lambdaSAM = await generateAPISAM({
41+
lambdas: {
42+
first: {
43+
filename: 'first.zip',
44+
handler: 'handler.handler',
45+
runtime: 'nodejs14.x',
46+
route: '/test',
47+
method: 'get',
48+
},
49+
},
50+
cwd: process.cwd(),
51+
});
52+
53+
const response = await lambdaSAM.sendApiGwRequest('/test');
54+
console.log(await response.text());
55+
// => Hello World!
56+
```
57+
1258
## License
1359

1460
Apache-2.0 - see [LICENSE](./LICENSE) for details.

jest.config.js

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module.exports = {
2+
preset: 'ts-jest',
3+
testEnvironment: 'node',
4+
rootDir: './',
5+
};

lib/SAMGenerator.ts

+177
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import * as fs from 'fs';
2+
import { EventEmitter } from 'events';
3+
import * as path from 'path';
4+
5+
import getPort from 'get-port';
6+
import { dirSync as tmpDirSync, DirResult } from 'tmp';
7+
8+
import { SAMTemplate } from './SAMTemplate';
9+
import { ConfigLambda, SAMLocalAPICLIOptions } from './types';
10+
import { unzipToLocation } from './utils';
11+
import { createSAMLocal, SAMLocal } from './SAMLocal';
12+
13+
type GeneratorType = 'api' | 'sdk';
14+
15+
type SAMInstance = {
16+
port: number;
17+
host: string;
18+
endpoint: string;
19+
region: string;
20+
};
21+
22+
class LoggerEmitter extends EventEmitter {}
23+
24+
/**
25+
* Generator for an AWS SAM CLI instance
26+
*/
27+
class SAMGenerator {
28+
loggerEmitter: LoggerEmitter;
29+
SAM?: SAMLocal;
30+
SAMInstance?: SAMInstance;
31+
template: SAMTemplate;
32+
tmpDir: DirResult;
33+
type: GeneratorType;
34+
35+
constructor(type: GeneratorType) {
36+
this.loggerEmitter = new LoggerEmitter();
37+
this.tmpDir = tmpDirSync({ unsafeCleanup: true });
38+
this.template = new SAMTemplate();
39+
this.type = type;
40+
}
41+
42+
// Explicit `this` binding
43+
private onData = (message: any) => {
44+
this.loggerEmitter.emit('data', message);
45+
};
46+
47+
// Explicit `this` binding
48+
private onError = (message: any) => {
49+
this.loggerEmitter.emit('error', message);
50+
};
51+
52+
/**
53+
* Unpacks the lambdas into a temporary workDir and creates a SAM Template
54+
* that can be passed to AWS SAM CLI
55+
*
56+
* @param lambdas - Key/Value object with the lambdas that should be created
57+
* @param cwd
58+
*/
59+
async generateLambdas(
60+
lambdas: Record<string, ConfigLambda>,
61+
cwd: string = process.cwd()
62+
) {
63+
// Unpack all lambdas
64+
for (const [functionName, lambda] of Object.entries(lambdas)) {
65+
const functionSourcePath = path.isAbsolute(lambda.filename)
66+
? lambda.filename
67+
: path.join(cwd, lambda.filename);
68+
69+
await unzipToLocation(
70+
functionSourcePath,
71+
path.join(this.tmpDir.name, functionName)
72+
);
73+
74+
this.template.addLambda(functionName, {
75+
Type: 'AWS::Serverless::Function',
76+
Properties: {
77+
Handler: `${functionName}/${lambda.handler}`,
78+
Description: functionName,
79+
Runtime: lambda.runtime,
80+
MemorySize: lambda.memorySize ?? 128,
81+
Timeout: 29, // Max timeout from API Gateway
82+
Environment: {
83+
Variables: lambda.environment ?? {},
84+
},
85+
},
86+
});
87+
88+
if (lambda.route) {
89+
this.template.addRoute(functionName, 'api', {
90+
Type: 'HttpApi',
91+
Properties: {
92+
Path: lambda.route,
93+
Method: lambda.method ?? 'any',
94+
TimeoutInMillis: 29000, // Max timeout
95+
PayloadFormatVersion: '2.0',
96+
},
97+
});
98+
} else if (lambda.routes) {
99+
for (const routeKey in lambda.routes) {
100+
this.template.addRoute(functionName, routeKey, {
101+
Type: 'HttpApi',
102+
Properties: {
103+
Path: lambda.routes[routeKey],
104+
Method: lambda.method ?? 'any',
105+
TimeoutInMillis: 29000, // Max timeout
106+
PayloadFormatVersion: '2.0',
107+
},
108+
});
109+
}
110+
}
111+
112+
// Write the SAM template
113+
fs.writeFileSync(
114+
path.join(this.tmpDir.name, 'template.yml'),
115+
this.template.toYaml()
116+
);
117+
}
118+
}
119+
120+
/**
121+
* Start the AWS SAM CLI
122+
*
123+
* @param cliOptions
124+
*/
125+
async start(cliOptions: SAMLocalAPICLIOptions = {}) {
126+
const port = cliOptions.port || (await getPort());
127+
const host = cliOptions.host || '127.0.0.1';
128+
const endpoint = `http://${host}:${port}`;
129+
const region = cliOptions.region || 'local';
130+
this.SAM = await createSAMLocal(this.type, this.tmpDir.name, {
131+
onData: this.onData,
132+
onError: this.onError,
133+
cliOptions: { ...cliOptions, port, host, region },
134+
});
135+
136+
this.SAMInstance = {
137+
port,
138+
host,
139+
endpoint,
140+
region,
141+
};
142+
143+
return this.SAMInstance;
144+
}
145+
146+
/**
147+
* Stop the AWS SAM CLI
148+
*/
149+
async stop() {
150+
if (this.SAM) {
151+
await this.SAM.kill();
152+
}
153+
154+
this.tmpDir.removeCallback();
155+
this.SAMInstance = undefined;
156+
}
157+
158+
/**
159+
* Subscribe to data or error messages from the CLI
160+
* @param topic
161+
* @param callback
162+
*/
163+
on(topic: 'data' | 'error', callback: (data: string) => void) {
164+
this.loggerEmitter.on(topic, callback);
165+
}
166+
167+
/**
168+
* Unsubscribe to data or error messages from the CLI
169+
* @param topic
170+
* @param callback
171+
*/
172+
off(topic: 'data' | 'error', callback: (data: string) => void) {
173+
this.loggerEmitter.off(topic, callback);
174+
}
175+
}
176+
177+
export { SAMGenerator };

lib/SAMTemplate.ts

+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { stringify as yaml } from 'yaml';
2+
3+
import {
4+
SAMTemplate as SAMTemplateJSON,
5+
ServerLessFunction,
6+
ServerLessFunctionAPIEvent,
7+
} from './types';
8+
9+
type PickPartial<T, K extends keyof T> = { [P in K]: Partial<T[P]> };
10+
11+
type PartialServerLessFunctionProps = 'MemorySize' | 'Runtime' | 'Timeout';
12+
13+
type ServerLessFunctionArgs = ServerLessFunction & {
14+
Properties: PickPartial<
15+
ServerLessFunction['Properties'],
16+
PartialServerLessFunctionProps
17+
>;
18+
};
19+
20+
const defaultFunctionProperties: Pick<
21+
ServerLessFunction['Properties'],
22+
PartialServerLessFunctionProps
23+
> = {
24+
MemorySize: 128, // in mb
25+
Runtime: 'nodejs14.x',
26+
Timeout: 30, // in seconds
27+
};
28+
29+
/**
30+
* Helper class to generate the SAMTemplate
31+
*/
32+
class SAMTemplate {
33+
template: SAMTemplateJSON;
34+
35+
constructor() {
36+
// Default header
37+
this.template = {
38+
AWSTemplateFormatVersion: '2010-09-09',
39+
Transform: ['AWS::Serverless-2016-10-31'],
40+
Resources: {},
41+
};
42+
}
43+
44+
private addAPIGatewayOutput() {
45+
if (!this.template.Outputs) {
46+
this.template.Outputs = {};
47+
}
48+
49+
if (!('WebEndpoint' in this.template.Outputs)) {
50+
this.template.Outputs['WebEndpoint'] = {
51+
Value:
52+
"!Sub 'https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/'",
53+
};
54+
}
55+
}
56+
57+
addLambda(functionName: string, lambda: ServerLessFunctionArgs) {
58+
this.template.Resources[functionName] = {
59+
...lambda,
60+
Properties: {
61+
...defaultFunctionProperties,
62+
...lambda.Properties,
63+
},
64+
};
65+
}
66+
67+
addRoute(
68+
functionName: string,
69+
routeKey: string,
70+
apiEvent: ServerLessFunctionAPIEvent
71+
) {
72+
if (!(functionName in this.template.Resources)) {
73+
throw new Error(
74+
`No function resource with name "${functionName}". Please create the function first before adding routes.`
75+
);
76+
}
77+
78+
// Initialize Events if not present
79+
if (!this.template.Resources[functionName].Properties.Events) {
80+
this.template.Resources[functionName].Properties.Events = {};
81+
}
82+
83+
this.template.Resources[functionName].Properties.Events![
84+
routeKey
85+
] = apiEvent;
86+
87+
this.addAPIGatewayOutput();
88+
}
89+
90+
toYaml() {
91+
return yaml(this.template);
92+
}
93+
}
94+
95+
export { SAMTemplate };

0 commit comments

Comments
 (0)