use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::BTreeMap;
#[derive(Deserialize)]
pub struct Request {
pub action: String,
pub project: String,
#[serde(default)]
pub settings: Map<String, Value>,
#[serde(default)]
pub input: Map<String, Value>,
#[serde(default)]
pub context: Context,
#[serde(default)]
pub button: Option<String>,
#[serde(default)]
pub changed: Option<String>,
#[serde(default)]
pub state: Option<Value>,
#[serde(default)]
pub resumed: bool,
#[serde(default)]
pub locale: Option<String>,
}
impl Request {
pub fn input<T: DeserializeOwned>(&self) -> crate::Result<T> {
Ok(serde_json::from_value(Value::Object(self.input.clone()))?)
}
pub fn state<T: DeserializeOwned>(&self) -> Option<T> {
self.state.as_ref().and_then(|state| T::deserialize(state).ok())
}
}
#[derive(Deserialize)]
pub struct Context {
pub place: String,
#[serde(default)]
pub id: Option<i64>,
}
impl Default for Context {
fn default() -> Self {
Context { place: "project".into(), id: None }
}
}
#[derive(Serialize, Default)]
pub struct Reply {
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<Page>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub errors: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub open: Option<Open>,
}
impl Reply {
pub fn message(message: impl Into<String>) -> Reply {
Reply { message: Some(message.into()), ..Reply::default() }
}
pub fn document(markdown: impl Into<String>) -> Reply {
Reply::page(Page { markdown: Some(markdown.into()), ..Page::default() })
}
pub fn page(page: Page) -> Reply {
Reply { page: Some(page), ..Reply::default() }
}
pub fn error(key: impl Into<String>, message: impl Into<String>) -> Reply {
Reply { errors: BTreeMap::from([(key.into(), message.into())]), ..Reply::default() }
}
pub fn open(open: Open) -> Reply {
Reply { open: Some(open), ..Reply::default() }
}
pub fn to_json(&self) -> crate::Result<String> {
Ok(serde_json::to_string(self)?)
}
}
#[derive(Serialize, Default)]
pub struct Page {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub markdown: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<Field>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub buttons: Vec<Button>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<Value>,
}
impl Page {
pub fn with_state(mut self, state: &impl Serialize) -> crate::Result<Page> {
self.state = Some(serde_json::to_value(state)?);
Ok(self)
}
}
#[derive(Serialize, Clone)]
pub struct Field {
pub key: String,
#[serde(rename = "type")]
pub kind: FieldKind,
pub label: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max: Option<i64>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub options: Vec<Choice>,
#[serde(skip_serializing_if = "TextFormat::is_plain")]
pub format: TextFormat,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub multiline: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub kinds: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub entry_types: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub multiple: Option<bool>,
}
#[derive(Serialize, Clone, Copy, PartialEq, Eq, Debug)]
#[serde(rename_all = "lowercase")]
pub enum FieldKind {
Text,
Bool,
Int,
Choice,
Scenes,
Entity,
}
#[derive(Serialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
#[serde(rename_all = "kebab-case")]
pub enum TextFormat {
#[default]
Plain,
Rich,
RichExtended,
}
impl TextFormat {
fn is_plain(&self) -> bool {
*self == TextFormat::Plain
}
}
#[derive(Serialize, Clone)]
pub struct Choice {
pub value: String,
pub label: String,
}
impl Choice {
pub fn new(value: impl Into<String>, label: impl Into<String>) -> Choice {
Choice { value: value.into(), label: label.into() }
}
}
impl Field {
fn new(key: impl Into<String>, kind: FieldKind, label: impl Into<String>) -> Field {
Field {
key: key.into(),
kind,
label: label.into(),
hint: None,
required: false,
default: None,
min: None,
max: None,
options: Vec::new(),
format: TextFormat::Plain,
multiline: false,
kinds: Vec::new(),
entry_types: Vec::new(),
tags: Vec::new(),
multiple: None,
}
}
pub fn text(key: impl Into<String>, label: impl Into<String>) -> Field {
Field::new(key, FieldKind::Text, label)
}
pub fn bool(key: impl Into<String>, label: impl Into<String>) -> Field {
Field::new(key, FieldKind::Bool, label)
}
pub fn int(key: impl Into<String>, label: impl Into<String>, min: Option<i64>, max: Option<i64>) -> Field {
Field { min, max, ..Field::new(key, FieldKind::Int, label) }
}
pub fn choice(key: impl Into<String>, label: impl Into<String>, options: Vec<Choice>) -> Field {
Field { options, ..Field::new(key, FieldKind::Choice, label) }
}
pub fn scenes(key: impl Into<String>, label: impl Into<String>) -> Field {
Field::new(key, FieldKind::Scenes, label)
}
pub fn entity(key: impl Into<String>, label: impl Into<String>) -> Field {
Field::new(key, FieldKind::Entity, label)
}
pub fn hint(mut self, hint: impl Into<String>) -> Field {
self.hint = Some(hint.into());
self
}
pub fn required(mut self) -> Field {
self.required = true;
self
}
pub fn default(mut self, value: impl Into<Value>) -> Field {
self.default = Some(value.into());
self
}
pub fn format(mut self, format: TextFormat) -> Field {
self.format = format;
self
}
pub fn multiline(mut self) -> Field {
self.multiline = true;
self
}
pub fn single(mut self) -> Field {
self.multiple = (self.kind != FieldKind::Entity).then_some(false);
self
}
pub fn multiple(mut self) -> Field {
self.multiple = (self.kind != FieldKind::Scenes).then_some(true);
self
}
pub fn kinds(mut self, kinds: &[&str]) -> Field {
self.kinds = kinds.iter().map(|it| it.to_string()).collect();
self
}
pub fn entry_types(mut self, types: &[&str]) -> Field {
self.entry_types = types.iter().map(|it| it.to_string()).collect();
self
}
pub fn tags(mut self, tags: &[&str]) -> Field {
self.tags = tags.iter().map(|it| it.to_string()).collect();
self
}
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub struct EntityRef {
pub kind: String,
pub id: i64,
}
impl EntityRef {
pub fn new(kind: impl Into<String>, id: i64) -> EntityRef {
EntityRef { kind: kind.into(), id }
}
}
impl From<EntityRef> for Value {
fn from(entity: EntityRef) -> Value {
serde_json::json!({"kind": entity.kind, "id": entity.id})
}
}
#[derive(Serialize, Clone)]
pub struct Button {
pub id: String,
pub label: String,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub primary: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub inline: bool,
#[serde(skip_serializing_if = "is_true")]
pub validate: bool,
}
impl Button {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Button {
Button { id: id.into(), label: label.into(), primary: false, inline: false, validate: true }
}
pub fn primary(mut self) -> Button {
self.primary = true;
self
}
pub fn inline(mut self) -> Button {
self.inline = true;
self
}
pub fn skip_checks(mut self) -> Button {
self.validate = false;
self
}
}
#[derive(Serialize, Clone)]
pub struct Open {
pub place: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<i64>,
}
impl Open {
pub fn new(place: impl Into<String>, id: Option<i64>) -> Open {
Open { place: place.into(), id }
}
}
fn is_true(value: &bool) -> bool {
*value
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn replies_leave_out_what_is_unset() {
assert_eq!(Reply::default().to_json().unwrap(), "{}");
assert_eq!(Reply::message("Hi").to_json().unwrap(), r#"{"message":"Hi"}"#);
assert_eq!(Reply::document("# Hi").to_json().unwrap(), r##"{"page":{"markdown":"# Hi"}}"##);
assert_eq!(Reply::error("", "No").to_json().unwrap(), r#"{"errors":{"":"No"}}"#);
let open = Reply { message: Some("Added".into()), ..Reply::open(Open::new("entry", Some(12))) };
assert_eq!(open.to_json().unwrap(), r#"{"message":"Added","open":{"place":"entry","id":12}}"#);
}
#[test]
fn a_page_has_its_fields_buttons_and_state() {
#[derive(Serialize)]
struct Step {
step: u32,
}
let page = Page {
title: Some("Who?".into()),
fields: vec![
Field::text("name", "Name").required(),
Field::text("want", "Wants").format(TextFormat::RichExtended).hint("A line"),
Field::text("notes", "Notes").multiline(),
Field::choice("set", "Set", vec![Choice::new("quick", "Quick")]).default("quick"),
Field::int("count", "Count", Some(1), None).default(8),
Field::bool("all", "All"),
Field::scenes("scene", "Scene").single(),
],
buttons: vec![Button::new("back", "Back").skip_checks(), Button::new("next", "Next").primary().inline()],
..Page::default()
}
.with_state(&Step { step: 2 })
.unwrap();
assert_eq!(
serde_json::to_value(Reply::page(page)).unwrap(),
json!({"page": {
"title": "Who?",
"fields": [
{"key": "name", "type": "text", "label": "Name", "required": true},
{"key": "want", "type": "text", "label": "Wants", "hint": "A line", "format": "rich-extended"},
{"key": "notes", "type": "text", "label": "Notes", "multiline": true},
{"key": "set", "type": "choice", "label": "Set", "default": "quick",
"options": [{"value": "quick", "label": "Quick"}]},
{"key": "count", "type": "int", "label": "Count", "default": 8, "min": 1},
{"key": "all", "type": "bool", "label": "All"},
{"key": "scene", "type": "scenes", "label": "Scene", "multiple": false},
],
"buttons": [
{"id": "back", "label": "Back", "validate": false},
{"id": "next", "label": "Next", "primary": true, "inline": true},
],
"state": {"step": 2},
}})
);
}
#[test]
fn a_request_carries_state() {
#[derive(Deserialize)]
struct Step {
step: u32,
}
let request: Request = serde_json::from_value(json!({
"action": "wave", "project": "Storm", "settings": {}, "locale": null,
"context": {"place": "project", "id": null}, "input": {}, "button": "next",
"changed": null, "state": {"step": 2}, "resumed": true,
}))
.unwrap();
assert_eq!(request.state::<Step>().map(|it| it.step), Some(2));
assert!(request.state::<String>().is_none());
assert!(request.resumed);
let first: Request = serde_json::from_value(json!({"action": "wave", "project": "Storm", "state": null})).unwrap();
assert!(first.state.is_none() && !first.resumed && first.button.is_none());
}
#[test]
fn an_entity_is_one_item_unless_multiple() {
let mara = EntityRef::new("entry", 12);
let fields = vec![
Field::entity("who", "Who"),
Field::entity("a", "First")
.kinds(&["entry"])
.entry_types(&["person"])
.tags(&["pov"])
.required()
.default(mara.clone()),
Field::entity("cast", "Cast").multiple().default(vec![mara.clone(), EntityRef::new("scene", 3)]),
Field::entity("one", "One").multiple().single(),
Field::scenes("scenes", "Scenes").single().multiple(),
];
assert_eq!(
serde_json::to_value(&fields).unwrap(),
json!([
{"key": "who", "type": "entity", "label": "Who"},
{"key": "a", "type": "entity", "label": "First", "required": true,
"default": {"kind": "entry", "id": 12},
"kinds": ["entry"], "entry_types": ["person"], "tags": ["pov"]},
{"key": "cast", "type": "entity", "label": "Cast",
"default": [{"kind": "entry", "id": 12}, {"kind": "scene", "id": 3}], "multiple": true},
{"key": "one", "type": "entity", "label": "One"},
{"key": "scenes", "type": "scenes", "label": "Scenes"},
])
);
}
#[test]
fn entity_input_reads_as_entity_refs() {
#[derive(Deserialize)]
struct Picks {
who: Option<EntityRef>,
#[serde(default)]
cast: Vec<EntityRef>,
}
let request = |input: Value| -> Request {
serde_json::from_value(json!({"action": "meet", "project": "Storm", "input": input})).unwrap()
};
let picked: Picks = request(json!({"who": {"kind": "entry", "id": 12}, "cast": [{"kind": "event", "id": 4}]}))
.input()
.unwrap();
assert_eq!(picked.who, Some(EntityRef::new("entry", 12)));
assert_eq!(picked.cast, vec![EntityRef::new("event", 4)]);
let none: Picks = request(json!({"who": null, "cast": []})).input().unwrap();
assert!(none.who.is_none() && none.cast.is_empty());
}
}