# Topcoat 0.10 API cheat-sheet (git forge web layer) `REG` = `/home/tahinli/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f`. Sub-crates: `REG/topcoat--0.10.0/`. **UNVERIFIED** = not confirmed in source. Snippets checked against signatures and docs, not compiled. Key files: facade `REG/topcoat-0.10.0/src/{lib,serve}.rs`; router `REG/topcoat-router-0.10.0/src/{router,builder,request,response,error,body,body_limit,compression,origin,proxy,listener}.rs`, `.../src/content/{json,form}.rs`; cookies `REG/topcoat-cookie-0.10.0/src/{lib,prefix,store}.rs`; context `REG/topcoat-core-0.10.0/src/context/app_context.rs`; views `REG/topcoat-view-0.10.0/src/`, `REG/topcoat-view-macro-0.10.0/docs/view.md`; guides `REG/topcoat-0.10.0/docs/*.md`, `REG/topcoat-router-0.10.0/docs/*.md`. --- ## 1. Feature flags Default: `asset, compression, cookie, font, icon, router, runtime, serve, session, view, discover`. | Feature | Enables | |---|---| | `router` | `topcoat::router` (routes, pages, layouts, layers, `Router`); pulls `view` | | `serve` | `topcoat::start/serve/serve_until`. Without it use `Router::handle` only | | `discover` | Link-time collection: `module_router!()`, `.discover()` | | `view` | `view!`, `#[component]`, `class!`, `suspense`, `error_boundary` | | `cookie` | `topcoat::cookie`. Still call `.cookies()` on the builder | | `asset` | `asset!`, `AssetBundle` | | `compression` | Response compression (on by default, see 7e) | | `session` | `topcoat::session` (**UNVERIFIED**, not covered) | | `fs` | `public_dir`, `serve_dir` (not default) | | `multipart` | `content::Multipart` (not default) | | `websocket`, `sse`, `sitemap`, `tower`, `tailwind`, `datastar`, `htmx`, `alpine-ajax`, `mail`, `ui`, `font-fontsource`, `icon-iconify`, `anyhow`, `full` | Optional extras (not default) | Minimal deps: `topcoat = "0.10.0"`, `tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io-util"] }`. For streaming (4b) also `http-body = "1"`, `http-body-util = "0.1"`, `tokio-util = { version = "0.7", features = ["io"] }`, `futures-util = "0.3"`. --- ## 2. Routes ### 2a. Module routing (recommended) ```rust // src/app.rs (module maps to "/") use topcoat::router::{Router, module_router}; pub fn router() -> Router { module_router!().discover().build() } ``` - `module_router!()` returns a `RouterBuilder`. `.discover()` (trait `topcoat::router::RouterBuilderDiscoverExt`) also collects explicit-path items. - `app::about` -> `/about`; `app::blog_posts` -> `/blog-posts`. Handler names do not affect the URL. - Without a path string, `#[page]`, `#[route(GET)]`, `#[layout]`, `#[layer]` take the module path. `#[route(GET "./health")]` appends to it. Source: `REG/topcoat-router-0.10.0/docs/module.md`. ### 2b. Explicit paths ```rust use topcoat::{Result, router::{Router, page, route}, view::{View, view}}; #[page("/")] async fn home() -> Result { Ok(view! {

