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

sh
mkdir -p examples/my-notes/src

Create examples/my-notes/Cargo.toml with exactly this content:

examples/my-notes/Cargo.tomltoml
[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-core is crates/core/iet-core.
  • [workspace]. A manifest with a [workspace] table is its own workspace root, which keeps this lesson's dependency graph and Cargo.lock separate from the framework root. The root manifest is never edited.
  • Features. iet-http-axum/host adds the HostConfig listener and the networking dependencies. Without it, the crate provides routing conventions but no server.
  • Tokio. macros gives #[tokio::main]/#[tokio::test]; rt-multi-thread runs the server; net and signal back the listener and Ctrl+C; time is used by the host's drain budget.
  • Dev-dependencies. serde_json reads response bodies in tests; tower provides ServiceExt::oneshot for in-process HTTP tests.

Create examples/my-notes/src/lib.rs, starting with one module. You will add one line per step:

examples/my-notes/src/lib.rsrust
pub mod note;

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)
examples/my-notes/src/note.rsrust
//! 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 iet_core::operation::{Access, Operation};
use iet_core::{AppError, AppResult, CallContext, ErrorKind, FieldError, Validate};
use serde::{Deserialize, Serialize};
use std::future::Future;

/// A stored note, as the API returns it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Note {
    pub id: u64,
    pub text: String,
}

/// 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.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CreateNote {
    pub text: String,
}

impl Validate for CreateNote {
    fn validate(&self) -> AppResult<()> {
        // A rule belongs to the input, not to the HTTP extractor. The same
        // `validate()` runs at the transport edge and again in the service.
        if self.text.trim().is_empty() {
            let mut error = AppError::new(
                ErrorKind::Validation,
                "note.text_required",
                "A note needs some text",
            );
            error.fields.push(FieldError {
                path: "text".into(),
                code: "required".into(),
            });
            return Err(error);
        }
        Ok(())
    }
}

/// The input for `GET /notes/{id}`. Notes are numbered from 1.
#[derive(Debug, Clone, Copy)]
pub struct GetNote {
    pub id: u64,
}

impl Validate for GetNote {
    fn validate(&self) -> AppResult<()> {
        if self.id == 0 {
            return Err(AppError::new(
                ErrorKind::Validation,
                "note.id_required",
                "A note id must be at least 1",
            ));
        }
        Ok(())
    }
}

/// `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.
impl Operation for CreateNote {
    type Input = Self;
    const ACCESS: Access = Access::Public;
}

impl Operation for GetNote {
    type Input = Self;
    const ACCESS: Access = Access::Public;
}

/// The application boundary. The HTTP adapter is generic over this trait and
/// never names a concrete store.
pub trait Notes: Clone + Send + Sync + 'static {
    fn create(
        &self,
        context: &CallContext,
        input: CreateNote,
    ) -> impl Future<Output = AppResult<Note>> + Send;

    fn get(
        &self,
        context: &CallContext,
        input: GetNote,
    ) -> impl Future<Output = AppResult<Note>> + Send;
}

/// The storage boundary. A repository deals in stored records only; it does not
/// validate, authorize, or decide HTTP status codes.
pub trait NoteRepository: Clone + Send + Sync + 'static {
    fn insert(&self, text: String) -> impl Future<Output = AppResult<Note>> + Send;
    fn find(&self, id: u64) -> impl Future<Output = AppResult<Option<Note>>> + Send;
}

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:

sh
cargo check --manifest-path examples/my-notes/Cargo.toml --lib

Step 3: The in-memory repository

examples/my-notes/src/memory.rs (complete)
examples/my-notes/src/memory.rsrust
//! 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::note::{Note, NoteRepository};
use iet_core::{AppError, AppResult, ErrorKind};
use std::{
    collections::BTreeMap,
    sync::{
        Arc, RwLock,
        atomic::{AtomicU64, Ordering},
    },
};

/// Clones share one store, so the service, the handlers, and tests can each
/// hold a handle without a global or a service locator.
#[derive(Clone)]
pub struct MemoryNotes {
    notes: Arc<RwLock<BTreeMap<u64, Note>>>,
    next_id: Arc<AtomicU64>,
    capacity: usize,
}

impl MemoryNotes {
    pub fn new(capacity: usize) -> AppResult<Self> {
        if capacity == 0 {
            return Err(AppError::new(
                ErrorKind::Validation,
                "notes.capacity",
                "The notes capacity must be at least 1",
            ));
        }
        Ok(Self {
            notes: Arc::new(RwLock::new(BTreeMap::new())),
            // Ids start at 1 so that 0 can stay an invalid path value.
            next_id: Arc::new(AtomicU64::new(1)),
            capacity,
        })
    }

