Skip to main content

max / quasi

17.0 KB · 421 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 //! }
52 //!
53 //! fn home(_app: &App, _request: Request) -> Result<Response, RouteError> {
54 //! Ok(Screen::sidebar_content("Home")
55 //! .with(Slot::new("content", RegionKind::Pane))
56 //! .into())
57 //! }
58 //!
59 //! # async fn run() {
60 //! let quasi = Router::<App>::new().get("/", home);
61 //! let app = axum::Router::new()
62 //! .merge(Adapter::new(quasi, Arc::new(App), Arc::new(Html)).into_router());
63 //!
64 //! let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
65 //! axum::serve(listener, app).await.unwrap();
66 //! # }
67 //! ```
68
69 use std::future::Future;
70 use std::pin::Pin;
71 use std::sync::Arc;
72
73 use axum::body::{Body, Bytes};
74 use axum::extract::Request;
75 use axum::response::Response as HttpResponse;
76 use http::request::Parts;
77 use quasi_http::Refusal;
78 use quasi_router::{Params, Response, RouteError, Router};
79
80 pub use quasi_http::{DEFAULT_BODY_LIMIT, Serves, htmx};
81
82 /// The state one request is answered against, once it is resolved.
83 ///
84 /// A future, because what a viewer is resolved from is a lookup: a session
85 /// cookie against a store, a token against an introspection endpoint. That is
86 /// async work, and it happens here rather than in a handler because a handler
87 /// is sync by decision 6 and this is the one layer that can await.
88 ///
89 /// `Err` is the resolution itself failing, not the viewer being anonymous. A
90 /// signed-out reader is a state the factory builds; a store that would not
91 /// answer is a [`RouteError`], and the request never reaches the router.
92 pub type StateFuture<S> = Pin<Box<dyn Future<Output = Result<S, RouteError>> + Send>>;
93
94 /// Builds the state one request is answered against.
95 ///
96 /// The returned future is `'static`, so a factory takes what it needs out of
97 /// the request parts first and the future owns it. That is the discipline
98 /// rather than a limitation: a future holding a borrow on the request head
99 /// would be a second lifetime running through every host that mounts this.
100 pub type StateFactory<S> = dyn Fn(&Parts) -> StateFuture<S> + Send + Sync;
101
102 /// Builds the renderer for one request.
103 ///
104 /// It is handed the state, the request's parameters and the answer the router
105 /// gave, and returns the renderer that answer is rendered with. The
106 /// [`Response`] is there so the factory can walk the screen and do work only
107 /// for the regions that are actually on it: a factory that highlighted a blob
108 /// without looking would run syntect for every request that never shows a file.
109 ///
110 /// `None` is the router refusing. There is no screen, the answer is a notice
111 /// fragment, and a renderer built for it has nothing per-request to say.
112 pub type RenderFactory<S, R> = dyn Fn(&S, &Params, Option<&Response>) -> R + Send + Sync;
113
114 /// Where the renderer for a request comes from.
115 ///
116 /// A host with nothing per-request to say shares one, which is what every
117 /// client-side host wants and what it cost before this existed. A host serving
118 /// a public website builds one per request, because that is the only moment it
119 /// knows 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 /// # }
203 /// # let quasi = Router::<App>::new();
204 /// Adapter::per_request(quasi, Arc::new(App), |_app, _params, answer| {
205 /// let mut render = Html::default();
206 /// if let Some(Outcome::Screen(screen)) = answer.map(|a| &a.outcome) {
207 /// for slot in &screen.slots {
208 /// // Only the regions this screen actually has.
209 /// render.fills.insert(slot.id.clone(), String::new());
210 /// }
211 /// }
212 /// render
213 /// });
214 /// ```
215 #[must_use]
216 pub fn per_request<F>(router: Router<S>, state: Arc<S>, factory: F) -> Self
217 where
218 F: Fn(&S, &Params, Option<&Response>) -> R + Send + Sync + 'static,
219 {
220 Self {
221 router,
222 state: States::Shared(state),
223 render: Renderers::PerRequest(Box::new(factory)),
224 body_limit: DEFAULT_BODY_LIMIT,
225 }
226 }
227
228 /// Mount a router that resolves both its state and its renderer per
229 /// request.
230 ///
231 /// For the host with many viewers behind one process. The factory is handed
232 /// the request head and runs in async context, so the session lookup, the
233 /// viewer and the theme resolved from them are loaded before the router is
234 /// called, and a handler reads them off `&S` as it reads anything else.
235 ///
236 /// This is why the handler signature did not have to grow. A request-scoped
237 /// `S` is the same ruling the host boundary already made, that a host fact a
238 /// described screen needs is one the app puts in `S` while it still has a
239 /// handle to ask. All that changes for a server is how long `S` lives, and
240 /// the alternatives all cost more: a second parameter cascades to every
241 /// repo that has written a handler, an async handler kind was refused by
242 /// decision 6 for the hosts that have no runtime, and a capability protocol
243 /// was settled against when the host boundary was drawn.
244 ///
245 /// Both factories, because a host that resolves a viewer per request is a
246 /// host whose renderer has something to say about that viewer: their theme,
247 /// their chrome, whether the page is signed in. A host that genuinely wants
248 /// one renderer clones a configured template in the render factory, which is
249 /// an allocation per request against a store round trip it is already
250 /// paying.
251 ///
252 /// ```no_run
253 /// # use std::sync::Arc;
254 /// # use quasi_axum::Adapter;
255 /// # use quasi_router::Router;
256 /// # struct Store;
257 /// # impl Store { async fn viewer(&self, _: Option<String>) -> Option<u64> { None } }
258 /// # struct Viewer { store: Arc<Store>, viewer: Option<u64> }
259 /// # #[derive(Clone, Default)] struct Html;
260 /// # impl quasi_axum::Serves for Html {
261 /// # fn screen(&self, _: &quasi_router::Screen) -> String { String::new() }
262 /// # fn fragment(&self, _: &quasi_router::Node) -> String { String::new() }
263 /// # }
264 /// # let store = Arc::new(Store);
265 /// # let quasi = Router::<Viewer>::new();
266 /// Adapter::per_viewer(
267 /// quasi,
268 /// move |parts| {
269 /// // Taken out of the head first: the future owns what it needs.
270 /// let cookie = parts
271 /// .headers
272 /// .get(http::header::COOKIE)
273 /// .and_then(|value| value.to_str().ok())
274 /// .map(str::to_owned);
275 /// let store = Arc::clone(&store);
276 /// Box::pin(async move {
277 /// let viewer = store.viewer(cookie).await;
278 /// Ok(Viewer { store, viewer })
279 /// })
280 /// },
281 /// |_viewer, _params, _answer| Html,
282 /// );
283 /// ```
284 #[must_use]
285 pub fn per_viewer<F, G>(router: Router<S>, state: F, render: G) -> Self
286 where
287 F: Fn(&Parts) -> StateFuture<S> + Send + Sync + 'static,
288 G: Fn(&S, &Params, Option<&Response>) -> R + Send + Sync + 'static,
289 {
290 Self {
291 router,
292 state: States::PerRequest(Box::new(state)),
293 render: Renderers::PerRequest(Box::new(render)),
294 body_limit: DEFAULT_BODY_LIMIT,
295 }
296 }
297
298 /// Read at most this many bytes of a form body.
299 #[must_use]
300 pub fn body_limit(mut self, bytes: usize) -> Self {
301 self.body_limit = bytes;
302 self
303 }
304
305 /// An axum router serving every path through quasi.
306 ///
307 /// A fallback, so anything merged in front of it wins. That is deliberate:
308 /// a health endpoint, static assets and a file upload are all things that
309 /// are not descriptions, and they should stay ordinary axum routes rather
310 /// than being forced through a description layer that has no word for them.
311 pub fn into_router(self) -> axum::Router {
312 let context = Arc::new(Context {
313 router: self.router,
314 state: self.state,
315 render: self.render,
316 body_limit: self.body_limit,
317 });
318
319 axum::Router::new().fallback(move |request: Request| {
320 let context = Arc::clone(&context);
321 async move { serve(context, request).await }
322 })
323 }
324 }
325
326 /// Answer one request.
327 async fn serve<S, R>(context: Arc<Context<S, R>>, request: Request) -> HttpResponse
328 where
329 S: Send + Sync + 'static,
330 R: Serves,
331 {
332 let (parts, body) = request.into_parts();
333
334 // The limit is applied while reading rather than after, because a hosted
335 // server is the one host where the sender is not our own webview and the
336 // bytes should never be buffered in the first place.
337 let bytes: Bytes = match axum::body::to_bytes(body, context.body_limit).await {
338 Ok(bytes) => bytes,
339 Err(_) => return convert(quasi_http::refuse(Refusal::TooLarge)),
340 };
341
342 let incoming = match quasi_http::decode(
343 &parts.method,
344 &parts.uri,
345 &parts.headers,
346 &bytes,
347 context.body_limit,
348 ) {
349 Ok(incoming) => incoming,
350 Err(refusal) => return convert(quasi_http::refuse(refusal)),
351 };
352
353 // Kept because the router consumes them and a per-request renderer is
354 // built after dispatch, when what the factory needs to see is both what
355 // was asked and what was answered. The view is what a renderer factory
356 // reads — which theme, which viewer, which list you are on — so it is the
357 // carried half that is kept rather than the write's payload.
358 let params = incoming.carried.clone();
359
360 // Kept before the router consumes the request, because whether the answer
361 // is a place is decided from what was asked and the answer together.
362 let asked = quasi_http::Asked::new(&incoming);
363
364 // After the envelope is checked and before the router is called. An
365 // oversized body should not cost a session lookup, and a handler must not
366 // run before the viewer it reads is resolved.
367 let state = match &context.state {
368 States::Shared(state) => Arc::clone(state),
369 States::PerRequest(factory) => match factory(&parts).await {
370 Ok(state) => Arc::new(state),
371 Err(error) => return convert(unresolved(&error)),
372 },
373 };
374
375 // Decision 6: the router is sync. Calling it directly would block the
376 // executor for however long the store takes.
377 let dispatch = {
378 let context = Arc::clone(&context);
379 let state = Arc::clone(&state);
380 tokio::task::spawn_blocking(move || context.router.handle(&state, incoming.into())).await
381 };
382
383 let outcome = dispatch.unwrap_or_else(|_| {
384 // A panic in a handler. Reported as ours, because it is.
385 Err(RouteError::internal("the request could not be completed"))
386 });
387
388 match &context.render {
389 Renderers::Shared(render) => convert(quasi_http::respond(&**render, outcome, &asked)),
390 Renderers::PerRequest(factory) => {
391 // Built from the answer rather than before it, so the factory can
392 // fill the regions this screen has and skip the work for the ones
393 // it does not.
394 let render = factory(&state, &params, outcome.as_ref().ok());
395 convert(quasi_http::respond(&render, outcome, &asked))
396 }
397 }
398 }
399
400 /// A state factory's failure, which has no description and no renderer.
401 ///
402 /// Bodyless, for the reason [`quasi_http::refuse`] is: nothing was reached, so
403 /// there is nothing to say that the status does not already say. A renderer
404 /// cannot be built either, since the one thing a per-request renderer is handed
405 /// is the state that could not be resolved.
406 fn unresolved(error: &RouteError) -> http::Response<Vec<u8>> {
407 http::Response::builder()
408 .status(error.class.http_status())
409 .body(Vec::new())
410 .expect("a response with no body and no headers is always valid")
411 }
412
413 /// An `http` response with a `Vec` body becomes an axum one.
414 fn convert(response: http::Response<Vec<u8>>) -> HttpResponse {
415 let (parts, body) = response.into_parts();
416 HttpResponse::from_parts(parts, Body::from(body))
417 }
418
419 #[cfg(test)]
420 mod tests;
421