use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::context::Context;
use crate::error::ApiError;
use crate::page::{CardGroup, Detail, Section};
use crate::schema::SelectOption;
#[derive(Debug, Clone, Serialize)]
pub struct Param {
key: String,
label: String,
#[serde(rename = "type")]
kind: ParamKind,
#[serde(skip_serializing_if = "Vec::is_empty")]
options: Vec<SelectOption>,
#[serde(skip_serializing_if = "Option::is_none")]
default: Option<String>,
#[serde(skip_serializing_if = "is_false")]
hidden: bool,
}
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum ParamKind {
Select,
String,
}
impl Param {
pub fn select(
key: impl Into<String>,
label: impl Into<String>,
options: impl IntoIterator<Item = impl Into<SelectOption>>,
) -> Self {
Self {
key: key.into(),
label: label.into(),
kind: ParamKind::Select,
options: options.into_iter().map(Into::into).collect(),
default: None,
hidden: false,
}
}
pub fn string(key: impl Into<String>, label: impl Into<String>) -> Self {
Self {
key: key.into(),
label: label.into(),
kind: ParamKind::String,
options: Vec::new(),
default: None,
hidden: false,
}
}
pub fn default(mut self, value: impl Into<String>) -> Self {
self.default = Some(value.into());
self
}
pub fn hidden(mut self) -> Self {
self.hidden = true;
self
}
pub fn key(&self) -> &str {
&self.key
}
pub(crate) fn fallback(&self) -> Option<&str> {
self.default.as_deref()
}
pub(crate) fn offers(&self, value: &str) -> bool {
self.kind != ParamKind::Select
|| self.options.is_empty()
|| self.options.iter().any(|option| option.value == value)
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ViewArgs(BTreeMap<String, String>);
impl ViewArgs {
pub(crate) fn from_query(query: &BTreeMap<String, String>) -> Self {
Self(query.clone())
}
pub(crate) fn resolve(query: &BTreeMap<String, String>, params: &[Param]) -> Self {
let mut args = query.clone();
for param in params {
let asked = args.get(param.key());
let keep = match asked {
Some(value) => param.offers(value),
None => false,
};
if keep {
continue;
}
if let Some(fallback) = param.fallback() {
args.insert(param.key().to_string(), fallback.to_string());
}
}
Self(args)
}
pub fn get(&self, key: &str) -> Option<&str> {
self.0.get(key).map(String::as_str)
}
pub fn get_or<'a>(&'a self, key: &str, fallback: &'a str) -> &'a str {
self.get(key).unwrap_or(fallback)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
self.0.iter().map(|(k, v)| (k.as_str(), v.as_str()))
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn len(&self) -> usize {
self.0.len()
}
}
#[derive(Debug, Clone, Default)]
pub struct Fields(BTreeMap<String, String>);
impl Fields {
pub fn get(&self, key: &str) -> Option<&str> {
self.0.get(key).map(String::as_str)
}
pub fn text(&self, key: &str) -> &str {
self.get(key).unwrap_or("").trim()
}
pub fn integer(&self, key: &str) -> Result<i64, ApiError> {
let text = self.text(key);
text.parse()
.map_err(|_| Self::refuse(key, text, "a whole number"))
}
pub fn number(&self, key: &str) -> Result<f64, ApiError> {
let text = self.text(key);
match text.parse::<f64>() {
Ok(number) if number.is_finite() => Ok(number),
_ => Err(Self::refuse(key, text, "a number")),
}
}
pub fn date(&self, key: &str) -> Result<&str, ApiError> {
let text = self.text(key);
if is_date(text) {
Ok(text)
} else {
Err(Self::refuse(key, text, "a date, as YYYY-MM-DD"))
}
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
self.0.iter().map(|(k, v)| (k.as_str(), v.as_str()))
}
fn refuse(key: &str, text: &str, wanted: &str) -> ApiError {
if text.is_empty() {
ApiError::bad_request(format!("{key} was left empty, and it wants {wanted}"))
} else {
ApiError::bad_request(format!("{key} is \"{text}\", which is not {wanted}"))
}
}
}
fn is_date(text: &str) -> bool {
let mut parts = text.split('-');
let (Some(year), Some(month), Some(day), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return false;
};
let digits =
|text: &str, width: usize| text.len() == width && text.bytes().all(|b| b.is_ascii_digit());
if !digits(year, 4) || !digits(month, 2) || !digits(day, 2) {
return false;
}
let number = |text: &str| text.parse::<u32>().unwrap_or(0);
(1..=12).contains(&number(month)) && (1..=31).contains(&number(day))
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ViewData {
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<String>,
sections: Vec<Section>,
#[serde(skip_serializing_if = "Vec::is_empty")]
groups: Vec<CardGroup>,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<Detail>,
}
impl ViewData {
pub fn new() -> Self {
Self::default()
}
pub fn note(mut self, note: impl Into<String>) -> Self {
self.note = Some(note.into());
self
}
pub fn section(mut self, section: Section) -> Self {
self.sections.push(section);
self
}
pub fn group(mut self, group: CardGroup) -> Self {
if !group.is_empty() {
self.groups.push(group);
}
self
}
pub fn detail(mut self, detail: Detail) -> Self {
self.detail = Some(detail);
self
}
fn bodies(&self) -> Vec<&'static str> {
let mut bodies = Vec::new();
if !self.sections.is_empty() {
bodies.push("sections of rows");
}
if !self.groups.is_empty() {
bodies.push("groups of cards");
}
if self.detail.is_some() {
bodies.push("one thing in detail");
}
bodies
}
fn one_body(&self, view: &str) -> Result<(), ApiError> {
let bodies = self.bodies();
if bodies.len() > 1 {
return Err(ApiError::server(format!(
"the view \"{view}\" answered with {}; a view answers with one of them",
bodies.join(" and ")
)));
}
Ok(())
}
fn offers(&self, name: &str, args: &ViewArgs) -> bool {
let Some(detail) = &self.detail else {
return false;
};
detail.actions().any(|(action, carried)| {
action == name
&& carried
.iter()
.all(|(key, value)| args.get(key) == Some(value.as_str()))
})
}
fn no_form_answers_a_parameter(&self, view: &str, params: &[Param]) -> Result<(), ApiError> {
let Some(detail) = &self.detail else {
return Ok(());
};
for (action, carried) in detail.actions() {
for key in carried.keys() {
if params.iter().any(|param| param.key() == key) {
return Err(ApiError::server(format!(
"the view \"{view}\" offers the action \"{action}\" with an argument \
keyed \"{key}\", which is one of the view's own parameters; a form's \
arguments are added to the page's, so that form would write about \
another page than the one it is on"
)));
}
}
}
Ok(())
}
}
pub trait ViewLogic: Send + Sync + 'static {
fn name(&self) -> &'static str;
fn title(&self) -> &'static str;
fn in_switcher(&self) -> bool {
true
}
fn params(&self, _ctx: &Context, _asked: &ViewArgs) -> Result<Vec<Param>, ApiError> {
Ok(Vec::new())
}
fn render(&self, args: &ViewArgs, ctx: &Context) -> Result<ViewData, ApiError>;
fn act(
&self,
name: &str,
_fields: &Fields,
_args: &ViewArgs,
_ctx: &Context,
) -> Result<String, ApiError> {
Err(ApiError::server(format!(
"this view has no action called \"{name}\" to write"
)))
}
}
pub trait View: Send + Sync {
fn route(&self) -> &'static str;
fn heading(&self) -> &'static str;
fn listed(&self) -> bool;
fn param_keys(&self, ctx: &Context) -> Result<Vec<String>, ApiError>;
fn handle_get(
&self,
query: &BTreeMap<String, String>,
ctx: &Context,
) -> Result<String, ApiError>;
fn handle_action(
&self,
name: &str,
query: &BTreeMap<String, String>,
body: &str,
ctx: &Context,
) -> Result<String, ApiError>;
}
impl<V: ViewLogic> View for V {
fn route(&self) -> &'static str {
self.name()
}
fn heading(&self) -> &'static str {
self.title()
}
fn listed(&self) -> bool {
self.in_switcher()
}
fn param_keys(&self, ctx: &Context) -> Result<Vec<String>, ApiError> {
Ok(self
.params(ctx, &ViewArgs::default())?
.iter()
.map(|param| param.key().to_string())
.collect())
}
fn handle_get(
&self,
query: &BTreeMap<String, String>,
ctx: &Context,
) -> Result<String, ApiError> {
let params = self.params(ctx, &ViewArgs::from_query(query))?;
let args = ViewArgs::resolve(query, ¶ms);
let data = self.render(&args, ctx)?;
data.one_body(self.name())?;
data.no_form_answers_a_parameter(self.name(), ¶ms)?;
serde_json::to_string(&ViewPayload {
view: self.name(),
title: self.title(),
params,
args,
note: data.note,
sections: data.sections,
groups: data.groups,
detail: data.detail,
})
.map_err(|e| ApiError::server(e.to_string()))
}
fn handle_action(
&self,
name: &str,
query: &BTreeMap<String, String>,
body: &str,
ctx: &Context,
) -> Result<String, ApiError> {
let request: ActionRequest = serde_json::from_str(body)
.map_err(|e| ApiError::bad_request(format!("invalid request body: {e}")))?;
let params = self.params(ctx, &ViewArgs::from_query(query))?;
let args = ViewArgs::resolve(query, ¶ms);
let page = self.render(&args, ctx)?;
page.one_body(self.name())?;
page.no_form_answers_a_parameter(self.name(), ¶ms)?;
if !page.offers(name, &args) {
return Err(ApiError::new(
404,
format!(
"the view \"{}\" offers no action called \"{name}\" about what was asked",
self.name()
),
));
}
let confirmation = self.act(name, &Fields(request.fields), &args, ctx)?;
serde_json::to_string(&ActionReply {
confirmation: &confirmation,
})
.map_err(|e| ApiError::server(e.to_string()))
}
}
#[derive(Serialize)]
struct ViewPayload<'a> {
view: &'a str,
title: &'a str,
params: Vec<Param>,
args: ViewArgs,
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<String>,
sections: Vec<Section>,
#[serde(skip_serializing_if = "Vec::is_empty")]
groups: Vec<CardGroup>,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<Detail>,
}
#[derive(Deserialize)]
struct ActionRequest {
#[serde(default)]
fields: BTreeMap<String, String>,
}
#[derive(Serialize)]
struct ActionReply<'a> {
confirmation: &'a str,
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use super::*;
use crate::fixture;
use crate::page::{
Button, Card, CardGroup, DetailRow, DetailSection, Field, Form, Status, Tone,
};
use crate::schema::Column;
fn query(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect()
}
fn fields(pairs: &[(&str, &str)]) -> Fields {
Fields(
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect(),
)
}
#[test]
fn an_address_that_names_a_parameter_is_taken_at_its_word() {
let params = vec![Param::select("branch", "Branch", ["cen", "est"]).default("cen")];
let args = ViewArgs::resolve(&query(&[("branch", "est")]), ¶ms);
assert_eq!(args.get("branch"), Some("est"));
}
#[test]
fn a_parameter_the_address_leaves_out_falls_back_to_its_default() {
let params = vec![Param::select("branch", "Branch", ["cen"]).default("cen")];
let args = ViewArgs::resolve(&query(&[]), ¶ms);
assert_eq!(args.get("branch"), Some("cen"));
}
#[test]
fn a_parameter_with_no_default_is_simply_absent() {
let params = vec![Param::string("who", "Borrower")];
let args = ViewArgs::resolve(&query(&[]), ¶ms);
assert_eq!(args.get("who"), None);
assert_eq!(args.get_or("who", "anyone"), "anyone");
assert!(args.is_empty());
}
#[test]
fn a_value_the_options_no_longer_offer_falls_back_to_the_default() {
let params = vec![Param::select("subgenre", "Subgenre", ["Memoir"]).default("Memoir")];
let args = ViewArgs::resolve(&query(&[("subgenre", "Natural History")]), ¶ms);
assert_eq!(args.get("subgenre"), Some("Memoir"));
}
#[test]
fn a_value_nothing_offers_and_nothing_replaces_is_left_alone() {
let params = vec![Param::select("subgenre", "Subgenre", ["Memoir"])];
let args = ViewArgs::resolve(&query(&[("subgenre", "Natural History")]), ¶ms);
assert_eq!(args.get("subgenre"), Some("Natural History"));
}
#[test]
fn a_select_that_offers_nothing_takes_whatever_it_is_given() {
let params = vec![Param::select("branch", "Branch", Vec::<String>::new()).default("cen")];
let args = ViewArgs::resolve(&query(&[("branch", "anything")]), ¶ms);
assert_eq!(args.get("branch"), Some("anything"));
}
#[test]
fn a_parameter_cleared_on_purpose_stays_cleared() {
let params = vec![Param::string("who", "Borrower").default("Ada")];
let args = ViewArgs::resolve(&query(&[("who", "")]), ¶ms);
assert_eq!(args.get("who"), Some(""));
}
#[test]
fn a_key_no_parameter_names_is_kept_for_a_view_that_wants_it() {
let params = vec![Param::select("branch", "Branch", ["cen"]).default("cen")];
let args = ViewArgs::resolve(&query(&[("sort", "due")]), ¶ms);
assert_eq!(args.get("sort"), Some("due"));
assert_eq!(args.get("branch"), Some("cen"));
assert_eq!(
args.iter().collect::<Vec<_>>(),
vec![("branch", "cen"), ("sort", "due")]
);
assert_eq!(args.len(), 2);
}
#[test]
fn a_parameter_serializes_to_the_documented_shape() {
let param = Param::select(
"branch",
"Branch",
[SelectOption::labelled("cen", "Central")],
)
.default("cen");
assert_eq!(
serde_json::to_value(¶m).unwrap(),
json!({ "key": "branch", "label": "Branch", "type": "select",
"options": [{ "value": "cen", "label": "Central" }],
"default": "cen" })
);
assert_eq!(
serde_json::to_value(Param::string("who", "Borrower")).unwrap(),
json!({ "key": "who", "label": "Borrower", "type": "string" })
);
}
#[test]
fn a_hidden_parameter_says_so_and_is_settled_like_any_other() {
let param = Param::string("codename", "Codename")
.default("teare")
.hidden();
assert_eq!(
serde_json::to_value(¶m).unwrap(),
json!({ "key": "codename", "label": "Codename", "type": "string",
"default": "teare", "hidden": true })
);
let args = ViewArgs::resolve(&query(&[]), &[param]);
assert_eq!(args.get("codename"), Some("teare"));
}
#[test]
fn a_field_is_read_as_the_kind_of_answer_it_asked_for() {
let filled = fields(&[
("borrower", " Ada Ferreira "),
("days", "21"),
("rating", "4.5"),
("from", "2026-09-21"),
]);
assert_eq!(filled.text("borrower"), "Ada Ferreira");
assert_eq!(filled.integer("days").unwrap(), 21);
assert_eq!(filled.number("rating").unwrap(), 4.5);
assert_eq!(filled.date("from").unwrap(), "2026-09-21");
assert_eq!(filled.get("days"), Some("21"));
assert_eq!(
filled.iter().map(|(k, _)| k).collect::<Vec<_>>(),
vec!["borrower", "days", "from", "rating"]
);
}
#[test]
fn a_letter_is_read_exactly_as_it_was_written() {
let letter = "Dear editor,\r\n\r\n Please find attached.\r\n";
let filled = fields(&[("letter", letter)]);
assert_eq!(filled.get("letter"), Some(letter));
assert_eq!(filled.text("letter"), letter.trim());
}
#[test]
fn a_field_the_form_did_not_carry_reads_as_empty_text() {
let empty = fields(&[]);
assert_eq!(empty.text("borrower"), "");
assert_eq!(empty.get("borrower"), None);
}
#[test]
fn a_field_that_does_not_parse_is_a_bad_request_naming_it() {
let filled = fields(&[
("days", "a fortnight"),
("from", "2026-13-01"),
("empty", ""),
]);
let days = filled.integer("days").unwrap_err();
assert_eq!(days.status, 400);
assert!(days.message.contains("days"), "{}", days.message);
assert!(days.message.contains("a fortnight"), "{}", days.message);
assert_eq!(
fields(&[("days", "4.5")])
.integer("days")
.unwrap_err()
.status,
400
);
assert_eq!(fields(&[("days", "4.5")]).number("days").unwrap(), 4.5);
for text in ["inf", "-inf", "infinity", "NaN", "nan"] {
let refused = fields(&[("rating", text)]).number("rating").unwrap_err();
assert_eq!(refused.status, 400, "{text}");
assert!(refused.message.contains("rating"), "{text}");
}
assert_eq!(filled.date("from").unwrap_err().status, 400);
for bad in [
"",
"2026-9-1",
"26-09-01",
"2026-09-32",
"2026-00-01",
"not a date",
"2026-09-01-02",
] {
assert!(!is_date(bad), "{bad} read as a date");
}
for good in ["2026-09-01", "1999-12-31", "2026-02-30"] {
assert!(is_date(good), "{good} did not read as a date");
}
let empty = filled.integer("empty").unwrap_err();
assert!(empty.message.contains("left empty"), "{}", empty.message);
}
struct Shelf;
impl ViewLogic for Shelf {
fn name(&self) -> &'static str {
"shelf"
}
fn title(&self) -> &'static str {
"Shelf"
}
fn params(&self, _ctx: &Context, _asked: &ViewArgs) -> Result<Vec<Param>, ApiError> {
Ok(vec![
Param::string("body", "Body").default("cards").hidden(),
])
}
fn render(&self, args: &ViewArgs, _ctx: &Context) -> Result<ViewData, ApiError> {
let card = |title: &str| {
Card::new(title.to_string()).status(Status::new("On the shelf", Tone::Good))
};
match args.get_or("body", "cards") {
"cards" => Ok(ViewData::new()
.note("Two of them.")
.group(CardGroup::new("Here").cards([card("Nine Doors"), card("Moss")]))
.group(CardGroup::new("Elsewhere"))),
"detail" => Ok(ViewData::new().detail(
Detail::new("Nine Doors").section(
DetailSection::main("Lend it").row(
DetailRow::new("Central").button(Button::form(
"Lend it out",
Form::new("lend")
.arg("title", "Nine Doors")
.field(Field::number("days", "Days").default(21)),
)),
),
),
)),
"both" => Ok(ViewData::new()
.group(CardGroup::new("Here").card(card("Moss")))
.detail(Detail::new("Moss"))),
_ => Ok(ViewData::new().section(Section::new([Column::string("title", "Title")]))),
}
}
fn act(
&self,
name: &str,
fields: &Fields,
args: &ViewArgs,
_ctx: &Context,
) -> Result<String, ApiError> {
let days = fields.integer("days")?;
Ok(format!(
"{name}: {} is out for {days} days.",
args.get_or("title", "nothing")
))
}
}
fn page(asked: &[(&str, &str)]) -> Value {
let dir = fixture::temp_dir();
let json = Shelf.handle_get(&query(asked), &dir.context()).unwrap();
serde_json::from_str(&json).unwrap()
}
#[test]
fn a_page_of_cards_carries_its_groups_and_no_sections() {
let shown = page(&[("body", "cards")]);
assert_eq!(shown["note"], "Two of them.");
assert_eq!(shown["sections"], json!([]));
assert!(shown.get("detail").is_none());
assert_eq!(shown["groups"][0]["heading"], "Here");
assert_eq!(shown["groups"][0]["cards"][0]["title"], "Nine Doors");
assert_eq!(shown["groups"].as_array().unwrap().len(), 1);
}
#[test]
fn a_page_of_one_thing_carries_a_detail_and_no_groups() {
let shown = page(&[("body", "detail")]);
assert_eq!(shown["detail"]["title"], "Nine Doors");
assert_eq!(shown["sections"], json!([]));
assert!(shown.get("groups").is_none());
}
#[test]
fn a_page_of_rows_still_carries_the_shape_it_always_did() {
let shown = page(&[("body", "rows")]);
assert_eq!(shown["sections"][0]["columns"][0]["field"], "title");
assert!(shown.get("groups").is_none());
assert!(shown.get("detail").is_none());
}
#[test]
fn a_view_that_answers_two_ways_at_once_is_refused() {
let dir = fixture::temp_dir();
let failure = Shelf
.handle_get(&query(&[("body", "both")]), &dir.context())
.unwrap_err();
assert_eq!(failure.status, 500);
assert!(failure.message.contains("shelf"), "{}", failure.message);
assert!(
failure.message.contains("groups of cards")
&& failure.message.contains("one thing in detail"),
"{}",
failure.message
);
}
#[test]
fn an_action_the_page_offers_writes_and_says_what_it_did() {
let dir = fixture::temp_dir();
let json = Shelf
.handle_action(
"lend",
&query(&[("body", "detail"), ("title", "Nine Doors")]),
r#"{"fields":{"days":"14"}}"#,
&dir.context(),
)
.unwrap();
assert_eq!(
serde_json::from_str::<Value>(&json).unwrap(),
json!({ "confirmation": "lend: Nine Doors is out for 14 days." })
);
}
#[test]
fn an_action_the_page_does_not_offer_is_refused() {
let dir = fixture::temp_dir();
let unknown = Shelf
.handle_action(
"burn-it",
&query(&[("body", "detail")]),
r#"{"fields":{}}"#,
&dir.context(),
)
.unwrap_err();
assert_eq!(unknown.status, 404);
assert!(unknown.message.contains("burn-it"), "{}", unknown.message);
let elsewhere = Shelf
.handle_action(
"lend",
&query(&[("body", "cards")]),
r#"{"fields":{"days":"14"}}"#,
&dir.context(),
)
.unwrap_err();
assert_eq!(elsewhere.status, 404);
}
#[test]
fn an_action_about_a_row_the_page_does_not_offer_is_refused() {
let dir = fixture::temp_dir();
let wrong_row = Shelf
.handle_action(
"lend",
&query(&[("body", "detail"), ("title", "Moss")]),
r#"{"fields":{"days":"14"}}"#,
&dir.context(),
)
.unwrap_err();
assert_eq!(wrong_row.status, 404);
assert!(wrong_row.message.contains("lend"), "{}", wrong_row.message);
let no_row = Shelf
.handle_action(
"lend",
&query(&[("body", "detail")]),
r#"{"fields":{"days":"14"}}"#,
&dir.context(),
)
.unwrap_err();
assert_eq!(no_row.status, 404);
}
#[test]
fn a_form_that_answers_one_of_the_views_own_questions_is_refused() {
struct Crossed;
impl ViewLogic for Crossed {
fn name(&self) -> &'static str {
"crossed"
}
fn title(&self) -> &'static str {
"Crossed"
}
fn params(&self, _ctx: &Context, _asked: &ViewArgs) -> Result<Vec<Param>, ApiError> {
Ok(vec![Param::string("body", "Body").default("detail")])
}
fn render(&self, _args: &ViewArgs, _ctx: &Context) -> Result<ViewData, ApiError> {
Ok(ViewData::new().detail(Detail::new("Nine Doors").section(
DetailSection::main("Lend it").row(DetailRow::new("Central").button(
Button::form("Lend it out", Form::new("lend").arg("body", "cards")),
)),
)))
}
}
let dir = fixture::temp_dir();
let rendered = Crossed.handle_get(&query(&[]), &dir.context()).unwrap_err();
assert_eq!(rendered.status, 500);
assert!(rendered.message.contains("crossed"), "{}", rendered.message);
assert!(rendered.message.contains("body"), "{}", rendered.message);
let written = Crossed
.handle_action("lend", &query(&[]), r#"{"fields":{}}"#, &dir.context())
.unwrap_err();
assert_eq!(written.status, 500);
}
#[test]
fn an_action_whose_field_does_not_parse_is_a_bad_request() {
let dir = fixture::temp_dir();
let failure = Shelf
.handle_action(
"lend",
&query(&[("body", "detail"), ("title", "Nine Doors")]),
r#"{"fields":{"days":"a fortnight"}}"#,
&dir.context(),
)
.unwrap_err();
assert_eq!(failure.status, 400);
assert!(failure.message.contains("days"), "{}", failure.message);
}
#[test]
fn an_action_with_an_unreadable_body_is_a_bad_request() {
let dir = fixture::temp_dir();
for body in ["", "not json", r#"{"fields":{"days":14}}"#] {
let failure = Shelf
.handle_action(
"lend",
&query(&[("body", "detail"), ("title", "Nine Doors")]),
body,
&dir.context(),
)
.unwrap_err();
assert_eq!(failure.status, 400, "{body}");
}
}
#[test]
fn a_view_with_no_action_of_its_own_refuses_to_write() {
struct Plain;
impl ViewLogic for Plain {
fn name(&self) -> &'static str {
"plain"
}
fn title(&self) -> &'static str {
"Plain"
}
fn render(&self, _args: &ViewArgs, _ctx: &Context) -> Result<ViewData, ApiError> {
Ok(ViewData::new())
}
}
let dir = fixture::temp_dir();
let refused = Plain
.act("lend", &fields(&[]), &ViewArgs::default(), &dir.context())
.unwrap_err();
assert_eq!(refused.status, 500);
assert!(refused.message.contains("lend"), "{}", refused.message);
}
}