    pub fn len(&self) -> usize {
        self.notes
            .read()
            .unwrap_or_else(|error| error.into_inner())
            .len()
    }

    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }
}

impl NoteRepository for MemoryNotes {
    async fn insert(&self, text: String) -> AppResult<Note> {
        let mut notes = self
            .notes
            .write()
            .unwrap_or_else(|error| error.into_inner());
        if notes.len() >= self.capacity {
            return Err(AppError::new(
                ErrorKind::Unavailable,
                "notes.capacity",
                "The notes store is full",
            ));
        }
        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
        let note = Note { id, text };
        notes.insert(id, note.clone());
        Ok(note)
    }

    async fn find(&self, id: u64) -> AppResult<Option<Note>> {
        Ok(self
            .notes
            .read()
            .unwrap_or_else(|error| error.into_inner())
            .get(&id)
            .cloned())
    }
}

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:

examples/my-notes/src/lib.rsrust
pub mod memory;
pub mod note;
sh
cargo check --manifest-path examples/my-notes/Cargo.toml --lib

Step 4: The service

examples/my-notes/src/service.rs (complete)
examples/my-notes/src/service.rsrust
//! 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::note::{CreateNote, GetNote, Note, NoteRepository, Notes};
use iet_core::{AppError, AppResult, CallContext, ErrorKind};

#[derive(Clone)]
pub struct NotesService<R> {
    repository: R,
}

impl<R> NotesService<R> {
    pub fn new(repository: R) -> Self {
        Self { repository }
    }
}

impl<R: NoteRepository> Notes for NotesService<R> {
    async fn create(&self, context: &CallContext, input: CreateNote) -> AppResult<Note> {
        // Access is checked before validation, and validation still runs even
        // though `ValidatedJson` already did it at the HTTP edge. A caller that
        // is not HTTP -- a worker, a test, another service -- gets the same rule.
        //
        // `DenyPermissions` implements the framework's `Authorizer` port. Public
        // operations never consult it, but if someone later changes `CreateNote`
        // to `Access::Permission(...)` it fails closed instead of silently
        // allowing the call.
        iet_core::operation::check::<CreateNote, _>(&iet_authz::DenyPermissions, context, &input)
            .await?;
        let text = input.text.trim().to_owned();
        self.repository.insert(text).await
    }

    async fn get(&self, context: &CallContext, input: GetNote) -> AppResult<Note> {
        iet_core::operation::check::<GetNote, _>(&iet_authz::DenyPermissions, context, &input)
            .await?;
        self.repository
            .find(input.id)
            .await?
            .ok_or_else(|| AppError::new(ErrorKind::NotFound, "note.not_found", "Note not found"))
    }
}

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:

examples/my-notes/src/lib.rsrust
pub mod memory;
pub mod note;
pub mod service;
sh
cargo check --manifest-path examples/my-notes/Cargo.toml --lib

Step 5: The HTTP adapter

examples/my-notes/src/routes.rs (complete)
examples/my-notes/src/routes.rsrust
//! 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::note::{CreateNote, GetNote, Note, Notes};
use axum::{
    Json, Router,
    extract::State,
    routing::{get, post},
};
use iet_cache_memory::MemoryCache;
use iet_http_axum::{ApiError, ApiPath, Created, RateLimitConfig, RestApi, ValidatedJson};
use iet_rate_limit::RateLimiter;

/// The concrete state every handler receives. It is generic over the notes
/// service, so the adapter never imports `MemoryNotes` or a database type.
#[derive(Clone)]
pub struct AppState<N> {
    pub notes: N,
}

/// The module's routes. `iet-http-axum` merges this into the REST stack and
/// applies the declared body limit and rate-limit policy.
pub fn routes<N: Notes>() -> Router<AppState<N>> {
    Router::new()
        .route("/notes", post(create::<N>))
        .route("/notes/{id}", get(fetch::<N>))
}

async fn create<N: Notes>(
    State(state): State<AppState<N>>,
    // `ValidatedJson` parses the body, enforces the content type and body
    // limit, and calls `CreateNote::validate()`. A rejected input never reaches
    // this function.
    ValidatedJson(input): ValidatedJson<CreateNote>,
) -> Result<Created<Note>, ApiError> {
    let note = state
        .notes
        .create(&iet_core::CallContext::default(), input)
        .await?;
    // `Created` writes 201; `?` turns `AppError` into `ApiError`, which writes
    // an `application/problem+json` response.
    Ok(Created(note))
}

