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`] 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}