1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# Rust backend services

Project-type-specific guidance for long-running HTTP services. Read
`scaffolding.md` first for shared project shape, then return here for
server-specific additions and the deploy workflow.

## Cargo.toml additions

On top of the baseline in `scaffolding.md`:

```toml
[dependencies]
axum = "*"
tower = "*"
tower-http = { version = "*", features = ["trace", "timeout", "compression-gzip"] }

[dev-dependencies]
reqwest = { version = "*", features = ["json"] }
```

- `axum` — minimal HTTP framework on top of `tower` and `hyper`.
- `tower-http` — common middleware (request tracing, timeouts,
  compression). Add features as needed; defaults stay light.
- `reqwest` — used by integration tests to hit the locally bound
  server. Not a runtime dependency.

Skip `clap` unless the server takes startup flags. Most servers read
configuration from the environment instead — see *Configuration*
below.

## Source layout

```
src/bin/<name>/
├── main.rs          # Bind, build router, run with graceful shutdown.
├── config.rs        # Env → typed config struct.
├── routes.rs        # Router construction.
└── handlers/        # One module per handler group.
    └── mod.rs
```

Domain logic still lives in the library. Handlers translate HTTP
requests into library calls and library results into HTTP responses.

## Entrypoint pattern

```rust
// src/bin/<name>/main.rs
mod config;
mod handlers;
mod routes;

use std::net::SocketAddr;

use miette::IntoDiagnostic as _;
use tokio::net::TcpListener;
use tokio::signal;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};

#[tokio::main]
async fn main() -> miette::Result<()> {
    miette::set_panic_hook();
    tracing_subscriber::registry()
        .with(fmt::layer())
        .with(EnvFilter::from_default_env())
        .init();

    let config = config::load()?;
    let app = routes::build(&config);

    let addr: SocketAddr = config.bind.parse().into_diagnostic()?;
    let listener = TcpListener::bind(addr).await.into_diagnostic()?;
    tracing::info!(%addr, "listening");

    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await
        .into_diagnostic()?;

    Ok(())
}

async fn shutdown_signal() {
    let ctrl_c = async {
        if let Err(err) = signal::ctrl_c().await {
            tracing::error!(%err, "failed to install SIGINT handler");
            std::future::pending::<()>().await;
        }
    };

    #[cfg(unix)]
    let terminate = async {
        match signal::unix::signal(signal::unix::SignalKind::terminate()) {
            Ok(mut sigterm) => {
                sigterm.recv().await;
            }
            Err(err) => {
                tracing::error!(%err, "failed to install SIGTERM handler");
                std::future::pending::<()>().await;
            }
        }
    };

    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();

    tokio::select! {
        () = ctrl_c => tracing::info!("received SIGINT, shutting down"),
        () = terminate => tracing::info!("received SIGTERM, shutting down"),
    }
}
```

Notes:

- `shutdown_signal()` returns once either SIGINT or SIGTERM fires.
  A handler that fails to install logs the error and parks forever
  rather than panicking, so the other signal still works and the
  template passes the `expect_used` lint under `-D warnings`.
  `axum::serve(...).with_graceful_shutdown(...)` stops accepting new
  connections and lets in-flight requests finish.
- Distroless containers have no init process. Either rely on this
  in-process signal handling, or wrap the binary in `tini` /
  `docker run --init`. See `dockerfile.md`.

## Error responses

Domain errors derive `thiserror::Error` and `miette::Diagnostic` (per
`scaffolding.md`). Add an `IntoResponse` impl in the binary so
handlers can return `Result<T, AppError>` directly.

```rust
// src/bin/<name>/handlers/error.rs
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::json;

use my_crate::MyError;

pub struct AppError(MyError);

impl From<MyError> for AppError {
    fn from(err: MyError) -> Self {
        Self(err)
    }
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, code) = match &self.0 {
            MyError::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
            MyError::Validation(_) => (StatusCode::BAD_REQUEST, "validation"),
            _ => (StatusCode::INTERNAL_SERVER_ERROR, "internal"),
        };

        // Log the full diagnostic; respond with code + status only.
        // The diagnostic detail belongs in observability, not in the
        // HTTP response.
        tracing::error!(error = ?self.0, "request failed");

        (status, axum::Json(json!({ "code": code }))).into_response()
    }
}
```

The wrapper is intentional — `IntoResponse` cannot be implemented for
the library's error type from the binary crate (orphan rule), and a
thin wrapper keeps the mapping in one place.

Body content is intentionally minimal. Diagnostic codes, source
spans, and help text leak implementation details — they belong in
logs and traces, not in the HTTP response body.

## Configuration

Punted. Server config conventions are project-dependent. For a quick
default, use `clap` derive with `env` (same as CLIs) so each setting
is documented in `--help` and overridable via env var. Revisit when a
preferred approach is locked in.

## Integration testing

`tests/api.rs` spawns the server on a random port and hits it with
`reqwest`. This catches wiring issues that unit tests miss (missing
routes, middleware misconfiguration, response shape).

```rust
use std::net::SocketAddr;

use reqwest::StatusCode;
use tokio::net::TcpListener;

async fn spawn() -> SocketAddr {
    let app = my_crate::routes::test_router();
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move {
        axum::serve(listener, app).await.unwrap();
    });
    addr
}

#[tokio::test]
async fn health_returns_200() {
    let addr = spawn().await;
    let res = reqwest::get(format!("http://{addr}/health"))
        .await
        .unwrap();
    assert_eq!(res.status(), StatusCode::OK);
}
```

Domain logic lives in the library and is exercised by hegeltest
property tests (see `property-testing.md`). Integration tests focus
on HTTP plumbing.

## Deploy

Server release is a Docker image build and push.

1. Build the image — see `dockerfile.md` for the cargo-chef +
   distroless template.
2. Tag with the git SHA and a semver / CalVer label.
3. Push to a registry of your choice (skill stays registry-agnostic).
4. Roll out via your orchestrator (Kubernetes, Nomad, ECS, …).

Image tags follow this pattern:

```
ghcr.io/<owner>/<name>:sha-<git-sha>     # immutable
ghcr.io/<owner>/<name>:<calver>          # human-readable
ghcr.io/<owner>/<name>:latest            # rolling pointer
```

CI builds and pushes on every merge to main. The orchestrator pulls
by sha tag for reproducibility; humans read the calver/latest tags
when diagnosing.