aws-lambda-handlers
v3.0.5
Published
Enhance AWS Lambdas with strong typing, secrets, schema validation, opentelemetry and sentry
Downloads
97
Maintainers
Readme
AWS Lambda Handlers
Enhance your AWS Lambdas with wrappers to bring strong typings and runtime logic to your lambdas. Now with Sentry, Opentelemetry and Yup and Secret prefetching
Breaking changes in v3.x
From v3.x, the validation system has been reworked, and we're dropping native support of yup in favor of a more global approach. Validators can now be registered at the manager level and consumed by the implementation of the AWS Lambda.
One major side-effect - and downside, really - is that we no longer infer the input and output types from the schemas. This must now be explicitely written by the implementation (e.g. .setTsInputType<InferType<typeof schema>>()
for yup).
The major upside is that you can write validators not only for the payload, but also for any additional information provided to the lambda, via for example the headers for the API Gateway, or via the Message Attributes for SQS. Lambdas can also chain validators, to not only enforce schema validation, but any other type of validator you may decide to write at the manager level.
Check out Runtime Validation for explanations and examples.
Breaking changes in v2.x
The only changes between v2.x and v1.x are in the handling of the secrets. For the documentation of v1.x, see documentation
Version 2 introduces a small breaking change when working with AWS secrets. In v2, it is possible to define custom secret fetchers other than target other sources than the AWS Secret manager. Therefore, we had to introduce a change in the following signatures:
// 1.x:
new LambdaFactoryManager().setSecrets(/*...*/);
// 2.x:
new LambdaFactoryManager().setAWSSecrets(/*...*/);
////////////////////////////
// 1.x:
factory.needsSecret('key', 'secretName', 'secretKey', required); // required is bool
// 2.x:
factory.needsSecret(
'aws',
'key',
'secretName',
'secretKey',
undefined,
required
);
The reason behind those changes in reflected in the following documentation (under Secret injection)
Why ?
AWS Lambda's are a great piece of engineering and makes our life easier, but they're pretty "raw". For example, it would be pretty useful to:
- Wrap the handlers with Sentry (you could also use a lambda layer for that)
- Automatically parse and validate the inputs based on schemas
- Sanitize the outputs: API Gateway's responses and SQS failed batch items
- Pre-fetch a bunch of secrets from the secret manager (you should not have them defined in environment variables !)
- Have static type safety
- Overcome the shortcomings of Opentelemetry's Lambda auto-instrumentation
This package provides an opiniated stack to insert additional logic in handling lambdas triggered from the API Gateway, the Event Bridge, SQS and SNS (and we will be adding more sources later !).
- AWS Lambda Handlers
How it works
With this framework, you start by declaring a Lambda Manager, which may be common across all your microservices. It allows to define which secrets are generally available (thereby getting autocompletion), configure secret sources for secret managers other than AWS and configure a base Sentry configuration for all your lambdas in all your services. The lambda manager is a common source of Handler Wrappers, for the API Gateway, SQS, SNS or for the EventBridge.
Then, in each service, use the Lambda Manager to define a Handler Wrapper, which can be further configured (this time on a per-lambda basis) with secrets, static typings and schema validation.
From there, you can go down two routes, depending on the level of complexity / project management you wish to follow:
- The simple route uses functional programming. Directly wrap the function handler using the
wrapFunc
method and be done. - The "better" route uses OOP. Produce a controller which implements a defined interface (2 method required), allowing you to handle many routes per controller, and feed this controller to the Handler Wrapper.
Finally, export the handler and expose it to AWS.
It may sound a bit overly complex, but after using it a bit, it will all make sense.
Installation
npm i aws-lambda-handlers
Features
- Strongly typed TS Interfaces to be implemented by Controllers
- Optional payload input (and output) validation against a schema (or any other validation function)
- Wrapping with Sentry (with cross-oranisation configuration sharing)
- Tracing with Opentelemetry, separating Lambda spans with source spans (no need for the auto-instrumentation)
- Before executing a controller, secrets may be pre-fetched and provided to you
- State can easily be persisted across invocations, and cold-start initialisation can be easily used for resource acquisition
Demo Usage
1. Create a service-wide (or cross-service) manager
Start by sharing a wrapper manager across all your lambda functions. This is useful to share configuration across your organisation.
Currently the manager is used for:
- Setting a global Sentry configuration
- Setting the list of available AWS secrets
- Setting another source of secrets as well as how to retrieve them
// path/to/manager.ts
import { LambdaFactoryManager } from 'aws-lambda-handlers';
const mgr = new LambdaFactoryManager();
// We'll import the manager later on !
export default mgr;
Go down the simpler functional route
2. Implement your business logic directly
You can now create the route / event handler and specify its implementation as such:
import manager from './path/to/manager';
export const { handler_name, configuration } = manager
.apiGatewayWrapperFactory('handler_name')
.setTsInputType<string>()
.wrapFunc( async ( data, init, secrets ) => {
// Business logic here
return HTTPResponse.OK_NO_CONTENT();
});
Note here how the name of the function in the output object (here: handler_name
) is the one you set in the apiGatewayWrapperFactory
method. It's also the handler you must configure in AWS: path/to/route.handler_name
You can use this to implement multiple functions in a single file (though we do not recommend it)
If you implement multiple handlers per file and you need to access the configuration
object, you will need to rename the configuration
object during destructuring.
Go down the more complex OOP route:
2. Create a route / event handler using the manager
It is good practice to separate the logic (a controller) from the handler itself (the entrypoint exposed to AWS), which allows you to swap controllers or implement multiple lambdas with a single controller.
Ideally, the controller route should be require
-able without it executing any service logic. This allows you to expose "meta-information" that can be used by other tools (for example, automatically add IAM permissions in a CDK code by loading the configuration
object, or building an OpenAPI v3 spec, etc.)
Start by the handler file: import the manager you just exported into a new file (the one that will use by AWS to handle your function) and either start an API Gateway wrapper, and Event Bridge wrapper, an SNS wrapper or an SQS wrapper
// path/to/route.ts
import manager from './path/to/manager'; // You can also use an npm module to share the mgr across your org
const wrapperFactory = manager
.apiGatewayWrapperFactory('handler_name')
.setTsInputType<string>();
import { Controller } from './path/to/controller';
export const { handler, configuration } =
wrapperFactory.createHandler(Controller);
export type Interface = CtrlInterfaceOf<typeof wrapperFactory>;
3. Create a controller
You may now write the controller, which must implement the interface exported by the Lambda wrapper (we called it Interface
, see above)
// path/to/controller.ts
import { Interface } from './path/to/route';
class Controller implements Interface {
static async init() {
return new Controller();
}
handler_name: IfHandler<Interface> = async (data, secrets) => {
// Write your logic here
};
}
And that's it for the most basic implementation ! You may now use path/to/route.handler
as a Lambda entry-point.
The syntax handler_name: IfHandler<Interface> =
allows to automatically infer the types of the method arguments without needing to be explicit. This is because in typescript (in 4.9 at least), arguments in methods that implement an interface are not inferred and default to any
. So rather than setting the type of the arguments explicitely, it's easier to just explicitely type the whole method.
Details
In this section we explore the benefits that our approach brings.
Triggering from different AWS sources
Wrapper factory constructors are available for
- API Gateway:
manager.apiGatewayWrapperFactory( handler: string );
- Event Bridge
manager.eventBridgeWrapperFactory( handler: string );
- SNS
manager.sqsWrapperFactory( handler: string );
- SQS
manager.snsWrapperFactory( handler: string );
The differences exist because the input types and output types are not the same whether the lambda is triggered by either of those event sources, and because the error handling is different (for example, the lambda triggered by the API Gateway should never fail, but the EventBridge lambda may be allowed fail). In addition, the SQS loop is unrolled (you implement only the method for the record, not for the whole event, which contains many records) for error management purposes.
Notes on immutability
Both the LambdaFactoryManager
and the derived APIGatewayWrapperFactory
and others are mostly immutable (understand by it that you cannot safely rely on their immutability either). It is important to understand that most of the methods return a new instance:
const apiWrapperFactory = new LambdaFactoryManager().apiGatewayWrapperFactory();
const apiWrapperFactory2 = api.needsSecret(/*...*/);
// apiWrapperFactory2 is of "similar" type as apiWrapperFactorty, and will require the secret
// apiWrapperFactory will NOT require the secret
// BAD !
api.needsSecret(); // Not assigned to a variable
Handler method name
The string parameter passed to the constructor function defines which method must be implemented by the constructor:
type HandlerIf = CtrlInterfaceOf<wrapperFactory>;
/* HandlerIf is of type
{
handler_name: ( data: APIGatewayData<unknown>, secrets: Record<string, string> ): Promise<HTTPResponse<unknown> | HTTPError>
}
*/
Detailed Usage
Main exports
This package exposes 3 main objects you may want to import:
class LambdaFactoryManager
, which is used to create a WrapperFactory (1 type per event source), used to then create the AWS Lambda handlertype CtrlInterfaceOf
, which derives the WrapperFactory into a TS interface to be implemented by the controllertype IfHandler
, which stands for "interface handler", and informs the controller handler about the parameter type (see examples).
Complete example
//====================================================================
// route.ts
import manager from 'path/to/manager';
import { MyController } from 'path/to/controller';
import { CtrlInterfaceOf } from 'aws-lambda-handlers';
// API Route definition file
const handlerWrapperFactory = manager
.apiGatewayWrapperFactory('handle')
.setTsInputType<INPUT_TYPE>() // Injects type safety, overrides yup schema
.setTsOutputType<OUTPUT_TYPE>() // Injects type safety, overrides yup schema
.validateInput("yup", yupSchema) // Of type yup.BaseSchema // ! The yup validator must be defined first
.validateOutput("yup", yupSchema) // Of type yup.BaseSchema // ! The yup validator must be defined first
.needsSecret(
'aws',
'process_env_key',
'SecretName',
'adminApiKey',
undefined,
true
) // Fetches the secrets during a cold start
.needsSecret(
'aws',
'process_env_other_key',
'SecretName',
'apiKey',
undefined,
true
);
type controllerInterface = CtrlInterfaceOf<typeof handlerWrapperFactory>;
export const { handler, configuration } =
handlerWrapperFactory.createHandler(MyController);
export { controllerInterface }; // Export the type to be reimported by the route implementation
//====================================================================
// controller.ts
import type { controllerInterface } from 'path/to/route';
export class MyController implements controllerInterface {
static async init() {
return new MyController();
}
// Method name Has to match the .setHandler() call
handle: IfHandler<controllerInterface> = // Without this type, req, secrets and the return value default to any
async (req, secrets) => {
return Response.OK_NO_CONTENT();
};
}
Notes on the Wrapper Factory
manager.apiGatewayWrapperFactory()
(and similarly for all other event sources) must be called for every lambda that must be created. It takes a single argument: the name of the handler function to be implemented in the controller.setTsInputType<T>()
informs the interface on the input type you're expected to receive. We're not talking about the raw type (e.g.APIGatewayEvent
), but rather- The
body
field for the API gateway (will be JSON.parse'd if the Content-Type is application/json) - The
detail
field for the Event Bridge - The
message
content for SQS and SNS
- The
- similarly,
.setTsOutputType<T>()
informs the type of response the controller is supposed to return (or an instance ofHTTPError
if the controller failed). Only applies to API Gateway setInputSchema<SCHEMA_TYPE>( schema )
andsetOutputSchema<SCHEMA_TYPE>( schema )
add a runtime verification of ayup
schema. WhensetTsInputType
is not defined butsetInputSchema
is, then the controller is expected to received the result ofInferType< SCHEMA_TYPE >
instead ofT
needsSecret( source, key, secretName, secretKey, meta, required )
is used for ahead-of-execution secret injection: when a cold start occurs, the Lambda wrapper will detect if the secret has been injected intoprocess.env[ key ]
. If not, it will fetch it from AWS and inject it intoprocess.env
. It will also be made available in the handler method with strong typing. Therequired
field can be used to outrightly fail the lambda when the secret is not found. Note thatsecretName
andsecretKey
have auto-completion and will report a TS error if you have provided a secret list in the manager.
Other notes
Once the wrapper factory has been created, you can extract its interface type using:
// API Gateway handler
type controllerInterface = CtrlInterfaceOf<typeof APIHandlerWrapperFactory>;
// Event bridge handler
type controllerInterface = CtrlInterfaceOf<
typeof EventBridgeHandlerWrapperFactory
>;
// SNS handler
type controllerInterface = CtrlInterfaceOf<typeof snsHandlerWrapperFactory>;
// SQS handler
type controllerInterface = CtrlInterfaceOf<typeof sqsHandlerWrapperFactory>;
Implementing a controller
Implementing a Controller has 2 requirements:
- Provide a static async initializer, called
static async init
- Provide the method mandated by the route
import { InterfaceHandler } from './path/to/interface';
export class Controller implements InterfaceHandler {
constructor(private myResource: MyResource) {}
static async init() {
// Acquires MyResource only during a cold start
return new Controller(new MyResource());
}
// Inherits the method parameter types and return type from the interface. See for details
handler_name: IfHandler<InterfaceHandler> = async (data, secrets) => {
return HTTPResponse.OK_NOT_CONTENT();
};
}
Note on the following:
- The static initialisation is only called during an initial cold start. During the subsequent lambda invocations, the same controller instance will be reused without re-initialisation.
- The wrapper doesn't use the controller constructor directly. Instead, the async static init is used and brings the following benefits:
- Asynchronous initialisation
- Type safety in the controller (
myResource
is of typeMyResource
, and not of typeMyResource | undefined
)
- You may therefore use the static init method to perform any required initialisation you may desire and persist the state across invocations
- The IfHandler<> utility is provided because by default, implemented methods to do infer their parameter types from the implemented interface. See this issue for reference
- Several routes can be implemented using `implements IfOfRouteA, IfOfRouteB, ...``
Implementing multiple routes / events in a controller
Depending on your design choices, you may decide to create a single controller for multiple routes, for example when handling CRUD operations. This can be achieved like that:
Routes definitions (1 file per handler, or more, but then you'd have to rename all symbols)
// Create.ts
import Controller from 'path/to/controller';
import manager from 'path/to/manager';
const createHandlerWrapperFactory = manager.apiGatewayWrapperFactory('create');
export type controllerInterface = CtrlInterfaceOf<
typeof createHandlerWrapperFactory
>;
export const { handler, configuration } =
createHandlerWrapperFactory.createHandler(Controller);
// Read.ts
import Controller from 'path/to/controller';
import manager from 'path/to/manager';
const readHandlerWrapperFactory = manager.apiGatewayWrapperFactory('read');
export type controllerInterface = CtrlInterfaceOf<
typeof readHandlerWrapperFactory
>;
export const { handler, configuration } =
readHandlerWrapperFactory.createHandler(Controller);
// Update.ts...
// Delete.ts...
Controller implementation
// Controller.ts
import type { controllerInterface as createInterface } from 'path/to/create_route';
import type { controllerInterface as readInterface } from 'path/to/read_route';
import type { controllerInterface as updateInterface } from 'path/to/update_route';
import type { controllerInterface as deleteInterface } from 'path/to/delete_route';
export class Controller // The controller must now implement 4 interfaces, 1 for each route
implements createInterface, readInterface, updateInterface, deleteInterface
{
static async init() {
return new Controller();
}
// Implement your business logic below
create: IfHandler<createInterface> = async (payload, secrets) => {};
read: IfHandler<readInterface> = async (payload, secrets) => {};
update: IfHandler<updateInterface> = async (payload, secrets) => {};
delete: IfHandler<deleteInterface> = async (payload, secrets) => {};
}
Type system
When specifying setTsInputType
(and setTsOutputType
for the API Gateway), the input data will reference those types (even when a schema is set) but do nothing at the runtime (you need to set a schema for that)
If you are validating against a schema, most libraries provide with a way to infer a typescript type from the schema type. You may leverage the use of static type inference to avoid typing your TS typings twice:
- For yup, use
.setTsInputType<InferType<typeof schema>>()
- For zod, use
.setTsInputType<z.infer<typeof schema>>()
- For a JSON schema, use the package json-schema-to-ts and use
.setTsInputType<FromSchema<typeof schema>>()
Note that this doesn't give you runtime validation yet.
Runtime validation
When writing the LambdaFactoryManager
, you can add to it validators functions, which can be optionally consumed by the lambda implementation. Validators may be used to enforce a schema, but may also validate other other message properties (headers, message attributes, source origins, etc...)
Validators must:
- be asynchronous (even if the validation is synchronous, the function needs to return a Promise)
- throw an error when the validation fails
- takes a name and two functions
- The validator itseld
- An init function, run at cold start, allowing modifications of the arguments (and the BaseWrapperFactory as well) (see example below)
Adding a validator to the manager takes the following syntax
const mgr = new LambdaFactoryManager().addValidation(
"validationName",
// Validation function
async (
data: any,
rawData: APIGatewayEvent | EventBridgeEvent<any, any> | SQSEvent | SNSEvent,
arg2: T2,
arg3: T3,
//...
) => {
await schema.validate( data );
},
// Init function
( wrapper: BaseWrapperFactory<any>, arg2: T4 ): [ T2, T3 ] => {
let a: T2;
let b: T3;
return [ a, b ];
}
);
In other words, the init
function defines the arguments that the validateInput
and validateOutput
take (in this case: 1 arg of type T4
) and returns a tuple or arguments fed into the validator (in this case: 2 args of type T2
and T3
).
This allows you to run type modifications, e.g. schema compilation (which should only run on a cold start).
Note the two first 2 parameters of the validation function are fixed and of type any
. They represent the (1) extracted data itself (parsed request body, SNS message body, etc) and (2) the raw event that the lambda has received. It can be of any type because the validator defined at the LambdaManager
level could be used with any event source (API Gateway, EB, SQS, SNS).
From the third argument on, the values are passed during the consumption of the validator:
declare a: T4;
mgr.apiGatewayWrapperFactory("handler").validateInput( "validationName", a );
There is strong type safety in the sense that the second argument of the validateInput
method matches the type of the third argument in the validator method (and so on, the n+2 argument of the validateInput
matches the type of the n+3 argument of the validator, n >= 0 ).
The API Gateway factory also features a validateOutput
method.
For example, a schema validation for yup
could be written like that:
manager.addValidation("yup", async function (data, rawData: any, schema: BaseSchema) {
// Let it throw when the validation fails
await schema.validate(data, {
strict: true,
abortEarly: true
});
}, (wrapper: BaseWrapperFactory<any>, schema: BaseSchema): [BaseSchema] => {
if (schema instanceof StringSchema) {
wrapper._messageType = MessageType.String;
} else if (schema instanceof NumberSchema) {
wrapper._messageType = MessageType.Number;
} else if (schema instanceof ObjectSchema) {
wrapper._messageType = MessageType.Object;
}
return [schema]
})
Using pre-baked validators
We provide for you npm packages bundling common validators, notably:
aws-lambda-handlers-yup
: Yup schema validationaws-lambda-handlers-zod
: Zod schema validationaws-lambda-handlers-ajv
: AJV schema validation
They expose one default export, a function taking a LambdaFactoryManager
and returning another LambdaFactoryManager
with the validator included.
import yupValidation from 'aws-lambda-handlers-yup';
const mgr = yupValidation( new LambdaFactoryManager() );
yup
and zod
and ajv
are listed as dependencies in their respective packages, which means that if you install them with your manager, your bundler will include them with your manager and, in turn, your lambda will also be bundles will ALL validators you have installed (whether you actually use the validation or not). This shouldn't affect the runtime, but it will bloat a bit your package size.
API Gateway
If you need to fail and API Gateway lambda, you may decide to throw an HTTPError (e.g. throw new HTTPError.BAD_REQUEST()
) and the correct status code will be used. Otherwise, status code 500 will be used.
JSON, String, Number or Buffer ?
The API Gateway, SNS and SQS pass the message body (or request as a string), and we need to make some guesswork to determine if it should be JSON parsed, base64 parsed, number parsed or not parsed at all.
Here are the rules we generally apply:
If you have called the yup, zod or ajv validator, we infer from the
interface
of the schema and set it for you..If the schema is not set, but
setTsInputType
was called, then the handler will use JSON.parseIf
setNumberInputType
,setStringInputType
orsetBinaryInputType
is used instead ofsetTsInputType
, then the handler will parse a float, nothing and a base64 buffer, respectivelyIf nothing is called, there will do no parsing and the type will unknown anyway. In other words, you will get a string for API Gateway, SQS and SNS, and potentially a JSON for the Event Bridge.
For the API Gateway, if the
Content-Type
headers of the request areapplication/json
, we'll use JSON.parse to parse the body.
Metering
If you have configured the Opentelementry Metrics SDK, then the following metrics will automatically be acquired.
Note that you can change the name of the metrics using the .configureRuntime()
method (in the second argument, with type completion)
Note: Make sure your opentelemetry metrics sdk (@opentelemetry/sdk-trace-node and @opentelemetry/sdk-trace-base) is at version at least 1.9.1. Some previous versions do not implement the most recent standard of the forceFlush()
method.
General metrics
lambda_exec_total
(counter): Number of total lambda invocationslambda_error_total
(counter): Number of errored invocations (any lambda that throws an unhandled error)lambda_cold_start_total
(counter): Number of cold startslambda_exec_time
(counter): Execution time in seconds
Note that the execution time we calculate can be significantly lower than the one provided by AWS, especially in case of a cold start (and more particularly when you are using auto-instrumementation for large libraries like aws-sdk v2). For a more accurate reading, we recommand you looking into the AWS Telemetry API which can give you more accurate results, but is outside of the scope of this framework.
API Gateway
In addition, the API Gateway will record
http_requests_total
(counter): HTTP Request (equalslambda_exec_total
) with added cardinality by:- Status code (
status_code
) - HTTP method (
method
)
- Status code (
SNS
sns_records_total
(counter): Total number of SNS records (equalslambda_exec_total
given that SNS can receive only 1 record at the time) with added cardinality by- Topic (
topic
) - Event source (
source
)
- Topic (
SQS
sqs_records_total
(counter): Total number of SQS records (is larger or equal tolambda_exec_total
given that SQS can process multiple records at the time) with added cardinality by- Event source (
source
) (the name of the queue) - AWS Region (
region
)
- Event source (
Note that an failed invocation counts towards a status 500
Using Sentry
Sentry's configuration is likely to be used across your organisation's microservices, save for its DSN, which is likely to be one per service.
You may compose a manager using .configureSentry( opts: Sentry.NodeOptions, expand: boolean )
(see @sentry/node), and compose it as many times as you see fit (Note that the configuration is mutable, i.e. the configureSentry
method does not return a new manager)
The way to configure Sentry is to do it on the manager level:
// path/to/manager.ts
import { LambdaFactoryManager } from 'aws-lambda-handlers';
const mgr = new LambdaFactoryManager().configureSentry(
{
enabled: true,
},
true
);
// We'll import the manager later on !
export default mgr;
It would be a common pattern to have a shared Sentry configuration for your whole organisation, used across all services, and then overwrite the DSN in each service:
// Import an org-wide manager
import manager from '@myorg/my-lambda-manager'; // Image you published your utility manager there
const myNewManager = manager.configureSentryDSN(MY_SENTRY_DSN);
export default myNewManager; // Optional
Because the configuration is mutable, lambda handlers can still reference @myorg/my-lambda-manager
and inherit the correct DSN.
Disabling Sentry
Additionally, Sentry can be disabled on a per-lambda basis using
wrapperFactory.sentryDisable();
or by setting the environment variable DISABLE_SENTRY in the lambda's configuration (useful to avoid having to rebuild when you want to temporarily disable Sentry)
Secret injection
Another cool feature of those lambda wrappers is that secrets from the AWS Secret Manager can be injected before the handler is called. Secrets are fetched during a cold start, of after a 2h cache has expired, but otherwise, the secret values are cached and reused between invocations.
Secrets are exposed in two ways:
- Injected into process.env
- Available in the controller method (the 2nd argument)
controllerFactory.needsSecret(
source, // Use 'aws' for the default AWS secret manager
'key',
'SecretName',
'SecretKey',
meta, // Use undefined for the default AWS secret manager
true
);
class Controller implements RouteHandler {
handler: IfHandler<RouteHandler> = async (data, secrets) => {
// ^^^^^^^^
// secrets is of type Record<"key", string>
// secrets.key is available as type "string" for use
// process.env.key is also available for use
};
}
Dealing with Key-Value Secrets
AWS Secrets can be of JSON type. It is pretty common to store a simple key-value structure in AWS, which we support for retrieval:
controllerFactory.needsSecret(
source,
'process_env_key',
'SecretName',
'SecretKey',
meta,
true
);
Note that the lambda will fail if the provided secret is NOT JSON-valid, except if the required
parameter is false
.
Dealing with String Secrets
By setting undefined
as the second parameter, the string version of the JSON is returned.
controllerFactory.needsSecret(
source,
'process_env_key',
'SecretName',
undefined,
meta,
true
);
When the last parameter of the needsSecret
method is true, the secret is required and the lambda will fail if it can't be found. When false, the method will be called, but the secret may be undefined.
Providing a secret list to the manager
Imagine an object aws_secrets
contains the list of all available secrets in the format
enum ENUM_OF_SECRET_NAME {
'SecretKey',
'SecretOtherKey',
}
export const aws_secrets = {
secretName: ENUM_OF_SECRET_NAME,
otherSecretName: ENUM_OF_OTHER_SECRET_NAME,
};
By setting the secret list into the manager, they can provide type safety when calling needsSecret
:
import { LambdaFactoryManager } from 'aws-lambda-handlers';
const mgr = new LambdaFactoryManager().setAWSSecrets(aws_secrets);
// Imagine a list of secrets, indexed by secret name on the first level, and secret key (for key-value secrets) on the second level
export default mgr;
///
mgr
.apiGatewayWrapperFactory('read')
.needsSecret('aws', 'key', 'secretName', 'SecretKey');
Autocompletion of the secret name:
Autocompletion of the secret key:
Providing alternative secret sources
Since v2, it is possible to specify an implementation for secret managers other than the AWS secrets manager (for example, Hashicorp Vault, or GCP)
Fetching credentials from other sources will typically require authentication, and you can store the authentication credentials in the AWS secrets manager, which will be retrieved before your custom fetcher is called.
In addition, when tuning the manager, you can require that the services consuming your manager (when using needsSecret
) to specify an extra set of arguments along with the secretName
and secretKey
parameters. This "meta information" may be used to alter the behaviour of your fetcher. For example, the region where the secret manager is located, or the namespace of the secret, its version, etc...
The fetching logic is written at the manager level, so it is by default shared across projects.
type META = {
"metaKey": string
};
const awsSecrets = {
"Hashicorp": {
"Auth": "Auth",
"OtherInfo": "OtherInfo"
}
}
// K-V of secrets stored in your other secret manager
const otherSecrets = {
"Secret": {
"Key": "Key",
"OtherKey": "OtherKey",
},
"Secret2": {
"Key": "Key",
"OtherKey": "OtherKey",
}
}
const mgr = new LambdaFactoryManager()
.setAWSSecrets( awsSecrets )
.addSecretSource<META>()( // Note here the "special" syntax, due to the fact that typescript doesn't have partial type inference at the time of writing
"HashicorpVault",
otherSecrets,
( aws ) => { // aws is a convenience function helping with auto-completion, based on the secrets passed to the manager in .setAWSSecrets()
return {
// With auto-completion if you're using VSCode :) !
"authKey": aws("Hashicorp", "Auth", true),
"otherKey": aws("Hashicorp", "OtherInfo" ) // Required defaults to true
};
},
async ( toFetch, awsSecrets ) => {
/*
toFetch is of type
Record<string, {
source: string,
secret: string,
secretKey?: string,
meta: META,
required: boolean
}>
Where the key of the record is to be reused as the key in the return object of type Record<string, string | undefined>
*/
const hashicorp_auth = awsSecrets.authKey;
const other_helper_secret_from_aws = awsSecrets.otherKey;
// Possible implementation
let out: Record<string, string> = {};
for( let [ k, secret ] of Object.entries( toFetch ) ) {
out[k] = // Fetch here the secret;
}
return out;
}
);
Note that the prefetched AWS secrets are only fetched if the consumer actually requires a secret from the additional secret source. In that case, those prefetching secrets end up in the configuration and can be picked up by some of your IaC tools if you wish it.
In the example above, the "Hashicorp" secret is stored in AWS and prefetched at runtime. The aws
method provided in the prefetch definition callback provides is just a helper to help with the configuration by providing auto-completion of the aws secrets:
If you previously passed secrets via
setAWSSecrets
, auto-completion is enabled and the typescript compiler will complain if you require a secret that "doesn't exist" (and you'll need to silence it).If you do not provide any AWS secrets, parameters of the
aws
method become( string, string | undefined )
and therefore any string can be passed without TS complaining.
As the lambda consumes the manager, the developer may now call:
// Auto-completion here as well !
api.needsSecret(
'HashicorpVault', // Same value as passed to the .addSecretSource method
'injectedKey', // Any string
'Secret',
'Key',
{
metaKey: 'metaVal',
},
true
);
Which can then be consumed by the handler as injectedKey
.
Visit this example for a more complete example.
Configuring runtime
There is a certain level of configuration you can use in order to control the behaviour, notably of unhandled errors, of the wrappers. For example, you may not wish for unhandled errors to raise an exception with Sentry, or register with Opentelemetry. You may also wish to decide what happens when schema validation fails. Those configurations can be done at the manager level (again, to be used across your organisation/services) and can be overridden on a per-lambda basis.
Manager level
Simply call the following:
const mgr = new LambdaFactoryManager().setRuntimeConfig({
_general: {
// General configuration for all types of even sources
recordExceptionOnLambdaFail: true, // When your inner wrapper throws an unhandled error, should we record the exception ?
logInput: false // Whether to log (info level) the input data of the lambda handler
},
apiGateway: {
recordExceptionOnValidationFail: true, // When the schema validation fails, should we record the exception ?
},
eventBridge: {
failLambdaOnValidationFail: true, // When the validation fails, should we make the lambda fail (true) or just return and do nothing (false) ?
recordExceptionOnValidationFail: true, // When the schema validation fails, should we record the exception ?
},
sns: {
recordExceptionOnValidationFail: true, // When the schema validation fails, should we record the exception ?
silenceRecordOnValidationFail: false, // When the schema validation fails, should we tag the record for a retry ?
},
sqs: {
recordExceptionOnValidationFail: true, // When the schema validation fails, should we record the exception ?
silenceRecordOnValidationFail: false, // When the schema validation fails, should we tag the record for a retry ?
},
});
Notes:
- For SNS and SQS, if you want to use dead-letter queues, then
silenceRecordOnValidationFail
should be set tofalse
.true
will just not execute your handler and exit silently. For the DLQ to work, the record needs to fail, and therefore you need AWS to retry it.
Wrapper handler level
For each wrapper handler (one for each event source), you can call the same function with two parameters:
wrapperFactory.configureRuntime(SpecificRuntimeConfig, GeneralRuntimeConfig);
Where SpecificRuntimeConfig
matches the config for the API Gateway, EB, SNS and SQS (see section "Manager level") and GeneralRuntimeConfig
matches the config under the key _general
(again, see above for an example of the payload)
Specificifities
API Gateway
Input
The payload passed to your handler is of type Request<T>
( where T
is the static type set in setTsInputType
or infered from the schema ).
The payload may be retrieved using:
declare const request: Request<any>;
// Retrieves the payload, JSON parsed and validated
const payload = request.getData();
// Returns the raw APIGatewayProxyEvent, where the body is a string
const raw = request.getRawData()
Output
To return an API Gateway Response, you are expected to return a HTTPResponse
, using the static constructors:
return HTTPResponse.OK(/* your data */);
// or
return HTTPResponse.OK_NO_CONTENT();
// or
// ... other static methods
If you set an output type with setTsOutputType
, typescript will enforce static type safety in your response and you must conform to it.
If you set an output schema with setOutputSchema
, javascript will validate your payload. If the payload does not validate, an HTTPError 422 will be sent to the upstream caller, in order to protect it from failing further.
To reply with a managed Error, use the static constructor methods on HTTPError
, which take an Error or a string in their static constructor methods.
return HTTPError.BAD_REQUEST(error);
// or
return HTTPError.BAD_REQUEST('Failure !');
Errors can be "acceptable" or "anormal". An anormal error will be registered with Sentry and Opentelemetry, and should indicate a condition that your service shouldn't enter. If this condition is a consequence of an invalid payload, do not set the error to anormal. This is a problem with the sender of the request. To make an error anormal, just to do following
return HTTPError.BAD_REQUEST(error).anormal();
Note: HTTPError.INTERNAL_ERROR()
is by default anormal.
In summary, the API Gateway handler should return Promise<HTTPError | HTTPResponse<T>>
:
Error handling
When your lambda throws an error, the wrapper will catch it and automatically reply with HTTPResponse.INTERNAL_ERROR( error )
, which means it's considered "anormal" and will register the exception with Sentry as well as fail the Opentelemetry span. In other words, it's perfectly acceptable to let the handler fail.
Event Bridge
Input
The input type of the event bridge is of type AwsEventBridgeEvent<T>
, and the following methods are exposed
declare const data: AwsEventBridgeEvent<any>;
data.getData(); // => T
data.getSource(); // Returns the event source field
data.getDetailType(); // Returns the event detail-type field
data.getRawData(); // Returns the raw underlying EventBridgeEvent<string, T> object
Output
The event bridge lambda is not expect to return anything, but you may return if you so wishes. The value will be discarded.
In the following cases will Sentry and Opentelemetry pick up errors:
- When the schema doesn't validate the data
- When an unhandled exception is thrown from the lambda
A note on error handling in controllers
Error handling is an important part of the Lambda handler logic. Here is a list of good practices
- Let the handler fail in case of unexpected errors: We'll catch it for you and reply with an error 500 (for the API gateway at least). Same for SQS, we'll handle notifying the entry-point handler that the message processing has failed. We'll also notify Sentry and fail the span in Opentelemetry. Finally, we'll log appropriate messages.
- When returning error, use the class HTTPError: It allows us to implement some extra logic when the request fails. Also, it allows you to not respect to type
T
inRequest<T>
. - For errors that should be recorded, return an error like this:
return HTTPError.BAD_REQUEST( error ).anormal()
: Any error set as "anormal" will trigger a Sentry error, register the exception in Opentelemetry and fail the tracing span. HTTPResponse.INTERNAL_ERROR
is alwaysanormal
and will always register: You do not need to call.anormal()