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 pub fields: Option<Vec<String>>,
25 pub read_model: Option<String>,
27 pub title: Option<String>,
29 pub back: Option<String>,
31}
32
33struct Handler<'app> {
34 kind: &'static str,
35 name: &'app str,
36 bound: Option<&'app Aggregate>,
37 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
194fn 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
226fn 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
246fn 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
290fn 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
376fn 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}