Skip to main content

tapes_client/core/models/
params.rs

1//! Typed parameters for the sealed operations that take them.
2//!
3//! # Why these are types and not `Vec<(&str, String)>`
4//!
5//! The untyped form is still there — [`crate::core::CoreClient::call`] takes
6//! wire-named pairs, and always will, because that is what makes an operation
7//! this crate never named still reachable. But a pair list is checked against
8//! the contract at *runtime*: a misspelled `payolad` is refused when the call
9//! is made, which is late for a name that was wrong the moment it was typed.
10//! These structs move the spelling to compile time and leave the runtime check
11//! exactly where it was, as the backstop for the untyped route.
12//!
13//! # Why a request enum is closed and a response string is not
14//!
15//! [`super`]'s models keep `status` and `kind` as `String` because an added
16//! variant must never fail a decode. A *request* parameter is the opposite
17//! situation: the value is the client's to choose, the contract declares the
18//! closed set the server accepts, and a value outside it is a 400 the client
19//! could have prevented. So where the document declares an `enum`, this module
20//! declares one too — and [`super::coverage`] holds the two together.
21
22use serde::{Deserialize, Serialize};
23
24/// One operation's parameters, in the shape the contract declares them.
25pub trait ContractParams {
26    /// The `operationId` these parameters belong to.
27    const OPERATION: &'static str;
28
29    /// The wire pairs to send: set parameters only, under the contract's own
30    /// names.
31    ///
32    /// An unset optional parameter is omitted rather than sent as a default,
33    /// so the server's default applies and this client never has to be
34    /// updated when one of them changes.
35    fn values(&self) -> Vec<(&'static str, String)>;
36}
37
38/// A parameter whose accepted values the contract closes with an `enum`.
39pub trait ContractEnum: Sized + Copy {
40    /// Where the contract declares this set: `(operationId, parameter)`, once
41    /// per operation that takes it.
42    const DECLARED_BY: &'static [(&'static str, &'static str)];
43
44    /// Every value, in the document's own spelling.
45    const VALUES: &'static [&'static str];
46
47    /// This value, as it travels.
48    fn as_str(self) -> &'static str;
49}
50
51/// How much of a span's payload a trace read should carry.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum PayloadDetail {
55    /// Whole payloads.
56    Full,
57    /// Truncated payloads, with a marker on the spans that were cut.
58    Preview,
59}
60
61impl ContractEnum for PayloadDetail {
62    const DECLARED_BY: &'static [(&'static str, &'static str)] =
63        &[("getSessionTraces", "payload"), ("getTrace", "payload")];
64    const VALUES: &'static [&'static str] = &["full", "preview"];
65
66    fn as_str(self) -> &'static str {
67        match self {
68            Self::Full => "full",
69            Self::Preview => "preview",
70        }
71    }
72}
73
74/// Which way a listing is ordered.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "lowercase")]
77pub enum SortDirection {
78    /// Oldest, or lowest, first.
79    Asc,
80    /// Newest, or highest, first.
81    Desc,
82}
83
84impl ContractEnum for SortDirection {
85    const DECLARED_BY: &'static [(&'static str, &'static str)] = &[("listSessions", "direction")];
86    const VALUES: &'static [&'static str] = &["asc", "desc"];
87
88    fn as_str(self) -> &'static str {
89        match self {
90            Self::Asc => "asc",
91            Self::Desc => "desc",
92        }
93    }
94}
95
96/// `GET /v1/sessions` — the sessions listing.
97#[derive(Debug, Clone, Default, PartialEq, Eq)]
98pub struct SessionListParams {
99    /// How many sessions to return.
100    pub limit: Option<u32>,
101    /// The cursor from a previous page's `next_cursor`.
102    pub cursor: Option<String>,
103    /// Which column to order by.
104    pub sort: Option<String>,
105    /// Which way to order it.
106    pub direction: Option<SortDirection>,
107    /// Lower bound on activity, as an RFC 3339 timestamp.
108    pub since: Option<String>,
109    /// Upper bound on activity, as an RFC 3339 timestamp.
110    pub until: Option<String>,
111    /// With [`Self::harness_session_id`], narrows the point lookup to the
112    /// single session with this harness id. Rejected alone (400): a harness
113    /// id names a harness, not a session.
114    pub harness_id: Option<String>,
115    /// Only sessions with this harness-side id (exact match). Alone it
116    /// matches across all harnesses — at most one row per harness; with
117    /// [`Self::harness_id`] it is a single-harness point lookup. The server
118    /// refuses either combined with `cursor`, `sort`, `direction`, `since`,
119    /// or `until` (400), and ignores `limit` while the filter is active.
120    pub harness_session_id: Option<String>,
121    /// Only sessions captured for this authenticated subject.
122    pub auth_subject: Option<String>,
123    /// Claimed filter params: repeatable `(name, value)` pairs appended to
124    /// the query string, in order, after the declared parameters.
125    ///
126    /// The names are **data**, not contract. A cassette claims extra filter
127    /// params on the sessions listing at runtime (the server's generic
128    /// publishes/claims mechanism), so the vendored document cannot declare
129    /// them and this client must not pretend to know them: nothing here is
130    /// validated, normalized, or filtered. An unclaimed name is ignored
131    /// byte-identically server-side — a claimed one is the server's to
132    /// interpret — and either way the answer is the server's, which is what
133    /// keeps this crate working against deployments whose cassette sets it
134    /// has never heard of.
135    pub claimed: Vec<(String, String)>,
136}
137
138impl ContractParams for SessionListParams {
139    const OPERATION: &'static str = "listSessions";
140
141    fn values(&self) -> Vec<(&'static str, String)> {
142        // `claimed` is deliberately absent from this list: `values()` feeds
143        // the declared-parameter check, which would refuse a name the
144        // contract does not declare. The sessions-list method appends the
145        // claimed pairs to the query itself, after everything listed here.
146        let mut values = Vec::new();
147        push_num(&mut values, "limit", self.limit);
148        push(&mut values, "cursor", self.cursor.as_deref());
149        push(&mut values, "sort", self.sort.as_deref());
150        push_enum(&mut values, "direction", self.direction);
151        push(&mut values, "since", self.since.as_deref());
152        push(&mut values, "until", self.until.as_deref());
153        push(&mut values, "harness_id", self.harness_id.as_deref());
154        push(
155            &mut values,
156            "harness_session_id",
157            self.harness_session_id.as_deref(),
158        );
159        push(&mut values, "auth_subject", self.auth_subject.as_deref());
160        values
161    }
162}
163
164/// `GET /v1/sessions/{id}/traces` — the derived span read model.
165#[derive(Debug, Clone, Default, PartialEq, Eq)]
166pub struct SessionTracesParams {
167    /// How much of each span's payload to carry.
168    pub payload: Option<PayloadDetail>,
169}
170
171impl ContractParams for SessionTracesParams {
172    const OPERATION: &'static str = "getSessionTraces";
173
174    fn values(&self) -> Vec<(&'static str, String)> {
175        let mut values = Vec::new();
176        push_enum(&mut values, "payload", self.payload);
177        values
178    }
179}
180
181/// `GET /v1/traces/{trace_id}` — one trace with its spans.
182#[derive(Debug, Clone, Default, PartialEq, Eq)]
183pub struct TraceParams {
184    /// How much of each span's payload to carry.
185    pub payload: Option<PayloadDetail>,
186}
187
188impl ContractParams for TraceParams {
189    const OPERATION: &'static str = "getTrace";
190
191    fn values(&self) -> Vec<(&'static str, String)> {
192        let mut values = Vec::new();
193        push_enum(&mut values, "payload", self.payload);
194        values
195    }
196}
197
198/// `GET /v1/traces` — the trace summaries for one session.
199///
200/// `session_id` is required: the contract scopes this listing with it, and a
201/// call without one is refused before it is sent rather than answering a
202/// different question than the caller asked.
203#[derive(Debug, Clone, Default, PartialEq, Eq)]
204pub struct TraceListParams {
205    /// The session whose traces to list.
206    pub session_id: String,
207}
208
209impl ContractParams for TraceListParams {
210    const OPERATION: &'static str = "listTraces";
211
212    fn values(&self) -> Vec<(&'static str, String)> {
213        vec![("session_id", self.session_id.clone())]
214    }
215}
216
217/// `GET /v1/stats` — the aggregate rollups.
218#[derive(Debug, Clone, Default, PartialEq, Eq)]
219pub struct StatsParams {
220    /// Lower bound, as an RFC 3339 timestamp.
221    pub since: Option<String>,
222    /// Upper bound, as an RFC 3339 timestamp.
223    pub until: Option<String>,
224    /// Narrow every total to sessions captured for this authenticated
225    /// subject — the same subject the sessions listing filters by, so totals
226    /// can agree with the rows beside them. Omitted, the totals are org-wide.
227    pub auth_subject: Option<String>,
228}
229
230impl ContractParams for StatsParams {
231    const OPERATION: &'static str = "getStats";
232
233    fn values(&self) -> Vec<(&'static str, String)> {
234        let mut values = Vec::new();
235        push(&mut values, "since", self.since.as_deref());
236        push(&mut values, "until", self.until.as_deref());
237        push(&mut values, "auth_subject", self.auth_subject.as_deref());
238        values
239    }
240}
241
242fn push(values: &mut Vec<(&'static str, String)>, wire: &'static str, value: Option<&str>) {
243    if let Some(value) = value {
244        values.push((wire, value.to_owned()));
245    }
246}
247
248fn push_num(values: &mut Vec<(&'static str, String)>, wire: &'static str, value: Option<u32>) {
249    if let Some(value) = value {
250        values.push((wire, value.to_string()));
251    }
252}
253
254fn push_enum<E: ContractEnum>(
255    values: &mut Vec<(&'static str, String)>,
256    wire: &'static str,
257    value: Option<E>,
258) {
259    if let Some(value) = value {
260        values.push((wire, value.as_str().to_owned()));
261    }
262}
263
264#[cfg(test)]
265#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn an_unset_optional_parameter_is_omitted_rather_than_defaulted() {
271        // The omit-when-unset rule, at the typed layer: sending `limit=50`
272        // because the caller said nothing would pin this client to today's
273        // server default forever.
274        assert!(SessionListParams::default().values().is_empty());
275    }
276
277    #[test]
278    fn a_set_parameter_travels_under_the_contracts_own_name() {
279        let params = SessionListParams {
280            limit: Some(25),
281            direction: Some(SortDirection::Desc),
282            ..Default::default()
283        };
284        assert_eq!(
285            params.values(),
286            vec![("limit", "25".to_owned()), ("direction", "desc".to_owned())],
287        );
288    }
289
290    #[test]
291    fn a_required_parameter_is_always_sent_even_when_it_is_empty() {
292        // Empty is a value the server can reject in its own words; omitting it
293        // is a differently-shaped request the contract layer would refuse.
294        assert_eq!(
295            TraceListParams::default().values(),
296            vec![("session_id", String::new())],
297        );
298    }
299}