tapes-client 0.2.0

One client for the whole tapes read surface: the sealed contract and a deployment's discovered cassettes, driven through a single pluggable transport.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
//! Typed parameters for the sealed operations that take them.
//!
//! # Why these are types and not `Vec<(&str, String)>`
//!
//! The untyped form is still there — [`crate::core::CoreClient::call`] takes
//! wire-named pairs, and always will, because that is what makes an operation
//! this crate never named still reachable. But a pair list is checked against
//! the contract at *runtime*: a misspelled `payolad` is refused when the call
//! is made, which is late for a name that was wrong the moment it was typed.
//! These structs move the spelling to compile time and leave the runtime check
//! exactly where it was, as the backstop for the untyped route.
//!
//! # Why a request enum is closed and a response string is not
//!
//! [`super`]'s models keep `status` and `kind` as `String` because an added
//! variant must never fail a decode. A *request* parameter is the opposite
//! situation: the value is the client's to choose, the contract declares the
//! closed set the server accepts, and a value outside it is a 400 the client
//! could have prevented. So where the document declares an `enum`, this module
//! declares one too — and [`super::coverage`] holds the two together.

use serde::{Deserialize, Serialize};

/// One operation's parameters, in the shape the contract declares them.
pub trait ContractParams {
    /// The `operationId` these parameters belong to.
    const OPERATION: &'static str;

    /// The wire pairs to send: set parameters only, under the contract's own
    /// names.
    ///
    /// An unset optional parameter is omitted rather than sent as a default,
    /// so the server's default applies and this client never has to be
    /// updated when one of them changes.
    fn values(&self) -> Vec<(&'static str, String)>;
}

/// A parameter whose accepted values the contract closes with an `enum`.
pub trait ContractEnum: Sized + Copy {
    /// Where the contract declares this set: `(operationId, parameter)`, once
    /// per operation that takes it.
    const DECLARED_BY: &'static [(&'static str, &'static str)];

    /// Every value, in the document's own spelling.
    const VALUES: &'static [&'static str];

    /// This value, as it travels.
    fn as_str(self) -> &'static str;
}

/// How much of a span's payload a trace read should carry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PayloadDetail {
    /// Whole payloads.
    Full,
    /// Truncated payloads, with a marker on the spans that were cut.
    Preview,
}

impl ContractEnum for PayloadDetail {
    const DECLARED_BY: &'static [(&'static str, &'static str)] =
        &[("getSessionTraces", "payload"), ("getTrace", "payload")];
    const VALUES: &'static [&'static str] = &["full", "preview"];

    fn as_str(self) -> &'static str {
        match self {
            Self::Full => "full",
            Self::Preview => "preview",
        }
    }
}

/// The grain an export is written at.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ExportDetail {
    /// One record per span.
    Spans,
    /// One record per trace.
    Traces,
}

impl ContractEnum for ExportDetail {
    const DECLARED_BY: &'static [(&'static str, &'static str)] =
        &[("exportSession", "detail"), ("exportSessions", "detail")];
    const VALUES: &'static [&'static str] = &["spans", "traces"];

    fn as_str(self) -> &'static str {
        match self {
            Self::Spans => "spans",
            Self::Traces => "traces",
        }
    }
}

/// Which way a listing is ordered.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SortDirection {
    /// Oldest, or lowest, first.
    Asc,
    /// Newest, or highest, first.
    Desc,
}

impl ContractEnum for SortDirection {
    const DECLARED_BY: &'static [(&'static str, &'static str)] = &[("listSessions", "direction")];
    const VALUES: &'static [&'static str] = &["asc", "desc"];

    fn as_str(self) -> &'static str {
        match self {
            Self::Asc => "asc",
            Self::Desc => "desc",
        }
    }
}

/// Whose skills a listing covers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SkillScope {
    /// Everything the caller may see.
    All,
    /// Only the caller's own.
    Mine,
    /// Everyone else's.
    Team,
}

impl ContractEnum for SkillScope {
    const DECLARED_BY: &'static [(&'static str, &'static str)] = &[("listSkills", "scope")];
    const VALUES: &'static [&'static str] = &["all", "mine", "team"];

    fn as_str(self) -> &'static str {
        match self {
            Self::All => "all",
            Self::Mine => "mine",
            Self::Team => "team",
        }
    }
}

/// How a skills listing is ordered.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SkillSort {
    /// Most downloaded first.
    Downloads,
}

