RFC: Event Handler for REST APIs · aws-powertools/powertools-lambda-typescript · Discussion #3500 (original) (raw)

Note

The RFC has concluded, a big thank you to everyone who took the time to read and engage with it. You can follow the progress of the implementation in this milestone and here (#3251).


#413

Which area does this RFC relate to?

Event Handler

Summary

Resolvers handle request resolution, including one or more routers, and give access to the current event via typed properties.

While the code samples in the RFC will use mainly APIGatewayRestResolver, the first iteration of the feature will have in scope also the APIGatewayHttpResolver and LambdaFunctionUrlResolver resolvers. Other resolvers present in the Python version of Powertools for AWS will be added in subsequent releases and based on demand.

In terms of patterns of usage, Powertools for AWS Lambda (TypeScript) generally supports three patterns:

  1. manual usage - most verbose, but usually adoptable in any codebase with minimal changes
  2. class method decorator usage - least verbose, but requires adopting an experimental TypeScript feature, and usage of OOP patterns for
  3. Middy.js middleware usage - moderately verbose but requires extra 3rd party dependency (@middy/core)

The first iteration of the feature will include only 1/ and 2/, while Middy.js middleware usage will be added later if there's demand.

Use case

Powertools for AWS customers have been asking us to provide a utility similar to the one present in Powertools for AWS Lambda (Python) that allows them to easily build REST APIs backed by an AWS Lambda function.

While there are already many popular frameworks in the Node.js ecosystem such as Express.js, Fastify, and Hono, none of them targets specifically and uniquely Lambda customers, and many enterprise customers who have already adopted Powertools for AWS in their workloads, would prefer a solution delivered by us rather than relying on a 3rd party dependency.

For this reason we are continuing our work to improve feature parity between Powertools for AWS versions and will offer an Event Handler utility in this version. We don't necessarily expect customers who have had success and are happy with other frameworks to migrate to Event Handler; this utility is rather for those who appreciate our commitment to supply chain security and our efforts to build lightweight utilities specifically focused on Lambda.

The goal of this RFC is to propose a feature set that matches the one present in Powertools for AWS Lambda (Python) while also being idiomatic with the Node.js ecosystem and adding selected features that make sense in this version.

Proposal

Response auto-serialization

For your convenience, you can return a plain object response and we will automatically:

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers';

const app = new APIGatewayResolver();

app.get('/ping', () => { return { message: 'pong' }; });

export const handler = async (event, context) => app.resolve(event, context);

/* response { "statusCode": 200, "multiValueHeaders": { "Content-Type": [ "application/json" ] }, "body": "{'message':'pong'}", "isBase64Encoded": false } */

If you want full control of the response, headers, and status code you can also return a Response object (more on this later).

Dynamic routes

You can use /todos/:todoId to configure dynamic URL paths, where :todoId will be resolved at runtime.

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers';

const app = new APIGatewayResolver();

class Lambda { @app.get('/todos/:todoId') public async getTodoById({ params }) { const { todoId } = params; const todos = await fetch(https://jsonplaceholder.typicode.com/todos/${todoId});

return { todos: await todos.json() }

} }

export const handler = async (event, context) => app.resolve(event, context);

Dynamic paths can also be nested and used at different levels, i.e. /todos/:todoId?/comments/:commentId/hidden/:isHidden and in all cases, the parameters should be strongly typed and customers should get type hints in their IDE like this:

1

Query Strings

Within app.currentEvent property, you can access all available query strings as an object via query.

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers';

const app = new APIGatewayResolver();

app.get('/search', () => { const query = app.currentEvent.query('q'); // access one at the time

const { limit, offset } = app.currentEvent.query(); // access multiple at once

return { message: 'Hello, World!' }; });

export const handler = async (event, context) => app.resolve(event, context);

Payload

You can access the raw payload via the body property, or if it's a JSON string you can quickly deserialize it via the json() method.

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers';

const app = new APIGatewayResolver();

app.post('/todo', async () => { const rawBody = app.currentEvent.body;

const body = await app.currentEvent.json();

return { message: 'Hello, World!' }; });

export const handler = async (event, context) => app.resolve(event, context);

Headers

Similarly to query strings, you can access headers as dictionary via app.currentEvent.headers. Specifically for headers, it's a case-insensitive dictionary, so all lookups are case-insensitive.

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers';

const app = new APIGatewayResolver();

app.post('/todo', async () => { const apiKey = app.currentEvent.headers.get('X-Api-Key');

return { message: 'Hello, World!' }; });

export const handler = async (event, context) => app.resolve(event, context);

Catch-all routes

You can also create things like catch-all routes, for example the .+ expression allows you to handle an arbitrary number of paths within a request.

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers';

const app = new APIGatewayResolver();

app.get('.+', () => { return { message: 'Hello, World!' }; });

export const handler = async (event, context) => app.resolve(event, context);

HTTP Methods

You can use named methods to specify the HTTP method that should be handled in your functions. That is, app.<http_method>, where the HTTP method could be get(), post(), put(), patch(), delete(), head().

If you need to accept multiple HTTP methods in a single function, or support a custom HTTP method for which no method exists (e.g. TRACE), you can use the route() method and pass an array of HTTP methods.

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers';

const app = new APIGatewayResolver();

app.route('/todos/', () => { const data = JSON.parse(app.currentEvent.body || '{}'); const todos = await fetch(https://jsonplaceholder.typicode.com/todos, { body: JSON.stringify(data), });

return { todo: await todos.json(), }; }, { method: ['PUT', 'POST'] });

export const handler = async (event, context) => app.resolve(event, context);

Below you'll find sections about how we handle routes not found and method not allowed, as well as how to customize the default behavior.

Data validation

All resolvers can optionally coerce and validate incoming requests by setting enableValidation: true.

With this feature, we can now express how we expect our incoming data and response to look like. This moves data validation responsibilities to Event Handler resolvers, reducing a ton of boilerplate code.

You can pass an output schema to signal our resolver what shape you expect your data to be.

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers'; import { z } from 'zod';

const app = new APIGatewayResolver({ enableValidation: true, });

const todoSchema = z.object({ userId: z.number(), title: z.string(), completed: z.boolean() });

app.get('/todo/:todoId', async ({ todoId }) => { const response = await fetch(https://jsonplaceholder.typicode.com/todos/${todoId}`); const todo = await response.json();

return todo; }, { validation: { output: todoSchema } });

Handling validation errors

Any incoming request that fails validation will lead to a HTTP 422: Unprocessable Entity error response that will look similar to this:

{ "statusCode": 422, "body": "[{"type": "int_parsing", "loc": ["path", "todo_id"]}]}", "isBase64Encoded": false, "headers": { "Content-Type": "application/json" }, "cookies": [] }

You can customize the error message by catching the RequestValidationError exception. This is useful when you might have a security policy to return opaque validation errors, or have a company standard for API validation errors.

Here's an example where we catch validation errors, log all details for further investigation, and return the same HTTP 422 with an opaque error.

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers'; import { Logger } from '@aws-lambda-powertools/logger'; import { z } from 'zod';

const app = new APIGatewayResolver({ enableValidation: true, }); const logger = new Logger();

app.errorHandler(RequestValidationError, (error) => { logger.error('Request validation failed', { path: app.currentEvent.path, error, }) });

const todoSchema = z.object({ userId: z.number(), title: z.string(), completed: z.boolean() });

app.get('/todo/:todoId', async ({ todoId }) => { const response = await fetch(https://jsonplaceholder.typicode.com/todos/${todoId}`); const todo = await response.json();

return todo; }, { validation: { output: todoSchema } });

Validating payloads

You can do something similar with the incoming payload as well, in this case you can pass a schema to the validation.input property.

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers'; import { z } from 'zod';

const app = new APIGatewayResolver({ enableValidation: true, });

const todoSchema = z.object({ userId: z.number(), title: z.string(), completed: z.boolean() });

app.post('/todo', async ({ title, userId, completed }) => { // TODO: create todo

return true; }, { validation: { input: todoSchema, output: z.boolean() } });

We will automatically parse and validate the outer envelope of the event according to the resolver you are using, so that you can focus on providing the schema for the body only.

Enabling SwaggerUI

Since Event Handler supports OpenAPI, you can use SwaggerUI to visualize and interact with your API.

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers';

const app = new APIGatewayResolver({ enableValidation: true, });

app.enableSwagger({ path: '/swagger', });

TODO: routes w/ validation

export const handler = async (event, context) => app.resolve(event, context);

We will implement this feature as a complete opt-in feature and with as little overhead as possible. Because of this we will return the HTML needed to render the SwaggerUI but you will have to bundle your own swagger-ui-bundle.min.js together with your function or provide an url to a CDN-hosted version of it (i.e. this).

Accessing request details

Event Handler exposes the resolver request and Lambda context under convenient properties like: app.currentEvent and app.lambdaContext, this is why you see app.resolve(event, context) in every example.

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers'; import type { APIGatewayProxyEvent, Context } from 'aws-lambda';

const app = new APIGatewayResolver();

app.route('/todos/', () => { const event: APIGatewayProxyEvent = app.currentEvent; // event const context: Context = app.lambdaContext; // context

return { todo: await todos.json(), }; });

export const handler = async (event, context) => app.resolve(event, context);

Handling not found routes

By default, we return 404 for any unmatched route.

You can use the notFound() method or class method decorator to override this behavior, and return a custom response.

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers'; import { Logger } from '@aws-lambda-powertools/logger';

const logger = new Logger(); const app = new APIGatewayResolver();

app.notFound(async (error: Error) => { logger.info('Not found route', { path: app.currentEvent.path }); return { statusCode: 302, headers: { Location: '/login' } }
});

class Lambda { @app.get('/todos/:todoId') public async getTodoById({ params }) { const { todoId } = params; const todos = await fetch(https://jsonplaceholder.typicode.com/todos/${todoId});

return { todos: await todos.json() }

} }

export const handler = async (event, context) => app.resolve(event, context);

Handling methods not allowed

By default, we return 405 for any request that matches a route but is using a method with no resolver.

You can use the methodNotAllowed() method or class method decorator to override this behavior, and return a custom response.

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers'; import { Logger } from '@aws-lambda-powertools/logger';

const logger = new Logger(); const app = new APIGatewayResolver();

app.methodNotAllowed(async (error: Error) => { logger.info('Method not allowed', { path: app.currentEvent.path, method: app.currentEvent.method, });

return { statusCode: 403, }
});

class Lambda { @app.get('/todos/:todoId') public async getTodoById({ params }) { const { todoId } = params; const todos = await fetch(https://jsonplaceholder.typicode.com/todos/${todoId});

return { todos: await todos.json() }

} }

export const handler = async (event, context) => app.resolve(event, context);

Error handling

To keep your route handlers as focused as possible, you can use the errorHandler() method or class method decorator with any Error class or children. This allows you to handle common errors outside of your route, for example validation errors.

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers';

import { Logger } from '@aws-lambda-powertools/logger';

const logger = new Logger(); const app = new APIGatewayResolver();

class Lambda { @app.errorHandler(ZodError) public async handleInvalidRequest(error: ZodError) { logger.error('Malformed request', { path: app.currentEvent.path, });

return {
  statusCode: 400,
  headers: {
    'Content-Type': 'text/plain',
  },
  body: 'Invalid request parameters.'
}

}

@app.get('/todos/:todoId') public async getTodoById({ params }) { const { todoId } = params; const todos = await fetch(https://jsonplaceholder.typicode.com/todos/${todoId});

return { todos: await todos.json() }

} }

export const handler = async (event, context) => app.resolve(event, context);

The errorHandler() method also supports passing a list of error classes you want to handle with a single handler.

Internally, we’ll test the error being thrown using error instanceof YourError first, and then error.name === YourError.name, this allows us to ensure equality even when the imports or the bundle might be polluted with double imports.

Raising HTTP errors

You can easily raise any HTTP Error back to the client using one of the handy prebuilt error responses. This ensures your Lambda function doesn’t fail but return the correct HTTP response signalling the error.

We provide pre-defined errors for the most popular ones such as HTTP 400, 401, 404, 500, etc.

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers'; import { BadRequestError, InternalServerError, NotFoundError, ServiceError, UnauthorizedError, } from '@aws-lambda-powertools/event-handler/errors';

const app = new APIGatewayResolver();

app.get('/bad-request-error', () => { throw BadRequestError('Missing required parameter'); // HTTP 400 });

app.get('/unauthorized-error', () => { throw UnauthorizedError('Unauthorized'); // HTTP 401 });

app.get('/not-found-error', () => { throw NotFoundError(); // HTTP 404 });

app.get('/interanal-server-error', () => { throw InternalServerError('Internal server error'); // HTTP 500 });

app.get('/service-error', () => { throw ServiceError('Something went wrong!', { code: 502 }); // HTTP 502 });

export const handler = async (event, context) => app.resolve(event, context);

Custom Domain API Mappings

When using the Custom Domain API Mappings feature in Amazon API Gateway, you must use the stripPrefixes parameter in the APIGatewayRestResolver constructor.

Scenario: You have a custom domain api.mydomain.dev. Then you set a /payment mapping to forward any payment requests to your Payments API.

Challenge: This means you path value for any API requests will always contain /payment/<actual_request>, leading to HTTP 404 as Event Handler is trying to match what’s after payment/. This gets further complicated with an arbitrary level of nesting.

Solution: To address this, we use the stripPrefixes parameter to remove these prefixes before trying to match the routes you defined in your application.

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers';

const app = new APIGatewayResolver({ stripPrefixes: ['/payment'] });

/**

export const handler = async (event, context) => app.resolve(event, context);

After removing a path prefix with stripPrefixes, the new root path will automatically be mapped to the path argument of /.

For example. when using the stripPrefixes value of /pay, there is no difference between a request path of /pay and /pay/; and the path argument would be defined as just /.

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers';

/**

app.get('/subscription/:subscriptionId', async ({ params }) => { return { subscriptionId: params.subscriptionId, }; });

export const handler = async (event, context) => app.resolve(event, context);

Advanced use cases

CORS

You can configure CORS in each resolver constructor via the cors parameter with a CORSConfig class.

This will ensure that CORS headers are returned as part of the response when your functions match the path invoked and the Origin matches one of the allowed values.

Optionally, you can disable CORS on a per path basis with the cors: false option.

Pre-flight

Pre-flight (OPTIONS) calls are typically handled by API Gateway or Lambda Function URL. For ALB instead, you are expected to handle these requests yourself.

For convenience, we automatically handle them for you as long as you enable CORS in the constructor.

Defaults

For convenience, these are the default values when using CORSConfig to enable CORS:

Key Value Note
allowOrigin * Only use the default value for development. Never use * for production unless your use case strictly requires it
extraOrigins [] Additional origins to be allowed, in addition to the one specified in allowOrigin
allowHeaders ["Authorization", "Content-Type", "X-Amz-Date", "X-Api-Key", "Amz-Security-Token"] Additional headers that will be appended to the default list for your convenience
exposeHeaders [] Any additional header beyond the safe ones listed by the CORS specification
maxAge `` Only for pre-flight requests if you choose to have your function handle it instead of API Gateway
allowCredentials false Only necessary when working with cookies, authorization headers, or TLS client certificates

Compress

You can compress with gzip and base64 encode your responses via the compress parameter. You have the option to pass the compress parameter when working with a specific route.

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers';

const app = new APIGatewayResolver();

app.get('/subscription/:subscriptionId', async ({ params }) => { return { subscriptionId: params.subscriptionId, }; }, { compress: true, });

export const handler = async (event, context) => app.resolve(event, context);

The client must send the Accept-Encoding header, otherwise we will send a normal response.

Binary responses

For convenience, we automatically base64 encode binary responses. You can also use in combination with compress parameter if your client supports gzip.

Similar to the compress feature, the client must send the Accept header with the correct media type.

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers';

const app = new APIGatewayResolver();

app.get('/logo', async () => { TODO: response object });

export const handler = async (event, context) => app.resolve(event, context);

Notes:

Debug mode

You can enable debug mode via the POWERTOOLS_DEV or POWERTOOLS_EVENT_HANDLER_DEBUG environment variables.

This will enable full traceback errors in the response, log any request and response, and set CORS to allow any origin (*).

Since this might reveal sensitive information in your logs and relax CORS restrictions, we recommend you to use this only for local development and as sparingly as possible.

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers';

const app = new APIGatewayResolver();

app.get('/subscription/:subscriptionId', async ({ params }) => { return { subscriptionId: params.subscriptionId, }; });

export const handler = async (event, context) => app.resolve(event, context);

OpenAPI

When you enable data validation, we use a combination of standard-schema compatible schemas (Zod, Valibot, Arktypes) and OpenAPI type annotations to add constraints to your API's parameters.

In OpenAPI documentation tools like SwaggerUI, these annotations become readable descriptions, offering a self-explanatory API interface. This reduces boilerplate code while improving functionality and enabling auto-documentation.

Customizing OpenAPI parameters

Whenever you use OpenAPI parameters to validate query strings or path parameters, you can enhance validation and OpenAPI documentation by using any of these parameters:

Field name Type Description
alias string Alternative name for a field, used when serializing and deserializing data
validation_alias string Alternative name for a field during validation (but not serialization)
serialization_alias string Alternative name for a field during serialization (but not during validation)
description string Human-readable description
gt number Greater than. If set, value must be greater than this. Only applicable to numbers
ge number Greater than or equal. If set, value must be greater than or equal to this. Only applicable to numbers
lt number Less than. If set, value must be less than this. Only applicable to numbers
le number Less than or equal. If set, value must be less than or equal to this. Only applicable to numbers
min_length number Minimum length for strings
max_length number Maximum length for strings
pattern string A regular expression that the string must match.
strict boolean If true, strict validation is applied to the field. See Strict Mode for details
multiple_of number Value must be a multiple of this. Only applicable to numbers
allow_inf_nan boolean Allow inf, -inf, nan. Only applicable to numbers
max_digits number Maximum number of allow digits for strings
decimal_places number Maximum number of decimal places allowed for numbers
examples Array List of examples of the field
deprecated boolean Marks the field as deprecated
include_in_schema boolean If false the field will not be part of the exported OpenAPI schema
json_schema_extra JsonDict Any additional JSON schema data for the schema property

Customizing API operations

Customize your API endpoints by adding metadata to endpoint definitions.

Here's a breakdown of various customizable fields:

Field Name Type Description
summary string A concise overview of the main functionality of the endpoint. This brief introduction is usually displayed in autogenerated API documentation and helps consumers quickly understand what the endpoint does.
description string A more detailed explanation of the endpoint, which can include information about the operation's behavior, including side effects, error states, and other operational guidelines.
responses Record<number, Record<string, OpenAPIResponse>> A dictionary that maps each HTTP status code to a Response Object as defined by the OpenAPI Specification. This allows you to describe expected responses, including default or error messages, and their corresponding schemas or models for different status codes.
response_description str Provides the default textual description of the response sent by the endpoint when the operation is successful. It is intended to give a human-readable understanding of the result.
tags Array Tags are a way to categorize and group endpoints within the API documentation. They can help organize the operations by resources or other heuristic.
operation_id string A unique identifier for the operation, which can be used for referencing this operation in documentation or code. This ID must be unique across all operations described in the API.
include_in_schema boolean A boolean value that determines whether or not this operation should be included in the OpenAPI schema. Setting it to False can hide the endpoint from generated documentation and schema exports, which might be useful for private or experimental endpoints.
deprecated boolean A boolean value that determines whether or not this operation should be marked as deprecated in the OpenAPI schema.

To implement these customizations, include extra parameters when defining your routes:

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers';

const app = new APIGatewayResolver();

app.get('/subscription/:subscriptionId', async ({ params }) => { return { subscriptionId: params.subscriptionId, }; }, { openApi: { summary: 'Retrieves a subscription', description: 'Loads a subscription identified by a subscriptionId', responseDescription: 'The subscription object', responses: { 200: { description: 'subscription found' }, 404: { description: 'subscription not found' } }, tags: ['Subscription'] } });

export const handler = async (event, context) => app.resolve(event, context);

Split routes with Router

As you grow the number of routes a given Lambda function should handle, it is natural to either break into smaller Lambda functions, or split routes into separate files to ease maintenance - that's where the Router feature is useful.

Let's assume you have index.ts as your Lambda function entrypoint and routes in todos.ts. This is how you'd use the Router feature.

todos.ts

import { Router } from '@aws-lambda-powertools/event-handler';

const todosRouter = new Router();

todosRouter.get('/todos/', () => { const headers = todosRouter.currentEvent.headers; // currentEvent is available on the router const todos = await fetch(https://jsonplaceholder.typicode.com/todos/${todoId}); return { todo: await todos.json(), }; });

export { todosRouter };

index.ts

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers'; import { todosRouter } from './todos.js';

const app = new APIGatewayResolver(); app.includeRouter({ router: todosRouter });

export const handler = async (event, context) => app.resolve(event, context);

Route prefix

When necessary, you can set a prefix when including a router object. This means you could remove /todos prefix altogether.

todos.ts

import { Router } from '@aws-lambda-powertools/event-handler';

const todosRouter = new Router();

todosRouter.get('/', () => { const headers = todosRouter.currentEvent.headers; // currentEvent is available on the router const todos = await fetch(https://jsonplaceholder.typicode.com/todos/${todoId}); return { todo: await todos.json(), }; });

export { todosRouter };

index.ts

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers'; import { todosRouter } from './todos.js';

const app = new APIGatewayResolver(); app.includeRouter({ router: todosRouter, prefix: '/todos' });

export const handler = async (event, context) => app.resolve(event, context);

Specialized routers

You can use specialized router classes according to the type of event that you are resolving. This way you'll get type hints from your IDE as you access the currentEvent property.

Router Resolver curentEvent type
APIGatewayRouter APIGatewayRestResolver APIGatewayProxyEvent
APIGatewayHttpRouter APIGatewayHttpResolver APIGatewayProxyEventV2
LambdaFunctionUrlRouter LambdaFunctionUrlResolver LambdaFunctionUrlEvent

Sharing contextual data

You can use appendContext when you want to share data between your App and Router instances. Any data you share will be available via the context object available in your App or Router context.

We always clear data available in context after each invocation.

index.ts

import { APIGatewayResolver } from '@aws-lambda-powertools/event-handler/resolvers'; import { todosRouter } from './todos.js';

const app = new APIGatewayResolver(); app.includeRouter({ router: todosRouter, prefix: '/todos' });

export const handler = async (event, context) => app.appendContext({ isAdmin: true }); app.resolve(event, context);

import { Router } from '@aws-lambda-powertools/event-handler';

const todosRouter = new Router();

todosRouter.get('/', () => { const { isAdmin } = router.context; const headers = todosRouter.currentEvent.headers; // currentEvent is available on the router const todos = await fetch(https://jsonplaceholder.typicode.com/todos/${todoId}); return { todo: await todos.json(), }; });

export { todosRouter };

Out of scope

The following items are to be considered out of scope for the first iteration of the feature:

If you are interested in any of them and would like us to prioritize them right after the first version, please let us know under this RFC.

Potential challenges

TBD

Dependencies and Integrations

No response

Alternative solutions

Acknowledgment

Future readers

Please react with 👍 and your use case to help us understand customer demand.