1use std::collections::BTreeMap;
22
23use serde::{Deserialize, Serialize};
24
25use crate::context::Context;
26use crate::error::ApiError;
27use crate::page::{CardGroup, Detail, Section};
28use crate::schema::SelectOption;
29
30#[derive(Debug, Clone, Serialize)]
32pub struct Param {
33 key: String,
34 label: String,
35 #[serde(rename = "type")]
36 kind: ParamKind,
37 #[serde(skip_serializing_if = "Vec::is_empty")]
38 options: Vec<SelectOption>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 default: Option<String>,
41 #[serde(skip_serializing_if = "is_false")]
42 hidden: bool,
43}
44
45fn is_false(value: &bool) -> bool {
46 !*value
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
53#[serde(rename_all = "lowercase")]
54pub(crate) enum ParamKind {
55 Select,
56 String,
57}
58
59impl Param {
60 pub fn select(
63 key: impl Into<String>,
64 label: impl Into<String>,
65 options: impl IntoIterator<Item = impl Into<SelectOption>>,
66 ) -> Self {
67 Self {
68 key: key.into(),
69 label: label.into(),
70 kind: ParamKind::Select,
71 options: options.into_iter().map(Into::into).collect(),
72 default: None,
73 hidden: false,
74 }
75 }
76
77 pub fn string(key: impl Into<String>, label: impl Into<String>) -> Self {
78 Self {
79 key: key.into(),
80 label: label.into(),
81 kind: ParamKind::String,
82 options: Vec::new(),
83 default: None,
84 hidden: false,
85 }
86 }
87
88 pub fn default(mut self, value: impl Into<String>) -> Self {
90 self.default = Some(value.into());
91 self
92 }
93
94 pub fn hidden(mut self) -> Self {
100 self.hidden = true;
101 self
102 }
103
104 pub fn key(&self) -> &str {
105 &self.key
106 }
107
108 pub(crate) fn fallback(&self) -> Option<&str> {
109 self.default.as_deref()
110 }
111
112 pub(crate) fn offers(&self, value: &str) -> bool {
115 self.kind != ParamKind::Select
116 || self.options.is_empty()
117 || self.options.iter().any(|option| option.value == value)
118 }
119}
120
121#[derive(Debug, Clone, Default, Serialize)]
140pub struct ViewArgs(BTreeMap<String, String>);
141
142impl ViewArgs {
143 pub(crate) fn from_query(query: &BTreeMap<String, String>) -> Self {
144 Self(query.clone())
145 }
146
147 pub(crate) fn resolve(query: &BTreeMap<String, String>, params: &[Param]) -> Self {
150 let mut args = query.clone();
151 for param in params {
152 let asked = args.get(param.key());
153 let keep = match asked {
154 Some(value) => param.offers(value),
155 None => false,
156 };
157 if keep {
158 continue;
159 }
160 if let Some(fallback) = param.fallback() {
163 args.insert(param.key().to_string(), fallback.to_string());
164 }
165 }
166 Self(args)
167 }
168
169 pub fn get(&self, key: &str) -> Option<&str> {
170 self.0.get(key).map(String::as_str)
171 }
172
173 pub fn get_or<'a>(&'a self, key: &str, fallback: &'a str) -> &'a str {
176 self.get(key).unwrap_or(fallback)
177 }
178
179 pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
181 self.0.iter().map(|(k, v)| (k.as_str(), v.as_str()))
182 }
183
184 pub fn is_empty(&self) -> bool {
185 self.0.is_empty()
186 }
187
188 pub fn len(&self) -> usize {
189 self.0.len()
190 }
191}
192
193#[derive(Debug, Clone, Default)]
201pub struct Fields(BTreeMap<String, String>);
202
203impl Fields {
204 pub fn get(&self, key: &str) -> Option<&str> {
207 self.0.get(key).map(String::as_str)
208 }
209
210 pub fn text(&self, key: &str) -> &str {
213 self.get(key).unwrap_or("").trim()
214 }
215
216 pub fn integer(&self, key: &str) -> Result<i64, ApiError> {
218 let text = self.text(key);
219 text.parse()
220 .map_err(|_| Self::refuse(key, text, "a whole number"))
221 }
222
223 pub fn number(&self, key: &str) -> Result<f64, ApiError> {
229 let text = self.text(key);
230 match text.parse::<f64>() {
231 Ok(number) if number.is_finite() => Ok(number),
232 _ => Err(Self::refuse(key, text, "a number")),
233 }
234 }
235
236 pub fn date(&self, key: &str) -> Result<&str, ApiError> {
243 let text = self.text(key);
244 if is_date(text) {
245 Ok(text)
246 } else {
247 Err(Self::refuse(key, text, "a date, as YYYY-MM-DD"))
248 }
249 }
250
251 pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
253 self.0.iter().map(|(k, v)| (k.as_str(), v.as_str()))
254 }
255
256 fn refuse(key: &str, text: &str, wanted: &str) -> ApiError {
257 if text.is_empty() {
258 ApiError::bad_request(format!("{key} was left empty, and it wants {wanted}"))
259 } else {
260 ApiError::bad_request(format!("{key} is \"{text}\", which is not {wanted}"))
261 }
262 }
263}
264
265fn is_date(text: &str) -> bool {
268 let mut parts = text.split('-');
269 let (Some(year), Some(month), Some(day), None) =
270 (parts.next(), parts.next(), parts.next(), parts.next())
271 else {
272 return false;
273 };
274 let digits =
275 |text: &str, width: usize| text.len() == width && text.bytes().all(|b| b.is_ascii_digit());
276 if !digits(year, 4) || !digits(month, 2) || !digits(day, 2) {
277 return false;
278 }
279 let number = |text: &str| text.parse::<u32>().unwrap_or(0);
280 (1..=12).contains(&number(month)) && (1..=31).contains(&number(day))
281}
282
283#[derive(Debug, Clone, Default, Serialize)]
291pub struct ViewData {
292 #[serde(skip_serializing_if = "Option::is_none")]
293 note: Option<String>,
294 sections: Vec<Section>,
295 #[serde(skip_serializing_if = "Vec::is_empty")]
296 groups: Vec<CardGroup>,
297 #[serde(skip_serializing_if = "Option::is_none")]
298 detail: Option<Detail>,
299}
300
301impl ViewData {
302 pub fn new() -> Self {
303 Self::default()
304 }
305
306 pub fn note(mut self, note: impl Into<String>) -> Self {
307 self.note = Some(note.into());
308 self
309 }
310
311 pub fn section(mut self, section: Section) -> Self {
312 self.sections.push(section);
313 self
314 }
315
316 pub fn group(mut self, group: CardGroup) -> Self {
320 if !group.is_empty() {
321 self.groups.push(group);
322 }
323 self
324 }
325
326 pub fn detail(mut self, detail: Detail) -> Self {
327 self.detail = Some(detail);
328 self
329 }
330
331 fn bodies(&self) -> Vec<&'static str> {
333 let mut bodies = Vec::new();
334 if !self.sections.is_empty() {
335 bodies.push("sections of rows");
336 }
337 if !self.groups.is_empty() {
338 bodies.push("groups of cards");
339 }
340 if self.detail.is_some() {
341 bodies.push("one thing in detail");
342 }
343 bodies
344 }
345
346 fn one_body(&self, view: &str) -> Result<(), ApiError> {
349 let bodies = self.bodies();
350 if bodies.len() > 1 {
351 return Err(ApiError::server(format!(
352 "the view \"{view}\" answered with {}; a view answers with one of them",
353 bodies.join(" and ")
354 )));
355 }
356 Ok(())
357 }
358
359 fn offers(&self, name: &str, args: &ViewArgs) -> bool {
369 let Some(detail) = &self.detail else {
370 return false;
371 };
372 detail.actions().any(|(action, carried)| {
373 action == name
374 && carried
375 .iter()
376 .all(|(key, value)| args.get(key) == Some(value.as_str()))
377 })
378 }
379
380 fn no_form_answers_a_parameter(&self, view: &str, params: &[Param]) -> Result<(), ApiError> {
389 let Some(detail) = &self.detail else {
390 return Ok(());
391 };
392 for (action, carried) in detail.actions() {
393 for key in carried.keys() {
394 if params.iter().any(|param| param.key() == key) {
395 return Err(ApiError::server(format!(
396 "the view \"{view}\" offers the action \"{action}\" with an argument \
397 keyed \"{key}\", which is one of the view's own parameters; a form's \
398 arguments are added to the page's, so that form would write about \
399 another page than the one it is on"
400 )));
401 }
402 }
403 }
404 Ok(())
405 }
406}
407
408pub trait ViewLogic: Send + Sync + 'static {
414 fn name(&self) -> &'static str;
418
419 fn title(&self) -> &'static str;
421
422 fn in_switcher(&self) -> bool {
431 true
432 }
433
434 fn params(&self, _ctx: &Context, _asked: &ViewArgs) -> Result<Vec<Param>, ApiError> {
443 Ok(Vec::new())
444 }
445
446 fn render(&self, args: &ViewArgs, ctx: &Context) -> Result<ViewData, ApiError>;
447
448 fn act(
464 &self,
465 name: &str,
466 _fields: &Fields,
467 _args: &ViewArgs,
468 _ctx: &Context,
469 ) -> Result<String, ApiError> {
470 Err(ApiError::server(format!(
471 "this view has no action called \"{name}\" to write"
472 )))
473 }
474}
475
476pub trait View: Send + Sync {
480 fn route(&self) -> &'static str;
482
483 fn heading(&self) -> &'static str;
485
486 fn listed(&self) -> bool;
488
489 fn param_keys(&self, ctx: &Context) -> Result<Vec<String>, ApiError>;
492
493 fn handle_get(
496 &self,
497 query: &BTreeMap<String, String>,
498 ctx: &Context,
499 ) -> Result<String, ApiError>;
500
501 fn handle_action(
504 &self,
505 name: &str,
506 query: &BTreeMap<String, String>,
507 body: &str,
508 ctx: &Context,
509 ) -> Result<String, ApiError>;
510}
511
512impl<V: ViewLogic> View for V {
513 fn route(&self) -> &'static str {
514 self.name()
515 }
516
517 fn heading(&self) -> &'static str {
518 self.title()
519 }
520
521 fn listed(&self) -> bool {
522 self.in_switcher()
523 }
524
525 fn param_keys(&self, ctx: &Context) -> Result<Vec<String>, ApiError> {
526 Ok(self
527 .params(ctx, &ViewArgs::default())?
528 .iter()
529 .map(|param| param.key().to_string())
530 .collect())
531 }
532
533 fn handle_get(
534 &self,
535 query: &BTreeMap<String, String>,
536 ctx: &Context,
537 ) -> Result<String, ApiError> {
538 let params = self.params(ctx, &ViewArgs::from_query(query))?;
541 let args = ViewArgs::resolve(query, ¶ms);
542 let data = self.render(&args, ctx)?;
543 data.one_body(self.name())?;
544 data.no_form_answers_a_parameter(self.name(), ¶ms)?;
545
546 serde_json::to_string(&ViewPayload {
547 view: self.name(),
548 title: self.title(),
549 params,
550 args,
551 note: data.note,
552 sections: data.sections,
553 groups: data.groups,
554 detail: data.detail,
555 })
556 .map_err(|e| ApiError::server(e.to_string()))
557 }
558
559 fn handle_action(
560 &self,
561 name: &str,
562 query: &BTreeMap<String, String>,
563 body: &str,
564 ctx: &Context,
565 ) -> Result<String, ApiError> {
566 let request: ActionRequest = serde_json::from_str(body)
567 .map_err(|e| ApiError::bad_request(format!("invalid request body: {e}")))?;
568
569 let params = self.params(ctx, &ViewArgs::from_query(query))?;
570 let args = ViewArgs::resolve(query, ¶ms);
571
572 let page = self.render(&args, ctx)?;
578 page.one_body(self.name())?;
579 page.no_form_answers_a_parameter(self.name(), ¶ms)?;
580 if !page.offers(name, &args) {
581 return Err(ApiError::new(
582 404,
583 format!(
584 "the view \"{}\" offers no action called \"{name}\" about what was asked",
585 self.name()
586 ),
587 ));
588 }
589
590 let confirmation = self.act(name, &Fields(request.fields), &args, ctx)?;
591 serde_json::to_string(&ActionReply {
592 confirmation: &confirmation,
593 })
594 .map_err(|e| ApiError::server(e.to_string()))
595 }
596}
597
598#[derive(Serialize)]
602struct ViewPayload<'a> {
603 view: &'a str,
604 title: &'a str,
605 params: Vec<Param>,
606 args: ViewArgs,
607 #[serde(skip_serializing_if = "Option::is_none")]
608 note: Option<String>,
609 sections: Vec<Section>,
610 #[serde(skip_serializing_if = "Vec::is_empty")]
611 groups: Vec<CardGroup>,
612 #[serde(skip_serializing_if = "Option::is_none")]
613 detail: Option<Detail>,
614}
615
616#[derive(Deserialize)]
620struct ActionRequest {
621 #[serde(default)]
622 fields: BTreeMap<String, String>,
623}
624
625#[derive(Serialize)]
628struct ActionReply<'a> {
629 confirmation: &'a str,
630}
631
632#[cfg(test)]
633mod tests {
634 use serde_json::{Value, json};
635
636 use super::*;
637 use crate::fixture;
638 use crate::page::{
639 Button, Card, CardGroup, DetailRow, DetailSection, Field, Form, Status, Tone,
640 };
641 use crate::schema::Column;
642
643 fn query(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
644 pairs
645 .iter()
646 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
647 .collect()
648 }
649
650 fn fields(pairs: &[(&str, &str)]) -> Fields {
651 Fields(
652 pairs
653 .iter()
654 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
655 .collect(),
656 )
657 }
658
659 #[test]
660 fn an_address_that_names_a_parameter_is_taken_at_its_word() {
661 let params = vec![Param::select("branch", "Branch", ["cen", "est"]).default("cen")];
662 let args = ViewArgs::resolve(&query(&[("branch", "est")]), ¶ms);
663 assert_eq!(args.get("branch"), Some("est"));
664 }
665
666 #[test]
667 fn a_parameter_the_address_leaves_out_falls_back_to_its_default() {
668 let params = vec![Param::select("branch", "Branch", ["cen"]).default("cen")];
669 let args = ViewArgs::resolve(&query(&[]), ¶ms);
670 assert_eq!(args.get("branch"), Some("cen"));
671 }
672
673 #[test]
674 fn a_parameter_with_no_default_is_simply_absent() {
675 let params = vec![Param::string("who", "Borrower")];
676 let args = ViewArgs::resolve(&query(&[]), ¶ms);
677 assert_eq!(args.get("who"), None);
678 assert_eq!(args.get_or("who", "anyone"), "anyone");
679 assert!(args.is_empty());
680 }
681
682 #[test]
683 fn a_value_the_options_no_longer_offer_falls_back_to_the_default() {
684 let params = vec![Param::select("subgenre", "Subgenre", ["Memoir"]).default("Memoir")];
687 let args = ViewArgs::resolve(&query(&[("subgenre", "Natural History")]), ¶ms);
688 assert_eq!(args.get("subgenre"), Some("Memoir"));
689 }
690
691 #[test]
692 fn a_value_nothing_offers_and_nothing_replaces_is_left_alone() {
693 let params = vec![Param::select("subgenre", "Subgenre", ["Memoir"])];
694 let args = ViewArgs::resolve(&query(&[("subgenre", "Natural History")]), ¶ms);
695 assert_eq!(args.get("subgenre"), Some("Natural History"));
696 }
697
698 #[test]
699 fn a_select_that_offers_nothing_takes_whatever_it_is_given() {
700 let params = vec![Param::select("branch", "Branch", Vec::<String>::new()).default("cen")];
701 let args = ViewArgs::resolve(&query(&[("branch", "anything")]), ¶ms);
702 assert_eq!(args.get("branch"), Some("anything"));
703 }
704
705 #[test]
706 fn a_parameter_cleared_on_purpose_stays_cleared() {
707 let params = vec![Param::string("who", "Borrower").default("Ada")];
710 let args = ViewArgs::resolve(&query(&[("who", "")]), ¶ms);
711 assert_eq!(args.get("who"), Some(""));
712 }
713
714 #[test]
715 fn a_key_no_parameter_names_is_kept_for_a_view_that_wants_it() {
716 let params = vec![Param::select("branch", "Branch", ["cen"]).default("cen")];
717 let args = ViewArgs::resolve(&query(&[("sort", "due")]), ¶ms);
718 assert_eq!(args.get("sort"), Some("due"));
719 assert_eq!(args.get("branch"), Some("cen"));
720 assert_eq!(
721 args.iter().collect::<Vec<_>>(),
722 vec![("branch", "cen"), ("sort", "due")]
723 );
724 assert_eq!(args.len(), 2);
725 }
726
727 #[test]
728 fn a_parameter_serializes_to_the_documented_shape() {
729 let param = Param::select(
730 "branch",
731 "Branch",
732 [SelectOption::labelled("cen", "Central")],
733 )
734 .default("cen");
735
736 assert_eq!(
737 serde_json::to_value(¶m).unwrap(),
738 json!({ "key": "branch", "label": "Branch", "type": "select",
739 "options": [{ "value": "cen", "label": "Central" }],
740 "default": "cen" })
741 );
742
743 assert_eq!(
744 serde_json::to_value(Param::string("who", "Borrower")).unwrap(),
745 json!({ "key": "who", "label": "Borrower", "type": "string" })
746 );
747 }
748
749 #[test]
750 fn a_hidden_parameter_says_so_and_is_settled_like_any_other() {
751 let param = Param::string("codename", "Codename")
752 .default("teare")
753 .hidden();
754 assert_eq!(
755 serde_json::to_value(¶m).unwrap(),
756 json!({ "key": "codename", "label": "Codename", "type": "string",
757 "default": "teare", "hidden": true })
758 );
759
760 let args = ViewArgs::resolve(&query(&[]), &[param]);
761 assert_eq!(args.get("codename"), Some("teare"));
762 }
763
764 #[test]
765 fn a_field_is_read_as_the_kind_of_answer_it_asked_for() {
766 let filled = fields(&[
767 ("borrower", " Ada Ferreira "),
768 ("days", "21"),
769 ("rating", "4.5"),
770 ("from", "2026-09-21"),
771 ]);
772 assert_eq!(filled.text("borrower"), "Ada Ferreira");
773 assert_eq!(filled.integer("days").unwrap(), 21);
774 assert_eq!(filled.number("rating").unwrap(), 4.5);
775 assert_eq!(filled.date("from").unwrap(), "2026-09-21");
776 assert_eq!(filled.get("days"), Some("21"));
777 assert_eq!(
778 filled.iter().map(|(k, _)| k).collect::<Vec<_>>(),
779 vec!["borrower", "days", "from", "rating"]
780 );
781 }
782
783 #[test]
784 fn a_field_the_form_did_not_carry_reads_as_empty_text() {
785 let empty = fields(&[]);
786 assert_eq!(empty.text("borrower"), "");
787 assert_eq!(empty.get("borrower"), None);
788 }
789
790 #[test]
791 fn a_field_that_does_not_parse_is_a_bad_request_naming_it() {
792 let filled = fields(&[
793 ("days", "a fortnight"),
794 ("from", "2026-13-01"),
795 ("empty", ""),
796 ]);
797
798 let days = filled.integer("days").unwrap_err();
799 assert_eq!(days.status, 400);
800 assert!(days.message.contains("days"), "{}", days.message);
801 assert!(days.message.contains("a fortnight"), "{}", days.message);
802
803 assert_eq!(
805 fields(&[("days", "4.5")])
806 .integer("days")
807 .unwrap_err()
808 .status,
809 400
810 );
811 assert_eq!(fields(&[("days", "4.5")]).number("days").unwrap(), 4.5);
812
813 for text in ["inf", "-inf", "infinity", "NaN", "nan"] {
817 let refused = fields(&[("rating", text)]).number("rating").unwrap_err();
818 assert_eq!(refused.status, 400, "{text}");
819 assert!(refused.message.contains("rating"), "{text}");
820 }
821
822 assert_eq!(filled.date("from").unwrap_err().status, 400);
824 for bad in [
825 "",
826 "2026-9-1",
827 "26-09-01",
828 "2026-09-32",
829 "2026-00-01",
830 "not a date",
831 "2026-09-01-02",
832 ] {
833 assert!(!is_date(bad), "{bad} read as a date");
834 }
835 for good in ["2026-09-01", "1999-12-31", "2026-02-30"] {
836 assert!(is_date(good), "{good} did not read as a date");
837 }
838
839 let empty = filled.integer("empty").unwrap_err();
840 assert!(empty.message.contains("left empty"), "{}", empty.message);
841 }
842
843 struct Shelf;
847
848 impl ViewLogic for Shelf {
849 fn name(&self) -> &'static str {
850 "shelf"
851 }
852
853 fn title(&self) -> &'static str {
854 "Shelf"
855 }
856
857 fn params(&self, _ctx: &Context, _asked: &ViewArgs) -> Result<Vec<Param>, ApiError> {
858 Ok(vec![
859 Param::string("body", "Body").default("cards").hidden(),
860 ])
861 }
862
863 fn render(&self, args: &ViewArgs, _ctx: &Context) -> Result<ViewData, ApiError> {
864 let card = |title: &str| {
865 Card::new(title.to_string()).status(Status::new("On the shelf", Tone::Good))
866 };
867 match args.get_or("body", "cards") {
868 "cards" => Ok(ViewData::new()
869 .note("Two of them.")
870 .group(CardGroup::new("Here").cards([card("Nine Doors"), card("Moss")]))
871 .group(CardGroup::new("Elsewhere"))),
872 "detail" => Ok(ViewData::new().detail(
873 Detail::new("Nine Doors").section(
874 DetailSection::main("Lend it").row(
875 DetailRow::new("Central").button(Button::form(
876 "Lend it out",
877 Form::new("lend")
878 .arg("title", "Nine Doors")
879 .field(Field::number("days", "Days").default(21)),
880 )),
881 ),
882 ),
883 )),
884 "both" => Ok(ViewData::new()
885 .group(CardGroup::new("Here").card(card("Moss")))
886 .detail(Detail::new("Moss"))),
887 _ => Ok(ViewData::new().section(Section::new([Column::string("title", "Title")]))),
888 }
889 }
890
891 fn act(
892 &self,
893 name: &str,
894 fields: &Fields,
895 args: &ViewArgs,
896 _ctx: &Context,
897 ) -> Result<String, ApiError> {
898 let days = fields.integer("days")?;
899 Ok(format!(
900 "{name}: {} is out for {days} days.",
901 args.get_or("title", "nothing")
902 ))
903 }
904 }
905
906 fn page(asked: &[(&str, &str)]) -> Value {
907 let dir = fixture::temp_dir();
908 let json = Shelf.handle_get(&query(asked), &dir.context()).unwrap();
909 serde_json::from_str(&json).unwrap()
910 }
911
912 #[test]
913 fn a_page_of_cards_carries_its_groups_and_no_sections() {
914 let shown = page(&[("body", "cards")]);
915 assert_eq!(shown["note"], "Two of them.");
916 assert_eq!(shown["sections"], json!([]));
917 assert!(shown.get("detail").is_none());
918 assert_eq!(shown["groups"][0]["heading"], "Here");
919 assert_eq!(shown["groups"][0]["cards"][0]["title"], "Nine Doors");
920 assert_eq!(shown["groups"].as_array().unwrap().len(), 1);
922 }
923
924 #[test]
925 fn a_page_of_one_thing_carries_a_detail_and_no_groups() {
926 let shown = page(&[("body", "detail")]);
927 assert_eq!(shown["detail"]["title"], "Nine Doors");
928 assert_eq!(shown["sections"], json!([]));
929 assert!(shown.get("groups").is_none());
930 }
931
932 #[test]
933 fn a_page_of_rows_still_carries_the_shape_it_always_did() {
934 let shown = page(&[("body", "rows")]);
935 assert_eq!(shown["sections"][0]["columns"][0]["field"], "title");
936 assert!(shown.get("groups").is_none());
937 assert!(shown.get("detail").is_none());
938 }
939
940 #[test]
941 fn a_view_that_answers_two_ways_at_once_is_refused() {
942 let dir = fixture::temp_dir();
943 let failure = Shelf
944 .handle_get(&query(&[("body", "both")]), &dir.context())
945 .unwrap_err();
946 assert_eq!(failure.status, 500);
947 assert!(failure.message.contains("shelf"), "{}", failure.message);
948 assert!(
949 failure.message.contains("groups of cards")
950 && failure.message.contains("one thing in detail"),
951 "{}",
952 failure.message
953 );
954 }
955
956 #[test]
957 fn an_action_the_page_offers_writes_and_says_what_it_did() {
958 let dir = fixture::temp_dir();
959 let json = Shelf
960 .handle_action(
961 "lend",
962 &query(&[("body", "detail"), ("title", "Nine Doors")]),
963 r#"{"fields":{"days":"14"}}"#,
964 &dir.context(),
965 )
966 .unwrap();
967 assert_eq!(
968 serde_json::from_str::<Value>(&json).unwrap(),
969 json!({ "confirmation": "lend: Nine Doors is out for 14 days." })
970 );
971 }
972
973 #[test]
974 fn an_action_the_page_does_not_offer_is_refused() {
975 let dir = fixture::temp_dir();
976 let unknown = Shelf
977 .handle_action(
978 "burn-it",
979 &query(&[("body", "detail")]),
980 r#"{"fields":{}}"#,
981 &dir.context(),
982 )
983 .unwrap_err();
984 assert_eq!(unknown.status, 404);
985 assert!(unknown.message.contains("burn-it"), "{}", unknown.message);
986
987 let elsewhere = Shelf
990 .handle_action(
991 "lend",
992 &query(&[("body", "cards")]),
993 r#"{"fields":{"days":"14"}}"#,
994 &dir.context(),
995 )
996 .unwrap_err();
997 assert_eq!(elsewhere.status, 404);
998 }
999
1000 #[test]
1001 fn an_action_about_a_row_the_page_does_not_offer_is_refused() {
1002 let dir = fixture::temp_dir();
1006 let wrong_row = Shelf
1007 .handle_action(
1008 "lend",
1009 &query(&[("body", "detail"), ("title", "Moss")]),
1010 r#"{"fields":{"days":"14"}}"#,
1011 &dir.context(),
1012 )
1013 .unwrap_err();
1014 assert_eq!(wrong_row.status, 404);
1015 assert!(wrong_row.message.contains("lend"), "{}", wrong_row.message);
1016
1017 let no_row = Shelf
1020 .handle_action(
1021 "lend",
1022 &query(&[("body", "detail")]),
1023 r#"{"fields":{"days":"14"}}"#,
1024 &dir.context(),
1025 )
1026 .unwrap_err();
1027 assert_eq!(no_row.status, 404);
1028 }
1029
1030 #[test]
1031 fn a_form_that_answers_one_of_the_views_own_questions_is_refused() {
1032 struct Crossed;
1036 impl ViewLogic for Crossed {
1037 fn name(&self) -> &'static str {
1038 "crossed"
1039 }
1040 fn title(&self) -> &'static str {
1041 "Crossed"
1042 }
1043 fn params(&self, _ctx: &Context, _asked: &ViewArgs) -> Result<Vec<Param>, ApiError> {
1044 Ok(vec![Param::string("body", "Body").default("detail")])
1045 }
1046 fn render(&self, _args: &ViewArgs, _ctx: &Context) -> Result<ViewData, ApiError> {
1047 Ok(ViewData::new().detail(Detail::new("Nine Doors").section(
1048 DetailSection::main("Lend it").row(DetailRow::new("Central").button(
1049 Button::form("Lend it out", Form::new("lend").arg("body", "cards")),
1050 )),
1051 )))
1052 }
1053 }
1054
1055 let dir = fixture::temp_dir();
1056 let rendered = Crossed.handle_get(&query(&[]), &dir.context()).unwrap_err();
1057 assert_eq!(rendered.status, 500);
1058 assert!(rendered.message.contains("crossed"), "{}", rendered.message);
1059 assert!(rendered.message.contains("body"), "{}", rendered.message);
1060
1061 let written = Crossed
1064 .handle_action("lend", &query(&[]), r#"{"fields":{}}"#, &dir.context())
1065 .unwrap_err();
1066 assert_eq!(written.status, 500);
1067 }
1068
1069 #[test]
1070 fn an_action_whose_field_does_not_parse_is_a_bad_request() {
1071 let dir = fixture::temp_dir();
1072 let failure = Shelf
1073 .handle_action(
1074 "lend",
1075 &query(&[("body", "detail"), ("title", "Nine Doors")]),
1076 r#"{"fields":{"days":"a fortnight"}}"#,
1077 &dir.context(),
1078 )
1079 .unwrap_err();
1080 assert_eq!(failure.status, 400);
1081 assert!(failure.message.contains("days"), "{}", failure.message);
1082 }
1083
1084 #[test]
1085 fn an_action_with_an_unreadable_body_is_a_bad_request() {
1086 let dir = fixture::temp_dir();
1087 for body in ["", "not json", r#"{"fields":{"days":14}}"#] {
1088 let failure = Shelf
1089 .handle_action(
1090 "lend",
1091 &query(&[("body", "detail"), ("title", "Nine Doors")]),
1092 body,
1093 &dir.context(),
1094 )
1095 .unwrap_err();
1096 assert_eq!(failure.status, 400, "{body}");
1097 }
1098 }
1099
1100 #[test]
1101 fn a_view_with_no_action_of_its_own_refuses_to_write() {
1102 struct Plain;
1103 impl ViewLogic for Plain {
1104 fn name(&self) -> &'static str {
1105 "plain"
1106 }
1107 fn title(&self) -> &'static str {
1108 "Plain"
1109 }
1110 fn render(&self, _args: &ViewArgs, _ctx: &Context) -> Result<ViewData, ApiError> {
1111 Ok(ViewData::new())
1112 }
1113 }
1114
1115 let dir = fixture::temp_dir();
1116 let refused = Plain
1119 .act("lend", &fields(&[]), &ViewArgs::default(), &dir.context())
1120 .unwrap_err();
1121 assert_eq!(refused.status, 500);
1122 assert!(refused.message.contains("lend"), "{}", refused.message);
1123 }
1124}