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)]
152pub struct PaginationStyle {
153    /// Response wraps data in a `data` key
154    pub has_data_wrapper: bool,
155    /// Contains a `current_page` or `page` field
156    pub has_current_page: bool,
157    /// Contains a `total_pages` field
158    pub has_total_pages: bool,
159    /// Contains a `last_page` field
160    pub has_last_page: bool,
161    /// Contains a `total` or `total_count` field
162    pub has_total: bool,
163    /// Contains a `per_page` or `page_size` field
164    pub has_per_page: bool,
165}
166
167impl PaginationStyle {
168    /// Returns `true` if any pagination signal was detected.
169    ///
170    /// # Example
171    ///
172    /// ```
173    /// use stygian_graph::domain::discovery::PaginationStyle;
174    ///
175    /// let empty = PaginationStyle::default();
176    /// assert!(!empty.is_paginated());
177    /// ```
178    #[must_use]
179    pub const fn is_paginated(&self) -> bool {
180        self.has_current_page
181            || self.has_total_pages
182            || self.has_last_page
183            || self.has_total
184            || self.has_per_page
185    }
186
187    /// Detect pagination style from a JSON response body.
188    ///
189    /// Looks for common pagination envelope keys at the top level.
190    ///
191    /// # Example
192    ///
193    /// ```
194    /// use stygian_graph::domain::discovery::PaginationStyle;
195    /// use serde_json::json;
196    ///
197    /// let body = json!({"data": [], "current_page": 1, "total": 42, "per_page": 25});
198    /// let style = PaginationStyle::detect(&body);
199    /// assert!(style.has_data_wrapper);
200    /// assert!(style.has_current_page);
201    /// assert!(style.has_total);
202    /// ```
203    #[must_use]
204    pub fn detect(body: &Value) -> Self {
205        let Some(obj) = body.as_object() else {
206            return Self::default();
207        };
208        Self {
209            has_data_wrapper: obj.contains_key("data"),
210            has_current_page: obj.contains_key("current_page") || obj.contains_key("page"),
211            has_total_pages: obj.contains_key("total_pages"),
212            has_last_page: obj.contains_key("last_page"),
213            has_total: obj.contains_key("total") || obj.contains_key("total_count"),
214            has_per_page: obj.contains_key("per_page") || obj.contains_key("page_size"),
215        }
216    }
217}
218
219// ─────────────────────────────────────────────────────────────────────────────
220// ResponseShape
221// ─────────────────────────────────────────────────────────────────────────────
222
223/// Shape of a single discovered endpoint's response.
224///
225/// # Example
226///
227/// ```
228/// use stygian_graph::domain::discovery::{ResponseShape, PaginationStyle, JsonType};
229/// use serde_json::json;
230/// use std::collections::BTreeMap;
231///
232/// let shape = ResponseShape {
233///     fields: BTreeMap::from([("id".into(), JsonType::Integer), ("name".into(), JsonType::String)]),
234///     sample: Some(json!({"id": 1, "name": "Widget"})),
235///     pagination_detected: true,
236///     pagination_style: PaginationStyle::default(),
237/// };
238/// assert_eq!(shape.fields.len(), 2);
239/// ```
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct ResponseShape {
242    /// Inferred field types
243    pub fields: BTreeMap<String, JsonType>,
244    /// Optional representative sample value
245    pub sample: Option<Value>,
246    /// Whether pagination was detected
247    pub pagination_detected: bool,
248    /// Pagination envelope style details
249    pub pagination_style: PaginationStyle,
250}
251
252impl ResponseShape {
253    /// Build a `ResponseShape` by analysing a JSON response body.
254    ///
255    /// If the body is an object with a `data` key that is an array,
256    /// fields are inferred from the first array element.  Otherwise
257    /// the top-level object fields are used.
258    ///
259    /// # Example
260    ///
261    /// ```
262    /// use stygian_graph::domain::discovery::ResponseShape;
263    /// use serde_json::json;
264    ///
265    /// let body = json!({"data": [{"id": 1, "name": "A"}], "total": 50, "per_page": 25});
266    /// let shape = ResponseShape::from_body(&body);
267    /// assert!(shape.pagination_detected);
268    /// assert!(shape.fields.contains_key("id"));
269    /// ```
270    #[must_use]
271    pub fn from_body(body: &Value) -> Self {
272        let pagination_style = PaginationStyle::detect(body);
273        let pagination_detected = pagination_style.is_paginated();
274
275        // Try to extract fields from data[0] if it's a wrapped array
276        let (fields, sample) = body
277            .get("data")
278            .and_then(Value::as_array)
279            .and_then(|arr| {
280                arr.first().map(|first| {
281                    let inferred = match JsonType::infer(first) {
282                        JsonType::Object(m) => m,
283                        other => BTreeMap::from([("value".into(), other)]),
284                    };
285                    (inferred, Some(first.clone()))
286                })
287            })
288            .unwrap_or_else(|| match JsonType::infer(body) {
289                JsonType::Object(m) => {
290                    let sample = Some(body.clone());
291                    (m, sample)
292                }
293                other => (
294                    BTreeMap::from([("value".into(), other)]),
295                    Some(body.clone()),
296                ),
297            });
298
299        Self {
300            fields,
301            sample,
302            pagination_detected,
303            pagination_style,
304        }
305    }
306}
307
308// ─────────────────────────────────────────────────────────────────────────────
309// DiscoveryReport
310// ─────────────────────────────────────────────────────────────────────────────
311
312/// Collection of [`ResponseShape`]s keyed by endpoint name.
313///
314/// A discovery probe fills this report and passes it to
315/// [`OpenApiGenerator`](crate::adapters::openapi_gen::OpenApiGenerator).
316///
317/// # Example
318///
319/// ```
320/// use stygian_graph::domain::discovery::{DiscoveryReport, ResponseShape};
321/// use serde_json::json;
322///
323/// let mut report = DiscoveryReport::new();
324/// let body = json!({"id": 1, "name": "Test"});
325/// report.add_endpoint("get_items", ResponseShape::from_body(&body));
326/// assert_eq!(report.endpoints().len(), 1);
327/// ```
328#[derive(Debug, Clone, Default, Serialize, Deserialize)]
329pub struct DiscoveryReport {
330    endpoints: BTreeMap<String, ResponseShape>,
331}
332
333impl DiscoveryReport {
334    /// Create an empty report.
335    ///
336    /// # Example
337    ///
338    /// ```
339    /// use stygian_graph::domain::discovery::DiscoveryReport;
340    ///
341    /// let report = DiscoveryReport::new();
342    /// assert!(report.endpoints().is_empty());
343    /// ```
344    #[must_use]
345    pub fn new() -> Self {
346        Self::default()
347    }
348
349    /// Add a discovered endpoint shape.
350    ///
351    /// # Example
352    ///
353    /// ```
354    /// use stygian_graph::domain::discovery::{DiscoveryReport, ResponseShape};
355    /// use serde_json::json;
356    ///
357    /// let mut report = DiscoveryReport::new();
358    /// report.add_endpoint("users", ResponseShape::from_body(&json!({"id": 1})));
359    /// ```
360    pub fn add_endpoint(&mut self, name: &str, shape: ResponseShape) {
361        self.endpoints.insert(name.to_string(), shape);
362    }
363
364    /// Return a view of all discovered endpoints.
365    ///
366    /// # Example
367    ///
368    /// ```
369    /// use stygian_graph::domain::discovery::DiscoveryReport;
370    ///
371    /// let report = DiscoveryReport::new();
372    /// assert!(report.endpoints().is_empty());
373    /// ```
374    #[must_use]
375    pub const fn endpoints(&self) -> &BTreeMap<String, ResponseShape> {
376        &self.endpoints
377    }
378}
379
380// ─────────────────────────────────────────────────────────────────────────────
381// Tests
382// ─────────────────────────────────────────────────────────────────────────────
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use serde_json::json;
388
389    #[test]
390    fn json_type_infer_primitives() {
391        assert_eq!(JsonType::infer(&json!(null)), JsonType::Null);
392        assert_eq!(JsonType::infer(&json!(true)), JsonType::Bool);
393        assert_eq!(JsonType::infer(&json!(42)), JsonType::Integer);
394        assert_eq!(
395            JsonType::infer(&json!(std::f64::consts::PI)),
396            JsonType::Float
397        );
398        assert_eq!(JsonType::infer(&json!("hello")), JsonType::String);
399    }
400
401    #[test]
402    fn json_type_infer_array_uniform() {
403        let t = JsonType::infer(&json!([1, 2, 3]));
404        assert_eq!(t, JsonType::Array(Box::new(JsonType::Integer)));
405    }
406
407    #[test]
408    fn json_type_infer_array_mixed() {
409        let t = JsonType::infer(&json!([1, "two", 3]));
410        assert_eq!(t, JsonType::Array(Box::new(JsonType::Mixed)));
411    }
412
413    #[test]
414    fn json_type_infer_object() -> Result<(), Box<dyn std::error::Error>> {
415        let t = JsonType::infer(&json!({"name": "Alice", "age": 30}));
416        match t {
417            JsonType::Object(fields) => {
418                assert_eq!(fields.len(), 2);
419                let name_type = fields.get("name").ok_or("missing 'name' field")?;
420                assert_eq!(name_type, &JsonType::String);
421                let age_type = fields.get("age").ok_or("missing 'age' field")?;
422                assert_eq!(age_type, &JsonType::Integer);
423            }
424            other => return Err(format!("expected Object, got {other:?}").into()),
425        }
426        Ok(())
427    }
428
429    #[test]
430    fn pagination_style_detect_common_envelope() {
431        let body = json!({
432            "data": [{"id": 1}],
433            "current_page": 1,
434            "total": 100,
435            "per_page": 25,
436        });
437        let style = PaginationStyle::detect(&body);
438        assert!(style.has_data_wrapper);
439        assert!(style.has_current_page);
440        assert!(style.has_total);
441        assert!(style.has_per_page);
442        assert!(style.is_paginated());
443    }
444
445    #[test]
446    fn pagination_style_detect_none() {
447        let body = json!({"items": [{"id": 1}]});
448        let style = PaginationStyle::detect(&body);
449        assert!(!style.is_paginated());
450    }
451
452    #[test]
453    fn response_shape_from_wrapped_body() {
454        let body = json!({
455            "data": [{"id": 1, "name": "Test"}],
456            "total": 42,
457            "per_page": 25,
458        });
459        let shape = ResponseShape::from_body(&body);
460        assert!(shape.pagination_detected);
461        assert!(shape.fields.contains_key("id"));
462        assert!(shape.fields.contains_key("name"));
463    }
464
465    #[test]
466    fn response_shape_from_flat_body() {
467        let body = json!({"id": 1, "name": "Test"});
468        let shape = ResponseShape::from_body(&body);
469        assert!(!shape.pagination_detected);
470        assert!(shape.fields.contains_key("id"));
471    }
472
473    #[test]
474    fn discovery_report_roundtrip() {
475        let mut report = DiscoveryReport::new();
476        let body = json!({"data": [{"id": 1}], "total": 1, "per_page": 25});
477        report.add_endpoint("items", ResponseShape::from_body(&body));
478
479        assert_eq!(report.endpoints().len(), 1);
480        assert!(report.endpoints().contains_key("items"));
481    }
482}