Build it yourself
This page is the full recipe for the notes API you ran in Store and retrieve notes. It contains the complete contents of every file.
Create your copy in examples/my-notes/, not examples/handbook-notes/.
The latter is the checked-in reference the first page runs; writing into it
would overwrite a file you did not author. The package is still named
handbook-notes, so the use handbook_notes::... imports in the tests work
unchanged.
Every command runs from the repository root and names
examples/my-notes/Cargo.toml, so you never have to change directories.
Step 1: The lesson workspace
Create examples/my-notes/Cargo.toml with exactly this content:
[package]
name = "handbook-notes"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
publish = false
[dependencies]
iet-authz = { path = "../../crates/core/iet-authz" }
iet-cache-memory = { path = "../../crates/features/iet-cache-memory" }
iet-config = { path = "../../crates/core/iet-config" }
iet-config-env = { path = "../../crates/features/iet-config-env" }
iet-config-macros = { path = "../../crates/macros/iet-config-macros" }
iet-core = { path = "../../crates/core/iet-core" }
iet-http-axum = { path = "../../crates/features/iet-http-axum", features = ["host"] }
iet-rate-limit = { path = "../../crates/features/iet-rate-limit" }
axum = "0.8"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "time"] }
[dev-dependencies]
serde_json = "1"
tower = { version = "0.5", features = ["util"] }
# This lesson is its own workspace so it does not join the framework's root
# workspace. The path dependencies above are the only framework crates it uses.
[workspace]
- Path dependencies. This checkout resolves the framework crates from local
paths; no registry release is assumed. From
examples/my-notes/, the repository root is two levels up, so../../crates/core/iet-coreiscrates/core/iet-core. [workspace]. A manifest with a[workspace]table is its own workspace root, which keeps this lesson's dependency graph andCargo.lockseparate from the framework root. The root manifest is never edited.- Features.
iet-http-axum/hostadds theHostConfiglistener and the networking dependencies. Without it, the crate provides routing conventions but no server. - Tokio.
macrosgives#[tokio::main]/#[tokio::test];rt-multi-threadruns the server;netandsignalback the listener and Ctrl+C;timeis used by the host's drain budget. - Dev-dependencies.
serde_jsonreads response bodies in tests;towerprovidesServiceExt::oneshotfor in-process HTTP tests.
Create examples/my-notes/src/lib.rs, starting with one module. You will add
one line per step:
Step 2: The contract
src/note.rs holds the data, the inputs and their rules, and the two traits that
separate application behavior from storage and transport.
examples/my-notes/src/note.rs (complete)
//! The notes contract: the data, the inputs and their rules, and the two traits
//! that separate application behavior from storage and transport.
//!
//! This module depends on `iet-core` and Serde only. It knows nothing about
//! Axum, JSON responses, or any particular store, so it can be tested on its
//! own and reused from a different adapter later.
use ;
use ;
use ;
use Future;
/// A stored note, as the API returns it.
/// The body of `POST /notes`.
///
/// `deny_unknown_fields` makes an unexpected key a parse error instead of being
/// silently ignored. The type implements `Deserialize` because it arrives over
/// HTTP; the response type is decided by the adapter.
/// The input for `GET /notes/{id}`. Notes are numbered from 1.
/// `Operation` names the access an input needs so the service and any adapter
/// run the same check. Both notes operations are public; a future one can be
/// `Access::Authenticated` or `Access::Permission("...")` without touching the
/// validation rules.
/// The application boundary. The HTTP adapter is generic over this trait and
/// never names a concrete store.
/// The storage boundary. A repository deals in stored records only; it does not
/// validate, authorize, or decide HTTP status codes.
Note is the output model (Serialize); CreateNote is the input model
(Deserialize). Validate is a iet-core trait; AppResult<T> is
Result<T, AppError>. AppError classifies a failure with an ErrorKind, a
code, a message, and optional fields; a FieldError names the field and a
short code but never the submitted value. Operation is metadata about the
access an input needs, not a grant. The ports return impl Future + Send so an
implementation can be asynchronous without boxing, keeping dispatch static.
Compile the contract in isolation:
Step 3: The in-memory repository
examples/my-notes/src/memory.rs (complete)
//! A process-local repository.
//!
//! It is deliberately volatile: restarting the process loses every note.
//!
//! The `NoteRepository` trait is the seam that keeps storage decisions out of
//! the service and the HTTP adapter. Replacing this file with a durable adapter
//! is *not* a drop-in change: a database backend also brings configuration, a
//! schema and migrations, a session type, and (if writes need more than one
//! statement) `CallScope`/transaction threading. Those are introduced in the
//! persistence guide, which is a separate worked slice, not an implementation
//! of this exact trait with this exact id type.
use crate;
use ;
use ;
/// Clones share one store, so the service, the handlers, and tests can each
/// hold a handle without a global or a service locator.
Arc lets handlers share one store; cloning the struct clones the Arc.
RwLock<BTreeMap<u64, Note>> is a standard-library lock and map, and
unwrap_or_else(|error| error.into_inner()) recovers from a poisoned lock
instead of panicking again. AtomicU64 hands out ids without a second lock,
starting at 1. capacity bounds stored notes and returns Unavailable when
full; it is not a rate limit. The async fn bodies never await, but the
signature is what a database-backed repository would implement.
Add the module:
Step 4: The service
examples/my-notes/src/service.rs (complete)
//! The application boundary. `NotesService` implements `Notes` over any
//! `NoteRepository`, so the service has no idea whether notes live in a map or
//! a database.
use crate;
use ;
NotesService<R> is generic and resolves R at compile time; the adapter names
NotesService<MemoryNotes> once, in main.rs. CallContext is trusted metadata
(a request id, an optional principal, an optional tenant) and constructing it
verifies nothing. iet_core::operation::check enforces access before validation;
for Access::Public it skips the authorizer but still validates, and passing
DenyPermissions makes a future permission-protected operation fail closed.
Validating here as well as in the extractor is one rule at two checkpoints, so a
non-HTTP caller gets the same rule. NotFound is an application classification,
not a status code — the adapter maps it later.
Add the module:
Step 5: The HTTP adapter
examples/my-notes/src/routes.rs (complete)
//! The HTTP adapter. It is the only module that names Axum and `iet-http-axum`.
//! Its job is to translate a request into a contract call and an application
//! error into a problem response -- nothing more.
use crate;
use ;
use MemoryCache;
use ;
use RateLimiter;
/// The concrete state every handler receives. It is generic over the notes
/// service, so the adapter never imports `MemoryNotes` or a database type.
/// The module's routes. `iet-http-axum` merges this into the REST stack and
/// applies the declared body limit and rate-limit policy.
async
async
/// Assemble the REST stack the same way `main` runs it. Tests call this so they
/// exercise the real router, body limit, and rate-limit layer.
Router<AppState<N>> still needs its state; finish(state) supplies it.
State extracts the router state and Json serializes a response.
ValidatedJson<T> parses the body, rejects a wrong content type (415,
http.content_type), enforces the body limit (413, http.body_limit), and
calls validate(). ApiPath<T> parses path parameters without echoing a bad
value. Created<T> writes 201. Result<_, ApiError> lets ? convert an
AppError into an application/problem+json response, mapping ErrorKind to a
status and hiding internals for server errors. build_router uses RestApi:
rate_limit selects the limiter, module_with_policy registers the named
notes module with one policy (validated at startup), and finish installs the
default 16 KiB JSON body limit plus problem responses for unknown paths and
methods.
Add the module:
Step 6: Settings
examples/my-notes/src/config.rs (complete)
//! Application-owned settings, loaded once at startup.
//!
//! Each field name becomes a logical key, and the environment prefix selects
//! this application's variables: `http_address` is read from
//! `NOTES_HTTP_ADDRESS`, `capacity` from `NOTES_CAPACITY`.
The derive produces load (read each field from a ConfigSource) and validate
(apply the range). The address defaults to 127.0.0.1:3012; the capacity is
bounded to 1–100,000. A missing value uses its default, while a malformed value
fails startup. The environment variables are NOTES_HTTP_ADDRESS and
NOTES_CAPACITY. The configuration reference
covers combining a struct prefix with an environment prefix.
Add the module:
Step 7: Composition and the host
examples/my-notes/src/main.rs (complete)
//! Composition root: load settings, choose the in-memory repository, and bind
//! the framework's HTTP host.
use Duration;
use MemoryCache;
use ConfigLoader;
use EnvSource;
use ;
use ;
use NotesConfig;
use MemoryNotes;
use build_router;
use NotesService;
async
Read this as the one place that names concrete types. The notes bound in
config.capacity and the limiter's counter cache (MemoryCache::<u64>::new(4096))
are independent numbers. RateLimitConfig::fixed(120, 60s) is the policy the
named notes module applies; build_router overrides the limiter's own default
for that module, so 120 per minute is the effective limit. HostConfig owns the
body limit and drain timeout, and this lesson overrides only the address.
HttpHost::bind binds explicitly; host.address() reports the result, and
host.run(shutdown) drains and returns. The framework installs no signal
handler, so main awaits Ctrl+C itself. The limiter keys on peer IP via
by_ip, using connection info the host installs.
Compile everything:
Run your copy
You should see notes API listening on http://127.0.0.1:3012. The requests and
responses are the same as in
Store and retrieve notes; the
happy path is a 201 then a 200, blank text is a 422 with the code
note.text_required, and an unknown id is a 404 with note.not_found.
If 3012 is already taken, set the address without editing code:
NOTES_HTTP_ADDRESS=127.0.0.1:4010 \
Verify with tests
The app runs, but it is not verified until the rules and responses are pinned down. Follow Test the notes API to add the unit tests and the in-process HTTP tests, then run:
You should see 13 unit tests and 5 HTTP tests pass:
running 13 tests
...
test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
running 5 tests
...
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
The checked-in reference (examples/handbook-notes/) already contains those
tests, so you can compare your copy against it or run its suite directly with
the same command and that manifest path.