1use std::path::Path;
7
8use serde_json::Value;
9
10#[derive(Debug, Clone)]
11pub struct ApiSpec {
12 pub paths: Vec<String>,
14 pub request_data_props: Vec<String>,
16 pub response_data_props: Vec<String>,
18 pub open_props: bool,
20}
21
22pub fn load(path: &Path) -> Result<ApiSpec, String> {
23 let text = std::fs::read_to_string(path).map_err(|e| format!("cannot read spec: {e}"))?;
24 let doc: Value =
25 serde_json::from_str(&text).map_err(|e| format!("spec is not valid JSON: {e}"))?;
26 parse(&doc)
27}
28
29pub fn parse(doc: &Value) -> Result<ApiSpec, String> {
30 if doc.get("openapi").and_then(Value::as_str).is_none() {
31 return Err("missing `openapi` version field".to_string());
32 }
33 let Some(paths_obj) = doc.get("paths").and_then(Value::as_object) else {
34 return Err("missing `paths`".to_string());
35 };
36 let paths: Vec<String> = paths_obj.keys().cloned().collect();
37
38 let mut request_data_props = Vec::new();
40 let mut response_data_props = Vec::new();
41 let mut open_props = true;
42 for item in paths_obj.values() {
43 let Some(post) = item.get("post") else {
44 continue;
45 };
46 let request_schema = post
47 .pointer("/requestBody/content/application~1json/schema")
48 .map(|s| resolve_ref(doc, s));
49 let response_schema = post
50 .pointer("/responses/200/content/application~1json/schema")
51 .map(|s| resolve_ref(doc, s));
52 if let Some(schema) = request_schema {
53 let (props, open) = data_props(doc, &schema);
54 request_data_props = props;
55 open_props = open;
56 }
57 if let Some(schema) = response_schema {
58 let (props, open) = data_props(doc, &schema);
59 response_data_props = props;
60 open_props = open_props || open;
61 }
62 break;
63 }
64 Ok(ApiSpec {
65 paths,
66 request_data_props,
67 response_data_props,
68 open_props,
69 })
70}
71
72fn resolve_ref(doc: &Value, schema: &Value) -> Value {
74 if let Some(reference) = schema.get("$ref").and_then(Value::as_str) {
75 if let Some(path) = reference.strip_prefix("#/") {
76 let pointer = format!("/{}", path);
77 if let Some(resolved) = doc.pointer(&pointer) {
78 return resolve_ref(doc, resolved);
79 }
80 }
81 }
82 schema.clone()
83}
84
85fn data_props(doc: &Value, envelope: &Value) -> (Vec<String>, bool) {
87 let data = envelope
88 .pointer("/properties/values/items")
89 .map(|s| resolve_ref(doc, s))
90 .and_then(|items| {
91 items
92 .pointer("/properties/data")
93 .map(|d| resolve_ref(doc, d))
94 });
95 let Some(data) = data else {
96 return (Vec::new(), true);
97 };
98 let props: Vec<String> = data
99 .get("properties")
100 .and_then(Value::as_object)
101 .map(|m| m.keys().cloned().collect())
102 .unwrap_or_default();
103 let open = match data.get("additionalProperties") {
104 Some(Value::Bool(false)) => false,
105 Some(_) | None => props.is_empty() || data.get("additionalProperties").is_some(),
106 };
107 (props, open)
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[test]
115 fn parses_the_scaffold_spec() {
116 let spec_doc = crate::scaffold::scaffold_api_spec("enrich");
117 let spec = parse(&spec_doc).unwrap();
118 assert_eq!(spec.paths, vec!["/api/enrich"]);
119 assert!(spec.open_props, "scaffold uses additionalProperties: true");
120 }
121
122 #[test]
123 fn closed_contract_props_are_extracted() {
124 let doc = serde_json::json!({
125 "openapi": "3.1.0",
126 "info": {"title": "t", "version": "1"},
127 "paths": {
128 "/api/translate": {
129 "post": {
130 "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Req"}}}},
131 "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Res"}}}}}
132 }
133 }
134 },
135 "components": {"schemas": {
136 "Req": {"type": "object", "properties": {"values": {"type": "array", "items": {
137 "type": "object",
138 "properties": {"recordId": {"type": "string"}, "data": {
139 "type": "object",
140 "properties": {"text": {"type": "string"}, "language": {"type": "string"}},
141 "additionalProperties": false
142 }}
143 }}}},
144 "Res": {"type": "object", "properties": {"values": {"type": "array", "items": {
145 "type": "object",
146 "properties": {"recordId": {"type": "string"}, "data": {
147 "type": "object",
148 "properties": {"translation": {"type": "string"}},
149 "additionalProperties": false
150 }}
151 }}}}
152 }}
153 });
154 let spec = parse(&doc).unwrap();
155 assert_eq!(spec.paths, vec!["/api/translate"]);
156 let mut req = spec.request_data_props.clone();
157 req.sort();
158 assert_eq!(req, vec!["language".to_string(), "text".to_string()]);
159 assert!(!spec.open_props);
160 assert_eq!(spec.response_data_props, vec!["translation"]);
161 }
162
163 #[test]
164 fn rejects_non_openapi() {
165 assert!(parse(&serde_json::json!({"paths": {}})).is_err());
166 assert!(parse(&serde_json::json!({"openapi": "3.1.0"})).is_err());
167 }
168}