Skip to main content

max / quasi

17.6 KB · 429 lines History Blame Raw
1 //! The axum host adapter for [`quasi_router`].
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! A host adapter is thin by design, and since the Tauri protocol adapter
6 //! arrived it is thinner still: decoding, status mapping and the retarget
7 //! header live in [`quasi_http`], which both hosts call. What is left here is
8 //! the part that is genuinely axum's, which is how it mounts, how it reads a
9 //! body, and where it puts the blocking hop.
10 //!
11 //! Hosted web was described as free rather than aspirational, and this is what
12 //! that meant: a router returning descriptions plus a renderer emitting HTML is
13 //! already the shape MNW's server has, so a hosted app is a second adapter and
14 //! not a port.
15 //!
16 //! # What it owns
17 //!
18 //! - Mounting, as a fallback. See [`Adapter::into_router`].
19 //! - Reading the body, with a limit, before anything is parsed.
20 //! - Resolving the state a request is answered against, for the one host that
21 //! serves many viewers out of one process. See [`Adapter::per_viewer`].
22 //! - The blocking hop. The router is sync per decision 6, so the call happens
23 //! on a blocking thread, which is what an axum handler over a blocking store
24 //! pays anyway.
25 //!
26 //! # What it does not own
27 //!
28 //! Markup. See [`Serves`], and the reason it is a parameter: the Tauri
29 //! custom-protocol adapter serves HTML to a webview too, and generating it here
30 //! would guarantee a second implementation.
31 //!
32 //! # Mounting it
33 //!
34 //! quasi matches paths itself, so the adapter mounts as a fallback rather than
35 //! registering each route with axum. One matcher, not two agreeing ones. Real
36 //! axum routes merged in front of it keep working, which is how static files,
37 //! a health endpoint and anything else outside the description layer are
38 //! served.
39 //!
40 //! ```no_run
41 //! use std::sync::Arc;
42 //! use quasi_axum::{Adapter, Serves};
43 //! use quasi_router::{Node, Request, Response, RouteError, Router, Screen, Slot, RegionKind};
44 //!
45 //! struct App;
46 //! struct Html;
47 //!
48 //! impl Serves for Html {
49 //! fn screen(&self, screen: &Screen) -> String { format!("<h1>{}</h1>", screen.title) }
50 //! fn fragment(&self, _node: &Node) -> String { String::new() }
51 //! fn suggestions(&self, _: &str, _: &[quasi_router::Candidate]) -> String { String::new() }
52 //! }
53 //!
54 //! fn home(_app: &App, _request: Request) -> Result<Response, RouteError> {
55 //! Ok(Screen::sidebar_content("Home")
56 //! .with(Slot::new("content", RegionKind::Pane))
57 //! .into())
58 //! }
59 //!
60 //! # async fn run() {
61 //! let quasi = Router::<App>::new().get("/", home);
62 //! let app = axum::Router::new()
63 //! .merge(Adapter::new(quasi, Arc::new(App), Arc::new(Html)).into_router());
64 //!
65 //! let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
66 //! axum::serve(listener, app).await.unwrap();
67 //! # }
68 //! ```
69
70 use std::future::Future;
71 use std::pin::Pin;
72 use std::sync::Arc;
73
74 use axum::body::{Body, Bytes};
75 use axum::extract::Request;
76 use axum::response::Response as HttpResponse;
77 use http::request::Parts;
78 use quasi_http::Refusal;
79 use quasi_router::{Params, Response, RouteError, Router};
80
81 pub use quasi_http::{DEFAULT_BODY_LIMIT, Serves, htmx};
82
83 /// The state one request is answered against, once it is resolved.
84 ///
85 /// A future, because what a viewer is resolved from is a lookup: a session
86 /// cookie against a store, a token against an introspection endpoint. That is
87 /// async work, and it happens here rather than in a handler because a handler
88 /// is sync by decision 6 and this is the one layer that can await.
89 ///
90 /// `Err` is the resolution itself failing, not the viewer being anonymous. A
91 /// signed-out reader is a state the factory builds; a store that would not
92 /// answer is a [`RouteError`], and the request never reaches the router.
93 pub type StateFuture<S> = Pin<Box<dyn Future<Output = Result<S, RouteError>> + Send>>;
94
95 /// Builds the state one request is answered against.
96 ///
97 /// The returned future is `'static`, so a factory takes what it needs out of
98 /// the request parts first and the future owns it. That is the discipline
99 /// rather than a limitation: a future holding a borrow on the request head
100 /// would be a second lifetime running through every host that mounts this.
101 pub type StateFactory<S> = dyn Fn(&Parts) -> StateFuture<S> + Send + Sync;
102
103 /// Builds the renderer for one request.
104 ///
105 /// It is handed the state, the request's parameters and the answer the router
106 /// gave, and returns the renderer that answer is rendered with. The
107 /// [`Response`] is there so the factory can walk the screen and do work only
108 /// for the regions that are actually on it: a factory that highlighted a blob
109 /// without looking would run syntect for every request that never shows a file.
110 ///
111 /// `None` is the router refusing. There is no screen, the answer is a notice
112 /// fragment, and a renderer built for it has nothing per-request to say.
113 pub type RenderFactory<S, R> = dyn Fn(&S, &Params, Option<&Response>) -> R + Send + Sync;
114
115 /// Where the renderer for a request comes from.
116 ///
117 /// A host with nothing per-request to say shares one. A host serving a public
118 /// website builds one per request, because that is the only moment it knows
119 /// what the page contains. See [`Adapter::per_request`].
120 enum Renderers<S, R> {
121 Shared(Arc<R>),
122 PerRequest(Box<RenderFactory<S, R>>),
123 }
124
125 /// Where the state for a request comes from.
126 ///
127 /// `Handler<S> = fn(&S, Request)` builds `S` once and reads it for the life of
128 /// the program, which is correct for the hosts quasi was designed against: a
129 /// Tauri window, an egui frame and a terminal loop each have one user, so one
130 /// of everything is the truth. A hosted server is the one host that breaks
131 /// that assumption, because it has one process and many viewers.
132 ///
133 /// So the adapter builds `S` per request, and nothing above it moves: the
134 /// router, the handler signature and the description layer are untouched. See
135 /// [`Adapter::per_viewer`].
136 enum States<S> {
137 Shared(Arc<S>),
138 PerRequest(Box<StateFactory<S>>),
139 }
140
141 /// The router, the app's state and a renderer, mounted as an axum service.
142 pub struct Adapter<S, R> {
143 router: Router<S>,
144 state: States<S>,
145 render: Renderers<S, R>,
146 body_limit: usize,
147 }
148
149 /// What a request needs, once, behind one `Arc`.
150 struct Context<S, R> {
151 router: Router<S>,
152 state: States<S>,
153 render: Renderers<S, R>,
154 body_limit: usize,
155 }
156
157 impl<S, R> Adapter<S, R>
158 where
159 S: Send + Sync + 'static,
160 R: Serves,
161 {
162 /// Mount a router over this state, rendered by this renderer.
163 ///
164 /// One renderer, shared by every request. Right for a host whose renderer
165 /// is configuration: where the assets live and what the classes are called
166 /// do not vary by request.
167 #[must_use]
168 pub fn new(router: Router<S>, state: Arc<S>, render: Arc<R>) -> Self {
169 Self {
170 router,
171 state: States::Shared(state),
172 render: Renderers::Shared(render),
173 body_limit: DEFAULT_BODY_LIMIT,
174 }
175 }
176
177 /// Mount a router over this state, building a renderer per request.
178 ///
179 /// For the host that has something to say about this page and not about
180 /// the next one: the markup filling a bespoke region, a per-screen head, a
181 /// theme block computed from the signed-in reader. A renderer is
182 /// configuration and an allocation, so building one per request is cheap;
183 /// what it buys is that the host gets its say at the one moment it knows
184 /// what the answer contains.
185 ///
186 /// This is the only channel for it, and deliberately. A handler is
187 /// `fn(&S, Request) -> Result<Response, RouteError>` with no request object
188 /// and no side channel, so anything it computed could only ride in the
189 /// [`Response`], and host markup in the router's response type would make
190 /// the router host-aware. So the factory does the work and the handler
191 /// stops doing it.
192 ///
193 /// ```no_run
194 /// # use std::sync::Arc;
195 /// # use quasi_axum::Adapter;
196 /// # use quasi_router::{Outcome, Router};
197 /// # struct App;
198 /// # #[derive(Default)] struct Html { fills: std::collections::HashMap<String, String> }
199 /// # impl quasi_axum::Serves for Html {
200 /// # fn screen(&self, _: &quasi_router::Screen) -> String { String::new() }
201 /// # fn fragment(&self, _: &quasi_router::Node) -> String { String::new() }
202 /// # fn suggestions(&self, _: &str, _: &[quasi_router::Candidate]) -> String { String::new() }
203 /// # }
204 /// # let quasi = Router::<App>::new();
205 /// Adapter::per_request(quasi, Arc::new(App), |_app, _params, answer| {
206 /// let mut render = Html::default();
207 /// if let Some(Outcome::Screen(screen)) = answer.map(|a| &a.outcome) {
208 /// for slot in &screen.slots {
209 /// // Only the regions this screen actually has.
210 /// render.fills.insert(slot.id.clone(), String::new());
211 /// }
212 /// }
213 /// render
214 /// });
215 /// ```
216 #[must_use]
217 pub fn per_request<F>(router: Router<S>, state: Arc<S>, factory: F) -> Self
218 where
219 F: Fn(&S, &Params, Option<&Response>) -> R + Send + Sync + 'static,
220 {
221 Self {
222 router,
223 state: States::Shared(state),
224 render: Renderers::PerRequest(Box::new(factory)),
225 body_limit: DEFAULT_BODY_LIMIT,
226 }
227 }
228
229 /// Mount a router that resolves both its state and its renderer per
230 /// request.
231 ///
232 /// For the host with many viewers behind one process. The factory is handed
233 /// the request head and runs in async context, so the session lookup, the
234 /// viewer and the theme resolved from them are loaded before the router is
235 /// called, and a handler reads them off `&S` as it reads anything else.
236 ///
237 /// This is why the handler signature did not have to grow. A request-scoped
238 /// `S` is the same ruling the host boundary already made, that a host fact a
239 /// described screen needs is one the app puts in `S` while it still has a
240 /// handle to ask. All that changes for a server is how long `S` lives, and
241 /// the alternatives all cost more: a second parameter cascades to every
242 /// repo that has written a handler, an async handler kind was refused by
243 /// decision 6 for the hosts that have no runtime, and a capability protocol
244 /// was settled against when the host boundary was drawn.
245 ///
246 /// Both factories, because a host that resolves a viewer per request is a
247 /// host whose renderer has something to say about that viewer: their theme,
248 /// their chrome, whether the page is signed in. A host that genuinely wants
249 /// one renderer clones a configured template in the render factory, which is
250 /// an allocation per request against a store round trip it is already
251 /// paying.
252 ///
253 /// ```no_run
254 /// # use std::sync::Arc;
255 /// # use quasi_axum::Adapter;
256 /// # use quasi_router::Router;
257 /// # struct Store;
258 /// # impl Store { async fn viewer(&self, _: Option<String>) -> Option<u64> { None } }
259 /// # struct Viewer { store: Arc<Store>, viewer: Option<u64> }
260 /// # #[derive(Clone, Default)] struct Html;
261 /// # impl quasi_axum::Serves for Html {
262 /// # fn screen(&self, _: &quasi_router::Screen) -> String { String::new() }
263 /// # fn fragment(&self, _: &quasi_router::Node) -> String { String::new() }
264 /// # fn suggestions(&self, _: &str, _: &[quasi_router::Candidate]) -> String { String::new() }
265 /// # }
266 /// # let store = Arc::new(Store);
267 /// # let quasi = Router::<Viewer>::new();
268 /// Adapter::per_viewer(
269 /// quasi,
270 /// move |parts| {
271 /// // Taken out of the head first: the future owns what it needs.
272 /// let cookie = parts
273 /// .headers
274 /// .get(http::header::COOKIE)
275 /// .and_then(|value| value.to_str().ok())
276 /// .map(str::to_owned);
277 /// let store = Arc::clone(&store);
278 /// Box::pin(async move {
279 /// let viewer = store.viewer(cookie).await;
280 /// Ok(Viewer { store, viewer })
281 /// })
282 /// },
283 /// |_viewer, _params, _answer| Html,
284 /// );
285 /// ```
286 #[must_use]
287 pub fn per_viewer<F, G>(router: Router<S>, state: F, render: G) -> Self
288 where
289 F: Fn(&Parts) -> StateFuture<S> + Send + Sync + 'static,
290 G: Fn(&S, &Params, Option<&Response>) -> R + Send + Sync + 'static,
291 {
292 Self {
293 router,
294 state: States::PerRequest(Box::new(state)),
295 render: Renderers::PerRequest(Box::new(render)),
296 body_limit: DEFAULT_BODY_LIMIT,
297 }
298 }
299
300 /// Read at most this many bytes of a form body.
301 #[must_use]
302 pub fn body_limit(mut self, bytes: usize) -> Self {
303 self.body_limit = bytes;
304 self
305 }
306
307 /// An axum router serving every path through quasi.
308 ///
309 /// A fallback, so anything merged in front of it wins. That is deliberate:
310 /// a health endpoint, static assets and a file upload are all things that
311 /// are not descriptions, and they should stay ordinary axum routes rather
312 /// than being forced through a description layer that has no word for them.
313 pub fn into_router(self) -> axum::Router {
314 let context = Arc::new(Context {
315 router: self.router,
316 state: self.state,
317 render: self.render,
318 body_limit: self.body_limit,
319 });
320
321 axum::Router::new().fallback(move |request: Request| {
322 let context = Arc::clone(&context);
323 async move { serve(context, request).await }
324 })
325 }
326 }
327
328 /// Answer one request.
329 async fn serve<S, R>(context: Arc<Context<S, R>>, request: Request) -> HttpResponse
330 where
331 S: Send + Sync + 'static,
332 R: Serves,
333 {
334 let (parts, body) = request.into_parts();
335
336 // The limit is applied while reading rather than after, because a hosted
337 // server is the one host where the sender is not our own webview and the
338 // bytes should never be buffered in the first place.
339 let bytes: Bytes = match axum::body::to_bytes(body, context.body_limit).await {
340 Ok(bytes) => bytes,
341 Err(_) => return convert(quasi_http::refuse(Refusal::TooLarge)),
342 };
343
344 let incoming = match quasi_http::decode(
345 &parts.method,
346 &parts.uri,
347 &parts.headers,
348 &bytes,
349 context.body_limit,
350 ) {
351 Ok(incoming) => incoming,
352 Err(refusal) => return convert(quasi_http::refuse(refusal)),
353 };
354
355 // Kept because the router consumes them and a per-request renderer is
356 // built after dispatch, when what the factory needs to see is both what
357 // was asked and what was answered. The view is what a renderer factory
358 // reads — which theme, which viewer, which list you are on — so it is the
359 // carried half that is kept rather than the write's payload.
360 let params = incoming.carried.clone();
361
362 // Kept before the router consumes the request, because whether the answer
363 // is a place is decided from what was asked and the answer together.
364 let asked = quasi_http::Asked::new(&incoming);
365
366 // After the envelope is checked and before the router is called. An
367 // oversized body should not cost a session lookup, and a handler must not
368 // run before the viewer it reads is resolved.
369 let state = match &context.state {
370 States::Shared(state) => Arc::clone(state),
371 States::PerRequest(factory) => match factory(&parts).await {
372 Ok(state) => Arc::new(state),
373 Err(error) => return convert(unresolved(&error)),
374 },
375 };
376
377 // Decision 6: the router is sync. Calling it directly would block the
378 // executor for however long the store takes.
379 let dispatch = {
380 let context = Arc::clone(&context);
381 let state = Arc::clone(&state);
382 tokio::task::spawn_blocking(move || context.router.handle(&state, incoming.into())).await
383 };
384
385 let outcome = dispatch.unwrap_or_else(|_| {
386 // A panic in a handler. Reported as ours, because it is.
387 Err(RouteError::internal("the request could not be completed"))
388 });
389
390 match &context.render {
391 Renderers::Shared(render) => convert(quasi_http::respond(&**render, outcome, &asked)),
392 Renderers::PerRequest(factory) => {
393 // Built from the answer rather than before it, so the factory can
394 // fill the regions this screen has and skip the work for the ones
395 // it does not.
396 let render = factory(&state, &params, outcome.as_ref().ok());
397 convert(quasi_http::respond(&render, outcome, &asked))
398 }
399 }
400 }
401
402 /// A state factory's failure, which has no description and no renderer.
403 ///
404 /// Bodyless, for the reason [`quasi_http::refuse`] is: nothing was reached, so
405 /// there is nothing to say that the status does not already say. A renderer
406 /// cannot be built either, since the one thing a per-request renderer is handed
407 /// is the state that could not be resolved.
408 fn unresolved(error: &RouteError) -> http::Response<Vec<u8>> {
409 let mut builder = http::Response::builder().status(error.class.http_status());
410 // Bodyless still owes `Allow` if the class is the one that claims verbs.
411 // A state factory cannot produce that class today, and hard-coding the
412 // assumption here is how it would stop being true silently.
413 if let Some(allow) = error.allow_header() {
414 builder = builder.header(http::header::ALLOW, allow);
415 }
416 builder
417 .body(Vec::new())
418 .expect("a response with no body and no headers is always valid")
419 }
420
421 /// An `http` response with a `Vec` body becomes an axum one.
422 fn convert(response: http::Response<Vec<u8>>) -> HttpResponse {
423 let (parts, body) = response.into_parts();
424 HttpResponse::from_parts(parts, Body::from(body))
425 }
426
427 #[cfg(test)]
428 mod tests;
429