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}