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<JsonType>),
48    /// Object with field name → inferred type mapping
49    Object(BTreeMap<std::string::String, JsonType>),
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 = Self::infer(&arr[0]);
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 => "string",
124            Self::Array(_) => "array",
125            Self::Object(_) => "object",
126            Self::Mixed => "string", // fallback
127        }
128    }
129}
130
131// ─────────────────────────────────────────────────────────────────────────────
132// PaginationStyle
133// ─────────────────────────────────────────────────────────────────────────────
134
135/// Detected pagination envelope style from API response inspection.
136///
137/// # Example
138///
139/// ```
140/// use stygian_graph::domain::discovery::PaginationStyle;
141///
142/// let style = PaginationStyle {
143///     has_data_wrapper: true,
144///     has_current_page: true,
145///     has_total_pages: true,
146///     has_last_page: false,
147///     has_total: true,
148///     has_per_page: true,
149/// };
150/// assert!(style.is_paginated());
151/// ```
152#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
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 obj = match body.as_object() {
207            Some(o) => o,
208            None => return Self::default(),
209        };
210        Self {
211            has_data_wrapper: obj.contains_key("data"),
212            has_current_page: obj.contains_key("current_page") || obj.contains_key("page"),
213            has_total_pages: obj.contains_key("total_pages"),
214            has_last_page: obj.contains_key("last_page"),
215            has_total: obj.contains_key("total") || obj.contains_key("total_count"),
216            has_per_page: obj.contains_key("per_page") || obj.contains_key("page_size"),
217        }
218    }
219}
220
221// ─────────────────────────────────────────────────────────────────────────────
222// ResponseShape
223// ─────────────────────────────────────────────────────────────────────────────
224
225/// Shape of a single discovered endpoint's response.
226///
227/// # Example
228///
229/// ```
230/// use stygian_graph::domain::discovery::{ResponseShape, PaginationStyle, JsonType};
231/// use serde_json::json;
232/// use std::collections::BTreeMap;
233///
234/// let shape = ResponseShape {
235///     fields: BTreeMap::from([("id".into(), JsonType::Integer), ("name".into(), JsonType::String)]),
236///     sample: Some(json!({"id": 1, "name": "Widget"})),
237///     pagination_detected: true,
238///     pagination_style: PaginationStyle::default(),
239/// };
240/// assert_eq!(shape.fields.len(), 2);
241/// ```
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct ResponseShape {
244    /// Inferred field types
245    pub fields: BTreeMap<String, JsonType>,
246    /// Optional representative sample value
247    pub sample: Option<Value>,
248    /// Whether pagination was detected
249    pub pagination_detected: bool,
250    /// Pagination envelope style details
251    pub pagination_style: PaginationStyle,
252}
253
254impl ResponseShape {
255    /// Build a `ResponseShape` by analysing a JSON response body.
256    ///
257    /// If the body is an object with a `data` key that is an array,
258    /// fields are inferred from the first array element.  Otherwise
259    /// the top-level object fields are used.
260    ///
261    /// # Example
262    ///
263    /// ```
264    /// use stygian_graph::domain::discovery::ResponseShape;
265    /// use serde_json::json;
266    ///
267    /// let body = json!({"data": [{"id": 1, "name": "A"}], "total": 50, "per_page": 25});
268    /// let shape = ResponseShape::from_body(&body);
269    /// assert!(shape.pagination_detected);
270    /// assert!(shape.fields.contains_key("id"));
271    /// ```
272    #[must_use]
273    pub fn from_body(body: &Value) -> Self {
274        let pagination_style = PaginationStyle::detect(body);
275        let pagination_detected = pagination_style.is_paginated();
276
277        // Try to extract fields from data[0] if it's a wrapped array
278        let (fields, sample) = if let Some(arr) = body.get("data").and_then(Value::as_array) {
279            if let Some(first) = arr.first() {
280                let inferred = match JsonType::infer(first) {
281                    JsonType::Object(m) => m,
282                    other => BTreeMap::from([("value".into(), other)]),
283                };
284                (inferred, Some(first.clone()))
285            } else {
286                (BTreeMap::new(), None)
287            }
288        } else {
289            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
301        Self {
302            fields,
303            sample,
304            pagination_detected,
305            pagination_style,
306        }
307    }
308}
309
310// ─────────────────────────────────────────────────────────────────────────────
311// DiscoveryReport
312// ─────────────────────────────────────────────────────────────────────────────
313
314/// Collection of [`ResponseShape`]s keyed by endpoint name.
315///
316/// A discovery probe fills this report and passes it to
317/// [`OpenApiGenerator`](crate::adapters::openapi_gen::OpenApiGenerator).
318///
319/// # Example
320///
321/// ```
322/// use stygian_graph::domain::discovery::{DiscoveryReport, ResponseShape};
323/// use serde_json::json;
324///
325/// let mut report = DiscoveryReport::new();
326/// let body = json!({"id": 1, "name": "Test"});
327/// report.add_endpoint("get_items", ResponseShape::from_body(&body));
328/// assert_eq!(report.endpoints().len(), 1);
329/// ```
330#[derive(Debug, Clone, Default, Serialize, Deserialize)]
331pub struct DiscoveryReport {
332    endpoints: BTreeMap<String, ResponseShape>,
333}
334
335impl DiscoveryReport {
336    /// Create an empty report.
337    ///
338    /// # Example
339    ///
340    /// ```
341    /// use stygian_graph::domain::discovery::DiscoveryReport;
342    ///
343    /// let report = DiscoveryReport::new();
344    /// assert!(report.endpoints().is_empty());
345    /// ```
346    #[must_use]
347    pub fn new() -> Self {
348        Self::default()
349    }
350
351    /// Add a discovered endpoint shape.
352    ///
353    /// # Example
354    ///
355    /// ```
356    /// use stygian_graph::domain::discovery::{DiscoveryReport, ResponseShape};
357    /// use serde_json::json;
358    ///
359    /// let mut report = DiscoveryReport::new();
360    /// report.add_endpoint("users", ResponseShape::from_body(&json!({"id": 1})));
361    /// ```
362    pub fn add_endpoint(&mut self, name: &str, shape: ResponseShape) {
363        self.endpoints.insert(name.to_string(), shape);
364    }
365
366    /// Return a view of all discovered endpoints.
367    ///
368    /// # Example
369    ///
370    /// ```
371    /// use stygian_graph::domain::discovery::DiscoveryReport;
372    ///
373    /// let report = DiscoveryReport::new();
374    /// assert!(report.endpoints().is_empty());
375    /// ```
376    #[must_use]
377    pub fn endpoints(&self) -> &BTreeMap<String, ResponseShape> {
378        &self.endpoints
379    }
380}
381
382// ─────────────────────────────────────────────────────────────────────────────
383// Tests
384// ─────────────────────────────────────────────────────────────────────────────
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use serde_json::json;
390
391    #[test]
392    fn json_type_infer_primitives() {
393        assert_eq!(JsonType::infer(&json!(null)), JsonType::Null);
394        assert_eq!(JsonType::infer(&json!(true)), JsonType::Bool);
395        assert_eq!(JsonType::infer(&json!(42)), JsonType::Integer);
396        assert_eq!(JsonType::infer(&json!(3.14)), JsonType::Float);
397        assert_eq!(JsonType::infer(&json!("hello")), JsonType::String);
398    }
399
400    #[test]
401    fn json_type_infer_array_uniform() {
402        let t = JsonType::infer(&json!([1, 2, 3]));
403        assert_eq!(t, JsonType::Array(Box::new(JsonType::Integer)));
404    }
405
406    #[test]
407    fn json_type_infer_array_mixed() {
408        let t = JsonType::infer(&json!([1, "two", 3]));
409        assert_eq!(t, JsonType::Array(Box::new(JsonType::Mixed)));
410    }
411
412    #[test]
413    fn json_type_infer_object() {
414        let t = JsonType::infer(&json!({"name": "Alice", "age": 30}));
415        match t {
416            JsonType::Object(fields) => {
417                assert_eq!(fields.len(), 2);
418                assert_eq!(fields["name"], JsonType::String);
419                assert_eq!(fields["age"], JsonType::Integer);
420            }
421            other => panic!("expected Object, got {other:?}"),
422        }
423    }
424
425    #[test]
426    fn pagination_style_detect_common_envelope() {
427        let body = json!({
428            "data": [{"id": 1}],
429            "current_page": 1,
430            "total": 100,
431            "per_page": 25,
432        });
433        let style = PaginationStyle::detect(&body);
434        assert!(style.has_data_wrapper);
435        assert!(style.has_current_page);
436        assert!(style.has_total);
437        assert!(style.has_per_page);
438        assert!(style.is_paginated());
439    }
440
441    #[test]
442    fn pagination_style_detect_none() {
443        let body = json!({"items": [{"id": 1}]});
444        let style = PaginationStyle::detect(&body);
445        assert!(!style.is_paginated());
446    }
447
448    #[test]
449    fn response_shape_from_wrapped_body() {
450        let body = json!({
451            "data": [{"id": 1, "name": "Test"}],
452            "total": 42,
453            "per_page": 25,
454        });
455        let shape = ResponseShape::from_body(&body);
456        assert!(shape.pagination_detected);
457        assert!(shape.fields.contains_key("id"));
458        assert!(shape.fields.contains_key("name"));
459    }
460
461    #[test]
462    fn response_shape_from_flat_body() {
463        let body = json!({"id": 1, "name": "Test"});
464        let shape = ResponseShape::from_body(&body);
465        assert!(!shape.pagination_detected);
466        assert!(shape.fields.contains_key("id"));
467    }
468
469    #[test]
470    fn discovery_report_roundtrip() {
471        let mut report = DiscoveryReport::new();
472        let body = json!({"data": [{"id": 1}], "total": 1, "per_page": 25});
473        report.add_endpoint("items", ResponseShape::from_body(&body));
474
475        assert_eq!(report.endpoints().len(), 1);
476        assert!(report.endpoints().contains_key("items"));
477    }
478}