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