Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ Require the `lambda-api` module into your Lambda handler script and instantiate
| version | `String` | Version number accessible via the `REQUEST` object |
| errorHeaderWhitelist | `Array` | Array of headers to maintain on errors |
| s3Config | `Object` | Optional object to provide as config to S3 sdk. [S3ClientConfig](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-s3/interfaces/s3clientconfig.html) |
| directInvoke | `boolean` | Enables support for [direct AWS SDK `Invoke` requests](#direct-lambda-invocations) that bypass API Gateway/ALB. Defaults to `false`. |

```javascript
// Require the framework and instantiate it with optional version and base parameters
Expand Down Expand Up @@ -433,7 +434,7 @@ The `REQUEST` object contains a parsed and normalized request from API Gateway.
- `app`: A reference to an instance of the app
- `version`: The version set at initialization
- `id`: The awsRequestId from the Lambda `context`
- `interface`: The interface being used to access Lambda (`apigateway`,`alb`, or `edge`)
- `interface`: The interface being used to access Lambda (`apigateway`, `alb`, or `lambda` for [direct AWS SDK `Invoke` requests](#direct-lambda-invocations))
- `params`: Dynamic path parameters parsed from the path (see [path parameters](#path-parameters))
- `method`: The HTTP method of the request
- `path`: The path passed in by the request including the `base` and any `prefix` assigned to routes
Expand Down Expand Up @@ -1479,6 +1480,61 @@ Please note that ALB events do not contain all of the same headers as API Gatewa

Sample ALB request and response events can be found [here](https://docs.aws.amazon.com/lambda/latest/dg/services-alb.html).

## Direct Lambda Invocations

In addition to API Gateway and ALB, Lambda API can process requests that **bypass API Gateway** and invoke your function directly through the AWS SDK [`Invoke`](https://docs.aws.amazon.com/lambda/latest/api/API_Invoke.html) API. This is useful for testing, service-to-service calls, and asynchronous fan-out. Both the `RequestResponse` (synchronous) and `Event` (asynchronous) invocation types are supported.

This behavior is **opt-in**. Enable it by setting `directInvoke: true` when you instantiate the API:

```javascript
const api = require('lambda-api')({ directInvoke: true });
```

With `directInvoke` enabled, any event that does **not** contain a `requestContext` is treated as a direct invocation and assigned the `lambda` [interface](#request). API Gateway and ALB events always include a `requestContext`, so the same handler continues to serve them with the standard proxy envelope — no code changes required.

Provide an event that describes the request you want to route. The shape mirrors the API Gateway v1 proxy format, but only `httpMethod` and `path` are required:

```javascript
// RequestResponse invocation payload
{
"httpMethod": "GET",
"path": "/users/123"
}

// With a body (may be a JSON string or a raw object) and query string
{
"httpMethod": "POST",
"path": "/users",
"queryStringParameters": { "notify": "true" },
"body": { "name": "Jane" }
}
```

Unlike API Gateway/ALB requests, a direct invocation returns an **unwrapped response** so the caller receives the payload with a single parse of the SDK `Invoke` `Payload`, rather than the proxy envelope's stringified `body` (which would require a second `JSON.parse`):

```javascript
// Handler: res.status(200).json({ id: 123 })
// Invoke Payload:
{
"statusCode": 200,
"body": { "id": 123 }
}

// Errors (Lambda API never throws — status is the only failure signal):
// Invoke Payload:
{
"statusCode": 404,
"body": { "error": "Route not found" }
}
```

A few things to keep in mind for direct invocations:

- The response omits the proxy-only `headers`, `multiValueHeaders`, and `statusDescription` fields, so response headers and cookies set via `res.header()`/`res.cookie()` are not returned. `isBase64Encoded: true` is preserved for binary responses (e.g. `res.sendFile()`), where `body` is the base64 string.
- `Event` (asynchronous) invocations discard the function's return value, so the response body is only meaningful for `RequestResponse` invocations.
- Because there is no HTTP layer, `req.ip` is `undefined` and `req.clientType`/`req.clientCountry` default to `'unknown'` unless you supply the corresponding headers on the event.
- A `path` must be supplied; an event without one routes to `/`.

## Configuring Routes in API Gateway

Routes must be configured in API Gateway in order to support routing to the Lambda function. The easiest way to support all of your routes without recreating them is to use [API Gateway's Proxy Integration](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-set-up-simple-proxy.html#api-gateway-proxy-resource?icmpid=docs_apigateway_console).
Expand Down
90 changes: 90 additions & 0 deletions __tests__/requests.unit.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
// Init API instance
const api = require('../index')({ version: 'v1.0' })

// Init API instance with direct Invoke support enabled
const apiDirect = require('../index')({ version: 'v1.0', directInvoke: true })

/******************************************************************************/
/*** DEFINE TEST ROUTES ***/
/******************************************************************************/
Expand All @@ -18,6 +21,15 @@ api.get('/test/201', function(req,res) {
res.status(201).json({ request })
})

apiDirect.get('/test/hello', function(req,res) {
let request = Object.assign(req,{app:null})
res.status(200).json({ request })
})

apiDirect.get('/test/binary', function(req,res) {
res.sendFile(Buffer.from('binary-data'))
})



/******************************************************************************/
Expand Down Expand Up @@ -223,4 +235,82 @@ describe('Request Tests:', function() {

})

describe('Lambda Direct Invoke', function() {

// NOTE: The 'Event' (async) invocation type needs no separate test — AWS
// discards the function return value for async invokes, so the framework
// behavior is identical to 'RequestResponse' from its side.

it('Returns an unwrapped { statusCode, body } response', async function() {
let _event = require('./sample-event-lambda1.json')
let result = await new Promise(r => apiDirect.run(_event,{},(e,res) => { r(res) }))
// Unwrapped shape: status + parsed body, no proxy envelope fields
expect(result.statusCode).toBe(200)
expect(typeof result.body).toBe('object')
expect(result.headers).toBeUndefined()
expect(result.multiValueHeaders).toBeUndefined()
expect(result.isBase64Encoded).toBeUndefined()
expect(result.statusDescription).toBeUndefined()
})

it('Detects the lambda interface and routes correctly', async function() {
let _event = require('./sample-event-lambda1.json')
let result = await new Promise(r => apiDirect.run(_event,{},(e,res) => { r(res) }))
expect(result.body.request.interface).toBe('lambda')
expect(result.body.request.method).toBe('GET')
expect(result.body.request.route).toBe('/test/hello')
expect(result.body.request.query.qs1).toBe('foo')
})

it('Returns an unwrapped error payload for unmatched routes', async function() {
let _event = Object.assign({}, require('./sample-event-lambda1.json'), { path: '/nope' })
let result = await new Promise(r => apiDirect.run(_event,{},(e,res) => { r(res) }))
expect(result.statusCode).toBe(404)
expect(result.body).toEqual({ error: 'Route not found' })
expect(result.headers).toBeUndefined()
})

it('Preserves the base64 flag and raw body for binary responses', async function() {
let _event = Object.assign({}, require('./sample-event-lambda1.json'), { path: '/test/binary' })
let result = await new Promise(r => apiDirect.run(_event,{},(e,res) => { r(res) }))
expect(result.statusCode).toBe(200)
expect(result.isBase64Encoded).toBe(true)
// Body stays the base64 string (not parsed) so the caller can decode it
expect(typeof result.body).toBe('string')
expect(Buffer.from(result.body, 'base64').toString()).toBe('binary-data')
expect(result.headers).toBeUndefined()
})

it('Returns an empty body for HEAD requests', async function() {
let _event = Object.assign({}, require('./sample-event-lambda1.json'), { httpMethod: 'HEAD' })
let result = await new Promise(r => apiDirect.run(_event,{},(e,res) => { r(res) }))
expect(result.statusCode).toBe(200)
expect(result.body).toBe('')
expect(result.isBase64Encoded).toBeUndefined()
expect(result.headers).toBeUndefined()
})

it('Backward compat: directInvoke off keeps the API Gateway envelope', async function() {
// Same requestContext-less event through the default api instance
let _event = require('./sample-event-lambda1.json')
let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) }))
let body = JSON.parse(result.body)
expect(typeof result.body).toBe('string')
expect(result.headers).toBeDefined()
expect(body.request.interface).toBe('apigateway')
})

it('Dual mode: directInvoke on still returns the envelope for API Gateway events', async function() {
// API Gateway events carry a requestContext, so they keep the proxy envelope
let _event = require('./sample-event-apigateway-v1.json')
let _context = require('./sample-context-apigateway1.json')
let result = await new Promise(r => apiDirect.run(_event,_context,(e,res) => { r(res) }))
let body = JSON.parse(result.body)
expect(typeof result.body).toBe('string')
expect(result.multiValueHeaders).toBeDefined()
expect(body.request.interface).toBe('apigateway')
})

})

}) // end Request tests
7 changes: 7 additions & 0 deletions __tests__/sample-event-lambda1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"httpMethod": "GET",
"path": "/test/hello",
"queryStringParameters": {
"qs1": "foo"
}
}
2 changes: 2 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export declare interface Options {
compression?: boolean;
headers?: object;
s3Config?: S3ClientConfig;
directInvoke?: boolean;
}

export declare class Request {
Expand All @@ -145,6 +146,7 @@ export declare class Request {
};
method: string;
path: string;
interface: 'apigateway' | 'alb' | 'lambda';
query: {
[key: string]: string | undefined;
};
Expand Down
8 changes: 8 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ class API {
? props.compression
: false;

// Treat events without a requestContext (direct AWS SDK Invoke requests)
// as the 'lambda' interface and return an unwrapped { statusCode, body }
// response. Opt-in to preserve the API Gateway envelope by default.
this._directInvoke =
props && typeof props.directInvoke === 'boolean'
? props.directInvoke
: false;

this._s3Config = props && props.s3Config;

// Set S3 Client
Expand Down
12 changes: 10 additions & 2 deletions src/lib/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,16 @@ class REQUEST {
this.requestContext['identity']['sourceIp'] &&
this.requestContext['identity']['sourceIp'].split(',')[0].trim());

// Assign the requesting interface
this.interface = this.requestContext.elb ? 'alb' : 'apigateway';
// Assign the requesting interface. Events carrying a requestContext come
// from API Gateway/ALB; when directInvoke is enabled, an event without one
// is treated as a direct AWS SDK Invoke request ('lambda' interface).
this.interface = this.requestContext.elb
? 'alb'
: this.app._event.requestContext
? 'apigateway'
: this.app._directInvoke
? 'lambda'
: 'apigateway';

// Set the pathParameters
this.pathParameters = this.app._event.pathParameters || {};
Expand Down
23 changes: 23 additions & 0 deletions src/lib/response.js
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,29 @@ class RESPONSE {
body = '';
}

// Direct AWS SDK Invoke request: return an unwrapped payload with status,
// bypassing the API Gateway/ALB proxy envelope (headers, cookies,
// compression, base64 flag). The body is returned as a parsed value so the
// caller receives it with a single JSON.parse of the Invoke response.
if (this._request.interface === 'lambda') {
const encodedBody =
this._request.method === 'HEAD'
? ''
: UTILS.encodeBody(body, this._serializer);

this._response = this._isBase64
? {
statusCode: this._statusCode,
body: encodedBody,
isBase64Encoded: true,
}
: { statusCode: this._statusCode, body: UTILS.parseBody(encodedBody) };

// Trigger the callback function
this.app._callback(null, this._response, this);
return;
}

let headers = {};
let cookies = {};

Expand Down
Loading