Skip to main content

tapes_client/cassettes/
discovery.rs

1//! The `/v1/cassettes` discovery document.
2//!
3//! Only the fields this client acts on are modelled. The rest of the document —
4//! `tables`, `depends`, `config` and the other manifest projections — is an
5//! operator's view of what a cassette *is*, and deployment/configuration is
6//! deliberately not part of the generated command surface.
7//!
8//! Note which digest is which: `manifest_digest` covers the cassette's manifest,
9//! while the `ETag` on the OpenAPI route covers the republished document. They
10//! are two digests over two different byte streams, so the cache revalidates
11//! with the ETag and keeps `manifest_digest` only for reporting.
12
13use serde::{Deserialize, Serialize};
14
15/// The document served at `GET /v1/cassettes`.
16#[derive(Debug, Clone, Default, Serialize, Deserialize)]
17pub struct Discovery {
18    /// The newest cassette contract this core serves. May legitimately be empty
19    /// when the configured set is malformed.
20    #[serde(default)]
21    pub contract_version: String,
22
23    /// What is installed here. Never null, and ordered by name.
24    #[serde(default)]
25    pub cassettes: Vec<DiscoveryEntry>,
26
27    /// Configured cassette sources that could not be loaded. Never null.
28    ///
29    /// Carried so `--help` can say *why* an expected cassette is missing rather
30    /// than leaving the user to guess; an operator's typo in a cassette URL is
31    /// otherwise indistinguishable from the cassette not existing.
32    #[serde(default)]
33    pub problems: Vec<Problem>,
34}
35
36/// One served cassette.
37#[derive(Debug, Clone, Default, Serialize, Deserialize)]
38pub struct DiscoveryEntry {
39    /// The cassette's name, which is also its noun on the command line.
40    pub name: String,
41
42    /// The cassette's own version, when core could project its manifest.
43    #[serde(default)]
44    pub version: Option<String>,
45
46    /// A human-facing name, when the manifest carries one.
47    #[serde(default)]
48    pub display_name: Option<String>,
49
50    /// One line of prose, used as the subcommand's `about`.
51    #[serde(default)]
52    pub description: Option<String>,
53
54    /// Where the cassette is mounted, `/v1/cassettes/<name>`.
55    #[serde(default)]
56    pub route_prefix: String,
57
58    /// Where this cassette's OpenAPI document is served.
59    #[serde(default)]
60    pub openapi_path: String,
61
62    /// How current core's cached copy of that document is: `fresh`, `stale`, or
63    /// `missing`. `missing` is normal at boot, before anything is fetched.
64    #[serde(default)]
65    pub openapi_status: String,
66
67    /// Digest of the cassette's manifest. Reported, not used as a cache key —
68    /// see the module docs.
69    #[serde(default)]
70    pub manifest_digest: String,
71}
72
73impl DiscoveryEntry {
74    /// Whether core has a document to serve for this cassette.
75    ///
76    /// A `missing` spec is not an error: core publishes the cassette as soon as
77    /// it is admitted and fetches the document on its own schedule. There is
78    /// simply nothing to generate commands from yet.
79    #[must_use]
80    pub fn has_spec(&self) -> bool {
81        !self.openapi_path.is_empty() && self.openapi_status != "missing"
82    }
83}
84
85/// A configured cassette source core refused, and why.
86#[derive(Debug, Clone, Default, Serialize, Deserialize)]
87pub struct Problem {
88    /// The configured OpenAPI URL, with any credential already redacted by the
89    /// server.
90    #[serde(default)]
91    pub subject: String,
92
93    /// Prose. Never parsed.
94    #[serde(default)]
95    pub reason: String,
96}
97
98#[cfg(test)]
99#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn a_document_decodes_from_the_servers_own_field_names() {
105        let document: Discovery = serde_json::from_str(
106            r#"{
107                "contract_version": "v1",
108                "cassettes": [{
109                    "name": "summary",
110                    "version": "0.3.1",
111                    "display_name": "Summaries",
112                    "description": "Rolling summaries",
113                    "route_prefix": "/v1/cassettes/summary",
114                    "tables": ["summary.summary"],
115                    "config": [{"key": "llm.model", "type": "string", "required": false, "secret": false}],
116                    "openapi_path": "/v1/cassettes/summary/openapi.json",
117                    "openapi_status": "fresh",
118                    "manifest_digest": "sha256:abc"
119                }],
120                "problems": [{"subject": "http://sidecar.invalid/openapi", "reason": "kind is required"}]
121            }"#,
122        )
123        .unwrap();
124
125        assert_eq!(document.contract_version, "v1");
126        assert_eq!(document.cassettes[0].name, "summary");
127        assert_eq!(
128            document.cassettes[0].openapi_path,
129            "/v1/cassettes/summary/openapi.json",
130        );
131        assert!(document.cassettes[0].has_spec());
132        assert_eq!(document.problems[0].reason, "kind is required");
133    }
134
135    #[test]
136    fn fields_this_client_does_not_model_do_not_break_the_decode() {
137        // `tables` and `config` are an operator's view and are skipped on
138        // purpose; a stricter decode would turn a server that grows a field into
139        // a client that cannot read discovery at all.
140        let document: Discovery = serde_json::from_str(
141            r#"{"contract_version":"v1","cassettes":[{"name":"x","route_prefix":"/v1/cassettes/x",
142                "openapi_path":"/v1/cassettes/x/openapi.json","openapi_status":"fresh",
143                "manifest_digest":"","a_field_from_the_future":7}],"problems":[]}"#,
144        )
145        .unwrap();
146
147        assert_eq!(document.cassettes[0].name, "x");
148    }
149
150    #[test]
151    fn an_empty_install_is_a_document_not_an_error() {
152        let document: Discovery =
153            serde_json::from_str(r#"{"contract_version":"v1","cassettes":[],"problems":[]}"#)
154                .unwrap();
155        assert!(document.cassettes.is_empty());
156    }
157
158    #[test]
159    fn a_cassette_whose_spec_core_has_not_fetched_yet_generates_nothing() {
160        // `missing` is the honest answer at boot, not a failure.
161        let entry = DiscoveryEntry {
162            name: "summary".to_owned(),
163            openapi_path: "/v1/cassettes/summary/openapi.json".to_owned(),
164            openapi_status: "missing".to_owned(),
165            ..Default::default()
166        };
167        assert!(!entry.has_spec());
168
169        // `stale` still has a document, and a stale surface beats none: core
170        // keeps serving it precisely so a client can read a cassette that is
171        // currently down.
172        let stale = DiscoveryEntry {
173            openapi_status: "stale".to_owned(),
174            ..entry
175        };
176        assert!(stale.has_spec());
177    }
178}