use crate::constraints::ConstraintValue;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use smallvec::SmallVec;
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq)]
pub enum PathSegment {
Field(Arc<str>),
Index(usize),
Wildcard,
}
#[derive(Debug, Clone)]
pub struct CompiledPath {
pub original: Arc<str>,
pub segments: SmallVec<[PathSegment; 4]>,
pub has_wildcard: bool,
}
impl CompiledPath {
pub fn compile(path: &str) -> Self {
let mut segments = SmallVec::new();
let mut has_wildcard = false;
for segment in path.split('.') {
if segment == "*" {
segments.push(PathSegment::Wildcard);
has_wildcard = true;
} else if let Ok(idx) = segment.parse::<usize>() {
segments.push(PathSegment::Index(idx));
} else {
segments.push(PathSegment::Field(Arc::from(segment)));
}
}
Self {
original: Arc::from(path),
segments,
has_wildcard,
}
}
pub fn extract(&self, body: &Value) -> Option<ConstraintValue> {
extract_compiled_recursive(body, &self.segments)
}
}
fn extract_compiled_recursive(value: &Value, segments: &[PathSegment]) -> Option<ConstraintValue> {
if segments.is_empty() {
return json_to_constraint_value(value);
}
let segment = &segments[0];
let rest = &segments[1..];
match segment {
PathSegment::Wildcard => {
if let Some(arr) = value.as_array() {
let values: Vec<ConstraintValue> = arr
.iter()
.filter_map(|item| extract_compiled_recursive(item, rest))
.collect();
if values.is_empty() {
return None;
}
return Some(ConstraintValue::List(values));
}
None
}
PathSegment::Index(idx) => {
let item = value.get(*idx)?;
extract_compiled_recursive(item, rest)
}
PathSegment::Field(name) => {
let child = value.get(name.as_ref())?;
extract_compiled_recursive(child, rest)
}
}
}
#[derive(Debug, Clone)]
pub struct CompiledExtractionRule {
pub rule: ExtractionRule,
pub compiled_path: Option<CompiledPath>,
pub lowercase_key: Option<Arc<str>>,
}
impl CompiledExtractionRule {
pub fn compile(rule: ExtractionRule) -> Self {
let compiled_path = if rule.from == ExtractionSource::Body {
Some(CompiledPath::compile(&rule.path))
} else {
None
};
let lowercase_key = if rule.from == ExtractionSource::Header {
Some(Arc::from(rule.path.to_lowercase()))
} else {
None
};
Self {
rule,
compiled_path,
lowercase_key,
}
}
pub fn extract(&self, ctx: &RequestContext) -> Option<ConstraintValue> {
match &self.rule.from {
ExtractionSource::Path => ctx
.path_params
.get(&self.rule.path)
.map(|s| ConstraintValue::String(s.clone())),
ExtractionSource::Query => ctx
.query_params
.get(&self.rule.path)
.map(|s| ConstraintValue::String(s.clone())),
ExtractionSource::Header => {
let key = self.lowercase_key.as_ref()?;
ctx.headers
.get(key.as_ref())
.map(|s| ConstraintValue::String(s.clone()))
}
ExtractionSource::Body => {
let path = self.compiled_path.as_ref()?;
path.extract(&ctx.body)
}
ExtractionSource::Literal => self
.rule
.default
.as_ref()
.and_then(json_to_constraint_value),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct CompiledExtractionRules {
pub rules: HashMap<String, CompiledExtractionRule>,
}
impl CompiledExtractionRules {
pub fn compile(rules: HashMap<String, ExtractionRule>) -> Self {
let compiled = rules
.into_iter()
.map(|(name, rule)| (name, CompiledExtractionRule::compile(rule)))
.collect();
Self { rules: compiled }
}
pub fn extract_all(
&self,
ctx: &RequestContext,
) -> Result<(HashMap<String, ConstraintValue>, Vec<ExtractionTrace>), ExtractionError> {
let mut constraints = HashMap::new();
let mut traces = Vec::new();
for (name, compiled) in &self.rules {
let value = compiled.extract(ctx);
let trace = ExtractionTrace {
field: name.clone(),
source: compiled.rule.from.clone(),
path: compiled.rule.path.clone(),
result: value.clone(),
required: compiled.rule.required,
hint: if value.is_none() {
Some(generate_hint(&compiled.rule, ctx))
} else {
None
},
};
traces.push(trace);
match value {
Some(v) => {
constraints.insert(name.clone(), v);
}
None if compiled.rule.required => {
return Err(ExtractionError {
field: name.clone(),
source: compiled.rule.from.clone(),
path: compiled.rule.path.clone(),
hint: generate_hint(&compiled.rule, ctx),
required: true,
});
}
None => {
if let Some(ref default) = compiled.rule.default {
if let Some(v) = json_to_constraint_value(default) {
constraints.insert(name.clone(), v);
}
}
}
}
}
Ok((constraints, traces))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum ExtractionSource {
Path,
Query,
Header,
Body,
Literal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractionRule {
pub from: ExtractionSource,
pub path: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub default: Option<Value>,
#[serde(rename = "type", default)]
pub value_type: Option<String>,
#[serde(default)]
pub allowed_values: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default)]
pub struct RequestContext {
pub path_params: HashMap<String, String>,
pub query_params: HashMap<String, String>,
pub headers: HashMap<String, String>,
pub body: Value,
}
impl RequestContext {
pub fn new() -> Self {
Self::default()
}
pub fn with_body(body: Value) -> Self {
Self {
body,
..Default::default()
}
}
pub fn path_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.path_params.insert(key.into(), value.into());
self
}
pub fn query_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.query_params.insert(key.into(), value.into());
self
}
pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.insert(key.into().to_lowercase(), value.into());
self
}
}
#[derive(Debug, Clone)]
pub struct ExtractionTrace {
pub field: String,
pub source: ExtractionSource,
pub path: String,
pub result: Option<ConstraintValue>,
pub required: bool,
pub hint: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ExtractionError {
pub field: String,
pub source: ExtractionSource,
pub path: String,
pub hint: String,
pub required: bool,
}
impl std::fmt::Display for ExtractionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Missing required field '{}' (from {:?}, path: {}). {}",
self.field, self.source, self.path, self.hint
)
}
}
impl std::error::Error for ExtractionError {}
pub fn extract_json_path(body: &Value, path: &str) -> Option<ConstraintValue> {
let segments: Vec<&str> = path.split('.').collect();
extract_recursive(body, &segments)
}
fn extract_recursive(value: &Value, segments: &[&str]) -> Option<ConstraintValue> {
if segments.is_empty() {
return json_to_constraint_value(value);
}
let segment = segments[0];
let rest = &segments[1..];
if segment == "*" {
if let Some(arr) = value.as_array() {
let values: Vec<ConstraintValue> = arr
.iter()
.filter_map(|item| extract_recursive(item, rest))
.collect();
if values.is_empty() {
return None;
}
return Some(ConstraintValue::List(values));
}
return None;
}
if let Ok(idx) = segment.parse::<usize>() {
let item = value.get(idx)?;
return extract_recursive(item, rest);
}
let child = value.get(segment)?;
extract_recursive(child, rest)
}
fn json_to_constraint_value(value: &Value) -> Option<ConstraintValue> {
match value {
Value::String(s) => Some(ConstraintValue::String(s.clone())),
Value::Bool(b) => Some(ConstraintValue::Boolean(*b)),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
Some(ConstraintValue::Integer(i))
} else if let Some(u) = n.as_u64() {
if u > i64::MAX as u64 {
Some(ConstraintValue::String(u.to_string()))
} else {
u.try_into()
.ok()
.map(ConstraintValue::Integer)
.or_else(|| Some(ConstraintValue::String(u.to_string())))
}
} else {
n.as_f64().map(ConstraintValue::Float)
}
}
Value::Array(arr) => {
let values: Vec<ConstraintValue> =
arr.iter().filter_map(json_to_constraint_value).collect();
Some(ConstraintValue::List(values))
}
Value::Null => Some(ConstraintValue::Null),
Value::Object(map) => {
let mut result = BTreeMap::new();
for (k, v) in map {
if let Some(cv) = json_to_constraint_value(v) {
result.insert(k.clone(), cv);
}
}
Some(ConstraintValue::Object(result))
}
}
}
pub fn extract_by_rule(rule: &ExtractionRule, ctx: &RequestContext) -> Option<ConstraintValue> {
match &rule.from {
ExtractionSource::Path => ctx
.path_params
.get(&rule.path)
.map(|s| ConstraintValue::String(s.clone())),
ExtractionSource::Query => ctx
.query_params
.get(&rule.path)
.map(|s| ConstraintValue::String(s.clone())),
ExtractionSource::Header => {
let key = rule.path.to_lowercase();
ctx.headers
.get(&key)
.map(|s| ConstraintValue::String(s.clone()))
}
ExtractionSource::Body => extract_json_path(&ctx.body, &rule.path),
ExtractionSource::Literal => rule.default.as_ref().and_then(json_to_constraint_value),
}
}
pub fn generate_hint(rule: &ExtractionRule, ctx: &RequestContext) -> String {
match &rule.from {
ExtractionSource::Body => {
let available = list_available_paths(&ctx.body, 3);
if available.is_empty() {
"Body is empty or not valid JSON".to_string()
} else {
format!(
"Path '{}' not found. Available paths: {}",
rule.path,
available.join(", ")
)
}
}
ExtractionSource::Header => {
let available: Vec<_> = ctx.headers.keys().take(5).cloned().collect();
format!(
"Header '{}' not found. Available: {}",
rule.path,
if available.is_empty() {
"(none)".to_string()
} else {
available.join(", ")
}
)
}
ExtractionSource::Path => {
format!(
"Path param '{}' not matched. Check route pattern.",
rule.path
)
}
ExtractionSource::Query => {
let available: Vec<_> = ctx.query_params.keys().take(5).cloned().collect();
format!(
"Query param '{}' not found. Available: {}",
rule.path,
if available.is_empty() {
"(none)".to_string()
} else {
available.join(", ")
}
)
}
ExtractionSource::Literal => "Literal has no default value".to_string(),
}
}
fn list_available_paths(value: &Value, max_depth: usize) -> Vec<String> {
let mut paths = Vec::new();
collect_paths(value, String::new(), max_depth, &mut paths);
paths
}
fn collect_paths(value: &Value, prefix: String, depth: usize, paths: &mut Vec<String>) {
if depth == 0 {
return;
}
match value {
Value::Object(map) => {
for (key, val) in map {
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{}.{}", prefix, key)
};
paths.push(path.clone());
collect_paths(val, path, depth - 1, paths);
}
}
Value::Array(arr) if !arr.is_empty() => {
paths.push(format!("{}.*", prefix));
let path = format!("{}.0", prefix);
collect_paths(&arr[0], path, depth - 1, paths);
}
_ => {}
}
}
pub fn extract_all(
rules: &HashMap<String, ExtractionRule>,
ctx: &RequestContext,
) -> Result<(HashMap<String, ConstraintValue>, Vec<ExtractionTrace>), ExtractionError> {
let mut constraints = HashMap::new();
let mut traces = Vec::new();
for (name, rule) in rules {
let value = extract_by_rule(rule, ctx);
let trace = ExtractionTrace {
field: name.clone(),
source: rule.from.clone(),
path: rule.path.clone(),
result: value.clone(),
required: rule.required,
hint: if value.is_none() {
Some(generate_hint(rule, ctx))
} else {
None
},
};
traces.push(trace);
match value {
Some(v) => {
constraints.insert(name.clone(), v);
}
None if rule.required => {
return Err(ExtractionError {
field: name.clone(),
source: rule.from.clone(),
path: rule.path.clone(),
hint: generate_hint(rule, ctx),
required: true,
});
}
None => {
if let Some(ref default) = rule.default {
if let Some(v) = json_to_constraint_value(default) {
constraints.insert(name.clone(), v);
}
}
}
}
}
Ok((constraints, traces))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_simple_extraction() {
let body = json!({ "name": "test", "count": 42 });
assert_eq!(
extract_json_path(&body, "name"),
Some(ConstraintValue::String("test".into()))
);
assert_eq!(
extract_json_path(&body, "count"),
Some(ConstraintValue::Integer(42))
);
}
#[test]
fn test_nested_extraction() {
let body = json!({
"spec": {
"replicas": 5,
"container": {
"image": "nginx:latest"
}
}
});
assert_eq!(
extract_json_path(&body, "spec.replicas"),
Some(ConstraintValue::Integer(5))
);
assert_eq!(
extract_json_path(&body, "spec.container.image"),
Some(ConstraintValue::String("nginx:latest".into()))
);
}
#[test]
fn test_array_index() {
let body = json!({
"items": [
{ "id": "first" },
{ "id": "second" }
]
});
assert_eq!(
extract_json_path(&body, "items.0.id"),
Some(ConstraintValue::String("first".into()))
);
assert_eq!(
extract_json_path(&body, "items.1.id"),
Some(ConstraintValue::String("second".into()))
);
}
#[test]
fn test_wildcard_extraction() {
let body = json!({
"transfers": [
{ "amount": 100, "recipient": "alice" },
{ "amount": 200, "recipient": "bob" }
]
});
assert_eq!(
extract_json_path(&body, "transfers.*.amount"),
Some(ConstraintValue::List(vec![
ConstraintValue::Integer(100),
ConstraintValue::Integer(200),
]))
);
}
#[test]
fn test_integer_preservation() {
let body = json!({ "id": 9007199254740993_i64 });
if let Some(ConstraintValue::Integer(i)) = extract_json_path(&body, "id") {
assert_eq!(i, 9007199254740993);
} else {
panic!("Expected integer");
}
}
#[test]
fn test_float_extraction() {
let body = json!({ "price": 19.99 });
assert_eq!(
extract_json_path(&body, "price"),
Some(ConstraintValue::Float(19.99))
);
}
#[test]
fn test_request_context() {
let ctx = RequestContext::new()
.path_param("cluster", "staging-web")
.query_param("namespace", "default")
.header("X-Tenant-Id", "acme");
let rule = ExtractionRule {
from: ExtractionSource::Path,
path: "cluster".into(),
description: None,
required: true,
default: None,
value_type: None,
allowed_values: None,
};
assert_eq!(
extract_by_rule(&rule, &ctx),
Some(ConstraintValue::String("staging-web".into()))
);
}
#[test]
fn test_extract_all_with_defaults() {
let mut rules = HashMap::new();
rules.insert(
"cluster".into(),
ExtractionRule {
from: ExtractionSource::Path,
path: "cluster".into(),
description: None,
required: true,
default: None,
value_type: None,
allowed_values: None,
},
);
rules.insert(
"namespace".into(),
ExtractionRule {
from: ExtractionSource::Query,
path: "namespace".into(),
description: None,
required: false,
default: Some(json!("default")),
value_type: None,
allowed_values: None,
},
);
let ctx = RequestContext::new().path_param("cluster", "prod");
let (constraints, _traces) = extract_all(&rules, &ctx).unwrap();
assert_eq!(
constraints.get("cluster"),
Some(&ConstraintValue::String("prod".into()))
);
assert_eq!(
constraints.get("namespace"),
Some(&ConstraintValue::String("default".into()))
);
}
#[test]
fn test_missing_required_field() {
let mut rules = HashMap::new();
rules.insert(
"cluster".into(),
ExtractionRule {
from: ExtractionSource::Path,
path: "cluster".into(),
description: None,
required: true,
default: None,
value_type: None,
allowed_values: None,
},
);
let ctx = RequestContext::new();
let result = extract_all(&rules, &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.field, "cluster");
assert!(err.required);
}
#[test]
fn test_compiled_path_simple() {
let path = CompiledPath::compile("name");
assert_eq!(path.segments.len(), 1);
assert!(!path.has_wildcard);
let body = json!({ "name": "test" });
assert_eq!(
path.extract(&body),
Some(ConstraintValue::String("test".into()))
);
}
#[test]
fn test_compiled_path_nested() {
let path = CompiledPath::compile("spec.replicas");
assert_eq!(path.segments.len(), 2);
assert!(!path.has_wildcard);
let body = json!({ "spec": { "replicas": 5 } });
assert_eq!(path.extract(&body), Some(ConstraintValue::Integer(5)));
}
#[test]
fn test_compiled_path_array_index() {
let path = CompiledPath::compile("items.0.id");
assert_eq!(path.segments.len(), 3);
let body = json!({ "items": [{ "id": "first" }, { "id": "second" }] });
assert_eq!(
path.extract(&body),
Some(ConstraintValue::String("first".into()))
);
}
#[test]
fn test_compiled_path_wildcard() {
let path = CompiledPath::compile("items.*.id");
assert!(path.has_wildcard);
let body = json!({ "items": [{ "id": "a" }, { "id": "b" }] });
assert_eq!(
path.extract(&body),
Some(ConstraintValue::List(vec![
ConstraintValue::String("a".into()),
ConstraintValue::String("b".into()),
]))
);
}
#[test]
fn test_compiled_extraction_rule() {
let rule = ExtractionRule {
from: ExtractionSource::Body,
path: "metadata.cost".into(),
description: None,
required: true,
default: None,
value_type: None,
allowed_values: None,
};
let compiled = CompiledExtractionRule::compile(rule);
assert!(compiled.compiled_path.is_some());
let ctx = RequestContext::with_body(json!({ "metadata": { "cost": 150.0 } }));
assert_eq!(compiled.extract(&ctx), Some(ConstraintValue::Float(150.0)));
}
#[test]
fn test_compiled_extraction_rules() {
let mut rules = HashMap::new();
rules.insert(
"cluster".into(),
ExtractionRule {
from: ExtractionSource::Path,
path: "cluster".into(),
description: None,
required: true,
default: None,
value_type: None,
allowed_values: None,
},
);
rules.insert(
"cost".into(),
ExtractionRule {
from: ExtractionSource::Body,
path: "metadata.cost".into(),
description: None,
required: false,
default: None,
value_type: None,
allowed_values: None,
},
);
let compiled = CompiledExtractionRules::compile(rules);
let ctx = RequestContext::with_body(json!({ "metadata": { "cost": 100.0 } }))
.path_param("cluster", "prod");
let (constraints, _traces) = compiled.extract_all(&ctx).unwrap();
assert_eq!(
constraints.get("cluster"),
Some(&ConstraintValue::String("prod".into()))
);
assert_eq!(
constraints.get("cost"),
Some(&ConstraintValue::Float(100.0))
);
}
#[test]
fn test_object_extraction() {
let body = json!({
"meta": {
"cost": 100,
"owner": "admin"
}
});
if let Some(ConstraintValue::Object(map)) = extract_json_path(&body, "meta") {
assert_eq!(map.get("cost"), Some(&ConstraintValue::Integer(100)));
assert_eq!(
map.get("owner"),
Some(&ConstraintValue::String("admin".to_string()))
);
} else {
panic!("Failed to extract object");
}
}
}