From 87de02d3286201fbba9a3543ed603281b2a52ced Mon Sep 17 00:00:00 2001 From: naorpeled Date: Sat, 4 Jul 2026 18:56:41 +0300 Subject: [PATCH] feat: support direct AWS SDK Invoke requests (#106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an opt-in `directInvoke` option so Lambda API can process requests that bypass API Gateway/ALB and are invoked directly via the AWS SDK `Invoke` API (both RequestResponse and Event invocation types). When `directInvoke: true`, an event without a `requestContext` is detected as the new `lambda` interface and routed normally. Instead of the API Gateway proxy envelope (whose stringified `body` forces callers to double-parse), a direct invocation returns an unwrapped `{ statusCode, body }` payload — the parsed value plus the status code, which is the only success/failure signal since Lambda API never throws. Binary responses keep `isBase64Encoded: true` with the raw base64 body. The option defaults to `false`, so API Gateway/ALB behavior (which always carry a `requestContext`) is unchanged and one handler serves all three. - src/index.js: parse `directInvoke` config - src/lib/request.js: gate `lambda` interface detection - src/lib/response.js: unwrapped response for the `lambda` interface - index.d.ts: add `Options.directInvoke` and `Request.interface` - __tests__: Lambda Direct Invoke suite + fixture (shape, routing, errors, base64, HEAD, backward-compat and dual-mode guards) - README.md: document the option, interface value, and new section Closes #106 --- README.md | 58 ++++++++++++++++++- __tests__/requests.unit.js | 90 +++++++++++++++++++++++++++++ __tests__/sample-event-lambda1.json | 7 +++ index.d.ts | 2 + src/index.js | 8 +++ src/lib/request.js | 12 +++- src/lib/response.js | 23 ++++++++ 7 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 __tests__/sample-event-lambda1.json diff --git a/README.md b/README.md index 7e1bd30..9b54101 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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). diff --git a/__tests__/requests.unit.js b/__tests__/requests.unit.js index 4e600e7..7f1afe2 100644 --- a/__tests__/requests.unit.js +++ b/__tests__/requests.unit.js @@ -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 ***/ /******************************************************************************/ @@ -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')) +}) + /******************************************************************************/ @@ -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 diff --git a/__tests__/sample-event-lambda1.json b/__tests__/sample-event-lambda1.json new file mode 100644 index 0000000..7ad6b80 --- /dev/null +++ b/__tests__/sample-event-lambda1.json @@ -0,0 +1,7 @@ +{ + "httpMethod": "GET", + "path": "/test/hello", + "queryStringParameters": { + "qs1": "foo" + } +} diff --git a/index.d.ts b/index.d.ts index 07f63f3..a767146 100644 --- a/index.d.ts +++ b/index.d.ts @@ -134,6 +134,7 @@ export declare interface Options { compression?: boolean; headers?: object; s3Config?: S3ClientConfig; + directInvoke?: boolean; } export declare class Request { @@ -145,6 +146,7 @@ export declare class Request { }; method: string; path: string; + interface: 'apigateway' | 'alb' | 'lambda'; query: { [key: string]: string | undefined; }; diff --git a/src/index.js b/src/index.js index 2d7604a..4112823 100644 --- a/src/index.js +++ b/src/index.js @@ -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 diff --git a/src/lib/request.js b/src/lib/request.js index 431e28f..19122ad 100644 --- a/src/lib/request.js +++ b/src/lib/request.js @@ -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 || {}; diff --git a/src/lib/response.js b/src/lib/response.js index fe1e26b..0875683 100644 --- a/src/lib/response.js +++ b/src/lib/response.js @@ -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 = {};