From 5b3bec25b0edfba5576eff387df2e615685d0380 Mon Sep 17 00:00:00 2001 From: Luca Palmieri <20745048+LukeMathWalker@users.noreply.github.com> Date: Wed, 6 Dec 2023 08:45:43 +0100 Subject: [PATCH] Quickstart tutorial (#107) I've resized my ambition: we won't have a bootcamp for now, but the quickstart tutorial is a bit meatier than I had initially planned. The quickstart itself is pretty much "done" with this PR: now it's a matter to add safeguards to make sure that code examples do not go stale, but that'll be the job of a separate PR. --- .github/dependabot.yml | 6 + .github/workflows/links.yml | 22 + docs/getting_started/.pages | 3 +- docs/getting_started/bootcamp.md | 0 docs/getting_started/index.md | 21 + docs/getting_started/learning_paths.md | 17 +- docs/getting_started/quickstart.md | 595 +++++++++++++++++- mkdocs.yml | 1 + template/template/.env | 1 + template/template/.gitignore | 2 +- template/template/Cargo.toml | 4 + template/template/README.md | 32 +- .../template/{{crate_name}}_server/Cargo.toml | 1 + .../configuration/base.yml | 1 + .../configuration/dev.yml | 6 + .../configuration/prod.yml | 3 +- .../{{crate_name}}_server/src/bin/api.rs | 5 +- .../tests/integration/ping.rs | 2 +- 18 files changed, 679 insertions(+), 43 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/links.yml delete mode 100644 docs/getting_started/bootcamp.md create mode 100644 template/template/.env create mode 100644 template/template/{{crate_name}}_server/configuration/dev.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..a33ccdd2d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: ".github/workflows" + schedule: + interval: "daily" \ No newline at end of file diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml new file mode 100644 index 000000000..1d31f297e --- /dev/null +++ b/.github/workflows/links.yml @@ -0,0 +1,22 @@ +name: Links (Fail Fast) + +on: + pull_request: + branches: + - main + +jobs: + linkChecker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + sparse-checkout: | + .github + docs + + - name: Link Checker + uses: lycheeverse/lychee-action@v1 + with: + fail: true + args: --base . --exclude-loopback --require-https --verbose --no-progress docs diff --git a/docs/getting_started/.pages b/docs/getting_started/.pages index b496e01f3..0b9b53930 100644 --- a/docs/getting_started/.pages +++ b/docs/getting_started/.pages @@ -1,5 +1,4 @@ nav: - "Installation": index.md - learning_paths.md - - quickstart.md - - bootcamp.md \ No newline at end of file + - quickstart.md \ No newline at end of file diff --git a/docs/getting_started/bootcamp.md b/docs/getting_started/bootcamp.md deleted file mode 100644 index e69de29bb..000000000 diff --git a/docs/getting_started/index.md b/docs/getting_started/index.md index caecf1e0f..68bc2202d 100644 --- a/docs/getting_started/index.md +++ b/docs/getting_started/index.md @@ -22,6 +22,27 @@ rustup --version && \ If there are no errors, you're good to go! +### Nightly toolchain + +To perform code generation, Pavex relies on an unstable Rust feature: +[`rustdoc-json`](https://github.com/rust-lang/rust/issues/76578). +As a consequence, Pavex requires you to have the Rust `nightly` toolchain installed. + +You can add `nightly` to your toolchain by running: +```bash +rustup toolchain install nightly +``` + +Once `nightly` is installed, add the `rust-docs-json` component: + +```bash +rustup component add --toolchain nightly rust-docs-json +``` + +**Pavex will never use `nightly` to compile your application**. +All the code you'll be running (in production or otherwise) will be compiled with the stable toolchain. +Pavex relies on `nightly` to perform code generation and compile-time reflection—nothing else. + ## Pavex Pavex provides a command-line interface to scaffold new projects and work with existing ones. diff --git a/docs/getting_started/learning_paths.md b/docs/getting_started/learning_paths.md index 0629a1746..8c31994b0 100644 --- a/docs/getting_started/learning_paths.md +++ b/docs/getting_started/learning_paths.md @@ -1,7 +1,7 @@ # Learning Paths Each person has a different background and different goals. -To accommodate this diversity, we provide multiple learning paths for Pavex, each tailored to a specific audience. +To accommodate this diversity, this section provides multiple learning paths for Pavex, each tailored to a specific audience. ## I'm new to Rust @@ -26,16 +26,5 @@ Once you feel comfortable enough with the language, you can start learning about ## I know some Rust, but I'm new to Pavex -If you're already familiar with Rust, you can start learning about Pavex right away. -We provide two different introductions to the framework: [Quickstart](quickstart.md) and [Bootcamp](bootcamp.md). - -Would you rather get your hands dirty than reading documentation upfront? -Follow the [Quickstart tutorial](quickstart.md)! -It covers the basics of the framework, -but it doesn't go into much detail—just enough to get you started in 10 minutes or so. -You'll then have to figure things out on your own, leveraging Pavex's error messages and consulting the -[documentation](../documentation/index.md) on a need-to-know basis. - -If you prefer a more guided tour, you should check out the [Bootcamp](bootcamp.md) tutorial instead. -It walks you through the process of building a simple API with Pavex, explaining the core concepts of the framework -along the way. +If you're already familiar with Rust, you can start learning about Pavex right away: head over to our +[Quickstart tutorial](quickstart.md) to learn about the core concepts of the framework. \ No newline at end of file diff --git a/docs/getting_started/quickstart.md b/docs/getting_started/quickstart.md index b9f20a3b3..9b9b64729 100644 --- a/docs/getting_started/quickstart.md +++ b/docs/getting_started/quickstart.md @@ -1,6 +1,6 @@ # Quickstart -!!! note "Estimated time: 10 minutes" +!!! note "Estimated time: 20 minutes" !!! warning "Prerequisites" @@ -10,16 +10,18 @@ ## Create a new Pavex project The `pavex` CLI provides a `new` subcommand to scaffold a new Pavex project. -Let's use it to create a new project called `blog`: +Let's use it to create a new project called `demo`: ```bash -pavex new blog && cd blog +pavex new demo && cd demo ``` -## Build a Pavex project +## Commands + +### Build a Pavex project `cargo` is not enough, on its own, to build a Pavex project: -you need to use the [`cargo-px`](https://github.com/LukeMathWalker/cargo-px) subcommand instead (1). +you need to use the [`cargo-px`](https://github.com/LukeMathWalker/cargo-px) subcommand instead(1). From a usage perspective, it's a **drop-in replacement for `cargo`**: you can use it to build, test, run, etc. your project just like you would with `cargo` itself. { .annotate } @@ -28,28 +30,591 @@ you can use it to build, test, run, etc. your project just like you would with ` overcoming some limitations of `cargo`'s build scripts. -Let's use it to build our project: +Let's use it to check that your project compiles successfully: ```bash -cargo px build +cargo px check # (1)! ``` -If everything went well, you can try to execute the test suite: +1. `cargo px check` is faster than `cargo px build` because it doesn't produce an executable binary. + It's the quickest way to check that your project compiles while you're working on it. + +If everything went well, try to execute the test suite: ```bash cargo px test ``` -## Run a Pavex project +### Run a Pavex project + +Now launch your application: -The project scaffolded by `pavex new` bundles a hierarchical configuration system: you can load -different configuration values for different **profiles**. -The `APP_PROFILE` environment variable tells the application which profile to use. +```bash +cargo px run +``` -Let's run our application in `development` mode: +Once the application is running, you should start seeing JSON logs in your terminal: + +```json +{ + "name": "demo", + "msg": "Starting to listen for incoming requests at 127.0.0.1:8000", + "level": 30, + "target": "api" + // [...] +} +``` + +Leave it running in the background and open a new terminal window. + +### Issue your first request + +Let's issue your first request to a Pavex application! +The template project comes with a `GET /api/ping` endpoint to be used as health check. +Let's hit it: ```bash -APP_PROFILE=dev cargo px run --bin api +curl -v http://localhost:8000/api/ping # (1)! +``` + +1. We are using curl here, but you can replace it with your favourite HTTP client! + +If all goes according to plan, you'll receive a `200 OK` response with an empty body: + +```text +> GET /api/ping HTTP/1.1 +> Host: localhost:8000 +> User-Agent: [...] +> Accept: */* +> +< HTTP/1.1 200 OK +< content-length: 0 +< date: [...] +``` + +You've just created a new Pavex project, built it, launched it and verified that it accepts requests correctly. +It's a good time to start exploring the codebase! + +## Blueprint + +The core of a Pavex project is its `Blueprint`. +It's the type you'll use to define your API: routes, middlewares, error handlers, etc. + +You can find the `Blueprint` for the `demo` project in the `demo/src/blueprint.rs` file: + +```rust title="demo/src/blueprint.rs" +pub fn blueprint() -> Blueprint { + let mut bp = Blueprint::new(); + register_common_constructors(&mut bp); + + add_telemetry_middleware(&mut bp); + + bp.route(GET, "/api/ping", f!(crate::routes::status::ping)); + bp +} ``` -After the build is complete, you should see the following output: +## Routing + +### Route registration + +All the routes exposed by your API must be registered with its `Blueprint`. +In the snippet below you can see the registration of the `GET /api/ping` route, the one you targeted with your `curl` request. + +```rust title="demo/src/blueprint.rs" hl_lines="7" +pub fn blueprint() -> Blueprint { + let mut bp = Blueprint::new(); + register_common_constructors(&mut bp); + + add_telemetry_middleware(&mut bp); + + bp.route(GET, "/api/ping", f!(crate::routes::status::ping)); + bp +} +``` + +It specifies: + +- The HTTP method (`GET`) +- The path (`/api/ping`) +- The fully qualified path to the handler function (`crate::routes::status::ping`), wrapped in a macro (`f!`) + +### Request handlers + +The `ping` function is the handler for the `GET /api/ping` route: + +```rust title="demo/src/routes/status.rs" +use pavex::http::StatusCode; + +/// Respond with a `200 OK` status code to indicate that the server is alive +/// and ready to accept new requests. +pub fn ping() -> StatusCode { + StatusCode::OK +} +``` + +It's a public function that returns a `StatusCode`. +`StatusCode` is a valid response type for a Pavex handler since it implements the `IntoResponse` trait: the framework +knows how to convert it into a "full" `Response` object. + +### Add a new route + +The `ping` function is fairly boring: it doesn't take any arguments, and it always returns the same response. +Let's spice things up with a new route: `GET /api/greet/:name`. +It takes a dynamic **route parameter** (`name`) and we want it to return a success response with `Hello, {name}` as its body. + +Create a new module, `greet.rs`, in the `demo/src/routes` folder: + +```rust title="demo/src/routes/lib.rs" hl_lines="2" +pub mod status; +pub mod greet; +``` + +```rust title="demo/src/routes/greet.rs" +use pavex::response::Response; + +pub fn greet() -> Response { + todo!() +} +``` + +The body of the `greet` handler is stubbed out with `todo!()` for now, but we'll fix that soon enough. +Let's register the new route with the `Blueprint` in the meantime: + +```rust title="demo/src/blueprint.rs" hl_lines="8" +pub fn blueprint() -> Blueprint { + let mut bp = Blueprint::new(); + register_common_constructors(&mut bp); + + add_telemetry_middleware(&mut bp); + + bp.route(GET, "/api/ping", f!(crate::routes::status::ping)); + bp.route(GET, "/api/greet/:name"/* (1)! */, f!(crate::routes::greet::greet)); + bp +} +``` + +1. Dynamic route parameters are prefixed with a colon (`:`). + +### Extract route parameters + +To access the `name` route parameter from your new handler you must use the `RouteParams` extractor: + +```rust title="demo/src/routes/greet.rs" +use pavex::response::Response; +use pavex::request::RouteParams; + +#[RouteParams] +pub struct GreetParams { + pub name/* (1)! */: String, +} + +pub fn greet(params: RouteParams/* (2)! */) -> Response { + todo!() +} +``` + +1. The name of the field must match the name of the route parameter as it appears in the path we registered with the `Blueprint`. +2. The `RouteParams` extractor is generic over the type of the route parameters. + In this case, we're using the `GreetParams` type we just defined. + +You can now return the expected response from the `greet` handler: + +```rust title="demo/src/routes/greet.rs" hl_lines="10 11 12 13" +use pavex::response::Response; +use pavex::request::route::RouteParams; + +#[RouteParams] +pub struct GreetParams { + pub name: String, +} + +pub fn greet(params: RouteParams) -> Response { + let GreetParams { name }/* (1)! */= params.0; + Response::ok()// (2)! + .set_typed_body(format!("Hello, {name}!"))// (3)! + .box_body() +} +``` + +1. This is an example of Rust's [destructuring syntax](https://doc.rust-lang.org/book/ch18-03-pattern-syntax.html#destructuring-to-break-apart-values). +2. `Response` has a convenient constructor for each HTTP status code: `Response::ok()` starts building a `Response` with a `200 OK` status code. +3. `typed_body` sets the body of the response and automatically infers a suitable value for the `Content-Type` header based on the response body type. + +Does it work? Only one way to find out! +Re-launch the application and issue a new request: (1) +{ .annotate } + +1. Remember to use `cargo px run` instead of `cargo run`! + +```bash +curl http://localhost:8000/api/greet/Ursula +``` + +You should see `Hello, Ursula!` in your terminal if everything went well. + +## Dependency injection + +You just added a new input parameter to your `greet` handler and, somehow, the framework was able to provide its value +at runtime without you having to do anything. +How does that work? + +It's all thanks to **dependency injection**. +Pavex automatically injects the expected input parameters when invoking your handler functions as long as +it knows how to construct them. + +### Constructor registration + +Let's zoom in on `RouteParams`: how does the framework know how to construct it? +You need to go back to the `Blueprint` to find out: + +```rust title="demo/src/blueprint.rs" hl_lines="3" +pub fn blueprint() -> Blueprint { + let mut bp = Blueprint::new(); + register_common_constructors(&mut bp); + + add_telemetry_middleware(&mut bp); + + bp.route(GET, "/api/ping", f!(crate::routes::status::ping)); + bp.route(GET, "/api/greet/:name", f!(crate::routes::greet::greet)); + bp +} +``` + +The `register_common_constructors` function takes care of registering constructors for a set of types that +are defined in the `pavex` crate itself and commonly used in Pavex applications. +If you check out its definition, you'll see that it registers a constructor for `RouteParams`: + +```rust title="pavex/src/blueprint.rs" hl_lines="3 4 5 6" +fn register_common_constructors(bp: &mut Blueprint) { + // [...] + bp.constructor( + f!(pavex::request::route::RouteParams::extract), + Lifecycle::RequestScoped, + ) + // [...] +} +``` + +It specifies: + +- The fully qualified path to the constructor method, wrapped in a macro (`f!`) +- The constructor's lifecycle (`Lifecycle::RequestScoped`): the framework will invoke this constructor at most once per request + +### A new extractor: `UserAgent` + +There's no substitute for hands-on experience, so let's design a brand-new constructor for our demo project to +get a better understanding of how they work. +We only want to greet people who include a `User-Agent` header in their request(1). +{ .annotate } + +1. It's an arbitrary requirement, follow along for the sake of the example! + +Let's start by defining a new `UserAgent` type: + +```rust title="demo/src/lib.rs" +//! [...] +pub mod user_agent; +``` + +```rust title="demo/src/user_agent.rs" +pub enum UserAgent { + /// No `User-Agent` header was provided. + Unknown, + /// The value of the `User-Agent` header for the incoming request. + Known(String), +} +``` + +### Missing constructor + +What if you tried to inject `UserAgent` into your `greet` handler straight away? Would it work? +Let's find out! + +```rust title="demo/src/routes/greet.rs" hl_lines="4" +use crate::user_agent::UserAgent; +// [...] + +pub fn greet(params: RouteParams, user_agent: UserAgent/* (1)! */) -> Response { + if let UserAgent::Anonymous = user_agent { + return Response::unauthorized() + .set_typed_body("You must provide a `User-Agent` header") + .box_body(); + } + // [...] +} +``` + +1. New input parameter! + +If you try to build the project now, you'll get an error from Pavex: + +```text +ERROR: + × I can't invoke your request handler, `demo::routes::greet::greet`, because it needs an instance of + │ `demo::user_agent::UserAgent` as input, but I can't find a constructor for that type. + │ + │ ╭─[demo/src/blueprint.rs:13:1] + │ 13 │ bp.route(GET, "/api/ping", f!(crate::routes::status::ping)); + │ 14 │ bp.route(GET, "/api/greet/:name", f!(crate::routes::greet::greet)); + │ · ───────────────┬─────────────── + │ · The request handler was registered here + │ 15 │ bp + │ ╰──── + │ ╭─[demo/src/routes/greet.rs:9:1] + │ 9 │ + │ 10 │ pub fn greet(params: RouteParams, _user_agent: UserAgent) -> Response { + │ · ────┬──── + │ · I don't know how to construct an instance + │ · of this input parameter + │ 11 │ let GreetParams { name } = params.0; + │ ╰──── + │ help: Register a constructor for `demo::user_agent::UserAgent` +``` + +Pavex cannot do miracles, nor does it want to: it only knows how to construct a type if you tell it how to do so. + +By the way: this is also your first encounter with Pavex's error messages! +We strive to make them as helpful as possible. If you find them confusing, report it as a bug! + +### Add a new constructor + +To inject `UserAgent` into our `greet` handler, you need to define a constructor for it. +Constructors, just like request handlers, can take advantage of dependency injection: they can request input parameters +that will be injected by the framework at runtime. +Since you need to look at headers, ask for `RequestHead` as input parameter: the incoming request data, +minus the body. + +```rust title="demo/src/user_agent.rs" hl_lines="10 11 12 13 14 15 16 17 18 19" +use pavex::http::header::USER_AGENT; +use pavex::request::RequestHead; + +pub enum UserAgent { + Unknown, + Known(String), +} + +impl UserAgent { + pub fn extract(request_head: &RequestHead) -> Self { + let Some(user_agent) = request_head.headers.get(USER_AGENT) else { + return Self::Anonymous; + }; + + match user_agent.to_str() { + Ok(s) => Self::Known(s.into()), + Err(_e) => todo!() + } + } +} +``` + +Now register the new constructor with the `Blueprint`: + +```rust title="demo/src/blueprint.rs" hl_lines="5 6 7 8" +pub fn blueprint() -> Blueprint { + let mut bp = Blueprint::new(); + register_common_constructors(&mut bp); + + bp.constructor( + f!(crate::user_agent::UserAgent::extract), + Lifecycle::RequestScoped, + ); + // [...] +} +``` + +`Lifecycle::RequestScoped` is the right choice for this type: the data in `UserAgent` is request-specific. +You don't want to share it across requests (`Lifecycle::Singleton`) nor do you want to recompute it multiple times for +the same request (`Lifecycle::Transient`). + +Make sure that the project compiles successfully now. + +## Error handling + +In `UserAgent::extract` you're only handling the happy path: +the method panics if the `User-Agent` header is not valid UTF-8. +Panicking for bad user input is poor behavior: you should handle the issue gracefully and return an error instead. + +Let's change the signature of `UserAgent::extract` to return a `Result` instead: + +```rust title="demo/src/user_agent.rs" +use pavex::http::header::{ToStrError, USER_AGENT}; +// [...] + +impl UserAgent { + pub fn extract(request_head: &RequestHead) -> Result { + let Some(user_agent) = request_head.headers.get(USER_AGENT) else { + return Ok(UserAgent::Anonymous); + }; + + user_agent.to_str().map(|s| UserAgent::Known(s.into())) + } +} +``` + +1. `ToStrError` is the error type returned by `to_str` when the header value is not valid UTF-8. + +### All errors must be handled + +If you try to build the project now, you'll get an error from Pavex: + +```text +ERROR: + × You registered a constructor that returns a `Result`, but you did not register an error handler for it. + | If I don't have an error handler, I don't know what to do with the error when the constructor fails! + │ + │ ╭─[demo/src/blueprint.rs:11:1] + │ 11 │ bp.constructor( + │ 12 │ f!(crate::user_agent::UserAgent::extract), + │ · ────────────────────┬──────────────────── + │ · ╰── The fallible constructor was registered here + │ 13 │ Lifecycle::RequestScoped, + │ ╰──── + │ help: Add an error handler via `.error_handler` +``` + +Pavex is complaining: you can register a fallible constructor, but you must also register an error handler for it. + +### Add an error handler + +An error handler must convert a reference to the error type into a `Response` (1). +It decouples the detection of an error from its representation on the wire: a constructor doesn't need to know how the +error will be represented in the response, it just needs to signal that something went wrong. +You can then change the representation of an error on the wire without touching the constructor: you only need to change the +error handler. +{ .annotate } + +1. Error handlers, just like request handlers and constructors, can take advantage of dependency injection! + You could, for example, change the response representation according to the `Accept` header specified in the request. + +Define a new `invalid_user_agent` function in `demo/src/user_agent.rs`: + +```rust title="demo/src/user_agent.rs" +// [...] + +pub fn invalid_user_agent(_e: &ToStrError) -> Response { + Response::bad_request() + .set_typed_body("The `User-Agent` header value must be a valid UTF-8 string") + .box_body() +} +``` + +Then register the error handler with the `Blueprint`: + +```rust title="demo/src/blueprint.rs" hl_lines="9" +pub fn blueprint() -> Blueprint { + let mut bp = Blueprint::new(); + register_common_constructors(&mut bp); + + bp.constructor( + f!(crate::user_agent::UserAgent::extract), + Lifecycle::RequestScoped, + ) + .error_handler(f!(crate::user_agent::invalid_user_agent)); + // [...] +} +``` + +The application should compile successfully now. + +## Testing + +All your testing, so far, has been manual: you've been launching the application and issuing requests to it with `curl`. +Let's move away from that: it's time to write some automated tests! + +### Black-box testing + +The preferred way to test a Pavex application is to treat it as a black box: you should only test the application +through its HTTP interface. This is the most realistic way to test your application: it's how your users will +interact with it, after all. + +The template project includes a reference example for the `/api/ping` endpoint: + +```rust title="demo_server/tests/integration/ping.rs" +use crate::helpers::TestApi;//(1)! +use pavex::http::StatusCode; + +#[tokio::test] +async fn ping_works() { + let api = TestApi::spawn().await;//(2)! + + let response = api.get_ping().await;//(3)! + + assert_eq!(response.status().as_u16(), StatusCode::OK.as_u16()); +} +``` + +1. `TestApi` is a helper struct that provides a convenient interface to interact with the application. + It's defined in `demo_server/tests/helpers.rs`. +2. `TestApi::spawn` starts a new instance of the application in the background. +3. `TestApi::get_ping` issues an actual `GET /api/ping` request to the application. + +### Add a new integration test + +Let's write a new integration test to verify the behaviour on the happy path for `GET /api/greet/:name`: + +```rust title="demo_server/tests/integration/main.rs hl_lines="1" +mod greet; +mod ping; +mod helpers; +``` + +```rust title="demo_server/tests/integration/greet.rs" +use crate::helpers::TestApi; +use pavex::http::StatusCode; + +#[tokio::test] +async fn greet_happy_path() { + let api = TestApi::spawn().await; + let name = "Ursula"; + + let response = api + .api_client + .get(&format!("{}/api/greet/{name}", &api.api_address)) + .header("User-Agent", "Test runner") + .send() + .await + .expect("Failed to execute request."); + + assert_eq!(response.status().as_u16(), StatusCode::OK.as_u16()); + assert_eq!(response.text().await.unwrap(), "Hello, Ursula!"); +} +``` + +It follows the same pattern as the `ping` test: it spawns a new instance of the application, issues a request to it +and verifies that the response is correct. +Let's complement it with a test for the unhappy path as well: requests with a malformed `User-Agent` header should be rejected. + +```rust title="demo_server/tests/integration/greet.rs" +// [...] +#[tokio::test] +async fn non_utf8_user_agent_is_rejected() { + let api = TestApi::spawn().await; + let name = "Ursula"; + + let response = api + .api_client + .get(&format!("{}/api/greet/{name}", &api.api_address)) + .header("User-Agent", b"hello\xfa".as_slice()) + .send() + .await + .expect("Failed to execute request."); + + assert_eq!(response.status().as_u16(), StatusCode::BAD_REQUEST.as_u16()); + assert_eq!( + response.text().await.unwrap(), + "The `User-Agent` header value must be a valid UTF-8 string" + ); +} +``` + +`cargo px test` should report three passing tests now. As a bonus exercise, try to add a test for the case where the +`User-Agent` header is missing. + +## Going further + +Your first (guided) tour of Pavex ends here: you've touched the key concepts of the framework and got some hands-on +experience with a basic application. +From here onwards, you are free to carve out your own learning path: you can explore the rest of the documentation +to learn more about the framework, or you can start hacking on your own project, consulting the documentation on a +need-to-know basis. diff --git a/mkdocs.yml b/mkdocs.yml index 6af8a0eb1..cadb71c5b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -37,6 +37,7 @@ theme: - toc.follow - content.action.edit - content.code.copy + - content.code.annotate palette: # Palette toggle for light mode - scheme: default diff --git a/template/template/.env b/template/template/.env new file mode 100644 index 000000000..24394c3db --- /dev/null +++ b/template/template/.env @@ -0,0 +1 @@ +APP_PROFILE=dev \ No newline at end of file diff --git a/template/template/.gitignore b/template/template/.gitignore index f1a2edb0d..0b745e292 100644 --- a/template/template/.gitignore +++ b/template/template/.gitignore @@ -1,2 +1,2 @@ /target -/{{crate_name}}_server/configuration/dev.yml \ No newline at end of file +.env \ No newline at end of file diff --git a/template/template/Cargo.toml b/template/template/Cargo.toml index 4ccd330bb..36fa4c22d 100644 --- a/template/template/Cargo.toml +++ b/template/template/Cargo.toml @@ -1,3 +1,7 @@ [workspace] members = ["{{crate_name}}", "{{crate_name}}_server_sdk", "{{crate_name}}_server"] +# By setting `{{crate_name}}_server` as the default member, `cargo run` will default to running the server binary +# when executed from the root of the workspace. +# Otherwise, you would have to use `cargo run --bin api` to run the server binary. +default-members = ["{{crate_name}}_server"] resolver = "2" \ No newline at end of file diff --git a/template/template/README.md b/template/template/README.md index d688d518e..2eaf2586b 100644 --- a/template/template/README.md +++ b/template/template/README.md @@ -7,9 +7,9 @@ - Rust (see [here](https://www.rust-lang.org/tools/install) for instructions) - `cargo-px`: ```bash - cargo install cargo-px + cargo install --locked cargo-px --version="~0.1" ``` -- [Pavex's CLI](https://pavex.dev): +- [Pavex](https://pavex.dev) ## Useful commands @@ -31,7 +31,7 @@ cargo px build ### Run ```bash -APP_PROFILE=dev cargo px run --bin api +cargo px run ``` ### Test @@ -42,14 +42,30 @@ cargo px test ## Configuration +All configurable parameters are listed in `{{crate_name}}/src/configuration.rs`. + +Configuration values are loaded from two sources: + +- Configuration files +- Environment variables + +Environment variables take precedence over configuration files. + All configuration files are in the `{{crate_name}}_server/configuration` folder. -The settings that are shared across all environments are stored in `{{crate_name}}_server/configuration/base.yml`. +The application can be run in three different profiles: `dev`, `test` and `prod`. +The settings that you want to share across all profiles should be placed in `{{crate_name}}_server/configuration/base.yml`. +Profile-specific configuration files can be then used +to override or supply additional values on top of the default settings (e.g. `{{crate_name}}_server/configuration/dev.yml`). -Environment-specific configuration files can be used to override or supply additional values on top the default settings (see `prod.yml`). -You must specify the app profile that you want to use by setting the `APP_PROFILE` environment variable to either `dev`, `test` or `prod`; e.g.: +You can specify the app profile that you want to use by setting the `APP_PROFILE` environment variable; e.g.: ```bash -APP_PROFILE=prod cargo px run --bin api +APP_PROFILE=prod cargo px run ``` -All configurable parameters are listed in `{{crate_name}}/src/configuration.rs`. +for running the application with the `prod` profile. + +By default, the `dev` profile is used since `APP_PROFILE` is set to `dev` in the `.env` file at the root of the project. +The `.env` file should not be committed to version control: it is meant to be used for local development only, +so that each developer can specify their own environment variables for secret values (e.g. database credentials) +that shouldn't be stored in configuration files (given their sensitive nature). diff --git a/template/template/{{crate_name}}_server/Cargo.toml b/template/template/{{crate_name}}_server/Cargo.toml index 61a0ff409..8b211da20 100644 --- a/template/template/{{crate_name}}_server/Cargo.toml +++ b/template/template/{{crate_name}}_server/Cargo.toml @@ -15,6 +15,7 @@ tokio = { version = "1", features = ["full"] } {{crate_name}} = { path = "../{{crate_name}}" } # Configuration +dotenvy = "0.15" figment = { version = "0.10", features = ["env", "yaml"] } serde = { version = "1", features = ["derive"]} diff --git a/template/template/{{crate_name}}_server/configuration/base.yml b/template/template/{{crate_name}}_server/configuration/base.yml index 1ad94ef96..2e6c1938e 100644 --- a/template/template/{{crate_name}}_server/configuration/base.yml +++ b/template/template/{{crate_name}}_server/configuration/base.yml @@ -1,2 +1,3 @@ server: + ip: "0.0.0.0" port: 8000 \ No newline at end of file diff --git a/template/template/{{crate_name}}_server/configuration/dev.yml b/template/template/{{crate_name}}_server/configuration/dev.yml new file mode 100644 index 000000000..fe1171b8c --- /dev/null +++ b/template/template/{{crate_name}}_server/configuration/dev.yml @@ -0,0 +1,6 @@ +# This file contains the configuration for the dev environment. +# None of the values here are actually secret, so it's fine +# to commit this file to the repository. +server: + ip: "127.0.0.1" + port: 8000 diff --git a/template/template/{{crate_name}}_server/configuration/prod.yml b/template/template/{{crate_name}}_server/configuration/prod.yml index 539fea7d7..2a9375957 100644 --- a/template/template/{{crate_name}}_server/configuration/prod.yml +++ b/template/template/{{crate_name}}_server/configuration/prod.yml @@ -1,2 +1,3 @@ server: - ip: "0.0.0.0" \ No newline at end of file + ip: "0.0.0.0" + port: 8000 diff --git a/template/template/{{crate_name}}_server/src/bin/api.rs b/template/template/{{crate_name}}_server/src/bin/api.rs index 2b0444c53..f8fc2aa54 100644 --- a/template/template/{{crate_name}}_server/src/bin/api.rs +++ b/template/template/{{crate_name}}_server/src/bin/api.rs @@ -8,7 +8,7 @@ use pavex::server::Server; #[tokio::main] async fn main() -> anyhow::Result<()> { - let subscriber = get_subscriber("realworld".into(), "info".into(), std::io::stdout); + let subscriber = get_subscriber("{{crate_name}}".into(), "info".into(), std::io::stdout); init_telemetry(subscriber)?; // We isolate all the server setup and launch logic in a separate function @@ -26,6 +26,9 @@ async fn main() -> anyhow::Result<()> { } 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; diff --git a/template/template/{{crate_name}}_server/tests/integration/ping.rs b/template/template/{{crate_name}}_server/tests/integration/ping.rs index 704cc0e49..c79eb0eb7 100644 --- a/template/template/{{crate_name}}_server/tests/integration/ping.rs +++ b/template/template/{{crate_name}}_server/tests/integration/ping.rs @@ -2,7 +2,7 @@ use crate::helpers::TestApi; use pavex::http::StatusCode; #[tokio::test] -async fn signup_works() { +async fn ping_works() { let api = TestApi::spawn().await; let response = api.get_ping().await;