Skip to content
Learn Rust
Theme

Project: a notes API

This page builds one project end to end. The pieces are the ones the other guides teach separately: axum for HTTP, sqlx for SQLite, tracing for logs and tests. What is new here is how they fit together, and which parts are allowed to know about which. It ran in the pinned image (Rust 1.99.1, Linux ARM64). Cargo downloads the crates the first time it builds, so a session below that starts with a build says “needs network”; later builds reuse what was downloaded.

RememberPut a trait where one layer meets the next, and the layer above can be tested with a stand-in for the layer below.

Project layout
notes-api/
├── migrations/
│   └── 0001_create_notes.sql   the table
├── src/
│   ├── storage/
│   │   ├── memory.rs           a Vec behind a Mutex
│   │   ├── mod.rs              the NoteStore trait
│   │   └── sqlite.rs           the sqlx version
│   ├── config.rs               settings from the environment
│   ├── domain.rs               what a note is, and which text is allowed
│   ├── http.rs                 routes, JSON, errors to status codes
│   ├── lib.rs                  declares the layers
│   └── main.rs                 the only file that picks the real parts
├── tests/
│   └── api.rs                  integration tests
├── Cargo.lock                  committed
├── Cargo.toml
└── Dockerfile                  shown below, not run

The pinned dependencies are in Cargo.toml. Cargo.lock is committed. The Cargo FAQ says a lock file exists “to describe the state of the world at the time of a successful build”, so that “the exact same dependencies and versions are used” later, and that whether to commit it “is dependent on the needs of your package” (FAQ). Here the Dockerfile builds with --locked, so it needs the file.

