Skip to main content

brk_bindgen/openapi/
mod.rs

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
21/// Type schema extracted from OpenAPI components
22pub type TypeSchemas = BTreeMap<String, Value>;
23
24/// Parse OpenAPI spec from JSON string
25///
26/// Pre-processes the JSON to handle oas3 limitations:
27/// - Removes unsupported siblings from `$ref` objects (oas3 only supports `summary` and `description`)
28pub 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 up for oas3 compatibility
33    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
41/// Extract type schemas from OpenAPI JSON
42pub 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
60/// Clean up OpenAPI spec for oas3 compatibility.
61/// - Removes unsupported siblings from $ref objects (oas3 only supports summary and description)
62/// - Converts boolean schemas to object schemas (oas3 doesn't handle `"schema": true`)
63fn clean_for_oas3(value: &mut Value) {
64    match value {
65        Value::Object(map) => {
66            // Handle $ref with unsupported siblings
67            if map.contains_key("$ref") {
68                map.retain(|k, _| k == "$ref" || k == "summary" || k == "description");
69            } else {
70                // Convert boolean schemas to empty object schemas
71                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
90/// Extract all endpoints from OpenAPI spec
91pub 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
150/// Extract the request body shape, if any.
151/// Prefers `text/plain` (string) over `application/json` (typed).
152fn 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
175/// Check if the endpoint supports CSV format (has text/csv in 200 response content types).
176fn 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
189/// Extract path parameters in the order they appear in the path URL.
190fn extract_path_parameters(path: &str, operation: &Operation) -> Vec<Parameter> {
191    // Extract parameter names from the path in order (e.g., "/api/series/{series}/{index}" -> ["series", "index"])
192    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    // Get all path parameters from the operation
198    let params = extract_parameters(operation, ParameterIn::Path);
199
200    // Sort by position in the path
201    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
270/// Resolves `name` against `components.schemas` and reports whether the
271/// underlying primitive is `integer` or `number`.
272fn 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                // Return the type name as-is (e.g., "Height", "Address")
297                // These should have definitions generated from schemas
298                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                // For nullable types like ["integer", "null"], return the non-null type
310                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    // Handle anyOf/oneOf unions (e.g., Option<RangeIndex> → anyOf: [$ref, null])
320    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}