Skip to main content

sofie_cli/new_command/
route.rs

1use std::path::{Path, PathBuf};
2
3use sofie_model::naming::{
4    bound_aggregate, handler_module, pascal_case, path_parameters, snake_case,
5};
6use sofie_model::{Aggregate, App, KnownArea, Method, Module, check_route_path};
7
8use super::detail_screen::{DetailScreen, back_link, checked_title, screen_source};
9use super::edits::{area_type, refuse_function, with_imports};
10use super::read_model::{ReadFields, aggregate_module, read_fields};
11use super::{Edits, checked_name, checked_type_name, refuse_existing, refuse_prelude_type};
12use crate::gen_command::GenError;
13use crate::source_edits::{add_call, add_import, read};
14use sofie_model::syntax::use_paths;
15
16pub struct NewRoute {
17    pub module: String,
18    pub area: String,
19    pub method: String,
20    pub path: String,
21    pub handler: String,
22    pub screen: Option<String>,
23    /// The aggregate's fields a `get`'s read model reads; those a page may show when `None`.
24    pub fields: Option<Vec<String>>,
25    /// The type a `get` answers; named after the handler when `None`.
26    pub read_model: Option<String>,
27    /// The page's heading and tab title; humanized from the screen's name when `None`.
28    pub title: Option<String>,
29    /// A `get` page of the area without parameters to link back to, or `none`; found when `None`.
30    pub back: Option<String>,
31}
32
33struct Handler<'app> {
34    kind: &'static str,
35    name: &'app str,
36    bound: Option<&'app Aggregate>,
37    /// The read model a GET answers.
38    answered: Option<String>,
39    read: Option<ReadFields>,
40}
41
42fn sensitive_values(app: &App) -> Vec<String> {
43    let mut values: Vec<String> = app
44        .modules
45        .iter()
46        .flat_map(|module| module.code.telemetry.sensitive_values.iter().cloned())
47        .collect();
48    values.sort();
49    values.dedup();
50    values
51}
52
53pub(super) fn new_route(edits: &mut Edits, app: &App, route: &NewRoute) -> Result<(), GenError> {
54    let module = super::find_module(app, &route.module)?;
55    let area = app
56        .area(&route.area)
57        .ok_or_else(|| unknown_area(app, &route.area))?;
58    let method = checked_method(&route.method)?;
59    let path = checked_path(&route.path)?;
60    refuse_prefixed(&area, path)?;
61    let screen = checked_screen(route, method)?;
62    let title = route.title.as_deref().map(checked_title).transpose()?;
63    refuse_routed(module, &route.area, method, path)?;
64    let name = checked_name(&route.handler)?;
65    let bound = path_parameters(path)
66        .into_iter()
67        .find_map(|parameter| bound_aggregate(module, parameter));
68    let answers_model = method == Method::Get && (screen.is_some() || bound.is_some());
69    let read = match bound {
70        Some(aggregate) if method == Method::Get => Some(read_fields(
71            &module.krate.path.join("src"),
72            aggregate,
73            route.fields.as_deref(),
74            &sensitive_values(app),
75        )?),
76        _ => None,
77    };
78    if route.fields.is_some() && read.is_none() {
79        return Err(GenError::Usage(
80            "`fields` are read from the aggregate a `get` path names, as `{invoice}`; \
81             this route loads none"
82                .to_owned(),
83        ));
84    }
85    let handler = Handler {
86        kind: handler_module(method),
87        name,
88        bound,
89        answered: answered_model(
90            module,
91            name,
92            route.read_model.as_deref(),
93            answers_model,
94            bound,
95        )?,
96        read,
97    };
98    if let Some(model) = &handler.answered {
99        refuse_taken_type(module, &handler, model)?;
100    }
101    let routes_file = routes_file(module, &route.area);
102    let screen_file = screen.map(|screen| {
103        routes_file
104            .with_file_name("screens")
105            .join(format!("{screen}.html"))
106    });
107    if let Some(file) = &screen_file {
108        refuse_existing(file)?;
109    }
110    let (back, back_note) = match screen {
111        Some(_) => back_link(module, &area, path, bound, route.back.as_deref())?,
112        None => (None, None),
113    };
114    let target = write_handler(edits, module, &handler)?;
115    let call = match screen {
116        Some(screen) => format!("screen(\"{path}\", \"{screen}\", {target})"),
117        None => format!(
118            "{}(\"{path}\", {target})",
119            method.name().to_ascii_lowercase()
120        ),
121    };
122    write_route(edits, module, &area, &routes_file, &handler, &call)?;
123    if let (Some(file), Some(screen)) = (&screen_file, screen) {
124        let detail = DetailScreen {
125            screen,
126            title,
127            back,
128            read: handler.read.as_ref(),
129            shows_id: route.fields.iter().flatten().any(|field| field == "id"),
130        };
131        edits.create(file, &screen_source(&detail))?;
132    }
133    for left_out in handler.read.iter().flat_map(|read| &read.left_out) {
134        edits.note(left_out.to_string());
135    }
136    if let Some(note) = back_note {
137        edits.note(note);
138    }
139    depend_on_area_contract(edits, app, module, &area)
140}
141
142fn checked_screen(route: &NewRoute, method: Method) -> Result<Option<&str>, GenError> {
143    let screen = match (&route.screen, method) {
144        (None, _) => None,
145        (Some(screen), Method::Get) => Some(checked_name(screen)?),
146        (Some(_), _) => {
147            return Err(GenError::Usage(
148                "a screen is shown by a `get` route; route the write without `--screen`".to_owned(),
149            ));
150        }
151    };
152    if screen.is_none() && (route.title.is_some() || route.back.is_some()) {
153        return Err(GenError::Usage(
154            "`title` and `back` shape the page a `get` shows; route it with a `screen`".to_owned(),
155        ));
156    }
157    Ok(screen)
158}
159
160fn depend_on_area_contract(
161    edits: &mut Edits,
162    app: &App,
163    module: &Module,
164    area: &KnownArea,
165) -> Result<(), GenError> {
166    let defining = area.contract.and_then(|contract| {
167        app.modules
168            .iter()
169            .filter_map(|candidate| candidate.contract.as_ref())
170            .find(|candidate| candidate.package == contract)
171    });
172    match defining {
173        Some(defining) => edits.depend(&module.krate, defining),
174        None => Ok(()),
175    }
176}
177
178fn unknown_area(app: &App, name: &str) -> GenError {
179    let known: Vec<&str> = app.areas().iter().map(|area| area.name).collect();
180    GenError::Usage(format!(
181        "the app has no area `{name}`; its areas: {}",
182        known.join(", ")
183    ))
184}
185
186fn checked_method(method: &str) -> Result<Method, GenError> {
187    Method::from_routes_method(&method.to_ascii_lowercase()).ok_or_else(|| {
188        GenError::Usage(format!(
189            "`{method}` is not a method `sofie new route` takes: get, post, put, patch or delete"
190        ))
191    })
192}
193
194/// What the model would refuse on the next load, refused before anything is written.
195fn checked_path(path: &str) -> Result<&str, GenError> {
196    let plain = path.starts_with('/')
197        && !path
198            .chars()
199            .any(|character| character.is_whitespace() || matches!(character, '"' | '\\'));
200    if !plain {
201        return Err(GenError::Usage(format!(
202            "`{path}` is not a route path: start it with `/`, without spaces, quotes or backslashes"
203        )));
204    }
205    check_route_path(path).map_err(GenError::Usage)?;
206    Ok(path)
207}
208
209fn refuse_routed(module: &Module, area: &str, method: Method, path: &str) -> Result<(), GenError> {
210    let routed = module
211        .areas
212        .iter()
213        .filter(|contribution| contribution.area == area)
214        .flat_map(|contribution| &contribution.routes)
215        .find(|existing| existing.method == method && existing.path == path);
216    let Some(existing) = routed else {
217        return Ok(());
218    };
219    Err(GenError::Usage(format!(
220        "{} {path} is routed already in area `{area}`, to `{}`",
221        method.name(),
222        existing.handler.join("::")
223    )))
224}
225
226/// Route paths are relative to the area's prefix, so repeating it would route `/account/account/…`.
227fn refuse_prefixed(area: &KnownArea, path: &str) -> Result<(), GenError> {
228    let prefix = area.prefix().unwrap_or_default();
229    if prefix.is_empty() {
230        return Ok(());
231    }
232    let Some(relative) = path.strip_prefix(prefix) else {
233        return Ok(());
234    };
235    if !relative.is_empty() && !relative.starts_with('/') {
236        return Ok(());
237    }
238    let relative = if relative.is_empty() { "/" } else { relative };
239    Err(GenError::Usage(format!(
240        "`{path}` repeats area `{}`'s prefix `{prefix}`: route paths are relative to it, so write \
241         `{relative}`",
242        area.name
243    )))
244}
245
246/// A GET with a screen or an aggregate answers a read model, so a screen has data `sofie check` can follow.
247fn answered_model(
248    module: &Module,
249    handler: &str,
250    named: Option<&str>,
251    answers_model: bool,
252    read: Option<&Aggregate>,
253) -> Result<Option<String>, GenError> {
254    if !answers_model {
255        if named.is_some() {
256            return Err(GenError::Usage(
257                "a read model is answered by a `get` with a screen or naming an aggregate, as \
258                 `{invoice}`; route it without `read_model`"
259                    .to_owned(),
260            ));
261        }
262        return Ok(None);
263    }
264    let name = if let Some(name) = named {
265        checked_type_name(name)?.to_owned()
266    } else {
267        let name = pascal_case(handler);
268        refuse_prelude_type(&name)?;
269        name
270    };
271    let rename = if named.is_some() {
272        "another `read_model`"
273    } else {
274        "another handler name or a `read_model`"
275    };
276    if read.is_some_and(|aggregate| aggregate.name == name) {
277        return Err(GenError::Usage(format!(
278            "the read model would be named `{name}`, like the aggregate it reads; choose {rename}"
279        )));
280    }
281    if module.code.read_model(&name).is_some() {
282        return Err(GenError::Usage(format!(
283            "module `{}` has a read model `{name}` already; choose {rename}",
284            module.krate.package
285        )));
286    }
287    Ok(Some(name))
288}
289
290/// The read model joins the handler's file, so it may not shadow a type the module declares
291/// or a name that file or the read model's own imports bring in.
292fn refuse_taken_type(module: &Module, handler: &Handler, model: &str) -> Result<(), GenError> {
293    let code = &module.code;
294    let declared = code
295        .aggregates
296        .iter()
297        .map(|declared| &declared.name)
298        .chain(code.enums.iter().map(|declared| &declared.name))
299        .chain(code.inputs.iter().map(|declared| &declared.name));
300    if declared.into_iter().any(|name| name == model) {
301        return Err(GenError::Usage(format!(
302            "`{model}` is a type of module `{}` already; choose another `read_model`",
303            module.krate.package
304        )));
305    }
306    let file = module
307        .krate
308        .path
309        .join("src")
310        .join(format!("{}.rs", handler.kind));
311    let in_file = if file.is_file() {
312        file_names(&file)?
313    } else {
314        Vec::new()
315    };
316    let imported = handler
317        .read
318        .iter()
319        .flat_map(|read| &read.imports)
320        .filter_map(|(_, named)| named.rsplit(' ').next());
321    if in_file
322        .iter()
323        .map(String::as_str)
324        .chain(imported)
325        .any(|name| name == model)
326    {
327        return Err(GenError::Usage(format!(
328            "`{model}` is a name the handler's file declares or imports already; choose another \
329             `read_model`"
330        )));
331    }
332    Ok(())
333}
334
335pub(super) fn file_names(file: &Path) -> Result<Vec<String>, GenError> {
336    let parsed = syn::parse_file(&read(file)?)
337        .map_err(|error| GenError::Usage(format!("{}: {error}", file.display())))?;
338    let mut names = Vec::new();
339    for item in &parsed.items {
340        match item {
341            syn::Item::Use(used) => {
342                names.extend(
343                    use_paths(&used.tree)
344                        .into_iter()
345                        .filter_map(|used| used.binding),
346                );
347            }
348            syn::Item::Struct(declared) => names.push(declared.ident.to_string()),
349            syn::Item::Enum(declared) => names.push(declared.ident.to_string()),
350            syn::Item::Type(declared) => names.push(declared.ident.to_string()),
351            _ => {}
352        }
353    }
354    Ok(names)
355}
356
357fn routes_file(module: &Module, area: &str) -> PathBuf {
358    module
359        .areas
360        .iter()
361        .find(|contribution| contribution.area == area)
362        .map_or_else(
363            || {
364                module
365                    .krate
366                    .path
367                    .join("src")
368                    .join("areas")
369                    .join(area)
370                    .join("routes.rs")
371            },
372            |contribution| contribution.routes_file.clone(),
373        )
374}
375
376/// A module keeping one file per handler (`src/actions/`) gets one more; otherwise the handler joins `actions.rs`.
377fn write_handler(
378    edits: &mut Edits,
379    module: &Module,
380    handler: &Handler,
381) -> Result<String, GenError> {
382    let source = module.krate.path.join("src");
383    let aggregate = handler.bound.map(|bound| {
384        let module = aggregate_module(&source, bound).join("::");
385        (format!("use {module}"), bound.name.clone())
386    });
387    let named = aggregate
388        .iter()
389        .chain(handler.read.iter().flat_map(|read| &read.imports))
390        .map(|(path, name)| (path.as_str(), name.as_str()));
391    let imports =
392        crate::sorted::first_seen(std::iter::once(("use sofie::prelude", "*")).chain(named));
393    let stub = stub(handler);
394    if source.join(handler.kind).is_dir() {
395        let file = source
396            .join(handler.kind)
397            .join(format!("{}.rs", handler.name));
398        edits.create_rust(&file, &format!("{}\n{stub}", with_imports("", &imports)))?;
399        edits.declare(&module.krate, &[handler.kind, handler.name])?;
400        return Ok(format!(
401            "{}::{}::{}",
402            handler.kind, handler.name, handler.name
403        ));
404    }
405    let file = source.join(format!("{}.rs", handler.kind));
406    refuse_function(&file, handler.name)?;
407    edits.append(&file, &imports, &stub)?;
408    edits.declare(&module.krate, &[handler.kind])?;
409    Ok(format!("{}::{}", handler.kind, handler.name))
410}
411
412fn stub(handler: &Handler) -> String {
413    let name = handler.name;
414    match (&handler.answered, handler.bound) {
415        (Some(model), Some(aggregate)) => {
416            let (from, binding) = (&aggregate.name, snake_case(&aggregate.name));
417            let fields = handler
418                .read
419                .as_ref()
420                .map(ReadFields::declarations)
421                .unwrap_or_default();
422            format!(
423                "#[derive(ReadModel)]\n#[read(from = {from})]\npub struct {model} {{\n{fields}}}\n\n\
424                 pub async fn {name}({binding}: {model}) -> Result<{model}> {{\n    Ok({binding})\n}}\n"
425            )
426        }
427        (Some(model), None) => format!(
428            "#[derive(ReadModel)]\npub struct {model} {{}}\n\n\
429             pub async fn {name}(_read: Read) -> Result<{model}> {{\n    Ok({model} {{}})\n}}\n"
430        ),
431        (None, Some(aggregate)) => {
432            let (aggregate, binding) = (&aggregate.name, snake_case(&aggregate.name));
433            format!(
434                "pub async fn {name}({binding}: {aggregate}) -> Result<{aggregate}> {{\n    Ok({binding})\n}}\n"
435            )
436        }
437        (None, None) => format!("pub async fn {name}() -> Result<()> {{\n    Ok(())\n}}\n"),
438    }
439}
440
441fn write_route(
442    edits: &mut Edits,
443    module: &Module,
444    area: &KnownArea,
445    routes_file: &Path,
446    handler: &Handler,
447    call: &str,
448) -> Result<(), GenError> {
449    if routes_file.is_file() {
450        return edits.edit_rust(routes_file, |text| {
451            let routed = add_call(text, "routes", call).ok_or_else(|| {
452                GenError::Usage(format!(
453                    "{} has no `pub fn routes(routes: &mut Routes<…>) {{ … }}` to add the route to",
454                    routes_file.display()
455                ))
456            })?;
457            Ok(add_import(&routed, "use crate", handler.kind))
458        });
459    }
460    let (area_import, area_type) = area_type(area);
461    edits.create_rust(
462        routes_file,
463        &format!(
464            "use sofie::prelude::*;\n{area_import}\nuse crate::{};\n\n\
465             pub fn routes(routes: &mut Routes<{area_type}>) {{\n    routes.{call};\n}}\n",
466            handler.kind
467        ),
468    )?;
469    edits.declare(&module.krate, &["areas", area.name, "routes"])
470}