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 request_body_required: bool,
56 pub response_schemas: Vec<(String, Option<Value>)>,
58 pub raw: Value,
60}
61
62#[derive(Debug, Clone)]
64pub struct Parameter {
65 pub name: String,
67 pub location: ParameterLocation,
69 pub required: bool,
71 pub schema: Option<Value>,
73 pub description: Option<String>,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ParameterLocation {
80 Path,
81 Query,
82 Header,
83 Cookie,
84}
85
86impl OpenApiSpec {
87 pub fn parse(input: &str) -> Result<Self> {
89 let trimmed = input.trim_start();
90 let value: Value = if trimmed.starts_with('{') {
91 serde_json::from_str(input)?
92 } else {
93 parse_yaml_value(input)?
94 };
95 Self::from_value(value)
96 }
97
98 pub fn from_value(value: Value) -> Result<Self> {
100 let openapi_version = value
101 .get("openapi")
102 .and_then(|v| v.as_str())
103 .ok_or_else(|| OpenApiError::MissingField("openapi".to_string()))?
104 .to_string();
105
106 if !openapi_version.starts_with("3.") {
108 return Err(OpenApiError::UnsupportedVersion(openapi_version));
109 }
110
111 let info = value
112 .get("info")
113 .ok_or_else(|| OpenApiError::MissingField("info".to_string()))?;
114
115 let title = info
116 .get("title")
117 .and_then(|v| v.as_str())
118 .unwrap_or("Untitled API")
119 .to_string();
120
121 let description = info
122 .get("description")
123 .and_then(|v| v.as_str())
124 .unwrap_or("")
125 .to_string();
126
127 let api_version = info
128 .get("version")
129 .and_then(|v| v.as_str())
130 .unwrap_or("0.0.0")
131 .to_string();
132
133 let paths_obj = value
134 .get("paths")
135 .and_then(|v| v.as_object())
136 .ok_or_else(|| OpenApiError::MissingField("paths".to_string()))?;
137
138 let mut paths = Vec::new();
139 for (path, path_value) in paths_obj {
140 let path_item = Self::parse_path_item(path_value, &value)?;
141 paths.push((path.clone(), path_item));
142 }
143
144 paths.sort_by(|a, b| a.0.cmp(&b.0));
146
147 Ok(Self {
148 openapi_version,
149 title,
150 description,
151 api_version,
152 paths,
153 raw: value,
154 })
155 }
156
157 fn resolve_ref<'a>(root: &'a Value, ref_str: &str) -> Result<&'a Value> {
159 if !ref_str.starts_with("#/") {
160 return Err(OpenApiError::UnresolvedRef(ref_str.to_string()));
161 }
162
163 let pointer = ref_str.replacen('#', "", 1);
164 root.pointer(&pointer)
165 .ok_or_else(|| OpenApiError::UnresolvedRef(ref_str.to_string()))
166 }
167
168 fn maybe_resolve<'a>(root: &'a Value, value: &'a Value) -> Result<&'a Value> {
171 if let Some(ref_str) = value.get("$ref").and_then(|v| v.as_str()) {
172 Self::resolve_ref(root, ref_str)
173 } else {
174 Ok(value)
175 }
176 }
177
178 fn parse_path_item(path_value: &Value, root: &Value) -> Result<PathItem> {
179 let obj = match path_value.as_object() {
180 Some(o) => o,
181 None => {
182 return Ok(PathItem {
183 common_parameters: Vec::new(),
184 operations: Vec::new(),
185 })
186 }
187 };
188
189 let common_parameters = if let Some(params) = obj.get("parameters") {
191 Self::parse_parameters(params, root)?
192 } else {
193 Vec::new()
194 };
195
196 let methods = ["get", "post", "put", "patch", "delete", "head", "options"];
197 let mut operations = Vec::new();
198
199 for method in &methods {
200 if let Some(op_value) = obj.get(*method) {
201 let operation = Self::parse_operation(op_value, root)?;
202 operations.push((method.to_uppercase(), operation));
203 }
204 }
205
206 Ok(PathItem {
207 common_parameters,
208 operations,
209 })
210 }
211
212 fn parse_operation(op_value: &Value, root: &Value) -> Result<Operation> {
213 let operation_id = op_value
214 .get("operationId")
215 .and_then(|v| v.as_str())
216 .map(String::from);
217
218 let summary = op_value
219 .get("summary")
220 .and_then(|v| v.as_str())
221 .map(String::from);
222
223 let description = op_value
224 .get("description")
225 .and_then(|v| v.as_str())
226 .map(String::from);
227
228 let tags = op_value
229 .get("tags")
230 .and_then(|v| v.as_array())
231 .map(|arr| {
232 arr.iter()
233 .filter_map(|v| v.as_str().map(String::from))
234 .collect()
235 })
236 .unwrap_or_default();
237
238 let parameters = if let Some(params) = op_value.get("parameters") {
239 Self::parse_parameters(params, root)?
240 } else {
241 Vec::new()
242 };
243
244 let request_body_required = Self::request_body_required(op_value, root)?;
245 let request_body_schema = Self::extract_request_body_schema(op_value, root)?;
246
247 let response_schemas = Self::extract_response_schemas(op_value, root)?;
248
249 Ok(Operation {
250 operation_id,
251 summary,
252 description,
253 tags,
254 parameters,
255 request_body_schema,
256 request_body_required,
257 response_schemas,
258 raw: op_value.clone(),
259 })
260 }
261
262 fn parse_parameters(params_value: &Value, root: &Value) -> Result<Vec<Parameter>> {
263 let arr = match params_value.as_array() {
264 Some(a) => a,
265 None => {
266 return Err(OpenApiError::InvalidSpec(
267 "parameters must be an array".to_string(),
268 ))
269 }
270 };
271
272 let mut result = Vec::new();
273 for param_value in arr {
274 let resolved = Self::maybe_resolve(root, param_value)?;
275 let param = Self::parse_single_parameter(resolved, root)?;
276 result.push(param);
277 }
278 Ok(result)
279 }
280
281 fn parse_single_parameter(value: &Value, root: &Value) -> Result<Parameter> {
282 let name = value
283 .get("name")
284 .and_then(|v| v.as_str())
285 .filter(|name| !name.trim().is_empty())
286 .ok_or_else(|| OpenApiError::MissingField("parameter.name".to_string()))?
287 .to_string();
288
289 let location_value = value
290 .get("in")
291 .and_then(|v| v.as_str())
292 .filter(|location| !location.trim().is_empty())
293 .ok_or_else(|| OpenApiError::MissingField("parameter.in".to_string()))?;
294
295 let location = match location_value {
296 "path" => ParameterLocation::Path,
297 "query" => ParameterLocation::Query,
298 "header" => ParameterLocation::Header,
299 "cookie" => ParameterLocation::Cookie,
300 _ => {
301 return Err(OpenApiError::InvalidSpec(format!(
302 "parameter.in has unsupported location `{location_value}`"
303 )))
304 }
305 };
306
307 let required = if let Some(required_value) = value.get("required") {
308 required_value.as_bool().ok_or_else(|| {
309 OpenApiError::InvalidSpec("parameter.required must be a boolean".to_string())
310 })?
311 } else {
312 location == ParameterLocation::Path
314 };
315
316 let schema = value
317 .get("schema")
318 .map(|schema| Self::maybe_resolve(root, schema).cloned())
319 .transpose()?;
320
321 let description = value
322 .get("description")
323 .and_then(|v| v.as_str())
324 .map(String::from);
325
326 Ok(Parameter {
327 name,
328 location,
329 required,
330 schema,
331 description,
332 })
333 }
334
335 fn extract_request_body_schema(op_value: &Value, root: &Value) -> Result<Option<Value>> {
336 let body = match op_value.get("requestBody") {
337 Some(b) => Self::maybe_resolve(root, b)?,
338 None => return Ok(None),
339 };
340
341 let content = match body.get("content").and_then(|c| c.as_object()) {
343 Some(c) => c,
344 None => return Ok(None),
345 };
346
347 let media = preferred_content_media(content);
348
349 match media {
350 Some(m) => {
351 if let Some(schema) = m.get("schema") {
352 let resolved = Self::maybe_resolve(root, schema)?;
353 Ok(Some(resolved.clone()))
354 } else {
355 Ok(None)
356 }
357 }
358 None => Ok(None),
359 }
360 }
361
362 fn request_body_required(op_value: &Value, root: &Value) -> Result<bool> {
363 let body = match op_value.get("requestBody") {
364 Some(body) => Self::maybe_resolve(root, body)?,
365 None => return Ok(false),
366 };
367 Ok(body
368 .get("required")
369 .and_then(Value::as_bool)
370 .unwrap_or(false))
371 }
372
373 fn extract_response_schemas(
374 op_value: &Value,
375 root: &Value,
376 ) -> Result<Vec<(String, Option<Value>)>> {
377 let responses = match op_value.get("responses").and_then(|r| r.as_object()) {
378 Some(r) => r,
379 None => return Ok(Vec::new()),
380 };
381
382 let mut result = Vec::new();
383 for (status, resp_value) in responses {
384 let resolved = Self::maybe_resolve(root, resp_value)?;
385 let schema = Self::extract_content_schema(resolved, root)?;
386 result.push((status.clone(), schema));
387 }
388 Ok(result)
389 }
390
391 fn extract_content_schema(resp: &Value, root: &Value) -> Result<Option<Value>> {
392 let content = match resp.get("content").and_then(|c| c.as_object()) {
393 Some(c) => c,
394 None => return Ok(None),
395 };
396
397 let media = preferred_content_media(content);
398
399 match media {
400 Some(m) => {
401 if let Some(schema) = m.get("schema") {
402 let resolved = Self::maybe_resolve(root, schema)?;
403 Ok(Some(resolved.clone()))
404 } else {
405 Ok(None)
406 }
407 }
408 None => Ok(None),
409 }
410 }
411
412 #[must_use]
414 pub fn raw(&self) -> &Value {
415 &self.raw
416 }
417
418 #[must_use]
420 pub fn extensions_for(operation: &Operation) -> ChioExtensions {
421 ChioExtensions::from_operation(&operation.raw)
422 }
423}
424
425fn parse_yaml_value(input: &str) -> Result<Value> {
426 Ok(serde_yaml::from_str(input)?)
427}
428
429fn preferred_content_media(content: &serde_json::Map<String, Value>) -> Option<&Value> {
430 content
431 .get("application/json")
432 .or_else(|| content.values().next())
433}
434
435#[cfg(test)]
436#[allow(clippy::unwrap_used, clippy::expect_used)]
437mod tests {
438 use super::*;
439
440 fn op_parameter_schema<'a>(operation: &'a Operation, name: &str) -> Option<&'a Value> {
441 operation
442 .parameters
443 .iter()
444 .find(|parameter| parameter.name == name)
445 .and_then(|parameter| parameter.schema.as_ref())
446 }
447
448 fn minimal_spec_json() -> &'static str {
449 r##"{
450 "openapi": "3.0.3",
451 "info": {
452 "title": "Test API",
453 "version": "1.0.0"
454 },
455 "paths": {
456 "/pets": {
457 "get": {
458 "operationId": "listPets",
459 "summary": "List all pets",
460 "parameters": [
461 {
462 "name": "limit",
463 "in": "query",
464 "required": false,
465 "schema": { "type": "integer" }
466 }
467 ],
468 "responses": {
469 "200": {
470 "description": "A list of pets",
471 "content": {
472 "application/json": {
473 "schema": {
474 "type": "array",
475 "items": { "type": "object" }
476 }
477 }
478 }
479 }
480 }
481 }
482 }
483 }
484 }"##
485 }
486
487 fn minimal_spec_yaml() -> &'static str {
488 r##"openapi: "3.1.0"
489info:
490 title: Test API
491 version: "1.0.0"
492paths:
493 /items:
494 get:
495 operationId: listItems
496 summary: List items
497 responses:
498 "200":
499 description: OK
500"##
501 }
502
503 #[test]
504 fn parse_json_spec() {
505 let spec = OpenApiSpec::parse(minimal_spec_json()).unwrap();
506 assert_eq!(spec.openapi_version, "3.0.3");
507 assert_eq!(spec.title, "Test API");
508 assert_eq!(spec.api_version, "1.0.0");
509 assert_eq!(spec.paths.len(), 1);
510
511 let (path, item) = &spec.paths[0];
512 assert_eq!(path, "/pets");
513 assert_eq!(item.operations.len(), 1);
514 assert_eq!(item.operations[0].0, "GET");
515
516 let op = &item.operations[0].1;
517 assert_eq!(op.operation_id.as_deref(), Some("listPets"));
518 assert_eq!(op.parameters.len(), 1);
519 assert_eq!(op.parameters[0].name, "limit");
520 assert_eq!(op.parameters[0].location, ParameterLocation::Query);
521 assert!(!op.parameters[0].required);
522 }
523
524 #[test]
525 fn parse_yaml_spec() {
526 let spec = OpenApiSpec::parse(minimal_spec_yaml()).unwrap();
527 assert_eq!(spec.openapi_version, "3.1.0");
528 assert_eq!(spec.paths.len(), 1);
529 let (path, _) = &spec.paths[0];
530 assert_eq!(path, "/items");
531 }
532
533 #[test]
534 fn preferred_content_media_selects_application_json_when_present() {
535 let content = serde_json::json!({
536 "text/plain": { "schema": { "type": "string" } },
537 "application/json": { "schema": { "type": "object" } }
538 });
539 let media = preferred_content_media(content.as_object().unwrap()).unwrap();
540
541 assert_eq!(
542 media
543 .get("schema")
544 .and_then(|schema| schema.get("type"))
545 .and_then(Value::as_str),
546 Some("object")
547 );
548 }
549
550 #[test]
551 fn unsupported_version() {
552 let input = r##"{"openapi": "2.0", "info": {"title": "T", "version": "1"}, "paths": {}}"##;
553 let err = OpenApiSpec::parse(input).unwrap_err();
554 assert!(matches!(err, OpenApiError::UnsupportedVersion(_)));
555 }
556
557 #[test]
558 fn missing_openapi_field() {
559 let input = r##"{"info": {"title": "T", "version": "1"}, "paths": {}}"##;
560 let err = OpenApiSpec::parse(input).unwrap_err();
561 assert!(matches!(err, OpenApiError::MissingField(_)));
562 }
563
564 #[test]
565 fn ref_resolution() {
566 let input = r##"{
567 "openapi": "3.0.3",
568 "info": { "title": "T", "version": "1" },
569 "paths": {
570 "/things": {
571 "get": {
572 "operationId": "getThings",
573 "parameters": [
574 { "$ref": "#/components/parameters/LimitParam" }
575 ],
576 "responses": { "200": { "description": "OK" } }
577 }
578 }
579 },
580 "components": {
581 "parameters": {
582 "LimitParam": {
583 "name": "limit",
584 "in": "query",
585 "required": false,
586 "schema": { "type": "integer" }
587 }
588 }
589 }
590 }"##;
591
592 let spec = OpenApiSpec::parse(input).unwrap();
593 let (_, item) = &spec.paths[0];
594 let op = &item.operations[0].1;
595 assert_eq!(op.parameters.len(), 1);
596 assert_eq!(op.parameters[0].name, "limit");
597 }
598
599 #[test]
600 fn parameter_schema_ref_is_resolved() {
601 let input = r##"{
602 "openapi": "3.0.3",
603 "info": { "title": "T", "version": "1" },
604 "paths": {
605 "/things": {
606 "get": {
607 "operationId": "getThings",
608 "parameters": [
609 {
610 "name": "limit",
611 "in": "query",
612 "schema": { "$ref": "#/components/schemas/Limit" }
613 }
614 ],
615 "responses": { "200": { "description": "OK" } }
616 }
617 }
618 },
619 "components": {
620 "schemas": {
621 "Limit": { "type": "integer", "minimum": 1 }
622 }
623 }
624 }"##;
625
626 let spec = OpenApiSpec::parse(input).unwrap();
627 let (_, item) = &spec.paths[0];
628 let schema = op_parameter_schema(&item.operations[0].1, "limit").unwrap();
629
630 assert_eq!(schema.get("type").and_then(Value::as_str), Some("integer"));
631 assert_eq!(schema.get("minimum").and_then(Value::as_i64), Some(1));
632 assert!(schema.get("$ref").is_none());
633 }
634
635 #[test]
636 fn request_body_schema_extracted() {
637 let input = r##"{
638 "openapi": "3.0.3",
639 "info": { "title": "T", "version": "1" },
640 "paths": {
641 "/pets": {
642 "post": {
643 "operationId": "createPet",
644 "requestBody": {
645 "content": {
646 "application/json": {
647 "schema": {
648 "type": "object",
649 "properties": {
650 "name": { "type": "string" }
651 }
652 }
653 }
654 }
655 },
656 "responses": { "201": { "description": "Created" } }
657 }
658 }
659 }
660 }"##;
661
662 let spec = OpenApiSpec::parse(input).unwrap();
663 let (_, item) = &spec.paths[0];
664 let op = &item.operations[0].1;
665 assert!(op.request_body_schema.is_some());
666 assert!(!op.request_body_required);
667 let schema = op.request_body_schema.as_ref().unwrap();
668 assert_eq!(schema.get("type").and_then(|v| v.as_str()), Some("object"));
669 }
670
671 #[test]
672 fn request_body_required_is_extracted_after_ref_resolution() {
673 let input = r##"{
674 "openapi": "3.0.3",
675 "info": { "title": "T", "version": "1" },
676 "paths": {
677 "/pets": {
678 "post": {
679 "operationId": "createPet",
680 "requestBody": { "$ref": "#/components/requestBodies/PetBody" },
681 "responses": { "201": { "description": "Created" } }
682 }
683 }
684 },
685 "components": {
686 "requestBodies": {
687 "PetBody": {
688 "required": true,
689 "content": {
690 "application/json": {
691 "schema": {
692 "type": "object",
693 "properties": {
694 "name": { "type": "string" }
695 }
696 }
697 }
698 }
699 }
700 }
701 }
702 }"##;
703
704 let spec = OpenApiSpec::parse(input).unwrap();
705 let (_, item) = &spec.paths[0];
706 let op = &item.operations[0].1;
707 assert!(op.request_body_schema.is_some());
708 assert!(op.request_body_required);
709 }
710
711 #[test]
712 fn path_parameters_required_by_default() {
713 let input = r##"{
714 "openapi": "3.0.3",
715 "info": { "title": "T", "version": "1" },
716 "paths": {
717 "/pets/{petId}": {
718 "get": {
719 "operationId": "getPet",
720 "parameters": [
721 { "name": "petId", "in": "path", "schema": { "type": "string" } }
722 ],
723 "responses": { "200": { "description": "OK" } }
724 }
725 }
726 }
727 }"##;
728
729 let spec = OpenApiSpec::parse(input).unwrap();
730 let (_, item) = &spec.paths[0];
731 let op = &item.operations[0].1;
732 assert!(op.parameters[0].required);
733 assert_eq!(op.parameters[0].location, ParameterLocation::Path);
734 }
735
736 #[test]
737 fn parameter_missing_name_is_rejected() {
738 let input = r##"{
739 "openapi": "3.0.3",
740 "info": { "title": "T", "version": "1" },
741 "paths": {
742 "/pets": {
743 "get": {
744 "operationId": "listPets",
745 "parameters": [
746 { "in": "query", "schema": { "type": "string" } }
747 ],
748 "responses": { "200": { "description": "OK" } }
749 }
750 }
751 }
752 }"##;
753
754 let err = OpenApiSpec::parse(input).unwrap_err();
755
756 assert!(matches!(err, OpenApiError::MissingField(ref field) if field == "parameter.name"));
757 }
758
759 #[test]
760 fn parameter_missing_in_is_rejected() {
761 let input = r##"{
762 "openapi": "3.0.3",
763 "info": { "title": "T", "version": "1" },
764 "paths": {
765 "/pets": {
766 "get": {
767 "operationId": "listPets",
768 "parameters": [
769 { "name": "limit", "schema": { "type": "string" } }
770 ],
771 "responses": { "200": { "description": "OK" } }
772 }
773 }
774 }
775 }"##;
776
777 let err = OpenApiSpec::parse(input).unwrap_err();
778
779 assert!(matches!(err, OpenApiError::MissingField(ref field) if field == "parameter.in"));
780 }
781
782 #[test]
783 fn non_array_parameters_rejected() {
784 let input = r##"{
785 "openapi": "3.0.3",
786 "info": {"title": "T", "version": "1"},
787 "paths": {
788 "/pets": {
789 "get": {
790 "parameters": {"name": "limit", "in": "query"},
791 "responses": { "200": { "description": "OK" } }
792 }
793 }
794 }
795 }"##;
796
797 let err = OpenApiSpec::parse(input).unwrap_err();
798
799 assert!(
800 matches!(err, OpenApiError::InvalidSpec(ref message) if message.contains("parameters must be an array"))
801 );
802 }
803
804 #[test]
805 fn unsupported_parameter_location_rejected() {
806 let input = r##"{
807 "openapi": "3.0.3",
808 "info": {"title": "T", "version": "1"},
809 "paths": {
810 "/pets": {
811 "get": {
812 "parameters": [
813 {"name": "petId", "in": "pat", "required": true}
814 ],
815 "responses": { "200": { "description": "OK" } }
816 }
817 }
818 }
819 }"##;
820
821 let err = OpenApiSpec::parse(input).unwrap_err();
822
823 assert!(
824 matches!(err, OpenApiError::InvalidSpec(ref message) if message.contains("unsupported location"))
825 );
826 }
827
828 #[test]
829 fn non_boolean_parameter_required_rejected() {
830 let input = r##"{
831 "openapi": "3.0.3",
832 "info": {"title": "T", "version": "1"},
833 "paths": {
834 "/pets": {
835 "get": {
836 "parameters": [
837 {"name": "limit", "in": "query", "required": "false"}
838 ],
839 "responses": { "200": { "description": "OK" } }
840 }
841 }
842 }
843 }"##;
844
845 let err = OpenApiSpec::parse(input).unwrap_err();
846
847 assert!(
848 matches!(err, OpenApiError::InvalidSpec(ref message) if message.contains("parameter.required"))
849 );
850 }
851
852 #[test]
853 fn missing_paths_field() {
854 let input = r##"{"openapi": "3.0.3", "info": {"title": "T", "version": "1"}}"##;
855 let err = OpenApiSpec::parse(input).unwrap_err();
856 assert!(matches!(err, OpenApiError::MissingField(ref f) if f == "paths"));
857 }
858
859 #[test]
860 fn missing_info_field() {
861 let input = r##"{"openapi": "3.0.3", "paths": {}}"##;
862 let err = OpenApiSpec::parse(input).unwrap_err();
863 assert!(matches!(err, OpenApiError::MissingField(ref f) if f == "info"));
864 }
865
866 #[test]
867 fn empty_paths_object() {
868 let input =
869 r##"{"openapi": "3.0.3", "info": {"title": "T", "version": "1"}, "paths": {}}"##;
870 let spec = OpenApiSpec::parse(input).unwrap();
871 assert!(spec.paths.is_empty());
872 assert_eq!(spec.title, "T");
873 }
874
875 #[test]
876 fn spec_with_no_operations_on_path() {
877 let input = r##"{
878 "openapi": "3.0.3",
879 "info": {"title": "T", "version": "1"},
880 "paths": {
881 "/empty": {
882 "parameters": [
883 {"name": "id", "in": "query", "schema": {"type": "string"}}
884 ]
885 }
886 }
887 }"##;
888 let spec = OpenApiSpec::parse(input).unwrap();
889 assert_eq!(spec.paths.len(), 1);
890 let (_, item) = &spec.paths[0];
891 assert!(item.operations.is_empty());
892 assert_eq!(item.common_parameters.len(), 1);
893 }
894
895 #[test]
896 fn broken_ref_produces_error() {
897 let input = r##"{
898 "openapi": "3.0.3",
899 "info": {"title": "T", "version": "1"},
900 "paths": {
901 "/things": {
902 "get": {
903 "parameters": [
904 {"$ref": "#/components/parameters/NonExistent"}
905 ],
906 "responses": {"200": {"description": "OK"}}
907 }
908 }
909 }
910 }"##;
911 let err = OpenApiSpec::parse(input).unwrap_err();
912 assert!(matches!(err, OpenApiError::UnresolvedRef(_)));
913 }
914
915 #[test]
916 fn external_ref_produces_error() {
917 let input = r##"{
918 "openapi": "3.0.3",
919 "info": {"title": "T", "version": "1"},
920 "paths": {
921 "/things": {
922 "get": {
923 "parameters": [
924 {"$ref": "https://example.com/params.yaml#/Limit"}
925 ],
926 "responses": {"200": {"description": "OK"}}
927 }
928 }
929 }
930 }"##;
931 let err = OpenApiSpec::parse(input).unwrap_err();
932 assert!(matches!(err, OpenApiError::UnresolvedRef(_)));
933 }
934
935 #[test]
936 fn invalid_json_produces_error() {
937 let input = r##"{not valid json"##;
938 let err = OpenApiSpec::parse(input).unwrap_err();
939 assert!(matches!(err, OpenApiError::InvalidJson(_)));
940 }
941
942 #[test]
943 fn invalid_yaml_produces_error() {
944 let input = "openapi: [unclosed\n";
945 let err = OpenApiSpec::parse(input).unwrap_err();
946 assert!(matches!(err, OpenApiError::InvalidYaml(_)));
947 }
948
949 #[test]
950 fn invalid_yaml_does_not_invoke_outer_hook() {
951 use std::sync::atomic::{AtomicBool, Ordering};
952 use std::sync::Arc;
953
954 let input = "openapi: [unclosed\n";
955 let hook_called = Arc::new(AtomicBool::new(false));
956 let hook_called_clone = Arc::clone(&hook_called);
957 let previous_hook = std::panic::take_hook();
958 std::panic::set_hook(Box::new(move |_| {
959 hook_called_clone.store(true, Ordering::SeqCst);
960 }));
961
962 let err = OpenApiSpec::parse(input).unwrap_err();
963 std::panic::set_hook(previous_hook);
964
965 assert!(matches!(err, OpenApiError::InvalidYaml(_)));
966 assert!(!hook_called.load(Ordering::SeqCst));
967 }
968
969 #[test]
970 fn fuzz_malformed_yaml_rejected_without_panic() {
971 let input = "openapi:ets:\n get:\n ope: integer\n responses:\n \"201\":\n description:ope: integer\n responses:A \"201\":\n A list of pets\n";
972
973 assert!(OpenApiSpec::parse(input).is_err());
974 }
975
976 #[test]
977 fn missing_title_defaults_to_untitled() {
978 let input = r##"{
979 "openapi": "3.0.3",
980 "info": {"version": "1"},
981 "paths": {}
982 }"##;
983 let spec = OpenApiSpec::parse(input).unwrap();
984 assert_eq!(spec.title, "Untitled API");
985 }
986
987 #[test]
988 fn missing_version_defaults_to_000() {
989 let input = r##"{
990 "openapi": "3.0.3",
991 "info": {"title": "T"},
992 "paths": {}
993 }"##;
994 let spec = OpenApiSpec::parse(input).unwrap();
995 assert_eq!(spec.api_version, "0.0.0");
996 }
997
998 #[test]
999 fn chio_extensions_extracted() {
1000 let input = r##"{
1001 "openapi": "3.0.3",
1002 "info": { "title": "T", "version": "1" },
1003 "paths": {
1004 "/admin/reset": {
1005 "post": {
1006 "operationId": "resetSystem",
1007 "x-chio-sensitivity": "restricted",
1008 "x-chio-approval-required": true,
1009 "x-chio-side-effects": true,
1010 "responses": { "200": { "description": "OK" } }
1011 }
1012 }
1013 }
1014 }"##;
1015
1016 let spec = OpenApiSpec::parse(input).unwrap();
1017 let (_, item) = &spec.paths[0];
1018 let op = &item.operations[0].1;
1019 let ext = OpenApiSpec::extensions_for(op);
1020 assert_eq!(
1021 ext.sensitivity,
1022 Some(crate::extensions::Sensitivity::Restricted)
1023 );
1024 assert_eq!(ext.approval_required, Some(true));
1025 assert_eq!(ext.side_effects, Some(true));
1026 }
1027}