An opinionated template for lightweight server-side web APIs written in Rust.
The goal of this template is to provide a simple, scalable project skeleton for a typical web API service. The opinions laid out below are not gospel. Make adjustments to suit the needs of your application and your team.
Use a .env
file to store configuration locally. This is loaded by
dotenvy
upon startup. In a cloud
environment, use the provider's tools to configure environment variables set
during deployment.
Use tracing
for instrumentation and
structured logging. You should choose a subscriber implementation that uses a
format suitable for your log management service - see the list of crates
here.
Use sqlx
with
PostgreSQL for persistence. Add models to
src/model.rs
, and use sqlx-cli
to
handle migrations. Don't be afraid of SQL. Try to ensure your queries are
checked at compile-time.
Routers should be created for each "section" of your service and nested under
the Router
defined in src/route.rs
. How you define "section" is up to you.
You can create a new router by creating a submodule of src/route.rs
.
- See
src/route/meta.rs
andsrc/route/docs.rs
. - A hypothetical
src/route/user.rs
might contain a router pointing to handlers that perform CRUD operations on user models. - A hypothetical
src/route/authn.rs
might contain a router pointing to several authentication-related handlers.
Some of your routes might require middleware.
If your middleware will be limited to a few related routes, add it to the
module that also defines those routes. If the middleware can be shared across
many routes, add it to a submodule of src/middleware.rs
, or directly to
src/middleware.rs
if you prefer.
- By default, every request is instrumented using
tracing
and contains a request ID stored in the request extensions. Seesrc/middleware.rs
for details on how this works. - If you want your middleware to apply to the entire service, you can modify
the
ServiceBuilder
layers insrc/main.rs
.
Controllers are collections of handlers. Your routes should ultimately point to these handlers. The two main concerns of a handler should be to interpret/validate a request and to construct a response. Defer more complex tasks to service modules.
If interpreting/validating the request or building the response involves behavior that can be shared across many routes, consider separating this behavior into middleware.
If non-trivial logic is required for a particular handler, extract the logic to
a submodule of src/service.rs
and call that from the handler instead.
- By default, the
controller::meta::health
handler calls upon theservice::health
module to perform health checks. Thecontroller::meta::version
handler is trivial, so it does not require a service module. - A hypothetical
controller::orders
module might have a handler to calculate the shipping cost for someorder_id
. The handler should extract theorder_id
from the request, and call a service function in theservice::orders
module, which fetches the order details from the database, performs any needed calculations, and returns the result back to the handler. The handler should then use this result to construct the final response.
If you write modules with miscellaneous shared logic that does not interact
with requests, responses, or databases, place them in a separate crate, named
something like lib
or common
, to keep this crate focused on API-specific
functionality.
Use anyhow
to add context to your errors
and Result
s. If you are constructing an error response, you should include a
final line of user-friendly context, as well as any additional details.
Wrap handler errors in a crate::Error
. For axum extractors, wrap rejections
in a crate::Error
using
WithRejection
.
use anyhow::{anyhow, bail, Context};
use axum::http::StatusCode;
use serde_json::json;
use crate::Error;
fn make_coffee() -> anyhow::Result<()> {
bail!("something has gone terribly wrong")
}
// Wraps the underlying error with a HTTP 418 error
async fn explicit_wrap() -> Result<impl IntoResponse, Error> {
make_coffee()
.map_err(|err| Error::new(StatusCode::IM_A_TEAPOT, err.context("I must be a teapot")))
}
// Responds with a HTTP 500 error if not explicitly wrapped
async fn no_wrap() -> Result<impl IntoResponse, Error> {
Ok(make_coffee().context("failed to make coffee")?)
}
async fn with_details() -> Error {
Error::new(
StatusCode::SERVICE_UNAVAILABLE,
anyhow!("service is temporarily down for maintenance"),
)
.details(json!({ "time_remaining": 999999 }))
}
API documentation is maintained inside the docs
directory, which is a
Node.js/TypeScript project. Using the OpenAPI
specification, write your
documentation in docs/openapi.ts
.
Inside the docs
directory:
- Run
npm run build
to generate anopenapi.json
, which is used by the/docs/openapi.json
endpoint. - Run
npm run check
to type-checkopenapi.ts
withtsc
. - Run
npm run format
to formatopenapi.ts
withprettier
.
Coming soon.
- Contributions to this project must be submitted under the project's license.
- Contributors to this project must attest to the Developer Certificate of Origin by including a
Signed-off-by
statement in all commit messages. - All commits must have a valid digital signature.