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
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
- a core crate (library), conventionally named
- The
app
crate contains theBlueprint
for your API. It's where you'll spend most of your time. - The
server_sdk
crate is generated from the core crate bypavex generate
, which is invoked automatically bycargo-px
when building or running the project.
You'll never modifyserver_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.
// [...]
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:
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:
- an instance of
ApplicationState
- a
pavex::server::Server
instance
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
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: