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
12#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum ParamLocation {
55 Path,
56 Query,
57 Header,
58 Cookie,
59}
60
61#[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#[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#[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#[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 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 pub fn has_error_responses(&self) -> bool {
130 self.responses.iter().any(|r| r.status_code.is_error())
131 }
132}
133
134#[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
143pub 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#[derive(Debug, Clone)]
153pub struct SpecInfo {
154 pub title: String,
155 pub version: String,
156 pub description: Option<String>,
157}
158
159impl ParsedSpec {
160 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 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 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 pub fn operations(&self) -> impl Iterator<Item = &Operation> {
219 self.operations.iter()
220 }
221
222 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
231pub 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 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 for param_ref in path_params {
259 if let Some(param) = resolve_parameter(param_ref, spec)? {
260 parameters.push(param);
261 }
262 }
263
264 for param_ref in &op.parameters {
266 if let Some(param) = resolve_parameter(param_ref, spec)? {
267 parameters.retain(|p| !(p.name == param.name && p.location == param.location));
269 parameters.push(param);
270 }
271 }
272
273 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 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, };
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 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 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 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}