Skip to main content

mecha10_cli/services/
catalog_client.rs

1//! Live template/node catalog client for `mecha10-api`
2//!
3//! `mecha10 init`'s template/node selection and `mecha10 add <node>` used to validate against
4//! catalogs compiled directly into this CLI binary (`mecha10_templates::init::templates`'s
5//! hardcoded `TemplateType`/`Template::new(...)` list, and `mecha10_node_kind::FRAMEWORK_NODE_CATALOG`).
6//! `mecha10-api` now serves both catalogs live over HTTP instead:
7//!
8//! - `GET {mecha10_api_base_url}/templates/catalog` -> `[{ name, description, nodes }, ...]`
9//! - `GET {mecha10_api_base_url}/nodes/catalog` -> `[{ name, recommended, targets }, ...]`
10//!
11//! `targets` (added as the final step of the node->target(s) placement migration) is each
12//! node's live `targets.<dev|simulation|robot|remote>` placement, sourced from
13//! `mecha10-nodes`' own `Cargo.toml` `[package.metadata.mecha10] targets = [...]` and published
14//! to `mecha10-api`'s Postgres-backed catalog on every `mecha10-nodes` CI push - the single
15//! source of truth this CLI now reads directly instead of maintaining its own hand-written
16//! `node id -> targets` table (see `mecha10_cli_core::component_catalog::types` module history:
17//! that table, and the two separate tag/`run_target` inference functions it was merged with,
18//! are what let `pan-tilt`/`servo`/`video-streamer` drift from the real placement in the past).
19//!
20//! Both are public, unauthenticated endpoints served through the same `mecha10-api` base URL
21//! every other CLI-side catalog fetch already goes through (see
22//! `mecha10_models::services::model_service::catalog::fetch_catalog` and
23//! `handlers::add::fetch_framework_nodes` for the prior art this mirrors) - there is no
24//! bundled/compiled-in fallback for either, matching those callers: a project with no network
25//! access to `mecha10-api` can't resolve either catalog at all.
26
27use anyhow::{Context, Result};
28use serde::{Deserialize, Serialize};
29use std::time::Duration;
30
31const CATALOG_FETCH_TIMEOUT: Duration = Duration::from_secs(10);
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct TemplateCatalogEntry {
35    pub name: String,
36    pub description: String,
37    #[serde(default)]
38    pub nodes: Vec<String>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct NodeCatalogEntry {
43    pub name: String,
44    #[serde(default)]
45    pub recommended: bool,
46    /// Which `mecha10.json` `targets` entries (`dev`/`simulation`/`robot`/`remote`) this node
47    /// should be placed into - the live replacement for the retired
48    /// `mecha10_cli_core::component_catalog::types::NODE_TARGETS` table. `serde(default)` so a
49    /// response from a `mecha10-api` build that predates this field (or any other
50    /// differently-shaped response) still deserializes instead of hard-failing - the field is
51    /// expected to always be present on a current `mecha10-api`, this is just defensive.
52    #[serde(default)]
53    pub targets: Vec<String>,
54}
55
56/// `GET {mecha10_api_base_url}/templates/catalog` - the live catalog of robot project templates,
57/// replacing the compiled-in `mecha10_templates::init::templates::get_all_templates()`. No
58/// caching, no fallback: every call is a fresh HTTP round-trip.
59pub async fn fetch_template_catalog() -> Result<Vec<TemplateCatalogEntry>> {
60    let url = mecha10_cli_core::paths::urls::mecha10_api_path("/templates/catalog");
61
62    let client = mecha10_cli_core::utils::build_client()?;
63    let response = client
64        .get(&url)
65        .timeout(CATALOG_FETCH_TIMEOUT)
66        .send()
67        .await
68        .context(format!("Failed to fetch template catalog from {url}"))?
69        .error_for_status()
70        .context(format!("Template catalog request to {url} failed"))?;
71
72    response
73        .json::<Vec<TemplateCatalogEntry>>()
74        .await
75        .context(format!("Malformed template catalog response from {url}"))
76}
77
78/// `GET {mecha10_api_base_url}/nodes/catalog` - the live catalog of bundled framework nodes,
79/// replacing both `mecha10_node_kind::FRAMEWORK_NODE_CATALOG` (as the `mecha10 init`/`mecha10 add`
80/// selectable-list source) and the previous `handlers::add::fetch_framework_nodes` call against
81/// `/api/downloads/nodes`. No caching, no fallback: every call is a fresh HTTP round-trip.
82pub async fn fetch_node_catalog() -> Result<Vec<NodeCatalogEntry>> {
83    let url = mecha10_cli_core::paths::urls::mecha10_api_path("/nodes/catalog");
84
85    let client = mecha10_cli_core::utils::build_client()?;
86    let response = client
87        .get(&url)
88        .timeout(CATALOG_FETCH_TIMEOUT)
89        .send()
90        .await
91        .context(format!("Failed to fetch node catalog from {url}"))?
92        .error_for_status()
93        .context(format!("Node catalog request to {url} failed"))?;
94
95    response
96        .json::<Vec<NodeCatalogEntry>>()
97        .await
98        .context(format!("Malformed node catalog response from {url}"))
99}
100
101const FALLBACK_BASIC_NODES: &[&str] = &[
102    "speaker",
103    "listener",
104    "motor",
105    "imu",
106    "teleop",
107    "diagnostics",
108    "websocket-bridge",
109    "image-classifier",
110    "object-detector",
111    "llm-command",
112    "behavior-executor",
113    "simulator",
114    "rl",
115];
116
117/// Compute which of `available_ids` should be pre-checked in `mecha10 init`'s node-selection
118/// step, sourced from the live-fetched `templates` catalog instead of the compiled-in
119/// `Template::new(...)` list `mecha10_templates::init::defaults::get_template_defaults` used to
120/// read. `template_id` defaults to `"rover"` when omitted, exactly like that function; an
121/// unrecognized `template_id` falls back to [`FALLBACK_BASIC_NODES`], same as that function's own
122/// `get_fallback_defaults` fallback.
123///
124/// This intentionally doesn't error on an unrecognized `template_id` - non-interactive `mecha10
125/// init --template <name>` kept this same graceful-fallback behavior before this migration (no
126/// hard validation error), and this preserves that rather than introducing a new failure mode.
127pub fn template_node_defaults(
128    templates: &[TemplateCatalogEntry],
129    template_id: &Option<String>,
130    available_ids: &[String],
131) -> Vec<bool> {
132    let template_id_str = template_id.as_deref().unwrap_or("rover");
133
134    match templates.iter().find(|t| t.name == template_id_str) {
135        Some(template) => available_ids
136            .iter()
137            .map(|id| template.nodes.iter().any(|n| n == id))
138            .collect(),
139        None => available_ids
140            .iter()
141            .map(|id| FALLBACK_BASIC_NODES.contains(&id.as_str()))
142            .collect(),
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use std::io::{Read, Write};
150    use std::net::TcpListener;
151
152    /// Spins up a minimal local HTTP server (same raw-`TcpListener` approach used by
153    /// `handlers::add`'s `spawn_fake_node_list_server`) that answers every request with a fixed
154    /// JSON body, for driving these fetchers against a controlled response instead of the real
155    /// `mecha10-api`.
156    fn spawn_fake_catalog_server(body: String) -> std::net::SocketAddr {
157        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
158        let addr = listener.local_addr().unwrap();
159
160        std::thread::spawn(move || {
161            for stream in listener.incoming() {
162                let mut stream = match stream {
163                    Ok(s) => s,
164                    Err(_) => continue,
165                };
166                let mut buf = [0u8; 4096];
167                let _ = stream.read(&mut buf);
168                let header = format!(
169                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
170                    body.len()
171                );
172                let _ = stream.write_all(header.as_bytes());
173                let _ = stream.write_all(body.as_bytes());
174                let _ = stream.flush();
175            }
176        });
177
178        addr
179    }
180
181    struct FakeCatalogServer;
182
183    impl Drop for FakeCatalogServer {
184        fn drop(&mut self) {
185            std::env::remove_var("MECHA10_API_URL");
186        }
187    }
188
189    fn start_fake_catalog_server(body: String) -> FakeCatalogServer {
190        let addr = spawn_fake_catalog_server(body);
191        std::env::set_var("MECHA10_API_URL", format!("http://{}/api", addr));
192        FakeCatalogServer
193    }
194
195    #[tokio::test]
196    #[serial_test::serial(mecha10_api_url_env_var)]
197    async fn fetch_template_catalog_parses_entries_from_the_live_endpoint() {
198        let body = serde_json::json!([
199            {"name": "rover", "description": "Mobile robot", "nodes": ["speaker", "motor"]},
200            {"name": "drone", "description": "Flying robot", "nodes": []},
201        ])
202        .to_string();
203        let _server = start_fake_catalog_server(body);
204
205        let entries = fetch_template_catalog().await.unwrap();
206
207        assert_eq!(entries.len(), 2);
208        assert_eq!(entries[0].name, "rover");
209        assert_eq!(entries[0].nodes, vec!["speaker".to_string(), "motor".to_string()]);
210        assert_eq!(entries[1].name, "drone");
211        assert!(entries[1].nodes.is_empty());
212    }
213
214    #[tokio::test]
215    #[serial_test::serial(mecha10_api_url_env_var)]
216    async fn fetch_node_catalog_parses_entries_from_the_live_endpoint() {
217        let body = serde_json::json!([
218            {"name": "camera", "recommended": true, "targets": ["robot"]},
219            {"name": "motor", "recommended": true, "targets": ["robot"]},
220        ])
221        .to_string();
222        let _server = start_fake_catalog_server(body);
223
224        let entries = fetch_node_catalog().await.unwrap();
225
226        assert_eq!(entries.len(), 2);
227        assert_eq!(entries[0].name, "camera");
228        assert!(entries[0].recommended);
229        assert_eq!(entries[0].targets, vec!["robot".to_string()]);
230        assert_eq!(entries[1].name, "motor");
231    }
232
233    /// Regression coverage for the node->target(s) placement migration: an older/differently
234    /// shaped `mecha10-api` response with no `targets` field at all must still deserialize
235    /// (`#[serde(default)]`), rather than hard-failing every `mecha10 init`/`mecha10 add`.
236    #[tokio::test]
237    #[serial_test::serial(mecha10_api_url_env_var)]
238    async fn fetch_node_catalog_defaults_targets_to_empty_when_absent_from_the_response() {
239        let body = serde_json::json!([{"name": "camera", "recommended": true}]).to_string();
240        let _server = start_fake_catalog_server(body);
241
242        let entries = fetch_node_catalog().await.unwrap();
243
244        assert_eq!(entries.len(), 1);
245        assert!(entries[0].targets.is_empty());
246    }
247
248    #[tokio::test]
249    #[serial_test::serial(mecha10_api_url_env_var)]
250    async fn fetch_node_catalog_fails_hard_when_mecha10_api_is_unreachable() {
251        std::env::set_var("MECHA10_API_URL", "http://127.0.0.1:1/api");
252
253        let result = fetch_node_catalog().await;
254
255        std::env::remove_var("MECHA10_API_URL");
256
257        let err = result.unwrap_err();
258        assert!(err.to_string().contains("Failed to fetch node catalog"), "got: {}", err);
259    }
260
261    fn template_entry(name: &str, nodes: &[&str]) -> TemplateCatalogEntry {
262        TemplateCatalogEntry {
263            name: name.to_string(),
264            description: String::new(),
265            nodes: nodes.iter().map(|n| n.to_string()).collect(),
266        }
267    }
268
269    #[test]
270    fn template_node_defaults_selects_the_named_templates_nodes() {
271        let templates = vec![
272            template_entry("rover", &["speaker", "listener", "motor", "imu", "camera"]),
273            template_entry("drone", &[]),
274        ];
275        let available = vec![
276            "speaker".to_string(),
277            "listener".to_string(),
278            "motor".to_string(),
279            "imu".to_string(),
280            "camera".to_string(),
281        ];
282
283        let defaults = template_node_defaults(&templates, &Some("rover".to_string()), &available);
284
285        assert_eq!(defaults, vec![true, true, true, true, true]);
286    }
287
288    #[test]
289    fn template_node_defaults_defaults_to_rover_when_template_id_is_omitted() {
290        let templates = vec![template_entry("rover", &["speaker"]), template_entry("drone", &[])];
291        let available = vec!["speaker".to_string(), "camera".to_string()];
292
293        let defaults = template_node_defaults(&templates, &None, &available);
294
295        assert_eq!(defaults, vec![true, false]);
296    }
297
298    #[test]
299    fn template_node_defaults_placeholder_template_has_no_defaults() {
300        let templates = vec![template_entry("rover", &["speaker"]), template_entry("drone", &[])];
301        let available = vec!["speaker".to_string(), "listener".to_string(), "motor".to_string()];
302
303        let defaults = template_node_defaults(&templates, &Some("drone".to_string()), &available);
304
305        assert_eq!(defaults, vec![false, false, false]);
306    }
307
308    #[test]
309    fn template_node_defaults_falls_back_to_basic_nodes_for_an_unrecognized_template() {
310        let templates = vec![template_entry("rover", &["camera"])];
311        let available = vec!["speaker".to_string(), "camera".to_string(), "rl".to_string()];
312
313        let defaults = template_node_defaults(&templates, &Some("not-a-real-template".to_string()), &available);
314
315        assert_eq!(defaults, vec![true, false, true]);
316    }
317}