1mod endpoint;
2mod parameter;
3mod response_kind;
4mod text_schema;
5
6pub use endpoint::{Endpoint, RequestBody};
7pub use parameter::Parameter;
8pub use response_kind::ResponseKind;
9pub use text_schema::TextSchema;
10
11use std::{collections::BTreeMap, io};
12
13use crate::ref_to_type_name;
14use oas3::Spec;
15use oas3::spec::{
16 ObjectOrReference, ObjectSchema, Operation, ParameterIn, PathItem, Schema, SchemaType,
17 SchemaTypeSet,
18};
19use serde_json::Value;
20
21pub type TypeSchemas = BTreeMap<String, Value>;
23
24pub fn parse_openapi_json(json: &str) -> io::Result<Spec> {
29 let mut value: Value =
30 serde_json::from_str(json).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
31
32 clean_for_oas3(&mut value);
34
35 let cleaned_json =
36 serde_json::to_string(&value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
37
38 oas3::from_json(&cleaned_json).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
39}
40
41pub fn extract_schemas(json: &str) -> TypeSchemas {
43 let Ok(value) = serde_json::from_str::<Value>(json) else {
44 return BTreeMap::new();
45 };
46
47 value
48 .get("components")
49 .and_then(|c| c.get("schemas"))
50 .and_then(|s| s.as_object())
51 .map(|schemas| {
52 schemas
53 .iter()
54 .map(|(name, schema)| (name.clone(), schema.clone()))
55 .collect()
56 })
57 .unwrap_or_default()
58}
59
60fn clean_for_oas3(value: &mut Value) {
64 match value {
65 Value::Object(map) => {
66 if map.contains_key("$ref") {
68 map.retain(|k, _| k == "$ref" || k == "summary" || k == "description");
69 } else {
70 if let Some(schema) = map.get_mut("schema")
72 && schema.is_boolean()
73 {
74 *schema = Value::Object(serde_json::Map::new());
75 }
76 for v in map.values_mut() {
77 clean_for_oas3(v);
78 }
79 }
80 }
81 Value::Array(arr) => {
82 for v in arr {
83 clean_for_oas3(v);
84 }
85 }
86 _ => {}
87 }
88}
89
90pub fn extract_endpoints(spec: &Spec) -> Vec<Endpoint> {
92 let mut endpoints = Vec::new();
93
94 let Some(paths) = &spec.paths else {
95 return endpoints;
96 };
97
98 for (path, path_item) in paths {
99 for (method, operation) in get_operations(path_item) {
100 if let Some(endpoint) = extract_endpoint(path, method, operation, spec) {
101 endpoints.push(endpoint);
102 }
103 }
104 }
105
106 endpoints
107}
108
109fn get_operations(path_item: &PathItem) -> Vec<(&'static str, &Operation)> {
110 [
111 ("GET", &path_item.get),
112 ("POST", &path_item.post),
113 ("PUT", &path_item.put),
114 ("DELETE", &path_item.delete),
115 ("PATCH", &path_item.patch),
116 ]
117 .into_iter()
118 .filter_map(|(method, op)| op.as_ref().map(|o| (method, o)))
119 .collect()
120}
121
122fn extract_endpoint(
123 path: &str,
124 method: &str,
125 operation: &Operation,
126 spec: &Spec,
127) -> Option<Endpoint> {
128 let path_params = extract_path_parameters(path, operation);
129 let query_params = extract_parameters(operation, ParameterIn::Query);
130
131 let response_kind = extract_response_kind(operation, spec);
132 let request_body = extract_request_body(operation);
133 let supports_csv = check_csv_support(operation);
134
135 Some(Endpoint {
136 method: method.to_string(),
137 path: path.to_string(),
138 operation_id: operation.operation_id.clone(),
139 summary: operation.summary.clone(),
140 description: operation.description.clone(),
141 path_params,
142 query_params,
143 request_body,
144 response_kind,
145 deprecated: operation.deprecated.unwrap_or(false),
146 supports_csv,
147 })
148}
149
150fn extract_request_body(operation: &Operation) -> Option<RequestBody> {
153 let req = operation.request_body.as_ref()?;
154 let req = match req {
155 ObjectOrReference::Object(rb) => rb,
156 ObjectOrReference::Ref { .. } => return None,
157 };
158
159 let body_type = if req.content.contains_key("text/plain; charset=utf-8")
160 || req.content.contains_key("text/plain")
161 {
162 "string".to_string()
163 } else if let Some(content) = req.content.get("application/json") {
164 schema_name_from_content(content).unwrap_or_else(|| "Object".to_string())
165 } else {
166 "string".to_string()
167 };
168
169 Some(RequestBody {
170 body_type,
171 required: req.required.unwrap_or(false),
172 })
173}
174
175fn check_csv_support(operation: &Operation) -> bool {
177 let Some(responses) = operation.responses.as_ref() else {
178 return false;
179 };
180 let Some(response) = responses.get("200") else {
181 return false;
182 };
183 match response {
184 ObjectOrReference::Object(response) => response.content.contains_key("text/csv"),
185 ObjectOrReference::Ref { .. } => false,
186 }
187}
188
189fn extract_path_parameters(path: &str, operation: &Operation) -> Vec<Parameter> {
191 let path_order: Vec<&str> = path
193 .split('/')
194 .filter_map(|segment| segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')))
195 .collect();
196
197 let params = extract_parameters(operation, ParameterIn::Path);
199
200 let mut sorted_params: Vec<Parameter> = params;
202 sorted_params.sort_by_key(|p| {
203 path_order
204 .iter()
205 .position(|&name| name == p.name)
206 .unwrap_or(usize::MAX)
207 });
208
209 sorted_params
210}
211
212fn extract_parameters(operation: &Operation, location: ParameterIn) -> Vec<Parameter> {
213 operation
214 .parameters
215 .iter()
216 .filter_map(|p| match p {
217 ObjectOrReference::Object(param) if param.location == location => {
218 let param_type = param
219 .schema
220 .as_ref()
221 .and_then(schema_type_from_schema)
222 .unwrap_or_else(|| "string".to_string());
223 Some(Parameter {
224 name: param.name.clone(),
225 required: param.required.unwrap_or(false),
226 param_type,
227 description: param.description.clone(),
228 })
229 }
230 _ => None,
231 })
232 .collect()
233}
234
235fn extract_response_kind(operation: &Operation, spec: &Spec) -> ResponseKind {
236 let response = operation
237 .responses
238 .as_ref()
239 .and_then(|r| r.get("200"))
240 .and_then(|r| match r {
241 ObjectOrReference::Object(o) => Some(o),
242 ObjectOrReference::Ref { .. } => None,
243 });
244 let Some(response) = response else {
245 return ResponseKind::Text(None);
246 };
247
248 if response.content.contains_key("application/octet-stream") {
249 return ResponseKind::Binary;
250 }
251 if let Some(content) = response.content.get("application/json") {
252 return ResponseKind::Json(
253 schema_name_from_content(content).unwrap_or_else(|| "*".to_string()),
254 );
255 }
256 if let Some(content) = response.content.get("text/plain; charset=utf-8") {
257 let schema = schema_name_from_content(content).map(|name| {
258 let is_numeric = is_numeric_schema(spec, &name);
259 TextSchema { name, is_numeric }
260 });
261 return ResponseKind::Text(schema);
262 }
263 ResponseKind::Text(None)
264}
265
266fn schema_name_from_content(content: &oas3::spec::MediaType) -> Option<String> {
267 schema_type_from_schema(content.schema.as_ref()?)
268}
269
270fn is_numeric_schema(spec: &Spec, name: &str) -> bool {
273 let Some(components) = spec.components.as_ref() else {
274 return false;
275 };
276 let Some(Schema::Object(obj_or_ref)) = components.schemas.get(name) else {
277 return false;
278 };
279 let ObjectOrReference::Object(schema) = obj_or_ref.as_ref() else {
280 return false;
281 };
282 matches!(
283 schema.schema_type.as_ref(),
284 Some(SchemaTypeSet::Single(
285 SchemaType::Integer | SchemaType::Number
286 ))
287 )
288}
289
290fn schema_type_from_schema(schema: &Schema) -> Option<String> {
291 match schema {
292 Schema::Boolean(_) => Some("boolean".to_string()),
293 Schema::Object(obj_or_ref) => match obj_or_ref.as_ref() {
294 ObjectOrReference::Object(obj_schema) => schema_to_type_name(obj_schema),
295 ObjectOrReference::Ref { ref_path, .. } => {
296 ref_to_type_name(ref_path).map(|s| s.to_string())
299 }
300 },
301 }
302}
303
304fn schema_to_type_name(schema: &ObjectSchema) -> Option<String> {
305 if let Some(schema_type) = schema.schema_type.as_ref() {
306 return match schema_type {
307 SchemaTypeSet::Single(t) => single_type_to_name(t, schema),
308 SchemaTypeSet::Multiple(types) => {
309 types
311 .iter()
312 .find(|t| !matches!(t, SchemaType::Null))
313 .and_then(|t| single_type_to_name(t, schema))
314 .or(Some("*".to_string()))
315 }
316 };
317 }
318
319 let variants = if !schema.any_of.is_empty() {
321 &schema.any_of
322 } else if !schema.one_of.is_empty() {
323 &schema.one_of
324 } else {
325 return None;
326 };
327
328 let types: Vec<String> = variants
329 .iter()
330 .filter_map(|v| match v {
331 Schema::Boolean(_) => None,
332 Schema::Object(obj_or_ref) => match obj_or_ref.as_ref() {
333 ObjectOrReference::Ref { ref_path, .. } => {
334 ref_to_type_name(ref_path).map(|s| s.to_string())
335 }
336 ObjectOrReference::Object(obj) => {
337 if matches!(
338 obj.schema_type.as_ref(),
339 Some(SchemaTypeSet::Single(SchemaType::Null))
340 ) {
341 return None;
342 }
343 schema_to_type_name(obj)
344 }
345 },
346 })
347 .collect();
348
349 match types.len() {
350 0 => None,
351 1 => Some(types.into_iter().next().unwrap()),
352 _ => Some(types.join(" | ")),
353 }
354}
355
356fn single_type_to_name(t: &SchemaType, schema: &ObjectSchema) -> Option<String> {
357 match t {
358 SchemaType::String => Some("string".to_string()),
359 SchemaType::Number => Some("number".to_string()),
360 SchemaType::Integer => Some("integer".to_string()),
361 SchemaType::Boolean => Some("boolean".to_string()),
362 SchemaType::Array => {
363 let inner = match &schema.items {
364 Some(boxed_schema) => schema_type_from_schema(boxed_schema),
365 None => Some("*".to_string()),
366 };
367 inner.map(|t| format!("{}[]", t))
368 }
369 SchemaType::Object => Some("Object".to_string()),
370 SchemaType::Null => Some("null".to_string()),
371 }
372}