osproxy_core/endpoint.rs
1//! Classification of OpenSearch requests into handling categories.
2//!
3//! The OpenSearch REST surface is large; [`EndpointKind`] mirrors the supported
4//! matrix in `docs/specs/opensearch-endpoints.md` and decides how a request is
5//! treated by the tenancy layer. Adding a variant to a tenancy-aware class
6//! requires a symmetry test (`docs/09`).
7
8/// How a classified request must be handled by the routing/tenancy layer.
9///
10/// `#[non_exhaustive]` because the supported matrix grows over time and adding
11/// an endpoint class must not be a breaking change (`docs/08` §7).
12#[non_exhaustive]
13#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
14pub enum EndpointKind {
15 /// Single-document ingest (`_doc`, `_create`, `_update`): inject/construct,
16 /// single target.
17 IngestDoc,
18 /// Bulk ingest (`_bulk`): demux by partition, re-interleave `items[]`.
19 IngestBulk,
20 /// Search/read (`_search`): partition filter + response field strip,
21 /// single target.
22 Search,
23 /// Multi-search (`_msearch`): per-search partition filter + hit strip,
24 /// demux by target, re-interleave `responses[]`, the search counterpart of
25 /// `_bulk`.
26 MultiSearch,
27 /// Count (`_count`): same partition filter as search, but returns a count
28 /// rather than hits, so no response field strip.
29 Count,
30 /// Read by id (`GET _doc/{id}`): logical→physical id transform.
31 GetById,
32 /// Multi-get (`_mget`): per-doc partition resolve, demux by target,
33 /// re-interleave `docs[]`, the read counterpart of `_bulk`.
34 MultiGet,
35 /// Delete by id: logical→physical id transform.
36 DeleteById,
37 /// Delete by query (`_delete_by_query`): in async fan-out mode the proxy
38 /// runs the partition-scoped query, then enqueues a concrete delete per
39 /// match (`docs/04` §9). No synchronous implementation, rejected otherwise.
40 DeleteByQuery,
41 /// Cursor lifecycle (scroll, PIT): affinity pinning.
42 Cursor,
43 /// Administrative endpoints (`_cat`, `_cluster`, …): pass-through allow-list
44 /// or reject; no tenancy semantics.
45 Admin,
46 /// Unmatched endpoint: rejected by default, pass-through if configured.
47 Unknown,
48}
49
50impl EndpointKind {
51 /// A stable, value-free name for this class, used in introspection readouts
52 /// (e.g. a control-plane directive's `endpoint` target). Matches the variant
53 /// name so it round-trips with a parser built from the same list.
54 #[must_use]
55 pub fn as_str(self) -> &'static str {
56 match self {
57 Self::IngestDoc => "IngestDoc",
58 Self::IngestBulk => "IngestBulk",
59 Self::Search => "Search",
60 Self::MultiSearch => "MultiSearch",
61 Self::Count => "Count",
62 Self::GetById => "GetById",
63 Self::MultiGet => "MultiGet",
64 Self::DeleteById => "DeleteById",
65 Self::DeleteByQuery => "DeleteByQuery",
66 Self::Cursor => "Cursor",
67 Self::Admin => "Admin",
68 Self::Unknown => "Unknown",
69 }
70 }
71
72 /// The inverse of [`EndpointKind::as_str`]: parses a class name back, or
73 /// `None` if it is not a known class. Lets a control-plane directive target an
74 /// endpoint over the wire (round-tripping with introspection).
75 #[must_use]
76 pub fn from_name(name: &str) -> Option<Self> {
77 match name {
78 "IngestDoc" => Some(Self::IngestDoc),
79 "IngestBulk" => Some(Self::IngestBulk),
80 "Search" => Some(Self::Search),
81 "MultiSearch" => Some(Self::MultiSearch),
82 "Count" => Some(Self::Count),
83 "GetById" => Some(Self::GetById),
84 "MultiGet" => Some(Self::MultiGet),
85 "DeleteById" => Some(Self::DeleteById),
86 "DeleteByQuery" => Some(Self::DeleteByQuery),
87 "Cursor" => Some(Self::Cursor),
88 "Admin" => Some(Self::Admin),
89 "Unknown" => Some(Self::Unknown),
90 _ => None,
91 }
92 }
93
94 /// Whether this class participates in tenancy rewriting (inject/filter/strip
95 /// or id mapping). Used to decide whether a [`crate::ids::PartitionId`] must
96 /// be resolvable for the request.
97 #[must_use]
98 pub fn is_tenancy_aware(self) -> bool {
99 matches!(
100 self,
101 Self::IngestDoc
102 | Self::IngestBulk
103 | Self::Search
104 | Self::MultiSearch
105 | Self::Count
106 | Self::GetById
107 | Self::MultiGet
108 | Self::DeleteById
109 | Self::DeleteByQuery
110 | Self::Cursor
111 )
112 }
113
114 /// Whether this class writes data (and therefore must be epoch-stamped at
115 /// the sink, `docs/06` §2).
116 #[must_use]
117 pub fn is_write(self) -> bool {
118 matches!(self, Self::IngestDoc | Self::IngestBulk | Self::DeleteById)
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn admin_and_unknown_are_not_tenancy_aware() {
128 assert!(!EndpointKind::Admin.is_tenancy_aware());
129 assert!(!EndpointKind::Unknown.is_tenancy_aware());
130 }
131
132 #[test]
133 fn ingest_and_read_paths_are_tenancy_aware() {
134 for kind in [
135 EndpointKind::IngestDoc,
136 EndpointKind::IngestBulk,
137 EndpointKind::Search,
138 EndpointKind::MultiSearch,
139 EndpointKind::Count,
140 EndpointKind::GetById,
141 EndpointKind::MultiGet,
142 EndpointKind::DeleteById,
143 EndpointKind::Cursor,
144 ] {
145 assert!(kind.is_tenancy_aware(), "{kind:?} should be tenancy-aware");
146 }
147 }
148
149 #[test]
150 fn write_classification_matches_intent() {
151 assert!(EndpointKind::IngestDoc.is_write());
152 assert!(EndpointKind::IngestBulk.is_write());
153 assert!(EndpointKind::DeleteById.is_write());
154 assert!(!EndpointKind::Search.is_write());
155 assert!(!EndpointKind::GetById.is_write());
156 }
157
158 #[test]
159 fn every_kind_round_trips_through_its_name() {
160 // The introspection ↔ publish round-trip depends on as_str/from_name being
161 // exact inverses for every variant; a new variant with a missed arm fails.
162 for kind in [
163 EndpointKind::IngestDoc,
164 EndpointKind::IngestBulk,
165 EndpointKind::Search,
166 EndpointKind::MultiSearch,
167 EndpointKind::Count,
168 EndpointKind::GetById,
169 EndpointKind::MultiGet,
170 EndpointKind::DeleteById,
171 EndpointKind::Cursor,
172 EndpointKind::Admin,
173 EndpointKind::Unknown,
174 ] {
175 assert_eq!(EndpointKind::from_name(kind.as_str()), Some(kind));
176 }
177 assert_eq!(EndpointKind::from_name("nope"), None);
178 }
179}