RustCargo.toml
[package]
name = "notes-api"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.9"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"
sqlx = { version = "0.9.0", features = ["runtime-tokio", "sqlite"] }
thiserror = "2.0.21"
tokio = { version = "1.54.1", features = ["full"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
[dev-dependencies]
tower = { version = "0.5.3", features = ["util"] }

The domain is plain Rust: no web, no database. A note has an id and text, and text has rules. NoteText keeps its string private, so the only way to make one is parse, which trims it and refuses empty or over-long text. Once a function takes a &NoteText, it does not check again. This is the “parse, don’t validate” idea from Idioms and design patterns: a value that cannot be built wrong. (Note derives Serialize so the HTTP layer can send it; that is a shortcut, and a larger service would keep a separate response type.)

Rustsrc/domain.rs
//! The domain layer: plain types and rules.
use serde::Serialize;
/// The longest text a note may have, in characters.
pub const MAX_CHARS: usize = 280;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Note {
pub id: i64,
pub text: String,
}
/// Note text that passed the checks. The field is private, so the only way to
/// get a `NoteText` is [`NoteText::parse`]: a store never has to check again.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NoteText(String);
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
pub enum InvalidText {
#[error("text is empty")]
Empty,
#[error("text is longer than 280 characters")]
TooLong,
}
impl NoteText {
pub fn parse(raw: &str) -> Result<Self, InvalidText> {
let text = raw.trim();
if text.is_empty() {
return Err(InvalidText::Empty);
}
if text.chars().count() > MAX_CHARS {
return Err(InvalidText::TooLong);
}
Ok(NoteText(text.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trims_and_keeps_the_text() {
assert_eq!(
NoteText::parse(" buy milk \n").unwrap().as_str(),
"buy milk"
);
}
#[test]
fn rejects_empty_and_blank_text() {
assert_eq!(NoteText::parse(""), Err(InvalidText::Empty));
assert_eq!(NoteText::parse(" \t\n"), Err(InvalidText::Empty));
}
#[test]
fn counts_characters_not_bytes() {
// 280 two-byte characters are 560 bytes but still allowed; 281 are not.
assert!(NoteText::parse(&"é".repeat(MAX_CHARS)).is_ok());
assert_eq!(
NoteText::parse(&"é".repeat(MAX_CHARS + 1)),
Err(InvalidText::TooLong)
);
}
}

The HTTP layer needs four things from a store, and it does not care how they are done. That is a trait (Traits). NoteStore has two implementations here: SqliteStore for the real thing and MemoryStore, a Vec behind a Mutex (see Shared state). The trait returns StoreError, an opaque wrapper, so no sqlx::Error appears in the layer above. SqliteStore converts at its own edge, and keeps its row type (NoteRow) private so Note never learns about sqlx.

Rust
//! The storage layer: one trait, two implementations.
mod memory;
mod sqlite;
pub use memory::MemoryStore;
pub use sqlite::SqliteStore;
use crate::domain::{Note, NoteText};
use std::future::Future;
/// Something went wrong in the backend. The domain does not care what: a
/// database error, a full disk and a dropped connection are all "storage failed".
#[derive(Debug, thiserror::Error)]
#[error("storage backend failed")]
pub struct StoreError {
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
}
impl StoreError {
pub fn new(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
StoreError {
source: source.into(),
}
}
}
/// The boundary between the HTTP layer and wherever notes live.
///
/// The methods return `impl Future + Send` instead of using `async fn` so the
/// trait can promise `Send`, which axum needs. An implementation may still
/// write `async fn`.
pub trait NoteStore: Send + Sync + 'static {
fn list(&self) -> impl Future<Output = Result<Vec<Note>, StoreError>> + Send;
fn get(&self, id: i64) -> impl Future<Output = Result<Option<Note>, StoreError>> + Send;
fn insert(&self, text: &NoteText) -> impl Future<Output = Result<Note, StoreError>> + Send;
/// `true` if a note was deleted, `false` if there was none with that id.
fn delete(&self, id: i64) -> impl Future<Output = Result<bool, StoreError>> + Send;
}

The methods are written -> impl Future<...> + Send instead of async fn. The Rust blog’s announcement of async fn in traits explains why: a trait author decides whether the futures must be Send, because users of the trait cannot add that later. axum needs Send, so the trait says so, and the implementations may still write async fn.

A reader of the trait objects lesson will ask why the router is generic (router<S: NoteStore>) instead of holding an Arc<dyn NoteStore>. This is the compiler’s answer:

Rustdyn_store.rs
use notes_api::storage::NoteStore;
use std::sync::Arc;
fn main() {
let _store: Option<Arc<dyn NoteStore>> = None;
}
$ cargo check --bin dyn_store # needs network the first time (crates)
error[E0038]: the trait `NoteStore` is not dyn compatible
  --> src/bin/dyn_store.rs:5:32
   |
 5 |     let _store: Option<Arc<dyn NoteStore>> = None;
   |                                ^^^^^^^^^ `NoteStore` is not dyn compatible
   |
note: for a trait to be dyn compatible it needs to allow building a vtable
      for more information, visit <https://doc.rust-lang.org/reference/items/traits.html#dyn-compatibility>
  --> src/storage/mod.rs:35:23
   |
35 |     fn list(&self) -> impl Future<Output = Result<Vec<Note>, StoreError>> + Send;
   |                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait is not dyn compatible because method `list` references an `impl Trait` type in its return type
36 |     fn get(&self, id: i64) -> impl Future<Output = Result<Option<Note>, StoreError>> + Send;
   |                               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait is not dyn compatible because method `get` references an `impl Trait` type in its return type
37 |     fn insert(&self, text: &NoteText) -> impl Future<Output = Result<Note, StoreError>> + Send;
   |                                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait is not dyn compatible because method `insert` references an `impl Trait` type in its return type
38 |     /// `true` if a note was deleted, `false` if there was none with that id.
39 |     fn delete(&self, id: i64) -> impl Future<Output = Result<bool, StoreError>> + Send;
   |                                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait is not dyn compatible because method `delete` references an `impl Trait` type in its return type
   = help: the following types implement `NoteStore`:
             notes_api::storage::MemoryStore
             notes_api::storage::SqliteStore
           consider defining an enum where each variant holds one of these types,
           implementing `NoteStore` for this new enum and using it instead

A trait with these methods is not dyn compatible, so this project uses generics: the handlers are create::<S> and so on, and main picks SqliteStore. The error message itself suggests another route, an enum that holds each store. I did not try that, or the trait_variant crate the blog post mentions.

http.rs knows axum and the NoteStore trait, not sqlx or SQLite. Each handler returns Result<_, ApiError>, and ? converts InvalidText and StoreError into ApiError through the #[from] attributes (the pattern in Errors in real apps). ApiError::into_response is the one place that turns one of these errors into a status code and a body. Failures that happen before a handler runs are axum’s own: a body that is not valid JSON gets a 400 and a plain-text message, and a POST without a content-type of application/json gets a 415, neither in the {"error": ...} shape (I ran both against this project). A storage failure is logged with its cause, and the caller gets only internal error.

Rustsrc/http.rs
//! The HTTP layer: routes, JSON, and the mapping from errors to status codes.
//! It is written against `NoteStore` and never mentions SQLite.
use crate::domain::{InvalidText, Note, NoteText};
use crate::storage::{NoteStore, StoreError};
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use serde::Deserialize;
use serde_json::json;
use std::error::Error;
use std::sync::Arc;
pub fn router<S: NoteStore>(store: S) -> Router {
Router::new()
.route("/health", get(health))
.route("/notes", get(list::<S>).post(create::<S>))
.route("/notes/{id}", get(get_one::<S>).delete(remove::<S>))
.with_state(Arc::new(store))
}
#[derive(Deserialize)]
struct CreateNote {
text: String,
}
async fn health() -> &'static str {
"ok"
}
async fn list<S: NoteStore>(State(store): State<Arc<S>>) -> Result<Json<Vec<Note>>, ApiError> {
Ok(Json(store.list().await?))
}
async fn create<S: NoteStore>(
State(store): State<Arc<S>>,
Json(body): Json<CreateNote>,
) -> Result<(StatusCode, Json<Note>), ApiError> {
let text = NoteText::parse(&body.text)?;
let note = store.insert(&text).await?;
tracing::info!(id = note.id, "note created");
Ok((StatusCode::CREATED, Json(note)))
}
async fn get_one<S: NoteStore>(
State(store): State<Arc<S>>,
Path(id): Path<i64>,
) -> Result<Json<Note>, ApiError> {
store.get(id).await?.map(Json).ok_or(ApiError::NotFound(id))
}
async fn remove<S: NoteStore>(
State(store): State<Arc<S>>,
Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> {
if store.delete(id).await? {
tracing::info!(id, "note deleted");
Ok(StatusCode::NO_CONTENT)
} else {
Err(ApiError::NotFound(id))
}
}
/// Everything a handler can fail with, and the one place that turns it into a response.
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error(transparent)]
Invalid(#[from] InvalidText),
#[error("no note with id {0}")]
NotFound(i64),
#[error(transparent)]
Store(#[from] StoreError),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match &self {
ApiError::Invalid(e) => (StatusCode::UNPROCESSABLE_ENTITY, e.to_string()),
ApiError::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
ApiError::Store(e) => {
// The details go to the log, not to the caller.
tracing::error!(error = %e, cause = ?e.source(), "storage failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}

Config::from_lookup takes a closure that looks up a name, so a test can pass a HashMap instead of changing the real environment. std::env::set_var is an unsafe fn in the std docs for 1.99.1, which say that in multi-threaded programs on operating systems other than Windows, the only sound option is not to use it at all. main is where everything real is chosen: it reads the environment, opens SQLite, and starts the server.

tracing_subscriber reads RUST_LOG through EnvFilter (the docs name it as the default variable) and falls back to info when it is unset or cannot be parsed (I set RUST_LOG to a bad value and got the info logs with no complaint). I turned timestamps off with without_time() so the logs on this page do not change between runs; remove that line in real use. Logging with tracing covers spans and fields.

Graceful shutdown is with_graceful_shutdown(shutdown_signal()). The docs say it “Prepares a server to handle graceful shutdown when the provided future completes”, and the future here completes on Ctrl-C or, on Unix, SIGTERM, which is what docker stop sends first. The code after serve returns closes the pool and logs stopped.

Rust
use notes_api::{config::Config, http, storage::SqliteStore};
use tokio::net::TcpListener;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.without_time() // keeps the output on the page stable; remove it for real use
.init();
let config = Config::from_env()?;
let store = SqliteStore::connect(&config.database_url).await?;
let listener = TcpListener::bind(config.bind).await?;
tracing::info!(addr = %listener.local_addr()?, "listening on");
axum::serve(listener, http::router(store.clone()))
.with_graceful_shutdown(shutdown_signal())
.await?;
store.close().await;
tracing::info!("stopped");
Ok(())
}
/// Finishes when the process is asked to stop: Ctrl-C, or SIGTERM on Unix
/// (what `docker stop` and most process managers send).
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("could not install the Ctrl-C handler")
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("could not install the SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {}
_ = terminate => {}
}
tracing::info!("shutdown signal received");
}

tests/api.rs builds the router, and sends it requests with tower::ServiceExt::oneshot, which the tower docs describe as consuming the service and calling it “once it is ready”. Router is Clone, so the helper call clones it for each request. Nothing opens a socket, so the tests do not clash with a server that is already running on port 3000.

Two things are worth copying. First, crud_roundtrip is written once and run against both stores, so MemoryStore and SqliteStore must agree. Second, BrokenStore always fails, which lets one test check that a storage error becomes a 500 that does not repeat the cause. That test needs the trait; with SqliteStore hard-wired into the handlers it would need a broken database.

Rusttests/api.rs
//! Integration tests: the whole router, called in-process with `oneshot`.
//! No port is opened, so the tests cannot clash with a running server.
use axum::{
Router,
body::{Body, to_bytes},
http::{Request, StatusCode},
};
use notes_api::domain::{Note, NoteText};
use notes_api::http::router;
use notes_api::storage::{MemoryStore, NoteStore, SqliteStore, StoreError};
use serde_json::{Value, json};
use tower::ServiceExt; // for `oneshot`
/// Sends one request to a copy of the router and returns the status and the JSON body.
async fn call(app: &Router, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
let mut request = Request::builder().method(method).uri(uri);
let body = match body {
Some(json) => {
request = request.header("content-type", "application/json");
Body::from(json.to_string())
}
None => Body::empty(),
};
let response = app
.clone()
.oneshot(request.body(body).unwrap())
.await
.unwrap();
let status = response.status();
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let body = match serde_json::from_slice(&bytes) {
Ok(json) => json,
Err(_) if bytes.is_empty() => Value::Null,
Err(_) => Value::String(String::from_utf8_lossy(&bytes).into_owned()),
};
(status, body)
}
/// The behaviour every store must give the API. Run once per implementation.
async fn crud_roundtrip(app: Router) {
let (status, body) = call(
&app,
"POST",
"/notes",
Some(json!({"text": " learn axum "})),
)
.await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(body, json!({"id": 1, "text": "learn axum"}));
call(&app, "POST", "/notes", Some(json!({"text": "ship it"}))).await;
let (status, body) = call(&app, "GET", "/notes", None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body,
json!([{"id": 1, "text": "learn axum"}, {"id": 2, "text": "ship it"}])
);
let (status, body) = call(&app, "GET", "/notes/2", None).await;
assert_eq!(
(status, body),
(StatusCode::OK, json!({"id": 2, "text": "ship it"}))
);
let (status, _) = call(&app, "DELETE", "/notes/2", None).await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (status, _) = call(&app, "GET", "/notes/2", None).await;
assert_eq!(status, StatusCode::NOT_FOUND);
// An id is not handed out again after its note is deleted.
let (_, body) = call(&app, "POST", "/notes", Some(json!({"text": "third"}))).await;
assert_eq!(body["id"].as_i64(), Some(3));
}
#[tokio::test]
async fn memory_store_roundtrip() {
crud_roundtrip(router(MemoryStore::new())).await;
}
#[tokio::test]
async fn sqlite_store_roundtrip() {
let store = SqliteStore::connect("sqlite::memory:").await.unwrap();
crud_roundtrip(router(store)).await;
}
#[tokio::test]
async fn health_answers_ok() {
let (status, body) = call(&router(MemoryStore::new()), "GET", "/health", None).await;
assert_eq!((status, body), (StatusCode::OK, Value::String("ok".into())));
}
#[tokio::test]
async fn invalid_text_is_422_with_a_reason() {
let app = router(MemoryStore::new());
let (status, body) = call(&app, "POST", "/notes", Some(json!({"text": " "}))).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(body, json!({"error": "text is empty"}));
let (status, body) = call(
&app,
"POST",
"/notes",
Some(json!({"text": "x".repeat(281)})),
)
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(body, json!({"error": "text is longer than 280 characters"}));
let (_, notes) = call(&app, "GET", "/notes", None).await;
assert_eq!(notes, json!([]), "a refused note must not be stored");
}
#[tokio::test]
async fn unknown_and_malformed_ids() {
let app = router(MemoryStore::new());
let (status, body) = call(&app, "GET", "/notes/99", None).await;
assert_eq!(
(status, body),
(
StatusCode::NOT_FOUND,
json!({"error": "no note with id 99"})
)
);
let (status, _) = call(&app, "DELETE", "/notes/99", None).await;
assert_eq!(status, StatusCode::NOT_FOUND);
let (status, _) = call(&app, "GET", "/notes/abc", None).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
/// A store that always fails: only possible to write because the API takes a trait.
struct BrokenStore;
impl NoteStore for BrokenStore {
async fn list(&self) -> Result<Vec<Note>, StoreError> {
Err(StoreError::new("disk on fire"))
}
async fn get(&self, _id: i64) -> Result<Option<Note>, StoreError> {
Err(StoreError::new("disk on fire"))
}
async fn insert(&self, _text: &NoteText) -> Result<Note, StoreError> {
Err(StoreError::new("disk on fire"))
}
async fn delete(&self, _id: i64) -> Result<bool, StoreError> {
Err(StoreError::new("disk on fire"))
}
}
#[tokio::test]
async fn storage_failure_is_500_and_does_not_leak_details() {
let (status, body) = call(&router(BrokenStore), "GET", "/notes", None).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(body, json!({"error": "internal error"}));
}
terminal
$ cargo test -- --test-threads=1    # needs network the first time (crates); timings replaced by X.XXs
running 6 tests
test config::tests::defaults_when_nothing_is_set ... ok
test config::tests::names_the_setting_that_is_wrong ... ok
test config::tests::reads_both_settings ... ok
test domain::tests::counts_characters_not_bytes ... ok
test domain::tests::rejects_empty_and_blank_text ... ok
test domain::tests::trims_and_keeps_the_text ... ok

test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs

running 6 tests
test health_answers_ok ... ok
test invalid_text_is_422_with_a_reason ... ok
test memory_store_roundtrip ... ok
test sqlite_store_roundtrip ... ok
test storage_failure_is_500_and_does_not_leak_details ... ok
test unknown_and_malformed_ids ... ok

test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs

   Doc-tests notes_api

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs

Twelve tests ran: six unit tests inside src/ (in #[cfg(test)] modules) and six integration tests. The two blocks that say running 0 tests are src/main.rs and the doc-tests, which have none. The shared test is meant to catch a store that breaks the contract. To check that it does, I removed AUTOINCREMENT from the migration. SQLite’s autoincrement page says the keyword exists to prevent reuse of row ids from deleted rows, and says it “imposes extra CPU, memory, disk space, and disk I/O overhead and should be avoided if not strictly needed”. This project does need it, because MemoryStore never reuses an id and both stores should behave the same.

$ cargo test --test api -- --test-threads=1 # needs network the first time (crates); timings replaced by X.XXs, the thread number by ID
running 6 tests
test health_answers_ok ... ok
test invalid_text_is_422_with_a_reason ... ok
test memory_store_roundtrip ... ok
test sqlite_store_roundtrip ... FAILED
test storage_failure_is_500_and_does_not_leak_details ... ok
test unknown_and_malformed_ids ... ok

failures:

---- sqlite_store_roundtrip stdout ----

thread 'sqlite_store_roundtrip' (ID) panicked at tests/api.rs:73:5:
assertion `left == right` failed
  left: Some(2)
 right: Some(3)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures:
    sqlite_store_roundtrip

test result: FAILED. 5 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs

error: test failed, to rerun pass `--test api`

This session started the server in the background with cargo run, called it with curl (-w prints the status after each body), and printed the log it wrote. The database is the default notes.db file in the project directory.

terminal
$ cargo run    # needs network the first time; the log is also copied to server.log
 INFO notes_api: listening on addr=127.0.0.1:3000
$ curl -s -w '\n[%{http_code}]\n' -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"text":"learn axum"}'
{"id":1,"text":"learn axum"}
[201]
$ curl -s -w '\n[%{http_code}]\n' -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"text":"  ship it  "}'
{"id":2,"text":"ship it"}
[201]
$ curl -s -w '\n[%{http_code}]\n' localhost:3000/notes
[{"id":1,"text":"learn axum"},{"id":2,"text":"ship it"}]
[200]
$ curl -s -w '\n[%{http_code}]\n' localhost:3000/notes/2
{"id":2,"text":"ship it"}
[200]
$ curl -s -w '\n[%{http_code}]\n' -X DELETE localhost:3000/notes/2
[204]
$ curl -s -w '\n[%{http_code}]\n' localhost:3000/notes/2
{"error":"no note with id 2"}
[404]
$ curl -s -w '\n[%{http_code}]\n' -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"text":"   "}'
{"error":"text is empty"}
[422]
$ cat server.log    # what the server logged
 INFO notes_api: listening on addr=127.0.0.1:3000
 INFO notes_api::http: note created id=1
 INFO notes_api::http: note created id=2
 INFO notes_api::http: note deleted id=2

The next session builds the program, runs it, posts a note, and sends SIGTERM with kill. wait returns the exit status, and the log shows the order: the signal, then stopped, which main writes after the pool is closed. A second run reads the note back from notes.db, and is stopped with SIGINT, which is what Ctrl-C sends. The last command shows a bad setting. main returns the Err, and Rust prints its Debug form (the Errors lesson shows what main returning a Result does), which is fine for a bad setting at start-up; Errors in real apps covers nicer reports.

terminal
$ cargo build    # needs network the first time (crates)
$ ./target/debug/notes-api > server.log 2>&1 &
pid=$!
timeout 20 sh -c 'until grep -q "listening on" server.log; do sleep 0.1; done'
curl -s -w '\n' -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"text":"survives a restart"}'
kill -TERM $pid
wait $pid; echo "exit status: $?"
cat server.log
{"id":1,"text":"survives a restart"}
exit status: 0
 INFO notes_api: listening on addr=127.0.0.1:3000
 INFO notes_api::http: note created id=1
 INFO notes_api: shutdown signal received
 INFO notes_api: stopped
$ ./target/debug/notes-api > server.log 2>&1 &
pid=$!
timeout 20 sh -c 'until grep -q "listening on" server.log; do sleep 0.1; done'
curl -s -w '\n' localhost:3000/notes
kill -INT $pid
wait $pid; echo "exit status: $?"
cat server.log
[{"id":1,"text":"survives a restart"}]
exit status: 0
 INFO notes_api: listening on addr=127.0.0.1:3000
 INFO notes_api: shutdown signal received
 INFO notes_api: stopped
$ BIND_ADDR=nope ./target/debug/notes-api; echo "exit status: $?"
Error: BadBind("nope")
exit status: 1

What I did not run: a request that is still being handled when the signal arrives. axum’s own graceful-shutdown example says in a comment that graceful shutdown “will wait for outstanding requests to complete” and adds a timeout so requests do not hang forever. This project has no such timeout.

The image that built this page has no Docker, so the Dockerfile below was not built or run. I checked two things it depends on: the tag rust:1.99.1-slim-trixie is listed on the Docker Hub page, and the Dockerfile of that image installs gcc, which the bundled SQLite needs. Everything else (the --locked build, the /data volume, the final image starting and answering) is untested. The build stage copies Cargo.toml, Cargo.lock, migrations/ and src/ by name, so a local target/ or notes.db is not copied into the image.

DockerfileDockerfile
# Not run on the page (see the text): the image that builds this page has no Docker.
FROM rust:1.99.1-slim-trixie AS build
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY migrations migrations
COPY src src
RUN cargo build --release --locked
FROM debian:trixie-slim
COPY --from=build /app/target/release/notes-api /usr/local/bin/notes-api
ENV BIND_ADDR=0.0.0.0:3000 \
DATABASE_URL=sqlite:///data/notes.db?mode=rwc
VOLUME /data
EXPOSE 3000
CMD ["notes-api"]

cargo fmt --check and cargo clippy --all-targets -- -D warnings print nothing when they find nothing, as below. cargo tree --depth 1 lists the direct dependencies with the versions Cargo.lock pins, and the last command counts the packages the lock file lists.

terminal
$ cargo fmt --check
$ cargo clippy --all-targets -- -D warnings    # needs network the first time (crates)
$ cargo tree --depth 1
notes-api v0.1.0 (/home/you/notes-api)
├── axum v0.8.9
├── serde v1.0.229
├── serde_json v1.0.154
├── sqlx v0.9.0
├── thiserror v2.0.21
├── tokio v1.54.1
├── tracing v0.1.44
└── tracing-subscriber v0.3.23
[dev-dependencies]
└── tower v0.5.3
$ grep -c '^name = ' Cargo.lock
176

None of these were done for this page.

  • Add PUT /notes/{id} and a done flag. Start with a test that fails, then change the trait, then both stores. An implementation that lacks the new method will not compile.
  • Add tower-http’s TraceLayer and a timeout layer like the one in axum’s example. I tried TraceLayer::new_for_http() once (tower-http 0.7.1, not kept in the project): with the default info filter it printed nothing, because it logs at debug; with RUST_LOG=tower_http=debug,info it printed three lines per request. The timeout layer was not run.
  • Read DATABASE_URL and BIND_ADDR from a .env file, and decide where that belongs: Config, or main.
  • Split the layers into crates in a workspace, which the shop project does, and use project structure to decide what each crate may import.
  • Write a property test for NoteText::parse (more testing).