Skip to main content

stygian_graph/domain/
discovery.rs

1//! API discovery domain types.
2//!
3//! Provides generic types for reverse-engineering undocumented REST APIs.
4//! An API prober builds a [`DiscoveryReport`](crate::domain::discovery::DiscoveryReport) by analysing JSON responses
5//! from target endpoints; the report can then be fed to
6//! [`OpenApiGenerator`](crate::adapters::openapi_gen::OpenApiGenerator) to
7//! produce an [`openapiv3::OpenAPI`] specification.
8//!
9//! These types are domain-pure — no I/O, no network calls.
10
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use std::collections::BTreeMap;
14
15// ─────────────────────────────────────────────────────────────────────────────
16// JsonType
17// ─────────────────────────────────────────────────────────────────────────────
18
19/// Recursive enum representing an inferred JSON Schema type from a
20/// [`serde_json::Value`].
21///
22/// # Example
23///
24/// ```
25/// use stygian_graph::domain::discovery::JsonType;
26/// use serde_json::json;
27///
28/// let t = JsonType::infer(&json!(42));
29/// assert_eq!(t, JsonType::Integer);
30///
31/// let t = JsonType::infer(&json!({"name": "Alice", "age": 30}));
32/// assert!(matches!(t, JsonType::Object(_)));
33/// ```
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub enum JsonType {
36    /// JSON `null`
37    Null,
38    /// JSON boolean
39    Bool,
40    /// Integer (no fractional part)
41    Integer,
42    /// Floating-point number
43    Float,
44    /// JSON string
45    String,
46    /// Homogeneous array with inferred item type
47    Array(Box<Self>),
48    /// Object with field name → inferred type mapping
49    Object(BTreeMap<String, Self>),
50    /// Mixed / conflicting types (e.g. field is sometimes string, sometimes int)
51    Mixed,
52}
53
54impl JsonType {
55    /// Infer the [`JsonType`] of a [`serde_json::Value`].
56    ///
57    /// For arrays, the item type is inferred from all elements; conflicting
58    /// element types collapse to [`JsonType::Mixed`].
59    ///
60    /// # Example
61    ///
62    /// ```
63    /// use stygian_graph::domain::discovery::JsonType;
64    /// use serde_json::json;
65    ///
66    /// assert_eq!(JsonType::infer(&json!("hello")), JsonType::String);
67    /// assert_eq!(JsonType::infer(&json!(true)), JsonType::Bool);
68    /// assert_eq!(JsonType::infer(&json!(null)), JsonType::Null);
69    /// assert_eq!(JsonType::infer(&json!(3.14)), JsonType::Float);
70    /// ```
71    #[must_use]
72    pub fn infer(value: &Value) -> Self {
73        match value {
74            Value::Null => Self::Null,
75            Value::Bool(_) => Self::Bool,
76            Value::Number(n) => {
77                if n.is_f64() && n.as_i64().is_none() && n.as_u64().is_none() {
78                    Self::Float
79                } else {
80                    Self::Integer
81                }
82            }
83            Value::String(_) => Self::String,
84            Value::Array(arr) => {
85                if arr.is_empty() {
86                    return Self::Array(Box::new(Self::Mixed));
87                }
88                let first = arr.first().map_or(Self::Mixed, Self::infer);
89                let uniform = arr.iter().skip(1).all(|v| Self::infer(v) == first);
90                if uniform {
91                    Self::Array(Box::new(first))
92                } else {
93                    Self::Array(Box::new(Self::Mixed))
94                }
95            }
96            Value::Object(map) => {
97                let fields = map
98                    .iter()
99                    .map(|(k, v)| (k.clone(), Self::infer(v)))
100                    .collect();
101                Self::Object(fields)
102            }
103        }
104    }
105
106    /// Return the JSON Schema type string for this variant.
107    ///
108    /// # Example
109    ///
110    /// ```
111    /// use stygian_graph::domain::discovery::JsonType;
112    ///
113    /// assert_eq!(JsonType::String.schema_type(), "string");
114    /// assert_eq!(JsonType::Integer.schema_type(), "integer");
115    /// ```
116    #[must_use]
117    pub const fn schema_type(&self) -> &'static str {
118        match self {
119            Self::Null => "null",
120            Self::Bool => "boolean",
121            Self::Integer => "integer",
122            Self::Float => "number",
123            Self::String | Self::Mixed => "string",
124            Self::Array(_) => "array",
125            Self::Object(_) => "object",
126        }
127    }
128}
129
130// ─────────────────────────────────────────────────────────────────────────────
131// PaginationStyle
132// ─────────────────────────────────────────────────────────────────────────────
133
134/// Detected pagination envelope style from API response inspection.
135///
136/// # Example
137///
138/// ```
139/// use stygian_graph::domain::discovery::PaginationStyle;
140///
141/// let style = PaginationStyle {
142///     has_data_wrapper: true,
143///     has_current_page: true,
144///     has_total_pages: true,
145///     has_last_page: false,
146///     has_total: true,
147///     has_per_page: true,
148/// };
149/// assert!(style.is_paginated());
150/// ```
151#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
152#[allow(clippy::struct_excessive_bools)] // 6 page-shape feature flags read more clearly as named bools than a u8 bitmask
153pub struct PaginationStyle {
154    /// Response wraps data in a `data` key
155    pub has_data_wrapper: bool,
156    /// Contains a `current_page` or `page` field
157    pub has_current_page: bool,
158    /// Contains a `total_pages` field
159    pub has_total_pages: bool,
160    /// Contains a `last_page` field
161    pub has_last_page: bool,
162    /// Contains a `total` or `total_count` field
163    pub has_total: bool,
164    /// Contains a `per_page` or `page_size` field
165    pub has_per_page: bool,
166}
167
168impl PaginationStyle {
169    /// Returns `true` if any pagination signal was detected.
170    ///
171    /// # Example
172    ///
173    /// ```
174    /// use stygian_graph::domain::discovery::PaginationStyle;
175    ///
176    /// let empty = PaginationStyle::default();
177    /// assert!(!empty.is_paginated());
178    /// ```
179    #[must_use]
180    pub const fn is_paginated(&self) -> bool {
181        self.has_current_page
182            || self.has_total_pages
183            || self.has_last_page
184            || self.has_total
185            || self.has_per_page
186    }
187
188    /// Detect pagination style from a JSON response body.
189    ///
190    /// Looks for common pagination envelope keys at the top level.
191    ///
192    /// # Example
193    ///
194    /// ```
195    /// use stygian_graph::domain::discovery::PaginationStyle;
196    /// use serde_json::json;
197    ///
198    /// let body = json!({"data": [], "current_page": 1, "total": 42, "per_page": 25});
199    /// let style = PaginationStyle::detect(&body);
200    /// assert!(style.has_data_wrapper);
201    /// assert!(style.has_current_page);
202    /// assert!(style.has_total);
203    /// ```
204    #[must_use]
205    pub fn detect(body: &Value) -> Self {
206        let Some(obj) = body.as_object() else {
207            return Self::default();
208        };
209        Self {
210            has_data_wrapper: obj.contains_key("data"),
211            has_current_page: obj.contains_key("current_page") || obj.contains_key("page"),
212            has_total_pages: obj.contains_key("total_pages"),
213            has_last_page: obj.contains_key("last_page"),
214            has_total: obj.contains_key("total") || obj.contains_key("total_count"),
215            has_per_page: obj.contains_key("per_page") || obj.contains_key("page_size"),
216        }
217    }
218}
219
220// ─────────────────────────────────────────────────────────────────────────────
221// ResponseShape
222// ─────────────────────────────────────────────────────────────────────────────
223
224/// Shape of a single discovered endpoint's response.
225///
226/// # Example
227///
228/// ```
229/// use stygian_graph::domain::discovery::{ResponseShape, PaginationStyle, JsonType};
230/// use serde_json::json;
231/// use std::collections::BTreeMap;
232///
233/// let shape = ResponseShape {
234///     fields: BTreeMap::from([("id".into(), JsonType::Integer), ("name".into(), JsonType::String)]),
235///     sample: Some(json!({"id": 1, "name": "Widget"})),
236///     pagination_detected: true,
237///     pagination_style: PaginationStyle::default(),
238/// };
239/// assert_eq!(shape.fields.len(), 2);
240/// ```
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct ResponseShape {
243    /// Inferred field types
244    pub fields: BTreeMap<String, JsonType>,
245    /// Optional representative sample value
246    pub sample: Option<Value>,
247    /// Whether pagination was detected
248    pub pagination_detected: bool,
249    /// Pagination envelope style details
250    pub pagination_style: PaginationStyle,
251}
252
253impl ResponseShape {
254    /// Build a `ResponseShape` by analysing a JSON response body.
255    ///
256    /// If the body is an object with a `data` key that is an array,
257    /// fields are inferred from the first array element.  Otherwise
258    /// the top-level object fields are used.
259    ///
260    /// # Example
261    ///
262    /// ```
263    /// use stygian_graph::domain::discovery::ResponseShape;
264    /// use serde_json::json;
265    ///
266    /// let body = json!({"data": [{"id": 1, "name": "A"}], "total": 50, "per_page": 25});
267    /// let shape = ResponseShape::from_body(&body);
268    /// assert!(shape.pagination_detected);
269    /// assert!(shape.fields.contains_key("id"));
270    /// ```
271    #[must_use]
272    pub fn from_body(body: &Value) -> Self {
273        let pagination_style = PaginationStyle::detect(body);
274        let pagination_detected = pagination_style.is_paginated();
275
276        // Try to extract fields from data[0] if it's a wrapped array
277        let (fields, sample) = body
278            .get("data")
279            .and_then(Value::as_array)
280            .and_then(|arr| {
281                arr.first().map(|first| {
282                    let inferred = match JsonType::infer(first) {
283                        JsonType::Object(m) => m,
284                        other => BTreeMap::from([("value".into(), other)]),
285                    };
286                    (inferred, Some(first.clone()))
287                })
288            })
289            .unwrap_or_else(|| match JsonType::infer(body) {
290                JsonType::Object(m) => {
291                    let sample = Some(body.clone());
292                    (m, sample)
293                }
294                other => (
295                    BTreeMap::from([("value".into(), other)]),
296                    Some(body.clone()),
297                ),
298            });
299
300        Self {
301            fields,
302            sample,
303            pagination_detected,
304            pagination_style,
305        }
306    }
307}
308
309// ─────────────────────────────────────────────────────────────────────────────
310// DiscoveryReport
311// ─────────────────────────────────────────────────────────────────────────────
312
313/// Collection of [`ResponseShape`]s keyed by endpoint name.
314///
315/// A discovery probe fills this report and passes it to
316/// [`OpenApiGenerator`](crate::adapters::openapi_gen::OpenApiGenerator).
317///
318/// # Example
319///
320/// ```
321/// use stygian_graph::domain::discovery::{DiscoveryReport, ResponseShape};
322/// use serde_json::json;
323///
324/// let mut report = DiscoveryReport::new();
325/// let body = json!({"id": 1, "name": "Test"});
326/// report.add_endpoint("get_items", ResponseShape::from_body(&body));
327/// assert_eq!(report.endpoints().len(), 1);
328/// ```
329#[derive(Debug, Clone, Default, Serialize, Deserialize)]
330pub struct DiscoveryReport {
331    endpoints: BTreeMap<String, ResponseShape>,
332}
333
334impl DiscoveryReport {
335    /// Create an empty report.
336    ///
337    /// # Example
338    ///
339    /// ```
340    /// use stygian_graph::domain::discovery::DiscoveryReport;
341    ///
342    /// let report = DiscoveryReport::new();
343    /// assert!(report.endpoints().is_empty());
344    /// ```
345    #[must_use]
346    pub fn new() -> Self {
347        Self::default()
348    }
349
350    /// Add a discovered endpoint shape.
351    ///
352    /// # Example
353    ///
354    /// ```
355    /// use stygian_graph::domain::discovery::{DiscoveryReport, ResponseShape};
356    /// use serde_json::json;
357    ///
358    /// let mut report = DiscoveryReport::new();
359    /// report.add_endpoint("users", ResponseShape::from_body(&json!({"id": 1})));
360    /// ```
361    pub fn add_endpoint(&mut self, name: &str, shape: ResponseShape) {
362        self.endpoints.insert(name.to_string(), shape);
363    }
364
365    /// Return a view of all discovered endpoints.
366    ///
367    /// # Example
368    ///
369    /// ```
370    /// use stygian_graph::domain::discovery::DiscoveryReport;
371    ///
372    /// let report = DiscoveryReport::new();
373    /// assert!(report.endpoints().is_empty());
374    /// ```
375    #[must_use]
376    pub const fn endpoints(&self) -> &BTreeMap<String, ResponseShape> {
377        &self.endpoints
378    }
379}
380
381// ─────────────────────────────────────────────────────────────────────────────
382// Tests
383// ─────────────────────────────────────────────────────────────────────────────
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use serde_json::json;
389
390    #[test]
391    fn json_type_infer_primitives() {
392        assert_eq!(JsonType::infer(&json!(null)), JsonType::Null);
393        assert_eq!(JsonType::infer(&json!(true)), JsonType::Bool);
394        assert_eq!(JsonType::infer(&json!(42)), JsonType::Integer);
395        assert_eq!(
396            JsonType::infer(&json!(std::f64::consts::PI)),
397            JsonType::Float
398        );
399        assert_eq!(JsonType::infer(&json!("hello")), JsonType::String);
400    }
401
402    #[test]
403    fn json_type_infer_array_uniform() {
404        let t = JsonType::infer(&json!([1, 2, 3]));
405        assert_eq!(t, JsonType::Array(Box::new(JsonType::Integer)));
406    }
407
408    #[test]
409    fn json_type_infer_array_mixed() {
410        let t = JsonType::infer(&json!([1, "two", 3]));
411        assert_eq!(t, JsonType::Array(Box::new(JsonType::Mixed)));
412    }
413
414    #[test]
415    fn json_type_infer_object() -> Result<(), Box<dyn std::error::Error>> {
416        let t = JsonType::infer(&json!({"name": "Alice", "age": 30}));
417        match t {
418            JsonType::Object(fields) => {
419                assert_eq!(fields.len(), 2);
420                let name_type = fields.get("name").ok_or("missing 'name' field")?;
421                assert_eq!(name_type, &JsonType::String);
422                let age_type = fields.get("age").ok_or("missing 'age' field")?;
423                assert_eq!(age_type, &JsonType::Integer);
424            }
425            other => return Err(format!("expected Object, got {other:?}").into()),
426        }
427        Ok(())
428    }
429
430    #[test]
431    fn pagination_style_detect_common_envelope() {
432        let body = json!({
433            "data": [{"id": 1}],
434            "current_page": 1,
435            "total": 100,
436            "per_page": 25,
437        });
438        let style = PaginationStyle::detect(&body);
439        assert!(style.has_data_wrapper);
440        assert!(style.has_current_page);
441        assert!(style.has_total);
442        assert!(style.has_per_page);
443        assert!(style.is_paginated());
444    }
445
446    #[test]
447    fn pagination_style_detect_none() {
448        let body = json!({"items": [{"id": 1}]});
449        let style = PaginationStyle::detect(&body);
450        assert!(!style.is_paginated());
451    }
452
453    #[test]
454    fn response_shape_from_wrapped_body() {
455        let body = json!({
456            "data": [{"id": 1, "name": "Test"}],
457            "total": 42,
458            "per_page": 25,
459        });
460        let shape = ResponseShape::from_body(&body);
461        assert!(shape.pagination_detected);
462        assert!(shape.fields.contains_key("id"));
463        assert!(shape.fields.contains_key("name"));
464    }
465
466    #[test]
467    fn response_shape_from_flat_body() {
468        let body = json!({"id": 1, "name": "Test"});
469        let shape = ResponseShape::from_body(&body);
470        assert!(!shape.pagination_detected);
471        assert!(shape.fields.contains_key("id"));
472    }
473
474    #[test]
475    fn discovery_report_roundtrip() {
476        let mut report = DiscoveryReport::new();
477        let body = json!({"data": [{"id": 1}], "total": 1, "per_page": 25});
478        report.add_endpoint("items", ResponseShape::from_body(&body));
479
480        assert_eq!(report.endpoints().len(), 1);
481        assert!(report.endpoints().contains_key("items"));
482    }
483}