Skip to content

Commit b8e028e

Browse files
authored
Merge pull request #17 from smithclay/api-events
Support Invocation from API Gateway
2 parents 5360b6b + fc85702 commit b8e028e

8 files changed

+298
-55
lines changed

README.md

+3-2
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33

44
**Lambdium allows you to run a Selenium Webdriver script written in Javascript inside of an AWS Lambda function bundled with [Headless Chromium](https://developers.google.com/web/updates/2017/04/headless-chrome).**
55

6-
You can use this AWS Lambda function to:
6+
You can use this AWS Lambda function by itself or with other AWS services to:
77

88
* Run many concurrent selenium scripts at the same time without worrying about the infrastructure
9-
* Configure Cloudwatch events to run script(s) on a schedule ([example app](/examples/apps/scheduled-event.yaml))
9+
* Run execute a selenium script via an HTTP call using API Gateway
10+
* Configure Cloudwatch events to run a script on a schedule ([example app](/examples/apps/scheduled-event.yaml))
1011
* Integrate selenium tests running in Chrome into different event-driven workflows (like CodeDeploy checks, webhooks, or uploads to an S3 bucket)
1112

1213
Since this Lambda function is written using node.js, you can run almost any script written for [selenium-webdriver](https://www.npmjs.com/package/selenium-webdriver). Example scripts can be found in the `examples` directory.

event-api.json

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"body": "LS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS03NjgyM2JlYzc1MjY0MGQ0DQpDb250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9InNjcmlwdCI7IGZpbGVuYW1lPSJ2aXNpdGdvb2dsZS5qcyINCkNvbnRlbnQtVHlwZTogYXBwbGljYXRpb24vb2N0ZXQtc3RyZWFtDQoNCi8vIFNhbXBsZSBzZWxlbmltdW0td2ViZHJpdmVyIHNjcmlwdCB0aGF0IHZpc2l0cyBnb29nbGUuY29tCi8vIFRoaXMgdXNlcyB0aGUgc2VsZW5pdW0td2ViZHJpdmVyIDMuNCBwYWNrYWdlLgovLyBEb2NzOiBodHRwczovL3NlbGVuaXVtaHEuZ2l0aHViLmlvL3NlbGVuaXVtL2RvY3MvYXBpL2phdmFzY3JpcHQvbW9kdWxlL3NlbGVuaXVtLXdlYmRyaXZlci9pbmRleC5odG1sCi8vICRicm93c2VyID0gd2ViZHJpdmVyIHNlc3Npb24KLy8gJGRyaXZlciA9IGRyaXZlciBsaWJyYXJpZXMKLy8gY29uc29sZS5sb2cgd2lsbCBvdXRwdXQgdG8gQVdTIExhbWJkYSBsb2dzICh2aWEgQ2xvdWR3YXRjaCkKCmNvbnNvbGUubG9nKCdBYm91dCB0byB2aXNpdCBnb29nbGUuY29tLi4uJyk7CiRicm93c2VyLmdldCgnaHR0cDovL3d3dy5nb29nbGUuY29tL25jcicpOwokYnJvd3Nlci5maW5kRWxlbWVudCgkZHJpdmVyLkJ5Lm5hbWUoJ2J0bksnKSkuY2xpY2soKTsKJGJyb3dzZXIud2FpdCgkZHJpdmVyLnVudGlsLnRpdGxlSXMoJ0dvb2dsZScpLCAxMDAwKTsKJGJyb3dzZXIuZ2V0VGl0bGUoKS50aGVuKGZ1bmN0aW9uKHRpdGxlKSB7CiAgICBjb25zb2xlLmxvZygidGl0bGUgaXM6ICIgKyB0aXRsZSk7CiAgICBjb25zb2xlLmxvZygnRmluaXNoZWQgcnVubmluZyBzY3JpcHQhJyk7Cn0pOwoNCi0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tNzY4MjNiZWM3NTI2NDBkNC0tDQo=",
3+
"resource": "/{proxy+}",
4+
"requestContext": {
5+
"resourceId": "123456",
6+
"apiId": "1234567890",
7+
"resourcePath": "/{proxy+}",
8+
"httpMethod": "POST",
9+
"requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef",
10+
"accountId": "123456789012",
11+
"identity": {
12+
"apiKey": null,
13+
"userArn": null,
14+
"cognitoAuthenticationType": null,
15+
"caller": null,
16+
"userAgent": "Custom User Agent String",
17+
"user": null,
18+
"cognitoIdentityPoolId": null,
19+
"cognitoIdentityId": null,
20+
"cognitoAuthenticationProvider": null,
21+
"sourceIp": "127.0.0.1",
22+
"accountId": null
23+
},
24+
"stage": "prod"
25+
},
26+
"queryStringParameters": {
27+
"foo": "bar"
28+
},
29+
"headers": {
30+
"Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)",
31+
"Accept-Language": "en-US,en;q=0.8",
32+
"CloudFront-Is-Desktop-Viewer": "true",
33+
"CloudFront-Is-SmartTV-Viewer": "false",
34+
"CloudFront-Is-Mobile-Viewer": "false",
35+
"X-Forwarded-For": "127.0.0.1, 127.0.0.2",
36+
"CloudFront-Viewer-Country": "US",
37+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
38+
"Upgrade-Insecure-Requests": "1",
39+
"X-Forwarded-Port": "443",
40+
"Host": "1234567890.execute-api.us-east-1.amazonaws.com",
41+
"X-Forwarded-Proto": "https",
42+
"X-Amz-Cf-Id": "aaaaaaaaaae3VYQb9jd-nvCd-de396Uhbp027Y2JvkCPNLmGJHqlaA==",
43+
"CloudFront-Is-Tablet-Viewer": "false",
44+
"Cache-Control": "max-age=0",
45+
"Content-Type": "multipart/form-data; boundary=------------------------76823bec752640d4",
46+
"User-Agent": "Custom User Agent String",
47+
"CloudFront-Forwarded-Proto": "https",
48+
"Accept-Encoding": "gzip, deflate, sdch"
49+
},
50+
"pathParameters": {
51+
"proxy": "/examplepath"
52+
},
53+
"httpMethod": "POST",
54+
"stageVariables": {
55+
"baz": "qux"
56+
},
57+
"isBase64Encoded": true,
58+
"path": "/examplepath"
59+
}

examples/apps/api-gateway.yaml

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
AWSTemplateFormatVersion : '2010-09-09'
2+
Transform: AWS::Serverless-2016-10-31
3+
Description: selenium with headless chromium
4+
Resources:
5+
Lambdium:
6+
Type: AWS::Serverless::Function
7+
Properties:
8+
Handler: index.postApiGatewayHandler
9+
Runtime: nodejs6.10
10+
FunctionName: lambdium
11+
Description: headless chromium running selenium
12+
# This needs to be fairly large: chromium needs a lot of memory
13+
MemorySize: 1156
14+
Timeout: 20
15+
Environment:
16+
Variables:
17+
CLEAR_TMP: "true"
18+
# packaged lambdium archive @ v0.2
19+
CodeUri: <<replace>>
20+
Events:
21+
RunScript:
22+
Properties:
23+
Method: POST
24+
Path: '/runScript'
25+
RestApiId: !Ref Api
26+
Type: Api
27+
Api:
28+
Type: AWS::Serverless::Api
29+
Properties:
30+
Name: RunScriptAPI
31+
StageName: Prod
32+
DefinitionBody:
33+
swagger: "2.0"
34+
schemes:
35+
- "https"
36+
paths:
37+
'/runScript':
38+
post:
39+
responses: {}
40+
x-amazon-apigateway-integration:
41+
uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${Lambdium.Arn}/invocations
42+
passthroughBehavior: "when_no_match"
43+
httpMethod: "POST"
44+
type: aws_proxy
45+
x-amazon-apigateway-binary-media-types:
46+
- "*/*"

index.js

+19-48
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
const webdriver = require('selenium-webdriver');
2-
const child = require('child_process');
3-
const fs = require('fs');
41

52
const chromium = require('./lib/chromium');
63
const sandbox = require('./lib/sandbox');
74
const log = require('lambda-log');
5+
const apiHandler = require('./lib/api-handler');
6+
7+
if (process.env.DEBUG_ENV || process.env.SAM_LOCAL) {
8+
log.config.debug = true;
9+
log.config.dev = true;
10+
}
811

912
log.info('Loading function');
1013

@@ -13,53 +16,21 @@ if (!process.env.CLEAN_SESSIONS) {
1316
$browser = chromium.createSession();
1417
}
1518

16-
const parseScriptInput = (event) => {
17-
const inputParam = event.Base64Script || process.env.BASE64_SCRIPT;
18-
if (typeof inputParam !== 'string') {
19-
return null
20-
}
21-
22-
return Buffer.from(inputParam, 'base64').toString('utf8');
23-
}
24-
19+
// Handler for POST events from API gateway
20+
// curl -v -F "script=@examples/visitgoogle.js" <<API Gateway URL>>
21+
exports.postApiGatewayHandler = apiHandler;
22+
23+
// Default function event handler
24+
// Accepts events:
25+
// * {"Base64Script": "<<encoded selenium script>>"}
26+
// * {"pageUrl": "<<URI to visit>>"}
27+
// Accepts environment variables:
28+
// * BASE64_SCRIPT: encoded selenium script
29+
// * PAGE_URL: URI to visit
2530
exports.handler = (event, context, callback) => {
26-
context.callbackWaitsForEmptyEventLoop = false;
27-
28-
if (process.env.CLEAN_SESSIONS) {
29-
log.info('attempting to clear /tmp directory')
30-
log.info(child.execSync('rm -rf /tmp/core*').toString());
31-
}
32-
33-
if (process.env.DEBUG_ENV || process.env.SAM_LOCAL) {
34-
log.config.debug = true;
35-
log.config.dev = true;
36-
}
37-
38-
if (process.env.LOG_DEBUG) {
39-
log.debug(child.execSync('pwd').toString());
40-
log.debug(child.execSync('ls -lhtra .').toString());
41-
log.debug(child.execSync('ls -lhtra /tmp').toString());
42-
}
43-
44-
log.info(`Received event: ${JSON.stringify(event, null, 2)}`);
45-
46-
// Creates a new session on each event (instead of reusing for performance benefits)
47-
if (process.env.CLEAN_SESSIONS) {
48-
$browser = chromium.createSession();
49-
}
50-
51-
var opts = {
52-
browser: $browser,
53-
driver: webdriver
54-
};
31+
$browser = sandbox.initBrowser(event, context);
5532

56-
// Determine script to run: either a 1) base64-encoded selenium script or 2) a URL to visit
57-
var inputBuffer = parseScriptInput(event);
58-
if (inputBuffer !== null) {
59-
opts.scriptText = inputBuffer;
60-
} else if (event.pageUrl || process.env.PAGE_URL) {
61-
opts.pageUrl = event.pageUrl || process.env.PAGE_URL;
62-
}
33+
var opts = sandbox.buildOptions(event, $browser);
6334

6435
sandbox.executeScript(opts, function(err) {
6536
if (process.env.LOG_DEBUG) {

lib/api-handler.js

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
const busboy = require('busboy');
2+
const path = require('path');
3+
const os = require('os');
4+
5+
const sandbox = require('./sandbox');
6+
7+
module.exports = function(event, context, callback) {
8+
$browser = sandbox.initBrowser(event, context);
9+
var errorMessage = '';
10+
const response = {
11+
statusCode: 200,
12+
headers: {
13+
"Content-Type": 'application/text',
14+
"X-Error": errorMessage || null
15+
},
16+
body: '',
17+
isBase64Encoded: false
18+
};
19+
var body = event.body;
20+
if (event.isBase64Encoded) {
21+
body = Buffer.from(event.body, 'base64').toString('utf8');
22+
}
23+
var scriptFile = new Buffer(0)
24+
25+
26+
const SCRIPT_FIELDNAME = 'script';
27+
28+
var contentType = event.headers['Content-Type'] || event.headers['content-type'];
29+
var bb = new busboy({ headers: { 'content-type': contentType }});
30+
var result = {};
31+
bb.on('file', function (fieldname, file, filename, encoding, mimetype) {
32+
file.on('data', data => {
33+
result.file = data;
34+
});
35+
36+
file.on('end', () => {
37+
result.filename = filename;
38+
result.contentType = mimetype;
39+
});
40+
})
41+
.on('finish', () => {
42+
43+
// Execute uploaded script
44+
var scriptText = result.file.toString();
45+
var opts = sandbox.buildOptions(event, $browser);
46+
opts.scriptText = scriptText;
47+
48+
sandbox.executeScript(opts, function(err, output) {
49+
if (err) {
50+
response.headers['X-Error'] = err;
51+
response.body = err;
52+
response.statusCode = 500;
53+
return callback(null, response);
54+
}
55+
response.body = output;
56+
callback(null, response);
57+
});
58+
})
59+
.on('error', err => {
60+
response.headers['X-Error'] = err;
61+
response.body = err;
62+
response.statusCode = 500;
63+
callback(null, response);
64+
});
65+
66+
bb.end(body);
67+
};

lib/sandbox.js

+42-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,47 @@
11
const vm = require('vm');
22
const log = require('lambda-log');
3+
const chromium = require('./chromium');
4+
const webdriver = require('selenium-webdriver');
5+
6+
exports.initBrowser = function(event, context) {
7+
context.callbackWaitsForEmptyEventLoop = false;
8+
9+
if (process.env.CLEAN_SESSIONS) {
10+
log.info('attempting to clear /tmp directory')
11+
log.info(child.execSync('rm -rf /tmp/core*').toString());
12+
}
13+
14+
log.info(`Received event: ${JSON.stringify(event, null, 2)}`);
15+
16+
// Creates a new session on each event (instead of reusing for performance benefits)
17+
if (process.env.CLEAN_SESSIONS) {
18+
$browser = chromium.createSession();
19+
}
20+
return $browser;
21+
};
22+
23+
exports.buildOptions = (event, browser) => {
24+
var opts = opts = {
25+
browser: $browser,
26+
driver: webdriver
27+
};
28+
29+
const inputParam = event.Base64Script || process.env.BASE64_SCRIPT;
30+
if (typeof inputParam !== 'string') {
31+
opts.pageUrl = event.pageUrl || process.env.PAGE_URL;
32+
return opts;
33+
}
34+
35+
var inputBuffer = Buffer.from(inputParam, 'base64').toString('utf8');
36+
opts.scriptText = inputBuffer;
37+
38+
return opts;
39+
};
340

441
exports.executeScript = function(opts = {}, cb) {
542
const browser = opts.browser;
643
const driver = opts.driver;
44+
var output = '';
745
var scriptText = opts.scriptText;
846

947
// Just visit a web page if a script isn't specified
@@ -20,6 +58,7 @@ exports.executeScript = function(opts = {}, cb) {
2058
log: function(){
2159
var args = Array.prototype.slice.call(arguments);
2260
args.unshift('[lambdium-selenium]');
61+
output = `${output}\n${args.join(' ')}`;
2362
console.log.apply(console, args);
2463
}
2564
};
@@ -46,15 +85,15 @@ exports.executeScript = function(opts = {}, cb) {
4685
// Reuse existing session, likely some edge cases around this...
4786
if (process.env.CLEAN_SESSIONS) {
4887
browser.quit().then(function() {
49-
cb(null);
88+
cb(null, output);
5089
});
5190
} else {
5291
browser.manage().deleteAllCookies().then(function() {
5392
return browser.get('about:blank').then(function() {
54-
cb(null);
93+
cb(null, output);
5594
});
5695
}).catch(function(err) {
57-
cb(err);
96+
cb(err, output);
5897
});
5998
}
6099
}

0 commit comments

Comments
 (0)