super-stt-daemon/src/daemon/http/url_surface_contract.rs
Line | Count | Source |
1 | | // SPDX-License-Identifier: GPL-3.0-only |
2 | | //! Contract: the URL surface is exactly this, and its namespaces mean something. |
3 | | //! |
4 | | //! `openapi_contract` checks each operation is *described* well. This checks the |
5 | | //! set of paths itself — what a client can call, and where. |
6 | | //! |
7 | | //! The inventory below is the point. A path is a promise: rename one and every |
8 | | //! client 404s at the moment it is upgraded, with nothing in the daemon's own |
9 | | //! logs to say why. Generating the document from the router means the two can |
10 | | //! never disagree, but it also means a rename regenerates cleanly and passes |
11 | | //! every check — the mistake becomes the new truth. So the surface is written |
12 | | //! out once, by hand, and any change to it has to be made here too, in a diff a |
13 | | //! reviewer can read. |
14 | | //! |
15 | | //! Adding an endpoint is meant to fail this test. Add its path to the list. |
16 | | |
17 | | use std::collections::{BTreeMap, BTreeSet}; |
18 | | |
19 | | use serde_json::Value; |
20 | | |
21 | | /// Every path the daemon serves, with the methods it answers on. |
22 | | /// |
23 | | /// Sorted, and deliberately spelled out rather than derived: this is the list a |
24 | | /// reviewer reads to see what the URL surface became. |
25 | | const URL_SURFACE: &[(&str, &str)] = &[ |
26 | | ("/v1/auth/request", "post"), |
27 | | ("/v1/auth/status", "get"), |
28 | | ("/v1/backend/list", "get"), |
29 | | ("/v1/backend/{backend_id}", "delete"), |
30 | | ("/v1/backend/{backend_id}/context", "delete,get,post"), |
31 | | ("/v1/backend/{backend_id}/option/list", "get"), |
32 | | ("/v1/backend/{backend_id}/option/{name}", "delete,get,post"), |
33 | | ("/v1/backend/{backend_id}/secret/list", "get"), |
34 | | ("/v1/backend/{backend_id}/secret/{name}", "delete,get,post"), |
35 | | ("/v1/context/active", "get,post"), |
36 | | ("/v1/context/list", "get"), |
37 | | ("/v1/context/{id}", "delete,get,post"), |
38 | | ("/v1/events", "get"), |
39 | | ("/v1/gpu_info", "get"), |
40 | | ("/v1/ping", "get"), |
41 | | ("/v1/pipeline", "get"), |
42 | | ("/v1/pipeline/{stage}", "delete,get,post"), |
43 | | ("/v1/pipeline/{stage}/backend/list", "get"), |
44 | | ("/v1/pipeline/{stage}/device/list", "get"), |
45 | | ("/v1/pipeline/{stage}/model", "delete,get,post"), |
46 | | ("/v1/pipeline/{stage}/model/cancel", "post"), |
47 | | ("/v1/pipeline/{stage}/model/list", "get"), |
48 | | ("/v1/pipeline/{stage}/model/reload", "post"), |
49 | | ("/v1/pipeline/{stage}/model/{model}/device", "get,post"), |
50 | | ("/v1/pipeline/{stage}/model/{model}/device/list", "get"), |
51 | | ( |
52 | | "/v1/pipeline/{stage}/model/{model}/language", |
53 | | "delete,get,post", |
54 | | ), |
55 | | ("/v1/pipeline/{stage}/model/{model}/language/list", "get"), |
56 | | ("/v1/registry/backend/list", "get"), |
57 | | ("/v1/registry/backend/preview", "post"), |
58 | | ("/v1/registry/backend/install", "post"), |
59 | | ("/v1/registry/backend/refresh", "post"), |
60 | | ("/v1/registry/backend/update", "post"), |
61 | | ("/v1/settings/audio_theme", "get,post"), |
62 | | ("/v1/settings/audio_theme/list", "get"), |
63 | | ("/v1/settings/audio_theme/test", "post"), |
64 | | ("/v1/settings/custom_models_dir", "get,post"), |
65 | | ("/v1/settings/language", "delete,get,post"), |
66 | | ("/v1/settings/language/list", "get"), |
67 | | ("/v1/settings/notification_method", "get,post"), |
68 | | ("/v1/settings/preview_typing", "get,post"), |
69 | | ("/v1/settings/recording_stop_mode", "get,post"), |
70 | | ("/v1/settings/update_beta_optin", "get,post"), |
71 | | ("/v1/settings/update_check_enabled", "get,post"), |
72 | | ("/v1/settings/volume", "get,post"), |
73 | | ("/v1/settings/write_method", "get,post"), |
74 | | ("/v1/settings/write_method/test", "post"), |
75 | | ("/v1/status", "get"), |
76 | | ("/v1/transcribe", "post"), |
77 | | ("/v1/transcribe/realtime", "get"), |
78 | | ("/v1/transcribe/stop", "post"), |
79 | | ("/v1/update", "get"), |
80 | | ("/v1/update/check", "post"), |
81 | | ]; |
82 | | |
83 | | /// The generated document, as a consumer receives it. |
84 | 5 | fn document() -> Value { |
85 | 5 | serde_json::to_value(super::openapi_document()).expect("the document serializes") |
86 | 5 | } |
87 | | |
88 | | /// `path -> "delete,get,post"` for what the router actually serves. |
89 | 2 | fn served() -> BTreeMap<String, String> { |
90 | | const METHODS: [&str; 7] = ["get", "put", "post", "delete", "options", "head", "patch"]; |
91 | 2 | let doc = document(); |
92 | 2 | let mut out = BTreeMap::new(); |
93 | 104 | for (path, item) in doc["paths"]2 .as_object2 ().expect2 ("paths is an object"2 ) { |
94 | 104 | let mut methods: Vec<String> = item |
95 | 104 | .as_object() |
96 | 104 | .expect("a path item is an object") |
97 | 104 | .keys() |
98 | 158 | .filter104 (|m| METHODS.contains(&m.as_str())) |
99 | 104 | .cloned() |
100 | 104 | .collect(); |
101 | 104 | methods.sort(); |
102 | 104 | out.insert(path.clone(), methods.join(",")); |
103 | | } |
104 | 2 | out |
105 | 2 | } |
106 | | |
107 | | /// The whole URL surface, spelled out. |
108 | | /// |
109 | | /// Fails on any added, removed or renamed path, and on any method added to or |
110 | | /// dropped from one. The message names what moved, because "the surface |
111 | | /// changed" is not something a reviewer can act on. |
112 | | #[test] |
113 | 1 | fn the_url_surface_is_exactly_this() { |
114 | 1 | let served = served(); |
115 | 1 | let expected: BTreeMap<String, String> = URL_SURFACE |
116 | 1 | .iter() |
117 | 52 | .map1 (|(p, m)| ((*p).to_string(), (*m).to_string())) |
118 | 1 | .collect(); |
119 | | |
120 | 1 | assert_eq!( |
121 | 1 | URL_SURFACE.len(), |
122 | 1 | expected.len(), |
123 | | "URL_SURFACE lists a path twice" |
124 | | ); |
125 | | |
126 | 1 | let added: Vec<_> = served |
127 | 1 | .keys() |
128 | 52 | .filter1 (|p| !expected.contains_key(*p)) |
129 | 1 | .collect(); |
130 | 1 | let removed: Vec<_> = expected |
131 | 1 | .keys() |
132 | 52 | .filter1 (|p| !served.contains_key(*p)) |
133 | 1 | .collect(); |
134 | 1 | assert!( |
135 | 1 | added.is_empty() && removed.is_empty(), |
136 | | "the URL surface moved.\n served but not listed: {added:?}\n listed but not served: {removed:?}\n\ |
137 | | Both empty? Then a path was renamed — it will show as one of each." |
138 | | ); |
139 | | |
140 | 1 | let changed: Vec<String> = expected |
141 | 1 | .iter() |
142 | 52 | .filter_map1 (|(p, want)| { |
143 | 52 | let got = served.get(p)?0 ; |
144 | 52 | (got != want).then(|| format!0 ("{p}: listed {want}, serves {got}")) |
145 | 52 | }) |
146 | 1 | .collect(); |
147 | 1 | assert!( |
148 | 1 | changed.is_empty(), |
149 | | "methods changed:\n {}", |
150 | 0 | changed.join("\n ") |
151 | | ); |
152 | 1 | } |
153 | | |
154 | | /// Everything under `/v1/settings/` is a setting, and every setting is there. |
155 | | /// |
156 | | /// This is the invariant the namespace exists to carry. It is easy to lose in |
157 | | /// the obvious way: `settings` is also a *scope*, and the scope guards far more |
158 | | /// than the settings — `/backends`, `/pipeline` and `/registry` all sit behind |
159 | | /// it. Tagging by scope rather than by subject is what once filed a live GPU |
160 | | /// probe and a model catalog as "settings", which is how a reader ends up |
161 | | /// looking for `/v1/settings/gpu_info`. |
162 | | /// |
163 | | /// So: the namespace and the tag have to agree, in both directions. |
164 | | #[test] |
165 | 1 | fn the_settings_namespace_and_the_settings_tag_agree() { |
166 | 1 | let doc = document(); |
167 | | const METHODS: [&str; 7] = ["get", "put", "post", "delete", "options", "head", "patch"]; |
168 | | |
169 | 1 | let mut namespaced_but_untagged = Vec::new(); |
170 | 1 | let mut tagged_but_not_namespaced = Vec::new(); |
171 | | |
172 | 52 | for (path, item) in doc["paths"]1 .as_object1 ().expect1 ("paths is an object"1 ) { |
173 | 79 | for (method, op) in item52 .as_object52 ().expect52 ("a path item is an object"52 ) { |
174 | 79 | if !METHODS.contains(&method.as_str()) { |
175 | 0 | continue; |
176 | 79 | } |
177 | 79 | let tagged = op["tags"] |
178 | 79 | .as_array() |
179 | 79 | .expect("every operation carries tags") |
180 | 79 | .iter() |
181 | 79 | .any(|t| t == "settings"); |
182 | 79 | let namespaced = path.starts_with("/v1/settings/"); |
183 | 79 | match (namespaced, tagged) { |
184 | 0 | (true, false) => namespaced_but_untagged.push(format!("{method} {path}")), |
185 | 0 | (false, true) => tagged_but_not_namespaced.push(format!("{method} {path}")), |
186 | 79 | _ => {} |
187 | | } |
188 | | } |
189 | | } |
190 | | |
191 | 1 | assert!( |
192 | 1 | namespaced_but_untagged.is_empty(), |
193 | | "under /v1/settings/ but not tagged `settings`: {namespaced_but_untagged:?}" |
194 | | ); |
195 | 1 | assert!( |
196 | 1 | tagged_but_not_namespaced.is_empty(), |
197 | | "tagged `settings` but not under /v1/settings/: {tagged_but_not_namespaced:?}\n\ |
198 | | Either move it into the namespace, or give it the tag its subject deserves — \ |
199 | | sharing the `settings` scope is not the same as being a setting." |
200 | | ); |
201 | 1 | } |
202 | | |
203 | | /// Every `/v1/settings/` path is guarded by the `settings` scope. |
204 | | /// |
205 | | /// The namespace is a promise about access as much as about subject. A settings |
206 | | /// path reachable with a `status` token — or with no token — would be a hole a |
207 | | /// reader has no reason to go looking for, precisely because the prefix says |
208 | | /// what it says. |
209 | | #[test] |
210 | 1 | fn the_settings_namespace_is_settings_scoped() { |
211 | 1 | let wrong: Vec<String> = super::v1::enforced_scopes() |
212 | 1 | .into_iter() |
213 | 52 | .filter1 (|(path, _)| path.starts_with("/v1/settings/")) |
214 | 14 | .filter1 (|(_, scopes)| scopes.as_deref() != Some(&["settings"])) |
215 | 1 | .map(|(path, scopes)| format!0 ("{path}: guarded by {scopes:?}")) |
216 | 1 | .collect(); |
217 | 1 | assert!( |
218 | 1 | wrong.is_empty(), |
219 | | "settings paths not behind the `settings` scope:\n {}", |
220 | 0 | wrong.join("\n ") |
221 | | ); |
222 | 1 | } |
223 | | |
224 | | /// No path is served under two spellings. |
225 | | /// |
226 | | /// Registering a handler twice under different paths is silent: both answer, |
227 | | /// the document lists both, and clients split between them until one is |
228 | | /// removed. The `summary` is the cheapest per-operation identity available. |
229 | | #[test] |
230 | 1 | fn no_operation_is_served_at_two_paths() { |
231 | 1 | let doc = document(); |
232 | | const METHODS: [&str; 7] = ["get", "put", "post", "delete", "options", "head", "patch"]; |
233 | 1 | let mut seen: BTreeMap<String, String> = BTreeMap::new(); |
234 | 1 | let mut dupes = Vec::new(); |
235 | | |
236 | 52 | for (path, item) in doc["paths"]1 .as_object1 ().expect1 ("paths is an object"1 ) { |
237 | 79 | for (method, op) in item52 .as_object52 ().expect52 ("a path item is an object"52 ) { |
238 | 79 | if !METHODS.contains(&method.as_str()) { |
239 | 0 | continue; |
240 | 79 | } |
241 | 79 | let summary = op["summary"] |
242 | 79 | .as_str() |
243 | 79 | .expect("every operation has a summary"); |
244 | 79 | if let Some(first0 ) = seen.insert(summary.to_string(), path.clone()) |
245 | 0 | && &first != path |
246 | 0 | { |
247 | 0 | dupes.push(format!("{summary:?}: {first} and {path}")); |
248 | 79 | } |
249 | | } |
250 | | } |
251 | 1 | assert!( |
252 | 1 | dupes.is_empty(), |
253 | | "the same operation is served at two paths:\n {}", |
254 | 0 | dupes.join("\n ") |
255 | | ); |
256 | 1 | } |
257 | | |
258 | | /// The declared tags and the tags in use are the same set. |
259 | | /// |
260 | | /// Both directions drift silently. A tag declared and no longer carried by any |
261 | | /// operation renders as an empty section in the published docs — which is how |
262 | | /// `models` was left behind when `GET /models` became |
263 | | /// `GET /pipeline/{stage}/model/list`. A tag used but never declared renders |
264 | | /// with no description at all, under a heading the reader has to guess at. |
265 | | #[test] |
266 | 1 | fn every_declared_tag_is_used_and_every_used_tag_is_declared() { |
267 | 1 | let doc = document(); |
268 | | const METHODS: [&str; 7] = ["get", "put", "post", "delete", "options", "head", "patch"]; |
269 | | |
270 | 1 | let declared: BTreeSet<String> = doc["tags"] |
271 | 1 | .as_array() |
272 | 1 | .expect("the document declares tags") |
273 | 1 | .iter() |
274 | 11 | .map1 (|t| t["name"].as_str().expect("a tag has a name").to_string()) |
275 | 1 | .collect(); |
276 | | |
277 | 1 | let mut used = BTreeSet::new(); |
278 | 52 | for (_, item) in doc["paths"]1 .as_object1 ().expect1 ("paths is an object"1 ) { |
279 | 79 | for (method, op) in item52 .as_object52 ().expect52 ("a path item is an object"52 ) { |
280 | 79 | if !METHODS.contains(&method.as_str()) { |
281 | 0 | continue; |
282 | 79 | } |
283 | 79 | for t in op["tags"].as_array().expect("every operation carries tags") { |
284 | 79 | used.insert(t.as_str().expect("a tag is a string").to_string()); |
285 | 79 | } |
286 | | } |
287 | | } |
288 | | |
289 | 1 | let orphaned: Vec<_> = declared.difference(&used).collect(); |
290 | 1 | let undeclared: Vec<_> = used.difference(&declared).collect(); |
291 | 1 | assert!( |
292 | 1 | orphaned.is_empty(), |
293 | | "declared but carried by no operation: {orphaned:?}" |
294 | | ); |
295 | 1 | assert!( |
296 | 1 | undeclared.is_empty(), |
297 | | "used but never declared, so they publish with no description: {undeclared:?}" |
298 | | ); |
299 | 1 | } |
300 | | |
301 | | /// Every path the document lists is one the guards know about. |
302 | | /// |
303 | | /// [`super::v1::enforced_scopes`] is built from the scope grouping, the document |
304 | | /// from the route registrations. A path in one and not the other means a route |
305 | | /// was registered outside every group — reachable, and guarded by nothing. |
306 | | #[test] |
307 | 1 | fn every_path_sits_in_a_scope_group() { |
308 | 1 | let guarded: BTreeSet<String> = super::v1::enforced_scopes().into_keys().collect(); |
309 | 1 | let served = served(); |
310 | 52 | let ungrouped1 : Vec<&String>1 = served1 .keys1 ().filter1 (|p| !guarded.contains(*p)).collect1 (); |
311 | 1 | assert!( |
312 | 1 | ungrouped.is_empty(), |
313 | | "served but in no scope group, so behind no guard: {ungrouped:?}" |
314 | | ); |
315 | 1 | } |