async fn fetch<N: Notes>(
    State(state): State<AppState<N>>,
    // `ApiPath` keeps a bad path value from being reflected into the response.
    ApiPath(id): ApiPath<u64>,
) -> Result<Json<Note>, ApiError> {
    let note = state
        .notes
        .get(&iet_core::CallContext::default(), GetNote { id })
        .await?;
    Ok(Json(note))
}

/// 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.
pub fn build_router(
    notes: impl Notes,
    limiter: RateLimiter<MemoryCache<u64>>,
    policy: RateLimitConfig,
) -> iet_core::AppResult<Router> {
    let state = AppState { notes };
    Ok(RestApi::new()
        .rate_limit(limiter)
        // A named module gets one shared policy. `module_with_policy` validates
        // the policy before the route is registered.
        .module_with_policy("notes", routes(), policy)?
        .finish(state))
}

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:

examples/my-notes/src/lib.rsrust
pub mod memory;
pub mod note;
pub mod routes;
pub mod service;
sh
cargo check --manifest-path examples/my-notes/Cargo.toml --lib

Step 6: Settings

examples/my-notes/src/config.rs (complete)
examples/my-notes/src/config.rsrust
//! 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`.

#[derive(Debug, iet_config_macros::Configuration)]
pub struct NotesConfig {
    /// Address the JSON API listens on (default `127.0.0.1:3012`).
    #[config(default = "127.0.0.1:3012")]
    pub http_address: std::net::SocketAddr,
    /// Maximum number of notes kept in memory (default 1000).
    #[config(default = 1000, range(min = 1, max = 100_000))]
    pub capacity: usize,
}

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:

examples/my-notes/src/lib.rsrust
pub mod config;
pub mod memory;
pub mod note;
pub mod routes;
pub mod service;

Step 7: Composition and the host

examples/my-notes/src/main.rs (complete)
examples/my-notes/src/main.rsrust
//! Composition root: load settings, choose the in-memory repository, and bind
//! the framework's HTTP host.

use std::time::Duration;

use iet_cache_memory::MemoryCache;
use iet_config::ConfigLoader;
use iet_config_env::EnvSource;
use iet_http_axum::{HostConfig, HttpHost, RateLimitConfig};
use iet_rate_limit::{RateLimiter, RatePolicy};
use handbook_notes::config::NotesConfig;
use handbook_notes::memory::MemoryNotes;
use handbook_notes::routes::build_router;
use handbook_notes::service::NotesService;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    // Read the environment once into an immutable snapshot, then validate the
    // typed settings. A missing value uses its declared default; a malformed
    // supplied value fails startup instead of silently falling back.
    let source = EnvSource::snapshot("NOTES")?;
    let config: NotesConfig = ConfigLoader::new(source).load()?;

    let notes = NotesService::new(MemoryNotes::new(config.capacity)?);
    // The limiter counts requests in a process-local cache. `by_ip` needs the
    // peer address, which the framework host installs for every connection.
    let limiter = RateLimiter::new(
        MemoryCache::<u64>::new(4096)?,
        RatePolicy::new("notes.http.ip", 120, Duration::from_secs(60))?,
    );
    let router = build_router(
        notes,
        limiter,
        RateLimitConfig::fixed(120, Duration::from_secs(60)),
    )?;

    // `HostConfig::default()` supplies the body limit and drain timeout; this
    // lesson only overrides the listen address.
    let host = HttpHost::bind(
        HostConfig {
            address: config.http_address,
            ..HostConfig::default()
        },
        router,
    )
    .await?;
    println!("notes API listening on http://{}", host.address()?);
    println!("press Ctrl+C to stop");

    // The caller owns shutdown and signals; the host only drains and returns.
    host.run(async {
        let _ = tokio::signal::ctrl_c().await;
    })
    .await?;
    Ok(())
}

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:

sh
cargo check --manifest-path examples/my-notes/Cargo.toml

Run your copy

sh
cargo run --manifest-path examples/my-notes/Cargo.toml

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:

sh
NOTES_HTTP_ADDRESS=127.0.0.1:4010 \
  cargo run --manifest-path examples/my-notes/Cargo.toml

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:

sh
cargo test --manifest-path examples/my-notes/Cargo.toml

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.

IET Framework documentation — built with the IET Framework.