1use crate::cache::models::{
2 CachedApertureSecret, CachedCommand, CachedParameter, CachedRequestBody, CachedResponse,
3 CachedSecurityScheme, CachedSpec, CommandExample, PaginationInfo, PaginationStrategy,
4 SkippedEndpoint, CACHE_FORMAT_VERSION,
5};
6use crate::constants;
7use crate::error::Error;
8use crate::utils::to_kebab_case;
9use openapiv3::{OpenAPI, Operation, Parameter, ReferenceOr, RequestBody, SecurityScheme};
10use serde_json;
11use std::collections::HashMap;
12use std::fmt::Write;
13
14type SchemaTypeInfo = (String, Option<String>, Option<String>, Vec<String>);
17
18type ParameterSchemaInfo = (
21 Option<String>,
22 Option<String>,
23 Option<String>,
24 Option<String>,
25 Vec<String>,
26);
27
28#[derive(Debug, Clone)]
30pub struct TransformOptions {
31 pub name: String,
33 pub skip_endpoints: Vec<(String, String)>,
35 pub warnings: Vec<crate::spec::validator::ValidationWarning>,
37}
38
39impl TransformOptions {
40 #[must_use]
42 pub fn new(name: impl Into<String>) -> Self {
43 Self {
44 name: name.into(),
45 skip_endpoints: Vec::new(),
46 warnings: Vec::new(),
47 }
48 }
49
50 #[must_use]
52 pub fn with_skip_endpoints(mut self, endpoints: Vec<(String, String)>) -> Self {
53 self.skip_endpoints = endpoints;
54 self
55 }
56
57 #[must_use]
59 pub fn with_warnings(
60 mut self,
61 warnings: Vec<crate::spec::validator::ValidationWarning>,
62 ) -> Self {
63 self.warnings = warnings;
64 self
65 }
66}
67
68pub struct SpecTransformer;
70
71impl SpecTransformer {
72 #[must_use]
74 pub const fn new() -> Self {
75 Self
76 }
77
78 pub fn transform_with_options(
87 &self,
88 spec: &OpenAPI,
89 options: &TransformOptions,
90 ) -> Result<CachedSpec, Error> {
91 self.transform_with_warnings(
92 &options.name,
93 spec,
94 &options.skip_endpoints,
95 &options.warnings,
96 )
97 }
98
99 pub fn transform(&self, name: &str, spec: &OpenAPI) -> Result<CachedSpec, Error> {
108 self.transform_with_filter(name, spec, &[])
109 }
110
111 pub fn transform_with_filter(
126 &self,
127 name: &str,
128 spec: &OpenAPI,
129 skip_endpoints: &[(String, String)],
130 ) -> Result<CachedSpec, Error> {
131 self.transform_with_warnings(name, spec, skip_endpoints, &[])
132 }
133
134 pub fn transform_with_warnings(
147 &self,
148 name: &str,
149 spec: &OpenAPI,
150 skip_endpoints: &[(String, String)],
151 warnings: &[crate::spec::validator::ValidationWarning],
152 ) -> Result<CachedSpec, Error> {
153 let mut commands = Vec::new();
154
155 let version = spec.info.version.clone();
157
158 let servers: Vec<String> = spec.servers.iter().map(|s| s.url.clone()).collect();
160 let base_url = servers.first().cloned();
161
162 let server_variables: HashMap<String, crate::cache::models::ServerVariable> = spec
164 .servers
165 .first()
166 .and_then(|server| server.variables.as_ref())
167 .map(|vars| {
168 vars.iter()
169 .map(|(name, variable)| {
170 (
171 name.clone(),
172 crate::cache::models::ServerVariable {
173 default: Some(variable.default.clone()),
174 enum_values: variable.enumeration.clone(),
175 description: variable.description.clone(),
176 },
177 )
178 })
179 .collect()
180 })
181 .unwrap_or_default();
182
183 let global_security_requirements: Vec<String> = spec
185 .security
186 .iter()
187 .flat_map(|security_vec| {
188 security_vec
189 .iter()
190 .flat_map(|security_req| security_req.keys().cloned())
191 })
192 .collect();
193
194 for (path, path_item) in spec.paths.iter() {
196 Self::process_path_item(
197 spec,
198 path,
199 path_item,
200 skip_endpoints,
201 &global_security_requirements,
202 &mut commands,
203 )?;
204 }
205
206 let security_schemes = Self::extract_security_schemes(spec);
208
209 let skipped_endpoints: Vec<SkippedEndpoint> = warnings
211 .iter()
212 .map(|w| SkippedEndpoint {
213 path: w.endpoint.path.clone(),
214 method: w.endpoint.method.clone(),
215 content_type: w.endpoint.content_type.clone(),
216 reason: w.reason.clone(),
217 })
218 .collect();
219
220 Ok(CachedSpec {
221 cache_format_version: CACHE_FORMAT_VERSION,
222 name: name.to_string(),
223 version,
224 commands,
225 base_url,
226 servers,
227 security_schemes,
228 skipped_endpoints,
229 server_variables,
230 })
231 }
232
233 fn process_path_item(
235 spec: &OpenAPI,
236 path: &str,
237 path_item: &ReferenceOr<openapiv3::PathItem>,
238 skip_endpoints: &[(String, String)],
239 global_security_requirements: &[String],
240 commands: &mut Vec<CachedCommand>,
241 ) -> Result<(), Error> {
242 let ReferenceOr::Item(item) = path_item else {
243 return Ok(());
244 };
245
246 for (method, operation) in crate::spec::http_methods_iter(item) {
248 let Some(op) = operation else {
249 continue;
250 };
251
252 if Self::should_skip_endpoint(path, method, skip_endpoints) {
253 continue;
254 }
255
256 let command =
257 Self::transform_operation(spec, method, path, op, global_security_requirements)?;
258 commands.push(command);
259 }
260
261 Ok(())
262 }
263
264 fn should_skip_endpoint(path: &str, method: &str, skip_endpoints: &[(String, String)]) -> bool {
266 skip_endpoints.iter().any(|(skip_path, skip_method)| {
267 skip_path == path && skip_method.eq_ignore_ascii_case(method)
268 })
269 }
270
271 #[allow(clippy::too_many_lines)]
273 fn transform_operation(
274 spec: &OpenAPI,
275 method: &str,
276 path: &str,
277 operation: &Operation,
278 global_security_requirements: &[String],
279 ) -> Result<CachedCommand, Error> {
280 let operation_id = operation
282 .operation_id
283 .clone()
284 .unwrap_or_else(|| format!("{method}_{path}"));
285
286 let name = operation
288 .tags
289 .first()
290 .cloned()
291 .unwrap_or_else(|| constants::DEFAULT_GROUP.to_string());
292
293 let mut parameters = Vec::new();
295 for param_ref in &operation.parameters {
296 match param_ref {
297 ReferenceOr::Item(param) => {
298 parameters.push(Self::transform_parameter(param));
299 }
300 ReferenceOr::Reference { reference } => {
301 let param = Self::resolve_parameter_reference(spec, reference)?;
302 parameters.push(Self::transform_parameter(¶m));
303 }
304 }
305 }
306
307 let request_body = operation
309 .request_body
310 .as_ref()
311 .and_then(Self::transform_request_body);
312
313 let responses = operation
315 .responses
316 .responses
317 .iter()
318 .map(|(code, response_ref)| {
319 Self::transform_response(spec, code.to_string(), response_ref)
320 })
321 .collect();
322
323 let security_requirements = operation.security.as_ref().map_or_else(
325 || global_security_requirements.to_vec(),
326 |security_reqs| {
327 security_reqs
328 .iter()
329 .flat_map(|security_req| security_req.keys().cloned())
330 .collect()
331 },
332 );
333
334 let examples = Self::generate_command_examples(
336 &name,
337 &operation_id,
338 method,
339 path,
340 ¶meters,
341 request_body.as_ref(),
342 );
343
344 let pagination = Self::detect_pagination(operation, spec);
346
347 Ok(CachedCommand {
348 name,
349 description: operation.description.clone(),
350 summary: operation.summary.clone(),
351 operation_id,
352 method: method.to_uppercase(),
353 path: path.to_string(),
354 parameters,
355 request_body,
356 responses,
357 security_requirements,
358 tags: operation.tags.clone(),
359 deprecated: operation.deprecated,
360 external_docs_url: operation
361 .external_docs
362 .as_ref()
363 .map(|docs| docs.url.clone()),
364 examples,
365 display_group: None,
366 display_name: None,
367 aliases: vec![],
368 hidden: false,
369 pagination,
370 })
371 }
372
373 #[allow(clippy::too_many_lines)]
375 fn transform_parameter(param: &Parameter) -> CachedParameter {
376 let (param_data, location_str) = match param {
377 Parameter::Query { parameter_data, .. } => {
378 (parameter_data, constants::PARAM_LOCATION_QUERY)
379 }
380 Parameter::Header { parameter_data, .. } => {
381 (parameter_data, constants::PARAM_LOCATION_HEADER)
382 }
383 Parameter::Path { parameter_data, .. } => {
384 (parameter_data, constants::PARAM_LOCATION_PATH)
385 }
386 Parameter::Cookie { parameter_data, .. } => {
387 (parameter_data, constants::PARAM_LOCATION_COOKIE)
388 }
389 };
390
391 let (schema_json, schema_type, format, default_value, enum_values) =
393 Self::extract_parameter_schema_info(¶m_data.format);
394
395 let example = param_data
397 .example
398 .as_ref()
399 .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()));
400
401 CachedParameter {
402 name: param_data.name.clone(),
403 location: location_str.to_string(),
404 required: param_data.required,
405 description: param_data.description.clone(),
406 schema: schema_json,
407 schema_type,
408 format,
409 default_value,
410 enum_values,
411 example,
412 }
413 }
414
415 fn extract_parameter_schema_info(
417 format: &openapiv3::ParameterSchemaOrContent,
418 ) -> ParameterSchemaInfo {
419 let openapiv3::ParameterSchemaOrContent::Schema(schema_ref) = format else {
420 return (
422 Some(r#"{"type": "string"}"#.to_string()),
423 Some(constants::SCHEMA_TYPE_STRING.to_string()),
424 None,
425 None,
426 vec![],
427 );
428 };
429
430 match schema_ref {
431 ReferenceOr::Item(schema) => {
432 let schema_json = serde_json::to_string(schema).ok();
433
434 let (schema_type, format, default, enums) =
436 Self::extract_schema_type_info(&schema.schema_kind);
437
438 let default_value = schema
440 .schema_data
441 .default
442 .as_ref()
443 .map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.to_string()));
444
445 (
446 schema_json,
447 Some(schema_type),
448 format,
449 default_value.or(default),
450 enums,
451 )
452 }
453 ReferenceOr::Reference { .. } => {
454 (
456 Some(r#"{"type": "string"}"#.to_string()),
457 Some(constants::SCHEMA_TYPE_STRING.to_string()),
458 None,
459 None,
460 vec![],
461 )
462 }
463 }
464 }
465
466 fn extract_schema_type_info(schema_kind: &openapiv3::SchemaKind) -> SchemaTypeInfo {
468 let openapiv3::SchemaKind::Type(type_val) = schema_kind else {
469 return (
470 constants::SCHEMA_TYPE_STRING.to_string(),
471 None,
472 None,
473 vec![],
474 );
475 };
476
477 match type_val {
478 openapiv3::Type::String(string_type) => Self::extract_string_type_info(string_type),
479 openapiv3::Type::Number(number_type) => Self::extract_number_type_info(number_type),
480 openapiv3::Type::Integer(integer_type) => Self::extract_integer_type_info(integer_type),
481 openapiv3::Type::Boolean(_) => (
482 constants::SCHEMA_TYPE_BOOLEAN.to_string(),
483 None,
484 None,
485 vec![],
486 ),
487 openapiv3::Type::Array(_) => {
488 (constants::SCHEMA_TYPE_ARRAY.to_string(), None, None, vec![])
489 }
490 openapiv3::Type::Object(_) => (
491 constants::SCHEMA_TYPE_OBJECT.to_string(),
492 None,
493 None,
494 vec![],
495 ),
496 }
497 }
498
499 fn extract_string_type_info(
501 string_type: &openapiv3::StringType,
502 ) -> (String, Option<String>, Option<String>, Vec<String>) {
503 let enum_values: Vec<String> = string_type
504 .enumeration
505 .iter()
506 .filter_map(|v| v.as_ref())
507 .map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.clone()))
508 .collect();
509
510 let format = Self::extract_format_string(&string_type.format);
511
512 (
513 constants::SCHEMA_TYPE_STRING.to_string(),
514 format,
515 None,
516 enum_values,
517 )
518 }
519
520 fn extract_number_type_info(
522 number_type: &openapiv3::NumberType,
523 ) -> (String, Option<String>, Option<String>, Vec<String>) {
524 let format = match &number_type.format {
525 openapiv3::VariantOrUnknownOrEmpty::Item(fmt) => Some(format!("{fmt:?}")),
526 _ => None,
527 };
528 ("number".to_string(), format, None, vec![])
529 }
530
531 fn extract_integer_type_info(
533 integer_type: &openapiv3::IntegerType,
534 ) -> (String, Option<String>, Option<String>, Vec<String>) {
535 let format = match &integer_type.format {
536 openapiv3::VariantOrUnknownOrEmpty::Item(fmt) => Some(format!("{fmt:?}")),
537 _ => None,
538 };
539 (
540 constants::SCHEMA_TYPE_INTEGER.to_string(),
541 format,
542 None,
543 vec![],
544 )
545 }
546
547 fn extract_format_string(
549 format: &openapiv3::VariantOrUnknownOrEmpty<openapiv3::StringFormat>,
550 ) -> Option<String> {
551 match format {
552 openapiv3::VariantOrUnknownOrEmpty::Item(fmt) => Some(format!("{fmt:?}")),
553 _ => None,
554 }
555 }
556
557 fn transform_response(
559 spec: &OpenAPI,
560 status_code: String,
561 response_ref: &ReferenceOr<openapiv3::Response>,
562 ) -> CachedResponse {
563 let ReferenceOr::Item(response) = response_ref else {
564 return CachedResponse {
565 status_code,
566 description: None,
567 content_type: None,
568 schema: None,
569 example: None,
570 };
571 };
572
573 let description = if response.description.is_empty() {
575 None
576 } else {
577 Some(response.description.clone())
578 };
579
580 let preferred_content_type = if response.content.contains_key(constants::CONTENT_TYPE_JSON)
582 {
583 Some(constants::CONTENT_TYPE_JSON)
584 } else {
585 response.content.keys().next().map(String::as_str)
586 };
587
588 let (content_type, schema, example) =
589 preferred_content_type.map_or((None, None, None), |ct| {
590 let media_type = response.content.get(ct);
591 let schema = media_type.and_then(|mt| {
592 mt.schema
593 .as_ref()
594 .and_then(|schema_ref| Self::resolve_and_serialize_schema(spec, schema_ref))
595 });
596
597 let example = media_type.and_then(|mt| {
599 mt.example
600 .as_ref()
601 .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()))
602 });
603
604 (Some(ct.to_string()), schema, example)
605 });
606
607 CachedResponse {
608 status_code,
609 description,
610 content_type,
611 schema,
612 example,
613 }
614 }
615
616 fn resolve_and_serialize_schema(
618 spec: &OpenAPI,
619 schema_ref: &ReferenceOr<openapiv3::Schema>,
620 ) -> Option<String> {
621 match schema_ref {
622 ReferenceOr::Item(schema) => serde_json::to_string(schema).ok(),
623 ReferenceOr::Reference { reference } => {
624 crate::spec::resolve_schema_reference(spec, reference)
626 .ok()
627 .and_then(|schema| serde_json::to_string(&schema).ok())
628 }
629 }
630 }
631
632 fn transform_request_body(
634 request_body: &ReferenceOr<RequestBody>,
635 ) -> Option<CachedRequestBody> {
636 match request_body {
637 ReferenceOr::Item(body) => {
638 let content_type = if body.content.contains_key(constants::CONTENT_TYPE_JSON) {
640 constants::CONTENT_TYPE_JSON
641 } else {
642 body.content.keys().next()?
643 };
644
645 let media_type = body.content.get(content_type)?;
647 let schema = media_type
648 .schema
649 .as_ref()
650 .and_then(|schema_ref| match schema_ref {
651 ReferenceOr::Item(schema) => serde_json::to_string(schema).ok(),
652 ReferenceOr::Reference { .. } => None,
653 })
654 .unwrap_or_else(|| "{}".to_string());
655
656 let example = media_type
657 .example
658 .as_ref()
659 .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()));
660
661 Some(CachedRequestBody {
662 content_type: content_type.to_string(),
663 schema,
664 required: body.required,
665 description: body.description.clone(),
666 example,
667 })
668 }
669 ReferenceOr::Reference { .. } => None, }
671 }
672
673 fn extract_security_schemes(spec: &OpenAPI) -> HashMap<String, CachedSecurityScheme> {
675 let mut security_schemes = HashMap::new();
676
677 let Some(components) = &spec.components else {
678 return security_schemes;
679 };
680
681 for (name, scheme_ref) in &components.security_schemes {
682 let ReferenceOr::Item(scheme) = scheme_ref else {
683 continue;
684 };
685
686 let Some(cached_scheme) = Self::transform_security_scheme(name, scheme) else {
687 continue;
688 };
689
690 security_schemes.insert(name.clone(), cached_scheme);
691 }
692
693 security_schemes
694 }
695
696 fn transform_security_scheme(
698 name: &str,
699 scheme: &SecurityScheme,
700 ) -> Option<CachedSecurityScheme> {
701 match scheme {
702 SecurityScheme::APIKey {
703 location,
704 name: param_name,
705 description,
706 ..
707 } => {
708 let aperture_secret = Self::extract_aperture_secret(scheme);
709 let location_str = match location {
710 openapiv3::APIKeyLocation::Query => constants::PARAM_LOCATION_QUERY,
711 openapiv3::APIKeyLocation::Header => constants::PARAM_LOCATION_HEADER,
712 openapiv3::APIKeyLocation::Cookie => constants::PARAM_LOCATION_COOKIE,
713 };
714
715 Some(CachedSecurityScheme {
716 name: name.to_string(),
717 scheme_type: constants::AUTH_SCHEME_APIKEY.to_string(),
718 scheme: None,
719 location: Some(location_str.to_string()),
720 parameter_name: Some(param_name.clone()),
721 description: description.clone(),
722 bearer_format: None,
723 aperture_secret,
724 })
725 }
726 SecurityScheme::HTTP {
727 scheme: http_scheme,
728 bearer_format,
729 description,
730 ..
731 } => {
732 let aperture_secret = Self::extract_aperture_secret(scheme);
733 Some(CachedSecurityScheme {
734 name: name.to_string(),
735 scheme_type: constants::SECURITY_TYPE_HTTP.to_string(),
736 scheme: Some(http_scheme.clone()),
737 location: Some(constants::LOCATION_HEADER.to_string()),
738 parameter_name: Some(constants::HEADER_AUTHORIZATION.to_string()),
739 description: description.clone(),
740 bearer_format: bearer_format.clone(),
741 aperture_secret,
742 })
743 }
744 SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => None,
746 }
747 }
748
749 fn extract_aperture_secret(scheme: &SecurityScheme) -> Option<CachedApertureSecret> {
751 let extensions = match scheme {
753 SecurityScheme::APIKey { extensions, .. } | SecurityScheme::HTTP { extensions, .. } => {
754 extensions
755 }
756 SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => return None,
757 };
758
759 extensions
761 .get(crate::constants::EXT_APERTURE_SECRET)
762 .and_then(|value| {
763 let obj = value.as_object()?;
765 let source = obj.get(crate::constants::EXT_KEY_SOURCE)?.as_str()?;
766 let name = obj.get(crate::constants::EXT_KEY_NAME)?.as_str()?;
767
768 if source != constants::SOURCE_ENV {
770 return None;
771 }
772
773 Some(CachedApertureSecret {
774 source: source.to_string(),
775 name: name.to_string(),
776 })
777 })
778 }
779
780 fn resolve_parameter_reference(spec: &OpenAPI, reference: &str) -> Result<Parameter, Error> {
782 crate::spec::resolve_parameter_reference(spec, reference)
783 }
784
785 #[allow(clippy::too_many_lines)]
787 fn generate_command_examples(
788 tag: &str,
789 operation_id: &str,
790 method: &str,
791 path: &str,
792 parameters: &[CachedParameter],
793 request_body: Option<&CachedRequestBody>,
794 ) -> Vec<CommandExample> {
795 let mut examples = Vec::new();
796 let operation_kebab = to_kebab_case(operation_id);
797 let tag_kebab = to_kebab_case(tag);
798
799 let base_cmd = format!("aperture api myapi {tag_kebab} {operation_kebab}");
801
802 let required_params: Vec<&CachedParameter> =
804 parameters.iter().filter(|p| p.required).collect();
805
806 if !required_params.is_empty() {
807 let mut cmd = base_cmd.clone();
808 for param in &required_params {
809 write!(
810 &mut cmd,
811 " --{} {}",
812 param.name,
813 param.example.as_deref().unwrap_or("<value>")
814 )
815 .expect("writing to String cannot fail");
816 }
817
818 examples.push(CommandExample {
819 description: "Basic usage with required parameters".to_string(),
820 command_line: cmd,
821 explanation: Some(format!("{method} {path}")),
822 });
823 }
824
825 if let Some(_body) = request_body {
827 let mut cmd = base_cmd.clone();
828
829 let path_query_params = required_params
831 .iter()
832 .filter(|p| p.location == "path" || p.location == "query");
833
834 for param in path_query_params {
835 write!(
836 &mut cmd,
837 " --{} {}",
838 param.name,
839 param.example.as_deref().unwrap_or("123")
840 )
841 .expect("writing to String cannot fail");
842 }
843
844 cmd.push_str(r#" --body '{"name": "example", "value": 42}'"#);
846
847 examples.push(CommandExample {
848 description: "With request body".to_string(),
849 command_line: cmd,
850 explanation: Some("Sends JSON data in the request body".to_string()),
851 });
852 }
853
854 let optional_params: Vec<&CachedParameter> = parameters
856 .iter()
857 .filter(|p| !p.required && p.location == "query")
858 .take(2) .collect();
860
861 if !optional_params.is_empty() && !required_params.is_empty() {
862 let mut cmd = base_cmd.clone();
863
864 for param in &required_params {
866 write!(
867 &mut cmd,
868 " --{} {}",
869 param.name,
870 param.example.as_deref().unwrap_or("value")
871 )
872 .expect("writing to String cannot fail");
873 }
874
875 for param in &optional_params {
877 write!(
878 &mut cmd,
879 " --{} {}",
880 param.name,
881 param.example.as_deref().unwrap_or("optional")
882 )
883 .expect("writing to String cannot fail");
884 }
885
886 examples.push(CommandExample {
887 description: "With optional parameters".to_string(),
888 command_line: cmd,
889 explanation: Some(
890 "Includes optional query parameters for filtering or customization".to_string(),
891 ),
892 });
893 }
894
895 if examples.is_empty() {
897 examples.push(CommandExample {
898 description: "Basic usage".to_string(),
899 command_line: base_cmd,
900 explanation: Some(format!("Executes {method} {path}")),
901 });
902 }
903
904 examples
905 }
906
907 fn detect_pagination(operation: &Operation, spec: &OpenAPI) -> PaginationInfo {
915 let explicit = operation
917 .extensions
918 .get(constants::EXT_APERTURE_PAGINATION)
919 .and_then(parse_aperture_pagination_extension);
920 if let Some(info) = explicit {
921 return info;
922 }
923
924 if has_link_header_in_responses(&operation.responses, spec) {
926 return PaginationInfo {
927 strategy: PaginationStrategy::LinkHeader,
928 ..Default::default()
929 };
930 }
931
932 if let Some(info) = detect_cursor_from_responses(&operation.responses, spec) {
934 return info;
935 }
936
937 if let Some(info) = detect_offset_from_parameters(&operation.parameters, spec) {
939 return info;
940 }
941
942 PaginationInfo::default()
943 }
944}
945
946impl Default for SpecTransformer {
947 fn default() -> Self {
948 Self::new()
949 }
950}
951
952fn parse_aperture_pagination_extension(value: &serde_json::Value) -> Option<PaginationInfo> {
969 let obj = value.as_object()?;
970 let strategy_str = obj.get("strategy")?.as_str()?;
971
972 match strategy_str {
973 constants::PAGINATION_STRATEGY_CURSOR => {
974 let cursor_field = obj
975 .get("cursor_field")
976 .and_then(|v| v.as_str())
977 .map(String::from);
978 let cursor_param = obj
979 .get("cursor_param")
980 .and_then(|v| v.as_str())
981 .map(String::from);
982 let cursor_param = cursor_param.or_else(|| cursor_field.clone());
984 Some(PaginationInfo {
985 strategy: PaginationStrategy::Cursor,
986 cursor_field,
987 cursor_param,
988 ..Default::default()
989 })
990 }
991 constants::PAGINATION_STRATEGY_OFFSET => {
992 let page_param = obj
993 .get("page_param")
994 .and_then(|v| v.as_str())
995 .map(String::from);
996 let limit_param = obj
997 .get("limit_param")
998 .and_then(|v| v.as_str())
999 .map(String::from);
1000 Some(PaginationInfo {
1001 strategy: PaginationStrategy::Offset,
1002 page_param,
1003 limit_param,
1004 ..Default::default()
1005 })
1006 }
1007 constants::PAGINATION_STRATEGY_LINK_HEADER => Some(PaginationInfo {
1008 strategy: PaginationStrategy::LinkHeader,
1009 ..Default::default()
1010 }),
1011 _ => None,
1012 }
1013}
1014
1015fn has_link_header_in_responses(responses: &openapiv3::Responses, _spec: &OpenAPI) -> bool {
1021 constants::SUCCESS_STATUS_CODES.iter().any(|code| {
1022 let status =
1023 openapiv3::StatusCode::Code(code.parse().expect("hard-coded status codes are valid"));
1024 responses
1025 .responses
1026 .get(&status)
1027 .and_then(|r| {
1028 let openapiv3::ReferenceOr::Item(resp) = r else {
1029 return None;
1030 };
1031 Some(
1032 resp.headers
1033 .keys()
1034 .any(|k| k.eq_ignore_ascii_case(constants::HEADER_LINK)),
1035 )
1036 })
1037 .unwrap_or(false)
1038 })
1039}
1040
1041fn detect_cursor_from_responses(
1049 responses: &openapiv3::Responses,
1050 spec: &OpenAPI,
1051) -> Option<PaginationInfo> {
1052 for code in constants::SUCCESS_STATUS_CODES {
1053 let status =
1054 openapiv3::StatusCode::Code(code.parse().expect("hard-coded status codes are valid"));
1055 let Some(response_ref) = responses.responses.get(&status) else {
1056 continue;
1057 };
1058 let openapiv3::ReferenceOr::Item(response) = response_ref else {
1059 continue;
1060 };
1061
1062 let content_type = response
1064 .content
1065 .contains_key(constants::CONTENT_TYPE_JSON)
1066 .then_some(constants::CONTENT_TYPE_JSON)
1067 .or_else(|| response.content.keys().next().map(String::as_str));
1068 let Some(content_type) = content_type else {
1069 continue;
1070 };
1071
1072 let Some(media_type) = response.content.get(content_type) else {
1073 continue;
1074 };
1075 let Some(schema_ref) = &media_type.schema else {
1076 continue;
1077 };
1078
1079 let schema = match schema_ref {
1081 openapiv3::ReferenceOr::Item(s) => std::borrow::Cow::Borrowed(s),
1082 openapiv3::ReferenceOr::Reference { reference } => {
1083 let Ok(resolved) = crate::spec::resolve_schema_reference(spec, reference) else {
1084 continue;
1085 };
1086 std::borrow::Cow::Owned(resolved)
1087 }
1088 };
1089
1090 if let Some(found) = find_cursor_field_in_schema(&schema.schema_kind) {
1092 return Some(PaginationInfo {
1093 strategy: PaginationStrategy::Cursor,
1094 cursor_field: Some(found.to_string()),
1095 cursor_param: Some(found.to_string()),
1096 ..Default::default()
1097 });
1098 }
1099 }
1100 None
1101}
1102
1103fn find_cursor_field_in_schema(schema_kind: &openapiv3::SchemaKind) -> Option<&'static str> {
1105 let openapiv3::SchemaKind::Type(openapiv3::Type::Object(obj)) = schema_kind else {
1106 return None;
1107 };
1108 constants::PAGINATION_CURSOR_FIELDS
1109 .iter()
1110 .copied()
1111 .find(|field| obj.properties.contains_key(*field))
1112}
1113
1114fn detect_offset_from_parameters(
1116 params: &[openapiv3::ReferenceOr<openapiv3::Parameter>],
1117 spec: &OpenAPI,
1118) -> Option<PaginationInfo> {
1119 let mut page_param: Option<String> = None;
1120 let mut limit_param: Option<String> = None;
1121
1122 for param_ref in params {
1123 let param = match param_ref {
1124 openapiv3::ReferenceOr::Item(p) => std::borrow::Cow::Borrowed(p),
1125 openapiv3::ReferenceOr::Reference { reference } => {
1126 let Ok(resolved) = crate::spec::resolve_parameter_reference(spec, reference) else {
1127 continue;
1128 };
1129 std::borrow::Cow::Owned(resolved)
1130 }
1131 };
1132
1133 let openapiv3::Parameter::Query { parameter_data, .. } = param.as_ref() else {
1135 continue;
1136 };
1137
1138 let name = parameter_data.name.as_str();
1139 match () {
1140 () if constants::PAGINATION_PAGE_PARAMS.contains(&name) => {
1141 page_param = Some(name.to_string());
1142 }
1143 () if constants::PAGINATION_LIMIT_PARAMS.contains(&name) => {
1144 limit_param = Some(name.to_string());
1145 }
1146 () => {}
1147 }
1148 }
1149
1150 if page_param.is_some() {
1151 return Some(PaginationInfo {
1152 strategy: PaginationStrategy::Offset,
1153 page_param,
1154 limit_param,
1155 ..Default::default()
1156 });
1157 }
1158
1159 None
1160}
1161
1162#[cfg(test)]
1163#[allow(clippy::default_trait_access)]
1164#[allow(clippy::field_reassign_with_default)]
1165#[allow(clippy::too_many_lines)]
1166mod tests {
1167 use super::*;
1168 use openapiv3::{
1169 Components, Info, OpenAPI, Operation, Parameter, ParameterData, ParameterSchemaOrContent,
1170 PathItem, ReferenceOr, Responses, Schema, SchemaData, SchemaKind, Type,
1171 };
1172
1173 fn create_test_spec() -> OpenAPI {
1174 OpenAPI {
1175 openapi: "3.0.0".to_string(),
1176 info: Info {
1177 title: "Test API".to_string(),
1178 version: "1.0.0".to_string(),
1179 ..Default::default()
1180 },
1181 servers: vec![openapiv3::Server {
1182 url: "https://api.example.com".to_string(),
1183 ..Default::default()
1184 }],
1185 paths: Default::default(),
1186 ..Default::default()
1187 }
1188 }
1189
1190 #[test]
1191 fn test_transform_basic_spec() {
1192 let transformer = SpecTransformer::new();
1193 let spec = create_test_spec();
1194 let cached = transformer
1195 .transform("test", &spec)
1196 .expect("Transform should succeed");
1197
1198 assert_eq!(cached.name, "test");
1199 assert_eq!(cached.version, "1.0.0");
1200 assert_eq!(cached.base_url, Some("https://api.example.com".to_string()));
1201 assert_eq!(cached.servers.len(), 1);
1202 assert!(cached.commands.is_empty());
1203 assert!(cached.server_variables.is_empty());
1204 }
1205
1206 #[test]
1207 fn test_transform_spec_with_server_variables() {
1208 let mut variables = indexmap::IndexMap::new();
1209 variables.insert(
1210 "region".to_string(),
1211 openapiv3::ServerVariable {
1212 default: "us".to_string(),
1213 description: Some("The regional instance".to_string()),
1214 enumeration: vec!["us".to_string(), "eu".to_string()],
1215 extensions: indexmap::IndexMap::new(),
1216 },
1217 );
1218
1219 let spec = OpenAPI {
1220 openapi: "3.0.0".to_string(),
1221 info: Info {
1222 title: "Test API".to_string(),
1223 version: "1.0.0".to_string(),
1224 ..Default::default()
1225 },
1226 servers: vec![openapiv3::Server {
1227 url: "https://{region}.api.example.com".to_string(),
1228 description: Some("Regional server".to_string()),
1229 variables: Some(variables),
1230 extensions: indexmap::IndexMap::new(),
1231 }],
1232 ..Default::default()
1233 };
1234
1235 let transformer = SpecTransformer::new();
1236 let cached = transformer.transform("test", &spec).unwrap();
1237
1238 assert_eq!(cached.server_variables.len(), 1);
1240 assert!(cached.server_variables.contains_key("region"));
1241
1242 let region_var = &cached.server_variables["region"];
1243 assert_eq!(region_var.default, Some("us".to_string()));
1244 assert_eq!(
1245 region_var.description,
1246 Some("The regional instance".to_string())
1247 );
1248 assert_eq!(
1249 region_var.enum_values,
1250 vec!["us".to_string(), "eu".to_string()]
1251 );
1252
1253 assert_eq!(cached.name, "test");
1255 assert_eq!(
1256 cached.base_url,
1257 Some("https://{region}.api.example.com".to_string())
1258 );
1259 }
1260
1261 #[test]
1262 fn test_transform_spec_with_empty_default_server_variable() {
1263 let mut variables = indexmap::IndexMap::new();
1264 variables.insert(
1265 "prefix".to_string(),
1266 openapiv3::ServerVariable {
1267 default: String::new(), description: Some("Optional prefix".to_string()),
1269 enumeration: vec![],
1270 extensions: indexmap::IndexMap::new(),
1271 },
1272 );
1273
1274 let spec = OpenAPI {
1275 openapi: "3.0.0".to_string(),
1276 info: Info {
1277 title: "Test API".to_string(),
1278 version: "1.0.0".to_string(),
1279 ..Default::default()
1280 },
1281 servers: vec![openapiv3::Server {
1282 url: "https://{prefix}api.example.com".to_string(),
1283 description: Some("Server with empty default".to_string()),
1284 variables: Some(variables),
1285 extensions: indexmap::IndexMap::new(),
1286 }],
1287 ..Default::default()
1288 };
1289
1290 let transformer = SpecTransformer::new();
1291 let cached = transformer.transform("test", &spec).unwrap();
1292
1293 assert!(cached.server_variables.contains_key("prefix"));
1295 let prefix_var = &cached.server_variables["prefix"];
1296 assert_eq!(prefix_var.default, Some(String::new()));
1297 assert_eq!(prefix_var.description, Some("Optional prefix".to_string()));
1298 }
1299
1300 #[test]
1301 fn test_transform_with_operations() {
1302 let transformer = SpecTransformer::new();
1303 let mut spec = create_test_spec();
1304
1305 let mut path_item = PathItem::default();
1306 path_item.get = Some(Operation {
1307 operation_id: Some("getUsers".to_string()),
1308 tags: vec!["users".to_string()],
1309 description: Some("Get all users".to_string()),
1310 responses: Responses::default(),
1311 ..Default::default()
1312 });
1313
1314 spec.paths
1315 .paths
1316 .insert("/users".to_string(), ReferenceOr::Item(path_item));
1317
1318 let cached = transformer
1319 .transform("test", &spec)
1320 .expect("Transform should succeed");
1321
1322 assert_eq!(cached.commands.len(), 1);
1323 let command = &cached.commands[0];
1324 assert_eq!(command.name, "users");
1325 assert_eq!(command.operation_id, "getUsers");
1326 assert_eq!(command.method, constants::HTTP_METHOD_GET);
1327 assert_eq!(command.path, "/users");
1328 assert_eq!(command.description, Some("Get all users".to_string()));
1329 }
1330
1331 #[test]
1332 fn test_transform_with_parameter_reference() {
1333 let transformer = SpecTransformer::new();
1334 let mut spec = create_test_spec();
1335
1336 let mut components = Components::default();
1338 let user_id_param = Parameter::Path {
1339 parameter_data: ParameterData {
1340 name: "userId".to_string(),
1341 description: Some("Unique identifier of the user".to_string()),
1342 required: true,
1343 deprecated: Some(false),
1344 format: ParameterSchemaOrContent::Schema(ReferenceOr::Item(Schema {
1345 schema_data: SchemaData::default(),
1346 schema_kind: SchemaKind::Type(Type::String(Default::default())),
1347 })),
1348 example: None,
1349 examples: Default::default(),
1350 explode: None,
1351 extensions: Default::default(),
1352 },
1353 style: Default::default(),
1354 };
1355 components
1356 .parameters
1357 .insert("userId".to_string(), ReferenceOr::Item(user_id_param));
1358 spec.components = Some(components);
1359
1360 let mut path_item = PathItem::default();
1362 path_item.get = Some(Operation {
1363 operation_id: Some("getUserById".to_string()),
1364 tags: vec!["users".to_string()],
1365 parameters: vec![ReferenceOr::Reference {
1366 reference: "#/components/parameters/userId".to_string(),
1367 }],
1368 responses: Responses::default(),
1369 ..Default::default()
1370 });
1371
1372 spec.paths
1373 .paths
1374 .insert("/users/{userId}".to_string(), ReferenceOr::Item(path_item));
1375
1376 let cached = transformer
1377 .transform("test", &spec)
1378 .expect("Transform should succeed with parameter reference");
1379
1380 assert_eq!(cached.commands.len(), 1);
1382 let command = &cached.commands[0];
1383 assert_eq!(command.parameters.len(), 1);
1384 let param = &command.parameters[0];
1385 assert_eq!(param.name, "userId");
1386 assert_eq!(param.location, constants::PARAM_LOCATION_PATH);
1387 assert!(param.required);
1388 assert_eq!(
1389 param.description,
1390 Some("Unique identifier of the user".to_string())
1391 );
1392 }
1393
1394 #[test]
1395 fn test_transform_with_invalid_parameter_reference() {
1396 let transformer = SpecTransformer::new();
1397 let mut spec = create_test_spec();
1398
1399 let mut path_item = PathItem::default();
1401 path_item.get = Some(Operation {
1402 parameters: vec![ReferenceOr::Reference {
1403 reference: "#/invalid/reference/format".to_string(),
1404 }],
1405 responses: Responses::default(),
1406 ..Default::default()
1407 });
1408
1409 spec.paths
1410 .paths
1411 .insert("/users".to_string(), ReferenceOr::Item(path_item));
1412
1413 let result = transformer.transform("test", &spec);
1414 assert!(result.is_err());
1415 match result.unwrap_err() {
1416 crate::error::Error::Internal {
1417 kind: crate::error::ErrorKind::Validation,
1418 message: msg,
1419 ..
1420 } => {
1421 assert!(msg.contains("Invalid parameter reference format"));
1422 }
1423 _ => panic!("Expected Validation error"),
1424 }
1425 }
1426
1427 #[test]
1428 fn test_transform_with_missing_parameter_reference() {
1429 let transformer = SpecTransformer::new();
1430 let mut spec = create_test_spec();
1431
1432 spec.components = Some(Components::default());
1434
1435 let mut path_item = PathItem::default();
1437 path_item.get = Some(Operation {
1438 parameters: vec![ReferenceOr::Reference {
1439 reference: "#/components/parameters/nonExistent".to_string(),
1440 }],
1441 responses: Responses::default(),
1442 ..Default::default()
1443 });
1444
1445 spec.paths
1446 .paths
1447 .insert("/users".to_string(), ReferenceOr::Item(path_item));
1448
1449 let result = transformer.transform("test", &spec);
1450 assert!(result.is_err());
1451 match result.unwrap_err() {
1452 crate::error::Error::Internal {
1453 kind: crate::error::ErrorKind::Validation,
1454 message: msg,
1455 ..
1456 } => {
1457 assert!(msg.contains("Parameter 'nonExistent' not found in components"));
1458 }
1459 _ => panic!("Expected Validation error"),
1460 }
1461 }
1462
1463 #[test]
1464 fn test_transform_with_nested_parameter_reference() {
1465 let transformer = SpecTransformer::new();
1466 let mut spec = create_test_spec();
1467
1468 let mut components = Components::default();
1469
1470 components.parameters.insert(
1472 "userIdRef".to_string(),
1473 ReferenceOr::Reference {
1474 reference: "#/components/parameters/userId".to_string(),
1475 },
1476 );
1477
1478 let user_id_param = Parameter::Path {
1480 parameter_data: ParameterData {
1481 name: "userId".to_string(),
1482 description: Some("User ID parameter".to_string()),
1483 required: true,
1484 deprecated: Some(false),
1485 format: ParameterSchemaOrContent::Schema(ReferenceOr::Item(Schema {
1486 schema_data: SchemaData::default(),
1487 schema_kind: SchemaKind::Type(Type::String(Default::default())),
1488 })),
1489 example: None,
1490 examples: Default::default(),
1491 explode: None,
1492 extensions: Default::default(),
1493 },
1494 style: Default::default(),
1495 };
1496 components
1497 .parameters
1498 .insert("userId".to_string(), ReferenceOr::Item(user_id_param));
1499 spec.components = Some(components);
1500
1501 let mut path_item = PathItem::default();
1503 path_item.get = Some(Operation {
1504 parameters: vec![ReferenceOr::Reference {
1505 reference: "#/components/parameters/userIdRef".to_string(),
1506 }],
1507 responses: Responses::default(),
1508 ..Default::default()
1509 });
1510
1511 spec.paths
1512 .paths
1513 .insert("/users/{userId}".to_string(), ReferenceOr::Item(path_item));
1514
1515 let cached = transformer
1516 .transform("test", &spec)
1517 .expect("Transform should succeed with nested parameter reference");
1518
1519 assert_eq!(cached.commands.len(), 1);
1521 let command = &cached.commands[0];
1522 assert_eq!(command.parameters.len(), 1);
1523 let param = &command.parameters[0];
1524 assert_eq!(param.name, "userId");
1525 assert_eq!(param.description, Some("User ID parameter".to_string()));
1526 }
1527
1528 #[test]
1529 fn test_transform_with_circular_parameter_reference() {
1530 let transformer = SpecTransformer::new();
1531 let mut spec = create_test_spec();
1532
1533 let mut components = Components::default();
1534
1535 components.parameters.insert(
1537 "paramA".to_string(),
1538 ReferenceOr::Reference {
1539 reference: "#/components/parameters/paramA".to_string(),
1540 },
1541 );
1542
1543 spec.components = Some(components);
1544
1545 let mut path_item = PathItem::default();
1547 path_item.get = Some(Operation {
1548 parameters: vec![ReferenceOr::Reference {
1549 reference: "#/components/parameters/paramA".to_string(),
1550 }],
1551 responses: Responses::default(),
1552 ..Default::default()
1553 });
1554
1555 spec.paths
1556 .paths
1557 .insert("/test".to_string(), ReferenceOr::Item(path_item));
1558
1559 let result = transformer.transform("test", &spec);
1560 assert!(result.is_err());
1561 match result.unwrap_err() {
1562 crate::error::Error::Internal {
1563 kind: crate::error::ErrorKind::Validation,
1564 message: msg,
1565 ..
1566 } => {
1567 assert!(
1568 msg.contains("Circular reference detected"),
1569 "Error message should mention circular reference: {msg}"
1570 );
1571 }
1572 _ => panic!("Expected Validation error for circular reference"),
1573 }
1574 }
1575
1576 #[test]
1577 fn test_transform_with_indirect_circular_reference() {
1578 let transformer = SpecTransformer::new();
1579 let mut spec = create_test_spec();
1580
1581 let mut components = Components::default();
1582
1583 components.parameters.insert(
1585 "paramA".to_string(),
1586 ReferenceOr::Reference {
1587 reference: "#/components/parameters/paramB".to_string(),
1588 },
1589 );
1590
1591 components.parameters.insert(
1592 "paramB".to_string(),
1593 ReferenceOr::Reference {
1594 reference: "#/components/parameters/paramA".to_string(),
1595 },
1596 );
1597
1598 spec.components = Some(components);
1599
1600 let mut path_item = PathItem::default();
1602 path_item.get = Some(Operation {
1603 parameters: vec![ReferenceOr::Reference {
1604 reference: "#/components/parameters/paramA".to_string(),
1605 }],
1606 responses: Responses::default(),
1607 ..Default::default()
1608 });
1609
1610 spec.paths
1611 .paths
1612 .insert("/test".to_string(), ReferenceOr::Item(path_item));
1613
1614 let result = transformer.transform("test", &spec);
1615 assert!(result.is_err());
1616 match result.unwrap_err() {
1617 crate::error::Error::Internal {
1618 kind: crate::error::ErrorKind::Validation,
1619 message: msg,
1620 ..
1621 } => {
1622 assert!(
1623 msg.contains("Circular reference detected") || msg.contains("reference cycle"),
1624 "Error message should mention circular reference: {msg}"
1625 );
1626 }
1627 _ => panic!("Expected Validation error for circular reference"),
1628 }
1629 }
1630
1631 #[test]
1632 fn test_transform_with_complex_circular_reference() {
1633 let transformer = SpecTransformer::new();
1634 let mut spec = create_test_spec();
1635
1636 let mut components = Components::default();
1637
1638 components.parameters.insert(
1640 "paramA".to_string(),
1641 ReferenceOr::Reference {
1642 reference: "#/components/parameters/paramB".to_string(),
1643 },
1644 );
1645
1646 components.parameters.insert(
1647 "paramB".to_string(),
1648 ReferenceOr::Reference {
1649 reference: "#/components/parameters/paramC".to_string(),
1650 },
1651 );
1652
1653 components.parameters.insert(
1654 "paramC".to_string(),
1655 ReferenceOr::Reference {
1656 reference: "#/components/parameters/paramA".to_string(),
1657 },
1658 );
1659
1660 spec.components = Some(components);
1661
1662 let mut path_item = PathItem::default();
1664 path_item.get = Some(Operation {
1665 parameters: vec![ReferenceOr::Reference {
1666 reference: "#/components/parameters/paramA".to_string(),
1667 }],
1668 responses: Responses::default(),
1669 ..Default::default()
1670 });
1671
1672 spec.paths
1673 .paths
1674 .insert("/test".to_string(), ReferenceOr::Item(path_item));
1675
1676 let result = transformer.transform("test", &spec);
1677 assert!(result.is_err());
1678 match result.unwrap_err() {
1679 crate::error::Error::Internal {
1680 kind: crate::error::ErrorKind::Validation,
1681 message: msg,
1682 ..
1683 } => {
1684 assert!(
1685 msg.contains("Circular reference detected") || msg.contains("reference cycle"),
1686 "Error message should mention circular reference: {msg}"
1687 );
1688 }
1689 _ => panic!("Expected Validation error for circular reference"),
1690 }
1691 }
1692
1693 #[test]
1694 fn test_transform_with_depth_limit() {
1695 let transformer = SpecTransformer::new();
1696 let mut spec = create_test_spec();
1697
1698 let mut components = Components::default();
1699
1700 for i in 0..12 {
1702 let param_name = format!("param{i}");
1703 let next_param = format!("param{}", i + 1);
1704
1705 if i < 11 {
1706 components.parameters.insert(
1708 param_name,
1709 ReferenceOr::Reference {
1710 reference: format!("#/components/parameters/{next_param}"),
1711 },
1712 );
1713 } else {
1714 let actual_param = Parameter::Path {
1716 parameter_data: ParameterData {
1717 name: "deepParam".to_string(),
1718 description: Some("Very deeply nested parameter".to_string()),
1719 required: true,
1720 deprecated: Some(false),
1721 format: ParameterSchemaOrContent::Schema(ReferenceOr::Item(Schema {
1722 schema_data: SchemaData::default(),
1723 schema_kind: SchemaKind::Type(Type::String(Default::default())),
1724 })),
1725 example: None,
1726 examples: Default::default(),
1727 explode: None,
1728 extensions: Default::default(),
1729 },
1730 style: Default::default(),
1731 };
1732 components
1733 .parameters
1734 .insert(param_name, ReferenceOr::Item(actual_param));
1735 }
1736 }
1737
1738 spec.components = Some(components);
1739
1740 let mut path_item = PathItem::default();
1742 path_item.get = Some(Operation {
1743 parameters: vec![ReferenceOr::Reference {
1744 reference: "#/components/parameters/param0".to_string(),
1745 }],
1746 responses: Responses::default(),
1747 ..Default::default()
1748 });
1749
1750 spec.paths
1751 .paths
1752 .insert("/test".to_string(), ReferenceOr::Item(path_item));
1753
1754 let result = transformer.transform("test", &spec);
1755 assert!(result.is_err());
1756 match result.unwrap_err() {
1757 crate::error::Error::Internal {
1758 kind: crate::error::ErrorKind::Validation,
1759 message: msg,
1760 ..
1761 } => {
1762 assert!(
1763 msg.contains("Maximum reference depth") && msg.contains("10"),
1764 "Error message should mention depth limit: {msg}"
1765 );
1766 }
1767 _ => panic!("Expected Validation error for depth limit"),
1768 }
1769 }
1770
1771 fn make_operation_with_x_aperture_pagination(value: serde_json::Value) -> Operation {
1774 let mut ext = indexmap::IndexMap::new();
1775 ext.insert(constants::EXT_APERTURE_PAGINATION.to_string(), value);
1776 Operation {
1777 extensions: ext,
1778 responses: Responses::default(),
1779 ..Default::default()
1780 }
1781 }
1782
1783 fn make_spec() -> OpenAPI {
1784 create_test_spec()
1785 }
1786
1787 #[test]
1788 fn test_detect_pagination_explicit_cursor_extension() {
1789 let ext = serde_json::json!({
1790 "strategy": "cursor",
1791 "cursor_field": "next_cursor",
1792 "cursor_param": "after"
1793 });
1794 let op = make_operation_with_x_aperture_pagination(ext);
1795 let spec = make_spec();
1796 let info = SpecTransformer::detect_pagination(&op, &spec);
1797
1798 assert_eq!(info.strategy, PaginationStrategy::Cursor);
1799 assert_eq!(info.cursor_field.as_deref(), Some("next_cursor"));
1800 assert_eq!(info.cursor_param.as_deref(), Some("after"));
1801 }
1802
1803 #[test]
1804 fn test_detect_pagination_explicit_cursor_extension_defaults_cursor_param() {
1805 let ext = serde_json::json!({ "strategy": "cursor", "cursor_field": "after" });
1807 let op = make_operation_with_x_aperture_pagination(ext);
1808 let spec = make_spec();
1809 let info = SpecTransformer::detect_pagination(&op, &spec);
1810
1811 assert_eq!(info.strategy, PaginationStrategy::Cursor);
1812 assert_eq!(info.cursor_field.as_deref(), Some("after"));
1813 assert_eq!(info.cursor_param.as_deref(), Some("after"));
1814 }
1815
1816 #[test]
1817 fn test_detect_pagination_explicit_offset_extension() {
1818 let ext = serde_json::json!({
1819 "strategy": "offset",
1820 "page_param": "page",
1821 "limit_param": "limit"
1822 });
1823 let op = make_operation_with_x_aperture_pagination(ext);
1824 let spec = make_spec();
1825 let info = SpecTransformer::detect_pagination(&op, &spec);
1826
1827 assert_eq!(info.strategy, PaginationStrategy::Offset);
1828 assert_eq!(info.page_param.as_deref(), Some("page"));
1829 assert_eq!(info.limit_param.as_deref(), Some("limit"));
1830 }
1831
1832 #[test]
1833 fn test_detect_pagination_explicit_link_header_extension() {
1834 let ext = serde_json::json!({ "strategy": "link-header" });
1835 let op = make_operation_with_x_aperture_pagination(ext);
1836 let spec = make_spec();
1837 let info = SpecTransformer::detect_pagination(&op, &spec);
1838
1839 assert_eq!(info.strategy, PaginationStrategy::LinkHeader);
1840 }
1841
1842 #[test]
1843 fn test_detect_pagination_link_header_in_response() {
1844 use openapiv3::{
1845 Header, HeaderStyle, ParameterSchemaOrContent, Response, SchemaData, SchemaKind,
1846 StringType, Type,
1847 };
1848 let header = Header {
1849 description: None,
1850 style: HeaderStyle::Simple,
1851 required: false,
1852 deprecated: None,
1853 format: ParameterSchemaOrContent::Schema(ReferenceOr::Item(openapiv3::Schema {
1854 schema_data: SchemaData::default(),
1855 schema_kind: SchemaKind::Type(Type::String(StringType::default())),
1856 })),
1857 example: None,
1858 examples: Default::default(),
1859 extensions: Default::default(),
1860 };
1861 let mut response = Response::default();
1862 response
1863 .headers
1864 .insert("Link".to_string(), ReferenceOr::Item(header));
1865
1866 let mut responses = Responses::default();
1867 responses.responses.insert(
1868 openapiv3::StatusCode::Code(200),
1869 ReferenceOr::Item(response),
1870 );
1871
1872 let op = Operation {
1873 responses,
1874 ..Default::default()
1875 };
1876 let spec = make_spec();
1877 let info = SpecTransformer::detect_pagination(&op, &spec);
1878
1879 assert_eq!(info.strategy, PaginationStrategy::LinkHeader);
1880 }
1881
1882 fn make_string_schema_param(name: &str) -> openapiv3::Parameter {
1883 use openapiv3::{
1884 Parameter, ParameterData, ParameterSchemaOrContent, SchemaData, SchemaKind, StringType,
1885 Type,
1886 };
1887 Parameter::Query {
1888 parameter_data: ParameterData {
1889 name: name.to_string(),
1890 description: None,
1891 required: false,
1892 deprecated: None,
1893 format: ParameterSchemaOrContent::Schema(ReferenceOr::Item(openapiv3::Schema {
1894 schema_data: SchemaData::default(),
1895 schema_kind: SchemaKind::Type(Type::String(StringType::default())),
1896 })),
1897 example: None,
1898 examples: Default::default(),
1899 explode: None,
1900 extensions: Default::default(),
1901 },
1902 allow_reserved: false,
1903 style: Default::default(),
1904 allow_empty_value: None,
1905 }
1906 }
1907
1908 #[test]
1909 fn test_detect_pagination_offset_heuristic_page_param() {
1910 let op = Operation {
1911 parameters: vec![
1912 ReferenceOr::Item(make_string_schema_param("page")),
1913 ReferenceOr::Item(make_string_schema_param("limit")),
1914 ],
1915 responses: Responses::default(),
1916 ..Default::default()
1917 };
1918 let spec = make_spec();
1919 let info = SpecTransformer::detect_pagination(&op, &spec);
1920
1921 assert_eq!(info.strategy, PaginationStrategy::Offset);
1922 assert_eq!(info.page_param.as_deref(), Some("page"));
1923 assert_eq!(info.limit_param.as_deref(), Some("limit"));
1924 }
1925
1926 #[test]
1927 fn test_detect_pagination_no_strategy() {
1928 let op = Operation {
1929 responses: Responses::default(),
1930 ..Default::default()
1931 };
1932 let spec = make_spec();
1933 let info = SpecTransformer::detect_pagination(&op, &spec);
1934
1935 assert_eq!(info.strategy, PaginationStrategy::None);
1936 }
1937}