use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::context::Context;
use crate::error::{ApiError, ParseError, ValidationError};
use crate::jsonl;
use crate::schema::{RowLink, Schema};
use crate::view::View;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Front {
FirstTable,
Table(&'static str),
View(&'static str),
}
pub trait App: Send + Sync + 'static {
fn name(&self) -> &str;
fn subtitle(&self) -> Option<&str> {
None
}
fn tables(&self) -> Vec<&dyn Table>;
fn views(&self) -> Vec<&dyn View> {
Vec::new()
}
fn front(&self) -> Front {
Front::FirstTable
}
fn table(&self, route: &str) -> Option<&dyn Table> {
self.tables().into_iter().find(|t| t.route() == route)
}
fn view(&self, route: &str) -> Option<&dyn View> {
self.views().into_iter().find(|v| v.route() == route)
}
}
pub trait TableLogic: Send + Sync + 'static {
type Row: Serialize + DeserializeOwned + Send + Sync;
fn name(&self) -> &'static str;
fn file(&self) -> &'static str;
fn title(&self) -> &'static str;
fn schema(&self, ctx: &Context) -> Result<Schema, ApiError>;
fn parse(&self, text: &str) -> Result<Vec<Self::Row>, ParseError> {
jsonl::parse(text)
}
fn serialize(&self, rows: &[Self::Row]) -> Result<String, serde_json::Error> {
jsonl::serialize(rows)
}
fn validate(&self, rows: &[Self::Row], ctx: &Context)
-> Result<Vec<ValidationError>, ApiError>;
fn derive(
&self,
_rows: &[Self::Row],
_ctx: &Context,
) -> Result<Vec<serde_json::Value>, ApiError> {
Ok(Vec::new())
}
fn siblings(&self, _ctx: &Context) -> Result<serde_json::Value, ApiError> {
Ok(serde_json::json!({}))
}
fn link(&self) -> Option<RowLink> {
None
}
}
pub trait Table: Send + Sync {
fn route(&self) -> &'static str;
fn heading(&self) -> &'static str;
fn data_file(&self) -> &'static str;
fn row_link(&self) -> Option<RowLink>;
fn handle_get(&self, ctx: &Context) -> Result<String, ApiError>;
fn handle_put(&self, ctx: &Context, body: &str) -> Result<String, ApiError>;
fn handle_derive(&self, ctx: &Context, body: &str) -> Result<String, ApiError>;
}
impl<T: TableLogic> Table for T {
fn route(&self) -> &'static str {
self.name()
}
fn heading(&self) -> &'static str {
self.title()
}
fn data_file(&self) -> &'static str {
self.file()
}
fn row_link(&self) -> Option<RowLink> {
self.link()
}
fn handle_get(&self, ctx: &Context) -> Result<String, ApiError> {
let file = self.file();
let text = ctx.read(file)?;
let rows = self
.parse(&text)
.map_err(|e| ApiError::from_parse(file, &e))?;
let mut schema = self.schema(ctx)?;
schema.identify(self.name(), self.title());
schema.link_rows(self.link())?;
to_json(&GetPayload {
schema,
rows: &rows,
derived: self.derive(&rows, ctx)?,
errors: self.validate(&rows, ctx)?,
siblings: self.siblings(ctx)?,
version: ctx.version(file)?,
})
}
fn handle_put(&self, ctx: &Context, body: &str) -> Result<String, ApiError> {
let file = self.file();
let request: RowsRequest<T::Row> = parse_body(body)?;
if let Some(loaded) = request.version.as_deref()
&& loaded != ctx.version(file)?
{
return Err(ApiError::new(
409,
format!("{file} changed on disk after it was loaded; read it again before writing"),
));
}
let text = self
.serialize(&request.rows)
.map_err(|e| ApiError::server(format!("could not serialize {file}: {e}")))?;
let (derived, errors) = self.derivation(ctx, &request.rows)?;
ctx.write(file, &text)?;
to_json(&PutResponse {
derived,
errors,
version: ctx.version(file)?,
})
}
fn handle_derive(&self, ctx: &Context, body: &str) -> Result<String, ApiError> {
let request: RowsRequest<T::Row> = parse_body(body)?;
let (derived, errors) = self.derivation(ctx, &request.rows)?;
to_json(&DeriveResponse { derived, errors })
}
}
trait Derivation: TableLogic {
fn derivation(
&self,
ctx: &Context,
rows: &[Self::Row],
) -> Result<(Vec<serde_json::Value>, Vec<ValidationError>), ApiError> {
Ok((self.derive(rows, ctx)?, self.validate(rows, ctx)?))
}
}
impl<T: TableLogic> Derivation for T {}
#[derive(Deserialize)]
struct RowsRequest<T> {
rows: Vec<T>,
#[serde(default)]
version: Option<String>,
}
#[derive(Serialize)]
struct GetPayload<'a, R> {
schema: Schema,
rows: &'a [R],
derived: Vec<serde_json::Value>,
errors: Vec<ValidationError>,
siblings: serde_json::Value,
version: String,
}
#[derive(Serialize)]
struct PutResponse {
derived: Vec<serde_json::Value>,
errors: Vec<ValidationError>,
version: String,
}
#[derive(Serialize)]
struct DeriveResponse {
derived: Vec<serde_json::Value>,
errors: Vec<ValidationError>,
}
fn parse_body<T: DeserializeOwned>(body: &str) -> Result<RowsRequest<T>, ApiError> {
serde_json::from_str(body)
.map_err(|e| ApiError::bad_request(format!("invalid request body: {e}")))
}
fn to_json<T: Serialize>(value: &T) -> Result<String, ApiError> {
serde_json::to_string(value).map_err(|e| ApiError::server(e.to_string()))
}
#[cfg(test)]
mod tests {
use serde_json::Value;
use super::*;
use crate::fixture::{self, BOOKS_FILE, Book, Books, GENRES_FILE, Genre, Genres};
use crate::schema::Column;
#[test]
fn get_shapes_schema_rows_derived_errors_and_siblings() {
let dir = fixture::temp_dir();
dir.write(GENRES_FILE, fixture::NATURAL_HISTORY);
dir.write(BOOKS_FILE, fixture::MOSS);
let v: Value = serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
assert_eq!(v["rows"].as_array().unwrap().len(), 1);
assert_eq!(v["rows"][0]["title"], "A Field Guide to Moss");
assert_eq!(
v["derived"][0]["shelf"],
"A Field Guide to Moss, Natural History"
);
assert!(v["errors"].as_array().unwrap().is_empty());
assert_eq!(v["siblings"]["genres"][0]["subgenre"], "Natural History");
}
#[test]
fn get_carries_the_view_a_row_links_into() {
struct Linked;
impl TableLogic for Linked {
type Row = Book;
fn name(&self) -> &'static str {
"books"
}
fn file(&self) -> &'static str {
BOOKS_FILE
}
fn title(&self) -> &'static str {
"Books"
}
fn schema(&self, _ctx: &Context) -> Result<Schema, ApiError> {
Ok(Schema::new([Column::string("title", "Title")]))
}
fn validate(
&self,
_rows: &[Book],
_ctx: &Context,
) -> Result<Vec<ValidationError>, ApiError> {
Ok(Vec::new())
}
fn link(&self) -> Option<RowLink> {
Some(RowLink::new("book").arg("title", "title"))
}
}
let dir = fixture::temp_dir();
dir.write(BOOKS_FILE, fixture::MOSS);
let v: Value = serde_json::from_str(&Linked.handle_get(&dir.context()).unwrap()).unwrap();
assert_eq!(
v["schema"]["link"],
serde_json::json!({ "view": "book", "args": { "title": "title" } })
);
let plain: Value =
serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
assert!(plain["schema"].get("link").is_none());
}
#[test]
fn get_names_the_schema_after_the_table() {
let dir = fixture::temp_dir();
dir.write(BOOKS_FILE, fixture::MOSS);
let v: Value = serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
assert_eq!(v["schema"]["table"], Books.route());
assert_eq!(v["schema"]["title"], Books.heading());
}
#[test]
fn get_builds_dependent_options_from_the_sibling_table() {
let dir = fixture::temp_dir();
dir.write(GENRES_FILE, fixture::NATURAL_HISTORY);
dir.write(BOOKS_FILE, fixture::MOSS);
let v: Value = serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
let subgenre = &v["schema"]["columns"][2];
assert_eq!(subgenre["field"], "subgenre");
assert_eq!(subgenre["options_by"]["field"], "genre");
assert_eq!(
subgenre["options_by"]["options"]["Reference"][0]["value"],
"Natural History"
);
assert_eq!(subgenre["width_ch"], 19);
}
#[test]
fn get_cross_checks_against_the_sibling_table() {
let dir = fixture::temp_dir();
dir.write(GENRES_FILE, fixture::NATURAL_HISTORY);
dir.write(
BOOKS_FILE,
r#"{"title":"A Field Guide to Moss","genre":"Reference","subgenre":"Field Guides"}"#,
);
let v: Value = serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
assert!(v["errors"].as_array().unwrap().iter().any(|e| {
e["field"] == "subgenre" && e["message"].as_str().unwrap().contains("not found")
}));
}
#[test]
fn get_skips_cross_checks_when_the_sibling_file_is_missing() {
let dir = fixture::temp_dir();
dir.write(
BOOKS_FILE,
r#"{"title":"A Field Guide to Moss","genre":"Reference","subgenre":"Field Guides"}"#,
);
let v: Value = serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
assert!(v["errors"].as_array().unwrap().is_empty());
assert!(v["siblings"]["genres"].as_array().unwrap().is_empty());
}
#[test]
fn get_of_a_plain_table_has_no_derivation_and_no_siblings() {
let dir = fixture::temp_dir();
dir.write(GENRES_FILE, fixture::NATURAL_HISTORY);
let v: Value = serde_json::from_str(&Genres.handle_get(&dir.context()).unwrap()).unwrap();
assert_eq!(v["rows"][0]["subgenre"], "Natural History");
assert!(v["derived"].as_array().unwrap().is_empty());
assert!(v["siblings"].as_object().unwrap().is_empty());
}
#[test]
fn one_request_sees_one_version_of_a_sibling_table() {
let dir = fixture::temp_dir();
dir.write(GENRES_FILE, fixture::NATURAL_HISTORY);
dir.write(BOOKS_FILE, fixture::MOSS);
let ctx = dir.context();
let first: Value = serde_json::from_str(&Books.handle_get(&ctx).unwrap()).unwrap();
assert!(first["errors"].as_array().unwrap().is_empty());
dir.write(
GENRES_FILE,
r#"{"genre":"Travel","subgenre":"Field Guides"}"#,
);
let again: Value = serde_json::from_str(&Books.handle_get(&ctx).unwrap()).unwrap();
assert_eq!(again["schema"], first["schema"]);
assert_eq!(again["siblings"], first["siblings"]);
assert!(again["errors"].as_array().unwrap().is_empty());
let later: Value =
serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
assert_eq!(later["siblings"]["genres"][0]["genre"], "Travel");
assert!(!later["errors"].as_array().unwrap().is_empty());
}
#[test]
fn get_of_a_missing_file_is_a_server_error() {
let dir = fixture::temp_dir();
assert_eq!(Books.handle_get(&dir.context()).unwrap_err().status, 500);
}
#[test]
fn put_writes_the_file_and_returns_the_derivation() {
let dir = fixture::temp_dir();
dir.write(GENRES_FILE, fixture::NATURAL_HISTORY);
let body = format!(r#"{{"rows":[{}]}}"#, fixture::MOSS);
let json = Books.handle_put(&dir.context(), &body).unwrap();
let v: Value = serde_json::from_str(&json).unwrap();
assert_eq!(
v["derived"][0]["shelf"],
"A Field Guide to Moss, Natural History"
);
assert!(v["errors"].as_array().unwrap().is_empty());
let written = dir.read(BOOKS_FILE);
assert!(written.contains("A Field Guide to Moss"));
assert!(written.ends_with('\n'));
}
#[test]
fn put_writes_even_with_validation_errors() {
let dir = fixture::temp_dir();
let body = r#"{"rows":[{"title":"","genre":"Reference","subgenre":"Natural History"}]}"#;
let json = Books.handle_put(&dir.context(), body).unwrap();
let v: Value = serde_json::from_str(&json).unwrap();
assert!(!v["errors"].as_array().unwrap().is_empty());
assert!(dir.path().join(BOOKS_FILE).exists());
}
#[test]
fn derive_does_not_write() {
let dir = fixture::temp_dir();
let body = format!(r#"{{"rows":[{}]}}"#, fixture::MOSS);
let json = Books.handle_derive(&dir.context(), &body).unwrap();
let v: Value = serde_json::from_str(&json).unwrap();
assert_eq!(
v["derived"][0]["shelf"],
"A Field Guide to Moss, Natural History"
);
assert!(!dir.path().join(BOOKS_FILE).exists());
}
#[test]
fn put_round_trips_through_get() {
let dir = fixture::temp_dir();
let body = format!(r#"{{"rows":[{}]}}"#, fixture::NATURAL_HISTORY);
Genres.handle_put(&dir.context(), &body).unwrap();
let v: Value = serde_json::from_str(&Genres.handle_get(&dir.context()).unwrap()).unwrap();
assert_eq!(v["rows"][0]["genre"], "Reference");
assert_eq!(v["rows"][0]["subgenre"], "Natural History");
}
fn refusal(body: &str) -> Option<u16> {
parse_body::<Book>(body).err().map(|e| e.status)
}
#[test]
fn parse_body_rejects_a_malformed_body() {
assert_eq!(refusal("not json"), Some(400));
}
#[test]
fn parse_body_rejects_a_body_without_rows() {
assert_eq!(refusal("{}"), Some(400));
}
#[test]
fn a_body_that_states_no_version_states_none() {
let body = format!(r#"{{"rows":[{}]}}"#, fixture::MOSS);
assert!(parse_body::<Book>(&body).unwrap().version.is_none());
let stated = format!(
r#"{{"rows":[{}],"version":"0123456789abcdef"}}"#,
fixture::MOSS
);
assert_eq!(
parse_body::<Book>(&stated).unwrap().version.as_deref(),
Some("0123456789abcdef")
);
}
#[test]
fn get_sends_the_version_of_the_file_it_read() {
let dir = fixture::temp_dir();
dir.write(BOOKS_FILE, fixture::MOSS);
let v: Value = serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
assert_eq!(
v["version"],
dir.context().version(BOOKS_FILE).unwrap().as_str()
);
}
#[test]
fn put_refuses_rows_read_before_the_file_changed() {
let dir = fixture::temp_dir();
dir.write(BOOKS_FILE, fixture::MOSS);
let read_at = dir.context().version(BOOKS_FILE).unwrap();
let outside = r#"{"title":"The Harbour Road","genre":"Travel","subgenre":"Field Guides"}"#;
dir.write(BOOKS_FILE, outside);
let body = format!(r#"{{"rows":[{}],"version":"{read_at}"}}"#, fixture::MOSS);
let err = Books.handle_put(&dir.context(), &body).unwrap_err();
assert_eq!(err.status, 409);
assert!(err.message.contains("changed on disk"), "{}", err.message);
assert!(dir.read(BOOKS_FILE).contains("The Harbour Road"));
}
#[test]
fn put_takes_the_version_the_get_sent_and_answers_with_the_next_one() {
let dir = fixture::temp_dir();
dir.write(BOOKS_FILE, fixture::MOSS);
let got: Value = serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
let read_at = got["version"].as_str().unwrap();
let body = format!(
r#"{{"rows":[{}],"version":"{read_at}"}}"#,
r#"{"title":"The Harbour Road","genre":"Travel","subgenre":"Field Guides"}"#
);
let put: Value =
serde_json::from_str(&Books.handle_put(&dir.context(), &body).unwrap()).unwrap();
let written = dir.context().version(BOOKS_FILE).unwrap();
assert_eq!(put["version"], written.as_str());
assert_ne!(put["version"], read_at);
let again = format!(r#"{{"rows":[{}],"version":"{written}"}}"#, fixture::MOSS);
assert!(Books.handle_put(&dir.context(), &again).is_ok());
}
#[test]
fn put_without_a_version_writes_whatever_the_file_holds() {
let dir = fixture::temp_dir();
dir.write(
BOOKS_FILE,
r#"{"title":"The Harbour Road","genre":"Travel","subgenre":"Field Guides"}"#,
);
let body = format!(r#"{{"rows":[{}]}}"#, fixture::MOSS);
assert!(Books.handle_put(&dir.context(), &body).is_ok());
assert!(dir.read(BOOKS_FILE).contains("A Field Guide to Moss"));
}
#[test]
fn put_refuses_rows_read_from_a_file_since_deleted() {
let dir = fixture::temp_dir();
dir.write(BOOKS_FILE, fixture::MOSS);
let read_at = dir.context().version(BOOKS_FILE).unwrap();
std::fs::remove_file(dir.path().join(BOOKS_FILE)).unwrap();
let body = format!(r#"{{"rows":[{}],"version":"{read_at}"}}"#, fixture::MOSS);
assert_eq!(
Books.handle_put(&dir.context(), &body).unwrap_err().status,
409
);
assert!(!dir.path().join(BOOKS_FILE).exists());
}
#[test]
fn put_creates_a_table_for_rows_read_from_a_file_that_was_not_there() {
let dir = fixture::temp_dir();
let absent = dir.context().version(BOOKS_FILE).unwrap();
let body = format!(r#"{{"rows":[{}],"version":"{absent}"}}"#, fixture::MOSS);
let put: Value =
serde_json::from_str(&Books.handle_put(&dir.context(), &body).unwrap()).unwrap();
assert!(dir.read(BOOKS_FILE).contains("A Field Guide to Moss"));
assert_eq!(
put["version"],
dir.context().version(BOOKS_FILE).unwrap().as_str()
);
assert_ne!(put["version"], absent.as_str());
}
#[test]
fn put_of_the_same_rows_again_leaves_the_version_where_it_was() {
let dir = fixture::temp_dir();
dir.write(BOOKS_FILE, fixture::MOSS);
let read_at = dir.context().version(BOOKS_FILE).unwrap();
let body = format!(r#"{{"rows":[{}],"version":"{read_at}"}}"#, fixture::MOSS);
let put: Value =
serde_json::from_str(&Books.handle_put(&dir.context(), &body).unwrap()).unwrap();
assert_eq!(put["version"], read_at.as_str());
let again = format!(r#"{{"rows":[{}],"version":"{read_at}"}}"#, fixture::MOSS);
assert!(Books.handle_put(&dir.context(), &again).is_ok());
}
#[test]
fn put_leaves_the_file_alone_when_the_derivation_fails() {
struct Brittle;
impl TableLogic for Brittle {
type Row = Genre;
fn name(&self) -> &'static str {
"brittle"
}
fn file(&self) -> &'static str {
GENRES_FILE
}
fn title(&self) -> &'static str {
"Brittle"
}
fn schema(&self, _ctx: &Context) -> Result<Schema, ApiError> {
Ok(Schema::new([Column::string("genre", "Genre")]))
}
fn validate(
&self,
_rows: &[Genre],
_ctx: &Context,
) -> Result<Vec<ValidationError>, ApiError> {
Ok(Vec::new())
}
fn derive(
&self,
_rows: &[Genre],
_ctx: &Context,
) -> Result<Vec<serde_json::Value>, ApiError> {
Err(ApiError::server("the almanac is unreadable"))
}
}
let dir = fixture::temp_dir();
dir.write(GENRES_FILE, fixture::NATURAL_HISTORY);
let read_at = dir.context().version(GENRES_FILE).unwrap();
let body = format!(
r#"{{"rows":[{}],"version":"{read_at}"}}"#,
r#"{"genre":"Travel","subgenre":"Memoir"}"#
);
assert_eq!(
Brittle
.handle_put(&dir.context(), &body)
.unwrap_err()
.status,
500
);
assert!(dir.read(GENRES_FILE).contains("Natural History"));
assert_eq!(dir.context().version(GENRES_FILE).unwrap(), read_at);
}
#[test]
fn derive_answers_with_no_version() {
let dir = fixture::temp_dir();
let body = format!(r#"{{"rows":[{}]}}"#, fixture::MOSS);
let v: Value =
serde_json::from_str(&Books.handle_derive(&dir.context(), &body).unwrap()).unwrap();
assert!(v["version"].is_null());
let stated = format!(r#"{{"rows":[{}],"version":"nonsense"}}"#, fixture::MOSS);
assert!(Books.handle_derive(&dir.context(), &stated).is_ok());
}
#[test]
fn every_computed_column_names_a_key_the_derivation_emits() {
let dir = fixture::temp_dir();
dir.write(BOOKS_FILE, fixture::MOSS);
let v: Value = serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
let derived = v["derived"][0].as_object().unwrap();
let mut checked = 0;
for column in v["schema"]["columns"].as_array().unwrap() {
if column["type"] == "computed" {
let from = column["from"].as_str().unwrap();
assert!(derived.contains_key(from), "derivation lacks {from}");
checked += 1;
}
}
assert!(checked > 0, "the fixture has no computed column to check");
}
}