Store and retrieve notes

You already have an endpoint that validates JSON. This next example adds storage and separates the handler from application logic.

A larger example, not another prerequisite

This example introduces services and repository traits. They help separate responsibilities as an application grows; the framework does not require them for every endpoint. Storage is still process-local and public, with no user accounts.

This page is a guided tour of a working notes API. You run it first, see real requests and responses, and then read the few functions that produce them.

Prefer to build it yourself? Build it yourself is the full recipe: the manifest, every file, compile checkpoints, and the test stage. This page is the shorter tour for readers who want to see the finished behavior first.

The application is deliberately small: create a note, read a note, validate the input, and return predictable errors. Storage is in memory, so nothing needs a database. It uses the framework's contracts and HTTP adapter, not Axum alone.

Run every command from the repository root — the directory containing Cargo.toml, crates/, and examples/. If you have not set up yet, do the Quickstart first.

Run the finished example

The reference application is already in the checkout at examples/handbook-notes/. Start it:

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

The first build compiles the framework crates and takes a while. When it finishes you should see:

notes API listening on http://127.0.0.1:3012
press Ctrl+C to stop

Port 3012 keeps this lesson from colliding with other examples and the docs site; the loopback address keeps it off your network. Leave that terminal running and open a second one.

Create a note:

sh
curl -i -X POST http://127.0.0.1:3012/notes \
  -H 'content-type: application/json' \
  -d '{"text":"buy milk"}'
HTTP/1.1 201 Created
content-type: application/json

{"id":1,"text":"buy milk"}

Read it back by id:

sh
curl -i http://127.0.0.1:3012/notes/1
HTTP/1.1 200 OK
content-type: application/json

{"id":1,"text":"buy milk"}

Now the deliberate failures. Blank text is rejected by the contract rule:

sh
curl -i -X POST http://127.0.0.1:3012/notes \
  -H 'content-type: application/json' \
  -d '{"text":"   "}'
HTTP/1.1 422 Unprocessable Entity
content-type: application/problem+json

{"type":"about:blank","title":"Validation failed","status":422,"code":"note.text_required","errors":[{"path":"text","code":"required"}]}

An unknown id is a missing-note error:

sh
curl -i http://127.0.0.1:3012/notes/999
HTTP/1.1 404 Not Found
content-type: application/problem+json

{"type":"about:blank","title":"Not found","status":404,"code":"note.not_found","errors":[]}

Stop the server with Ctrl+C. Because the notes live in memory, they are gone when the process exits.

What just happened

Every request follows the same path. Nothing talks to the network except the host; the same functions can be called from a test or a worker:

  1. 01callerHTTP request
  2. 02adapterroutes.rs handlerParse and extract input
  3. 03applicationNotesServiceAccess check, validation, decisions
  4. 04persistenceNoteRepositoryStore and load
  5. 05callerHTTP responseSerialize or map the error

The code is split across three ideas, and the HTTP types are confined to routes.rs:

The following snippets focus on those functions and omit imports and some surrounding definitions. Use Build it yourself for complete files.

FileResponsibility
src/note.rsThe data, the inputs and their rules, and the two boundary traits
src/memory.rsA volatile in-process repository
src/service.rsThe application boundary over any repository
src/routes.rsThe HTTP adapter (the only file that names Axum)
src/config.rsThe settings loaded at startup
src/main.rsThe composition root: choose the pieces, bind the host

Read the next few functions in that order.

The rule: CreateNote::validate

examples/handbook-notes/src/note.rsrust
impl Validate for CreateNote {
    fn validate(&self) -> AppResult<()> {
        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(())
    }
}

Validate is a iet-core trait with one method. The rule lives on the input type, not in an Axum extractor, so a test or a different adapter can use it.

AppError carries an ErrorKind such as Validation, NotFound, or Unavailable, plus a machine-readable code, a human message, and optional fields. Here the kind is Validation; the code note.text_required and the field text are exactly what appeared in the 422 response. Keep submitted values out of FieldError: its fields are names and codes, not private input.

The service: NotesService::create and get

