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