Skip to main content

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}