Skip to content

Project structure

As you have seen in the Quickstart tutorial, pavex new is a quick way to scaffold a new project and start working on it. If you execute

pavex new demo

the CLI will create a project with the following structure:

app/
server/
server_sdk/
workspace_hack/
Cargo.toml
CONFIGURATION.md
deny.toml
Dockerfile
README.md
rust-toolchain.toml

What is the purpose of all those folders? Why is cargo-px needed to build a Pavex project? Are there any conventions to follow?

This guide will answer all these questions and more.

Summary

If you're in a hurry, here's a quick summary of the most important points:

  • A Pavex project is a Cargo workspace with at least three crates:
    • a core crate (library), conventionally named app
    • a server SDK crate (library), conventionally named server_sdk
    • a server crate (binary), conventionally named server
  • The app crate contains the Blueprint for your API. It's where you'll spend most of your time.
  • The server_sdk crate is generated from the core crate by pavex generate, which is invoked automatically by cargo-px when building or running the project.
    You'll never modify server_sdk manually.
  • The server crate is the entrypoint for your application. You'll have to change it whenever the application state changes or if you want to tweak the binary entrypoint (e.g. modify the default telemetry setup). Your integration tests live in this crate.

Using the demo project as an example, the relationship between the project crates can be visualised as follows:

graph 
  d[app] -->|contains| bp[Blueprint];
  bp -->|is used to generate| dss[server_sdk];
  dss -->|is used by| ds[server];
  dss -->|is used by| dst[API tests in server];

If you want to know more, read on!

Blueprint

Every Pavex project has, at its core, a Blueprint.
It's the type you use to declare the structure of your API: routes, middlewares, constructors, error handlers, error observers, etc.

app/src/blueprint.rs
// [...]
pub fn blueprint() -> Blueprint {
    let mut bp = Blueprint::new();
    ApiKit::new().register(&mut bp);
    telemetry::register(&mut bp);
    configuration::register(&mut bp);

    routes::register(&mut bp);
    bp
}

Think of a Blueprint as the specification for your API, a plan for how your application should behave at runtime.

Code generation

You can't run or execute a Blueprint as-is.

pavex generate

To convert a Blueprint into an executable toolkit, you need pavex generate. It's a CLI command that takes a Blueprint as input and outputs a Rust crate, the server SDK for your Pavex project.

app/
server/
server_sdk/
workspace_hack/
Cargo.toml
CONFIGURATION.md
deny.toml
Dockerfile
README.md
rust-toolchain.toml

Note

As a convention, the generated crate is named server_sdk.
You can use {project_name}_server_sdk if you need to disambiguate between multiple Pavex applications in the same workspace.

cargo-px

If you went through the Quickstart tutorial, you might be wondering: I've never run pavex generate! How comes my project worked?

That's thanks to cargo-px!
If you look into the Cargo.toml manifest for the server_sdk crate in the demo project, you'll find this section:

# [...]
[package.metadata.px.generate]
generator_type = "cargo_workspace_binary"
generator_name = "bp"

It's a cargo-px configuration section.
The server_sdk crate is telling cargo-px to generate the whole crate by executing a binary called bp (short for blueprint) from the current Cargo workspace.

That binary is defined in the demo crate:

app/src/bin/bp.rs
use app::blueprint;
use cargo_px_env::generated_pkg_manifest_path;
use pavex_cli_client::Client;
use std::env::args;
use std::error::Error;

/// Generate the `server_sdk` crate using Pavex's CLI.
///
/// Pavex will automatically wire all our routes, constructors and error handlers
/// into a "server SDK" that can be used by the final API server binary to launch
/// the application.
///
/// If `--check` is passed as an argument, it only verifies that the server SDK
/// crate is up-to-date. An error is returned if it isn't.
fn main() -> Result<(), Box<dyn Error>> {
    let generated_dir = generated_pkg_manifest_path()?.parent().unwrap().into();
    let mut cmd = Client::new().generate(blueprint(), generated_dir);
    if args().any(|arg| arg == "--check") {
        cmd = cmd.check()
    };
    if let Err(e) = cmd.execute() {
        eprintln!("{e}");
        std::process::exit(1);
    }
    Ok(())
}

Client::generate takes care of serializing the Blueprint and passing it as input to pavex generate.

All this is done automatically for you when you run cargo px build or cargo px run. cargo-px examines all the crates in your workspace, generates the ones that need it, and then goes on to complete the build process.

