1use serde::de::DeserializeOwned;
11use serde::{Deserialize, Serialize};
12
13use crate::context::Context;
14use crate::error::{ApiError, ParseError, ValidationError};
15use crate::jsonl;
16use crate::schema::Schema;
17use crate::view::View;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Front {
22 FirstTable,
25 Table(&'static str),
26 View(&'static str),
27}
28
29pub trait App: Send + Sync + 'static {
32 fn name(&self) -> &str;
33
34 fn subtitle(&self) -> Option<&str> {
35 None
36 }
37
38 fn tables(&self) -> Vec<&dyn Table>;
41
42 fn views(&self) -> Vec<&dyn View> {
45 Vec::new()
46 }
47
48 fn front(&self) -> Front {
50 Front::FirstTable
51 }
52
53 fn table(&self, route: &str) -> Option<&dyn Table> {
54 self.tables().into_iter().find(|t| t.route() == route)
55 }
56
57 fn view(&self, route: &str) -> Option<&dyn View> {
58 self.views().into_iter().find(|v| v.route() == route)
59 }
60}
61
62pub trait TableLogic: Send + Sync + 'static {
69 type Row: Serialize + DeserializeOwned + Send + Sync;
70
71 fn name(&self) -> &'static str;
76
77 fn file(&self) -> &'static str;
84
85 fn title(&self) -> &'static str;
87
88 fn schema(&self, ctx: &Context) -> Result<Schema, ApiError>;
91
92 fn parse(&self, text: &str) -> Result<Vec<Self::Row>, ParseError> {
93 jsonl::parse(text)
94 }
95
96 fn serialize(&self, rows: &[Self::Row]) -> Result<String, serde_json::Error> {
97 jsonl::serialize(rows)
98 }
99
100 fn validate(&self, rows: &[Self::Row], ctx: &Context)
104 -> Result<Vec<ValidationError>, ApiError>;
105
106 fn derive(
110 &self,
111 _rows: &[Self::Row],
112 _ctx: &Context,
113 ) -> Result<Vec<serde_json::Value>, ApiError> {
114 Ok(Vec::new())
115 }
116
117 fn siblings(&self, _ctx: &Context) -> Result<serde_json::Value, ApiError> {
120 Ok(serde_json::json!({}))
121 }
122}
123
124pub trait Table: Send + Sync {
127 fn route(&self) -> &'static str;
129
130 fn heading(&self) -> &'static str;
132
133 fn data_file(&self) -> &'static str;
135
136 fn handle_get(&self, ctx: &Context) -> Result<String, ApiError>;
139
140 fn handle_put(&self, ctx: &Context, body: &str) -> Result<String, ApiError>;
143
144 fn handle_derive(&self, ctx: &Context, body: &str) -> Result<String, ApiError>;
147}
148
149impl<T: TableLogic> Table for T {
150 fn route(&self) -> &'static str {
151 self.name()
152 }
153
154 fn heading(&self) -> &'static str {
155 self.title()
156 }
157
158 fn data_file(&self) -> &'static str {
159 self.file()
160 }
161
162 fn handle_get(&self, ctx: &Context) -> Result<String, ApiError> {
163 let file = self.file();
164 let text = ctx.read(file)?;
165 let rows = self
166 .parse(&text)
167 .map_err(|e| ApiError::from_parse(file, &e))?;
168
169 let mut schema = self.schema(ctx)?;
170 schema.identify(self.name(), self.title());
171
172 to_json(&GetPayload {
173 schema,
174 rows: &rows,
175 derived: self.derive(&rows, ctx)?,
176 errors: self.validate(&rows, ctx)?,
177 siblings: self.siblings(ctx)?,
178 })
179 }
180
181 fn handle_put(&self, ctx: &Context, body: &str) -> Result<String, ApiError> {
182 let file = self.file();
183 let rows: Vec<T::Row> = parse_rows(body)?;
184 let text = self
185 .serialize(&rows)
186 .map_err(|e| ApiError::server(format!("could not serialize {file}: {e}")))?;
187 ctx.write(file, &text)?;
188 self.derive_payload(ctx, &rows)
189 }
190
191 fn handle_derive(&self, ctx: &Context, body: &str) -> Result<String, ApiError> {
192 let rows: Vec<T::Row> = parse_rows(body)?;
193 self.derive_payload(ctx, &rows)
194 }
195}
196
197trait DerivePayload: TableLogic {
200 fn derive_payload(&self, ctx: &Context, rows: &[Self::Row]) -> Result<String, ApiError> {
201 to_json(&DeriveResponse {
202 derived: self.derive(rows, ctx)?,
203 errors: self.validate(rows, ctx)?,
204 })
205 }
206}
207
208impl<T: TableLogic> DerivePayload for T {}
209
210#[derive(Deserialize)]
212struct RowsRequest<T> {
213 rows: Vec<T>,
214}
215
216#[derive(Serialize)]
218struct GetPayload<'a, R> {
219 schema: Schema,
220 rows: &'a [R],
221 derived: Vec<serde_json::Value>,
222 errors: Vec<ValidationError>,
223 siblings: serde_json::Value,
224}
225
226#[derive(Serialize)]
228struct DeriveResponse {
229 derived: Vec<serde_json::Value>,
230 errors: Vec<ValidationError>,
231}
232
233fn parse_rows<T: DeserializeOwned>(body: &str) -> Result<Vec<T>, ApiError> {
235 let request: RowsRequest<T> = serde_json::from_str(body)
236 .map_err(|e| ApiError::bad_request(format!("invalid request body: {e}")))?;
237 Ok(request.rows)
238}
239
240fn to_json<T: Serialize>(value: &T) -> Result<String, ApiError> {
242 serde_json::to_string(value).map_err(|e| ApiError::server(e.to_string()))
243}
244
245#[cfg(test)]
246mod tests {
247 use serde_json::Value;
248
249 use super::*;
250 use crate::fixture::{self, BOOKS_FILE, Book, Books, GENRES_FILE, Genres};
251
252 #[test]
253 fn get_shapes_schema_rows_derived_errors_and_siblings() {
254 let dir = fixture::temp_dir();
255 dir.write(GENRES_FILE, fixture::NATURAL_HISTORY);
256 dir.write(BOOKS_FILE, fixture::MOSS);
257
258 let v: Value = serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
259
260 assert_eq!(v["rows"].as_array().unwrap().len(), 1);
261 assert_eq!(v["rows"][0]["title"], "A Field Guide to Moss");
262 assert_eq!(
263 v["derived"][0]["shelf"],
264 "A Field Guide to Moss, Natural History"
265 );
266 assert!(v["errors"].as_array().unwrap().is_empty());
267 assert_eq!(v["siblings"]["genres"][0]["subgenre"], "Natural History");
268 }
269
270 #[test]
271 fn get_names_the_schema_after_the_table() {
272 let dir = fixture::temp_dir();
273 dir.write(BOOKS_FILE, fixture::MOSS);
274
275 let v: Value = serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
276 assert_eq!(v["schema"]["table"], Books.route());
277 assert_eq!(v["schema"]["title"], Books.heading());
278 }
279
280 #[test]
281 fn get_builds_dependent_options_from_the_sibling_table() {
282 let dir = fixture::temp_dir();
283 dir.write(GENRES_FILE, fixture::NATURAL_HISTORY);
284 dir.write(BOOKS_FILE, fixture::MOSS);
285
286 let v: Value = serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
287 let subgenre = &v["schema"]["columns"][2];
288
289 assert_eq!(subgenre["field"], "subgenre");
290 assert_eq!(subgenre["options_by"]["field"], "genre");
291 assert_eq!(
292 subgenre["options_by"]["options"]["Reference"][0]["value"],
293 "Natural History"
294 );
295 assert_eq!(subgenre["width_ch"], 19);
297 }
298
299 #[test]
300 fn get_cross_checks_against_the_sibling_table() {
301 let dir = fixture::temp_dir();
302 dir.write(GENRES_FILE, fixture::NATURAL_HISTORY);
303 dir.write(
304 BOOKS_FILE,
305 r#"{"title":"A Field Guide to Moss","genre":"Reference","subgenre":"Field Guides"}"#,
306 );
307
308 let v: Value = serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
309 assert!(v["errors"].as_array().unwrap().iter().any(|e| {
310 e["field"] == "subgenre" && e["message"].as_str().unwrap().contains("not found")
311 }));
312 }
313
314 #[test]
315 fn get_skips_cross_checks_when_the_sibling_file_is_missing() {
316 let dir = fixture::temp_dir();
317 dir.write(
318 BOOKS_FILE,
319 r#"{"title":"A Field Guide to Moss","genre":"Reference","subgenre":"Field Guides"}"#,
320 );
321
322 let v: Value = serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
323 assert!(v["errors"].as_array().unwrap().is_empty());
324 assert!(v["siblings"]["genres"].as_array().unwrap().is_empty());
325 }
326
327 #[test]
328 fn get_of_a_plain_table_has_no_derivation_and_no_siblings() {
329 let dir = fixture::temp_dir();
330 dir.write(GENRES_FILE, fixture::NATURAL_HISTORY);
331
332 let v: Value = serde_json::from_str(&Genres.handle_get(&dir.context()).unwrap()).unwrap();
333
334 assert_eq!(v["rows"][0]["subgenre"], "Natural History");
335 assert!(v["derived"].as_array().unwrap().is_empty());
336 assert!(v["siblings"].as_object().unwrap().is_empty());
337 }
338
339 #[test]
340 fn one_request_sees_one_version_of_a_sibling_table() {
341 let dir = fixture::temp_dir();
342 dir.write(GENRES_FILE, fixture::NATURAL_HISTORY);
343 dir.write(BOOKS_FILE, fixture::MOSS);
344 let ctx = dir.context();
345
346 let first: Value = serde_json::from_str(&Books.handle_get(&ctx).unwrap()).unwrap();
347 assert!(first["errors"].as_array().unwrap().is_empty());
348
349 dir.write(
353 GENRES_FILE,
354 r#"{"genre":"Travel","subgenre":"Field Guides"}"#,
355 );
356 let again: Value = serde_json::from_str(&Books.handle_get(&ctx).unwrap()).unwrap();
357 assert_eq!(again["schema"], first["schema"]);
358 assert_eq!(again["siblings"], first["siblings"]);
359 assert!(again["errors"].as_array().unwrap().is_empty());
360
361 let later: Value =
363 serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
364 assert_eq!(later["siblings"]["genres"][0]["genre"], "Travel");
365 assert!(!later["errors"].as_array().unwrap().is_empty());
366 }
367
368 #[test]
369 fn get_of_a_missing_file_is_a_server_error() {
370 let dir = fixture::temp_dir();
371 assert_eq!(Books.handle_get(&dir.context()).unwrap_err().status, 500);
372 }
373
374 #[test]
375 fn put_writes_the_file_and_returns_the_derivation() {
376 let dir = fixture::temp_dir();
377 dir.write(GENRES_FILE, fixture::NATURAL_HISTORY);
378 let body = format!(r#"{{"rows":[{}]}}"#, fixture::MOSS);
379
380 let json = Books.handle_put(&dir.context(), &body).unwrap();
381 let v: Value = serde_json::from_str(&json).unwrap();
382 assert_eq!(
383 v["derived"][0]["shelf"],
384 "A Field Guide to Moss, Natural History"
385 );
386 assert!(v["errors"].as_array().unwrap().is_empty());
387
388 let written = dir.read(BOOKS_FILE);
389 assert!(written.contains("A Field Guide to Moss"));
390 assert!(written.ends_with('\n'));
391 }
392
393 #[test]
394 fn put_writes_even_with_validation_errors() {
395 let dir = fixture::temp_dir();
396 let body = r#"{"rows":[{"title":"","genre":"Reference","subgenre":"Natural History"}]}"#;
397
398 let json = Books.handle_put(&dir.context(), body).unwrap();
399 let v: Value = serde_json::from_str(&json).unwrap();
400 assert!(!v["errors"].as_array().unwrap().is_empty());
401 assert!(dir.path().join(BOOKS_FILE).exists());
402 }
403
404 #[test]
405 fn derive_does_not_write() {
406 let dir = fixture::temp_dir();
407 let body = format!(r#"{{"rows":[{}]}}"#, fixture::MOSS);
408
409 let json = Books.handle_derive(&dir.context(), &body).unwrap();
410 let v: Value = serde_json::from_str(&json).unwrap();
411 assert_eq!(
412 v["derived"][0]["shelf"],
413 "A Field Guide to Moss, Natural History"
414 );
415 assert!(!dir.path().join(BOOKS_FILE).exists());
416 }
417
418 #[test]
419 fn put_round_trips_through_get() {
420 let dir = fixture::temp_dir();
421 let body = format!(r#"{{"rows":[{}]}}"#, fixture::NATURAL_HISTORY);
422
423 Genres.handle_put(&dir.context(), &body).unwrap();
424 let v: Value = serde_json::from_str(&Genres.handle_get(&dir.context()).unwrap()).unwrap();
425 assert_eq!(v["rows"][0]["genre"], "Reference");
426 assert_eq!(v["rows"][0]["subgenre"], "Natural History");
427 }
428
429 #[test]
430 fn parse_rows_rejects_a_malformed_body() {
431 assert_eq!(parse_rows::<Book>("not json").unwrap_err().status, 400);
432 }
433
434 #[test]
435 fn parse_rows_rejects_a_body_without_rows() {
436 assert_eq!(parse_rows::<Book>("{}").unwrap_err().status, 400);
437 }
438
439 #[test]
440 fn every_computed_column_names_a_key_the_derivation_emits() {
441 let dir = fixture::temp_dir();
442 dir.write(BOOKS_FILE, fixture::MOSS);
443
444 let v: Value = serde_json::from_str(&Books.handle_get(&dir.context()).unwrap()).unwrap();
445 let derived = v["derived"][0].as_object().unwrap();
446
447 let mut checked = 0;
448 for column in v["schema"]["columns"].as_array().unwrap() {
449 if column["type"] == "computed" {
450 let from = column["from"].as_str().unwrap();
451 assert!(derived.contains_key(from), "derivation lacks {from}");
452 checked += 1;
453 }
454 }
455 assert!(checked > 0, "the fixture has no computed column to check");
456 }
457}