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}