use axum::http::Method;
use serde_json::{Map, Value, json};
use super::{
METHOD_CONFIG_GET, METHOD_CONFIG_SET, METHOD_HEALTH, METHOD_INDEX_CONFIG_GET,
METHOD_INDEX_CONFIG_SET, METHOD_INDEX_CREATE, METHOD_INDEX_DELETE, METHOD_INDEX_REINDEX,
METHOD_INDEX_REINDEX_STREAM, METHOD_INDEX_STATUS, METHOD_INDEXES_LIST, METHOD_LOGS_TAIL,
METHOD_QUERY, METHOD_QUERY_ALL, METHOD_REGISTRY_ORPHANS, METHOD_STATUS_STREAM,
};
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Call {
Unary {
method: &'static str,
params: Value,
},
Stream {
method: &'static str,
params: Value,
},
}
pub(crate) fn map_request(
method: &Method,
path: &str,
query: Option<&str>,
body: &[u8],
) -> Result<Call, String> {
let segments: Vec<&str> = path.trim_matches('/').split('/').collect();
let q = query_json(query);
let unary = |method: &'static str, params: Value| Ok(Call::Unary { method, params });
let stream = |method: &'static str, params: Value| Ok(Call::Stream { method, params });
match (method, segments.as_slice()) {
(&Method::GET, ["health"]) => unary(METHOD_HEALTH, json!({})),
(&Method::GET, ["indexes"]) => unary(METHOD_INDEXES_LIST, q),
(&Method::POST, ["indexes"]) => unary(METHOD_INDEX_CREATE, body_json(body)?),
(&Method::DELETE, ["indexes", id]) => unary(METHOD_INDEX_DELETE, with_index(id, q)),
(&Method::GET, ["indexes", id, "status"]) => {
unary(METHOD_INDEX_STATUS, json!({ "index_id": id }))
}
(&Method::GET, ["indexes", id, "config"]) => {
unary(METHOD_INDEX_CONFIG_GET, json!({ "index_id": id }))
}
(&Method::PATCH, ["indexes", id, "config"]) => unary(
METHOD_INDEX_CONFIG_SET,
json!({ "index_id": id, "body": body_json(body)? }),
),
(&Method::POST, ["indexes", id, "search"]) => unary(
METHOD_QUERY,
json!({ "index_id": id, "body": body_json(body)? }),
),
(&Method::POST, ["indexes", id, "reindex"]) => unary(
METHOD_INDEX_REINDEX,
json!({ "index_id": id, "body": body_json(body)? }),
),
(&Method::GET, ["indexes", id, "reindex", "stream"]) => {
stream(METHOD_INDEX_REINDEX_STREAM, json!({ "index_id": id }))
}
(&Method::POST, ["search"]) => unary(METHOD_QUERY_ALL, body_json(body)?),
(&Method::GET, ["config"]) => unary(METHOD_CONFIG_GET, json!({})),
(&Method::PATCH, ["config"]) => unary(METHOD_CONFIG_SET, body_json(body)?),
(&Method::GET, ["logs", "tail"]) => unary(METHOD_LOGS_TAIL, q),
(&Method::GET, ["registry", "orphans"]) => unary(METHOD_REGISTRY_ORPHANS, json!({})),
(&Method::GET, ["status", "stream"]) => stream(METHOD_STATUS_STREAM, json!({})),
_ => Err(format!(
"no trusty-search socket method serves {method} /{}. The console reaches \
trusty-search over its Unix socket (ADR-0032, #6285); only the endpoints the \
dashboard uses are mapped. `POST /chat` and `POST /admin/stop` have no socket \
method at all and are pending an owner decision on #6285.",
path.trim_matches('/')
)),
}
}
fn with_index(id: &str, mut params: Value) -> Value {
if let Some(obj) = params.as_object_mut() {
obj.insert("index_id".to_string(), Value::String(id.to_string()));
}
params
}
fn body_json(body: &[u8]) -> Result<Value, String> {
if body.iter().all(u8::is_ascii_whitespace) {
return Ok(Value::Null);
}
serde_json::from_slice(body).map_err(|e| format!("request body is not JSON: {e}"))
}
fn query_json(query: Option<&str>) -> Value {
let mut out = Map::new();
let Some(raw) = query.filter(|q| !q.is_empty()) else {
return Value::Object(out);
};
let Ok(url) = reqwest::Url::parse(&format!("http://console/?{raw}")) else {
return Value::Object(out);
};
for (key, value) in url.query_pairs() {
let coerced = match value.as_ref() {
"true" => Value::Bool(true),
"false" => Value::Bool(false),
other => match other.parse::<i64>() {
Ok(n) => Value::from(n),
Err(_) => Value::String(other.to_string()),
},
};
out.insert(key.into_owned(), coerced);
}
Value::Object(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn unary(method: &Method, path: &str, query: Option<&str>, body: &str) -> Call {
map_request(method, path, query, body.as_bytes()).expect("mapped")
}
#[test]
fn maps_every_endpoint_the_spa_calls() {
assert_eq!(
unary(&Method::GET, "health", None, ""),
Call::Unary {
method: METHOD_HEALTH,
params: json!({})
}
);
assert_eq!(
unary(&Method::GET, "indexes", Some("details=true"), ""),
Call::Unary {
method: METHOD_INDEXES_LIST,
params: json!({ "details": true })
}
);
assert_eq!(
unary(
&Method::POST,
"indexes",
None,
r#"{"id":"a","root_path":"/r"}"#
),
Call::Unary {
method: METHOD_INDEX_CREATE,
params: json!({ "id": "a", "root_path": "/r" })
}
);
assert_eq!(
unary(&Method::DELETE, "indexes/a", Some("delete_data=true"), ""),
Call::Unary {
method: METHOD_INDEX_DELETE,
params: json!({ "index_id": "a", "delete_data": true })
}
);
assert_eq!(
unary(&Method::GET, "indexes/a/status", None, ""),
Call::Unary {
method: METHOD_INDEX_STATUS,
params: json!({ "index_id": "a" })
}
);
assert_eq!(
unary(&Method::GET, "indexes/a/config", None, ""),
Call::Unary {
method: METHOD_INDEX_CONFIG_GET,
params: json!({ "index_id": "a" })
}
);
assert_eq!(
unary(
&Method::PATCH,
"indexes/a/config",
None,
r#"{"include_docs":false}"#
),
Call::Unary {
method: METHOD_INDEX_CONFIG_SET,
params: json!({ "index_id": "a", "body": { "include_docs": false } })
}
);
assert_eq!(
unary(
&Method::POST,
"indexes/a/search",
None,
r#"{"text":"q","top_k":10}"#
),
Call::Unary {
method: METHOD_QUERY,
params: json!({ "index_id": "a", "body": { "text": "q", "top_k": 10 } })
}
);
assert_eq!(
unary(&Method::POST, "indexes/a/reindex", None, "{}"),
Call::Unary {
method: METHOD_INDEX_REINDEX,
params: json!({ "index_id": "a", "body": {} })
}
);
assert_eq!(
unary(&Method::POST, "search", None, r#"{"query":"q","top_k":5}"#),
Call::Unary {
method: METHOD_QUERY_ALL,
params: json!({ "query": "q", "top_k": 5 })
}
);
assert_eq!(
unary(&Method::GET, "config", None, ""),
Call::Unary {
method: METHOD_CONFIG_GET,
params: json!({})
}
);
assert_eq!(
unary(&Method::PATCH, "config", None, r#"{"max_rss_mb":100}"#),
Call::Unary {
method: METHOD_CONFIG_SET,
params: json!({ "max_rss_mb": 100 })
}
);
assert_eq!(
unary(&Method::GET, "logs/tail", Some("n=200"), ""),
Call::Unary {
method: METHOD_LOGS_TAIL,
params: json!({ "n": 200 })
}
);
assert_eq!(
unary(&Method::GET, "registry/orphans", None, ""),
Call::Unary {
method: METHOD_REGISTRY_ORPHANS,
params: json!({})
}
);
assert_eq!(
unary(&Method::GET, "status/stream", None, ""),
Call::Stream {
method: METHOD_STATUS_STREAM,
params: json!({})
}
);
assert_eq!(
unary(&Method::GET, "indexes/a/reindex/stream", None, ""),
Call::Stream {
method: METHOD_INDEX_REINDEX_STREAM,
params: json!({ "index_id": "a" })
}
);
}
#[test]
fn refuses_an_unmapped_path() {
for (method, path) in [
(Method::POST, "chat"),
(Method::POST, "admin/stop"),
(Method::POST, "upgrade"),
(Method::GET, "metrics"),
(Method::GET, "indexes/a/communities"),
] {
let err = map_request(&method, path, None, b"").expect_err("unmapped");
assert!(err.contains(path), "the refusal must name the path: {err}");
}
}
#[test]
fn maps_the_same_with_or_without_a_leading_slash() {
assert_eq!(
unary(&Method::GET, "/health", None, ""),
unary(&Method::GET, "health", None, "")
);
}
#[test]
fn body_json_reads_an_empty_body_as_absent() {
assert_eq!(body_json(b"").expect("empty"), Value::Null);
assert_eq!(body_json(b" \n").expect("whitespace"), Value::Null);
assert_eq!(
unary(&Method::POST, "indexes/a/reindex", None, ""),
Call::Unary {
method: METHOD_INDEX_REINDEX,
params: json!({ "index_id": "a", "body": null })
}
);
}
#[test]
fn refuses_a_body_that_is_not_json() {
let err = map_request(&Method::POST, "search", None, b"not json").expect_err("refused");
assert!(err.contains("not JSON"), "{err}");
}
#[test]
fn query_json_coerces_bools_and_integers() {
let v = query_json(Some(
"details=true&force=false&n=200&format=json&repo=own%2Frepo",
));
assert_eq!(v["details"], json!(true));
assert_eq!(v["force"], json!(false));
assert_eq!(v["n"], json!(200));
assert_eq!(v["format"], json!("json"));
assert_eq!(v["repo"], json!("own/repo"));
}
#[test]
fn query_json_is_an_empty_object_for_no_query() {
assert_eq!(query_json(None), json!({}));
assert_eq!(query_json(Some("")), json!({}));
}
}