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