Skip to content

Commit

Permalink
Refactor response() to include statusCode, headers, and payload
Browse files Browse the repository at this point in the history
Include response headers in error output (where available)
Add plugin helper utils section to readme
  • Loading branch information
ryanblock committed Sep 30, 2023
1 parent ebe5a0a commit 3c9dcf2
Show file tree
Hide file tree
Showing 5 changed files with 98 additions and 42 deletions.
66 changes: 55 additions & 11 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- [`request()`](#request)
- [`response()`](#response)
- [`error()`](#error)
- [Plugin utils](#plugin-utils)
- [List of official `@aws-lite/*` plugins](#list-of-official-aws-lite-plugins)
- [Contributing](#contributing)
- [Setup](#setup)
Expand Down Expand Up @@ -250,6 +251,8 @@ The above four lifecycle hooks must be exported as an object named `methods`, al
// A simple plugin for validating input
export default {
service: 'dynamodb',
awsDoc: 'https://docs.aws.../API_PutItem.html',
readme: 'https://github...#PutItem',
methods: {
PutItem: {
validate: {
Expand All @@ -262,7 +265,7 @@ export default {
aws.dynamodb.PutItem({ TableName: 12345 }) // Throws validation error
```

Additionally, two optional metadata properties may be added that will be included in any method errors:
Additionally, two optional (but highly recommended) metadata properties may be added that will be included in any method errors:
- `awsDoc` (string) [optional] - intended to be a link to the AWS API doc pertaining to this method; should usually start with `https://docs.aws.amazon.com/...`
- `readme` (string) [optional] - a link to a relevant section in your plugin's readme or docs

Expand Down Expand Up @@ -310,7 +313,7 @@ The `request()` lifecycle hook is an optional async function that enables that e
- **`params` (object)**
- The method's input parameters
- **`utils` (object)**
- Helper utilities for (de)serializing AWS-flavored JSON: `awsjsonMarshall`, `awsjsonUnmarshall`
- [Plugin helper utilities](#plugin-utils)

The `request()` method may return nothing, or a [valid client request](#client-requests). An example:

Expand Down Expand Up @@ -340,22 +343,28 @@ The `response()` lifecycle hook is an async function that enables mutation of se

`response()` is executed with two positional arguments:

- **`response` (any)**
- Raw non-error response from AWS service API request; if the entire payload is JSON or AWS-flavored JSON, `aws-lite` will attempt to parse it prior to executing `response()`. Responses that are primarily JSON, but with nested AWS-flavored JSON, will be parsed only as JSON and may require additional deserialization with the `awsjsonUnmarshall` utility
- **`params` (object)**
- An object containing three properties from the API response:
- **`statusCode` (number)**
- HTTP response status code
- **`headers` (object)**
- HTTP response headers
- **`payload` (object or string)**
- Raw non-error response from AWS service API request; if the entire payload is JSON or AWS-flavored JSON, `aws-lite` will attempt to parse it prior to executing `response()`. Responses that are primarily JSON, but with nested AWS-flavored JSON, will be parsed only as JSON and may require additional deserialization with the `awsjsonUnmarshall` utility
- **`utils` (object)**
- Helper utilities for (de)serializing AWS-flavored JSON: `awsjsonMarshall`, `awsjsonUnmarshall`
- [Plugin helper utilities](#plugin-utils)

The `response()` method may return nothing, but if it does return a mutated response, it must come in the form of an object containing a `response` property, and an optional `awsjson` property (that behaves the same as in [client requests](#client-requests)). An example:
The `response()` method may return nothing, or it may return an object containing the following optional properties: `statusCode` (number), `headers` (object), `payload` (object or string), and `awsjson` (that behaves the same as in [client requests](#client-requests)). An example:

```js
// Automatically deserialize AWS-flavored JSON
export default {
service: 'dynamodb',
methods: {
GetItem: {
// Successful responses always have an AWS-flavored JSON `Item` property
response: async (response, utils) => {
return { awsjson: [ 'Item' ], response }
// Assume successful responses always have an AWS-flavored JSON `Item` property
response: async (params, utils) => {
return { awsjson: [ 'Item' ], ...params }
}
}
}
Expand All @@ -375,9 +384,9 @@ The `error()` lifecycle hook is an async function that enables mutation of servi
- **`metadata` (object)** - `aws-lite` error metadata; to improve the quality of the errors presented by `aws-lite`, please only append to this object
- **`statusCode` (number or undefined)** - resulting status code of the API response; if an HTTP connection error occurred, no `statusCode` will be present
- **`utils` (object)**
- Helper utilities for (de)serializing AWS-flavored JSON: `awsjsonMarshall`, `awsjsonUnmarshall`
- [Plugin helper utilities](#plugin-utils)

The `error()` method may return nothing, a new or mutated version of the error payload it was passed, a string, an object, or a JS error. An example
The `error()` method may return nothing, a new or mutated version of the error payload it was passed, a string, an object, or a JS error. An example:

```js
// Improve clarity of error output
Expand All @@ -399,6 +408,41 @@ export default {
```
#### Plugin utils
[`request()`](#request), [`response()`](#response), and [`error()`](#error) are all passed a second argument of helper utilities and data pertaining to the client:
- **`awsjsonMarshall` (function)**
- Utility for marshalling data to the format underlying AWS-flavored JSON serialization; accepts a plain object, returns a marshalled object
- **`awsjsonUnmarshall` (function)**
- Utility for unmarshalling data from the format underlying AWS-flavored JSON serialization; accepts a marshalled object, returns a plain object
- **`config` (object)**
- The current [client configuration](#configuration); any configured credentials are found in the `credentials` object
- **`credentials` (object)**
- `accessKeyId`, `secretAccessKey`, and `sessionToken` being used in this request
- Note: `secretAccessKey` and `sessionToken` are present in this object, but non-enumerable
- **`region` (string)**
- Canonical service region being used in this request; this value may differ from the region set in the `config` object if overridden per-request
An example of plugin utils:
```js
async function request (params, utils) {
let awsStyle = utils.awsjsonMarshall({ ok: true, hi: 'there' })
console.log(marshalled) // { ok: { BOOL: true }, hi: { S: 'there' } }

let plain = utils.awsjsonUnmarshall({ ok: { BOOL: true }, hi: { S: 'there' } })
console.log(unmarshalled) // { ok: true, hi: 'there' }

console.log(config) // { profile: 'my-profile', autoloadPlugins: true, ... }

console.log(credentials) // { accessKeyId: 'abc123...' } secrets are non-enumerable

console.log(region) // 'us-west-1'
}
```
### List of official `@aws-lite/*` plugins
<!-- ! Do not remove plugins_start / plugins_end ! -->
Expand Down
33 changes: 19 additions & 14 deletions src/client-factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ let { awsjson } = require('./lib')
let { marshall, unmarshall } = require('./_vendor')
let errorHandler = require('./error')

let credentialProps = [ 'accessKeyId', 'secretAccessKey', 'sessionToken' ]
let copy = obj => JSON.parse(JSON.stringify(obj))

// Never autoload these `@aws-lite/*` packages:
Expand Down Expand Up @@ -85,17 +86,16 @@ module.exports = async function clientFactory (config, creds, region) {
}
})

let configuration = copy(config)
credentialProps.forEach(p => delete configuration[p])
let credentials = copy(creds)
Object.defineProperty(config, 'secretAccessKey', { enumerable: false })
Object.defineProperty(config, 'secretAccessKey', { enumerable: false })
Object.defineProperty(credentials, 'sessionToken', { enumerable: false })
Object.defineProperty(credentials, 'secretAccessKey', { enumerable: false })
Object.defineProperty(credentials, 'sessionToken', { enumerable: false })
let pluginUtils = {
awsjsonMarshall: marshall,
awsjsonUnmarshall: unmarshall,
config: copy(config),
config: configuration,
credentials,
region,
}
let clientMethods = {}
Object.entries(methods).forEach(([ name, method ]) => {
Expand Down Expand Up @@ -126,7 +126,7 @@ module.exports = async function clientFactory (config, creds, region) {

// Run plugin.request()
try {
var req = await method.request(input, pluginUtils)
var req = await method.request(input, { ...pluginUtils, region: selectedRegion })
req = req || {}
}
catch (methodError) {
Expand All @@ -147,25 +147,30 @@ module.exports = async function clientFactory (config, creds, region) {
/* istanbul ignore next */ // TODO remove as soon as plugin.response() API settles
if (method.response) {
try {
var pluginRes = await method.response(response, pluginUtils)
if (pluginRes && pluginRes.response === undefined) {
throw TypeError('Response plugins must return a response property')
}
var pluginRes = await method.response(response, { ...pluginUtils, region: selectedRegion })
}
catch (methodError) {
errorHandler({ error: methodError, metadata })
}
response = pluginRes?.awsjson
? awsjson.unmarshall(pluginRes.response, pluginRes.awsjson)
: pluginRes?.response || response
if (pluginRes) {
let { statusCode, headers, payload } = pluginRes
if (pluginRes.awsjson) {
payload = awsjson.unmarshall(payload, pluginRes.awsjson)
}
response = {
statusCode: statusCode || response.statusCode,
headers: headers || response.headers,
payload: payload || response.payload,
}
}
}
return response
}
catch (err) {
// Run plugin.error()
if (method.error && !(input instanceof Error)) {
try {
let updatedError = await method.error(err, pluginUtils)
let updatedError = await method.error(err, { ...pluginUtils, region: selectedRegion })
errorHandler(updatedError || err)
}
catch (methodError) {
Expand Down
5 changes: 4 additions & 1 deletion src/error.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ module.exports = function errorHandler (input) {
throw input
}

let { error, statusCode, metadata } = input
let { statusCode, headers, error, metadata } = input

// If the error passed is an actual Error, it probably came from a plugin method failing, so we should attempt to retain its beautiful, beautiful stack trace
let err = error instanceof Error ? error : Error()
if (statusCode) {
err.statusCode = statusCode
}
if (headers) {
err.headers = headers
}

// The most common error response from AWS services
if (typeof error === 'object') {
Expand Down
10 changes: 5 additions & 5 deletions src/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,20 +131,20 @@ module.exports = function request (params, creds, region, config, metadata) {
res.on('data', chunk => data.push(chunk))
res.on('end', () => {
// TODO The following string coersion will definitely need be changed when we get into binary response payloads
let result = Buffer.concat(data).toString()
let payload = Buffer.concat(data).toString()
let contentType = headers['content-type'] || headers['Content-Type'] || ''
if (JSONContentType(contentType) || AwsJSONContentType(contentType)) {
result = JSON.parse(result)
payload = JSON.parse(payload)
}
// Some services may attempt to respond with regular JSON, but an AWS JSON content-type. Sure. Ok. Anyway, try to guard against that.
if (AwsJSONContentType(contentType)) {
try {
result = awsjson.unmarshall(result)
payload = awsjson.unmarshall(payload)
}
catch { /* noop, it's already parsed */ }
}
if (ok) resolve(result)
else reject({ error: result, metadata, statusCode })
if (ok) resolve({ statusCode, headers, payload })
else reject({ statusCode, headers, error: payload, metadata })
})
})
req.on('error', error => reject({
Expand Down
26 changes: 15 additions & 11 deletions test/unit/src/index-client-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ test('Set up env', async t => {
})

test('Primary client - core functionality', async t => {
t.plan(28)
t.plan(30)
let request, result, body, query, responseBody, url

let headers = { 'content-type': 'application/json' }
Expand All @@ -27,7 +27,9 @@ test('Primary client - core functionality', async t => {
result = await aws({ service, endpoint })
request = server.getCurrentRequest()
t.notOk(request.body, 'Request included no body')
t.equal(result, '', 'Client returned empty response body as empty string')
t.equal(result.statusCode, 200, 'Client returned status code of response')
t.ok(result.headers, 'Client returned response headers')
t.equal(result.payload, '', 'Client returned empty response body as empty string')
basicRequestChecks(t, 'GET')

// Basic get request with query string params
Expand All @@ -43,7 +45,7 @@ test('Primary client - core functionality', async t => {
result = await aws({ service, endpoint, body })
request = server.getCurrentRequest()
t.deepEqual(request.body, body, 'Request included correct body')
t.deepEqual(result, responseBody, 'Client returned response body as parsed JSON')
t.deepEqual(result.payload, responseBody, 'Client returned response body as parsed JSON')
basicRequestChecks(t, 'POST')

// Basic post with query string params
Expand Down Expand Up @@ -147,7 +149,7 @@ test('Primary client - AWS JSON payloads', async t => {
result = await aws({ service, endpoint, body, headers: headersAwsJSON() })
request = server.getCurrentRequest()
t.deepEqual(request.body, { ok: { BOOL: true } }, 'Request included correct body (raw AWS JSON)')
t.deepEqual(result, expectedResponseBody(), 'Client returned response body as parsed, unmarshalled JSON')
t.deepEqual(result.payload, expectedResponseBody(), 'Client returned response body as parsed, unmarshalled JSON')
basicRequestChecks(t, 'POST')
reset()

Expand All @@ -157,7 +159,7 @@ test('Primary client - AWS JSON payloads', async t => {
result = await aws({ service, endpoint, body, headers: headersAwsJSON() })
request = server.getCurrentRequest()
t.deepEqual(request.body, { ok: { BOOL: false } }, 'Request included correct body (raw AWS JSON)')
t.deepEqual(result, expectedResponseBody(), 'Client returned response body as parsed, unmarshalled JSON')
t.deepEqual(result.payload, expectedResponseBody(), 'Client returned response body as parsed, unmarshalled JSON')
basicRequestChecks(t, 'POST')
reset()

Expand All @@ -167,7 +169,7 @@ test('Primary client - AWS JSON payloads', async t => {
result = await aws({ service, endpoint, body, awsjson: true })
request = server.getCurrentRequest()
t.deepEqual(request.body, { ok: { BOOL: false } }, 'Request included correct body (raw AWS JSON)')
t.deepEqual(result, expectedResponseBody(), 'Client returned response body as parsed, unmarshalled JSON')
t.deepEqual(result.payload, expectedResponseBody(), 'Client returned response body as parsed, unmarshalled JSON')
basicRequestChecks(t, 'POST')
reset()

Expand All @@ -177,7 +179,7 @@ test('Primary client - AWS JSON payloads', async t => {
result = await aws({ service, endpoint, body, awsjson: [ 'fine' ] })
request = server.getCurrentRequest()
t.deepEqual(request.body, { ok: true, fine: { BOOL: false } }, 'Request included correct body (raw AWS JSON)')
t.deepEqual(result, expectedResponseBody(), 'Client returned response body as parsed, unmarshalled JSON')
t.deepEqual(result.payload, expectedResponseBody(), 'Client returned response body as parsed, unmarshalled JSON')
basicRequestChecks(t, 'POST')
reset()

Expand All @@ -186,12 +188,12 @@ test('Primary client - AWS JSON payloads', async t => {
server.use({ responseBody: regularJSON, responseHeaders: headersAwsJSON() })
result = await aws({ service, endpoint })
request = server.getCurrentRequest()
t.deepEqual(result, regularJSON, 'Client returned response body as parsed, unmarshalled JSON')
t.deepEqual(result.payload, regularJSON, 'Client returned response body as parsed, unmarshalled JSON')
reset()
})

test('Primary client - error handling', async t => {
t.plan(17)
t.plan(19)
let responseStatusCode, responseBody, responseHeaders

// Normal error
Expand All @@ -207,7 +209,8 @@ test('Primary client - error handling', async t => {
console.log(err)
t.match(err.message, /\@aws-lite\/client: lambda: lolno/, 'Error included basic information')
t.equal(err.other, responseBody.other, 'Error has other metadata')
t.equal(err.statusCode, responseStatusCode, 'Error has status code')
t.equal(err.statusCode, responseStatusCode, 'Error has response status code')
t.ok(err.headers, 'Error has response headers')
t.equal(err.service, service, 'Error has service')
t.ok(err.stack.includes(__filename), 'Stack trace includes this test')
reset()
Expand All @@ -227,7 +230,8 @@ test('Primary client - error handling', async t => {
console.log(err)
t.match(err.message, /\@aws-lite\/client: lambda/, 'Error included basic information')
t.ok(err.message.includes(responseBody), 'Error has message')
t.equal(err.statusCode, responseStatusCode, 'Error has status code')
t.equal(err.statusCode, responseStatusCode, 'Error has response status code')
t.ok(err.headers, 'Error has response headers')
t.equal(err.service, service, 'Error has service')
t.ok(err.stack.includes(__filename), 'Stack trace includes this test')
reset()
Expand Down

0 comments on commit 3c9dcf2

Please sign in to comment.