Security News
JSR Working Group Kicks Off with Ambitious Roadmap and Plans for Open Governance
At its inaugural meeting, the JSR Working Group outlined plans for an open governance model and a roadmap to enhance JavaScript package management.
aws-lambda-handlers
Advanced tools
Enhance AWS Lambdas with strong typing, secrets, schema validation, opentelemetry and sentry
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
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.
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)
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:
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 !).
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:
wrapFunc
method and be done.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.
npm i aws-lambda-handlers
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:
// path/to/manager.ts
import { LambdaFactoryManager } from 'aws-lambda-handlers';
const mgr = new LambdaFactoryManager();
// We'll import the manager later on !
export default mgr;
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.
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>;
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.
In this section we explore the benefits that our approach brings.
Wrapper factory constructors are available for
manager.apiGatewayWrapperFactory( handler: string );
manager.eventBridgeWrapperFactory( handler: string );
manager.sqsWrapperFactory( handler: string );
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.
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
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>
}
*/
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).//====================================================================
// 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();
};
}
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
body
field for the API gateway (will be JSON.parse'd if the Content-Type is application/json)detail
field for the Event Bridgemessage
content for SQS and SNS.setTsOutputType<T>()
informs the type of response the controller is supposed to return (or an instance of HTTPError
if the controller failed). Only applies to API GatewaysetInputSchema<SCHEMA_TYPE>( schema )
and setOutputSchema<SCHEMA_TYPE>( schema )
add a runtime verification of a yup
schema. When setTsInputType
is not defined but setInputSchema
is, then the controller is expected to received the result of InferType< SCHEMA_TYPE >
instead of T
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 into process.env[ key ]
. If not, it will fetch it from AWS and inject it into process.env
. It will also be made available in the handler method with strong typing.
The required
field can be used to outrightly fail the lambda when the secret is not found. Note that secretName
and secretKey
have auto-completion and will report a TS error if you have provided a secret list in the manager.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 has 2 requirements:
static async init
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:
myResource
is of type MyResource
, and not of type MyResource | undefined
)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) => {};
}
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:
.setTsInputType<InferType<typeof schema>>()
.setTsInputType<z.infer<typeof schema>>()
.setTsInputType<FromSchema<typeof schema>>()
Note that this doesn't give you runtime validation yet.
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:
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]
})
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 validationThey 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.
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.
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.parse
If setNumberInputType
, setStringInputType
or setBinaryInputType
is used instead of setTsInputType
, then the handler will parse a float, nothing and a base64 buffer, respectively
If 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 are application/json
, we'll use JSON.parse to parse the body.
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.
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 secondsNote 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.
In addition, the API Gateway will record
http_requests_total
(counter): HTTP Request (equals lambda_exec_total
) with added cardinality by:
status_code
)method
)sns_records_total
(counter): Total number of SNS records (equals lambda_exec_total
given that SNS can receive only 1 record at the time) with added cardinality by
topic
)source
)sqs_records_total
(counter): Total number of SQS records (is larger or equal to lambda_exec_total
given that SQS can process multiple records at the time) with added cardinality by
source
) (the name of the queue)region
)Note that an failed invocation counts towards a status 500
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.
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)
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:
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
};
}
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
.
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.
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:
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.
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.
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:
silenceRecordOnValidationFail
should be set to false
. 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.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)
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()
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>>
:
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.
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
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:
Error handling is an important part of the Lambda handler logic. Here is a list of good practices
T
in Request<T>
.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 always anormal
and will always register: You do not need to call .anormal()
FAQs
Enhance AWS Lambdas with strong typing, secrets, schema validation, opentelemetry and sentry
We found that aws-lambda-handlers demonstrated a not healthy version release cadence and project activity because the last version was released a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?
Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.
Security News
At its inaugural meeting, the JSR Working Group outlined plans for an open governance model and a roadmap to enhance JavaScript package management.
Security News
Research
An advanced npm supply chain attack is leveraging Ethereum smart contracts for decentralized, persistent malware control, evading traditional defenses.
Security News
Research
Attackers are impersonating Sindre Sorhus on npm with a fake 'chalk-node' package containing a malicious backdoor to compromise developers' projects.