Skip to main content

romm_api/
feature_compat.rs

1use serde::Serialize;
2
3use crate::openapi::EndpointRegistry;
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
6pub struct RequiredEndpoint {
7    pub method: &'static str,
8    pub path: &'static str,
9}
10
11impl RequiredEndpoint {
12    pub fn label(self) -> String {
13        format!("{} {}", self.method, self.path)
14    }
15}
16
17#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
18pub struct FeatureCompatibility {
19    pub feature: &'static str,
20    pub supported: bool,
21    pub missing: Vec<RequiredEndpoint>,
22    unsupported_message: &'static str,
23}
24
25impl FeatureCompatibility {
26    pub fn from_registry(
27        feature: &'static str,
28        unsupported_message: &'static str,
29        required: &[RequiredEndpoint],
30        registry: &EndpointRegistry,
31    ) -> Self {
32        let missing = required
33            .iter()
34            .copied()
35            .filter(|ep| !registry.has_endpoint(ep.method, ep.path))
36            .collect::<Vec<_>>();
37        Self {
38            feature,
39            supported: missing.is_empty(),
40            missing,
41            unsupported_message,
42        }
43    }
44
45    pub fn supported(feature: &'static str, unsupported_message: &'static str) -> Self {
46        Self {
47            feature,
48            supported: true,
49            missing: Vec::new(),
50            unsupported_message,
51        }
52    }
53
54    pub fn unsupported_message(&self) -> String {
55        if self.missing.is_empty() {
56            self.unsupported_message.to_string()
57        } else {
58            format!(
59                "{} Missing endpoint(s): {}",
60                self.unsupported_message,
61                self.missing
62                    .iter()
63                    .map(|ep| ep.label())
64                    .collect::<Vec<_>>()
65                    .join(", ")
66            )
67        }
68    }
69}
70
71pub const SAVE_SYNC_FEATURE: &str = "save-sync";
72
73pub const SAVE_SYNC_UNSUPPORTED_MESSAGE: &str =
74    "This RomM server does not expose save-sync endpoints; upgrade RomM to use romm-cli sync.";
75
76pub const SAVE_SYNC_REQUIRED_ENDPOINTS: [RequiredEndpoint; 6] = [
77    RequiredEndpoint {
78        method: "GET",
79        path: "/api/devices",
80    },
81    RequiredEndpoint {
82        method: "POST",
83        path: "/api/devices",
84    },
85    RequiredEndpoint {
86        method: "GET",
87        path: "/api/sync/sessions",
88    },
89    RequiredEndpoint {
90        method: "POST",
91        path: "/api/sync/negotiate",
92    },
93    RequiredEndpoint {
94        method: "POST",
95        path: "/api/sync/sessions/{session_id}/complete",
96    },
97    RequiredEndpoint {
98        method: "POST",
99        path: "/api/sync/devices/{device_id}/push-pull",
100    },
101];
102
103pub type SaveSyncCompatibility = FeatureCompatibility;
104
105pub fn save_sync_compatibility(registry: &EndpointRegistry) -> SaveSyncCompatibility {
106    FeatureCompatibility::from_registry(
107        SAVE_SYNC_FEATURE,
108        SAVE_SYNC_UNSUPPORTED_MESSAGE,
109        &SAVE_SYNC_REQUIRED_ENDPOINTS,
110        registry,
111    )
112}
113
114pub fn supported_save_sync_compatibility() -> SaveSyncCompatibility {
115    FeatureCompatibility::supported(SAVE_SYNC_FEATURE, SAVE_SYNC_UNSUPPORTED_MESSAGE)
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    fn registry_for(paths: &[(&str, &str)]) -> EndpointRegistry {
123        let mut paths_json = serde_json::Map::new();
124        for (method, path) in paths {
125            let entry = paths_json
126                .entry((*path).to_string())
127                .or_insert_with(|| serde_json::json!({}));
128            entry.as_object_mut().unwrap().insert(
129                method.to_ascii_lowercase(),
130                serde_json::json!({ "responses": { "200": { "description": "ok" } } }),
131            );
132        }
133        EndpointRegistry::from_openapi_json(
134            &serde_json::json!({
135                "openapi": "3.0.0",
136                "paths": paths_json
137            })
138            .to_string(),
139        )
140        .expect("parse")
141    }
142
143    #[test]
144    fn feature_compatibility_supported_when_all_required_endpoints_exist() {
145        let required = [
146            RequiredEndpoint {
147                method: "GET",
148                path: "/api/a",
149            },
150            RequiredEndpoint {
151                method: "POST",
152                path: "/api/b/{id}",
153            },
154        ];
155        let compat = FeatureCompatibility::from_registry(
156            "demo",
157            "Demo feature unsupported.",
158            &required,
159            &registry_for(&[("GET", "/api/a"), ("POST", "/api/b/{id}")]),
160        );
161
162        assert!(compat.supported);
163        assert!(compat.missing.is_empty());
164    }
165
166    #[test]
167    fn feature_compatibility_reports_missing_endpoints() {
168        let required = [
169            RequiredEndpoint {
170                method: "GET",
171                path: "/api/a",
172            },
173            RequiredEndpoint {
174                method: "POST",
175                path: "/api/b",
176            },
177        ];
178        let compat = FeatureCompatibility::from_registry(
179            "demo",
180            "Demo feature unsupported.",
181            &required,
182            &registry_for(&[("GET", "/api/a")]),
183        );
184
185        assert!(!compat.supported);
186        assert_eq!(
187            compat.missing,
188            vec![RequiredEndpoint {
189                method: "POST",
190                path: "/api/b"
191            }]
192        );
193        assert_eq!(
194            compat.unsupported_message(),
195            "Demo feature unsupported. Missing endpoint(s): POST /api/b"
196        );
197    }
198
199    #[test]
200    fn save_sync_compatibility_supported_when_all_required_endpoints_exist() {
201        let paths = SAVE_SYNC_REQUIRED_ENDPOINTS
202            .iter()
203            .map(|ep| (ep.method, ep.path))
204            .collect::<Vec<_>>();
205        let compat = save_sync_compatibility(&registry_for(&paths));
206
207        assert_eq!(compat.feature, SAVE_SYNC_FEATURE);
208        assert!(compat.supported);
209        assert!(compat.missing.is_empty());
210    }
211
212    #[test]
213    fn save_sync_compatibility_reports_partial_missing_endpoints() {
214        let compat = save_sync_compatibility(&registry_for(&[("GET", "/api/devices")]));
215
216        assert!(!compat.supported);
217        assert_eq!(compat.missing.len(), SAVE_SYNC_REQUIRED_ENDPOINTS.len() - 1);
218        assert!(compat
219            .unsupported_message()
220            .contains("POST /api/sync/negotiate"));
221    }
222
223    #[test]
224    fn save_sync_compatibility_empty_registry_is_unsupported() {
225        let compat = save_sync_compatibility(&EndpointRegistry::default());
226
227        assert!(!compat.supported);
228        assert_eq!(compat.missing.len(), SAVE_SYNC_REQUIRED_ENDPOINTS.len());
229    }
230}