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.rsandsrc/route/docs.rs. - A hypothetical
src/route/user.rsmight contain a router pointing to handlers that perform CRUD operations on user models. - A hypothetical
src/route/authn.rsmight 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
tracingand contains a request ID stored in the request extensions. Seesrc/middleware.rsfor details on how this works. - If you want your middleware to apply to the entire service, you can modify the
ServiceBuilderlayers 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::healthhandler calls upon theservice::healthmodule to perform health checks. Thecontroller::meta::versionhandler is trivial, so it does not require a service module. - A hypothetical
controller::ordersmodule might have a handler to calculate the shipping cost for someorder_id. The handler should extract theorder_idfrom the request, and call a service function in theservice::ordersmodule, 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 Results. 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 buildto generate anopenapi.json, which is used by the/docs/openapi.jsonendpoint. - Run
npm run checkto type-checkopenapi.tswithtsc. - Run
npm run formatto formatopenapi.tswithprettier.
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-bystatement in all commit messages. - All commits must have a valid digital signature.