Skip to content

Latest commit

 

History

History
162 lines (122 loc) · 6.1 KB

README.md

File metadata and controls

162 lines (122 loc) · 6.1 KB

api-rs

An opinionated template for lightweight server-side web APIs written in Rust.

Preface

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.

Configuration

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.

Instrumentation

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.

Database

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.

Routes

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.

Examples

  • See src/route/meta.rs and src/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.

Middleware

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.

Examples

  • By default, every request is instrumented using tracing and contains a request ID stored in the request extensions. See src/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 in src/main.rs.

Controllers

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.

Service Modules

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.

Examples

  • By default, the controller::meta::health handler calls upon the service::health module to perform health checks. The controller::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 some order_id. The handler should extract the order_id from the request, and call a service function in the service::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.

Other Modules

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.

Errors

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.

Examples

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 }))
}

Documentation

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 an openapi.json, which is used by the /docs/openapi.json endpoint.
  • Run npm run check to type-check openapi.ts with tsc.
  • Run npm run format to format openapi.ts with prettier.

Testing

Coming soon.

Contributing

  • 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.