1use serde_json::{Map, Value};
2
3use crate::{
4 OpenApiInspectionError, OpenApiSpecificationVersion, RawOpenApiDocument, load_raw_document,
5};
6
7const SUPPORTED_METHODS: &[&str] = &[
8 "get", "put", "post", "delete", "options", "head", "patch", "trace",
9];
10
11#[derive(Debug, Clone, PartialEq, Eq, Default)]
12pub struct OpenApiStats {
13 pub parameter_count: usize,
14 pub schema_count: usize,
15 pub path_item_count: usize,
16 pub request_body_count: usize,
17 pub response_count: usize,
18 pub operation_count: usize,
19 pub link_count: usize,
20 pub callback_count: usize,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct OpenApiInspection {
25 pub specification_version: OpenApiSpecificationVersion,
26 pub stats: OpenApiStats,
27}
28
29pub fn inspect_document(input: &str) -> Result<OpenApiInspection, OpenApiInspectionError> {
30 let raw = load_raw_document(input).map_err(OpenApiInspectionError::Load)?;
31 inspect_raw_document(&raw)
32}
33
34pub fn inspect_raw_document(
35 document: &RawOpenApiDocument,
36) -> Result<OpenApiInspection, OpenApiInspectionError> {
37 let specification_version = document
38 .specification_version()
39 .map_err(OpenApiInspectionError::VersionDetection)?;
40
41 Ok(OpenApiInspection {
42 specification_version,
43 stats: collect_stats(document.value(), specification_version),
44 })
45}
46
47fn collect_stats(root: &Value, specification_version: OpenApiSpecificationVersion) -> OpenApiStats {
48 let mut stats = OpenApiStats::default();
49
50 if let Some(paths) = root.get("paths").and_then(Value::as_object) {
51 stats.path_item_count = paths.len();
52
53 for path_item in paths.values() {
54 let Some(path_item) = path_item.as_object() else {
55 continue;
56 };
57
58 if let Some(parameters) = path_item.get("parameters").and_then(Value::as_array) {
59 stats.parameter_count += count_parameter_entries(parameters);
60 stats.request_body_count += count_swagger2_body_parameters(parameters);
61 stats.schema_count += count_parameter_schemas(parameters);
62 }
63
64 for method in SUPPORTED_METHODS {
65 let Some(operation) = path_item.get(*method).and_then(Value::as_object) else {
66 continue;
67 };
68
69 stats.operation_count += 1;
70
71 if let Some(parameters) = operation.get("parameters").and_then(Value::as_array) {
72 stats.parameter_count += count_parameter_entries(parameters);
73 stats.request_body_count += count_swagger2_body_parameters(parameters);
74 stats.schema_count += count_parameter_schemas(parameters);
75 }
76
77 if let Some(request_body) = operation.get("requestBody") {
78 stats.request_body_count += count_request_body_entries(request_body);
79 stats.schema_count += count_request_body_schemas(request_body);
80 }
81
82 if let Some(responses) = operation.get("responses").and_then(Value::as_object) {
83 stats.response_count += 1;
84 stats.schema_count += count_response_schemas(responses);
85 stats.schema_count += count_response_header_schemas(responses);
86 stats.link_count += count_response_links(responses);
87 }
88
89 if let Some(callbacks) = operation.get("callbacks").and_then(Value::as_object) {
90 stats.callback_count += count_callback_entries(callbacks);
91 }
92 }
93 }
94 }
95
96 stats.parameter_count += count_component_parameters(root, specification_version);
97 stats.schema_count += count_component_schemas(root, specification_version);
98 stats.schema_count += count_component_parameter_schemas(root, specification_version);
99 stats.request_body_count += count_component_request_bodies(root, specification_version);
100 stats.schema_count += count_component_request_body_schemas(root, specification_version);
101 stats.schema_count += count_component_header_schemas(root, specification_version);
102 stats.schema_count += count_component_response_schemas(root, specification_version);
103 stats.link_count += count_component_links(root, specification_version);
104 stats.link_count += count_component_response_links(root, specification_version);
105 stats.callback_count += count_component_callbacks(root, specification_version);
106 stats
107}
108
109fn count_parameter_entries(parameters: &[Value]) -> usize {
110 parameters
111 .iter()
112 .filter(|parameter| {
113 parameter
114 .as_object()
115 .is_some_and(|parameter| !is_reference_object(parameter))
116 })
117 .count()
118}
119
120fn count_request_body_entries(request_body: &Value) -> usize {
121 request_body
122 .as_object()
123 .filter(|request_body| !is_reference_object(request_body))
124 .map(|_| 1)
125 .unwrap_or_default()
126}
127
128fn count_callback_entries(callbacks: &Map<String, Value>) -> usize {
129 callbacks
130 .values()
131 .filter(|callback| {
132 callback
133 .as_object()
134 .is_some_and(|callback| !is_reference_object(callback))
135 })
136 .count()
137}
138
139fn component_entries<'a>(
140 root: &'a Value,
141 specification_version: OpenApiSpecificationVersion,
142 component_name: &str,
143) -> Option<&'a Map<String, Value>> {
144 match specification_version {
145 OpenApiSpecificationVersion::Swagger2 => None,
146 OpenApiSpecificationVersion::OpenApi30 | OpenApiSpecificationVersion::OpenApi31 => root
147 .get("components")
148 .and_then(|components| components.get(component_name))
149 .and_then(Value::as_object),
150 }
151}
152
153fn count_component_schemas(
154 root: &Value,
155 specification_version: OpenApiSpecificationVersion,
156) -> usize {
157 match specification_version {
158 OpenApiSpecificationVersion::Swagger2 => root
159 .get("definitions")
160 .and_then(Value::as_object)
161 .map(|definitions| {
162 definitions
163 .values()
164 .map(count_schema_objects)
165 .sum::<usize>()
166 })
167 .unwrap_or_default(),
168 OpenApiSpecificationVersion::OpenApi30 | OpenApiSpecificationVersion::OpenApi31 => root
169 .get("components")
170 .and_then(|components| components.get("schemas"))
171 .and_then(Value::as_object)
172 .map(|schemas| schemas.values().map(count_schema_objects).sum::<usize>())
173 .unwrap_or_default(),
174 }
175}
176
177fn count_component_parameters(
178 root: &Value,
179 specification_version: OpenApiSpecificationVersion,
180) -> usize {
181 component_entries(root, specification_version, "parameters")
182 .map(|parameters| {
183 parameters
184 .values()
185 .filter(|parameter| {
186 parameter
187 .as_object()
188 .is_some_and(|parameter| !is_reference_object(parameter))
189 })
190 .count()
191 })
192 .unwrap_or_default()
193}
194
195fn count_component_parameter_schemas(
196 root: &Value,
197 specification_version: OpenApiSpecificationVersion,
198) -> usize {
199 component_entries(root, specification_version, "parameters")
200 .map(|parameters| {
201 parameters
202 .values()
203 .map(|parameter| {
204 parameter
205 .as_object()
206 .map(count_parameter_schema_for_object)
207 .unwrap_or_default()
208 })
209 .sum::<usize>()
210 })
211 .unwrap_or_default()
212}
213
214fn count_component_request_bodies(
215 root: &Value,
216 specification_version: OpenApiSpecificationVersion,
217) -> usize {
218 component_entries(root, specification_version, "requestBodies")
219 .map(|request_bodies| {
220 request_bodies
221 .values()
222 .filter(|request_body| {
223 request_body
224 .as_object()
225 .is_some_and(|request_body| !is_reference_object(request_body))
226 })
227 .count()
228 })
229 .unwrap_or_default()
230}
231
232fn count_component_request_body_schemas(
233 root: &Value,
234 specification_version: OpenApiSpecificationVersion,
235) -> usize {
236 component_entries(root, specification_version, "requestBodies")
237 .map(|request_bodies| {
238 request_bodies
239 .values()
240 .map(count_request_body_schemas)
241 .sum::<usize>()
242 })
243 .unwrap_or_default()
244}
245
246fn count_component_header_schemas(
247 root: &Value,
248 specification_version: OpenApiSpecificationVersion,
249) -> usize {
250 component_entries(root, specification_version, "headers")
251 .map(|headers| {
252 headers
253 .values()
254 .map(|header| {
255 header
256 .as_object()
257 .map(count_header_schema_for_object)
258 .unwrap_or_default()
259 })
260 .sum::<usize>()
261 })
262 .unwrap_or_default()
263}
264
265fn count_component_response_schemas(
266 root: &Value,
267 specification_version: OpenApiSpecificationVersion,
268) -> usize {
269 component_entries(root, specification_version, "responses")
270 .map(|responses| {
271 responses
272 .values()
273 .filter_map(Value::as_object)
274 .map(|response| {
275 let response_value = Value::Object(response.clone());
276 count_response_value_schemas(&response_value)
277 + count_response_header_schemas_for_object(response)
278 })
279 .sum::<usize>()
280 })
281 .unwrap_or_default()
282}
283
284fn count_component_links(
285 root: &Value,
286 specification_version: OpenApiSpecificationVersion,
287) -> usize {
288 component_entries(root, specification_version, "links")
289 .map(|links| {
290 links
291 .values()
292 .filter(|link| {
293 link.as_object()
294 .is_some_and(|link| !is_reference_object(link))
295 })
296 .count()
297 })
298 .unwrap_or_default()
299}
300
301fn count_component_response_links(
302 root: &Value,
303 specification_version: OpenApiSpecificationVersion,
304) -> usize {
305 component_entries(root, specification_version, "responses")
306 .map(|responses| {
307 responses
308 .values()
309 .filter_map(Value::as_object)
310 .map(count_response_links_for_object)
311 .sum::<usize>()
312 })
313 .unwrap_or_default()
314}
315
316fn count_component_callbacks(
317 root: &Value,
318 specification_version: OpenApiSpecificationVersion,
319) -> usize {
320 component_entries(root, specification_version, "callbacks")
321 .map(|callbacks| count_callback_entries(callbacks))
322 .unwrap_or_default()
323}
324
325fn count_swagger2_body_parameters(parameters: &[Value]) -> usize {
326 parameters
327 .iter()
328 .filter(|parameter| {
329 parameter
330 .as_object()
331 .is_some_and(|parameter| !is_reference_object(parameter))
332 && parameter
333 .get("in")
334 .and_then(Value::as_str)
335 .is_some_and(|location| location == "body")
336 })
337 .count()
338}
339
340fn count_parameter_schemas(parameters: &[Value]) -> usize {
341 parameters
342 .iter()
343 .map(|parameter| {
344 parameter
345 .as_object()
346 .map(count_parameter_schema_for_object)
347 .unwrap_or_default()
348 })
349 .sum::<usize>()
350}
351
352fn count_request_body_schemas(request_body: &Value) -> usize {
353 let Some(request_body) = request_body.as_object() else {
354 return 0;
355 };
356
357 if is_reference_object(request_body) {
358 return 0;
359 }
360
361 request_body
362 .get("content")
363 .and_then(Value::as_object)
364 .map(count_media_type_schemas)
365 .unwrap_or_default()
366}
367
368fn count_media_type_schemas(content: &Map<String, Value>) -> usize {
369 content
370 .values()
371 .filter_map(|media_type| media_type.get("schema"))
372 .map(count_schema_objects)
373 .sum::<usize>()
374}
375
376fn count_response_schemas(responses: &Map<String, Value>) -> usize {
377 responses
378 .values()
379 .map(count_response_value_schemas)
380 .sum::<usize>()
381}
382
383fn count_response_value_schemas(response: &Value) -> usize {
384 let Some(response) = response.as_object() else {
385 return 0;
386 };
387
388 if is_reference_object(response) {
389 return 0;
390 }
391
392 response
393 .get("schema")
394 .map(count_schema_objects)
395 .unwrap_or_default()
396 + response
397 .get("content")
398 .and_then(Value::as_object)
399 .map(count_media_type_schemas)
400 .unwrap_or_default()
401}
402
403fn count_response_header_schemas(responses: &Map<String, Value>) -> usize {
404 responses
405 .values()
406 .filter_map(Value::as_object)
407 .map(count_response_header_schemas_for_object)
408 .sum::<usize>()
409}
410
411fn count_response_header_schemas_for_object(response: &Map<String, Value>) -> usize {
412 if is_reference_object(response) {
413 return 0;
414 }
415
416 response
417 .get("headers")
418 .and_then(Value::as_object)
419 .map(count_header_schemas)
420 .unwrap_or_default()
421}
422
423fn count_header_schemas(headers: &Map<String, Value>) -> usize {
424 headers
425 .values()
426 .filter_map(Value::as_object)
427 .map(count_header_schema_for_object)
428 .sum::<usize>()
429}
430
431fn count_response_links(responses: &Map<String, Value>) -> usize {
432 responses
433 .values()
434 .filter_map(Value::as_object)
435 .map(count_response_links_for_object)
436 .sum::<usize>()
437}
438
439fn count_response_links_for_object(response: &Map<String, Value>) -> usize {
440 if is_reference_object(response) {
441 return 0;
442 }
443
444 response
445 .get("links")
446 .and_then(Value::as_object)
447 .map(|links| {
448 links
449 .values()
450 .filter(|link| {
451 link.as_object()
452 .is_some_and(|link| !is_reference_object(link))
453 })
454 .count()
455 })
456 .unwrap_or_default()
457}
458
459fn count_parameter_schema_for_object(parameter: &Map<String, Value>) -> usize {
460 if is_reference_object(parameter) {
461 return 0;
462 }
463
464 if let Some(schema) = parameter.get("schema") {
465 return count_schema_objects(schema);
466 }
467
468 synthesized_parameter_schema(parameter)
469 .map(|schema| count_schema_objects(&schema))
470 .unwrap_or_default()
471}
472
473fn count_header_schema_for_object(header: &Map<String, Value>) -> usize {
474 if is_reference_object(header) {
475 return 0;
476 }
477
478 header
479 .get("schema")
480 .map(count_schema_objects)
481 .unwrap_or_default()
482}
483
484fn synthesized_parameter_schema(parameter: &Map<String, Value>) -> Option<Value> {
485 let mut schema = Map::new();
486
487 for field_name in ["type", "items", "allOf", "oneOf", "anyOf", "properties"] {
488 if let Some(value) = parameter.get(field_name) {
489 schema.insert(field_name.to_string(), value.clone());
490 }
491 }
492
493 if schema.is_empty() {
494 None
495 } else {
496 Some(Value::Object(schema))
497 }
498}
499
500fn count_schema_objects(value: &Value) -> usize {
501 let Some(schema) = value.as_object() else {
502 return 0;
503 };
504
505 let mut count = usize::from(is_schema_object(schema));
506
507 if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
508 count += properties.values().map(count_schema_objects).sum::<usize>();
509 }
510
511 if let Some(items) = schema.get("items") {
512 count += count_schema_objects(items);
513 }
514
515 if let Some(additional_properties) = schema.get("additionalProperties") {
516 count += count_schema_objects(additional_properties);
517 }
518
519 for field_name in ["allOf", "oneOf", "anyOf"] {
520 if let Some(values) = schema.get(field_name).and_then(Value::as_array) {
521 count += values.iter().map(count_schema_objects).sum::<usize>();
522 }
523 }
524
525 count
526}
527
528fn is_schema_object(schema: &Map<String, Value>) -> bool {
529 schema.contains_key("type")
530 || schema.contains_key("properties")
531 || schema.contains_key("items")
532 || schema.contains_key("allOf")
533 || schema.contains_key("oneOf")
534 || schema.contains_key("anyOf")
535 || schema.contains_key("additionalProperties")
536}
537
538fn is_reference_object(value: &Map<String, Value>) -> bool {
539 value.contains_key("$ref") && value.len() == 1
540}
541
542#[cfg(test)]
543mod tests {
544 use std::path::PathBuf;
545
546 use crate::{OpenApiSource, OpenApiSpecificationVersion, decode_raw_document};
547
548 use super::{OpenApiStats, inspect_raw_document};
549
550 #[test]
551 fn inspects_petstore_v30_with_dotnet_parity_counts() {
552 let raw = decode_raw_document(
553 OpenApiSource::Path(PathBuf::from("test/OpenAPI/v3.0/petstore.json")),
554 include_str!("../../../../test/OpenAPI/v3.0/petstore.json"),
555 )
556 .unwrap();
557
558 let inspection = inspect_raw_document(&raw).unwrap();
559
560 assert_eq!(
561 inspection.specification_version,
562 OpenApiSpecificationVersion::OpenApi30
563 );
564 assert_eq!(inspection.stats.path_item_count, 13);
565 assert_eq!(inspection.stats.operation_count, 19);
566 assert_eq!(inspection.stats.parameter_count, 17);
567 assert_eq!(inspection.stats.request_body_count, 9);
568 assert_eq!(inspection.stats.response_count, 19);
569 assert_eq!(inspection.stats.link_count, 0);
570 assert_eq!(inspection.stats.callback_count, 0);
571 assert_eq!(inspection.stats.schema_count, 73);
572 }
573
574 #[test]
575 fn inspects_petstore_v20_with_body_parameters_as_request_bodies() {
576 let raw = decode_raw_document(
577 OpenApiSource::Path(PathBuf::from("test/OpenAPI/v2.0/petstore.json")),
578 include_str!("../../../../test/OpenAPI/v2.0/petstore.json"),
579 )
580 .unwrap();
581
582 let inspection = inspect_raw_document(&raw).unwrap();
583
584 assert_eq!(
585 inspection.specification_version,
586 OpenApiSpecificationVersion::Swagger2
587 );
588 assert!(inspection.stats.request_body_count > 0);
589 assert!(inspection.stats.schema_count > 0);
590 }
591
592 #[test]
593 fn inspects_callback_examples_with_callbacks() {
594 let raw = decode_raw_document(
595 OpenApiSource::Path(PathBuf::from("test/OpenAPI/v3.0/callback-example.json")),
596 include_str!("../../../../test/OpenAPI/v3.0/callback-example.json"),
597 )
598 .unwrap();
599
600 let inspection = inspect_raw_document(&raw).unwrap();
601
602 assert!(inspection.stats.callback_count > 0);
603 }
604
605 #[test]
606 fn empty_stats_start_at_zero() {
607 let stats = OpenApiStats::default();
608
609 assert_eq!(stats.path_item_count, 0);
610 assert_eq!(stats.operation_count, 0);
611 assert_eq!(stats.parameter_count, 0);
612 assert_eq!(stats.request_body_count, 0);
613 assert_eq!(stats.response_count, 0);
614 assert_eq!(stats.link_count, 0);
615 assert_eq!(stats.callback_count, 0);
616 assert_eq!(stats.schema_count, 0);
617 }
618}