From 1337fb424ec583a4001848e0a3a3b4b1989feeeb Mon Sep 17 00:00:00 2001 From: h-kishi Date: Mon, 13 Jun 2022 14:48:45 +0900 Subject: [PATCH] feat: Support signed request to Elasticsearch service (#151) --- README.md | 51 +++++----- .../data-loaders/ElasticDataLoader.js | 73 +++++++++++++++ src/data-loaders/ElasticDataLoader.js | 93 +++++++++++++++++-- src/getAppSyncConfig.js | 5 + src/index.js | 1 + 5 files changed, 189 insertions(+), 34 deletions(-) create mode 100644 src/__tests__/data-loaders/ElasticDataLoader.js diff --git a/README.md b/README.md index 0fe0314..2a28bda 100644 --- a/README.md +++ b/README.md @@ -54,29 +54,32 @@ Serverless: GraphiQl: http://localhost:20002 Put options under `custom.appsync-simulator` in your `serverless.yml` file | option | default | description | -| ------------------------ | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| apiKey | `0123456789` | When using `API_KEY` as authentication type, the key to authenticate to the endpoint. | -| 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.) | -| 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.) | -| location | . (base directory) | Location of the lambda functions handlers. | -| refMap | {} | A mapping of [resource resolutions](#resource-cloudformation-functions-resolution) for the `Ref` function | -| getAttMap | {} | A mapping of [resource resolutions](#resource-cloudformation-functions-resolution) for the `GetAtt` function | -| importValueMap | {} | A mapping of [resource resolutions](#resource-cloudformation-functions-resolution) for the `ImportValue` function | -| functions | {} | A mapping of [external functions](#functions) for providing invoke url for external fucntions | -| 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 | -| dynamoDb.region | localhost | Dynamodb region. Specify it if you're connecting to a remote Dynamodb intance. | -| dynamoDb.accessKeyId | DEFAULT_ACCESS_KEY | AWS Access Key ID to access DynamoDB | -| dynamoDb.secretAccessKey | DEFAULT_SECRET | AWS Secret Key to access DynamoDB | -| dynamoDb.sessionToken | DEFAULT_ACCESS_TOKEEN | AWS Session Token to access DynamoDB, only if you have temporary security credentials configured on AWS | -| dynamoDb.\* | | You can add every configuration accepted by [DynamoDB SDK](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB.html#constructor-property) | -| rds.dbName | | Name of the database | -| rds.dbHost | | Database host | -| rds.dbDialect | | Database dialect. Possible values (mysql/postgres) | -| rds.dbUsername | | Database username | -| rds.dbPassword | | Database password | -| rds.dbPort | | Database port | -| watch | - \*.graphql
- \*.vtl | Array of glob patterns to watch for hot-reloading. | - +| -------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| apiKey | `0123456789` | When using `API_KEY` as authentication type, the key to authenticate to the endpoint. | +| 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.) | +| 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.) | +| location | . (base directory) | Location of the lambda functions handlers. | +| refMap | {} | A mapping of [resource resolutions](#resource-cloudformation-functions-resolution) for the `Ref` function | +| getAttMap | {} | A mapping of [resource resolutions](#resource-cloudformation-functions-resolution) for the `GetAtt` function | +| importValueMap | {} | A mapping of [resource resolutions](#resource-cloudformation-functions-resolution) for the `ImportValue` function | +| functions | {} | A mapping of [external functions](#functions) for providing invoke url for external fucntions | +| 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 | +| dynamoDb.region | localhost | Dynamodb region. Specify it if you're connecting to a remote Dynamodb intance. | +| dynamoDb.accessKeyId | DEFAULT_ACCESS_KEY | AWS Access Key ID to access DynamoDB | +| dynamoDb.secretAccessKey | DEFAULT_SECRET | AWS Secret Key to access DynamoDB | +| dynamoDb.sessionToken | DEFAULT_ACCESS_TOKEEN | AWS Session Token to access DynamoDB, only if you have temporary security credentials configured on AWS | +| dynamoDb.\* | | You can add every configuration accepted by [DynamoDB SDK](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB.html#constructor-property) | +| rds.dbName | | Name of the database | +| rds.dbHost | | Database host | +| rds.dbDialect | | Database dialect. Possible values (mysql/postgres) | +| rds.dbUsername | | Database username | +| rds.dbPassword | | Database password | +| rds.dbPort | | Database port | +| openSearch.useSignature | false | Enable signing requests to OpenSearch. The preference for credentials is config > environment variables > local credential file. | +| openSearch.region | | OpenSearch region. Specify it if you're connecting to a remote OpenSearch intance. | +| openSearch.accessKeyId | | AWS Access Key ID to access OpenSearch | +| openSearch.secretAccessKey | | AWS Secret Key to access OpenSearch | +| watch | - \*.graphql
- \*.vtl | Array of glob patterns to watch for hot-reloading. | Example: @@ -257,7 +260,7 @@ This plugin supports resolvers implemented by `amplify-appsync-simulator`, as we **Implemented by this plugin** -- AMAZON_ELASTIC_SEARCH +- AMAZON_ELASTICSEARCH - HTTP - RELATIONAL_DATABASE diff --git a/src/__tests__/data-loaders/ElasticDataLoader.js b/src/__tests__/data-loaders/ElasticDataLoader.js new file mode 100644 index 0000000..16dceaf --- /dev/null +++ b/src/__tests__/data-loaders/ElasticDataLoader.js @@ -0,0 +1,73 @@ +import { PassThrough } from 'stream'; +import * as AWS from 'aws-sdk'; +import axios from 'axios'; +import ElasticDataLoader from '../../data-loaders/ElasticDataLoader'; + +describe('data-loaders/ElasticDataLoader', () => { + beforeEach(() => { + jest.spyOn(AWS.HttpClient.prototype, 'handleRequest'); + jest.spyOn(axios, 'request'); + }); + + afterEach(() => { + AWS.HttpClient.prototype.handleRequest.mockClear(); + axios.request.mockClear(); + }); + + it('should send a request', async () => { + const loader = new ElasticDataLoader({ + endpoint: 'https://my-elasticsearch-cluster.region.amazonaws.com', + }); + axios.request.mockImplementation(async () => { + return { data: { hits: {} } }; + }); + const req = { + path: '[index]/_search', + operation: 'GET', + params: { + headers: {}, + body: '{"query": { "match_all": {} }}', + }, + }; + const data = await loader.load(req); + expect(data).toEqual({ hits: {} }); + }); + + it('should send a signed request', async () => { + const loader = new ElasticDataLoader({ + endpoint: 'https://my-elasticsearch-cluster.region.amazonaws.com', + useSignature: true, + accessKeyId: 'fakeAccessKeyId', + secretAccessKey: 'fakeSecretAccessKey', + region: '', + }); + const mockStream = new PassThrough(); + let signedRequest; + AWS.HttpClient.prototype.handleRequest.mockImplementation( + (request, _options, callback) => { + signedRequest = request; + callback(mockStream); + }, + ); + const body = '{"query": { "match_all": {} }}'; + const req = { + path: '[index]/_search', + operation: 'GET', + params: { + headers: {}, + body, + }, + }; + process.nextTick(() => { + mockStream.emit('data', '{ "hits": {} }'); + mockStream.end(); + }); + const data = await loader.load(req); + expect(signedRequest.headers.host).toEqual( + 'my-elasticsearch-cluster.region.amazonaws.com', + ); + expect(signedRequest.headers['Authorization']).toMatch(/^AWS4-HMAC-SHA256/); + expect(signedRequest.body).toEqual(body); + expect(data).toEqual({ hits: {} }); + }); +}); diff --git a/src/data-loaders/ElasticDataLoader.js b/src/data-loaders/ElasticDataLoader.js index 0521544..818ef94 100644 --- a/src/data-loaders/ElasticDataLoader.js +++ b/src/data-loaders/ElasticDataLoader.js @@ -1,4 +1,5 @@ import axios from 'axios'; +import * as AWS from 'aws-sdk'; export default class ElasticDataLoader { constructor(config) { @@ -7,20 +8,92 @@ export default class ElasticDataLoader { async load(req) { try { - const { data } = await axios.request({ - baseURL: this.config.endpoint, - url: req.path, - headers: req.params.headers, - params: req.params.queryString, - method: req.operation.toLowerCase(), - data: req.params.body, - }); - - return data; + if (this.config.useSignature) { + const signedRequest = await this.createSignedRequest(req); + const client = new AWS.HttpClient(); + const data = await new Promise((resolve, reject) => { + client.handleRequest( + signedRequest, + null, + (response) => { + let responseBody = ''; + response.on('data', (chunk) => { + responseBody += chunk; + }); + response.on('end', () => { + resolve(responseBody); + }); + }, + (err) => { + reject(err); + }, + ); + }); + return JSON.parse(data); + } else { + const { data } = await axios.request({ + baseURL: this.config.endpoint, + url: req.path, + headers: req.params.headers, + params: req.params.queryString, + method: req.operation.toLowerCase(), + data: req.params.body, + }); + + return data; + } } catch (err) { console.log(err); } return null; } + + async createSignedRequest(req) { + const domain = this.config.endpoint.replace('https://', ''); + const headers = { + ...req.params.headers, + host: domain, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(req.params.body), + }; + const endpoint = new AWS.Endpoint(domain); + const httpRequest = new AWS.HttpRequest(endpoint, this.config.region); + httpRequest.headers = headers; + httpRequest.body = req.params.body; + httpRequest.method = req.operation; + httpRequest.path = req.path; + + const credentials = await this.getCredentials(); + const signer = new AWS.Signers.V4(httpRequest, 'es'); + signer.addAuthorization(credentials, new Date()); + + return httpRequest; + } + + async getCredentials() { + const chain = new AWS.CredentialProviderChain([ + () => new AWS.EnvironmentCredentials('AWS'), + () => new AWS.EnvironmentCredentials('AMAZON'), + () => new AWS.SharedIniFileCredentials(), + ]); + if (this.config.accessKeyId && this.config.secretAccessKey) { + chain.providers.unshift( + () => + new AWS.Credentials( + this.config.accessKeyId, + this.config.secretAccessKey, + ), + ); + } + return new Promise((resolve, reject) => + chain.resolve((err, creds) => { + if (err) { + reject(err); + } else { + resolve(creds); + } + }), + ); + } } diff --git a/src/getAppSyncConfig.js b/src/getAppSyncConfig.js index 20e1c47..4dc1b97 100644 --- a/src/getAppSyncConfig.js +++ b/src/getAppSyncConfig.js @@ -186,6 +186,11 @@ export default function getAppSyncConfig(context, appSyncConfig) { }; } case SourceType.AMAZON_ELASTICSEARCH: + return { + ...context.options.openSearch, + ...dataSource, + endpoint: source.config.endpoint, + }; case SourceType.HTTP: { return { ...dataSource, diff --git a/src/index.js b/src/index.js index c59f207..c00227c 100644 --- a/src/index.js +++ b/src/index.js @@ -280,6 +280,7 @@ class ServerlessAppSyncSimulator { accessKeyId: 'DEFAULT_ACCESS_KEY', secretAccessKey: 'DEFAULT_SECRET', }, + openSearch: {}, }, get(this.serverless.service, 'custom.appsync-simulator', {}), );