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}