Skip to main content

sofie_http/
routes.rs

1use std::marker::PhantomData;
2
3use http::Method;
4use sofie_db::{Call, Reach};
5
6use crate::area::AreaPrincipal;
7use crate::handler::{Endpoint, Loader, endpoint, loader_endpoint};
8use crate::session::RequestSession;
9use crate::throttle::Rate;
10use crate::{Access, Area, RequestContext};
11
12pub(crate) const BODY_LIMIT: usize = 64 * 1024;
13
14#[derive(Clone)]
15pub(crate) enum View {
16    Json,
17    #[cfg(feature = "engine")]
18    Screen(ScreenView),
19    #[cfg(feature = "engine")]
20    Refresh(Refresh),
21    Redirect(String),
22    /// To the `location` the action replied, a page elsewhere such as a payment provider's.
23    Follow,
24    /// To the local `location` the action replied, else as `Redirect`.
25    RedirectOrFollow(String),
26    Document(Media),
27}
28
29/// What a document route's text is, for crawlers and feed readers rather than pages.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum Media {
32    Xml,
33    Text,
34    /// From a list of records the loader answers, not from `Text`.
35    #[cfg(feature = "csv")]
36    Csv,
37}
38
39/// What a document route's loader answers: its whole body.
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct Text(pub String);
42
43impl sofie_db::Outcome for Text {
44    fn reply(self) -> sofie_core::Result<sofie_db::Reply> {
45        Ok(sofie_db::Reply::Json(serde_json::Value::String(self.0)))
46    }
47}
48
49impl sofie_db::ReadOnly for Text {}
50
51impl Media {
52    pub(crate) fn content_type(self) -> &'static str {
53        match self {
54            Self::Xml => "application/xml; charset=utf-8",
55            Self::Text => "text/plain; charset=utf-8",
56            #[cfg(feature = "csv")]
57            Self::Csv => "text/csv; charset=utf-8",
58        }
59    }
60}
61
62#[cfg(feature = "engine")]
63#[derive(Clone)]
64pub(crate) struct ScreenView {
65    pub(crate) key: String,
66    pub(crate) title: String,
67    #[cfg(feature = "resources")]
68    pub(crate) builtin: Option<std::sync::Arc<crate::gate::optional::resource::Builtin>>,
69}
70
71#[cfg(feature = "engine")]
72#[derive(Clone)]
73pub(crate) struct Refresh {
74    pub(crate) screen: String,
75    pub(crate) fragment: String,
76}
77
78#[derive(Clone, Copy, Debug)]
79pub(crate) struct Audience {
80    pub(crate) name: &'static str,
81    /// Sessions are sealed for an area's name, so two area types of one name would share them.
82    pub(crate) area: std::any::TypeId,
83    pub(crate) access: Access,
84    pub(crate) sign_in: &'static str,
85    #[cfg(feature = "tokens")]
86    pub(crate) tokens: bool,
87}
88
89impl Audience {
90    fn of<A: Area>() -> Self {
91        Self {
92            name: A::NAME,
93            area: std::any::TypeId::of::<A>(),
94            access: <A::Principal as AreaPrincipal>::ACCESS,
95            sign_in: A::SIGN_IN,
96            #[cfg(feature = "tokens")]
97            tokens: A::TOKENS,
98        }
99    }
100
101    /// `None` turns the request away: an owned area without a principal.
102    pub(crate) fn reach(self, session: &RequestSession) -> Option<Reach> {
103        match self.access {
104            Access::Public => Some(Reach::All),
105            Access::Owned => session.principal_in(self.name).map(Reach::OwnedBy),
106            #[cfg(feature = "permissions")]
107            Access::Permitted => session.principal_in(self.name).map(Reach::AllAs),
108        }
109    }
110}
111
112#[cfg(feature = "engine")]
113#[must_use]
114pub fn screen_key(module: &str, area: &str, name: &str) -> String {
115    format!("{module}/{area}/{name}")
116}
117
118pub(crate) struct Route {
119    pub(crate) module: &'static str,
120    pub(crate) name: Option<&'static str>,
121    pub(crate) method: Method,
122    pub(crate) path: String,
123    pub(crate) audience: Audience,
124    pub(crate) endpoint: Endpoint,
125    pub(crate) view: View,
126    pub(crate) rules: RouteRules,
127}
128
129impl Route {
130    /// Leaked once per route as the app is built, so spans and metrics label it without copying.
131    pub(crate) fn name_as(&mut self, area: &str, handler: &str) {
132        let name = format!("{}.{area}.{handler}", self.module);
133        self.name = Some(Box::leak(name.into_boxed_str()));
134    }
135}
136
137#[derive(Clone, Default)]
138pub(crate) struct RouteRules {
139    pub(crate) throttle: Option<Rate>,
140    pub(crate) body_limit: Option<usize>,
141    #[cfg(feature = "uploads")]
142    pub(crate) file_limit: Option<usize>,
143    #[cfg(feature = "signed-links")]
144    pub(crate) signed: bool,
145    #[cfg(feature = "idempotency")]
146    pub(crate) idempotent: bool,
147    #[cfg(feature = "webhooks")]
148    pub(crate) verify: Option<std::sync::Arc<dyn crate::webhook::Verify>>,
149    #[cfg(feature = "flags")]
150    pub(crate) feature: Option<sofie_flags::Feature>,
151    #[cfg(feature = "entitlements")]
152    pub(crate) entitlement: Option<crate::Entitlement>,
153    /// Overrides the permission the wiring derives.
154    #[cfg(feature = "permissions")]
155    pub(crate) permission: Option<crate::Permission>,
156    #[cfg(feature = "live")]
157    pub(crate) live: Vec<String>,
158    #[cfg(feature = "screen-cache")]
159    pub(crate) cached: Option<std::time::Duration>,
160    /// Queues a `#[job]` instead of running it, which an owned area cannot.
161    #[cfg(feature = "jobs")]
162    pub(crate) queues: bool,
163}
164
165/// Handlers are plain `async fn`s whose arguments all resolve in `A`; a GET's in its `ReadContext`.
166pub struct Routes<A: Area> {
167    pub(crate) module: &'static str,
168    pub(crate) added: Vec<Route>,
169    #[cfg(feature = "islands")]
170    pub(crate) islands: Vec<crate::islands::IslandScreen>,
171    area: PhantomData<fn() -> A>,
172}
173
174pub struct PageRoute<'r, A: Area> {
175    route: &'r mut Route,
176    area: PhantomData<fn() -> A>,
177}
178
179pub struct ActionRoute<'r, A: Area> {
180    route: &'r mut Route,
181    area: PhantomData<fn() -> A>,
182}
183
184macro_rules! modifiers {
185    ($($builder:ident),*) => {$(
186        impl<'r, A: Area> $builder<'r, A> {
187            pub(crate) fn new(route: &'r mut Route) -> Self {
188                Self {
189                    route,
190                    area: PhantomData,
191                }
192            }
193
194            /// For a library's route: named `module.area.handler` like those `sofie gen` names.
195            pub fn name(&mut self, handler: &str) -> &mut Self {
196                self.route.name_as(A::NAME, handler);
197                self
198            }
199
200            /// For a library's route serving `Member`'s members from this area, as sign-in serves each
201            /// member area from `Web`.
202            pub fn name_for<Member: Area>(&mut self, handler: &str) -> &mut Self {
203                self.route.name_as(Member::NAME, handler);
204                self
205            }
206
207            /// Per principal, or per client address without one.
208            pub fn throttle(&mut self, rate: Rate) -> &mut Self {
209                self.rule(|rules| rules.throttle = Some(rate))
210            }
211
212            #[cfg(feature = "signed-links")]
213            pub fn signed(&mut self) -> &mut Self {
214                self.rule(|rules| rules.signed = true)
215            }
216
217            /// Not found while `feature` is off, so a hidden feature is not revealed.
218            #[cfg(feature = "flags")]
219            pub fn feature(&mut self, feature: sofie_flags::Feature) -> &mut Self {
220                self.rule(|rules| rules.feature = Some(feature))
221            }
222
223            /// Refused with 403 to a principal whose plan does not hold `entitlement`.
224            #[cfg(feature = "entitlements")]
225            pub fn entitled(&mut self, entitlement: crate::Entitlement) -> &mut Self {
226                self.rule(|rules| rules.entitlement = Some(entitlement))
227            }
228
229            pub(crate) fn rule(&mut self, change: impl FnOnce(&mut RouteRules)) -> &mut Self {
230                change(&mut self.route.rules);
231                self
232            }
233        }
234    )*};
235}
236
237modifiers!(PageRoute, ActionRoute);
238
239macro_rules! actions {
240    ($($name:ident: $method:ident),*) => {$(
241        pub fn $name<Args: 'static, H>(&mut self, path: &str, handler: H) -> ActionRoute<'_, A>
242        where
243            H: Call<RequestContext<A>, Args, Outcome: Send>,
244        {
245            let endpoint = endpoint(handler);
246            ActionRoute::new(self.push(Method::$method, path, endpoint, View::Json, RouteRules::default()))
247        }
248    )*};
249}
250
251impl<A: Area> Routes<A> {
252    pub(crate) fn new(module: &'static str) -> Self {
253        Self {
254            module,
255            added: Vec::new(),
256            #[cfg(feature = "islands")]
257            islands: Vec::new(),
258            area: PhantomData,
259        }
260    }
261
262    actions!(post: POST, put: PUT, patch: PATCH, delete: DELETE);
263
264    /// Also answers HEAD.
265    pub fn get<Args: 'static, H: Loader<A, Args>>(
266        &mut self,
267        path: &str,
268        loader: H,
269    ) -> PageRoute<'_, A> {
270        let endpoint = loader_endpoint(loader);
271        PageRoute::new(self.push(
272            Method::GET,
273            path,
274            endpoint,
275            View::Json,
276            RouteRules::default(),
277        ))
278    }
279
280    #[cfg(feature = "engine")]
281    pub fn screen<Args: 'static, H: Loader<A, Args>>(
282        &mut self,
283        path: &str,
284        name: &str,
285        loader: H,
286    ) -> PageRoute<'_, A> {
287        let view = View::Screen(ScreenView {
288            key: screen_key(self.module, A::NAME, name),
289            title: sofie_engine::humanize(name),
290            #[cfg(feature = "resources")]
291            builtin: None,
292        });
293        let endpoint = loader_endpoint(loader);
294        PageRoute::new(self.push(Method::GET, path, endpoint, view, RouteRules::default()))
295    }
296
297    pub(crate) fn push(
298        &mut self,
299        method: Method,
300        path: &str,
301        endpoint: Endpoint,
302        view: View,
303        rules: RouteRules,
304    ) -> &mut Route {
305        self.added.push_mut(Route {
306            module: self.module,
307            name: None,
308            method,
309            path: format!("{}{path}", A::PREFIX),
310            audience: Audience::of::<A>(),
311            endpoint,
312            view,
313            rules,
314        })
315    }
316}
317
318impl<A: Area> PageRoute<'_, A> {
319    /// Answers the loader's [`Text`] as `media` instead of JSON, such as a `/sitemap.xml`.
320    pub fn document(&mut self, media: Media) -> &mut Self {
321        self.route.view = View::Document(media);
322        self
323    }
324}
325
326impl<A: Area> ActionRoute<'_, A> {
327    pub fn body_limit(&mut self, bytes: usize) -> &mut Self {
328        self.rule(|rules| rules.body_limit = Some(bytes))
329    }
330
331    /// Each file of a form; the whole body stays within `body_limit`.
332    #[cfg(feature = "uploads")]
333    pub fn file_limit(&mut self, bytes: usize) -> &mut Self {
334        self.rule(|rules| rules.file_limit = Some(bytes))
335    }
336
337    /// A retry gets the first request's answer; the action's effects are written once.
338    #[cfg(feature = "idempotency")]
339    pub fn idempotent(&mut self) -> &mut Self {
340        self.rule(|rules| rules.idempotent = true)
341    }
342
343    /// JSON callers still get the action's reply.
344    #[cfg(feature = "engine")]
345    pub fn then_screen(&mut self, name: &str, fragment: &str) {
346        let refresh = Refresh {
347            screen: screen_key(self.route.module, A::NAME, name),
348            fragment: fragment.to_owned(),
349        };
350        self.route.view = View::Refresh(refresh);
351    }
352
353    /// JSON callers still get the action's reply. Each `{name}` in `location` is the route's
354    /// path parameter of that name, or else the reply's field: `"/orders/{id}"` after an action
355    /// replying the order it created, `"/shops/{shop}/orders"` after `/shops/{shop}/orders/new`.
356    pub fn then_redirect(&mut self, location: &str) {
357        self.route.view = View::Redirect(location.to_owned());
358    }
359
360    /// A browser goes to the `https://` `location` the action replies; JSON callers get the reply.
361    pub fn then_follow(&mut self) {
362        self.route.view = View::Follow;
363    }
364
365    /// As `then_redirect`, `next` included, unless the action replies a `location` of this
366    /// app, such as a second step of signing in, which the browser then goes to instead.
367    pub fn then_redirect_or_follow(&mut self, location: &str) {
368        self.route.view = View::RedirectOrFollow(location.to_owned());
369    }
370}