examples/handbook-notes/src/service.rsrust
impl<R: NoteRepository> Notes for NotesService<R> {
    async fn create(&self, context: &CallContext, input: CreateNote) -> AppResult<Note> {
        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 is generic over R: NoteRepository, so it never names a concrete store; main.rs names NotesService<MemoryNotes> exactly once.

CallContext is trusted invocation metadata: a request id, an optional principal, and an optional tenant. This lesson has no authentication, so callers pass an empty one. Building a CallContext proves nothing; a trusted adapter fills in the principal after authenticating.

iet_core::operation::check reads the Operation implementation on the input and enforces access before it calls validate(). Both notes operations are Access::Public, which skips authorization but still validates. Passing DenyPermissions is deliberate: it is the framework's deny-by-default policy, so an operation later changed to Access::Permission(...) fails closed instead of silently succeeding.

NotFound is an application classification. The service does not know that it becomes HTTP 404; the adapter decides that.

The adapter: the handlers

examples/handbook-notes/src/routes.rsrust
async fn create<N: Notes>(
    State(state): State<AppState<N>>,
    ValidatedJson(input): ValidatedJson<CreateNote>,
) -> Result<Created<Note>, ApiError> {
    let note = state
        .notes
        .create(&iet_core::CallContext::default(), input)
        .await?;
    Ok(Created(note))
}

async fn fetch<N: Notes>(
    State(state): State<AppState<N>>,
    ApiPath(id): ApiPath<u64>,
) -> Result<Json<Note>, ApiError> {
    let note = state
        .notes
        .get(&iet_core::CallContext::default(), GetNote { id })
        .await?;
    Ok(Json(note))
}
  • ValidatedJson<CreateNote> parses the body, rejects a wrong content type with 415 http.content_type, enforces the body limit, and calls validate(). That is why blank text produced a 422 before this function ran.
  • ApiPath<u64> parses the path parameter without echoing a bad value back.
  • Created writes 201; Json<Note> writes the 200 body.
  • Result<_, ApiError> lets ? convert an AppError from the service into an ApiError, which renders application/problem+json with type, title, status, code, and errors.

The store: MemoryNotes::insert and find

examples/handbook-notes/src/memory.rsrust
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())
}

MemoryNotes is one implementation of NoteRepository: a BTreeMap behind an RwLock, with an AtomicU64 handing out increasing ids that start at 1 (so 0 can stay an invalid path value). Cloning it clones an Arc, not the notes. When the store is full, insert returns Unavailable, which the adapter turns into 503. main.rs passes the capacity in from configuration.

The composition root

src/main.rs is the only file that names every concrete type. Its essential lines:

examples/handbook-notes/src/main.rsrust
let source = EnvSource::snapshot("NOTES")?;
let config: NotesConfig = ConfigLoader::new(source).load()?;

let notes = NotesService::new(MemoryNotes::new(config.capacity)?);
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)),
)?;

let host = HttpHost::bind(
    HostConfig {
        address: config.http_address,
        ..HostConfig::default()
    },
    router,
)
.await?;
  • EnvSource::snapshot("NOTES") takes one immutable copy of the environment, exposing NOTES_HTTP_ADDRESS and NOTES_CAPACITY. ConfigLoader runs the derived load and validation, so a malformed value fails at startup. The address defaults to 127.0.0.1:3012.
  • config.capacity bounds stored notes and is unrelated to the limiter.
  • MemoryCache::<u64>::new(4096) is the rate-limit counter cache: it holds up to 4096 distinct caller counters. That number is independent of how many notes you can store.
  • RateLimitConfig::fixed(120, 60s) is the policy the named notes module actually applies. build_router calls RestApi::module_with_policy, which overrides the limiter's own default policy for that module, so the effective limit is 120 requests per minute per client. The two 120s are the limiter fallback and the module policy; only the module policy governs these routes.
  • HttpHost::bind performs an explicit bind, host.address() reports the bound address, and host.run(shutdown) serves until the shutdown future completes. The framework does not install a signal handler, which is why main awaits tokio::signal::ctrl_c().

The rate limiter identifies callers by peer IP (by_ip), taken from connection info the host installs. That is why in-process tests must supply it themselves.

Where the full files live

This page showed the functions that answer a request. To create every file with its exact contents, including the manifest and settings, continue to Build it yourself. To see the tests that lock this behavior down, including the rate-limit and ConnectInfo details, go to Test the notes API.

Two boundaries are deliberately absent: there is no authentication (every operation is public and the context is empty), and there is no CallScope or transaction (an in-memory map has no exclusive session to thread). Calls and transactions introduces both.

Honest limits of this application: it is loopback-only and unauthenticated, so do not expose it; notes vanish on restart; rate-limit counters are per process; and a full store returns 503 rather than queuing.

IET Framework documentation — built with the IET Framework.