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 operation name in PascalCase.
204    ///
205    /// Uses the `operation_id` if available, otherwise falls back to the path.
206    pub fn name(&self) -> String {
207        self.operation_id
208            .as_deref()
209            .unwrap_or(&self.path)
210            .to_upper_camel_case()
211    }
212
213    /// Check if this operation has any error responses defined (4xx, 5xx, or default).
214    pub fn has_error_responses(&self) -> bool {
215        self.responses.iter().any(|r| r.status_code.is_error())
216    }
217}
218
219/// Request body information.
220#[derive(Debug, Clone)]
221pub struct RequestBody {
222    pub required: bool,
223    pub description: Option<String>,
224    pub content_type: String,
225    pub schema: Option<ReferenceOr<Schema>>,
226}
227
228/// Parsed OpenAPI specification.
229pub struct ParsedSpec {
230    pub info: SpecInfo,
231    operations: Vec<Operation>,
232    operation_map: HashMap<(HttpMethod, String), usize>,
233    pub components: Option<openapiv3::Components>,
234}
235
236/// Basic spec info.
237#[derive(Debug, Clone)]
238pub struct SpecInfo {
239    pub title: String,
240    pub version: String,
241    pub description: Option<String>,
242}
243
244impl ParsedSpec {
245    /// Parse an OpenAPI spec into our internal representation.
246    pub fn from_openapi(spec: OpenAPI) -> Result<Self> {
247        let info = SpecInfo {
248            title: spec.info.title.clone(),
249            version: spec.info.version.clone(),
250            description: spec.info.description.clone(),
251        };
252
253        let mut operations = Vec::new();
254        let mut operation_map = HashMap::new();
255
256        for (path, path_item) in &spec.paths.paths {
257            let item = match path_item {
258                ReferenceOr::Reference { .. } => {
259                    return Err(Error::Unsupported(
260                        "external path references not supported".to_string(),
261                    ));
262                }
263                ReferenceOr::Item(item) => item,
264            };
265
266            // Extract operations for each HTTP method
267            let method_ops = [
268                (HttpMethod::Get, &item.get),
269                (HttpMethod::Post, &item.post),
270                (HttpMethod::Put, &item.put),
271                (HttpMethod::Delete, &item.delete),
272                (HttpMethod::Patch, &item.patch),
273                (HttpMethod::Head, &item.head),
274                (HttpMethod::Options, &item.options),
275            ];
276
277            for (method, op_opt) in method_ops {
278                if let Some(op) = op_opt {
279                    let operation = parse_operation(method, path, op, &item.parameters, &spec)?;
280                    let idx = operations.len();
281                    operation_map.insert((method, path.clone()), idx);
282                    operations.push(operation);
283                }
284            }
285        }
286
287        Ok(Self {
288            info,
289            operations,
290            operation_map,
291            components: spec.components,
292        })
293    }
294
295    /// Get an operation by method and path.
296    pub fn get_operation(&self, method: HttpMethod, path: &str) -> Option<&Operation> {
297        self.operation_map
298            .get(&(method, path.to_string()))
299            .map(|&idx| &self.operations[idx])
300    }
301
302    /// Iterate over all operations.
303    pub fn operations(&self) -> impl Iterator<Item = &Operation> {
304        self.operations.iter()
305    }
306
307    /// Get all schema names defined in the spec.
308    pub fn schema_names(&self) -> Vec<String> {
309        self.components
310            .as_ref()
311            .map(|c| c.schemas.keys().cloned().collect())
312            .unwrap_or_default()
313    }
314}
315
316/// Load an OpenAPI spec from a file.
317pub fn load_spec(path: &Path) -> Result<OpenAPI> {
318    let file =
319        File::open(path).map_err(|e| Error::ParseError(format!("failed to open file: {}", e)))?;
320
321    // Try JSON first, then YAML
322    if let Ok(spec) = serde_json::from_reader::<_, OpenAPI>(&file) {
323        return Ok(spec);
324    }
325
326    let file =
327        File::open(path).map_err(|e| Error::ParseError(format!("failed to open file: {}", e)))?;
328
329    yaml_serde::from_reader(file)
330        .map_err(|e| Error::ParseError(format!("failed to parse spec: {}", e)))
331}
332
333fn parse_operation(
334    method: HttpMethod,
335    path: &str,
336    op: &openapiv3::Operation,
337    path_params: &[ReferenceOr<openapiv3::Parameter>],
338    spec: &OpenAPI,
339) -> Result<Operation> {
340    let mut parameters = Vec::new();
341
342    // First add path-level parameters
343    for param_ref in path_params {
344        if let Some(param) = resolve_parameter(param_ref, spec)? {
345            parameters.push(param);
346        }
347    }
348
349    // Then add operation-level parameters (which may override path-level)
350    for param_ref in &op.parameters {
351        if let Some(param) = resolve_parameter(param_ref, spec)? {
352            // Remove any existing param with same name/location
353            parameters.retain(|p| !(p.name == param.name && p.location == param.location));
354            parameters.push(param);
355        }
356    }
357
358    // Parse request body
359    let request_body = if let Some(body_ref) = &op.request_body {
360        parse_request_body(body_ref, spec)?
361    } else {
362        None
363    };
364
365    // Parse responses
366    let mut responses = Vec::new();
367
368    if let Some(default) = &op.responses.default
369        && let Some(resp) = parse_response(ResponseStatus::Default, default, spec)?
370    {
371        responses.push(resp);
372    }
373
374    for (code, resp_ref) in &op.responses.responses {
375        let status = match code {
376            StatusCode::Code(c) => ResponseStatus::Code(*c),
377            StatusCode::Range(_) => continue, // Skip ranges for now
378        };
379        if let Some(resp) = parse_response(status, resp_ref, spec)? {
380            responses.push(resp);
381        }
382    }
383
384    Ok(Operation {
385        operation_id: op.operation_id.clone(),
386        method,
387        path: path.to_string(),
388        summary: op.summary.clone(),
389        description: op.description.clone(),
390        parameters,
391        request_body,
392        responses,
393        tags: op.tags.clone(),
394    })
395}
396
397fn resolve_parameter(
398    param_ref: &ReferenceOr<openapiv3::Parameter>,
399    spec: &OpenAPI,
400) -> Result<Option<OperationParam>> {
401    let param = resolve_ref(param_ref, spec)?;
402
403    let (location, data) = match param {
404        openapiv3::Parameter::Path { parameter_data, .. } => (ParamLocation::Path, parameter_data),
405        openapiv3::Parameter::Query { parameter_data, .. } => {
406            (ParamLocation::Query, parameter_data)
407        }
408        openapiv3::Parameter::Header { parameter_data, .. } => {
409            (ParamLocation::Header, parameter_data)
410        }
411        openapiv3::Parameter::Cookie { parameter_data, .. } => {
412            (ParamLocation::Cookie, parameter_data)
413        }
414    };
415
416    let schema = match &data.format {
417        openapiv3::ParameterSchemaOrContent::Schema(s) => Some(s.clone()),
418        openapiv3::ParameterSchemaOrContent::Content(_) => None,
419    };
420
421    Ok(Some(OperationParam {
422        name: data.name.clone(),
423        location,
424        required: data.required,
425        schema,
426        description: data.description.clone(),
427    }))
428}
429
430fn parse_request_body(
431    body_ref: &ReferenceOr<openapiv3::RequestBody>,
432    spec: &OpenAPI,
433) -> Result<Option<RequestBody>> {
434    let body = resolve_ref(body_ref, spec)?;
435
436    // Prefer application/json
437    let (content_type, media) = body
438        .content
439        .iter()
440        .find(|(ct, _)| ct.starts_with("application/json"))
441        .or_else(|| body.content.first())
442        .ok_or_else(|| Error::ParseError("request body has no content".to_string()))?;
443
444    Ok(Some(RequestBody {
445        required: body.required,
446        description: body.description.clone(),
447        content_type: content_type.clone(),
448        schema: media.schema.clone(),
449    }))
450}
451
452fn parse_response(
453    status: ResponseStatus,
454    resp_ref: &ReferenceOr<openapiv3::Response>,
455    spec: &OpenAPI,
456) -> Result<Option<OperationResponse>> {
457    let resp = resolve_ref(resp_ref, spec)?;
458
459    // Get schema from content (prefer application/json)
460    let (content_type, schema) = if let Some((ct, media)) = resp
461        .content
462        .iter()
463        .find(|(ct, _)| ct.starts_with("application/json"))
464        .or_else(|| resp.content.first())
465    {
466        (Some(ct.clone()), media.schema.clone())
467    } else {
468        (None, None)
469    };
470
471    Ok(Some(OperationResponse {
472        status_code: status,
473        description: resp.description.clone(),
474        schema,
475        content_type,
476    }))
477}