use std::collections::BTreeMap;
use std::io::Read;
use anyhow::{Result, anyhow};
use serde::Serialize;
use tiny_http::{Header, Method, Request, Response};
use crate::context::Context;
use crate::error::ApiError;
use crate::head;
use crate::table::{App, Front, Table};
const JSON: &str = "application/json";
const HTML: &str = "text/html; charset=utf-8";
const TEXT: &str = "text/plain; charset=utf-8";
const ICON_CACHE: &str = "public, max-age=604800";
pub(crate) const RESERVED_NAMES: [&str; 13] = [
"android-chrome-192x192",
"android-chrome-512x512",
"app",
"apple-touch-icon",
"derive",
"favicon-16x16",
"favicon-32x32",
"health",
"icon",
"manifest",
"shutdown",
"stop",
"views",
];
pub(crate) fn handle(
mut request: Request,
app: &dyn App,
index_html: &str,
api_only: bool,
) -> Result<()> {
let method = request.method().clone();
let path = request.url().split('?').next().unwrap_or("").to_string();
if method == Method::Get && path == "/api/health" {
return respond_json(request, health_payload(app));
}
if method == Method::Post && path == "/api/shutdown" {
respond_json(request, Ok(r#"{"status":"stopping"}"#.to_string()))?;
std::process::exit(0);
}
if method == Method::Get && path == "/api/app" {
return respond_json(request, app_payload(app));
}
if let Some((view, action)) = parse_action_route(app, &path) {
if method != Method::Post {
return respond_json(
request,
Err(ApiError::new(405, "method not allowed for this endpoint")),
);
}
let args = parse_query(request.url());
let result = match refuse_write(&request) {
Some(refusal) => Err(refusal),
None => read_body(&mut request).and_then(|body| {
Context::find()
.map_err(|e| ApiError::server(e.to_string()))
.and_then(|ctx| view.handle_action(&action, &args, &body, &ctx))
}),
};
return respond_json(request, result);
}
if let Some(view) = parse_view_route(app, &path) {
if method != Method::Get {
return respond_json(
request,
Err(ApiError::new(405, "method not allowed for this endpoint")),
);
}
let args = parse_query(request.url());
let result = Context::find()
.map_err(|e| ApiError::server(e.to_string()))
.and_then(|ctx| view.handle_get(&args, &ctx));
return respond_json(request, result);
}
if let Some(route) = parse_api_route(app, &path) {
let result = dispatch(&mut request, &method, &route);
return respond_json(request, result);
}
if (method == Method::Get || method == Method::Head)
&& let Some((content_type, body)) = head::asset(app, &path)
{
return respond_with(
request,
200,
&[
("Content-Type", content_type),
("Cache-Control", ICON_CACHE),
],
&body,
);
}
if method == Method::Get && (path == "/" || path == "/index.html") {
if api_only {
return respond(request, 404, TEXT, b"UI is served by Vite in dev mode");
}
return respond(request, 200, HTML, index_html.as_bytes());
}
if path.starts_with("/api/") || path == "/api" {
return respond_json(
request,
Err(ApiError::new(404, format!("{path} is not an endpoint"))),
);
}
respond(request, 404, TEXT, b"not found")
}
const NO_STORE: &str = "no-store";
fn respond_json(request: Request, result: Result<String, ApiError>) -> Result<()> {
let (status, body) = match result {
Ok(json) => (200, json),
Err(err) => (err.status, error_json(&err.message)),
};
respond_with(
request,
status,
&[("Content-Type", JSON), ("Cache-Control", NO_STORE)],
body.as_bytes(),
)
}
fn respond(request: Request, status: u16, content_type: &str, body: &[u8]) -> Result<()> {
respond_with(request, status, &[("Content-Type", content_type)], body)
}
fn respond_with(
request: Request,
status: u16,
headers: &[(&str, &str)],
body: &[u8],
) -> Result<()> {
let mut response = Response::from_data(body.to_vec()).with_status_code(status);
for (name, value) in headers {
let header = Header::from_bytes(name.as_bytes(), value.as_bytes())
.map_err(|_| anyhow!("invalid {name} header"))?;
response = response.with_header(header);
}
request
.respond(response)
.map_err(|e| anyhow!("failed to send response: {e}"))?;
Ok(())
}
fn parse_view_route<'a>(app: &'a dyn App, path: &str) -> Option<&'a dyn crate::view::View> {
app.view(&decode(path.strip_prefix("/api/views/")?))
}
fn parse_action_route<'a>(
app: &'a dyn App,
path: &str,
) -> Option<(&'a dyn crate::view::View, String)> {
let (view, action) = path
.strip_prefix("/api/views/")?
.split_once("/actions/")
.filter(|(_, action)| !action.is_empty() && !action.contains('/'))?;
Some((app.view(&decode(view))?, decode(action)))
}
pub(crate) fn parse_query(url: &str) -> BTreeMap<String, String> {
let mut args = BTreeMap::new();
let Some((_, query)) = url.split_once('?') else {
return args;
};
for pair in query.split('&').filter(|pair| !pair.is_empty()) {
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
let key = decode(key);
if !key.is_empty() {
args.insert(key, decode(value));
}
}
args
}
fn decode(raw: &str) -> String {
let bytes = raw.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
match hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
Some(byte) => {
out.push(byte);
i += 3;
}
None => {
out.push(bytes[i]);
i += 1;
}
}
}
byte => {
out.push(byte);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
struct ApiRoute<'a> {
table: &'a dyn Table,
derive: bool,
}
fn parse_api_route<'a>(app: &'a dyn App, path: &str) -> Option<ApiRoute<'a>> {
let rest = path.strip_prefix("/api/")?;
let (name, derive) = match rest.strip_suffix("/derive") {
Some(n) => (n, true),
None => (rest, false),
};
Some(ApiRoute {
table: app.table(&decode(name))?,
derive,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Action {
Get,
Put,
Derive,
}
fn action(method: &Method, derive: bool) -> Option<Action> {
match (method, derive) {
(&Method::Get, false) => Some(Action::Get),
(&Method::Put, false) => Some(Action::Put),
(&Method::Post, true) => Some(Action::Derive),
_ => None,
}
}
fn dispatch(request: &mut Request, method: &Method, route: &ApiRoute) -> Result<String, ApiError> {
let Some(action) = action(method, route.derive) else {
return Err(ApiError::new(405, "method not allowed for this endpoint"));
};
let body = match action {
Action::Get => String::new(),
Action::Put | Action::Derive => {
if let Some(refusal) = refuse_write(request) {
return Err(refusal);
}
read_body(request)?
}
};
let ctx = Context::find().map_err(|e| ApiError::server(e.to_string()))?;
match action {
Action::Get => route.table.handle_get(&ctx),
Action::Put => route.table.handle_put(&ctx, &body),
Action::Derive => route.table.handle_derive(&ctx, &body),
}
}
const BODY_LIMIT: usize = 16 * 1024 * 1024;
fn read_body(request: &mut Request) -> Result<String, ApiError> {
let mut body = String::new();
let read = request
.as_reader()
.take(BODY_LIMIT as u64 + 1)
.read_to_string(&mut body)
.map_err(|e| ApiError::bad_request(format!("could not read request body: {e}")))?;
if read > BODY_LIMIT {
return Err(ApiError::new(
413,
format!("the request body is larger than {} MiB", BODY_LIMIT >> 20),
));
}
Ok(body)
}
fn refuse_write(request: &Request) -> Option<ApiError> {
let from_page = from_our_page(
header(request, "Sec-Fetch-Site"),
header(request, "Origin"),
header(request, "Host"),
);
if !from_page {
return Some(ApiError::new(
403,
"a write has to come from a page this server served",
));
}
match header(request, "Content-Type") {
Some(value) if is_json(value) => None,
_ => Some(ApiError::new(
415,
format!("a write has to be sent as {JSON}"),
)),
}
}
fn header<'a>(request: &'a Request, name: &'static str) -> Option<&'a str> {
request
.headers()
.iter()
.find(|header| header.field.equiv(name))
.map(|header| header.value.as_str())
}
fn is_json(content_type: &str) -> bool {
content_type
.split(';')
.next()
.is_some_and(|media| media.trim().eq_ignore_ascii_case(JSON))
}
fn from_our_page(fetch_site: Option<&str>, origin: Option<&str>, host: Option<&str>) -> bool {
if let Some(site) = fetch_site {
return site.trim().eq_ignore_ascii_case("same-origin");
}
let Some(origin) = origin else {
return true;
};
match (origin.split_once("://"), host) {
(Some((_, claimed)), Some(host)) => claimed.eq_ignore_ascii_case(host),
_ => false,
}
}
#[derive(Serialize)]
struct HealthPayload<'a> {
status: &'static str,
app: &'a str,
}
fn health_payload(app: &dyn App) -> Result<String, ApiError> {
to_json(&HealthPayload {
status: "ok",
app: app.name(),
})
}
#[derive(Serialize)]
struct AppPayload<'a> {
name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
subtitle: Option<&'a str>,
#[serde(skip_serializing_if = "Vec::is_empty")]
views: Vec<ViewEntry<'a>>,
tables: Vec<TableEntry<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
front: Option<FrontEntry<'a>>,
}
#[derive(Serialize)]
struct TableEntry<'a> {
table: &'a str,
title: &'a str,
}
#[derive(Serialize)]
struct ViewEntry<'a> {
view: &'a str,
title: &'a str,
#[serde(skip_serializing_if = "is_true")]
in_switcher: bool,
}
fn is_true(value: &bool) -> bool {
*value
}
#[derive(Serialize)]
#[serde(untagged)]
enum FrontEntry<'a> {
View { view: &'a str },
Table { table: &'a str },
}
fn app_payload(app: &dyn App) -> Result<String, ApiError> {
to_json(&AppPayload {
name: app.name(),
subtitle: app.subtitle(),
views: app
.views()
.iter()
.map(|v| ViewEntry {
view: v.route(),
title: v.heading(),
in_switcher: v.listed(),
})
.collect(),
tables: app
.tables()
.iter()
.map(|t| TableEntry {
table: t.route(),
title: t.heading(),
})
.collect(),
front: match app.front() {
Front::FirstTable => None,
Front::Table(table) => Some(FrontEntry::Table { table }),
Front::View(view) => Some(FrontEntry::View { view }),
},
})
}
fn to_json<T: Serialize>(value: &T) -> Result<String, ApiError> {
serde_json::to_string(value).map_err(|e| ApiError::server(e.to_string()))
}
fn error_json(message: &str) -> String {
serde_json::json!({ "error": message }).to_string()
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use super::*;
use crate::fixture::{Books, Library, Plain};
use crate::table::Front;
use crate::view::{View, ViewArgs, ViewData, ViewLogic};
#[test]
fn route_maps_every_table() {
let app = Library::new();
for name in ["books", "genres"] {
let route = parse_api_route(&app, &format!("/api/{name}")).expect("known table route");
assert_eq!(route.table.route(), name);
assert!(!route.derive);
}
}
#[test]
fn route_recognizes_derive_suffix() {
let app = Library::new();
let route = parse_api_route(&app, "/api/books/derive").expect("derive route");
assert_eq!(route.table.route(), "books");
assert!(route.derive);
}
#[test]
fn route_rejects_unknown_paths() {
let app = Library::new();
assert!(parse_api_route(&app, "/api/unknown").is_none());
assert!(parse_api_route(&app, "/index.html").is_none());
assert!(parse_api_route(&app, "/api/books/extra").is_none());
}
#[test]
fn methods_map_to_the_endpoint_they_reach() {
assert_eq!(action(&Method::Get, false), Some(Action::Get));
assert_eq!(action(&Method::Put, false), Some(Action::Put));
assert_eq!(action(&Method::Post, true), Some(Action::Derive));
}
#[test]
fn a_method_the_endpoint_does_not_take_has_no_action() {
assert_eq!(action(&Method::Post, false), None);
assert_eq!(action(&Method::Get, true), None);
assert_eq!(action(&Method::Put, true), None);
assert_eq!(action(&Method::Delete, false), None);
}
#[test]
fn a_view_route_names_the_view_it_reaches() {
let app = Library::new();
assert_eq!(
parse_view_route(&app, "/api/views/on-loan").map(|v| v.route()),
Some("on-loan")
);
assert!(parse_view_route(&app, "/api/views/nothing").is_none());
assert!(parse_view_route(&app, "/api/views/").is_none());
assert!(parse_view_route(&app, "/api/books").is_none());
}
#[test]
fn an_action_route_names_the_view_and_the_action_it_reaches() {
let app = Library::new();
let route = |path| parse_action_route(&app, path).map(|(v, a)| (v.route(), a));
assert_eq!(
route("/api/views/shelf/actions/lend"),
Some(("shelf", "lend".to_string()))
);
assert_eq!(
route("/api/views/shelf/actions/lend%20it%20out"),
Some(("shelf", "lend it out".to_string()))
);
assert_eq!(route("/api/views/nothing/actions/lend"), None);
assert_eq!(route("/api/views/shelf/actions/"), None);
assert_eq!(route("/api/views/shelf/actions/lend/again"), None);
assert_eq!(route("/api/views/shelf"), None);
assert!(parse_view_route(&app, "/api/views/shelf/actions/lend").is_none());
}
#[test]
fn a_write_has_to_be_sent_as_json() {
assert!(is_json("application/json"));
assert!(is_json("application/json; charset=utf-8"));
assert!(is_json("Application/JSON"));
assert!(!is_json("text/plain"));
assert!(!is_json("text/plain;charset=UTF-8"));
assert!(!is_json("multipart/form-data; boundary=x"));
assert!(!is_json("application/x-www-form-urlencoded"));
assert!(!is_json(""));
}
#[test]
fn a_write_has_to_come_from_a_page_this_server_served() {
let host = Some("127.0.0.1:8788");
assert!(from_our_page(Some("same-origin"), None, host));
assert!(from_our_page(
Some("same-origin"),
Some("http://localhost:5173"),
host
));
assert!(!from_our_page(Some("cross-site"), None, host));
assert!(!from_our_page(Some("same-site"), None, host));
assert!(!from_our_page(Some("none"), None, host));
assert!(from_our_page(None, Some("http://127.0.0.1:8788"), host));
assert!(!from_our_page(None, Some("https://evil.invalid"), host));
assert!(!from_our_page(None, Some("http://127.0.0.1:9999"), host));
assert!(!from_our_page(None, Some("null"), host));
assert!(!from_our_page(None, Some("http://127.0.0.1:8788"), None));
assert!(from_our_page(None, None, host));
}
#[test]
fn a_query_string_reads_as_the_parameters_it_carries() {
assert_eq!(parse_query("/api/views/on-loan"), BTreeMap::new());
assert_eq!(parse_query("/api/views/on-loan?"), BTreeMap::new());
assert_eq!(
parse_query("/api/views/on-loan?genre=Travel"),
BTreeMap::from([("genre".to_string(), "Travel".to_string())])
);
assert_eq!(
parse_query("/?a=1&b=2"),
BTreeMap::from([
("a".to_string(), "1".to_string()),
("b".to_string(), "2".to_string())
])
);
}
#[test]
fn a_parameter_value_arrives_as_it_was_typed() {
let args = parse_query("/v?who=Ada+Ferreira&title=A%20Field%20Guide&sign=%E2%9D%A7");
assert_eq!(args.get("who").unwrap(), "Ada Ferreira");
assert_eq!(args.get("title").unwrap(), "A Field Guide");
assert_eq!(args.get("sign").unwrap(), "❧");
}
#[test]
fn an_odd_query_string_is_read_rather_than_refused() {
let args = parse_query("/v?flag&empty=&half=%zz&trailing=%2");
assert_eq!(args.get("flag").unwrap(), "");
assert_eq!(args.get("empty").unwrap(), "");
assert_eq!(args.get("half").unwrap(), "%zz");
assert_eq!(args.get("trailing").unwrap(), "%2");
}
#[test]
fn health_names_the_app_it_serves() {
let v: Value = serde_json::from_str(&health_payload(&Library::new()).unwrap()).unwrap();
assert_eq!(v, json!({ "status": "ok", "app": "Library" }));
}
#[test]
fn app_payload_names_the_views_before_the_tables_and_the_front_page() {
let v: Value = serde_json::from_str(&app_payload(&Library::new()).unwrap()).unwrap();
assert_eq!(
v,
json!({
"name": "Library",
"subtitle": "Fixture",
"views": [
{ "view": "on-loan", "title": "On loan" },
{ "view": "shelf", "title": "Shelf" }
],
"tables": [
{ "table": "books", "title": "Books" },
{ "table": "genres", "title": "Genres" }
],
"front": { "view": "on-loan" }
})
);
}
#[test]
fn a_view_the_switcher_does_not_list_says_so() {
struct Story;
impl ViewLogic for Story {
fn name(&self) -> &'static str {
"story"
}
fn title(&self) -> &'static str {
"Story"
}
fn in_switcher(&self) -> bool {
false
}
fn render(&self, _args: &ViewArgs, _ctx: &Context) -> Result<ViewData, ApiError> {
Ok(ViewData::new())
}
}
struct Shelved(Story, Books);
impl App for Shelved {
fn name(&self) -> &str {
"Shelved"
}
fn tables(&self) -> Vec<&dyn Table> {
vec![&self.1]
}
fn views(&self) -> Vec<&dyn View> {
vec![&self.0]
}
}
let v: Value = serde_json::from_str(&app_payload(&Shelved(Story, Books)).unwrap()).unwrap();
assert_eq!(
v["views"],
json!([{ "view": "story", "title": "Story", "in_switcher": false }])
);
let library: Value = serde_json::from_str(&app_payload(&Library::new()).unwrap()).unwrap();
assert_eq!(
library["views"][0],
json!({ "view": "on-loan", "title": "On loan" })
);
}
#[test]
fn an_app_of_tables_alone_sends_neither_views_nor_a_front_page() {
assert_eq!(
app_payload(&Plain::new()).unwrap(),
r#"{"name":"Plain","tables":[{"table":"books","title":"Books"}]}"#
);
}
#[test]
fn a_front_page_that_names_a_table_says_so() {
struct Fronted(Books);
impl App for Fronted {
fn name(&self) -> &str {
"Fronted"
}
fn tables(&self) -> Vec<&dyn Table> {
vec![&self.0]
}
fn front(&self) -> Front {
Front::Table("books")
}
}
let v: Value = serde_json::from_str(&app_payload(&Fronted(Books)).unwrap()).unwrap();
assert_eq!(v["front"], json!({ "table": "books" }));
}
#[test]
fn app_payload_omits_an_absent_subtitle() {
struct Bare;
impl App for Bare {
fn name(&self) -> &str {
"Bare"
}
fn tables(&self) -> Vec<&dyn Table> {
Vec::new()
}
}
let v: Value = serde_json::from_str(&app_payload(&Bare).unwrap()).unwrap();
assert_eq!(v, json!({ "name": "Bare", "tables": [] }));
}
#[test]
fn error_json_carries_the_message() {
assert_eq!(error_json("no Data/"), r#"{"error":"no Data/"}"#);
}
}