"Home"

}) } #[route(GET "/api/health")] async fn health() -> Result<&'static str> { Ok("ok") } pub fn router() -> Router { Router::builder().page(home).route(health).build() } ``` Builder methods: `.page(f)`, `.route(f)`, `.layout(f)`, `.layer(f)`, `.discover()`, `.build()`. Source: `REG/topcoat-router-0.10.0/src/builder.rs`. ### 2c. Attributes ```rust #[route(GET "/x")] // one method #[route([GET, POST] "/x")] // several #[route(* "/x")] // any method; specific-method routes win over `*` #[page] // GET, module path #[page("/x")] // GET, explicit #[page(POST "/x")] // POST page ``` Route handlers return `Result` where `T: AsyncIntoResponse`. Pages return `Result`. Parameters: optional `cx: &Cx` and at most one body extractor, in either order. Overlapping methods at the same path fail at `.build()`. ### 2d. Path params: `/{owner}/{repo}/info/refs` ```rust use topcoat::{Result, context::Cx, router::{path_param, route}, view::{View, view}}; path_param!(owner); // unparsed: path_param::(cx) -> &str (percent-decoded) path_param!(repo); path_param!(id: u64); // parsed with FromStr: path_param::(cx) -> Result<&u64, _> path_param!(id2: u64, error = not_found); // error = not_found|bad_request|unauthorized|forbidden|redirect("/x") path_param!(*rest); // catch-all: "{*rest}", path_param::(cx) -> CatchAllSegments #[route(GET "/{owner}/{repo}/info/refs")] async fn info_refs(cx: &Cx) -> Result<&'static str> { let owner: &str = path_param::(cx); let repo: &str = path_param::(cx); let _ = (owner, repo); Ok("ok") } ``` - `path_param!(name)` generates `Name` (Pascal-cased). Name the parameter in the path as `{name}`. Reading a parameter the route did not capture panics. - Module form: `module_param!(post_id: u64, error = bad_request);` in `src/app/posts/post_id.rs` serves `/posts/{post_id}` (the module adds one segment). `module_param!(*path)` for catch-all. - Source: `REG/topcoat-router-macro-0.10.0/docs/path_param.md`, `.../docs/module_param.md`. ### 2e. Query params ```rust use topcoat::{Result, context::Cx, router::{page, query_params}, view::{View, view}}; #[query_params(error = bad_request)] struct InfoRefsQuery { service: Option } #[page] async fn page(cx: &Cx) -> Result { let q = query_params::(cx)?; // &InfoRefsQuery, memoized Ok(view! {

