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/// The grain an export is written at.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "lowercase")]
77pub enum ExportDetail {
78    /// One record per span.
79    Spans,
80    /// One record per trace.
81    Traces,
82}
83
84impl ContractEnum for ExportDetail {
85    const DECLARED_BY: &'static [(&'static str, &'static str)] =
86        &[("exportSession", "detail"), ("exportSessions", "detail")];
87    const VALUES: &'static [&'static str] = &["spans", "traces"];
88
89    fn as_str(self) -> &'static str {
90        match self {
91            Self::Spans => "spans",
92            Self::Traces => "traces",
93        }
94    }
95}
96
97/// Which way a listing is ordered.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "lowercase")]
100pub enum SortDirection {
101    /// Oldest, or lowest, first.
102    Asc,
103    /// Newest, or highest, first.
104    Desc,
105}
106
107impl ContractEnum for SortDirection {
108    const DECLARED_BY: &'static [(&'static str, &'static str)] = &[("listSessions", "direction")];
109    const VALUES: &'static [&'static str] = &["asc", "desc"];
110
111    fn as_str(self) -> &'static str {
112        match self {
113            Self::Asc => "asc",
114            Self::Desc => "desc",
115        }
116    }
117}
118
119/// Whose skills a listing covers.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "lowercase")]
122pub enum SkillScope {
123    /// Everything the caller may see.
124    All,
125    /// Only the caller's own.
126    Mine,
127    /// Everyone else's.
128    Team,
129}
130
131impl ContractEnum for SkillScope {
132    const DECLARED_BY: &'static [(&'static str, &'static str)] = &[("listSkills", "scope")];
133    const VALUES: &'static [&'static str] = &["all", "mine", "team"];
134
135    fn as_str(self) -> &'static str {
136        match self {
137            Self::All => "all",
138            Self::Mine => "mine",
139            Self::Team => "team",
140        }
141    }
142}
143
144/// How a skills listing is ordered.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "lowercase")]
147pub enum SkillSort {
148    /// Most downloaded first.
149    Downloads,
150}
151
152impl ContractEnum for SkillSort {
153    const DECLARED_BY: &'static [(&'static str, &'static str)] = &[("listSkills", "sort")];
154    const VALUES: &'static [&'static str] = &["downloads"];
155
156    fn as_str(self) -> &'static str {
157        match self {
158            Self::Downloads => "downloads",
159        }
160    }
161}
162
163/// `GET /v1/sessions` — the sessions listing.
164#[derive(Debug, Clone, Default, PartialEq, Eq)]
165pub struct SessionListParams {
166    /// How many sessions to return.
167    pub limit: Option<u32>,
168    /// The cursor from a previous page's `next_cursor`.
169    pub cursor: Option<String>,
170    /// Which column to order by.
171    pub sort: Option<String>,
172    /// Which way to order it.
173    pub direction: Option<SortDirection>,
174    /// Lower bound on activity, as an RFC 3339 timestamp.
175    pub since: Option<String>,
176    /// Upper bound on activity, as an RFC 3339 timestamp.
177    pub until: Option<String>,
178    /// Only sessions captured from this harness.
179    pub harness_id: Option<String>,
180    /// Only the session with this harness-side id.
181    pub harness_session_id: Option<String>,
182    /// Only sessions captured for this authenticated subject.
183    pub auth_subject: Option<String>,
184}
185
186impl ContractParams for SessionListParams {
187    const OPERATION: &'static str = "listSessions";
188
189    fn values(&self) -> Vec<(&'static str, String)> {
190        let mut values = Vec::new();
191        push_num(&mut values, "limit", self.limit);
192        push(&mut values, "cursor", self.cursor.as_deref());
193        push(&mut values, "sort", self.sort.as_deref());
194        push_enum(&mut values, "direction", self.direction);
195        push(&mut values, "since", self.since.as_deref());
196        push(&mut values, "until", self.until.as_deref());
197        push(&mut values, "harness_id", self.harness_id.as_deref());
198        push(
199            &mut values,
200            "harness_session_id",
201            self.harness_session_id.as_deref(),
202        );
203        push(&mut values, "auth_subject", self.auth_subject.as_deref());
204        values
205    }
206}
207
208/// `GET /v1/sessions/{id}/traces` — the derived span read model.
209#[derive(Debug, Clone, Default, PartialEq, Eq)]
210pub struct SessionTracesParams {
211    /// How much of each span's payload to carry.
212    pub payload: Option<PayloadDetail>,
213}
214
215impl ContractParams for SessionTracesParams {
216    const OPERATION: &'static str = "getSessionTraces";
217
218    fn values(&self) -> Vec<(&'static str, String)> {
219        let mut values = Vec::new();
220        push_enum(&mut values, "payload", self.payload);
221        values
222    }
223}
224
225/// `GET /v1/traces/{trace_id}` — one trace with its spans.
226#[derive(Debug, Clone, Default, PartialEq, Eq)]
227pub struct TraceParams {
228    /// How much of each span's payload to carry.
229    pub payload: Option<PayloadDetail>,
230}
231
232impl ContractParams for TraceParams {
233    const OPERATION: &'static str = "getTrace";
234
235    fn values(&self) -> Vec<(&'static str, String)> {
236        let mut values = Vec::new();
237        push_enum(&mut values, "payload", self.payload);
238        values
239    }
240}
241
242/// `GET /v1/traces` — the trace summaries for one session.
243///
244/// `session_id` is required: the contract scopes this listing with it, and a
245/// call without one is refused before it is sent rather than answering a
246/// different question than the caller asked.
247#[derive(Debug, Clone, Default, PartialEq, Eq)]
248pub struct TraceListParams {
249    /// The session whose traces to list.
250    pub session_id: String,
251}
252
253impl ContractParams for TraceListParams {
254    const OPERATION: &'static str = "listTraces";
255
256    fn values(&self) -> Vec<(&'static str, String)> {
257        vec![("session_id", self.session_id.clone())]
258    }
259}
260
261/// `GET /v1/search/spans` — semantic search over span embeddings.
262///
263/// `query` is required, for the same reason [`TraceListParams::session_id`] is.
264#[derive(Debug, Clone, Default, PartialEq, Eq)]
265pub struct SearchSpansParams {
266    /// The search text.
267    pub query: String,
268    /// How many hits to return.
269    pub top_k: Option<u32>,
270}
271
272impl ContractParams for SearchSpansParams {
273    const OPERATION: &'static str = "searchSpans";
274
275    fn values(&self) -> Vec<(&'static str, String)> {
276        let mut values = vec![("query", self.query.clone())];
277        push_num(&mut values, "top_k", self.top_k);
278        values
279    }
280}
281
282/// `GET /v1/sessions/{id}/export` — one session's export stream.
283#[derive(Debug, Clone, Default, PartialEq, Eq)]
284pub struct ExportSessionParams {
285    /// The grain to write.
286    pub detail: Option<ExportDetail>,
287}
288
289impl ContractParams for ExportSessionParams {
290    const OPERATION: &'static str = "exportSession";
291
292    fn values(&self) -> Vec<(&'static str, String)> {
293        let mut values = Vec::new();
294        push_enum(&mut values, "detail", self.detail);
295        values
296    }
297}
298
299/// `GET /v1/sessions/export` — every session in a window, streamed.
300#[derive(Debug, Clone, Default, PartialEq, Eq)]
301pub struct ExportSessionsParams {
302    /// Lower bound, as an RFC 3339 timestamp.
303    pub since: Option<String>,
304    /// Upper bound, as an RFC 3339 timestamp.
305    pub until: Option<String>,
306    /// The grain to write.
307    pub detail: Option<ExportDetail>,
308}
309
310impl ContractParams for ExportSessionsParams {
311    const OPERATION: &'static str = "exportSessions";
312
313    fn values(&self) -> Vec<(&'static str, String)> {
314        let mut values = Vec::new();
315        push(&mut values, "since", self.since.as_deref());
316        push(&mut values, "until", self.until.as_deref());
317        push_enum(&mut values, "detail", self.detail);
318        values
319    }
320}
321
322/// `GET /v1/skills` — the skills listing.
323#[derive(Debug, Clone, Default, PartialEq, Eq)]
324pub struct SkillsListParams {
325    /// How many skills to return.
326    pub limit: Option<u32>,
327    /// The cursor from a previous page's `next_cursor`.
328    pub cursor: Option<String>,
329    /// Free-text search.
330    pub q: Option<String>,
331    /// Whose skills to list.
332    pub scope: Option<SkillScope>,
333    /// How to order them.
334    pub sort: Option<SkillSort>,
335}
336
337impl ContractParams for SkillsListParams {
338    const OPERATION: &'static str = "listSkills";
339
340    fn values(&self) -> Vec<(&'static str, String)> {
341        let mut values = Vec::new();
342        push_num(&mut values, "limit", self.limit);
343        push(&mut values, "cursor", self.cursor.as_deref());
344        push(&mut values, "q", self.q.as_deref());
345        push_enum(&mut values, "scope", self.scope);
346        push_enum(&mut values, "sort", self.sort);
347        values
348    }
349}
350
351/// `GET /v1/stats` — the aggregate rollups.
352#[derive(Debug, Clone, Default, PartialEq, Eq)]
353pub struct StatsParams {
354    /// Lower bound, as an RFC 3339 timestamp.
355    pub since: Option<String>,
356    /// Upper bound, as an RFC 3339 timestamp.
357    pub until: Option<String>,
358}
359
360impl ContractParams for StatsParams {
361    const OPERATION: &'static str = "getStats";
362
363    fn values(&self) -> Vec<(&'static str, String)> {
364        let mut values = Vec::new();
365        push(&mut values, "since", self.since.as_deref());
366        push(&mut values, "until", self.until.as_deref());
367        values
368    }
369}
370
371fn push(values: &mut Vec<(&'static str, String)>, wire: &'static str, value: Option<&str>) {
372    if let Some(value) = value {
373        values.push((wire, value.to_owned()));
374    }
375}
376
377fn push_num(values: &mut Vec<(&'static str, String)>, wire: &'static str, value: Option<u32>) {
378    if let Some(value) = value {
379        values.push((wire, value.to_string()));
380    }
381}
382
383fn push_enum<E: ContractEnum>(
384    values: &mut Vec<(&'static str, String)>,
385    wire: &'static str,
386    value: Option<E>,
387) {
388    if let Some(value) = value {
389        values.push((wire, value.as_str().to_owned()));
390    }
391}
392
393#[cfg(test)]
394#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn an_unset_optional_parameter_is_omitted_rather_than_defaulted() {
400        // The omit-when-unset rule, at the typed layer: sending `limit=50`
401        // because the caller said nothing would pin this client to today's
402        // server default forever.
403        assert!(SessionListParams::default().values().is_empty());
404    }
405
406    #[test]
407    fn a_set_parameter_travels_under_the_contracts_own_name() {
408        let params = SessionListParams {
409            limit: Some(25),
410            direction: Some(SortDirection::Desc),
411            ..Default::default()
412        };
413        assert_eq!(
414            params.values(),
415            vec![("limit", "25".to_owned()), ("direction", "desc".to_owned())],
416        );
417    }
418
419    #[test]
420    fn a_required_parameter_is_always_sent_even_when_it_is_empty() {
421        // Empty is a value the server can reject in its own words; omitting it
422        // is a differently-shaped request the contract layer would refuse.
423        assert_eq!(
424            SearchSpansParams::default().values(),
425            vec![("query", String::new())],
426        );
427    }
428}