2 releases
0.1.2 | Jan 18, 2024 |
---|---|
0.1.1 | Jan 18, 2024 |
#24 in #api-gateway
98KB
1.5K
SLoC
OpenAPI Lambda for Rust 🦀
OpenAPI Lambda for Rust takes an OpenAPI definition and generates Rust boilerplate code for running the API "serverlessly" on AWS Lambda behind an Amazon API Gateway REST API. The generated code automatically routes requests, parses parameters, marshals responses, invokes middleware to authenticate requests, and handles related errors. This project's goal is to enable developers to focus on business logic, not boilerplate.
This project is not affiliated with the OpenAPI Initiative or Amazon Web Services (AWS).
Usage
1. Add dependencies
Add openapi-lambda
as a dependency and openapi-lambda-codegen
as a build dependency to your
crate's Cargo.toml
:
[dependencies]
openapi-lambda = "0.1"
[build-dependencies]
openapi-lambda-codegen = "0.1"
Both crates must have identical version numbers in Cargo.lock
.
2. Generate code
Add a build.rs
Rust build script to your crate's root directory (see comments below):
use openapi_lambda_codegen::{ApiLambda, CodeGenerator, LambdaArn};
fn main() {
CodeGenerator::new(
// Path to OpenAPI definition (relative to build.rs).
"openapi.yaml",
// Output path to a directory for generating artifacts. This directory should be added to
// `.gitignore`.
".openapi-lambda",
)
// Define one or more Lambda functions for implementing the API. A single "mono-Lambda" may
// be used to handle all API endpoints, or endpoints may be grouped into multiple Lambda
// functions using filters (see docs). Note that Lambda cold start time is roughly
// proportional to the size of each Lambda binary, so consider splitting APIs into smaller
// Lambda functions to reduce cold start times.
.add_api_lambda(ApiLambda::new(
// Name of the generated Rust module that will contain the API types.
"backend",
// AWS CloudFormation logical ID or Amazon Resource Name (ARN) that the Lambda function
// will have when deployed to AWS. This value is used for adding
// `x-amazon-apigateway-integration` extensions to the OpenAPI definition, which tells
// API Gateway which Lambda function to use for handling each API request. If using
// CloudFormation/SAM with a logical ID, the ARN will be populated automatically during
// deployment.
LambdaArn::cloud_formation("BackendApiFunction.Alias")
))
.generate();
}
Include the generated code in your crate's src/lib.rs
:
include!(concat!(env!("OUT_DIR"), "/out.rs"));
The generated file out.rs
defines a module named models
containing Rust types for the input
parameters and request/response bodies defined in the OpenAPI definition. It also defines one
module for each call to add_api_lambda()
, which defines an Api
trait with one
method for each operation (path + HTTP method) defined in the OpenAPI definition.
Generate documentation
It is often helpful to refer to rustdoc documentation to understand the generated models and API types. To generate documentation, run:
cargo doc --open
3. Implement API handlers
To implement the API, implement the generated Api
trait(s). To help you get started,
the code generator creates files named <MODULE_NAME>_handler.rs
in the configured output directory (e.g., .openapi-lambda/backend_handler.rs
) with a placeholder
implementation of each Api
trait. Copy these files into src/
, define corresponding modules in
src/lib.rs
(e.g., mod backend_handler
),
and replace each todo!()
to implement the API.
Each Api
trait declares two associated types that you must define in your implementation:
AuthOk
: the outcome of successful request authentication returned by your middleware (see below). This might represent a user, authentication session, or other abstraction relevant to your API. If none of the API endpoints require authentication, simply use the unit type (()
).HandlerError
: the error type returned by each API handler method. A typical API will define anenum
type for errors and have theApi::respond_to_handler_error()
method return appropriate HTTP responses depending on the nature of the error (e.g., status code 403 for access denied errors).
4. Implement middleware
The openapi_lambda::Middleware
trait defines the interface for authenticating requests and
optionally wrapping each API handler to add functionality such as logging and telemetry.
A convenience
UnauthenticatedMiddleware
implementation is provided for APIs with no endpoints
that require authentication.
Authenticating requests
The Middleware::AuthOk
associated type represents the outcome of a successful call to the
Middleware::authenticate()
trait method. This is a type you define that might represent a user,
authentication session, or
other abstraction relevant to your API. If none of the API endpoints require authentication, simply
use the unit type (()
). The Middleware::AuthOk
associated type must match the Api::AuthOk
associated type in your Api
trait implementation(s).
The Middleware::authenticate()
method provides a headers
argument with access to all request
headers, allowing you to authenticate requests using headers such as
Authorization
or
Cookie
.
It also provides a lambda_context
argument with access to Amazon Cognito identity information
if using an API Gateway
Cognito user pool authorizer.
If the request fails
to authenticate, be sure to return an HttpResponse
with the appropriate HTTP status code
(i.e., 401).
5. Add binary target(s)
Define a binary target for each Lambda function (e.g., bin/bootstrap_backend.rs
) to bootstrap the
Lambda runtime. The openapi_lambda::run_lambda()
function is the recommended entry point to start
the Lambda runtime and begin handling API requests:
// Replace `my_api` with the name of your crate and `backend` with the name of the module
// passed to `ApiLambda::new()`.
use my_api::backend::Api;
use my_api::backend_handler::BackendApiHandler;
use openapi_lambda::run_lambda;
#[tokio::main]
pub async fn main() {
let api = BackendApiHandler::new(...);
let middleware = ...; // Instantiate your middleware here.
run_lambda(|event| api.dispatch_request(event, &middleware)).await
}
6. Compile binaries
Cargo Lambda
The easiest way to compile Lambda functions written in Rust is with Cargo Lambda, which handles any necessary cross-compilation from your development environment to AWS Lambda (either x86-64 or ARM-based).
In addition to installing Cargo Lambda, be sure to install the relevant target
(x86_64-unknown-linux-gnu
or aarch64-unknown-linux-gnu
depending on the targeted Lambda
function architecture) for your Rust toolchain (e.g., via
rustup target add
).
After installing Cargo
Lambda, run the following command to build Lambda bootstrap
binaries in the target/lambda/
directory:
cargo lambda build --release
If targeting ARM-based Lambda functions, be sure to add the --arm64
flag.
musl-cross
An alternative to Cargo Lambda is musl-cross
,
which provides better backtrace support when
compiling on certain environments such as macOS with Apple Silicon. A
Homebrew package is available for easy
installation on macOS.
In addition to installing musl-cross
, be sure to install the relevant target
(x86_64-unknown-linux-musl
or aarch64-unknown-linux-musl
depending on the targeted Lambda
function architecture) for your Rust toolchain (e.g., via
rustup target add
).
To compile binaries for x86-64 Lambda functions, run:
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-linux-musl-gcc \
cargo build --target x86_64-unknown-linux-musl --release
To compile binaries for ARM Lambda functions, run:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-musl-gcc \
cargo build --target aarch64-unknown-linux-musl --release
The final binaries are written to the target/x86_64-unknown-linux-musl/release/
or
target/aarch64-unknown-linux-musl/release/
directory, depending on the target architecture.
7. Test and deploy
Deploying to AWS involves creating one or more Lambda functions and an API Gateway REST API.
Lambda functions written in Rust should use one of the provided
Lambda runtimes.
The provided
runtimes require each Lambda function to include a binary named bootstrap
, which
is produced by the compilation step above.
An API Gateway REST API uses an OpenAPI definition annotated with
x-amazon-apigateway-integration
extensions that determine which Lambda function is used for
handling each API endpoint. The openapi-lambda-codegen
crate writes an annotated
OpenAPI definition suitable for this purpose to a file named openapi-apigw.yaml
in the output
directory specified in build.rs
(e.g., .openapi-lambda/openapi-apigw.yaml
). This OpenAPI
definition is modified from the input to help adhere to the
subset of OpenAPI features
supported by Amazon API Gateway. In particular, all references are merged into a single file, and
discriminator
properties are removed.
As a best practice, consider using an infrastructure-as-code (IaC) solution such as AWS CloudFormation, AWS Serverless Application Model (SAM), or Terraform.
AWS Serverless Application Model (SAM)
The
Petstore example
provides a working AWS SAM template (template.yaml
) and accompanying Makefile
.
AWS SAM provides both a streamlined version of CloudFormation tailored to serverless use cases and a command-line interface (CLI) for deploying to AWS and locally testing APIs.
When defining a SAM CloudFormation stack template, define an
AWS::Serverless::Function
resource for each Lambda function. Be sure to specify the same logical ID (i.e., YAML key) in your
build.rs
Rust build script using the LambdaArn::cloud_formation()
function. If
specifying an
AutoPublishAlias
property (recommended), append the .Alias
suffix to the logical ID passed to
LambdaArn::cloud_formation()
. This ensures that API Gateway always executes the version of your
function associated with the specified alias. Aliases help support quick rollbacks in production
by simply updating the alias to point to a previous version of the Lambda function, without waiting
for a full stack deploy.
Each AWS::Serverless::Function
resource should specify
BuildMethod: makefile
in the Metadata
attribute (see
Building custom runtimes). The resource should also specify a CodeUri
attribute that points
to a directory containing your crate. A Makefile
must exist in the specified directory. The
Makefile
must define a target named build-LOGICAL_ID
, where LOGICAL_ID
is the logical ID (YAML
key) of the resource in the SAM template. The build-LOGICAL_ID
target must copy a binary named
bootstrap
to the directory referenced by the ARTIFACTS_DIR
environment variable (set at build
time by the AWS SAM CLI). See the
Petstore example
for details.
The SAM template must also include an
AWS::Serverless::Api
resource that defines the API Gateway REST API. Use the
AWS::Include
transform along with the annotated OpenAPI definition openapi-apigw.yaml
, which automatically
resolves the logical IDs of each Lambda function to the corresponding
Amazon Resource Name (ARN)
during deployment:
Resources:
MyApi:
Type: AWS::Serverless::Api
Properties:
Name: my-api
StageName: prod
DefinitionBody:
Fn::Transform:
Name: AWS::Include
Parameters:
Location: .openapi-lambda/openapi-apigw.yaml
Before testing or deploying an AWS SAM template, build it by running:
sam build
To start the API locally for testing, run:
sam local start-api
To deploy the template to AWS, run:
sam deploy
Example
The Petstore example illustrates how to use this crate together with AWS SAM to build, test, and deploy an API to AWS Lambda behind an Amazon API Gateway REST API.
Minimum supported Rust version (MSRV)
The minimum supported Rust version (MSRV) of this crate is 1.70.
This crate maintains a policy of supporting Rust releases going back at least 6 months. Changes that break compatibility with Rust releases older than 6 months will not be considered SemVer breaking changes and will not result in a new major version number for this crate. MSRV changes will coincide with minor version updates and will not happen in patch releases.
Logging
The generated code uses the log
crate to log requests. Consider
using the log4rs
or
env_logger
crates to enable logging in each Lambda
function's main()
entry point.
Enabling TRACE
level logs will log the raw contents of each request and response. This can be
useful for debugging, but TRACE
logs should never be enabled in production. In addition to
being verbose (incurring
Amazon CloudWatch Logs
charges), enabling TRACE
logs in production could log sensitive secrets such as passwords and API
keys.
OpenAPI support
The code generator supports a large portion of the
OpenAPI 3.0 specification,
but gaps remain. If you encounter an unimplemented!
error when generating code, please
submit a GitHub issue or open a
pull request (see
CONTRIBUTING.md
).
References ($ref
) found in OpenAPI definitions are supported, including references to objects in
other files. However, references that resolve to other references are currently not supported.
Every endpoint must have an operationId
property, which must be unique across all endpoints. The
operationId
property is used for routing requests and naming the handler method and related types
in the generated code.
Authenticated vs. unauthenticated API endpoints
By default, all API endpoints are assumed to require authentication. This means that
Middleware::authenticate()
is invoked, and the AuthOk
result is passed to the handler
method.
To denote an endpoint as unauthenticated, add an empty object ({}
) to the
security
property for the endpoint. For example:
security:
- {}
Unauthenticated endpoints will have their handlers invoked without calling
Middleware::authenticate()
, and the handler method will not receive an AuthOk
parameter.
Note that "unauthenticated" in this context simply means that the middleware will not be used to
authenticate requests. The handler method you implement may still perform its own authentication.
This is often useful for login endpoints (for which no authentication session exists yet), or for
webhook endpoints that require access to the raw request body in order to authenticate the request
(e.g., using an HMAC). In the latter case, a request body schema with type: string
(optionally
with format: binary
) should be used. The handler method can deserialize the body after verifying
the HMAC.
Request parameters
Request parameters must define a single schema
property. The content
property is currently not
supported.
Cookie parameters (in: cookie
) are currently not supported. Header parameters (in: header
) must
be plain string schemas.
Where supported, non-string parameter types must implement the FromStr
trait for parsing. Object
types are not supported in request parameters.
Request/response bodies
Request and response bodies that define more than one media type are currently not supported.
The code generator represents request and response bodies as Rust types according to the following table. GitHub issues and pull requests that add support for other widely-used data formats are encouraged.
Media type | Schema type |
Rust type | (De)serialization |
---|---|---|---|
application/json |
string |
Vec<u8> for format: binary or String (UTF-8) otherwise |
None |
application/json |
Non-string |
See below | serde_json |
application/octet-stream |
Any | Vec<u8> |
None |
text/* |
Any | String (UTF-8) |
None |
Others (fallback) | Any | Vec<u8> |
None |
Strings (type: string
)
String schemas that specify at least one enum
variant will result in a named Rust enum
being generated. Please note that null
variants are currently not supported.
Non-enum
string types are determined by the format
property, as indicated in the table
below:
format |
Rust type |
---|---|
Unspecified (default) | String |
date |
chrono::NaiveDate |
date-time |
chrono::DateTime<Utc> |
byte |
String (without base64 decoding) |
password |
String |
binary |
Vec<u8> |
Other | Treated as a verbatim Rust type |
Integers (type: integer
)
Integer enum
s are currently not supported. Non-enum
integer types are determined by the format
property, as indicated in the table below:
format |
Rust type |
---|---|
Unspecified (default) | i64 |
int32 |
i32 |
int64 |
i64 |
Other | Treated as a verbatim Rust type |
Floating-point numbers (type: number
)
Number enum
s are currently not supported. Non-enum
number types are determined by the format
property, as indicated in the table below:
format |
Rust type |
---|---|
Unspecified (default) | f64 |
float |
f32 |
double |
f64 |
Other | Treated as a verbatim Rust type |
Booleans (type: boolean
)
Boolean enum
s are currently not supported. Booleans are always represented as bool
.
Objects (type: object
)
The table below specifies the generated Rust types depending on an object schema's
properties
and additionalProperties
fields. Please note that properties
entries with schemas
that are objects or enum
s must use references ($ref
) to named schemas. Other property types
may use inline schemas or references.
properties |
additionalProperties |
Rust type |
---|---|---|
At least one | false or unspecified |
Named struct |
At least one | true |
Named struct + HashMap<String, serde_json::Value> with #[serde(flatten)] |
At least one | Schema | Named struct + HashMap<String, _> with #[serde(flatten)] |
None | false or unspecified |
openapi_lambda::models::EmptyModel |
None | true |
HashMap<String, serde_json::Value> |
None | Schema | HashMap<String, _> |
Arrays (type: array
)
Array schemas with uniqueItems: true
are represented as
indexmap::IndexSet<_>
. All
other arrays are represented as Vec<_>
.
Polymorphism (oneOf
)
A named Rust enum
is generated for schemas utilizing oneOf
, with one variant for each
entry contained in the oneOf
array. If a discriminator
is
specified, a Serde internally-tagged
enum
is generated, with that field as the tag. Otherwise, a Serde
untagged enum is generated.
Please note that each oneOf
variant must be a named reference ($ref
), which determines the name
of the Rust enum
variant. Each referenced schema must be either an object schema (type: object
)
or utilize allOf
. Inline variant schemas are not supported.
Composed objects (allOf
)
Schemas utilizing allOf
are treated as objects (see above) after merging all of the component
schemas into a single schema of type: object
. Each component of an allOf
schema must be an
object or a nested allOf
schema. At most one component may define additionalProperties
.
Other schema types
Schemas utilizing anyOf
or not
are currently not supported.
Responses
Responses must specify individual HTTP status codes. Status code ranges are currently not supported.
Sponsorship
This project is sponsored by Unflakable.
Dependencies
~12–23MB
~332K SLoC