Skip to main content

brk_bindgen/
openapi.rs

1use std::{collections::BTreeMap, io};
2
3use crate::ref_to_type_name;
4use oas3::Spec;
5use oas3::spec::{
6    ObjectOrReference, ObjectSchema, Operation, ParameterIn, PathItem, Schema, SchemaType,
7    SchemaTypeSet,
8};
9use serde_json::Value;
10
11/// Type schema extracted from OpenAPI components
12pub type TypeSchemas = BTreeMap<String, Value>;
13
14/// Endpoint information extracted from OpenAPI spec
15#[derive(Debug, Clone)]
16pub struct Endpoint {
17    /// HTTP method (GET, POST, etc.)
18    pub method: String,
19    /// Path template (e.g., "/blocks/{hash}")
20    pub path: String,
21    /// Operation ID (e.g., "getBlockByHash")
22    pub operation_id: Option<String>,
23    /// Short summary
24    pub summary: Option<String>,
25    /// Detailed description
26    pub description: Option<String>,
27    /// Path parameters
28    pub path_params: Vec<Parameter>,
29    /// Query parameters
30    pub query_params: Vec<Parameter>,
31    /// Response type (simplified)
32    pub response_type: Option<String>,
33    /// Whether this endpoint is deprecated
34    pub deprecated: bool,
35    /// Whether this endpoint supports CSV format (text/csv content type)
36    pub supports_csv: bool,
37}
38
39impl Endpoint {
40    /// Returns true if this endpoint should be included in client generation.
41    /// Only non-deprecated GET endpoints are included.
42    pub fn should_generate(&self) -> bool {
43        self.method == "GET" && !self.deprecated
44    }
45
46    /// Returns true if this endpoint returns JSON (has a response_type extracted from application/json).
47    pub fn returns_json(&self) -> bool {
48        self.response_type.is_some()
49    }
50
51    /// Returns the operation ID or generates one from the path.
52    /// The returned string uses the raw case from the spec (typically camelCase).
53    pub fn operation_name(&self) -> String {
54        if let Some(op_id) = &self.operation_id {
55            return op_id.clone();
56        }
57        // Generate from path: /api/block/{hash} -> "get_block"
58        // Skip "api" prefix, convert hyphens to underscores, avoid redundant param names
59        let mut parts: Vec<String> = Vec::new();
60        let mut prev_segment = "";
61
62        for segment in self.path.split('/').filter(|s| !s.is_empty()) {
63            if segment == "api" {
64                continue;
65            }
66            if let Some(param) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
67                // Only add "by_{param}" if the previous segment doesn't already contain the param name
68                let prev_normalized = prev_segment.replace('-', "_");
69                if !prev_normalized.ends_with(param) {
70                    parts.push(format!("by_{}", param));
71                }
72            } else {
73                let normalized = segment.replace('-', "_");
74                parts.push(normalized);
75                prev_segment = segment;
76            }
77        }
78        format!("get_{}", parts.join("_"))
79    }
80}
81
82/// Parameter information
83#[derive(Debug, Clone)]
84pub struct Parameter {
85    pub name: String,
86    pub required: bool,
87    pub param_type: String,
88    pub description: Option<String>,
89}
90
91/// Parse OpenAPI spec from JSON string
92///
93/// Pre-processes the JSON to handle oas3 limitations:
94/// - Removes unsupported siblings from `$ref` objects (oas3 only supports `summary` and `description`)
95pub fn parse_openapi_json(json: &str) -> io::Result<Spec> {
96    let mut value: Value =
97        serde_json::from_str(json).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
98
99    // Clean up for oas3 compatibility
100    clean_for_oas3(&mut value);
101
102    let cleaned_json =
103        serde_json::to_string(&value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
104
105    oas3::from_json(&cleaned_json).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
106}
107
108/// Extract type schemas from OpenAPI JSON
109pub fn extract_schemas(json: &str) -> TypeSchemas {
110    let Ok(value) = serde_json::from_str::<Value>(json) else {
111        return BTreeMap::new();
112    };
113
114    value
115        .get("components")
116        .and_then(|c| c.get("schemas"))
117        .and_then(|s| s.as_object())
118        .map(|schemas| {
119            schemas
120                .iter()
121                .map(|(name, schema)| (name.clone(), schema.clone()))
122                .collect()
123        })
124        .unwrap_or_default()
125}
126
127/// Clean up OpenAPI spec for oas3 compatibility.
128/// - Removes unsupported siblings from $ref objects (oas3 only supports summary and description)
129/// - Converts boolean schemas to object schemas (oas3 doesn't handle `"schema": true`)
130fn clean_for_oas3(value: &mut Value) {
131    match value {
132        Value::Object(map) => {
133            // Handle $ref with unsupported siblings
134            if map.contains_key("$ref") {
135                map.retain(|k, _| k == "$ref" || k == "summary" || k == "description");
136            } else {
137                // Convert boolean schemas to empty object schemas
138                if let Some(schema) = map.get_mut("schema")
139                    && schema.is_boolean()
140                {
141                    *schema = Value::Object(serde_json::Map::new());
142                }
143                for v in map.values_mut() {
144                    clean_for_oas3(v);
145                }
146            }
147        }
148        Value::Array(arr) => {
149            for v in arr {
150                clean_for_oas3(v);
151            }
152        }
153        _ => {}
154    }
155}
156
157/// Extract all endpoints from OpenAPI spec
158pub fn extract_endpoints(spec: &Spec) -> Vec<Endpoint> {
159    let mut endpoints = Vec::new();
160
161    let Some(paths) = &spec.paths else {
162        return endpoints;
163    };
164
165    for (path, path_item) in paths {
166        for (method, operation) in get_operations(path_item) {
167            if let Some(endpoint) = extract_endpoint(path, method, operation) {
168                endpoints.push(endpoint);
169            }
170        }
171    }
172
173    endpoints
174}
175
176fn get_operations(path_item: &PathItem) -> Vec<(&'static str, &Operation)> {
177    [
178        ("GET", &path_item.get),
179        ("POST", &path_item.post),
180        ("PUT", &path_item.put),
181        ("DELETE", &path_item.delete),
182        ("PATCH", &path_item.patch),
183    ]
184    .into_iter()
185    .filter_map(|(method, op)| op.as_ref().map(|o| (method, o)))
186    .collect()
187}
188
189fn extract_endpoint(path: &str, method: &str, operation: &Operation) -> Option<Endpoint> {
190    let path_params = extract_path_parameters(path, operation);
191    let query_params = extract_parameters(operation, ParameterIn::Query);
192
193    let response_type = extract_response_type(operation);
194    let supports_csv = check_csv_support(operation);
195
196    Some(Endpoint {
197        method: method.to_string(),
198        path: path.to_string(),
199        operation_id: operation.operation_id.clone(),
200        summary: operation.summary.clone(),
201        description: operation.description.clone(),
202        path_params,
203        query_params,
204        response_type,
205        deprecated: operation.deprecated.unwrap_or(false),
206        supports_csv,
207    })
208}
209
210/// Check if the endpoint supports CSV format (has text/csv in 200 response content types).
211fn check_csv_support(operation: &Operation) -> bool {
212    let Some(responses) = operation.responses.as_ref() else {
213        return false;
214    };
215    let Some(response) = responses.get("200") else {
216        return false;
217    };
218    match response {
219        ObjectOrReference::Object(response) => response.content.contains_key("text/csv"),
220        ObjectOrReference::Ref { .. } => false,
221    }
222}
223
224/// Extract path parameters in the order they appear in the path URL.
225fn extract_path_parameters(path: &str, operation: &Operation) -> Vec<Parameter> {
226    // Extract parameter names from the path in order (e.g., "/api/series/{series}/{index}" -> ["series", "index"])
227    let path_order: Vec<&str> = path
228        .split('/')
229        .filter_map(|segment| segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')))
230        .collect();
231
232    // Get all path parameters from the operation
233    let params = extract_parameters(operation, ParameterIn::Path);
234
235    // Sort by position in the path
236    let mut sorted_params: Vec<Parameter> = params;
237    sorted_params.sort_by_key(|p| {
238        path_order
239            .iter()
240            .position(|&name| name == p.name)
241            .unwrap_or(usize::MAX)
242    });
243
244    sorted_params
245}
246
247fn extract_parameters(operation: &Operation, location: ParameterIn) -> Vec<Parameter> {
248    operation
249        .parameters
250        .iter()
251        .filter_map(|p| match p {
252            ObjectOrReference::Object(param) if param.location == location => {
253                let param_type = param
254                    .schema
255                    .as_ref()
256                    .and_then(|s| match s {
257                        ObjectOrReference::Ref { ref_path, .. } => {
258                            ref_to_type_name(ref_path).map(|s| s.to_string())
259                        }
260                        ObjectOrReference::Object(obj_schema) => schema_to_type_name(obj_schema),
261                    })
262                    .unwrap_or_else(|| "string".to_string());
263                Some(Parameter {
264                    name: param.name.clone(),
265                    required: param.required.unwrap_or(false),
266                    param_type,
267                    description: param.description.clone(),
268                })
269            }
270            _ => None,
271        })
272        .collect()
273}
274
275fn extract_response_type(operation: &Operation) -> Option<String> {
276    let responses = operation.responses.as_ref()?;
277
278    // Look for 200 OK response
279    let response = responses.get("200")?;
280
281    match response {
282        ObjectOrReference::Object(response) => {
283            // Look for JSON content
284            let content = response.content.get("application/json")?;
285
286            match &content.schema {
287                Some(ObjectOrReference::Ref { ref_path, .. }) => {
288                    // Extract type name from reference like "#/components/schemas/Block"
289                    Some(ref_to_type_name(ref_path)?.to_string())
290                }
291                Some(ObjectOrReference::Object(schema)) => schema_to_type_name(schema),
292                None => None,
293            }
294        }
295        ObjectOrReference::Ref { .. } => None,
296    }
297}
298
299fn schema_type_from_schema(schema: &Schema) -> Option<String> {
300    match schema {
301        Schema::Boolean(_) => Some("boolean".to_string()),
302        Schema::Object(obj_or_ref) => match obj_or_ref.as_ref() {
303            ObjectOrReference::Object(obj_schema) => schema_to_type_name(obj_schema),
304            ObjectOrReference::Ref { ref_path, .. } => {
305                // Return the type name as-is (e.g., "Height", "Address")
306                // These should have definitions generated from schemas
307                ref_to_type_name(ref_path).map(|s| s.to_string())
308            }
309        },
310    }
311}
312
313fn schema_to_type_name(schema: &ObjectSchema) -> Option<String> {
314    if let Some(schema_type) = schema.schema_type.as_ref() {
315        return match schema_type {
316            SchemaTypeSet::Single(t) => single_type_to_name(t, schema),
317            SchemaTypeSet::Multiple(types) => {
318                // For nullable types like ["integer", "null"], return the non-null type
319                types
320                    .iter()
321                    .find(|t| !matches!(t, SchemaType::Null))
322                    .and_then(|t| single_type_to_name(t, schema))
323                    .or(Some("*".to_string()))
324            }
325        };
326    }
327
328    // Handle anyOf/oneOf unions (e.g., Option<RangeIndex> → anyOf: [$ref, null])
329    let variants = if !schema.any_of.is_empty() {
330        &schema.any_of
331    } else if !schema.one_of.is_empty() {
332        &schema.one_of
333    } else {
334        return None;
335    };
336
337    let types: Vec<String> = variants
338        .iter()
339        .filter_map(|v| match v {
340            ObjectOrReference::Ref { ref_path, .. } => {
341                ref_to_type_name(ref_path).map(|s| s.to_string())
342            }
343            ObjectOrReference::Object(obj) => {
344                // Skip null variants
345                if matches!(
346                    obj.schema_type.as_ref(),
347                    Some(SchemaTypeSet::Single(SchemaType::Null))
348                ) {
349                    return None;
350                }
351                schema_to_type_name(obj)
352            }
353        })
354        .collect();
355
356    match types.len() {
357        0 => None,
358        1 => Some(types.into_iter().next().unwrap()),
359        _ => Some(types.join(" | ")),
360    }
361}
362
363fn single_type_to_name(t: &SchemaType, schema: &ObjectSchema) -> Option<String> {
364    match t {
365        SchemaType::String => Some("string".to_string()),
366        SchemaType::Number => Some("number".to_string()),
367        SchemaType::Integer => Some("number".to_string()),
368        SchemaType::Boolean => Some("boolean".to_string()),
369        SchemaType::Array => {
370            let inner = match &schema.items {
371                Some(boxed_schema) => schema_type_from_schema(boxed_schema),
372                None => Some("*".to_string()),
373            };
374            inner.map(|t| format!("{}[]", t))
375        }
376        SchemaType::Object => Some("Object".to_string()),
377        SchemaType::Null => Some("null".to_string()),
378    }
379}