(q.service.as_deref().unwrap_or(""))

}) } ``` Derives `serde::Deserialize`. Works from any handler with `cx`. Source: `REG/topcoat-router-macro-0.10.0/docs/query_params.md`. ### 2f. Registration summary - `module_router!()` (module-derived), `Router::builder()` (manual), `.discover()` (all explicit-path items), `.public_dir("./public")` / `.serve_dir("/p/{*f}", "./dir")` (feature `fs`, static files). --- ## 3. Request access Import helpers from `topcoat::router::request`. All take `cx: &Cx`. | Need | Call | Returns | |---|---|---| | method | `method(cx)` | `&http::Method` | | uri / query | `uri(cx)` | `&http::Uri` (`uri(cx).query()`) | | headers | `headers(cx)` | `&HeaderMap` | | content type | `content_type(cx)` | `Option<&str>` | | all parts | `parts(cx)` | `&http::request::Parts` | | client IP | `client_ip(cx)` | `Option` (proxy-aware) | | peer addr | `remote_addr(cx)` | `Option` (None on Unix sockets) | | pre-rewrite | `original_uri(cx)`, `original_headers(cx)`, ... | same types | `client_ip` returns the direct peer unless `.trusted_proxies(TrustedProxies::new()...)` is set on the builder. `ForwardedHeader` variant names: **UNVERIFIED** (`REG/topcoat-router-0.10.0/src/proxy.rs`). Body extractors (one per handler): | Type | Behavior | |---|---| | `Body` (`topcoat::router::Body`) | Streaming. Not limited. `body.into_data_stream()` yields `Result` | | `Bytes` (`topcoat::router::request::Bytes`) | Buffered, body limit applies (413) | | `String` | Buffered UTF-8, body limit applies | | `Json` (`topcoat::router::content::Json`) | Requires `Content-Type: application/json` (400 otherwise); malformed = 400 | | `Form` (`topcoat::router::content::Form`) | `x-www-form-urlencoded`. On GET/HEAD reads the query string | | `RawForm` | Raw form bytes | | `Multipart` (feature `multipart`) | **UNVERIFIED** field API (`REG/topcoat-router-0.10.0/docs/content/multipart.md`) | | `Option` | Wraps any extractor to accept an absent body | ```rust use topcoat::{Result, context::Cx, router::{Body, route, content::Form}}; #[route(POST "/{owner}/{repo}/git-receive-pack")] async fn receive(cx: &Cx, body: Body) -> Result<&'static str> { let _ = (cx, body); Ok("") } #[derive(serde::Deserialize)] struct NewRepo { name: String } #[route(POST "/repos")] async fn create(Form(input): Form) -> Result<&'static str> { let _ = input.name; Ok("created") } ``` Custom extractor: `impl FromRequest { async fn from_request(cx: &Cx, body: Body) -> Result }`. Delegate buffering to `Bytes` or `to_bytes(body, body_limit(cx))`. --- ## 4. Responses and errors ### 4a. Return types - `&'static str`, `String`, `Vec`, `Bytes`, `()` -> 200 with body. `StatusCode` -> empty body. `Body` -> as-is. `Json` -> JSON. `Response` (`http::Response`) -> full control. - Tuples: a leading `StatusCode`, `Parts`, or `Response<()>` sets the status; middle items are `IntoResponseParts` (`HeaderMap`, `[(K, V); N]`, `Extensions`, `Option`); the last item is the body. Source: `REG/topcoat-router-0.10.0/src/response.rs`. ```rust use topcoat::{Result, router::{Body, HeaderMap, StatusCode, header, route}}; #[route(GET "/{owner}/{repo}/info/refs")] async fn info_refs() -> Result<(StatusCode, HeaderMap, Body)> { let mut h = HeaderMap::new(); h.insert(header::CONTENT_TYPE, "application/x-git-upload-pack-advertisement".parse().unwrap()); Ok((StatusCode::OK, h, Body::from("001e# service=git-upload-pack\n0000"))) } // or: Ok((StatusCode::OK, [(header::CONTENT_TYPE, "text/plain")], Body::from("ok"))) ``` ### 4b. Streaming from a child process Pattern from `REG/topcoat-router-0.10.0/src/route/directory.rs` (`fn stream`): `Body::new(StreamBody::new(stream_of_frames))`. ```rust use std::process::Stdio; use futures_util::TryStreamExt; use http_body::Frame; use http_body_util::StreamBody; use tokio::process::Command; use tokio_util::io::ReaderStream; use topcoat::{Result, router::{Body, HeaderMap, StatusCode}}; async fn stream_child(mut cmd: Command) -> Result<(StatusCode, HeaderMap, Body)> { let mut child = cmd.stdout(Stdio::piped()).spawn()?; // io::Error -> 500 let stdout = child.stdout.take().expect("piped"); let chunks = ReaderStream::new(stdout).map_ok(Frame::::data); Ok((StatusCode::OK, HeaderMap::new(), Body::new(StreamBody::new(chunks)))) } ``` - `bytes` is re-exported as `topcoat::router::request::Bytes`. - Caveat: `child` drops when this function returns. With `kill_on_drop(true)` the process would die immediately, so either omit it or move the `Child` into the stream (a struct owning `child` and the stream). Without `kill_on_drop`, the child is not killed on client disconnect; it exits on SIGPIPE. **UNVERIFIED** that the router does anything here. - Request body to child stdin: `tokio_util::io::StreamReader::new(body.into_data_stream().map_err(std::io::Error::other))` then `tokio::io::copy`. ### 4c. Errors -> status codes `topcoat::Result` = `Result`. Any `std::error::Error` converts with `?`. Non-router errors become **500** (body `internal server error`). Handler panics also become 500 (`REG/topcoat-router-0.10.0/src/router.rs`, `handle`). | Constructor (`topcoat::router::error`) | Status | Ext on `Option`/`Result` | |---|---|---| | `not_found()` | 404 | `ok_or_not_found()` | | `bad_request("msg")` | 400 | `ok_or_bad_request(..)` | | `unauthorized()` | 401 | `ok_or_unauthorized()` | | `forbidden()` | 403 | `ok_or_forbidden()` | | `method_not_allowed(methods)` | 405 (+ `Allow`) | - | | `content_too_large()` | 413 | - | | `too_many_requests(secs)` | 429 | - | | `service_unavailable(secs)` | 503 | - | | `redirect(uri)` | 307 | `ok_or_redirect(uri)` | | `redirect_permanent(uri)` | 308 | `ok_or_redirect_permanent(uri)` | | `see_other(uri)` | 303 (`SeeOther`, usable as `Ok(..)`) | - | | `internal_server_error(e)` | 500 | - | ```rust use topcoat::{Result, context::Cx, router::{error::{RouterErrorExt, forbidden, see_other, SeeOther}, route}}; #[route(POST "/repos")] async fn create(cx: &Cx) -> Result { let _ = cx; Ok(see_other("/repos")) } fn lookup() -> Result<&'static str> { Ok(None::<&str>.ok_or_not_found()?) } // 404 fn gate() -> Result<()> { Err(forbidden().into()) } // 403 ``` Error boundary: a layout can wrap `slot` in `error_boundary(fallback: |error| ..., (slot))` and `error.downcast_ref::()` for a branded 404 (`REG/topcoat-0.10.0/docs/router.md`). Headers that must appear on error responses: `topcoat::router::response::response_headers(cx).append(name, value)` in a layer (`REG/topcoat-router-0.10.0/src/response/headers.rs`). --- ## 5. Views, components, layouts, escaping, forms ```rust use topcoat::{Result, router::{Slot, layout}, view::{View, component, view, class}}; #[component] async fn badge(label: &str, ok: bool) -> Result { Ok(view! { (label) }) } #[layout] // or #[layout("/settings")] async fn root(slot: Slot<'_>) -> Result { Ok(view! {

