use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum JsonType {
Null,
Bool,
Integer,
Float,
String,
Array(Box<Self>),
Object(BTreeMap<String, Self>),
Mixed,
}
impl JsonType {
#[must_use]
pub fn infer(value: &Value) -> Self {
match value {
Value::Null => Self::Null,
Value::Bool(_) => Self::Bool,
Value::Number(n) => {
if n.is_f64() && n.as_i64().is_none() && n.as_u64().is_none() {
Self::Float
} else {
Self::Integer
}
}
Value::String(_) => Self::String,
Value::Array(arr) => {
if arr.is_empty() {
return Self::Array(Box::new(Self::Mixed));
}
let first = arr.first().map_or(Self::Mixed, Self::infer);
let uniform = arr.iter().skip(1).all(|v| Self::infer(v) == first);
if uniform {
Self::Array(Box::new(first))
} else {
Self::Array(Box::new(Self::Mixed))
}
}
Value::Object(map) => {
let fields = map
.iter()
.map(|(k, v)| (k.clone(), Self::infer(v)))
.collect();
Self::Object(fields)
}
}
}
#[must_use]
pub const fn schema_type(&self) -> &'static str {
match self {
Self::Null => "null",
Self::Bool => "boolean",
Self::Integer => "integer",
Self::Float => "number",
Self::String | Self::Mixed => "string",
Self::Array(_) => "array",
Self::Object(_) => "object",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaginationStyle {
pub has_data_wrapper: bool,
pub has_current_page: bool,
pub has_total_pages: bool,
pub has_last_page: bool,
pub has_total: bool,
pub has_per_page: bool,
}
impl PaginationStyle {
#[must_use]
pub const fn is_paginated(&self) -> bool {
self.has_current_page
|| self.has_total_pages
|| self.has_last_page
|| self.has_total
|| self.has_per_page
}
#[must_use]
pub fn detect(body: &Value) -> Self {
let Some(obj) = body.as_object() else {
return Self::default();
};
Self {
has_data_wrapper: obj.contains_key("data"),
has_current_page: obj.contains_key("current_page") || obj.contains_key("page"),
has_total_pages: obj.contains_key("total_pages"),
has_last_page: obj.contains_key("last_page"),
has_total: obj.contains_key("total") || obj.contains_key("total_count"),
has_per_page: obj.contains_key("per_page") || obj.contains_key("page_size"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponseShape {
pub fields: BTreeMap<String, JsonType>,
pub sample: Option<Value>,
pub pagination_detected: bool,
pub pagination_style: PaginationStyle,
}
impl ResponseShape {
#[must_use]
pub fn from_body(body: &Value) -> Self {
let pagination_style = PaginationStyle::detect(body);
let pagination_detected = pagination_style.is_paginated();
let (fields, sample) = body
.get("data")
.and_then(Value::as_array)
.and_then(|arr| {
arr.first().map(|first| {
let inferred = match JsonType::infer(first) {
JsonType::Object(m) => m,
other => BTreeMap::from([("value".into(), other)]),
};
(inferred, Some(first.clone()))
})
})
.unwrap_or_else(|| match JsonType::infer(body) {
JsonType::Object(m) => {
let sample = Some(body.clone());
(m, sample)
}
other => (
BTreeMap::from([("value".into(), other)]),
Some(body.clone()),
),
});
Self {
fields,
sample,
pagination_detected,
pagination_style,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DiscoveryReport {
endpoints: BTreeMap<String, ResponseShape>,
}
impl DiscoveryReport {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add_endpoint(&mut self, name: &str, shape: ResponseShape) {
self.endpoints.insert(name.to_string(), shape);
}
#[must_use]
pub const fn endpoints(&self) -> &BTreeMap<String, ResponseShape> {
&self.endpoints
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn json_type_infer_primitives() {
assert_eq!(JsonType::infer(&json!(null)), JsonType::Null);
assert_eq!(JsonType::infer(&json!(true)), JsonType::Bool);
assert_eq!(JsonType::infer(&json!(42)), JsonType::Integer);
assert_eq!(
JsonType::infer(&json!(std::f64::consts::PI)),
JsonType::Float
);
assert_eq!(JsonType::infer(&json!("hello")), JsonType::String);
}
#[test]
fn json_type_infer_array_uniform() {
let t = JsonType::infer(&json!([1, 2, 3]));
assert_eq!(t, JsonType::Array(Box::new(JsonType::Integer)));
}
#[test]
fn json_type_infer_array_mixed() {
let t = JsonType::infer(&json!([1, "two", 3]));
assert_eq!(t, JsonType::Array(Box::new(JsonType::Mixed)));
}
#[test]
fn json_type_infer_object() -> Result<(), Box<dyn std::error::Error>> {
let t = JsonType::infer(&json!({"name": "Alice", "age": 30}));
match t {
JsonType::Object(fields) => {
assert_eq!(fields.len(), 2);
let name_type = fields.get("name").ok_or("missing 'name' field")?;
assert_eq!(name_type, &JsonType::String);
let age_type = fields.get("age").ok_or("missing 'age' field")?;
assert_eq!(age_type, &JsonType::Integer);
}
other => return Err(format!("expected Object, got {other:?}").into()),
}
Ok(())
}
#[test]
fn pagination_style_detect_common_envelope() {
let body = json!({
"data": [{"id": 1}],
"current_page": 1,
"total": 100,
"per_page": 25,
});
let style = PaginationStyle::detect(&body);
assert!(style.has_data_wrapper);
assert!(style.has_current_page);
assert!(style.has_total);
assert!(style.has_per_page);
assert!(style.is_paginated());
}
#[test]
fn pagination_style_detect_none() {
let body = json!({"items": [{"id": 1}]});
let style = PaginationStyle::detect(&body);
assert!(!style.is_paginated());
}
#[test]
fn response_shape_from_wrapped_body() {
let body = json!({
"data": [{"id": 1, "name": "Test"}],
"total": 42,
"per_page": 25,
});
let shape = ResponseShape::from_body(&body);
assert!(shape.pagination_detected);
assert!(shape.fields.contains_key("id"));
assert!(shape.fields.contains_key("name"));
}
#[test]
fn response_shape_from_flat_body() {
let body = json!({"id": 1, "name": "Test"});
let shape = ResponseShape::from_body(&body);
assert!(!shape.pagination_detected);
assert!(shape.fields.contains_key("id"));
}
#[test]
fn discovery_report_roundtrip() {
let mut report = DiscoveryReport::new();
let body = json!({"data": [{"id": 1}], "total": 1, "per_page": 25});
report.add_endpoint("items", ResponseShape::from_body(&body));
assert_eq!(report.endpoints().len(), 1);
assert!(report.endpoints().contains_key("items"));
}
}