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}