Skip to content

Commit 1337fb4

Browse files
authored
feat: Support signed request to Elasticsearch service (#151)
1 parent 5324aa5 commit 1337fb4

File tree

5 files changed

+189
-34
lines changed

5 files changed

+189
-34
lines changed

README.md

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -54,29 +54,32 @@ Serverless: GraphiQl: http://localhost:20002
5454
Put options under `custom.appsync-simulator` in your `serverless.yml` file
5555

5656
| option | default | description |
57-
| ------------------------ | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
58-
| apiKey | `0123456789` | When using `API_KEY` as authentication type, the key to authenticate to the endpoint. |
59-
| port | 20002 | AppSync operations port; if using multiple APIs, the value of this option will be used as a starting point, and each other API will have a port of lastPort + 10 (e.g. 20002, 20012, 20022, etc.) |
60-
| wsPort | 20003 | AppSync subscriptions port; if using multiple APIs, the value of this option will be used as a starting point, and each other API will have a port of lastPort + 10 (e.g. 20003, 20013, 20023, etc.) |
61-
| location | . (base directory) | Location of the lambda functions handlers. |
62-
| refMap | {} | A mapping of [resource resolutions](#resource-cloudformation-functions-resolution) for the `Ref` function |
63-
| getAttMap | {} | A mapping of [resource resolutions](#resource-cloudformation-functions-resolution) for the `GetAtt` function |
64-
| importValueMap | {} | A mapping of [resource resolutions](#resource-cloudformation-functions-resolution) for the `ImportValue` function |
65-
| functions | {} | A mapping of [external functions](#functions) for providing invoke url for external fucntions |
66-
| dynamoDb.endpoint | http://localhost:8000 | Dynamodb endpoint. Specify it if you're not using serverless-dynamodb-local. Otherwise, port is taken from dynamodb-local conf |
67-
| dynamoDb.region | localhost | Dynamodb region. Specify it if you're connecting to a remote Dynamodb intance. |
68-
| dynamoDb.accessKeyId | DEFAULT_ACCESS_KEY | AWS Access Key ID to access DynamoDB |
69-
| dynamoDb.secretAccessKey | DEFAULT_SECRET | AWS Secret Key to access DynamoDB |
70-
| dynamoDb.sessionToken | DEFAULT_ACCESS_TOKEEN | AWS Session Token to access DynamoDB, only if you have temporary security credentials configured on AWS |
71-
| dynamoDb.\* | | You can add every configuration accepted by [DynamoDB SDK](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB.html#constructor-property) |
72-
| rds.dbName | | Name of the database |
73-
| rds.dbHost | | Database host |
74-
| rds.dbDialect | | Database dialect. Possible values (mysql/postgres) |
75-
| rds.dbUsername | | Database username |
76-
| rds.dbPassword | | Database password |
77-
| rds.dbPort | | Database port |
78-
| watch | - \*.graphql<br/> - \*.vtl | Array of glob patterns to watch for hot-reloading. |
79-
57+
| -------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
58+
| apiKey | `0123456789` | When using `API_KEY` as authentication type, the key to authenticate to the endpoint. |
59+
| port | 20002 | AppSync operations port; if using multiple APIs, the value of this option will be used as a starting point, and each other API will have a port of lastPort + 10 (e.g. 20002, 20012, 20022, etc.) |
60+
| wsPort | 20003 | AppSync subscriptions port; if using multiple APIs, the value of this option will be used as a starting point, and each other API will have a port of lastPort + 10 (e.g. 20003, 20013, 20023, etc.) |
61+
| location | . (base directory) | Location of the lambda functions handlers. |
62+
| refMap | {} | A mapping of [resource resolutions](#resource-cloudformation-functions-resolution) for the `Ref` function |
63+
| getAttMap | {} | A mapping of [resource resolutions](#resource-cloudformation-functions-resolution) for the `GetAtt` function |
64+
| importValueMap | {} | A mapping of [resource resolutions](#resource-cloudformation-functions-resolution) for the `ImportValue` function |
65+
| functions | {} | A mapping of [external functions](#functions) for providing invoke url for external fucntions |
66+
| dynamoDb.endpoint | http://localhost:8000 | Dynamodb endpoint. Specify it if you're not using serverless-dynamodb-local. Otherwise, port is taken from dynamodb-local conf |
67+
| dynamoDb.region | localhost | Dynamodb region. Specify it if you're connecting to a remote Dynamodb intance. |
68+
| dynamoDb.accessKeyId | DEFAULT_ACCESS_KEY | AWS Access Key ID to access DynamoDB |
69+
| dynamoDb.secretAccessKey | DEFAULT_SECRET | AWS Secret Key to access DynamoDB |
70+
| dynamoDb.sessionToken | DEFAULT_ACCESS_TOKEEN | AWS Session Token to access DynamoDB, only if you have temporary security credentials configured on AWS |
71+
| dynamoDb.\* | | You can add every configuration accepted by [DynamoDB SDK](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB.html#constructor-property) |
72+
| rds.dbName | | Name of the database |
73+
| rds.dbHost | | Database host |
74+
| rds.dbDialect | | Database dialect. Possible values (mysql/postgres) |
75+
| rds.dbUsername | | Database username |
76+
| rds.dbPassword | | Database password |
77+
| rds.dbPort | | Database port |
78+
| openSearch.useSignature | false | Enable signing requests to OpenSearch. The preference for credentials is config > environment variables > local credential file. |
79+
| openSearch.region | | OpenSearch region. Specify it if you're connecting to a remote OpenSearch intance. |
80+
| openSearch.accessKeyId | | AWS Access Key ID to access OpenSearch |
81+
| openSearch.secretAccessKey | | AWS Secret Key to access OpenSearch |
82+
| watch | - \*.graphql<br/> - \*.vtl | Array of glob patterns to watch for hot-reloading. |
8083

8184
Example:
8285

@@ -257,7 +260,7 @@ This plugin supports resolvers implemented by `amplify-appsync-simulator`, as we
257260

258261
**Implemented by this plugin**
259262

260-
- AMAZON_ELASTIC_SEARCH
263+
- AMAZON_ELASTICSEARCH
261264
- HTTP
262265
- RELATIONAL_DATABASE
263266

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { PassThrough } from 'stream';
2+
import * as AWS from 'aws-sdk';
3+
import axios from 'axios';
4+
import ElasticDataLoader from '../../data-loaders/ElasticDataLoader';
5+
6+
describe('data-loaders/ElasticDataLoader', () => {
7+
beforeEach(() => {
8+
jest.spyOn(AWS.HttpClient.prototype, 'handleRequest');
9+
jest.spyOn(axios, 'request');
10+
});
11+
12+
afterEach(() => {
13+
AWS.HttpClient.prototype.handleRequest.mockClear();
14+
axios.request.mockClear();
15+
});
16+
17+
it('should send a request', async () => {
18+
const loader = new ElasticDataLoader({
19+
endpoint: 'https://my-elasticsearch-cluster.region.amazonaws.com',
20+
});
21+
axios.request.mockImplementation(async () => {
22+
return { data: { hits: {} } };
23+
});
24+
const req = {
25+
path: '[index]/_search',
26+
operation: 'GET',
27+
params: {
28+
headers: {},
29+
body: '{"query": { "match_all": {} }}',
30+
},
31+
};
32+
const data = await loader.load(req);
33+
expect(data).toEqual({ hits: {} });
34+
});
35+
36+
it('should send a signed request', async () => {
37+
const loader = new ElasticDataLoader({
38+
endpoint: 'https://my-elasticsearch-cluster.region.amazonaws.com',
39+
useSignature: true,
40+
accessKeyId: 'fakeAccessKeyId',
41+
secretAccessKey: 'fakeSecretAccessKey',
42+
region: '',
43+
});
44+
const mockStream = new PassThrough();
45+
let signedRequest;
46+
AWS.HttpClient.prototype.handleRequest.mockImplementation(
47+
(request, _options, callback) => {
48+
signedRequest = request;
49+
callback(mockStream);
50+
},
51+
);
52+
const body = '{"query": { "match_all": {} }}';
53+
const req = {
54+
path: '[index]/_search',
55+
operation: 'GET',
56+
params: {
57+
headers: {},
58+
body,
59+
},
60+
};
61+
process.nextTick(() => {
62+
mockStream.emit('data', '{ "hits": {} }');
63+
mockStream.end();
64+
});
65+
const data = await loader.load(req);
66+
expect(signedRequest.headers.host).toEqual(
67+
'my-elasticsearch-cluster.region.amazonaws.com',
68+
);
69+
expect(signedRequest.headers['Authorization']).toMatch(/^AWS4-HMAC-SHA256/);
70+
expect(signedRequest.body).toEqual(body);
71+
expect(data).toEqual({ hits: {} });
72+
});
73+
});

src/data-loaders/ElasticDataLoader.js

Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import axios from 'axios';
2+
import * as AWS from 'aws-sdk';
23

34
export default class ElasticDataLoader {
45
constructor(config) {
@@ -7,20 +8,92 @@ export default class ElasticDataLoader {
78

89
async load(req) {
910
try {
10-
const { data } = await axios.request({
11-
baseURL: this.config.endpoint,
12-
url: req.path,
13-
headers: req.params.headers,
14-
params: req.params.queryString,
15-
method: req.operation.toLowerCase(),
16-
data: req.params.body,
17-
});
18-
19-
return data;
11+
if (this.config.useSignature) {
12+
const signedRequest = await this.createSignedRequest(req);
13+
const client = new AWS.HttpClient();
14+
const data = await new Promise((resolve, reject) => {
15+
client.handleRequest(
16+
signedRequest,
17+
null,
18+
(response) => {
19+
let responseBody = '';
20+
response.on('data', (chunk) => {
21+
responseBody += chunk;
22+
});
23+
response.on('end', () => {
24+
resolve(responseBody);
25+
});
26+
},
27+
(err) => {
28+
reject(err);
29+
},
30+
);
31+
});
32+
return JSON.parse(data);
33+
} else {
34+
const { data } = await axios.request({
35+
baseURL: this.config.endpoint,
36+
url: req.path,
37+
headers: req.params.headers,
38+
params: req.params.queryString,
39+
method: req.operation.toLowerCase(),
40+
data: req.params.body,
41+
});
42+
43+
return data;
44+
}
2045
} catch (err) {
2146
console.log(err);
2247
}
2348

2449
return null;
2550
}
51+
52+
async createSignedRequest(req) {
53+
const domain = this.config.endpoint.replace('https://', '');
54+
const headers = {
55+
...req.params.headers,
56+
host: domain,
57+
'Content-Type': 'application/json',
58+
'Content-Length': Buffer.byteLength(req.params.body),
59+
};
60+
const endpoint = new AWS.Endpoint(domain);
61+
const httpRequest = new AWS.HttpRequest(endpoint, this.config.region);
62+
httpRequest.headers = headers;
63+
httpRequest.body = req.params.body;
64+
httpRequest.method = req.operation;
65+
httpRequest.path = req.path;
66+
67+
const credentials = await this.getCredentials();
68+
const signer = new AWS.Signers.V4(httpRequest, 'es');
69+
signer.addAuthorization(credentials, new Date());
70+
71+
return httpRequest;
72+
}
73+
74+
async getCredentials() {
75+
const chain = new AWS.CredentialProviderChain([
76+
() => new AWS.EnvironmentCredentials('AWS'),
77+
() => new AWS.EnvironmentCredentials('AMAZON'),
78+
() => new AWS.SharedIniFileCredentials(),
79+
]);
80+
if (this.config.accessKeyId && this.config.secretAccessKey) {
81+
chain.providers.unshift(
82+
() =>
83+
new AWS.Credentials(
84+
this.config.accessKeyId,
85+
this.config.secretAccessKey,
86+
),
87+
);
88+
}
89+
return new Promise((resolve, reject) =>
90+
chain.resolve((err, creds) => {
91+
if (err) {
92+
reject(err);
93+
} else {
94+
resolve(creds);
95+
}
96+
}),
97+
);
98+
}
2699
}

src/getAppSyncConfig.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,11 @@ export default function getAppSyncConfig(context, appSyncConfig) {
186186
};
187187
}
188188
case SourceType.AMAZON_ELASTICSEARCH:
189+
return {
190+
...context.options.openSearch,
191+
...dataSource,
192+
endpoint: source.config.endpoint,
193+
};
189194
case SourceType.HTTP: {
190195
return {
191196
...dataSource,

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ class ServerlessAppSyncSimulator {
280280
accessKeyId: 'DEFAULT_ACCESS_KEY',
281281
secretAccessKey: 'DEFAULT_SECRET',
282282
},
283+
openSearch: {},
283284
},
284285
get(this.serverless.service, 'custom.appsync-simulator', {}),
285286
);

0 commit comments

Comments
 (0)