1use serde_json::Value;
9
10use crate::extensions::ChioExtensions;
11use crate::{OpenApiError, Result};
12
13#[derive(Debug, Clone)]
15pub struct OpenApiSpec {
16 pub openapi_version: String,
18 pub title: String,
20 pub description: String,
22 pub api_version: String,
24 pub paths: Vec<(String, PathItem)>,
26 raw: Value,
28}
29
30#[derive(Debug, Clone)]
32pub struct PathItem {
33 pub common_parameters: Vec<Parameter>,
35 pub operations: Vec<(String, Operation)>,
37}
38
39#[derive(Debug, Clone)]
41pub struct Operation {
42 pub operation_id: Option<String>,
44 pub summary: Option<String>,
46 pub description: Option<String>,
48 pub tags: Vec<String>,
50 pub parameters: Vec<Parameter>,
52 pub request_body_schema: Option<Value>,
54 pub response_schemas: Vec<(String, Option<Value>)>,
56 pub raw: Value,
58}
59
60#[derive(Debug, Clone)]
62pub struct Parameter {
63 pub name: String,
65 pub location: ParameterLocation,
67 pub required: bool,
69 pub schema: Option<Value>,
71 pub description: Option<String>,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum ParameterLocation {
78 Path,
79 Query,
80 Header,
81 Cookie,
82}
83
84impl OpenApiSpec {
85 pub fn parse(input: &str) -> Result<Self> {
87 let trimmed = input.trim_start();
88 let value: Value = if trimmed.starts_with('{') {
89 serde_json::from_str(input)?
90 } else {
91 serde_yml::from_str(input)?
92 };
93 Self::from_value(value)
94 }
95
96 pub fn from_value(value: Value) -> Result<Self> {
98 let openapi_version = value
99 .get("openapi")
100 .and_then(|v| v.as_str())
101 .ok_or_else(|| OpenApiError::MissingField("openapi".to_string()))?
102 .to_string();
103
104 if !openapi_version.starts_with("3.") {
106 return Err(OpenApiError::UnsupportedVersion(openapi_version));
107 }
108
109 let info = value
110 .get("info")
111 .ok_or_else(|| OpenApiError::MissingField("info".to_string()))?;
112
113 let title = info
114 .get("title")
115 .and_then(|v| v.as_str())
116 .unwrap_or("Untitled API")
117 .to_string();
118
119 let description = info
120 .get("description")
121 .and_then(|v| v.as_str())
122 .unwrap_or("")
123 .to_string();
124
125 let api_version = info
126 .get("version")
127 .and_then(|v| v.as_str())
128 .unwrap_or("0.0.0")
129 .to_string();
130
131 let paths_obj = value
132 .get("paths")
133 .and_then(|v| v.as_object())
134 .ok_or_else(|| OpenApiError::MissingField("paths".to_string()))?;
135
136 let mut paths = Vec::new();
137 for (path, path_value) in paths_obj {
138 let path_item = Self::parse_path_item(path_value, &value)?;
139 paths.push((path.clone(), path_item));
140 }
141
142 paths.sort_by(|a, b| a.0.cmp(&b.0));
144
145 Ok(Self {
146 openapi_version,
147 title,
148 description,
149 api_version,
150 paths,
151 raw: value,
152 })
153 }
154
155 fn resolve_ref<'a>(root: &'a Value, ref_str: &str) -> Result<&'a Value> {
157 if !ref_str.starts_with("#/") {
158 return Err(OpenApiError::UnresolvedRef(ref_str.to_string()));
159 }
160
161 let pointer = ref_str.replacen('#', "", 1);
162 root.pointer(&pointer)
163 .ok_or_else(|| OpenApiError::UnresolvedRef(ref_str.to_string()))
164 }
165
166 fn maybe_resolve<'a>(root: &'a Value, value: &'a Value) -> Result<&'a Value> {
169 if let Some(ref_str) = value.get("$ref").and_then(|v| v.as_str()) {
170 Self::resolve_ref(root, ref_str)
171 } else {
172 Ok(value)
173 }
174 }
175
176 fn parse_path_item(path_value: &Value, root: &Value) -> Result<PathItem> {
177 let obj = match path_value.as_object() {
178 Some(o) => o,
179 None => {
180 return Ok(PathItem {
181 common_parameters: Vec::new(),
182 operations: Vec::new(),
183 })
184 }
185 };
186
187 let common_parameters = if let Some(params) = obj.get("parameters") {
189 Self::parse_parameters(params, root)?
190 } else {
191 Vec::new()
192 };
193
194 let methods = ["get", "post", "put", "patch", "delete", "head", "options"];
195 let mut operations = Vec::new();
196
197 for method in &methods {
198 if let Some(op_value) = obj.get(*method) {
199 let operation = Self::parse_operation(op_value, root)?;
200 operations.push((method.to_uppercase(), operation));
201 }
202 }
203
204 Ok(PathItem {
205 common_parameters,
206 operations,
207 })
208 }
209
210 fn parse_operation(op_value: &Value, root: &Value) -> Result<Operation> {
211 let operation_id = op_value
212 .get("operationId")
213 .and_then(|v| v.as_str())
214 .map(String::from);
215
216 let summary = op_value
217 .get("summary")
218 .and_then(|v| v.as_str())
219 .map(String::from);
220
221 let description = op_value
222 .get("description")
223 .and_then(|v| v.as_str())
224 .map(String::from);
225
226 let tags = op_value
227 .get("tags")
228 .and_then(|v| v.as_array())
229 .map(|arr| {
230 arr.iter()
231 .filter_map(|v| v.as_str().map(String::from))
232 .collect()
233 })
234 .unwrap_or_default();
235
236 let parameters = if let Some(params) = op_value.get("parameters") {
237 Self::parse_parameters(params, root)?
238 } else {
239 Vec::new()
240 };
241
242 let request_body_schema = Self::extract_request_body_schema(op_value, root)?;
243
244 let response_schemas = Self::extract_response_schemas(op_value, root)?;
245
246 Ok(Operation {
247 operation_id,
248 summary,
249 description,
250 tags,
251 parameters,
252 request_body_schema,
253 response_schemas,
254 raw: op_value.clone(),
255 })
256 }
257
258 fn parse_parameters(params_value: &Value, root: &Value) -> Result<Vec<Parameter>> {
259 let arr = match params_value.as_array() {
260 Some(a) => a,
261 None => return Ok(Vec::new()),
262 };
263
264 let mut result = Vec::new();
265 for param_value in arr {
266 let resolved = Self::maybe_resolve(root, param_value)?;
267 let param = Self::parse_single_parameter(resolved)?;
268 result.push(param);
269 }
270 Ok(result)
271 }
272
273 fn parse_single_parameter(value: &Value) -> Result<Parameter> {
274 let name = value
275 .get("name")
276 .and_then(|v| v.as_str())
277 .unwrap_or("unknown")
278 .to_string();
279
280 let location = match value.get("in").and_then(|v| v.as_str()) {
281 Some("path") => ParameterLocation::Path,
282 Some("query") => ParameterLocation::Query,
283 Some("header") => ParameterLocation::Header,
284 Some("cookie") => ParameterLocation::Cookie,
285 _ => ParameterLocation::Query, };
287
288 let required = value
289 .get("required")
290 .and_then(|v| v.as_bool())
291 .unwrap_or(location == ParameterLocation::Path);
293
294 let schema = value.get("schema").cloned();
295
296 let description = value
297 .get("description")
298 .and_then(|v| v.as_str())
299 .map(String::from);
300
301 Ok(Parameter {
302 name,
303 location,
304 required,
305 schema,
306 description,
307 })
308 }
309
310 fn extract_request_body_schema(op_value: &Value, root: &Value) -> Result<Option<Value>> {
311 let body = match op_value.get("requestBody") {
312 Some(b) => Self::maybe_resolve(root, b)?,
313 None => return Ok(None),
314 };
315
316 let content = match body.get("content").and_then(|c| c.as_object()) {
318 Some(c) => c,
319 None => return Ok(None),
320 };
321
322 let media = content
323 .get("application/json")
324 .or_else(|| content.values().next());
325
326 match media {
327 Some(m) => {
328 if let Some(schema) = m.get("schema") {
329 let resolved = Self::maybe_resolve(root, schema)?;
330 Ok(Some(resolved.clone()))
331 } else {
332 Ok(None)
333 }
334 }
335 None => Ok(None),
336 }
337 }
338
339 fn extract_response_schemas(
340 op_value: &Value,
341 root: &Value,
342 ) -> Result<Vec<(String, Option<Value>)>> {
343 let responses = match op_value.get("responses").and_then(|r| r.as_object()) {
344 Some(r) => r,
345 None => return Ok(Vec::new()),
346 };
347
348 let mut result = Vec::new();
349 for (status, resp_value) in responses {
350 let resolved = Self::maybe_resolve(root, resp_value)?;
351 let schema = Self::extract_content_schema(resolved, root)?;
352 result.push((status.clone(), schema));
353 }
354 Ok(result)
355 }
356
357 fn extract_content_schema(resp: &Value, root: &Value) -> Result<Option<Value>> {
358 let content = match resp.get("content").and_then(|c| c.as_object()) {
359 Some(c) => c,
360 None => return Ok(None),
361 };
362
363 let media = content
364 .get("application/json")
365 .or_else(|| content.values().next());
366
367 match media {
368 Some(m) => {
369 if let Some(schema) = m.get("schema") {
370 let resolved = Self::maybe_resolve(root, schema)?;
371 Ok(Some(resolved.clone()))
372 } else {
373 Ok(None)
374 }
375 }
376 None => Ok(None),
377 }
378 }
379
380 #[must_use]
382 pub fn raw(&self) -> &Value {
383 &self.raw
384 }
385
386 #[must_use]
388 pub fn extensions_for(operation: &Operation) -> ChioExtensions {
389 ChioExtensions::from_operation(&operation.raw)
390 }
391}
392
393#[cfg(test)]
394#[allow(clippy::unwrap_used, clippy::expect_used)]
395mod tests {
396 use super::*;
397
398 fn minimal_spec_json() -> &'static str {
399 r##"{
400 "openapi": "3.0.3",
401 "info": {
402 "title": "Test API",
403 "version": "1.0.0"
404 },
405 "paths": {
406 "/pets": {
407 "get": {
408 "operationId": "listPets",
409 "summary": "List all pets",
410 "parameters": [
411 {
412 "name": "limit",
413 "in": "query",
414 "required": false,
415 "schema": { "type": "integer" }
416 }
417 ],
418 "responses": {
419 "200": {
420 "description": "A list of pets",
421 "content": {
422 "application/json": {
423 "schema": {
424 "type": "array",
425 "items": { "type": "object" }
426 }
427 }
428 }
429 }
430 }
431 }
432 }
433 }
434 }"##
435 }
436
437 fn minimal_spec_yaml() -> &'static str {
438 r##"openapi: "3.1.0"
439info:
440 title: Test API
441 version: "1.0.0"
442paths:
443 /items:
444 get:
445 operationId: listItems
446 summary: List items
447 responses:
448 "200":
449 description: OK
450"##
451 }
452
453 #[test]
454 fn parse_json_spec() {
455 let spec = OpenApiSpec::parse(minimal_spec_json()).unwrap();
456 assert_eq!(spec.openapi_version, "3.0.3");
457 assert_eq!(spec.title, "Test API");
458 assert_eq!(spec.api_version, "1.0.0");
459 assert_eq!(spec.paths.len(), 1);
460
461 let (path, item) = &spec.paths[0];
462 assert_eq!(path, "/pets");
463 assert_eq!(item.operations.len(), 1);
464 assert_eq!(item.operations[0].0, "GET");
465
466 let op = &item.operations[0].1;
467 assert_eq!(op.operation_id.as_deref(), Some("listPets"));
468 assert_eq!(op.parameters.len(), 1);
469 assert_eq!(op.parameters[0].name, "limit");
470 assert_eq!(op.parameters[0].location, ParameterLocation::Query);
471 assert!(!op.parameters[0].required);
472 }
473
474 #[test]
475 fn parse_yaml_spec() {
476 let spec = OpenApiSpec::parse(minimal_spec_yaml()).unwrap();
477 assert_eq!(spec.openapi_version, "3.1.0");
478 assert_eq!(spec.paths.len(), 1);
479 let (path, _) = &spec.paths[0];
480 assert_eq!(path, "/items");
481 }
482
483 #[test]
484 fn unsupported_version() {
485 let input = r##"{"openapi": "2.0", "info": {"title": "T", "version": "1"}, "paths": {}}"##;
486 let err = OpenApiSpec::parse(input).unwrap_err();
487 assert!(matches!(err, OpenApiError::UnsupportedVersion(_)));
488 }
489
490 #[test]
491 fn missing_openapi_field() {
492 let input = r##"{"info": {"title": "T", "version": "1"}, "paths": {}}"##;
493 let err = OpenApiSpec::parse(input).unwrap_err();
494 assert!(matches!(err, OpenApiError::MissingField(_)));
495 }
496
497 #[test]
498 fn ref_resolution() {
499 let input = r##"{
500 "openapi": "3.0.3",
501 "info": { "title": "T", "version": "1" },
502 "paths": {
503 "/things": {
504 "get": {
505 "operationId": "getThings",
506 "parameters": [
507 { "$ref": "#/components/parameters/LimitParam" }
508 ],
509 "responses": { "200": { "description": "OK" } }
510 }
511 }
512 },
513 "components": {
514 "parameters": {
515 "LimitParam": {
516 "name": "limit",
517 "in": "query",
518 "required": false,
519 "schema": { "type": "integer" }
520 }
521 }
522 }
523 }"##;
524
525 let spec = OpenApiSpec::parse(input).unwrap();
526 let (_, item) = &spec.paths[0];
527 let op = &item.operations[0].1;
528 assert_eq!(op.parameters.len(), 1);
529 assert_eq!(op.parameters[0].name, "limit");
530 }
531
532 #[test]
533 fn request_body_schema_extracted() {
534 let input = r##"{
535 "openapi": "3.0.3",
536 "info": { "title": "T", "version": "1" },
537 "paths": {
538 "/pets": {
539 "post": {
540 "operationId": "createPet",
541 "requestBody": {
542 "content": {
543 "application/json": {
544 "schema": {
545 "type": "object",
546 "properties": {
547 "name": { "type": "string" }
548 }
549 }
550 }
551 }
552 },
553 "responses": { "201": { "description": "Created" } }
554 }
555 }
556 }
557 }"##;
558
559 let spec = OpenApiSpec::parse(input).unwrap();
560 let (_, item) = &spec.paths[0];
561 let op = &item.operations[0].1;
562 assert!(op.request_body_schema.is_some());
563 let schema = op.request_body_schema.as_ref().unwrap();
564 assert_eq!(schema.get("type").and_then(|v| v.as_str()), Some("object"));
565 }
566
567 #[test]
568 fn path_parameters_required_by_default() {
569 let input = r##"{
570 "openapi": "3.0.3",
571 "info": { "title": "T", "version": "1" },
572 "paths": {
573 "/pets/{petId}": {
574 "get": {
575 "operationId": "getPet",
576 "parameters": [
577 { "name": "petId", "in": "path", "schema": { "type": "string" } }
578 ],
579 "responses": { "200": { "description": "OK" } }
580 }
581 }
582 }
583 }"##;
584
585 let spec = OpenApiSpec::parse(input).unwrap();
586 let (_, item) = &spec.paths[0];
587 let op = &item.operations[0].1;
588 assert!(op.parameters[0].required);
589 assert_eq!(op.parameters[0].location, ParameterLocation::Path);
590 }
591
592 #[test]
593 fn missing_paths_field() {
594 let input = r##"{"openapi": "3.0.3", "info": {"title": "T", "version": "1"}}"##;
595 let err = OpenApiSpec::parse(input).unwrap_err();
596 assert!(matches!(err, OpenApiError::MissingField(ref f) if f == "paths"));
597 }
598
599 #[test]
600 fn missing_info_field() {
601 let input = r##"{"openapi": "3.0.3", "paths": {}}"##;
602 let err = OpenApiSpec::parse(input).unwrap_err();
603 assert!(matches!(err, OpenApiError::MissingField(ref f) if f == "info"));
604 }
605
606 #[test]
607 fn empty_paths_object() {
608 let input =
609 r##"{"openapi": "3.0.3", "info": {"title": "T", "version": "1"}, "paths": {}}"##;
610 let spec = OpenApiSpec::parse(input).unwrap();
611 assert!(spec.paths.is_empty());
612 assert_eq!(spec.title, "T");
613 }
614
615 #[test]
616 fn spec_with_no_operations_on_path() {
617 let input = r##"{
618 "openapi": "3.0.3",
619 "info": {"title": "T", "version": "1"},
620 "paths": {
621 "/empty": {
622 "parameters": [
623 {"name": "id", "in": "query", "schema": {"type": "string"}}
624 ]
625 }
626 }
627 }"##;
628 let spec = OpenApiSpec::parse(input).unwrap();
629 assert_eq!(spec.paths.len(), 1);
630 let (_, item) = &spec.paths[0];
631 assert!(item.operations.is_empty());
632 assert_eq!(item.common_parameters.len(), 1);
633 }
634
635 #[test]
636 fn broken_ref_produces_error() {
637 let input = r##"{
638 "openapi": "3.0.3",
639 "info": {"title": "T", "version": "1"},
640 "paths": {
641 "/things": {
642 "get": {
643 "parameters": [
644 {"$ref": "#/components/parameters/NonExistent"}
645 ],
646 "responses": {"200": {"description": "OK"}}
647 }
648 }
649 }
650 }"##;
651 let err = OpenApiSpec::parse(input).unwrap_err();
652 assert!(matches!(err, OpenApiError::UnresolvedRef(_)));
653 }
654
655 #[test]
656 fn external_ref_produces_error() {
657 let input = r##"{
658 "openapi": "3.0.3",
659 "info": {"title": "T", "version": "1"},
660 "paths": {
661 "/things": {
662 "get": {
663 "parameters": [
664 {"$ref": "https://example.com/params.yaml#/Limit"}
665 ],
666 "responses": {"200": {"description": "OK"}}
667 }
668 }
669 }
670 }"##;
671 let err = OpenApiSpec::parse(input).unwrap_err();
672 assert!(matches!(err, OpenApiError::UnresolvedRef(_)));
673 }
674
675 #[test]
676 fn invalid_json_produces_error() {
677 let input = r##"{not valid json"##;
678 let err = OpenApiSpec::parse(input).unwrap_err();
679 assert!(matches!(err, OpenApiError::InvalidJson(_)));
680 }
681
682 #[test]
683 fn missing_title_defaults_to_untitled() {
684 let input = r##"{
685 "openapi": "3.0.3",
686 "info": {"version": "1"},
687 "paths": {}
688 }"##;
689 let spec = OpenApiSpec::parse(input).unwrap();
690 assert_eq!(spec.title, "Untitled API");
691 }
692
693 #[test]
694 fn missing_version_defaults_to_000() {
695 let input = r##"{
696 "openapi": "3.0.3",
697 "info": {"title": "T"},
698 "paths": {}
699 }"##;
700 let spec = OpenApiSpec::parse(input).unwrap();
701 assert_eq!(spec.api_version, "0.0.0");
702 }
703
704 #[test]
705 fn chio_extensions_extracted() {
706 let input = r##"{
707 "openapi": "3.0.3",
708 "info": { "title": "T", "version": "1" },
709 "paths": {
710 "/admin/reset": {
711 "post": {
712 "operationId": "resetSystem",
713 "x-chio-sensitivity": "restricted",
714 "x-chio-approval-required": true,
715 "x-chio-side-effects": true,
716 "responses": { "200": { "description": "OK" } }
717 }
718 }
719 }
720 }"##;
721
722 let spec = OpenApiSpec::parse(input).unwrap();
723 let (_, item) = &spec.paths[0];
724 let op = &item.operations[0].1;
725 let ext = OpenApiSpec::extensions_for(op);
726 assert_eq!(
727 ext.sensitivity,
728 Some(crate::extensions::Sensitivity::Restricted)
729 );
730 assert_eq!(ext.approval_required, Some(true));
731 assert_eq!(ext.side_effects, Some(true));
732 }
733}