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` — one page of the derived span read model.
165///
166/// The response is paged in traces: the first `limit` of them in turn order,
167/// closed early once the page passes its byte budget, so a page shorter than
168/// `limit` does not mean the end — only an absent `next_cursor` does.
169#[derive(Debug, Clone, Default, PartialEq, Eq)]
170pub struct SessionTracesParams {
171    /// How much of each span's payload to carry.
172    pub payload: Option<PayloadDetail>,
173    /// How many traces to return in the page (server default 50, max 200).
174    pub limit: Option<u32>,
175    /// The cursor from a previous page's `next_cursor`, minted for the same
176    /// session.
177    pub cursor: Option<String>,
178}
179
180impl ContractParams for SessionTracesParams {
181    const OPERATION: &'static str = "getSessionTraces";
182
183    fn values(&self) -> Vec<(&'static str, String)> {
184        let mut values = Vec::new();
185        push_enum(&mut values, "payload", self.payload);
186        push_num(&mut values, "limit", self.limit);
187        push(&mut values, "cursor", self.cursor.as_deref());
188        values
189    }
190}
191
192/// `GET /v1/traces/{trace_id}` — one page of a trace's spans.
193///
194/// Only `spans` is paged; the header and links are whole on every page. A
195/// page also closes at the next span boundary once it has emitted roughly
196/// 8 MiB, so a page shorter than `limit` does not mean the end — only an
197/// absent `next_cursor` does.
198#[derive(Debug, Clone, Default, PartialEq, Eq)]
199pub struct TraceParams {
200    /// How much of each span's payload to carry.
201    pub payload: Option<PayloadDetail>,
202    /// How many spans to return in the page (server default 200, max 1000).
203    pub limit: Option<u32>,
204    /// The cursor from a previous page's `next_cursor`, minted for the same
205    /// trace.
206    pub cursor: Option<String>,
207}
208
209impl ContractParams for TraceParams {
210    const OPERATION: &'static str = "getTrace";
211
212    fn values(&self) -> Vec<(&'static str, String)> {
213        let mut values = Vec::new();
214        push_enum(&mut values, "payload", self.payload);
215        push_num(&mut values, "limit", self.limit);
216        push(&mut values, "cursor", self.cursor.as_deref());
217        values
218    }
219}
220
221/// `GET /v1/sessions/{id}/raw_turns` — one page of a session's wire log.
222///
223/// Paged in rows only: the listing is payload-free, so a header count bounds
224/// its bytes and the page carries no byte budget.
225#[derive(Debug, Clone, Default, PartialEq, Eq)]
226pub struct RawTurnListParams {
227    /// How many raw turn headers to return in the page (server default 200,
228    /// max 1000).
229    pub limit: Option<u32>,
230    /// The cursor from a previous page's `next_cursor`, minted for the same
231    /// session.
232    pub cursor: Option<String>,
233}
234
235impl ContractParams for RawTurnListParams {
236    const OPERATION: &'static str = "listRawTurns";
237
238    fn values(&self) -> Vec<(&'static str, String)> {
239        let mut values = Vec::new();
240        push_num(&mut values, "limit", self.limit);
241        push(&mut values, "cursor", self.cursor.as_deref());
242        values
243    }
244}
245
246/// `GET /v1/traces` — the trace summaries for one session.
247///
248/// `session_id` is required: the contract scopes this listing with it, and a
249/// call without one is refused before it is sent rather than answering a
250/// different question than the caller asked.
251#[derive(Debug, Clone, Default, PartialEq, Eq)]
252pub struct TraceListParams {
253    /// The session whose traces to list.
254    pub session_id: String,
255}
256
257impl ContractParams for TraceListParams {
258    const OPERATION: &'static str = "listTraces";
259
260    fn values(&self) -> Vec<(&'static str, String)> {
261        vec![("session_id", self.session_id.clone())]
262    }
263}
264
265/// `GET /v1/stats` — the aggregate rollups.
266#[derive(Debug, Clone, Default, PartialEq, Eq)]
267pub struct StatsParams {
268    /// Lower bound, as an RFC 3339 timestamp.
269    pub since: Option<String>,
270    /// Upper bound, as an RFC 3339 timestamp.
271    pub until: Option<String>,
272    /// Narrow every total to sessions captured for this authenticated
273    /// subject — the same subject the sessions listing filters by, so totals
274    /// can agree with the rows beside them. Omitted, the totals are org-wide.
275    pub auth_subject: Option<String>,
276}
277
278impl ContractParams for StatsParams {
279    const OPERATION: &'static str = "getStats";
280
281    fn values(&self) -> Vec<(&'static str, String)> {
282        let mut values = Vec::new();
283        push(&mut values, "since", self.since.as_deref());
284        push(&mut values, "until", self.until.as_deref());
285        push(&mut values, "auth_subject", self.auth_subject.as_deref());
286        values
287    }
288}
289
290fn push(values: &mut Vec<(&'static str, String)>, wire: &'static str, value: Option<&str>) {
291    if let Some(value) = value {
292        values.push((wire, value.to_owned()));
293    }
294}
295
296fn push_num(values: &mut Vec<(&'static str, String)>, wire: &'static str, value: Option<u32>) {
297    if let Some(value) = value {
298        values.push((wire, value.to_string()));
299    }
300}
301
302fn push_enum<E: ContractEnum>(
303    values: &mut Vec<(&'static str, String)>,
304    wire: &'static str,
305    value: Option<E>,
306) {
307    if let Some(value) = value {
308        values.push((wire, value.as_str().to_owned()));
309    }
310}
311
312#[cfg(test)]
313#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn an_unset_optional_parameter_is_omitted_rather_than_defaulted() {
319        // The omit-when-unset rule, at the typed layer: sending `limit=50`
320        // because the caller said nothing would pin this client to today's
321        // server default forever.
322        assert!(SessionListParams::default().values().is_empty());
323    }
324
325    #[test]
326    fn a_set_parameter_travels_under_the_contracts_own_name() {
327        let params = SessionListParams {
328            limit: Some(25),
329            direction: Some(SortDirection::Desc),
330            ..Default::default()
331        };
332        assert_eq!(
333            params.values(),
334            vec![("limit", "25".to_owned()), ("direction", "desc".to_owned())],
335        );
336    }
337
338    #[test]
339    fn a_required_parameter_is_always_sent_even_when_it_is_empty() {
340        // Empty is a value the server can reject in its own words; omitting it
341        // is a differently-shaped request the contract layer would refuse.
342        assert_eq!(
343            TraceListParams::default().values(),
344            vec![("session_id", String::new())],
345        );
346    }
347}