impl ContractEnum for SkillSort {
    const DECLARED_BY: &'static [(&'static str, &'static str)] = &[("listSkills", "sort")];
    const VALUES: &'static [&'static str] = &["downloads"];

    fn as_str(self) -> &'static str {
        match self {
            Self::Downloads => "downloads",
        }
    }
}

/// `GET /v1/sessions` — the sessions listing.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SessionListParams {
    /// How many sessions to return.
    pub limit: Option<u32>,
    /// The cursor from a previous page's `next_cursor`.
    pub cursor: Option<String>,
    /// Which column to order by.
    pub sort: Option<String>,
    /// Which way to order it.
    pub direction: Option<SortDirection>,
    /// Lower bound on activity, as an RFC 3339 timestamp.
    pub since: Option<String>,
    /// Upper bound on activity, as an RFC 3339 timestamp.
    pub until: Option<String>,
    /// With [`Self::harness_session_id`], narrows the point lookup to the
    /// single session with this harness id. Rejected alone (400): a harness
    /// id names a harness, not a session.
    pub harness_id: Option<String>,
    /// Only sessions with this harness-side id (exact match). Alone it
    /// matches across all harnesses — at most one row per harness; with
    /// [`Self::harness_id`] it is a single-harness point lookup. The server
    /// refuses either combined with `cursor`, `sort`, `direction`, `since`,
    /// or `until` (400), and ignores `limit` while the filter is active.
    pub harness_session_id: Option<String>,
    /// Only sessions captured for this authenticated subject.
    pub auth_subject: Option<String>,
}

impl ContractParams for SessionListParams {
    const OPERATION: &'static str = "listSessions";

    fn values(&self) -> Vec<(&'static str, String)> {
        let mut values = Vec::new();
        push_num(&mut values, "limit", self.limit);
        push(&mut values, "cursor", self.cursor.as_deref());
        push(&mut values, "sort", self.sort.as_deref());
        push_enum(&mut values, "direction", self.direction);
        push(&mut values, "since", self.since.as_deref());
        push(&mut values, "until", self.until.as_deref());
        push(&mut values, "harness_id", self.harness_id.as_deref());
        push(
            &mut values,
            "harness_session_id",
            self.harness_session_id.as_deref(),
        );
        push(&mut values, "auth_subject", self.auth_subject.as_deref());
        values
    }
}

/// `GET /v1/sessions/{id}/traces` — the derived span read model.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SessionTracesParams {
    /// How much of each span's payload to carry.
    pub payload: Option<PayloadDetail>,
}

impl ContractParams for SessionTracesParams {
    const OPERATION: &'static str = "getSessionTraces";

    fn values(&self) -> Vec<(&'static str, String)> {
        let mut values = Vec::new();
        push_enum(&mut values, "payload", self.payload);
        values
    }
}

/// `GET /v1/traces/{trace_id}` — one trace with its spans.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TraceParams {
    /// How much of each span's payload to carry.
    pub payload: Option<PayloadDetail>,
}

impl ContractParams for TraceParams {
    const OPERATION: &'static str = "getTrace";

    fn values(&self) -> Vec<(&'static str, String)> {
        let mut values = Vec::new();
        push_enum(&mut values, "payload", self.payload);
        values
    }
}

/// `GET /v1/traces` — the trace summaries for one session.
///
/// `session_id` is required: the contract scopes this listing with it, and a
/// call without one is refused before it is sent rather than answering a
/// different question than the caller asked.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TraceListParams {
    /// The session whose traces to list.
    pub session_id: String,
}

impl ContractParams for TraceListParams {
    const OPERATION: &'static str = "listTraces";

    fn values(&self) -> Vec<(&'static str, String)> {
        vec![("session_id", self.session_id.clone())]
    }
}

/// `GET /v1/search/spans` — semantic search over span embeddings.
///
/// `query` is required, for the same reason [`TraceListParams::session_id`] is.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SearchSpansParams {
    /// The search text.
    pub query: String,
    /// How many hits to return.
    pub top_k: Option<u32>,
}

impl ContractParams for SearchSpansParams {
    const OPERATION: &'static str = "searchSpans";

    fn values(&self) -> Vec<(&'static str, String)> {
        let mut values = vec![("query", self.query.clone())];
        push_num(&mut values, "top_k", self.top_k);
        values
    }
}

/// `GET /v1/sessions/{id}/export` — one session's export stream.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExportSessionParams {
    /// The grain to write.
    pub detail: Option<ExportDetail>,
}

