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