Skip to content

Commit

Permalink
HTTP handlers (#9)
Browse files Browse the repository at this point in the history
  • Loading branch information
razor-x authored Jul 16, 2021
1 parent f8bab61 commit aec5977
Show file tree
Hide file tree
Showing 22 changed files with 699 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

### Added

- HTTP handlers.
- Handle request id for unparsed SQS event case.
- Call `onError` in strategies if registered as a dependency.

Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ are advanced features which are stable, but not yet fully documented.
They are used internally to create the included handler factories.
Please refer to the code for how they may be used.

Parsers and serializers should be agnostic to details of user input and response content.
They are not expected to throw runtime errors.
If a parsers or serializer throws, it indicates an bug in its implementation, or a
bad configuration (e.g., trying to parse payloads for the wrong event type).

### Handler Factories

All handler functions return a new handler factory with identical signature:
Expand Down Expand Up @@ -144,6 +149,19 @@ Each message will be processed in a child Awilix scope.
The `sqsJsonHandler` behaves like the `sqsHandler` except
it will parse the SQS message body as JSON.

#### HTTP Handler

The `httpHandler` handler handles [API Gateway Proxy events](./fixtures/event/api-gateway-proxy.json).

The handler will catch all processor errors, wrap them with Boom,
and return a basic status code response.
If the processor throws a Boom error, its status code will be respected.

The `httpJsonHandler` behaves like the `httpHandler` except
it will parse the request body as JSON, and if the processor
returns an object with a `body` property, it will
serialize that to JSON and add any missing response properties.

##### Example

```javascript
Expand Down
63 changes: 63 additions & 0 deletions fixtures/event/api-gateway-proxy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{
"version": "2.0",
"routeKey": "$default",
"rawPath": "/my/path",
"rawQueryString": "parameter1=value1&parameter1=value2&parameter2=value",
"cookies": ["cookie1", "cookie2"],
"headers": {
"header1": "value1",
"header2": "value1,value2"
},
"queryStringParameters": {
"parameter1": "value1,value2",
"parameter2": "value"
},
"requestContext": {
"accountId": "123456789012",
"apiId": "api-id",
"authentication": {
"clientCert": {
"clientCertPem": "CERT_CONTENT",
"subjectDN": "www.example.com",
"issuerDN": "Example issuer",
"serialNumber": "a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1",
"validity": {
"notBefore": "May 28 12:30:02 2019 GMT",
"notAfter": "Aug 5 09:36:04 2021 GMT"
}
}
},
"authorizer": {
"jwt": {
"claims": {
"claim1": "value1",
"claim2": "value2"
},
"scopes": ["scope1", "scope2"]
}
},
"domainName": "id.execute-api.us-east-1.amazonaws.com",
"domainPrefix": "id",
"http": {
"method": "POST",
"path": "/my/path",
"protocol": "HTTP/1.1",
"sourceIp": "IP",
"userAgent": "agent"
},
"requestId": "id",
"routeKey": "$default",
"stage": "$default",
"time": "12/Mar/2020:19:03:58 +0000",
"timeEpoch": 1583348638390
},
"body": "Hello from Lambda",
"pathParameters": {
"parameter1": "value1"
},
"isBase64Encoded": false,
"stageVariables": {
"stageVariable1": "value1",
"stageVariable2": "value2"
}
}
26 changes: 26 additions & 0 deletions lib/handlers/http.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { createHttpStrategy } from '../strategies/index.js'
import {
apiGatewayProxyJsonParser,
apiGatewayProxyParser
} from '../parsers/index.js'
import {
apiGatewayProxyJsonSerializer,
apiGatewayProxySerializer
} from '../serializers/index.js'

import { createHandler } from './factory.js'

export const httpHandler = (options = {}) =>
createHandler({
parser: apiGatewayProxyParser,
serializer: apiGatewayProxySerializer,
createStrategy: createHttpStrategy,
...options
})

export const httpJsonHandler = (options = {}) =>
httpHandler({
parser: apiGatewayProxyJsonParser,
serializer: apiGatewayProxyJsonSerializer,
...options
})
1 change: 1 addition & 0 deletions lib/handlers/index.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './http.js'
export * from './invoke.js'
export * from './sqs.js'
15 changes: 15 additions & 0 deletions lib/parsers/api-gateway-proxy.doc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* API Gateway Proxy event parser.
* @function apiGatewayProxyParser
* @param {Object} event The event.
* @returns {Object} The parsed event.
* Includes searchParams as an instance of URLSearchParams.
*/

/**
* API Gateway Proxy JSON event parser.
* @function apiGatewayProxyJsonParser
* @param {Object} event The event.
* @returns {Object} The parsed event with the body parsed as JSON.
* Includes searchParams as an instance of URLSearchParams.
*/
21 changes: 21 additions & 0 deletions lib/parsers/api-gateway-proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { isNil } from '@meltwater/phi'

export const apiGatewayProxyParser = (event) => {
const { version } = event

if (version !== '2.0') {
throw new Error(
`Only API Gateway Proxy payload version 2.0 supported, got version ${version}`
)
}

return {
...event,
searchParams: new URLSearchParams(event.rawQueryString ?? '')
}
}

export const apiGatewayProxyJsonParser = (event) => ({
...apiGatewayProxyParser(event),
body: isNil(event.body) ? undefined : JSON.parse(event.body)
})
61 changes: 61 additions & 0 deletions lib/parsers/api-gateway-proxy.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import test from 'ava'

import {
apiGatewayProxyJsonParser,
apiGatewayProxyParser
} from './api-gateway-proxy.js'

test('apiGatewayProxyParser: parses event', (t) => {
const version = '2.0'
const event = { version }
t.deepEqual(apiGatewayProxyParser(event), {
version,
searchParams: new URLSearchParams('')
})
})

test('apiGatewayProxyParser: only parses event version 2.0', (t) => {
const version = '1.0'
const event = { version }
t.throws(() => apiGatewayProxyParser(event), { message: /version 1.0/ })
})

test('apiGatewayProxyParser: parses event with search params', (t) => {
const version = '2.0'
const rawQueryString = 'foo=bar'
const event = { version, rawQueryString }
const data = apiGatewayProxyParser(event)
t.is(data.searchParams.get('foo'), 'bar')
})

test('apiGatewayProxyJsonParser: parses event', (t) => {
const version = '2.0'
const event = { version }
t.deepEqual(apiGatewayProxyJsonParser(event), {
version,
body: undefined,
searchParams: new URLSearchParams('')
})
})

test('apiGatewayProxyJsonParser: only parses event version 2.0', (t) => {
const version = '1.0'
const event = { version }
t.throws(() => apiGatewayProxyJsonParser(event), {
message: /version 1.0/
})
})

test('apiGatewayProxyJsonParser: parses event with search params', (t) => {
const version = '2.0'
const rawQueryString = 'foo=bar'
const event = { version, rawQueryString }
const data = apiGatewayProxyJsonParser(event)
t.is(data.searchParams.get('foo'), 'bar')
})

test('apiGatewayProxyJsonParser: parses event body as json', (t) => {
const event = { version: '2.0', body: '{"foo":2}' }
const data = apiGatewayProxyJsonParser(event)
t.deepEqual(data.body, { foo: 2 })
})
1 change: 1 addition & 0 deletions lib/parsers/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './api-gateway-proxy.js'
export * from './identity.js'
export * from './records.js'
export * from './sqs.js'
13 changes: 13 additions & 0 deletions lib/serializers/api-gateway-proxy.doc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* API Gateway Proxy event serializer.
* @function apiGatewayProxySerializer
* @param {Object} event The data.
* @returns {Object} The serialized data.
*/

/**
* API Gateway Proxy JSON event serializer.
* @function apiGatewayProxyJsonSerializer
* @param {Object} event The data.
* @returns {Object} The serialized data with the body serialized to JSON.
*/
18 changes: 18 additions & 0 deletions lib/serializers/api-gateway-proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { isNil } from '@meltwater/phi'

export const apiGatewayProxySerializer = (data) => data

export const apiGatewayProxyJsonSerializer = (data) => {
if (isNil(data?.body)) return data
return {
isBase64Encoded: false,
cookies: [],
statusCode: 200,
...data,
body: JSON.stringify(data.body),
headers: {
'content-type': 'application/json',
...data?.headers
}
}
}
31 changes: 31 additions & 0 deletions lib/serializers/api-gateway-proxy.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import test from 'ava'

import {
apiGatewayProxyJsonSerializer,
apiGatewayProxySerializer
} from './api-gateway-proxy.js'

test('apiGatewayProxySerializer: serializes', (t) => {
const data = { foo: 'bar', statusCode: 201 }
t.deepEqual(apiGatewayProxySerializer(data), data)
})

test('apiGatewayProxyJsonSerializer: serializes data', (t) => {
const data = { foo: 'bar', statusCode: 201 }
t.deepEqual(apiGatewayProxyJsonSerializer(data), data)
})

test('apiGatewayProxyJsonSerializer: serializes body as json', (t) => {
const data = { foo: 'bar', headers: { x: 'y' }, body: { a: 2 } }
t.deepEqual(apiGatewayProxyJsonSerializer(data), {
...data,
isBase64Encoded: false,
cookies: [],
statusCode: 200,
body: '{"a":2}',
headers: {
x: 'y',
'content-type': 'application/json'
}
})
})
1 change: 1 addition & 0 deletions lib/serializers/index.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './api-gateway-proxy.js'
export * from './identity.js'
10 changes: 10 additions & 0 deletions lib/strategies/http.doc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Create an HTTP strategy.
* Resolves and calls the processor for the event.
* Swallows all errors, wraps them as a Boom object,
* resolves and calls onError on the wrapped error,
* and returns a matching status code response.
* @function createEventStrategy
* @param {Object} container The Awilix container.
* @returns {Object[]} The strategy.
*/
25 changes: 25 additions & 0 deletions lib/strategies/http.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { noop } from '@meltwater/phi'
import { boomify, isBoom } from '@hapi/boom'

export const createHttpStrategy = (container) => async (event, ctx) => {
const scope = container.createScope()
const processor = scope.resolve('processor')
const onError = scope.resolve('onError', { allowUnregistered: true }) ?? noop
try {
return await processor(event, ctx)
} catch (err) {
const error = wrapError(err)
onError(error)
return toErrorResponse(error)
}
}

const wrapError = (err) =>
isBoom(err) ? err : boomify(err, { statusCode: 500 })

const toErrorResponse = (err) => {
const { output } = err
return {
statusCode: output.statusCode
}
}
48 changes: 48 additions & 0 deletions lib/strategies/http.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import test from 'ava'
import { asValue, createContainer } from 'awilix'
import { unauthorized } from '@hapi/boom'

import { createHttpStrategy } from './http.js'

test('createHttpStrategy', async (t) => {
const container = createContainer()
container.register('processor', asValue(processor))
const strategy = createHttpStrategy(container)
const data = await strategy({ a: 1 }, { b: 2 })
t.deepEqual(data, {
ctx: { b: 2 },
event: { a: 1 }
})
})

test('createHttpStrategy: calls onError', async (t) => {
t.plan(2)
const onError = (err) => {
t.is(err.message, errMessage)
}
const container = createContainer()
container.register('processor', asValue(processorWithError))
container.register('onError', asValue(onError))
const strategy = createHttpStrategy(container)
const data = await strategy({ a: 1 }, { b: 2 })
t.deepEqual(data, { statusCode: 500 })
})

test('createHttpStrategy: handles boom error', async (t) => {
const container = createContainer()
container.register('processor', asValue(processorWithBoomError))
const strategy = createHttpStrategy(container)
const data = await strategy({ a: 1 }, { b: 2 })
t.deepEqual(data, { statusCode: 401 })
})

const processor = async (event, ctx) => ({ event, ctx })

const errMessage = 'Mock processor error'
const processorWithError = async (event, ctx) => {
throw new Error(errMessage)
}

const processorWithBoomError = async (event, ctx) => {
throw unauthorized()
}
1 change: 1 addition & 0 deletions lib/strategies/index.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './event.js'
export * from './http.js'
export * from './parallel.js'
Loading

0 comments on commit aec5977

Please sign in to comment.