impl ContractParams for ExportSessionParams {
    const OPERATION: &'static str = "exportSession";

    fn values(&self) -> Vec<(&'static str, String)> {
        let mut values = Vec::new();
        push_enum(&mut values, "detail", self.detail);
        values
    }
}

/// `GET /v1/sessions/export` — every session in a window, streamed.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExportSessionsParams {
    /// Lower bound, as an RFC 3339 timestamp.
    pub since: Option<String>,
    /// Upper bound, as an RFC 3339 timestamp.
    pub until: Option<String>,
    /// The grain to write.
    pub detail: Option<ExportDetail>,
}

impl ContractParams for ExportSessionsParams {
    const OPERATION: &'static str = "exportSessions";

    fn values(&self) -> Vec<(&'static str, String)> {
        let mut values = Vec::new();
        push(&mut values, "since", self.since.as_deref());
        push(&mut values, "until", self.until.as_deref());
        push_enum(&mut values, "detail", self.detail);
        values
    }
}

/// `GET /v1/skills` — the skills listing.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SkillsListParams {
    /// How many skills to return.
    pub limit: Option<u32>,
    /// The cursor from a previous page's `next_cursor`.
    pub cursor: Option<String>,
    /// Free-text search.
    pub q: Option<String>,
    /// Whose skills to list.
    pub scope: Option<SkillScope>,
    /// How to order them.
    pub sort: Option<SkillSort>,
}

impl ContractParams for SkillsListParams {
    const OPERATION: &'static str = "listSkills";

    fn values(&self) -> Vec<(&'static str, String)> {
        let mut values = Vec::new();
        push_num(&mut values, "limit", self.limit);
        push(&mut values, "cursor", self.cursor.as_deref());
        push(&mut values, "q", self.q.as_deref());
        push_enum(&mut values, "scope", self.scope);
        push_enum(&mut values, "sort", self.sort);
        values
    }
}

/// `GET /v1/stats` — the aggregate rollups.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StatsParams {
    /// Lower bound, as an RFC 3339 timestamp.
    pub since: Option<String>,
    /// Upper bound, as an RFC 3339 timestamp.
    pub until: Option<String>,
    /// Narrow every total to sessions captured for this authenticated
    /// subject — the same subject the sessions listing filters by, so totals
    /// can agree with the rows beside them. Omitted, the totals are org-wide.
    pub auth_subject: Option<String>,
}

impl ContractParams for StatsParams {
    const OPERATION: &'static str = "getStats";

    fn values(&self) -> Vec<(&'static str, String)> {
        let mut values = Vec::new();
        push(&mut values, "since", self.since.as_deref());
        push(&mut values, "until", self.until.as_deref());
        push(&mut values, "auth_subject", self.auth_subject.as_deref());
        values
    }
}

fn push(values: &mut Vec<(&'static str, String)>, wire: &'static str, value: Option<&str>) {
    if let Some(value) = value {
        values.push((wire, value.to_owned()));
    }
}

fn push_num(values: &mut Vec<(&'static str, String)>, wire: &'static str, value: Option<u32>) {
    if let Some(value) = value {
        values.push((wire, value.to_string()));
    }
}

fn push_enum<E: ContractEnum>(
    values: &mut Vec<(&'static str, String)>,
    wire: &'static str,
    value: Option<E>,
) {
    if let Some(value) = value {
        values.push((wire, value.as_str().to_owned()));
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
    use super::*;

    #[test]
    fn an_unset_optional_parameter_is_omitted_rather_than_defaulted() {
        // The omit-when-unset rule, at the typed layer: sending `limit=50`
        // because the caller said nothing would pin this client to today's
        // server default forever.
        assert!(SessionListParams::default().values().is_empty());
    }

    #[test]
    fn a_set_parameter_travels_under_the_contracts_own_name() {
        let params = SessionListParams {
            limit: Some(25),
            direction: Some(SortDirection::Desc),
            ..Default::default()
        };
        assert_eq!(
            params.values(),
            vec![("limit", "25".to_owned()), ("direction", "desc".to_owned())],
        );
    }

    #[test]
    fn a_required_parameter_is_always_sent_even_when_it_is_empty() {
        // Empty is a value the server can reject in its own words; omitting it
        // is a differently-shaped request the contract layer would refuse.
        assert_eq!(
            SearchSpansParams::default().values(),
            vec![("query", String::new())],
        );
    }
}