Skip to main content

oxapi_impl/
openapi.rs

1//! OpenAPI spec parsing and operation extraction.
2
3use std::collections::HashMap;
4use std::fs::File;
5use std::path::Path;
6
7use heck::ToUpperCamelCase;
8use openapiv3::{OpenAPI, ReferenceOr, Schema, StatusCode};
9
10use crate::{Error, Result};
11
12/// Trait for types that can be resolved from OpenAPI component references.
13trait RefResolvable: Sized {
14    /// The prefix for references to this component type (e.g., "#/components/parameters/").
15    fn component_prefix() -> &'static str;
16
17    /// Get an item from the components section by name.
18    fn get_from_components<'a>(
19        c: &'a openapiv3::Components,
20        name: &str,
21    ) -> Option<&'a ReferenceOr<Self>>;
22}
23
24impl RefResolvable for openapiv3::Parameter {
25    fn component_prefix() -> &'static str {
26        "#/components/parameters/"
27    }
28
29    fn get_from_components<'a>(
30        c: &'a openapiv3::Components,
31        name: &str,
32    ) -> Option<&'a ReferenceOr<Self>> {
33        c.parameters.get(name)
34    }
35}
36
37impl RefResolvable for openapiv3::RequestBody {
38    fn component_prefix() -> &'static str {
39        "#/components/requestBodies/"
40    }
41
42    fn get_from_components<'a>(
43        c: &'a openapiv3::Components,
44        name: &str,
45    ) -> Option<&'a ReferenceOr<Self>> {
46        c.request_bodies.get(name)
47    }
48}
49
50impl RefResolvable for openapiv3::Response {
51    fn component_prefix() -> &'static str {
52        "#/components/responses/"
53    }
54
55    fn get_from_components<'a>(
56        c: &'a openapiv3::Components,
57        name: &str,
58    ) -> Option<&'a ReferenceOr<Self>> {
59        c.responses.get(name)
60    }
61}
62
63/// Resolve a reference to a component, returning the underlying item.
64fn resolve_ref<'a, T: RefResolvable>(
65    ref_or_item: &'a ReferenceOr<T>,
66    spec: &'a OpenAPI,
67) -> Result<&'a T> {
68    match ref_or_item {
69        ReferenceOr::Reference { reference } => {
70            let name = reference
71                .strip_prefix(T::component_prefix())
72                .ok_or_else(|| {
73                    Error::ParseError(format!(
74                        "invalid reference: {} (expected prefix {})",
75                        reference,
76                        T::component_prefix()
77                    ))
78                })?;
79            spec.components
80                .as_ref()
81                .and_then(|c| T::get_from_components(c, name))
82                .and_then(|r| match r {
83                    ReferenceOr::Item(item) => Some(item),
84                    _ => None,
85                })
86                .ok_or_else(|| Error::ParseError(format!("component not found: {}", name)))
87        }
88        ReferenceOr::Item(item) => Ok(item),
89    }
90}
91
92/// HTTP methods supported by OpenAPI.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
94pub enum HttpMethod {
95    Get,
96    Post,
97    Put,
98    Delete,
99    Patch,
100    Head,
101    Options,
102}
103
104impl std::fmt::Display for HttpMethod {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        match self {
107            HttpMethod::Get => write!(f, "GET"),
108            HttpMethod::Post => write!(f, "POST"),
109            HttpMethod::Put => write!(f, "PUT"),
110            HttpMethod::Delete => write!(f, "DELETE"),
111            HttpMethod::Patch => write!(f, "PATCH"),
112            HttpMethod::Head => write!(f, "HEAD"),
113            HttpMethod::Options => write!(f, "OPTIONS"),
114        }
115    }
116}
117
118impl HttpMethod {
119    pub fn as_str(&self) -> &'static str {
120        match self {
121            HttpMethod::Get => "get",
122            HttpMethod::Post => "post",
123            HttpMethod::Put => "put",
124            HttpMethod::Delete => "delete",
125            HttpMethod::Patch => "patch",
126            HttpMethod::Head => "head",
127            HttpMethod::Options => "options",
128        }
129    }
130
131    /// Returns true if this HTTP method typically has a request body.
132    pub fn has_body(&self) -> bool {
133        matches!(self, HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch)
134    }
135}
136
137/// Location of a parameter.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum ParamLocation {
140    Path,
141    Query,
142    Header,
143    Cookie,
144}
145
146/// A parsed operation parameter.
147#[derive(Debug, Clone)]
148pub struct OperationParam {
149    pub name: String,
150    pub location: ParamLocation,
151    pub required: bool,
152    pub schema: Option<ReferenceOr<Schema>>,
153    pub description: Option<String>,
154}
155
156/// A parsed response.
157#[derive(Debug, Clone)]
158pub struct OperationResponse {
159    pub status_code: ResponseStatus,
160    pub description: String,
161    pub schema: Option<ReferenceOr<Schema>>,
162    pub content_type: Option<String>,
163}
164
165/// Response status code or range.
166#[derive(Debug, Clone, PartialEq, Eq, Hash)]
167pub enum ResponseStatus {
168    Code(u16),
169    Default,
170}
171
172impl ResponseStatus {
173    pub fn is_success(&self) -> bool {
174        match self {
175            ResponseStatus::Code(code) => (200..300).contains(code),
176            ResponseStatus::Default => false,
177        }
178    }
179
180    pub fn is_error(&self) -> bool {
181        match self {
182            ResponseStatus::Code(code) => *code >= 400,
183            ResponseStatus::Default => true,
184        }
185    }
186}
187
188/// A parsed OpenAPI operation.
189#[derive(Debug, Clone)]
190pub struct Operation {
191    pub operation_id: Option<String>,
192    pub method: HttpMethod,
193    pub path: String,
194    pub summary: Option<String>,
195    pub description: Option<String>,
196    pub parameters: Vec<OperationParam>,
197    pub request_body: Option<RequestBody>,
198    pub responses: Vec<OperationResponse>,
199    pub tags: Vec<String>,
200}
201
202impl Operation {
203    /// Get the raw operation name (without case conversion).
204    ///
205    /// Uses the `operation_id` if available, otherwise falls back to the path.
206    pub fn raw_name(&self) -> &str {
207        self.operation_id.as_deref().unwrap_or(&self.path)
208    }
209
210    /// Get the operation name in PascalCase.
211    ///
212    /// Uses the `operation_id` if available, otherwise falls back to the path.
213    pub fn name(&self) -> String {
214        self.raw_name().to_upper_camel_case()
215    }
216
217    /// Check if this operation has any error responses defined (4xx, 5xx, or default).
218    pub fn has_error_responses(&self) -> bool {
219        self.responses.iter().any(|r| r.status_code.is_error())
220    }
221}
222
223/// Request body information.
224#[derive(Debug, Clone)]
225pub struct RequestBody {
226    pub required: bool,
227    pub description: Option<String>,
228    pub content_type: String,
229    pub schema: Option<ReferenceOr<Schema>>,
230}
231
232/// Parsed OpenAPI specification.
233pub struct ParsedSpec {
234    pub info: SpecInfo,
235    operations: Vec<Operation>,
236    operation_map: HashMap<(HttpMethod, String), usize>,
237    pub components: Option<openapiv3::Components>,
238}
239
240/// Basic spec info.
241#[derive(Debug, Clone)]
242pub struct SpecInfo {
243    pub title: String,
244    pub version: String,
245    pub description: Option<String>,
246}
247
248impl ParsedSpec {
249    /// Parse an OpenAPI spec into our internal representation.
250    pub fn from_openapi(spec: OpenAPI) -> Result<Self> {
251        let info = SpecInfo {
252            title: spec.info.title.clone(),
253            version: spec.info.version.clone(),
254            description: spec.info.description.clone(),
255        };
256
257        let mut operations = Vec::new();
258        let mut operation_map = HashMap::new();
259
260        for (path, path_item) in &spec.paths.paths {
261            let item = match path_item {
262                ReferenceOr::Reference { .. } => {
263                    return Err(Error::Unsupported(
264                        "external path references not supported".to_string(),
265                    ));
266                }
267                ReferenceOr::Item(item) => item,
268            };
269
270            // Extract operations for each HTTP method
271            let method_ops = [
272                (HttpMethod::Get, &item.get),
273                (HttpMethod::Post, &item.post),
274                (HttpMethod::Put, &item.put),
275                (HttpMethod::Delete, &item.delete),
276                (HttpMethod::Patch, &item.patch),
277                (HttpMethod::Head, &item.head),
278                (HttpMethod::Options, &item.options),
279            ];
280
281            for (method, op_opt) in method_ops {
282                if let Some(op) = op_opt {
283                    let operation = parse_operation(method, path, op, &item.parameters, &spec)?;
284                    let idx = operations.len();
285                    operation_map.insert((method, path.clone()), idx);
286                    operations.push(operation);
287                }
288            }
289        }
290
291        Ok(Self {
292            info,
293            operations,
294            operation_map,
295            components: spec.components,
296        })
297    }
298
299    /// Get an operation by method and path.
300    pub fn get_operation(&self, method: HttpMethod, path: &str) -> Option<&Operation> {
301        self.operation_map
302            .get(&(method, path.to_string()))
303            .map(|&idx| &self.operations[idx])
304    }
305
306    /// Iterate over all operations.
307    pub fn operations(&self) -> impl Iterator<Item = &Operation> {
308        self.operations.iter()
309    }
310
311    /// Get all schema names defined in the spec.
312    pub fn schema_names(&self) -> Vec<String> {
313        self.components
314            .as_ref()
315            .map(|c| c.schemas.keys().cloned().collect())
316            .unwrap_or_default()
317    }
318}
319
320/// Load an OpenAPI spec from a file.
321pub fn load_spec(path: &Path) -> Result<OpenAPI> {
322    let file =
323        File::open(path).map_err(|e| Error::ParseError(format!("failed to open file: {}", e)))?;
324
325    // Try JSON first, then YAML
326    if let Ok(spec) = serde_json::from_reader::<_, OpenAPI>(&file) {
327        return Ok(spec);
328    }
329
330    let file =
331        File::open(path).map_err(|e| Error::ParseError(format!("failed to open file: {}", e)))?;
332
333    yaml_serde::from_reader(file)
334        .map_err(|e| Error::ParseError(format!("failed to parse spec: {}", e)))
335}
336
337fn parse_operation(
338    method: HttpMethod,
339    path: &str,
340    op: &openapiv3::Operation,
341    path_params: &[ReferenceOr<openapiv3::Parameter>],
342    spec: &OpenAPI,
343) -> Result<Operation> {
344    let mut parameters = Vec::new();
345
346    // First add path-level parameters
347    for param_ref in path_params {
348        if let Some(param) = resolve_parameter(param_ref, spec)? {
349            parameters.push(param);
350        }
351    }
352
353    // Then add operation-level parameters (which may override path-level)
354    for param_ref in &op.parameters {
355        if let Some(param) = resolve_parameter(param_ref, spec)? {
356            // Remove any existing param with same name/location
357            parameters.retain(|p| !(p.name == param.name && p.location == param.location));
358            parameters.push(param);
359        }
360    }
361
362    // Parse request body
363    let request_body = if let Some(body_ref) = &op.request_body {
364        parse_request_body(body_ref, spec)?
365    } else {
366        None
367    };
368
369    // Parse responses
370    let mut responses = Vec::new();
371
372    if let Some(default) = &op.responses.default
373        && let Some(resp) = parse_response(ResponseStatus::Default, default, spec)?
374    {
375        responses.push(resp);
376    }
377
378    for (code, resp_ref) in &op.responses.responses {
379        let status = match code {
380            StatusCode::Code(c) => ResponseStatus::Code(*c),
381            StatusCode::Range(_) => continue, // Skip ranges for now
382        };
383        if let Some(resp) = parse_response(status, resp_ref, spec)? {
384            responses.push(resp);
385        }
386    }
387
388    Ok(Operation {
389        operation_id: op.operation_id.clone(),
390        method,
391        path: path.to_string(),
392        summary: op.summary.clone(),
393        description: op.description.clone(),
394        parameters,
395        request_body,
396        responses,
397        tags: op.tags.clone(),
398    })
399}
400
401fn resolve_parameter(
402    param_ref: &ReferenceOr<openapiv3::Parameter>,
403    spec: &OpenAPI,
404) -> Result<Option<OperationParam>> {
405    let param = resolve_ref(param_ref, spec)?;
406
407    let (location, data) = match param {
408        openapiv3::Parameter::Path { parameter_data, .. } => (ParamLocation::Path, parameter_data),
409        openapiv3::Parameter::Query { parameter_data, .. } => {
410            (ParamLocation::Query, parameter_data)
411        }
412        openapiv3::Parameter::Header { parameter_data, .. } => {
413            (ParamLocation::Header, parameter_data)
414        }
415        openapiv3::Parameter::Cookie { parameter_data, .. } => {
416            (ParamLocation::Cookie, parameter_data)
417        }
418    };
419
420    let schema = match &data.format {
421        openapiv3::ParameterSchemaOrContent::Schema(s) => Some(s.clone()),
422        openapiv3::ParameterSchemaOrContent::Content(_) => None,
423    };
424
425    Ok(Some(OperationParam {
426        name: data.name.clone(),
427        location,
428        required: data.required,
429        schema,
430        description: data.description.clone(),
431    }))
432}
433
434fn parse_request_body(
435    body_ref: &ReferenceOr<openapiv3::RequestBody>,
436    spec: &OpenAPI,
437) -> Result<Option<RequestBody>> {
438    let body = resolve_ref(body_ref, spec)?;
439
440    // Prefer application/json
441    let (content_type, media) = body
442        .content
443        .iter()
444        .find(|(ct, _)| ct.starts_with("application/json"))
445        .or_else(|| body.content.first())
446        .ok_or_else(|| Error::ParseError("request body has no content".to_string()))?;
447
448    Ok(Some(RequestBody {
449        required: body.required,
450        description: body.description.clone(),
451        content_type: content_type.clone(),
452        schema: media.schema.clone(),
453    }))
454}
455
456fn parse_response(
457    status: ResponseStatus,
458    resp_ref: &ReferenceOr<openapiv3::Response>,
459    spec: &OpenAPI,
460) -> Result<Option<OperationResponse>> {
461    let resp = resolve_ref(resp_ref, spec)?;
462
463    // Get schema from content (prefer application/json)
464    let (content_type, schema) = if let Some((ct, media)) = resp
465        .content
466        .iter()
467        .find(|(ct, _)| ct.starts_with("application/json"))
468        .or_else(|| resp.content.first())
469    {
470        (Some(ct.clone()), media.schema.clone())
471    } else {
472        (None, None)
473    };
474
475    Ok(Some(OperationResponse {
476        status_code: status,
477        description: resp.description.clone(),
478        schema,
479        content_type,
480    }))
481}