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