"Repo"

badge(label: "New", ok: true) // component call, named args (slot) // inner page }) } #[component] async fn list(items: Vec) -> Result { Ok(view! {
    for item in items.iter() {
  • (item)
  • }
}) } ``` - Syntax: HTML tags with real names; void tags without closing; text in quotes; Rust expressions in parentheses (child and attribute position); `if`, `for`, `match`, `let` in markup; dynamic names `<(tag) (attr)="x">`; attribute names may contain `-`, `:`, `.`. Source: `REG/topcoat-view-macro-0.10.0/docs/view.md`, `.../docs/component.md`. - Components take typed params; `Slot` for layouts; `cx: &Cx` optional, any order. - **Escaping: yes, auto-escaped.** Text and `(expr)` interpolations escape `<`, `&`, etc., and attribute values are escaped too (tests in `REG/topcoat-view-0.10.0/src/buffer/handle.rs`, `.../src/html/attribute.rs`). Opt-out is `push_*_unescaped` and an `Unescaped` wrapper (`REG/topcoat-view-0.10.0/src/string.rs`); facade path **UNVERIFIED**. Do not wrap commit messages, file names, or diffs in it. - Links and forms: `href!(handler_fn)` builds URLs from the handler's registered path; `href!(mod::handler, mod::Param(1))` passes params. `
` from source docs. HTML forms only send GET/POST. Source: `REG/topcoat-router-0.10.0/docs/href.md`. - Active nav: `href.is_current(cx)` plus `class!`. - A `#[page]` sets status or headers with a `StatusCode` or `HeaderMap` element inside `view!` (per `REG/topcoat-0.10.0/docs/router.md`; not run). For git endpoints use `#[route]` returning `Response` or a tuple. --- ## 6. Cookies ```rust use topcoat::{cookie::RouterBuilderCookieExt, router::{Router, RouterBuilderDiscoverExt, module_router}}; pub fn router() -> Router { module_router!().discover().cookies().build() } // required use topcoat::{Result, context::Cx, cookie::{Cookie, Cookies, cookies}, router::route}; #[route(POST "/theme")] async fn set_theme(cx: &Cx) -> Result<&'static str> { let jar = cookies(cx); // &CookieJar; `Cookies` trait in scope let _current = jar.get("theme"); // includes pending writes jar.add(Cookie::build(("theme", "dark")).path("/").http_only(true).secure(true).build()); jar.remove(Cookie::build(("theme", "")).path("/")); // same Path/Domain as when set Ok("ok") } ``` - `add`/`remove` accept `Into`, so `jar.add(("k", "v"))` works. - Attributes: `Path`, `Domain`, `HttpOnly`, `Secure`, `SameSite`, `MaxAge`, set via builder or `cookie!("s" = "v"; Path = "/"; Secure; HttpOnly; SameSite = Lax)` (`topcoat::cookie::cookie`). - Defaults/overrides on the jar: `cookies(cx).default_secure(true).default_http_only(true).default_same_site(SameSite::Lax).default_path("/")`. The `override_*` form forces a value. Wrap this in one helper `fn cookies(cx) -> impl Cookies`. - `__Host-`: works. `jar.override_prefix_host()` stores `__Host-name`, forces `Secure`, `Path=/`, no `Domain`; reads strip the prefix. `override_prefix_secure()` gives `__Secure-`. Browsers reject `Secure` cookies over plain `http://` except on localhost (**UNVERIFIED** for your deployment). - Signed/private: `signed_cookies(cx)`, `private_cookies(cx)` need a `Key` in app context: `.app_context(Key::generate())`. Calling them without a key panics. Persist the key across restarts. `cookies(cx).private(&key)` takes it explicitly. - Typed store: `cookie_store::(private_cookies(cx), "cart").parse_or_default().update(|t| ..).commit()?`. Nothing is written until `commit()`. - Timing: writes must happen before the handler returns. After the jar is sealed (e.g. inside a streaming body) writes **panic**. Reads still work. - Source: `REG/topcoat-0.10.0/docs/cookie.md`, `REG/topcoat-cookie-0.10.0/src/{lib,prefix,store}.rs`. --- ## 7. Layers, body limit, timeouts, compression, origin ### 7a. Layers ```rust use topcoat::{Result, context::Cx, router::{Body, Next, layer, response::Response}}; #[layer("/api")] // no path = wraps every request async fn timing(cx: &Cx, body: Body, next: Next<'_>) -> Result { next.run(cx, body).await } // register: Router::builder().layer(timing) ``` - Matching is by path prefix; outer = least specific. Same-path layers from `module_router!` are rejected; use explicit `.layer()` for ordering. A path-less layer also sees 404/405 as `Err` from `next.run`. Per-request values: `let cx = cx.with(value)`. - Source: `REG/topcoat-router-0.10.0/docs/router.md`, `.../docs/layer.md`. ### 7b. Tower (feature `tower`) `TowerLayer::new(mw).at("/api")`, `TowerRoute::any("/legacy/{*rest}", svc)`. Example: `TowerLayer::new(tower::timeout::TimeoutLayer::new(Duration::from_secs(5))).at("/api")`. Source: `REG/topcoat-router-0.10.0/docs/tower.md`. ### 7c. Body limit - Default **2 MiB** (`REG/topcoat-router-0.10.0/src/body_limit.rs`), enforced by `Bytes`, `String`, `Json`, `Form`. Over it: **413**. `Body` is not limited. - Override per path: ```rust use topcoat::router::{BodyLimit, module_router}; let router = module_router!() .layer(BodyLimit::max(64 << 20).at("/upload")) .layer(BodyLimit::disable().at("/{owner}/{repo}/git-receive-pack")) // `{param}` in .at(): UNVERIFIED .build(); ``` - `body_limit(cx)` returns the effective limit for custom extractors. ### 7d. Timeouts - **No request, header, or idle timeout** found in router or serve code (grepped `REG/topcoat-router-0.10.0/src` and `REG/topcoat-0.10.0/src/serve.rs`). - Only timeout: graceful shutdown, `RouterService::new(router).shutdown_timeout(d)`, default **30 s**. Request timeout, if wanted: TowerLayer with `TimeoutLayer` (7b). ### 7e. Compression (on by default; matters for git) - Negotiated from `Accept-Encoding` after all layers (`REG/topcoat-router-0.10.0/src/compression.rs`). - Streaming bodies of unknown size are always compressed. Excluded types: gRPC, images, SSE. `application/x-git-*-result` is not excluded, so pack responses would be compressed. - Fix (applied): `Router::builder().compression(Compression::off())` on the public router, plus `crates/forge-web/src/compression.rs` (`ResponseCompression`, a tower layer). Topcoat has no per-route or content-type switch, so the policy is in that layer. See architecture.md 5.6. ### 7f. Origin policy (CSRF guard, default on) - Before layers run: state-changing cross-origin browser requests and cross-origin WebSocket handshakes get **403**. Requests with no Origin or Fetch-Metadata headers pass, as do same-origin and direct navigations. - Configure: `.origin_policy(OriginPolicy::new().trust_origins([...]))` (`REG/topcoat-router-0.10.0/src/origin.rs`). - Git clients: expected to pass (no browser headers). **UNVERIFIED** per client. --- ## 8. Shared application state (answer to the critical question) Shared state uses **app context**: values keyed by Rust type, registered once on the builder. Not an extension, and not a field on `Cx`. ```rust // src/state.rs pub struct AppState { pub repos_root: std::path::PathBuf, pub db: std::sync::Arc } // router setup pub fn router(state: AppState) -> topcoat::router::Router { topcoat::router::module_router!().discover().app_context(state).build() } // any handler/layout/component with cx use topcoat::context::{Cx, app_context, try_app_context}; pub fn state(cx: &Cx) -> &AppState { app_context::(cx) } // panics if missing pub fn maybe_state(cx: &Cx) -> Option<&AppState> { try_app_context(cx) } ``` - Bounds: `T: Any + Send + Sync + 'static`. Duplicate type panics at registration. - Use `Arc` inside for mutable resources (pools, locks). - Per-request values: `request_context::(cx)` / `try_request_context`, added by a layer with `cx.with(value)`. - Background work: `let cx = cx.clone()` and move it into `tokio::spawn`. A cloned `Cx` still reads app context, but cannot set response headers. - Source: `REG/topcoat-0.10.0/docs/context.md`, `REG/topcoat-router-0.10.0/src/builder.rs` (`app_context`), `REG/topcoat-core-0.10.0/src/context/app_context.rs`. --- ## 9. Serving ```rust topcoat::start(service: impl Into) -> io::Result<()> // HOST (127.0.0.1) / PORT (3000) topcoat::serve(listener: impl Listener, service) -> io::Result<()> // Ctrl+C / SIGTERM topcoat::serve_until(listener, service, signal: impl Future) -> io::Result<()> ``` Source: `REG/topcoat-0.10.0/src/serve.rs`. `Listener` is implemented for `tokio::net::TcpListener` and `tokio::net::UnixListener` (`REG/topcoat-router-0.10.0/src/listener.rs`). `Router` converts to `RouterService` via `From`. Two routers (public TCP, internal Unix socket) with one shared shutdown: ```rust use tokio::net::{TcpListener, UnixListener}; use topcoat::router::RouterService; #[tokio::main] async fn main() -> std::io::Result<()> { let _ = std::fs::remove_file("/run/forge-internal.sock"); // stale socket is not removed for you let tcp = TcpListener::bind("0.0.0.0:8080").await?; let unix = UnixListener::bind("/run/forge-internal.sock")?; let (stop, _) = tokio::sync::watch::channel(()); let (mut a, mut b) = (stop.subscribe(), stop.subscribe()); tokio::try_join!( topcoat::serve_until(tcp, RouterService::new(public::router(state())), async move { let _ = a.changed().await; }), topcoat::serve_until(unix, RouterService::new(internal::router(state())), async move { let _ = b.changed().await; }), async { tokio::signal::ctrl_c().await?; stop.send_replace(()); Ok::<(), std::io::Error>(()) }, )?; Ok(()) } ``` - `serve` and `start` each install their own signal handlers, so use `serve_until` for several listeners. Unix listener: `remote_addr` and `client_ip` return `None`. Placeholders (`public::router`, `state()`) are not compiled. --- ## 10. Assets and CSS ```rust use topcoat::{Result, asset::{Asset, asset}, router::{Slot, layout}, view::{View, view}}; const APP_CSS: Asset = asset!("assets/app.css"); // relative to CARGO_MANIFEST_DIR; "./x" is relative to the source file #[layout] async fn root(slot: Slot<'_>) -> Result { Ok(view! { (slot) }) } ``` - Renders as `/_topcoat/assets/app-.css` (cache-safe). Build the bundle with `topcoat asset bundle` (or `topcoat dev`); output is `//assets`. - Load before `.build()`: `module_router!().discover().assets(AssetBundle::load().unwrap()).build()`; use `AssetBundle::load_dir("dist/assets")` for a custom directory. Rendering a missing asset panics. - Tailwind (feature `tailwind`): `build.rs` calls `topcoat::tailwind::BuildConfig::new().render().unwrap()`, then `tailwind::stylesheet!()` (`REG/topcoat-0.10.0/docs/tailwind.md`). Fixed-URL files: feature `fs`, `public_dir("./public")`. --- ## 11. Testing without a socket ```rust use topcoat::router::{Body, Router, request::Request, to_bytes}; #[tokio::test] async fn info_refs_ok() { let router: Router = my_app::router(test_state()); let req = Request::builder().method("GET") .uri("/alice/repo/info/refs?service=git-upload-pack") .body(Body::empty()).unwrap(); let res = router.handle(req).await; // http::Response assert_eq!(res.status(), 200); let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); assert!(!bytes.is_empty()); } ``` - `Router::handle(&self, Request) -> Response`. Panics become 500. Needs no `serve` feature. - `handle_with(request, values)` injects request-context values. `to_bytes` is exported from `topcoat::router`. --- ## 12. Differences from 0.9 - `module_param!` is new in 0.10 (CHANGELOG: "Added: module_param! macro", PR #460). Grep found no `module_param` in `REG/topcoat-router-macro-0.9.0/src`. - 0.10 changelog recommends the module router as the default example. - Same names exist in 0.9 (existence checked only): `serve`, `serve_until`, `start`, `Router::handle`, `app_context`, `request_context`, `cookies`, `signed_cookies`, `cookie_store`. - Not compared: `Cookies` trait, `Compression`, `OriginPolicy`, `TrustedProxies`, `session`. Use 0.10 source. Other 0.10 changelog items (CLI, runtime) are not relevant here. --- ## 13. Forge notes 1. Smart HTTP routes: `GET /{o}/{r}/info/refs?service=..` (query struct), `POST /{o}/{r}/git-upload-pack`, `POST /{o}/{r}/git-receive-pack`. Use explicit `#[route]` paths with `path_param!`. 2. Pack bodies: take `Body` (streaming). The 2 MiB default would reject pushes otherwise. Use `BodyLimit::disable()` or a large `max` on receive-pack. 3. Compression off on pack routes (7e). Serve pack output via `Body` from child stdout (4b). No request timeout by default (7d). 4. Repos root, config, and DB pool go in one `AppState` (section 8). 5. Web sessions: `private_cookies` with a persisted `Key`, `override_prefix_host()`, `HttpOnly`, `SameSite=Lax`. 6. Escape all user text (commit messages, paths). Never `Unescaped` for user data. 7. Internal API on a Unix socket via `serve_until` (section 9). `client_ip` is None there. --- ## 14. UNVERIFIED - Facade path for `Unescaped`. - Per-route compression disable (only `Compression::off()` on the builder is confirmed). - `BodyLimit::at` accepting `{param}` segments. - `ForwardedHeader` / `TrustedProxies` variant names. - Multipart field API. `session` API. - Whether the router kills child processes on client disconnect (not tested). - Whether a 401 from `unauthorized()` sends `WWW-Authenticate`. - Exact Origin/Fetch-Metadata headers sent by git clients. - `HeaderMap` inside `view!` for `#[page]` headers (documented, not run). - Sections 8, 9, 11 snippets: derived from signatures, not compiled or run.