diff --git a/doc_examples/quickstart/demo-bp_binary.snap b/doc_examples/quickstart/demo-bp_binary.snap new file mode 100644 index 000000000..f249f7534 --- /dev/null +++ b/doc_examples/quickstart/demo-bp_binary.snap @@ -0,0 +1,19 @@ +```rust title="demo/src/bin/bp.rs" +use cargo_px_env::generated_pkg_manifest_path; +use demo::blueprint; +use pavex_cli_client::Client; +use std::error::Error; + +/// Generate the `demo_server_sdk` crate using Pavex's CLI. +/// +/// Pavex will automatically wire all our routes, constructors and error handlers +/// into the a "server SDK" that can be used by the final API server binary to launch +/// the application. +fn main() -> Result<(), Box> { + let generated_dir = generated_pkg_manifest_path()?.parent().unwrap().into(); + Client::new() + .generate(blueprint(), generated_dir) + .execute()?; + Ok(()) +} +``` \ No newline at end of file diff --git a/doc_examples/quickstart/demo-bp_server_binary.snap b/doc_examples/quickstart/demo-bp_server_binary.snap new file mode 100644 index 000000000..abe018e8f --- /dev/null +++ b/doc_examples/quickstart/demo-bp_server_binary.snap @@ -0,0 +1,50 @@ +```rust title="demo_server/src/bin/api.rs" +use anyhow::Context; +use demo_server::{ + configuration::load_configuration, + telemetry::{get_subscriber, init_telemetry}, +}; +use demo_server_sdk::{build_application_state, run}; +use pavex::server::Server; + +#[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 + // in order 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::error!( + error.msg = %e, + error.error_chain = ?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 = load_configuration(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); + run(server_builder, application_state).await; + Ok(()) +} +``` \ No newline at end of file diff --git a/doc_examples/quickstart/demo-cargo_px_in_manifest.snap b/doc_examples/quickstart/demo-cargo_px_in_manifest.snap new file mode 100644 index 000000000..df4445c72 --- /dev/null +++ b/doc_examples/quickstart/demo-cargo_px_in_manifest.snap @@ -0,0 +1,4 @@ +\\ [...] +[package.metadata.px.generate] +generator_type = "cargo_workspace_binary" +generator_name = "bp" \ No newline at end of file diff --git a/doc_examples/quickstart/demo-ping_test.snap b/doc_examples/quickstart/demo-ping_test.snap new file mode 100644 index 000000000..8228c6f26 --- /dev/null +++ b/doc_examples/quickstart/demo-ping_test.snap @@ -0,0 +1,13 @@ +```rust title="demo_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().as_u16(), StatusCode::OK.as_u16()); +} +``` \ No newline at end of file diff --git a/doc_examples/quickstart/demo-project_structure.snap b/doc_examples/quickstart/demo-project_structure.snap new file mode 100644 index 000000000..0db8410d7 --- /dev/null +++ b/doc_examples/quickstart/demo-project_structure.snap @@ -0,0 +1,5 @@ +demo/ +demo_server/ +demo_server_sdk/ +Cargo.toml +README.md diff --git a/doc_examples/quickstart/tutorial.yml b/doc_examples/quickstart/tutorial.yml index b9bba4f05..6efd11f1f 100644 --- a/doc_examples/quickstart/tutorial.yml +++ b/doc_examples/quickstart/tutorial.yml @@ -12,6 +12,22 @@ snippets: - name: "ping_handler" source_path: "demo/src/routes/status.rs" ranges: [ ".." ] + - name: "cargo_px_in_manifest" + source_path: "demo_server_sdk/Cargo.toml" + ranges: [ "5.." ] + - name: "bp_binary" + source_path: "demo/src/bin/bp.rs" + ranges: [ ".." ] + - name: "bp_server_binary" + source_path: "demo_server/src/bin/api.rs" + ranges: [ ".." ] + - name: "ping_test" + source_path: "demo_server/tests/integration/ping.rs" + ranges: [ ".." ] +commands: + - command: "exa --oneline -F --group-directories-first" + expected_outcome: "success" + expected_output_at: "demo-project_structure.snap" steps: - patch: "02.patch" snippets: diff --git a/doc_examples/tutorial_generator/src/main.rs b/doc_examples/tutorial_generator/src/main.rs index bdd51f3f1..0d91435ac 100644 --- a/doc_examples/tutorial_generator/src/main.rs +++ b/doc_examples/tutorial_generator/src/main.rs @@ -13,8 +13,12 @@ use similar::{Algorithm, ChangeTag, TextDiff}; struct TutorialManifest { bootstrap: String, starter_project_folder: String, + #[serde(default)] snippets: Vec, + #[serde(default)] steps: Vec, + #[serde(default)] + commands: Vec, } #[derive(Debug, serde::Deserialize)] @@ -225,13 +229,15 @@ fn main() -> Result<(), anyhow::Error> { // Execute all commands and either verify the output or write it to a file - for step in &tutorial_manifest.steps { - for command in &step.commands { - println!( - "Running command for patch `{}`: {}", - step.patch, command.command - ); - let patch_dir = patch_directory_name(&step.patch); + let iterator = std::iter::once((repo_dir, tutorial_manifest.commands.as_slice())).chain( + tutorial_manifest + .steps + .iter() + .map(|step| (patch_directory_name(&step.patch), step.commands.as_slice())), + ); + for (repo_dir, commands) in iterator { + for command in commands { + println!("Running command for `{}`: {}", repo_dir, command.command); assert!( command.expected_output_at.ends_with(".snap"), @@ -239,7 +245,7 @@ fn main() -> Result<(), anyhow::Error> { command.expected_output_at ); - let script_outcome = run_script(&format!(r#"cd {patch_dir} && {}"#, command.command))?; + let script_outcome = run_script(&format!(r#"cd {repo_dir} && {}"#, command.command))?; if command.expected_outcome == StepCommandOutcome::Success { script_outcome.exit_on_failure("Failed to run command which should have succeeded"); @@ -256,9 +262,8 @@ fn main() -> Result<(), anyhow::Error> { .context("Failed to read file")?; if expected_output != output { let mut err_msg = format!( - "Expected output did not match actual output for patch {} (command: `{}`).\n", - step.patch, - command.command, + "Expected output did not match actual output for {} (command: `{}`).\n", + repo_dir, command.command, ); print_changeset(&expected_output, &output, &mut err_msg)?; errors.push(err_msg); diff --git a/docs/ansi.css b/docs/ansi.css index 5336e3880..80fab988c 100644 --- a/docs/ansi.css +++ b/docs/ansi.css @@ -30,4 +30,4 @@ .highlight .-Color-Faint { font-weight: lighter; -} +} \ No newline at end of file diff --git a/docs/guide/project_structure/index.md b/docs/guide/project_structure/index.md new file mode 100644 index 000000000..3ed9b6d52 --- /dev/null +++ b/docs/guide/project_structure/index.md @@ -0,0 +1,185 @@ +As you have seen in the [Quickstart](../../getting_started/quickstart/) tutorial, +`pavex new` is a quick way to scaffold a new project and start working on it. +If you execute + +```bash +pavex new demo +``` + +the CLI will create a project with the following structure: + +```text +--8<-- "doc_examples/quickstart/demo-project_structure.snap" +``` + +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. + +## TL;DR + +If you're in a hurry, here's a quick summary of the most important points: + +- A Pavex project is a [Cargo workspace](https://doc.rust-lang.org/cargo/reference/workspaces.html) + with at least three crates: + - a core crate (_library_) + - a server SDK crate (_library_) + - a server crate (_binary_) +- The core crate contains the [`Blueprint`][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`][cargo-px] when building or running the project. +- The server crate is the entry point for your application. It's also where you'll write your integration tests. + +Using the `demo` project as an example, the relationship between the project crates can be visualised as follows: + +```mermaid +graph + d[demo] -->|contains| bp[Blueprint]; + bp -->|is used to generate| dss[demo_server_sdk]; + dss -->|is invoked by| ds[demo_server]; + dss -->|is invoked by| dst[API tests in demo_server]; +``` + +If you want to know more, read on! + +## Blueprint + +Every Pavex project has, at its core, a [`Blueprint`][Blueprint]. +It's the type you use to declare the structure of your API: [routes], middlewares, constructors, error handlers, etc. + +--8<-- "doc_examples/quickstart/demo-blueprint_definition.snap" + +Think of a [`Blueprint`][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`][Blueprint] as-is. + +#### `pavex generate` + +To convert a [`Blueprint`][Blueprint] into an executable toolkit, you need `pavex generate`. +It's a CLI command that takes a [`Blueprint`][Blueprint] as input and outputs a +Rust crate, the **server SDK** for your Pavex project. + +```text hl_lines="3" +--8<-- "doc_examples/quickstart/demo-project_structure.snap" +``` + +!!! note + + As a convention, the generated crate is named `{project_name}_server_sdk`. + In the `demo` project, the generated crate is called `demo_server_sdk`. + + +#### `cargo-px` + +If you went through the [Quickstart](../../../getting_started/quickstart/) tutorial, you might be +wondering: I've never run `pavex generate`! How comes my project worked? + +That's thanks to [`cargo-px`][cargo-px]! +If you look into the `Cargo.toml` manifest for the `demo_server_sdk` crate in the `demo` project, +you'll find this section: + +```toml +--8<-- "doc_examples/quickstart/demo-cargo_px_in_manifest.snap" +``` + +It's a [`cargo-px`][cargo-px] configuration section. +The `demo_server_sdk` crate is telling [`cargo-px`][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: + +--8<-- "doc_examples/quickstart/demo-bp_binary.snap" + +[`Client::generate`][Client::generate] takes care of serializing the [`Blueprint`][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`][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][Lifecycle::Singleton] +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`][Server] instance + +[`pavex::server::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**. + +```text hl_lines="2" +--8<-- "doc_examples/quickstart/demo-project_structure.snap" +``` + +!!! note + + As a convention, the server crate is named `{project_name}_server`. + In the `demo` project, the generated crate is called `demo_server`. + +### 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. + +??? info "The `main` function in `demo_server`" + + --8<-- "doc_examples/quickstart/demo-bp_server_binary.snap" + +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: + +--8<-- "doc_examples/quickstart/demo-ping_test.snap" + +[Blueprint]: ../../api_reference/pavex/blueprint/struct.Blueprint.html +[Client::generate]: ../../api_reference/pavex_cli_client/client/struct.Client.html#method.generate +[Lifecycle::Singleton]: ../../api_reference/pavex/lifecycle/enum.Lifecycle.html#variant.Singleton +[Server]: ../../api_reference/pavex/server/struct.Server.html + +[routes]: ../routing/index.md +[cargo-px]: https://github.com/LukeMathWalker/cargo-px diff --git a/docs/mermaid.css b/docs/mermaid.css new file mode 100644 index 000000000..bfe4cb3d1 --- /dev/null +++ b/docs/mermaid.css @@ -0,0 +1,3 @@ +.mermaid { + text-align: center; +} diff --git a/mkdocs.yml b/mkdocs.yml index 7f3c24224..73111d8cd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,7 +12,11 @@ plugins: markdown_extensions: - admonition - pymdownx.details - - pymdownx.superfences + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format - pymdownx.inlinehilite - pymdownx.snippets: check_paths: true @@ -69,6 +73,7 @@ nav: - getting_started/quickstart/going_further.md - "Guide": - guide/index.md + - "Project structure": guide/project_structure/index.md - "Routing": - guide/routing/index.md - guide/routing/method_guards.md @@ -83,4 +88,5 @@ exclude_docs: | README.md Dockerfile extra_css: - - ansi.css \ No newline at end of file + - ansi.css + - mermaid.css \ No newline at end of file