The server SDK

We've talked at length about how the server SDK is generated, but we haven't yet discussed what it actually does.
The server SDK is the glue that wires everything together. It is the code executed at runtime when a request hits your API.

You can think of it as the output of a macro, with the difference that you can explore it. It's right there in your filesystem: you can open it, you can read it, you can use it as a way to get a deeper understanding of how Pavex works under the hood.

At the same time, you actually don't need to know how it works to use it.
As a Pavex user, you only need to care about the two public types it exports: the run function and the ApplicationState struct.

ApplicationState

ApplicationState holds all the types with a Singleton lifecycle that your application needs to access at runtime when processing a request.

To build an instance of ApplicationState, the server SDK exposes a function called build_application_state.

run

run is the entrypoint of your application.
It takes as input:

pavex::server::Server holds the configuration for the HTTP server that will be used to serve your API: the port(s) to listen on, the number of worker threads to be used, etc.
When you call run, the HTTP server starts listening for incoming requests. You're live!

The server crate

But who calls run?

The server SDK crate is a library, it doesn't contain an executable binary.
That's why you need a server crate.

app/
server/
server_sdk/
workspace_hack/
Cargo.toml
CONFIGURATION.md
deny.toml
Dockerfile
README.md
rust-toolchain.toml

Note

As a convention, the server crate is named server.
You can use {project_name}_server if you need to disambiguate between multiple Pavex applications in the same workspace.

The executable binary

The server crate contains the main function that you'll be running to start your application.
In that main function you'll be building an instance of ApplicationState and passing it to run. You'll be doing a few other things too: initializing your tracing subscriber, loading configuration, etc.

The main function in server
server/src/bin/server.rs
use anyhow::Context;
use pavex::server::{Server, ServerHandle, ShutdownMode};
use pavex_tracing::fields::{error_details, error_message, ERROR_DETAILS, ERROR_MESSAGE};
use server::{
    configuration::Config,
    telemetry::{get_subscriber, init_telemetry},
};
use server_sdk::{build_application_state, run};
use std::time::Duration;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let subscriber = get_subscriber("demo".into(), "info".into(), std::io::stdout);
    init_telemetry(subscriber)?;

    // We isolate all the server setup and launch logic in a separate function
    // to have a single choke point where we make sure to log fatal errors
    // that will cause the application to exit.
    if let Err(e) = _main().await {
        tracing::event!(
            tracing::Level::ERROR,
            { ERROR_MESSAGE } = error_message(&e),
            { ERROR_DETAILS } = error_details(&e),
            "The application is exiting due to an error"
        )
    }

    Ok(())
}

async fn _main() -> anyhow::Result<()> {
    // Load environment variables from a .env file, if it exists.
    let _ = dotenvy::dotenv();

    let config = Config::load(None)?;
    let application_state = build_application_state().await;
    let tcp_listener = config
        .server
        .listener()
        .await
        .context("Failed to bind the server TCP listener")?;
    let address = tcp_listener
        .local_addr()
        .context("The server TCP listener doesn't have a local socket address")?;
    let server_builder = Server::new().listen(tcp_listener);

    tracing::info!("Starting to listen for incoming requests at {}", address);
    let server_handle = run(server_builder, application_state);
    graceful_shutdown(
        server_handle.clone(),
        config.server.graceful_shutdown_timeout,
    )
    .await;
    server_handle.await;
    Ok(())
}

async fn graceful_shutdown(server_handle: ServerHandle, timeout: Duration) {
    tokio::spawn(async move {
        tokio::signal::ctrl_c()
            .await
            .expect("Failed to listen for the Ctrl+C signal");
        server_handle
            .shutdown(ShutdownMode::Graceful { timeout })
            .await;
    });
}

Most of this ceremony is taken care for you by the pavex new command, but it's good to know that it's happening (and where it's happening) in case you need to customize it.

Integration tests

The server crate is also where you'll be writing your API tests, also known as black-box tests.
These are scenarios that exercise your application as a customer would, by sending HTTP requests and asserting on the responses.

The demo project includes an example of such a test which you can use as a reference:

server/tests/integration/ping.rs
use crate::helpers::TestApi;
use pavex::http::StatusCode;

#[tokio::test]
async fn ping_works() {
    let api = TestApi::spawn().await;

    let response = api.get_ping().await;

    assert_eq!(response.status(), StatusCode::OK);
}