use std::path::Path;
use serde_json::Value;
#[derive(Debug, Clone)]
pub struct ApiSpec {
pub paths: Vec<String>,
pub request_data_props: Vec<String>,
pub response_data_props: Vec<String>,
pub open_props: bool,
}
pub fn load(path: &Path) -> Result<ApiSpec, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("cannot read spec: {e}"))?;
let doc: Value =
serde_json::from_str(&text).map_err(|e| format!("spec is not valid JSON: {e}"))?;
parse(&doc)
}
pub fn parse(doc: &Value) -> Result<ApiSpec, String> {
if doc.get("openapi").and_then(Value::as_str).is_none() {
return Err("missing `openapi` version field".to_string());
}
let Some(paths_obj) = doc.get("paths").and_then(Value::as_object) else {
return Err("missing `paths`".to_string());
};
let paths: Vec<String> = paths_obj.keys().cloned().collect();
let mut request_data_props = Vec::new();
let mut response_data_props = Vec::new();
let mut open_props = true;
for item in paths_obj.values() {
let Some(post) = item.get("post") else {
continue;
};
let request_schema = post
.pointer("/requestBody/content/application~1json/schema")
.map(|s| resolve_ref(doc, s));
let response_schema = post
.pointer("/responses/200/content/application~1json/schema")
.map(|s| resolve_ref(doc, s));
if let Some(schema) = request_schema {
let (props, open) = data_props(doc, &schema);
request_data_props = props;
open_props = open;
}
if let Some(schema) = response_schema {
let (props, open) = data_props(doc, &schema);
response_data_props = props;
open_props = open_props || open;
}
break;
}
Ok(ApiSpec {
paths,
request_data_props,
response_data_props,
open_props,
})
}
fn resolve_ref(doc: &Value, schema: &Value) -> Value {
if let Some(reference) = schema.get("$ref").and_then(Value::as_str) {
if let Some(path) = reference.strip_prefix("#/") {
let pointer = format!("/{}", path);
if let Some(resolved) = doc.pointer(&pointer) {
return resolve_ref(doc, resolved);
}
}
}
schema.clone()
}
fn data_props(doc: &Value, envelope: &Value) -> (Vec<String>, bool) {
let data = envelope
.pointer("/properties/values/items")
.map(|s| resolve_ref(doc, s))
.and_then(|items| {
items
.pointer("/properties/data")
.map(|d| resolve_ref(doc, d))
});
let Some(data) = data else {
return (Vec::new(), true);
};
let props: Vec<String> = data
.get("properties")
.and_then(Value::as_object)
.map(|m| m.keys().cloned().collect())
.unwrap_or_default();
let open = match data.get("additionalProperties") {
Some(Value::Bool(false)) => false,
Some(_) | None => props.is_empty() || data.get("additionalProperties").is_some(),
};
(props, open)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_scaffold_spec() {
let spec_doc = crate::scaffold::scaffold_api_spec("enrich");
let spec = parse(&spec_doc).unwrap();
assert_eq!(spec.paths, vec!["/api/enrich"]);
assert!(spec.open_props, "scaffold uses additionalProperties: true");
}
#[test]
fn closed_contract_props_are_extracted() {
let doc = serde_json::json!({
"openapi": "3.1.0",
"info": {"title": "t", "version": "1"},
"paths": {
"/api/translate": {
"post": {
"requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Req"}}}},
"responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Res"}}}}}
}
}
},
"components": {"schemas": {
"Req": {"type": "object", "properties": {"values": {"type": "array", "items": {
"type": "object",
"properties": {"recordId": {"type": "string"}, "data": {
"type": "object",
"properties": {"text": {"type": "string"}, "language": {"type": "string"}},
"additionalProperties": false
}}
}}}},
"Res": {"type": "object", "properties": {"values": {"type": "array", "items": {
"type": "object",
"properties": {"recordId": {"type": "string"}, "data": {
"type": "object",
"properties": {"translation": {"type": "string"}},
"additionalProperties": false
}}
}}}}
}}
});
let spec = parse(&doc).unwrap();
assert_eq!(spec.paths, vec!["/api/translate"]);
let mut req = spec.request_data_props.clone();
req.sort();
assert_eq!(req, vec!["language".to_string(), "text".to_string()]);
assert!(!spec.open_props);
assert_eq!(spec.response_data_props, vec!["translation"]);
}
#[test]
fn rejects_non_openapi() {
assert!(parse(&serde_json::json!({"paths": {}})).is_err());
assert!(parse(&serde_json::json!({"openapi": "3.1.0"})).is_err());
}
}