Tahinli/forge
public0 watching
★ 0
| 1 | # Topcoat 0.10 API cheat-sheet (git forge web layer) |
| 2 | |
| 3 | `REG` = `/home/tahinli/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f`. Sub-crates: `REG/topcoat-<name>-0.10.0/`. **UNVERIFIED** = not confirmed in source. Snippets checked against signatures and docs, not compiled. |
| 4 | |
| 5 | 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`. |
| 6 | |
| 7 | --- |
| 8 | |
| 9 | ## 1. Feature flags |
| 10 | |
| 11 | Default: `asset, compression, cookie, font, icon, router, runtime, serve, session, view, discover`. |
| 12 | |
| 13 | | Feature | Enables | |
| 14 | |---|---| |
| 15 | | `router` | `topcoat::router` (routes, pages, layouts, layers, `Router`); pulls `view` | |
| 16 | | `serve` | `topcoat::start/serve/serve_until`. Without it use `Router::handle` only | |
| 17 | | `discover` | Link-time collection: `module_router!()`, `.discover()` | |
| 18 | | `view` | `view!`, `#[component]`, `class!`, `suspense`, `error_boundary` | |
| 19 | | `cookie` | `topcoat::cookie`. Still call `.cookies()` on the builder | |
| 20 | | `asset` | `asset!`, `AssetBundle` | |
| 21 | | `compression` | Response compression (on by default, see 7e) | |
| 22 | | `session` | `topcoat::session` (**UNVERIFIED**, not covered) | |
| 23 | | `fs` | `public_dir`, `serve_dir` (not default) | |
| 24 | | `multipart` | `content::Multipart` (not default) | |
| 25 | | `websocket`, `sse`, `sitemap`, `tower`, `tailwind`, `datastar`, `htmx`, `alpine-ajax`, `mail`, `ui`, `font-fontsource`, `icon-iconify`, `anyhow`, `full` | Optional extras (not default) | |
| 26 | 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"`. |
| 27 | |
| 28 | --- |
| 29 | |
| 30 | ## 2. Routes |
| 31 | |
| 32 | ### 2a. Module routing (recommended) |
| 33 | |
| 34 | ```rust |
| 35 | // src/app.rs (module maps to "/") |
| 36 | use topcoat::router::{Router, module_router}; |
| 37 | pub fn router() -> Router { module_router!().discover().build() } |
| 38 | ``` |
| 39 | |
| 40 | - `module_router!()` returns a `RouterBuilder`. `.discover()` (trait `topcoat::router::RouterBuilderDiscoverExt`) also collects explicit-path items. |
| 41 | - `app::about` -> `/about`; `app::blog_posts` -> `/blog-posts`. Handler names do not affect the URL. |
| 42 | - 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`. |
| 43 | |
| 44 | ### 2b. Explicit paths |
| 45 | |
| 46 | ```rust |
| 47 | use topcoat::{Result, router::{Router, page, route}, view::{View, view}}; |
| 48 | #[page("/")] |
| 49 | async fn home() -> Result<impl View> { Ok(view! { <h1>"Home"</h1> }) } |
| 50 | #[route(GET "/api/health")] |
| 51 | async fn health() -> Result<&'static str> { Ok("ok") } |
| 52 | pub fn router() -> Router { Router::builder().page(home).route(health).build() } |
| 53 | ``` |
| 54 | |
| 55 | Builder methods: `.page(f)`, `.route(f)`, `.layout(f)`, `.layer(f)`, `.discover()`, `.build()`. Source: `REG/topcoat-router-0.10.0/src/builder.rs`. |
| 56 | |
| 57 | ### 2c. Attributes |
| 58 | |
| 59 | ```rust |
| 60 | #[route(GET "/x")] // one method |
| 61 | #[route([GET, POST] "/x")] // several |
| 62 | #[route(* "/x")] // any method; specific-method routes win over `*` |
| 63 | #[page] // GET, module path |
| 64 | #[page("/x")] // GET, explicit |
| 65 | #[page(POST "/x")] // POST page |
| 66 | ``` |
| 67 | |
| 68 | Route handlers return `Result<T>` where `T: AsyncIntoResponse`. Pages return `Result<impl View>`. Parameters: optional `cx: &Cx` and at most one body extractor, in either order. Overlapping methods at the same path fail at `.build()`. |
| 69 | |
| 70 | ### 2d. Path params: `/{owner}/{repo}/info/refs` |
| 71 | |
| 72 | ```rust |
| 73 | use topcoat::{Result, context::Cx, router::{path_param, route}, view::{View, view}}; |
| 74 | path_param!(owner); // unparsed: path_param::<Owner>(cx) -> &str (percent-decoded) |
| 75 | path_param!(repo); |
| 76 | path_param!(id: u64); // parsed with FromStr: path_param::<Id>(cx) -> Result<&u64, _> |
| 77 | path_param!(id2: u64, error = not_found); // error = not_found|bad_request|unauthorized|forbidden|redirect("/x") |
| 78 | path_param!(*rest); // catch-all: "{*rest}", path_param::<Rest>(cx) -> CatchAllSegments |
| 79 | |
| 80 | #[route(GET "/{owner}/{repo}/info/refs")] |
| 81 | async fn info_refs(cx: &Cx) -> Result<&'static str> { |
| 82 | let owner: &str = path_param::<Owner>(cx); |
| 83 | let repo: &str = path_param::<Repo>(cx); |
| 84 | let _ = (owner, repo); |
| 85 | Ok("ok") |
| 86 | } |
| 87 | ``` |
| 88 | |
| 89 | - `path_param!(name)` generates `Name` (Pascal-cased). Name the parameter in the path as `{name}`. Reading a parameter the route did not capture panics. |
| 90 | - 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. |
| 91 | - Source: `REG/topcoat-router-macro-0.10.0/docs/path_param.md`, `.../docs/module_param.md`. |
| 92 | |
| 93 | ### 2e. Query params |
| 94 | |
| 95 | ```rust |
| 96 | use topcoat::{Result, context::Cx, router::{page, query_params}, view::{View, view}}; |
| 97 | #[query_params(error = bad_request)] |
| 98 | struct InfoRefsQuery { service: Option<String> } |
| 99 | |
| 100 | #[page] |
| 101 | async fn page(cx: &Cx) -> Result<impl View> { |
| 102 | let q = query_params::<InfoRefsQuery>(cx)?; // &InfoRefsQuery, memoized |
| 103 | Ok(view! { <p>(q.service.as_deref().unwrap_or(""))</p> }) |
| 104 | } |
| 105 | ``` |
| 106 | |
| 107 | Derives `serde::Deserialize`. Works from any handler with `cx`. Source: `REG/topcoat-router-macro-0.10.0/docs/query_params.md`. |
| 108 | |
| 109 | ### 2f. Registration summary |
| 110 | |
| 111 | - `module_router!()` (module-derived), `Router::builder()` (manual), `.discover()` (all explicit-path items), `.public_dir("./public")` / `.serve_dir("/p/{*f}", "./dir")` (feature `fs`, static files). |
| 112 | |
| 113 | --- |
| 114 | |
| 115 | ## 3. Request access |
| 116 | |
| 117 | Import helpers from `topcoat::router::request`. All take `cx: &Cx`. |
| 118 | |
| 119 | | Need | Call | Returns | |
| 120 | |---|---|---| |
| 121 | | method | `method(cx)` | `&http::Method` | |
| 122 | | uri / query | `uri(cx)` | `&http::Uri` (`uri(cx).query()`) | |
| 123 | | headers | `headers(cx)` | `&HeaderMap` | |
| 124 | | content type | `content_type(cx)` | `Option<&str>` | |
| 125 | | all parts | `parts(cx)` | `&http::request::Parts` | |
| 126 | | client IP | `client_ip(cx)` | `Option<IpAddr>` (proxy-aware) | |
| 127 | | peer addr | `remote_addr(cx)` | `Option<SocketAddr>` (None on Unix sockets) | |
| 128 | | pre-rewrite | `original_uri(cx)`, `original_headers(cx)`, ... | same types | |
| 129 | |
| 130 | `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`). |
| 131 | |
| 132 | Body extractors (one per handler): |
| 133 | |
| 134 | | Type | Behavior | |
| 135 | |---|---| |
| 136 | | `Body` (`topcoat::router::Body`) | Streaming. Not limited. `body.into_data_stream()` yields `Result<Bytes, BoxError>` | |
| 137 | | `Bytes` (`topcoat::router::request::Bytes`) | Buffered, body limit applies (413) | |
| 138 | | `String` | Buffered UTF-8, body limit applies | |
| 139 | | `Json<T>` (`topcoat::router::content::Json`) | Requires `Content-Type: application/json` (400 otherwise); malformed = 400 | |
| 140 | | `Form<T>` (`topcoat::router::content::Form`) | `x-www-form-urlencoded`. On GET/HEAD reads the query string | |
| 141 | | `RawForm` | Raw form bytes | |
| 142 | | `Multipart` (feature `multipart`) | **UNVERIFIED** field API (`REG/topcoat-router-0.10.0/docs/content/multipart.md`) | |
| 143 | | `Option<T>` | Wraps any extractor to accept an absent body | |
| 144 | |
| 145 | ```rust |
| 146 | use topcoat::{Result, context::Cx, router::{Body, route, content::Form}}; |
| 147 | |
| 148 | #[route(POST "/{owner}/{repo}/git-receive-pack")] |
| 149 | async fn receive(cx: &Cx, body: Body) -> Result<&'static str> { let _ = (cx, body); Ok("") } |
| 150 | |
| 151 | #[derive(serde::Deserialize)] struct NewRepo { name: String } |
| 152 | #[route(POST "/repos")] |
| 153 | async fn create(Form(input): Form<NewRepo>) -> Result<&'static str> { let _ = input.name; Ok("created") } |
| 154 | ``` |
| 155 | |
| 156 | Custom extractor: `impl FromRequest { async fn from_request(cx: &Cx, body: Body) -> Result<Self> }`. Delegate buffering to `Bytes` or `to_bytes(body, body_limit(cx))`. |
| 157 | |
| 158 | --- |
| 159 | |
| 160 | ## 4. Responses and errors |
| 161 | |
| 162 | ### 4a. Return types |
| 163 | |
| 164 | - `&'static str`, `String`, `Vec<u8>`, `Bytes`, `()` -> 200 with body. `StatusCode` -> empty body. `Body` -> as-is. `Json<T>` -> JSON. `Response` (`http::Response<Body>`) -> full control. |
| 165 | - Tuples: a leading `StatusCode`, `Parts`, or `Response<()>` sets the status; middle items are `IntoResponseParts` (`HeaderMap`, `[(K, V); N]`, `Extensions`, `Option<T>`); the last item is the body. Source: `REG/topcoat-router-0.10.0/src/response.rs`. |
| 166 | |
| 167 | ```rust |
| 168 | use topcoat::{Result, router::{Body, HeaderMap, StatusCode, header, route}}; |
| 169 | #[route(GET "/{owner}/{repo}/info/refs")] |
| 170 | async fn info_refs() -> Result<(StatusCode, HeaderMap, Body)> { |
| 171 | let mut h = HeaderMap::new(); |
| 172 | h.insert(header::CONTENT_TYPE, "application/x-git-upload-pack-advertisement".parse().unwrap()); |
| 173 | Ok((StatusCode::OK, h, Body::from("001e# service=git-upload-pack\n0000"))) |
| 174 | } |
| 175 | // or: Ok((StatusCode::OK, [(header::CONTENT_TYPE, "text/plain")], Body::from("ok"))) |
| 176 | ``` |
| 177 | |
| 178 | ### 4b. Streaming from a child process |
| 179 | |
| 180 | Pattern from `REG/topcoat-router-0.10.0/src/route/directory.rs` (`fn stream`): `Body::new(StreamBody::new(stream_of_frames))`. |
| 181 | |
| 182 | ```rust |
| 183 | use std::process::Stdio; |
| 184 | use futures_util::TryStreamExt; |
| 185 | use http_body::Frame; |
| 186 | use http_body_util::StreamBody; |
| 187 | use tokio::process::Command; |
| 188 | use tokio_util::io::ReaderStream; |
| 189 | use topcoat::{Result, router::{Body, HeaderMap, StatusCode}}; |
| 190 | |
| 191 | async fn stream_child(mut cmd: Command) -> Result<(StatusCode, HeaderMap, Body)> { |
| 192 | let mut child = cmd.stdout(Stdio::piped()).spawn()?; // io::Error -> 500 |
| 193 | let stdout = child.stdout.take().expect("piped"); |
| 194 | let chunks = ReaderStream::new(stdout).map_ok(Frame::<bytes::Bytes>::data); |
| 195 | Ok((StatusCode::OK, HeaderMap::new(), Body::new(StreamBody::new(chunks)))) |
| 196 | } |
| 197 | ``` |
| 198 | |
| 199 | - `bytes` is re-exported as `topcoat::router::request::Bytes`. |
| 200 | - 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. |
| 201 | - Request body to child stdin: `tokio_util::io::StreamReader::new(body.into_data_stream().map_err(std::io::Error::other))` then `tokio::io::copy`. |
| 202 | |
| 203 | ### 4c. Errors -> status codes |
| 204 | |
| 205 | `topcoat::Result<T>` = `Result<T, topcoat_core::error::Error>`. 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`). |
| 206 | |
| 207 | | Constructor (`topcoat::router::error`) | Status | Ext on `Option`/`Result` | |
| 208 | |---|---|---| |
| 209 | | `not_found()` | 404 | `ok_or_not_found()` | |
| 210 | | `bad_request("msg")` | 400 | `ok_or_bad_request(..)` | |
| 211 | | `unauthorized()` | 401 | `ok_or_unauthorized()` | |
| 212 | | `forbidden()` | 403 | `ok_or_forbidden()` | |
| 213 | | `method_not_allowed(methods)` | 405 (+ `Allow`) | - | |
| 214 | | `content_too_large()` | 413 | - | |
| 215 | | `too_many_requests(secs)` | 429 | - | |
| 216 | | `service_unavailable(secs)` | 503 | - | |
| 217 | | `redirect(uri)` | 307 | `ok_or_redirect(uri)` | |
| 218 | | `redirect_permanent(uri)` | 308 | `ok_or_redirect_permanent(uri)` | |
| 219 | | `see_other(uri)` | 303 (`SeeOther`, usable as `Ok(..)`) | - | |
| 220 | | `internal_server_error(e)` | 500 | - | |
| 221 | |
| 222 | ```rust |
| 223 | use topcoat::{Result, context::Cx, router::{error::{RouterErrorExt, forbidden, see_other, SeeOther}, route}}; |
| 224 | #[route(POST "/repos")] |
| 225 | async fn create(cx: &Cx) -> Result<SeeOther> { let _ = cx; Ok(see_other("/repos")) } |
| 226 | fn lookup() -> Result<&'static str> { Ok(None::<&str>.ok_or_not_found()?) } // 404 |
| 227 | fn gate() -> Result<()> { Err(forbidden().into()) } // 403 |
| 228 | ``` |
| 229 | |
| 230 | Error boundary: a layout can wrap `slot` in `error_boundary(fallback: |error| ..., (slot))` and `error.downcast_ref::<NotFoundError>()` for a branded 404 (`REG/topcoat-0.10.0/docs/router.md`). |
| 231 | |
| 232 | 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`). |
| 233 | |
| 234 | --- |
| 235 | |
| 236 | ## 5. Views, components, layouts, escaping, forms |
| 237 | |
| 238 | ```rust |
| 239 | use topcoat::{Result, router::{Slot, layout}, view::{View, component, view, class}}; |
| 240 | |
| 241 | #[component] |
| 242 | async fn badge(label: &str, ok: bool) -> Result<impl View> { |
| 243 | Ok(view! { <span class=(class!("badge", "badge-ok" if ok))>(label)</span> }) |
| 244 | } |
| 245 | |
| 246 | #[layout] // or #[layout("/settings")] |
| 247 | async fn root(slot: Slot<'_>) -> Result<impl View> { |
| 248 | Ok(view! { |
| 249 | <!DOCTYPE html> |
| 250 | <html> |
| 251 | <body> |
| 252 | <h1>"Repo"</h1> |
| 253 | badge(label: "New", ok: true) // component call, named args |
| 254 | (slot) // inner page |
| 255 | </body> |
| 256 | </html> |
| 257 | }) |
| 258 | } |
| 259 | |
| 260 | #[component] |
| 261 | async fn list(items: Vec<String>) -> Result<impl View> { |
| 262 | Ok(view! { |
| 263 | <ul> |
| 264 | for item in items.iter() { <li>(item)</li> } |
| 265 | </ul> |
| 266 | }) |
| 267 | } |
| 268 | ``` |
| 269 | |
| 270 | - 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`. |
| 271 | - Components take typed params; `Slot` for layouts; `cx: &Cx` optional, any order. |
| 272 | - **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<T>` wrapper (`REG/topcoat-view-0.10.0/src/string.rs`); facade path **UNVERIFIED**. Do not wrap commit messages, file names, or diffs in it. |
| 273 | - Links and forms: `href!(handler_fn)` builds URLs from the handler's registered path; `href!(mod::handler, mod::Param(1))` passes params. `<form method="post" action=(href!(publish))>` from source docs. HTML forms only send GET/POST. Source: `REG/topcoat-router-0.10.0/docs/href.md`. |
| 274 | - Active nav: `href.is_current(cx)` plus `class!`. |
| 275 | - 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. |
| 276 | |
| 277 | --- |
| 278 | |
| 279 | ## 6. Cookies |
| 280 | |
| 281 | ```rust |
| 282 | use topcoat::{cookie::RouterBuilderCookieExt, router::{Router, RouterBuilderDiscoverExt, module_router}}; |
| 283 | pub fn router() -> Router { module_router!().discover().cookies().build() } // required |
| 284 | |
| 285 | use topcoat::{Result, context::Cx, cookie::{Cookie, Cookies, cookies}, router::route}; |
| 286 | #[route(POST "/theme")] |
| 287 | async fn set_theme(cx: &Cx) -> Result<&'static str> { |
| 288 | let jar = cookies(cx); // &CookieJar; `Cookies` trait in scope |
| 289 | let _current = jar.get("theme"); // includes pending writes |
| 290 | jar.add(Cookie::build(("theme", "dark")).path("/").http_only(true).secure(true).build()); |
| 291 | jar.remove(Cookie::build(("theme", "")).path("/")); // same Path/Domain as when set |
| 292 | Ok("ok") |
| 293 | } |
| 294 | ``` |
| 295 | |
| 296 | - `add`/`remove` accept `Into<Cookie>`, so `jar.add(("k", "v"))` works. |
| 297 | - Attributes: `Path`, `Domain`, `HttpOnly`, `Secure`, `SameSite`, `MaxAge`, set via builder or `cookie!("s" = "v"; Path = "/"; Secure; HttpOnly; SameSite = Lax)` (`topcoat::cookie::cookie`). |
| 298 | - 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`. |
| 299 | - `__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). |
| 300 | - 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. |
| 301 | - Typed store: `cookie_store::<T, _>(private_cookies(cx), "cart").parse_or_default().update(|t| ..).commit()?`. Nothing is written until `commit()`. |
| 302 | - Timing: writes must happen before the handler returns. After the jar is sealed (e.g. inside a streaming body) writes **panic**. Reads still work. |
| 303 | - Source: `REG/topcoat-0.10.0/docs/cookie.md`, `REG/topcoat-cookie-0.10.0/src/{lib,prefix,store}.rs`. |
| 304 | |
| 305 | --- |
| 306 | |
| 307 | ## 7. Layers, body limit, timeouts, compression, origin |
| 308 | |
| 309 | ### 7a. Layers |
| 310 | |
| 311 | ```rust |
| 312 | use topcoat::{Result, context::Cx, router::{Body, Next, layer, response::Response}}; |
| 313 | #[layer("/api")] // no path = wraps every request |
| 314 | async fn timing(cx: &Cx, body: Body, next: Next<'_>) -> Result<Response> { |
| 315 | next.run(cx, body).await |
| 316 | } |
| 317 | // register: Router::builder().layer(timing) |
| 318 | ``` |
| 319 | |
| 320 | - 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)`. |
| 321 | - Source: `REG/topcoat-router-0.10.0/docs/router.md`, `.../docs/layer.md`. |
| 322 | |
| 323 | ### 7b. Tower (feature `tower`) |
| 324 | |
| 325 | `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`. |
| 326 | |
| 327 | ### 7c. Body limit |
| 328 | |
| 329 | - 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. |
| 330 | - Override per path: |
| 331 | |
| 332 | ```rust |
| 333 | use topcoat::router::{BodyLimit, module_router}; |
| 334 | let router = module_router!() |
| 335 | .layer(BodyLimit::max(64 << 20).at("/upload")) |
| 336 | .layer(BodyLimit::disable().at("/{owner}/{repo}/git-receive-pack")) // `{param}` in .at(): UNVERIFIED |
| 337 | .build(); |
| 338 | ``` |
| 339 | |
| 340 | - `body_limit(cx)` returns the effective limit for custom extractors. |
| 341 | |
| 342 | ### 7d. Timeouts |
| 343 | |
| 344 | - **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`). |
| 345 | - Only timeout: graceful shutdown, `RouterService::new(router).shutdown_timeout(d)`, default **30 s**. Request timeout, if wanted: TowerLayer with `TimeoutLayer` (7b). |
| 346 | |
| 347 | ### 7e. Compression (on by default; matters for git) |
| 348 | |
| 349 | - Negotiated from `Accept-Encoding` after all layers (`REG/topcoat-router-0.10.0/src/compression.rs`). |
| 350 | - 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. |
| 351 | - 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. |
| 352 | |
| 353 | ### 7f. Origin policy (CSRF guard, default on) |
| 354 | |
| 355 | - 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. |
| 356 | - Configure: `.origin_policy(OriginPolicy::new().trust_origins([...]))` (`REG/topcoat-router-0.10.0/src/origin.rs`). |
| 357 | - Git clients: expected to pass (no browser headers). **UNVERIFIED** per client. |
| 358 | |
| 359 | --- |
| 360 | |
| 361 | ## 8. Shared application state (answer to the critical question) |
| 362 | |
| 363 | Shared state uses **app context**: values keyed by Rust type, registered once on the builder. Not an extension, and not a field on `Cx`. |
| 364 | |
| 365 | ```rust |
| 366 | // src/state.rs |
| 367 | pub struct AppState { pub repos_root: std::path::PathBuf, pub db: std::sync::Arc<Db> } |
| 368 | |
| 369 | // router setup |
| 370 | pub fn router(state: AppState) -> topcoat::router::Router { |
| 371 | topcoat::router::module_router!().discover().app_context(state).build() |
| 372 | } |
| 373 | |
| 374 | // any handler/layout/component with cx |
| 375 | use topcoat::context::{Cx, app_context, try_app_context}; |
| 376 | pub fn state(cx: &Cx) -> &AppState { app_context::<AppState>(cx) } // panics if missing |
| 377 | pub fn maybe_state(cx: &Cx) -> Option<&AppState> { try_app_context(cx) } |
| 378 | ``` |
| 379 | |
| 380 | - Bounds: `T: Any + Send + Sync + 'static`. Duplicate type panics at registration. |
| 381 | - Use `Arc` inside for mutable resources (pools, locks). |
| 382 | - Per-request values: `request_context::<T>(cx)` / `try_request_context`, added by a layer with `cx.with(value)`. |
| 383 | - Background work: `let cx = cx.clone()` and move it into `tokio::spawn`. A cloned `Cx` still reads app context, but cannot set response headers. |
| 384 | - 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`. |
| 385 | |
| 386 | --- |
| 387 | |
| 388 | ## 9. Serving |
| 389 | |
| 390 | ```rust |
| 391 | topcoat::start(service: impl Into<RouterService>) -> io::Result<()> // HOST (127.0.0.1) / PORT (3000) |
| 392 | topcoat::serve(listener: impl Listener, service) -> io::Result<()> // Ctrl+C / SIGTERM |
| 393 | topcoat::serve_until(listener, service, signal: impl Future<Output = ()>) -> io::Result<()> |
| 394 | ``` |
| 395 | |
| 396 | 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`. |
| 397 | |
| 398 | Two routers (public TCP, internal Unix socket) with one shared shutdown: |
| 399 | |
| 400 | ```rust |
| 401 | use tokio::net::{TcpListener, UnixListener}; |
| 402 | use topcoat::router::RouterService; |
| 403 | |
| 404 | #[tokio::main] |
| 405 | async fn main() -> std::io::Result<()> { |
| 406 | let _ = std::fs::remove_file("/run/forge-internal.sock"); // stale socket is not removed for you |
| 407 | let tcp = TcpListener::bind("0.0.0.0:8080").await?; |
| 408 | let unix = UnixListener::bind("/run/forge-internal.sock")?; |
| 409 | let (stop, _) = tokio::sync::watch::channel(()); |
| 410 | let (mut a, mut b) = (stop.subscribe(), stop.subscribe()); |
| 411 | tokio::try_join!( |
| 412 | topcoat::serve_until(tcp, RouterService::new(public::router(state())), async move { let _ = a.changed().await; }), |
| 413 | topcoat::serve_until(unix, RouterService::new(internal::router(state())), async move { let _ = b.changed().await; }), |
| 414 | async { tokio::signal::ctrl_c().await?; stop.send_replace(()); Ok::<(), std::io::Error>(()) }, |
| 415 | )?; |
| 416 | Ok(()) |
| 417 | } |
| 418 | ``` |
| 419 | |
| 420 | - `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. |
| 421 | |
| 422 | --- |
| 423 | |
| 424 | ## 10. Assets and CSS |
| 425 | |
| 426 | ```rust |
| 427 | use topcoat::{Result, asset::{Asset, asset}, router::{Slot, layout}, view::{View, view}}; |
| 428 | const APP_CSS: Asset = asset!("assets/app.css"); // relative to CARGO_MANIFEST_DIR; "./x" is relative to the source file |
| 429 | #[layout] |
| 430 | async fn root(slot: Slot<'_>) -> Result<impl View> { |
| 431 | Ok(view! { |
| 432 | <!DOCTYPE html> |
| 433 | <html><head><link rel="stylesheet" href=(APP_CSS)></head><body>(slot)</body></html> |
| 434 | }) |
| 435 | } |
| 436 | ``` |
| 437 | |
| 438 | - Renders as `/_topcoat/assets/app-<hash>.css` (cache-safe). Build the bundle with `topcoat asset bundle` (or `topcoat dev`); output is `<target>/<profile>/assets`. |
| 439 | - 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. |
| 440 | - 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")`. |
| 441 | |
| 442 | --- |
| 443 | |
| 444 | ## 11. Testing without a socket |
| 445 | |
| 446 | ```rust |
| 447 | use topcoat::router::{Body, Router, request::Request, to_bytes}; |
| 448 | |
| 449 | #[tokio::test] |
| 450 | async fn info_refs_ok() { |
| 451 | let router: Router = my_app::router(test_state()); |
| 452 | let req = Request::builder().method("GET") |
| 453 | .uri("/alice/repo/info/refs?service=git-upload-pack") |
| 454 | .body(Body::empty()).unwrap(); |
| 455 | let res = router.handle(req).await; // http::Response<Body> |
| 456 | assert_eq!(res.status(), 200); |
| 457 | let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); |
| 458 | assert!(!bytes.is_empty()); |
| 459 | } |
| 460 | ``` |
| 461 | |
| 462 | - `Router::handle(&self, Request) -> Response`. Panics become 500. Needs no `serve` feature. |
| 463 | - `handle_with(request, values)` injects request-context values. `to_bytes` is exported from `topcoat::router`. |
| 464 | |
| 465 | --- |
| 466 | |
| 467 | ## 12. Differences from 0.9 |
| 468 | |
| 469 | - `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`. |
| 470 | - 0.10 changelog recommends the module router as the default example. |
| 471 | - Same names exist in 0.9 (existence checked only): `serve`, `serve_until`, `start`, `Router::handle`, `app_context`, `request_context`, `cookies`, `signed_cookies`, `cookie_store`. |
| 472 | - Not compared: `Cookies` trait, `Compression`, `OriginPolicy`, `TrustedProxies`, `session`. Use 0.10 source. Other 0.10 changelog items (CLI, runtime) are not relevant here. |
| 473 | |
| 474 | --- |
| 475 | |
| 476 | ## 13. Forge notes |
| 477 | |
| 478 | 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!`. |
| 479 | 2. Pack bodies: take `Body` (streaming). The 2 MiB default would reject pushes otherwise. Use `BodyLimit::disable()` or a large `max` on receive-pack. |
| 480 | 3. Compression off on pack routes (7e). Serve pack output via `Body` from child stdout (4b). No request timeout by default (7d). |
| 481 | 4. Repos root, config, and DB pool go in one `AppState` (section 8). |
| 482 | 5. Web sessions: `private_cookies` with a persisted `Key`, `override_prefix_host()`, `HttpOnly`, `SameSite=Lax`. |
| 483 | 6. Escape all user text (commit messages, paths). Never `Unescaped` for user data. |
| 484 | 7. Internal API on a Unix socket via `serve_until` (section 9). `client_ip` is None there. |
| 485 | |
| 486 | --- |
| 487 | |
| 488 | ## 14. UNVERIFIED |
| 489 | |
| 490 | - Facade path for `Unescaped<T>`. |
| 491 | - Per-route compression disable (only `Compression::off()` on the builder is confirmed). |
| 492 | - `BodyLimit::at` accepting `{param}` segments. |
| 493 | - `ForwardedHeader` / `TrustedProxies` variant names. |
| 494 | - Multipart field API. `session` API. |
| 495 | - Whether the router kills child processes on client disconnect (not tested). |
| 496 | - Whether a 401 from `unauthorized()` sends `WWW-Authenticate`. |
| 497 | - Exact Origin/Fetch-Metadata headers sent by git clients. |
| 498 | - `HeaderMap` inside `view!` for `#[page]` headers (documented, not run). |
| 499 | - Sections 8, 9, 11 snippets: derived from signatures, not compiled or run. |