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}