use osproxy_core::EndpointKind;
use osproxy_spi::HttpMethod;
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Classified {
pub endpoint: EndpointKind,
pub logical_index: String,
pub doc_id: Option<String>,
}
#[must_use]
pub fn classify(method: HttpMethod, path: &str) -> Classified {
let mut buf = [""; 4];
let mut count = 0usize;
for seg in path.split('/').filter(|s| !s.is_empty()) {
if count < buf.len() {
buf[count] = seg;
}
count += 1;
}
let segments = &buf[..count.min(buf.len())];
match segments {
[index, verb @ ("_doc" | "_create"), id] => Classified {
endpoint: by_id_endpoint(method, verb),
logical_index: (*index).to_owned(),
doc_id: Some((*id).to_owned()),
},
[index, "_doc"] => Classified {
endpoint: doc_endpoint(method),
logical_index: (*index).to_owned(),
doc_id: None,
},
["_search", "scroll" | "point_in_time"] => classified(EndpointKind::Cursor, ""),
["_search", "scroll", scroll_id] => Classified {
endpoint: EndpointKind::Cursor,
logical_index: String::new(),
doc_id: Some((*scroll_id).to_owned()),
},
[index, "_search", "point_in_time"] => classified(EndpointKind::Cursor, index),
["_search"] => classified(EndpointKind::Search, ""),
[index, "_search"] => classified(EndpointKind::Search, index),
[index, "_count"] => classified(EndpointKind::Count, index),
["_mget"] => classified(EndpointKind::MultiGet, ""),
[index, "_mget"] => classified(EndpointKind::MultiGet, index),
["_msearch"] => classified(EndpointKind::MultiSearch, ""),
[index, "_msearch"] => classified(EndpointKind::MultiSearch, index),
["_bulk"] => Classified {
endpoint: EndpointKind::IngestBulk,
logical_index: String::new(),
doc_id: None,
},
[index, "_bulk"] => classified(EndpointKind::IngestBulk, index),
[index, "_delete_by_query"] => classified(EndpointKind::DeleteByQuery, index),
[first, ..] if matches!(*first, "_cat" | "_cluster" | "_nodes") => {
classified(EndpointKind::Admin, "")
}
_ => Classified {
endpoint: EndpointKind::Unknown,
logical_index: segments
.first()
.map(|s| (*s).to_owned())
.unwrap_or_default(),
doc_id: None,
},
}
}
fn by_id_endpoint(method: HttpMethod, verb: &str) -> EndpointKind {
match method {
HttpMethod::Get | HttpMethod::Head => EndpointKind::GetById,
HttpMethod::Delete => EndpointKind::DeleteById,
HttpMethod::Put | HttpMethod::Post if verb == "_create" || verb == "_doc" => {
EndpointKind::IngestDoc
}
_ => EndpointKind::Unknown,
}
}
fn doc_endpoint(method: HttpMethod) -> EndpointKind {
match method {
HttpMethod::Post | HttpMethod::Put => EndpointKind::IngestDoc,
_ => EndpointKind::Unknown,
}
}
fn classified(endpoint: EndpointKind, index: &str) -> Classified {
Classified {
endpoint,
logical_index: index.to_owned(),
doc_id: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn put_doc_with_id_is_ingest() {
let c = classify(HttpMethod::Put, "/orders/_doc/acme:1");
assert_eq!(c.endpoint, EndpointKind::IngestDoc);
assert_eq!(c.logical_index, "orders");
assert_eq!(c.doc_id.as_deref(), Some("acme:1"));
}
#[test]
fn post_doc_without_id_is_ingest() {
let c = classify(HttpMethod::Post, "/orders/_doc");
assert_eq!(c.endpoint, EndpointKind::IngestDoc);
assert_eq!(c.logical_index, "orders");
assert!(c.doc_id.is_none());
}
#[test]
fn get_and_delete_by_id_are_classified() {
assert_eq!(
classify(HttpMethod::Get, "/orders/_doc/1").endpoint,
EndpointKind::GetById
);
assert_eq!(
classify(HttpMethod::Delete, "/orders/_doc/1").endpoint,
EndpointKind::DeleteById
);
}
#[test]
fn search_count_and_bulk() {
assert_eq!(
classify(HttpMethod::Post, "/orders/_search").endpoint,
EndpointKind::Search
);
assert_eq!(
classify(HttpMethod::Get, "/orders/_count").endpoint,
EndpointKind::Count
);
assert_eq!(
classify(HttpMethod::Post, "/_bulk").endpoint,
EndpointKind::IngestBulk
);
assert_eq!(
classify(HttpMethod::Post, "/_mget").endpoint,
EndpointKind::MultiGet
);
assert_eq!(
classify(HttpMethod::Post, "/orders/_mget").endpoint,
EndpointKind::MultiGet
);
assert_eq!(
classify(HttpMethod::Post, "/_msearch").endpoint,
EndpointKind::MultiSearch
);
assert_eq!(
classify(HttpMethod::Post, "/orders/_msearch").endpoint,
EndpointKind::MultiSearch
);
assert_eq!(
classify(HttpMethod::Post, "/orders/_bulk").endpoint,
EndpointKind::IngestBulk
);
}
#[test]
fn scroll_and_pit_paths_are_cursor() {
assert_eq!(
classify(HttpMethod::Post, "/_search/scroll").endpoint,
EndpointKind::Cursor
);
let path_form = classify(HttpMethod::Get, "/_search/scroll/c2Nyb2xs");
assert_eq!(path_form.endpoint, EndpointKind::Cursor);
assert_eq!(path_form.doc_id.as_deref(), Some("c2Nyb2xs"));
assert!(
classify(HttpMethod::Delete, "/_search/scroll")
.logical_index
.is_empty(),
"scroll clear carries no logical index"
);
let pit_create = classify(HttpMethod::Post, "/orders/_search/point_in_time");
assert_eq!(pit_create.endpoint, EndpointKind::Cursor);
assert_eq!(pit_create.logical_index, "orders");
let pit_delete = classify(HttpMethod::Delete, "/_search/point_in_time");
assert_eq!(pit_delete.endpoint, EndpointKind::Cursor);
assert!(pit_delete.logical_index.is_empty());
}
#[test]
fn a_no_index_search_classifies_as_search() {
let c = classify(HttpMethod::Post, "/_search");
assert_eq!(c.endpoint, EndpointKind::Search);
assert!(c.logical_index.is_empty());
}
#[test]
fn a_real_search_is_not_mistaken_for_a_cursor() {
assert_eq!(
classify(HttpMethod::Post, "/orders/_search").endpoint,
EndpointKind::Search
);
}
#[test]
fn admin_endpoints_classify_as_admin() {
for path in ["/_cat/indices", "/_cluster/health", "/_nodes/stats"] {
let c = classify(HttpMethod::Get, path);
assert_eq!(c.endpoint, EndpointKind::Admin, "{path}");
assert!(
c.logical_index.is_empty(),
"{path} carries no logical index"
);
}
assert_eq!(
classify(HttpMethod::Post, "/_catalog/_search").endpoint,
EndpointKind::Search
);
}
#[test]
fn unknown_paths_classify_as_unknown() {
assert_eq!(
classify(HttpMethod::Get, "/").endpoint,
EndpointKind::Unknown
);
assert_eq!(
classify(HttpMethod::Get, "/_sql").endpoint,
EndpointKind::Unknown
);
}
#[test]
fn create_verb_is_always_ingest() {
assert_eq!(
classify(HttpMethod::Put, "/orders/_create/1").endpoint,
EndpointKind::IngestDoc
);
}
}