1use 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
12trait RefResolvable: Sized {
14 fn component_prefix() -> &'static str;
16
17 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
76fn 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#[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 pub fn has_body(&self) -> bool {
146 matches!(self, HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch)
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum ParamLocation {
153 Path,
154 Query,
155 Header,
156 Cookie,
157}
158
159#[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#[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#[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#[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#[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 pub fn raw_name(&self) -> &str {
230 self.operation_id.as_deref().unwrap_or(&self.path)
231 }
232
233 pub fn name(&self) -> String {
237 self.raw_name().to_upper_camel_case()
238 }
239
240 pub fn has_error_responses(&self) -> bool {
242 self.responses.iter().any(|r| r.status_code.is_error())
243 }
244}
245
246#[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
255pub 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#[derive(Debug, Clone)]
265pub struct SpecInfo {
266 pub title: String,
267 pub version: String,
268 pub description: Option<String>,
269}
270
271impl ParsedSpec {
272 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 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 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 pub fn operations(&self) -> impl Iterator<Item = &Operation> {
331 self.operations.iter()
332 }
333
334 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
343pub 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 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 for param_ref in path_params {
371 if let Some(param) = resolve_parameter(param_ref, spec)? {
372 parameters.push(param);
373 }
374 }
375
376 for param_ref in &op.parameters {
378 if let Some(param) = resolve_parameter(param_ref, spec)? {
379 parameters.retain(|p| !(p.name == param.name && p.location == param.location));
381 parameters.push(param);
382 }
383 }
384
385 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 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, };
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 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 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 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}