1use std::path::Path;
2use std::str::FromStr;
3
4use indexmap::IndexMap;
5
6use crate::error::ForgeError;
7use crate::types::{ref_name_from_path, OpenApiSpec, Operation, SchemaObject, SchemaOrRef, TypeInfo};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum RpcCrudVerb {
13 Create,
15 Read,
17 Update,
19 Delete,
21 List,
23}
24
25impl std::fmt::Display for RpcCrudVerb {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 match self {
28 Self::Create => f.write_str("create"),
29 Self::Read => f.write_str("read"),
30 Self::Update => f.write_str("update"),
31 Self::Delete => f.write_str("delete"),
32 Self::List => f.write_str("list"),
33 }
34 }
35}
36
37impl FromStr for RpcCrudVerb {
38 type Err = ForgeError;
39
40 fn from_str(s: &str) -> Result<Self, Self::Err> {
41 match s.to_lowercase().as_str() {
42 "create" => Ok(Self::Create),
43 "read" | "get" | "describe" => Ok(Self::Read),
44 "update" => Ok(Self::Update),
45 "delete" | "remove" => Ok(Self::Delete),
46 "list" => Ok(Self::List),
47 _ => Err(ForgeError::InvalidInput(format!(
48 "unknown CRUD verb: {s}"
49 ))),
50 }
51 }
52}
53
54#[derive(Debug, Clone)]
59pub struct RpcPattern {
60 pub verb: RpcCrudVerb,
61 pub template: String,
64 pub group_name: String,
66}
67
68impl RpcPattern {
69 #[must_use]
71 pub fn new(verb: RpcCrudVerb, template: &str, group_name: &str) -> Self {
72 Self {
73 verb,
74 template: template.to_string(),
75 group_name: group_name.to_string(),
76 }
77 }
78
79 fn try_match(&self, path: &str) -> Option<String> {
81 let path_lower = path.to_lowercase();
82 let template_lower = self.template.to_lowercase();
83
84 if let Some(idx) = template_lower.find("{resource}") {
85 let prefix = &template_lower[..idx];
86 let suffix = &template_lower[idx + "{resource}".len()..];
87
88 if !path_lower.starts_with(prefix) {
89 return None;
90 }
91
92 let rest = &path_lower[prefix.len()..];
93
94 let captured = if suffix.is_empty() {
95 rest.to_string()
96 } else if let Some(end_idx) = rest.find(suffix) {
97 rest[..end_idx].to_string()
98 } else {
99 return None;
100 };
101
102 if captured.is_empty() {
103 return None;
104 }
105
106 let group = self.group_name.replace("{0}", &captured);
107 Some(group.replace('-', "_"))
108 } else {
109 if path_lower == template_lower {
111 Some(self.group_name.replace('-', "_"))
112 } else {
113 None
114 }
115 }
116 }
117}
118
119#[derive(Debug, Clone)]
144pub struct RpcCrudGrouper {
145 patterns: Vec<RpcPattern>,
146}
147
148impl Default for RpcCrudGrouper {
149 fn default() -> Self {
150 Self::new()
151 }
152}
153
154impl RpcCrudGrouper {
155 #[must_use]
157 pub fn new() -> Self {
158 Self {
159 patterns: Vec::new(),
160 }
161 }
162
163 #[must_use]
165 pub fn pattern(mut self, pat: RpcPattern) -> Self {
166 self.patterns.push(pat);
167 self
168 }
169
170 #[must_use]
172 pub fn patterns(mut self, pats: Vec<RpcPattern>) -> Self {
173 self.patterns.extend(pats);
174 self
175 }
176
177 #[must_use]
182 pub fn default_patterns() -> Self {
183 Self::new().patterns(Self::generic_patterns())
184 }
185
186 fn generic_patterns() -> Vec<RpcPattern> {
189 vec![
190 RpcPattern::new(RpcCrudVerb::Create, "/create-{resource}", "{0}"),
191 RpcPattern::new(RpcCrudVerb::Create, "/add-{resource}", "{0}"),
192 RpcPattern::new(RpcCrudVerb::Read, "/get-{resource}", "{0}"),
193 RpcPattern::new(RpcCrudVerb::Read, "/describe-{resource}", "{0}"),
194 RpcPattern::new(RpcCrudVerb::Update, "/update-{resource}", "{0}"),
195 RpcPattern::new(RpcCrudVerb::Delete, "/delete-{resource}", "{0}"),
196 RpcPattern::new(RpcCrudVerb::Delete, "/remove-{resource}", "{0}"),
197 RpcPattern::new(RpcCrudVerb::List, "/list-{resource}", "{0}"),
198 ]
199 }
200
201 #[must_use]
212 #[allow(clippy::too_many_lines)]
213 pub fn akeyless_patterns() -> Self {
214 Self::new().patterns(vec![
215 RpcPattern::new(
217 RpcCrudVerb::Create,
218 "/auth-method-create-{resource}",
219 "auth_method_{0}",
220 ),
221 RpcPattern::new(
222 RpcCrudVerb::Update,
223 "/auth-method-update-{resource}",
224 "auth_method_{0}",
225 ),
226 RpcPattern::new(
228 RpcCrudVerb::Create,
229 "/create-auth-method-{resource}",
230 "auth_method_{0}",
231 ),
232 RpcPattern::new(
233 RpcCrudVerb::Update,
234 "/update-auth-method-{resource}",
235 "auth_method_{0}",
236 ),
237 RpcPattern::new(RpcCrudVerb::Read, "/get-auth-method", "auth_method"),
239 RpcPattern::new(RpcCrudVerb::Delete, "/delete-auth-method", "auth_method"),
240 RpcPattern::new(RpcCrudVerb::List, "/list-auth-methods", "auth_method"),
241 RpcPattern::new(
243 RpcCrudVerb::Create,
244 "/target-create-{resource}",
245 "target_{0}",
246 ),
247 RpcPattern::new(
248 RpcCrudVerb::Update,
249 "/target-update-{resource}",
250 "target_{0}",
251 ),
252 RpcPattern::new(
254 RpcCrudVerb::Create,
255 "/create-{resource}-target",
256 "target_{0}",
257 ),
258 RpcPattern::new(
259 RpcCrudVerb::Update,
260 "/update-{resource}-target",
261 "target_{0}",
262 ),
263 RpcPattern::new(RpcCrudVerb::Read, "/target-get", "target"),
265 RpcPattern::new(RpcCrudVerb::Delete, "/target-delete", "target"),
266 RpcPattern::new(RpcCrudVerb::List, "/list-targets", "target"),
267 RpcPattern::new(
269 RpcCrudVerb::Create,
270 "/dynamic-secret-create-{resource}",
271 "dynamic_secret_{0}",
272 ),
273 RpcPattern::new(
274 RpcCrudVerb::Update,
275 "/dynamic-secret-update-{resource}",
276 "dynamic_secret_{0}",
277 ),
278 RpcPattern::new(
280 RpcCrudVerb::Create,
281 "/create-dynamic-secret-{resource}",
282 "dynamic_secret_{0}",
283 ),
284 RpcPattern::new(
285 RpcCrudVerb::Update,
286 "/update-dynamic-secret-{resource}",
287 "dynamic_secret_{0}",
288 ),
289 RpcPattern::new(RpcCrudVerb::Read, "/dynamic-secret-get", "dynamic_secret"),
291 RpcPattern::new(
292 RpcCrudVerb::Delete,
293 "/dynamic-secret-delete",
294 "dynamic_secret",
295 ),
296 RpcPattern::new(RpcCrudVerb::List, "/list-dynamic-secrets", "dynamic_secret"),
297 RpcPattern::new(
299 RpcCrudVerb::Create,
300 "/rotated-secret-create-{resource}",
301 "rotated_secret_{0}",
302 ),
303 RpcPattern::new(
304 RpcCrudVerb::Update,
305 "/rotated-secret-update-{resource}",
306 "rotated_secret_{0}",
307 ),
308 RpcPattern::new(
310 RpcCrudVerb::Create,
311 "/create-rotated-secret-{resource}",
312 "rotated_secret_{0}",
313 ),
314 RpcPattern::new(
315 RpcCrudVerb::Update,
316 "/update-rotated-secret-{resource}",
317 "rotated_secret_{0}",
318 ),
319 RpcPattern::new(RpcCrudVerb::Read, "/rotated-secret-get", "rotated_secret"),
321 RpcPattern::new(
322 RpcCrudVerb::Delete,
323 "/rotated-secret-delete",
324 "rotated_secret",
325 ),
326 RpcPattern::new(RpcCrudVerb::List, "/list-rotated-secrets", "rotated_secret"),
327 RpcPattern::new(
329 RpcCrudVerb::Create,
330 "/gateway-create-producer-{resource}",
331 "gateway_producer_{0}",
332 ),
333 RpcPattern::new(
334 RpcCrudVerb::Update,
335 "/gateway-update-producer-{resource}",
336 "gateway_producer_{0}",
337 ),
338 RpcPattern::new(
339 RpcCrudVerb::Delete,
340 "/gateway-delete-producer-{resource}",
341 "gateway_producer_{0}",
342 ),
343 RpcPattern::new(RpcCrudVerb::Create, "/create-secret", "static_secret"),
345 RpcPattern::new(RpcCrudVerb::Update, "/update-secret-val", "static_secret"),
346 RpcPattern::new(RpcCrudVerb::Read, "/get-secret-value", "static_secret"),
347 RpcPattern::new(RpcCrudVerb::Read, "/describe-item", "item"),
349 RpcPattern::new(RpcCrudVerb::Delete, "/delete-item", "item"),
350 RpcPattern::new(RpcCrudVerb::List, "/list-items", "item"),
351 ])
352 .patterns(Self::generic_patterns())
353 }
354
355 #[must_use]
360 pub fn group(&self, endpoints: &[Endpoint]) -> Vec<CrudGroup> {
361 let mut groups: IndexMap<String, CrudGroup> = IndexMap::new();
362
363 for ep in endpoints {
364 if let Some((verb, group_name)) = self.match_endpoint(ep) {
365 let group = groups
366 .entry(group_name.clone())
367 .or_insert_with(|| CrudGroup {
368 base_name: group_name,
369 ..CrudGroup::default()
370 });
371
372 match verb {
373 RpcCrudVerb::Create => group.create = Some(ep.clone()),
374 RpcCrudVerb::Read => group.read = Some(ep.clone()),
375 RpcCrudVerb::Update => group.update = Some(ep.clone()),
376 RpcCrudVerb::Delete => group.delete = Some(ep.clone()),
377 RpcCrudVerb::List => group.list = Some(ep.clone()),
378 }
379 }
380 }
381
382 groups.into_values().collect()
383 }
384
385 #[must_use]
387 pub fn group_spec(&self, spec: &Spec) -> Vec<CrudGroup> {
388 self.group(&spec.endpoints())
389 }
390
391 fn match_endpoint(&self, ep: &Endpoint) -> Option<(RpcCrudVerb, String)> {
394 for pat in &self.patterns {
395 if let Some(group_name) = pat.try_match(&ep.path) {
396 return Some((pat.verb, group_name));
397 }
398 }
399 None
400 }
401}
402
403#[derive(Debug, Clone)]
405pub struct Spec {
406 raw: OpenApiSpec,
407}
408
409#[derive(Debug, Clone, PartialEq, Eq)]
411pub struct Endpoint {
412 pub path: String,
414 pub method: String,
416 pub operation_id: Option<String>,
418 pub summary: Option<String>,
420 pub tags: Vec<String>,
422 pub request_schema_ref: Option<String>,
424 pub response_schema_ref: Option<String>,
426}
427
428#[derive(Debug, Clone, PartialEq)]
430pub struct Field {
431 pub name: String,
433 pub type_info: TypeInfo,
435 pub required: bool,
437 pub description: Option<String>,
439 pub default: Option<serde_json::Value>,
441 pub format: Option<String>,
443 pub enum_values: Option<Vec<String>>,
445}
446
447#[derive(Debug, Clone, PartialEq)]
449pub struct SchemaDiff {
450 pub added: Vec<String>,
452 pub removed: Vec<String>,
454 pub changed: Vec<FieldChange>,
456}
457
458#[derive(Debug, Clone, PartialEq)]
460pub struct FieldChange {
461 pub name: String,
463 pub old_type: TypeInfo,
465 pub new_type: TypeInfo,
467 pub required_changed: bool,
469}
470
471#[derive(Debug, Clone, Default, PartialEq, Eq)]
473pub struct CrudGroup {
474 pub base_name: String,
476 pub create: Option<Endpoint>,
478 pub read: Option<Endpoint>,
480 pub update: Option<Endpoint>,
482 pub delete: Option<Endpoint>,
484 pub list: Option<Endpoint>,
486}
487
488impl CrudGroup {
489 #[must_use]
491 pub fn endpoint_count(&self) -> usize {
492 [
493 self.create.is_some(),
494 self.read.is_some(),
495 self.update.is_some(),
496 self.delete.is_some(),
497 self.list.is_some(),
498 ]
499 .iter()
500 .filter(|&&v| v)
501 .count()
502 }
503
504 #[must_use]
506 pub fn is_complete(&self) -> bool {
507 self.endpoint_count() == 5
508 }
509}
510
511impl Spec {
512 pub fn load(path: impl AsRef<Path>) -> Result<Self, ForgeError> {
518 let content = std::fs::read_to_string(path.as_ref())?;
519 Self::parse(&content)
520 }
521
522 pub fn parse(content: &str) -> Result<Self, ForgeError> {
530 let raw: OpenApiSpec = if content.trim_start().starts_with('{') {
531 serde_json::from_str(content)?
532 } else {
533 serde_yaml_ng::from_str(content)?
534 };
535
536 Ok(Self { raw })
537 }
538
539 #[must_use]
541 pub fn endpoints(&self) -> Vec<Endpoint> {
542 self.raw
543 .paths
544 .iter()
545 .flat_map(|(path, item)| {
546 let methods: [(&str, &Option<Operation>); 5] = [
547 ("get", &item.get),
548 ("post", &item.post),
549 ("put", &item.put),
550 ("delete", &item.delete),
551 ("patch", &item.patch),
552 ];
553 methods.into_iter().filter_map(move |(method, op)| {
554 op.as_ref().map(|operation| Endpoint {
555 path: path.clone(),
556 method: method.to_string(),
557 operation_id: operation.operation_id.clone(),
558 summary: operation.summary.clone(),
559 tags: operation.tags.clone(),
560 request_schema_ref: Self::extract_request_ref(operation),
561 response_schema_ref: Self::extract_response_ref(operation),
562 })
563 })
564 })
565 .collect()
566 }
567
568 pub fn schema(&self, name: &str) -> Result<&SchemaObject, ForgeError> {
574 self.raw
575 .components
576 .as_ref()
577 .and_then(|c| c.schemas.get(name))
578 .ok_or_else(|| ForgeError::SchemaNotFound(name.to_string()))
579 }
580
581 #[must_use]
583 pub fn schema_names(&self) -> Vec<&str> {
584 self.raw
585 .components
586 .as_ref()
587 .map(|c| c.schemas.keys().map(String::as_str).collect())
588 .unwrap_or_default()
589 }
590
591 pub fn fields(&self, schema_name: &str) -> Result<Vec<Field>, ForgeError> {
597 let schema = self.schema(schema_name)?;
598 Ok(self.resolve_fields(schema))
599 }
600
601 #[must_use]
605 pub fn resolve_type(&self, schema: &SchemaObject) -> TypeInfo {
606 takumi::schema_to_field_type(schema)
607 }
608
609 #[must_use]
611 pub fn resolve_schema_or_ref_type(&self, schema_or_ref: &SchemaOrRef) -> TypeInfo {
612 match schema_or_ref {
613 SchemaOrRef::Ref { ref_path } => {
614 let name = ref_name_from_path(ref_path).unwrap_or("Unknown");
615 TypeInfo::Object(name.to_string())
616 }
617 SchemaOrRef::Schema(schema) => takumi::schema_to_field_type(schema),
618 }
619 }
620
621 pub fn diff_schemas(&self, name_a: &str, name_b: &str) -> Result<SchemaDiff, ForgeError> {
627 let fields_a = self.fields(name_a)?;
628 let fields_b = self.fields(name_b)?;
629
630 let map_a: IndexMap<&str, &Field> = fields_a.iter().map(|f| (f.name.as_str(), f)).collect();
631 let map_b: IndexMap<&str, &Field> = fields_b.iter().map(|f| (f.name.as_str(), f)).collect();
632
633 let added: Vec<String> = map_b
634 .keys()
635 .filter(|name| !map_a.contains_key(*name))
636 .map(|name| (*name).to_string())
637 .collect();
638
639 let removed: Vec<String> = map_a
640 .keys()
641 .filter(|name| !map_b.contains_key(*name))
642 .map(|name| (*name).to_string())
643 .collect();
644
645 let changed: Vec<FieldChange> = map_b
646 .iter()
647 .filter_map(|(name, field_b)| {
648 let field_a = map_a.get(name)?;
649 if field_a.type_info != field_b.type_info || field_a.required != field_b.required {
650 Some(FieldChange {
651 name: (*name).to_string(),
652 old_type: field_a.type_info.clone(),
653 new_type: field_b.type_info.clone(),
654 required_changed: field_a.required != field_b.required,
655 })
656 } else {
657 None
658 }
659 })
660 .collect();
661
662 Ok(SchemaDiff {
663 added,
664 removed,
665 changed,
666 })
667 }
668
669 #[must_use]
675 pub fn group_by_crud_pattern(&self) -> Vec<CrudGroup> {
676 let endpoints = self.endpoints();
677 let mut groups: IndexMap<String, CrudGroup> = IndexMap::new();
678
679 for ep in &endpoints {
680 let path = ep.path.trim_start_matches('/');
681 let op_id = ep.operation_id.as_deref().unwrap_or(path);
682
683 let (verb, base) = Self::detect_crud_verb(op_id);
685 if base.is_empty() {
686 continue;
687 }
688
689 let group = groups.entry(base.clone()).or_insert_with(|| CrudGroup {
690 base_name: base,
691 ..CrudGroup::default()
692 });
693
694 match verb {
695 CrudVerb::Create => group.create = Some(ep.clone()),
696 CrudVerb::Read => group.read = Some(ep.clone()),
697 CrudVerb::Update => group.update = Some(ep.clone()),
698 CrudVerb::Delete => group.delete = Some(ep.clone()),
699 CrudVerb::List => group.list = Some(ep.clone()),
700 CrudVerb::None => {}
701 }
702 }
703
704 groups.into_values().collect()
705 }
706
707 #[must_use]
709 pub fn endpoint_by_path(&self, path: &str) -> Option<Endpoint> {
710 self.endpoints().into_iter().find(|e| e.path == path)
711 }
712
713 fn resolve_fields(&self, schema: &SchemaObject) -> Vec<Field> {
716 self.resolve_fields_recursive(schema, &mut Vec::new())
717 }
718
719 fn resolve_fields_recursive(
720 &self,
721 schema: &SchemaObject,
722 visited: &mut Vec<String>,
723 ) -> Vec<Field> {
724 let mut fields = Vec::new();
725
726 for item in &schema.all_of {
729 if let Some(ref_path) = &item.ref_path {
730 if let Some(name) = ref_name_from_path(ref_path) {
732 if !visited.contains(&name.to_string()) {
734 visited.push(name.to_string());
735 if let Ok(referenced) = self.schema(name) {
736 fields.extend(self.resolve_fields_recursive(referenced, visited));
737 }
738 }
739 }
740 } else {
741 fields.extend(self.resolve_fields_recursive(item, visited));
743 }
744 }
745
746 for (name, prop) in &schema.properties {
748 let required = schema.required.contains(name);
749 let type_info = takumi::schema_to_field_type(prop);
750
751 let description = prop.description.clone();
752 let default = prop.default.clone();
753 let format = prop.format.clone();
754 let enum_values = prop.enum_values.as_ref().map(|vals| {
755 vals.iter()
756 .map(|v| match v {
757 serde_json::Value::String(s) => s.clone(),
758 other => other.to_string(),
759 })
760 .collect()
761 });
762
763 fields.retain(|f: &Field| f.name != *name);
765
766 fields.push(Field {
767 name: name.clone(),
768 type_info,
769 required,
770 description,
771 default,
772 format,
773 enum_values,
774 });
775 }
776
777 fields
778 }
779
780 fn schema_ref_name(schema: &SchemaObject) -> Option<String> {
781 schema
782 .ref_path
783 .as_ref()
784 .and_then(|r| ref_name_from_path(r))
785 .map(String::from)
786 }
787
788 fn extract_request_ref(op: &Operation) -> Option<String> {
789 let body = op.request_body.as_ref()?;
790 let media = body.content.get("application/json")?;
791 Self::schema_ref_name(media.schema.as_ref()?)
792 }
793
794 fn extract_response_ref(op: &Operation) -> Option<String> {
795 let response = op
796 .responses
797 .get("200")
798 .or_else(|| op.responses.get("201"))
799 .or_else(|| op.responses.get("default"))?;
800 let content = response.content.as_ref()?;
801 let media = content.get("application/json")?;
802 Self::schema_ref_name(media.schema.as_ref()?)
803 }
804}
805
806impl FromStr for Spec {
807 type Err = ForgeError;
808
809 fn from_str(s: &str) -> Result<Self, Self::Err> {
810 Self::parse(s)
811 }
812}
813
814#[derive(Clone, Copy)]
815enum CrudVerb {
816 Create,
817 Read,
818 Update,
819 Delete,
820 List,
821 None,
822}
823
824impl Spec {
825 fn detect_crud_verb(operation_id: &str) -> (CrudVerb, String) {
826 let normalized = operation_id.replace('-', "").to_lowercase();
827
828 let prefixes: &[(&str, CrudVerb)] = &[
829 ("create", CrudVerb::Create),
830 ("add", CrudVerb::Create),
831 ("get", CrudVerb::Read),
832 ("describe", CrudVerb::Read),
833 ("update", CrudVerb::Update),
834 ("delete", CrudVerb::Delete),
835 ("remove", CrudVerb::Delete),
836 ("list", CrudVerb::List),
837 ];
838
839 for &(prefix, verb) in prefixes {
840 if let Some(base) = normalized.strip_prefix(prefix) {
841 let original_base = Self::strip_verb_prefix(operation_id);
842 let name = if original_base.is_empty() {
843 base.to_string()
844 } else {
845 original_base
846 };
847 return (verb, name);
848 }
849 }
850
851 (CrudVerb::None, String::new())
852 }
853
854 fn strip_verb_prefix(operation_id: &str) -> String {
855 const VERB_PREFIXES: &[&str] = &[
856 "create-",
857 "add-",
858 "get-",
859 "describe-",
860 "update-",
861 "delete-",
862 "remove-",
863 "list-",
864 ];
865 for verb in VERB_PREFIXES {
866 if let Some(rest) = operation_id.to_lowercase().strip_prefix(verb) {
867 return rest.to_string();
868 }
869 }
870 String::new()
871 }
872}
873
874#[cfg(test)]
875mod tests {
876 use super::*;
877
878 const MINIMAL_SPEC: &str = r#"
879openapi: "3.0.0"
880info:
881 title: Test API
882 version: "1.0"
883paths:
884 /create-secret:
885 post:
886 operationId: createSecret
887 requestBody:
888 content:
889 application/json:
890 schema:
891 $ref: '#/components/schemas/CreateSecret'
892 responses:
893 "200":
894 description: success
895 content:
896 application/json:
897 schema:
898 $ref: '#/components/schemas/CreateSecretOutput'
899 /get-secret-value:
900 post:
901 operationId: getSecretValue
902 requestBody:
903 content:
904 application/json:
905 schema:
906 $ref: '#/components/schemas/GetSecretValue'
907 responses:
908 "200":
909 description: success
910 content:
911 application/json:
912 schema:
913 $ref: '#/components/schemas/GetSecretValueOutput'
914 /update-secret-val:
915 post:
916 operationId: updateSecretVal
917 requestBody:
918 content:
919 application/json:
920 schema:
921 $ref: '#/components/schemas/UpdateSecretVal'
922 responses:
923 "200":
924 description: success
925 /delete-item:
926 post:
927 operationId: deleteItem
928 requestBody:
929 content:
930 application/json:
931 schema:
932 $ref: '#/components/schemas/DeleteItem'
933 responses:
934 "200":
935 description: success
936components:
937 schemas:
938 CreateSecret:
939 type: object
940 required:
941 - name
942 - value
943 properties:
944 name:
945 type: string
946 description: Secret name
947 value:
948 type: string
949 description: Secret value
950 tags:
951 type: array
952 items:
953 type: string
954 metadata:
955 type: string
956 description: Deprecated
957 token:
958 type: string
959 delete_protection:
960 type: string
961 description: "true/false"
962 CreateSecretOutput:
963 type: object
964 properties:
965 name:
966 type: string
967 GetSecretValue:
968 type: object
969 required:
970 - names
971 properties:
972 names:
973 type: array
974 items:
975 type: string
976 token:
977 type: string
978 GetSecretValueOutput:
979 type: object
980 properties:
981 name:
982 type: string
983 value:
984 type: string
985 type:
986 type: string
987 UpdateSecretVal:
988 type: object
989 required:
990 - name
991 - value
992 properties:
993 name:
994 type: string
995 value:
996 type: string
997 tags:
998 type: array
999 items:
1000 type: string
1001 token:
1002 type: string
1003 DeleteItem:
1004 type: object
1005 required:
1006 - name
1007 properties:
1008 name:
1009 type: string
1010 token:
1011 type: string
1012"#;
1013
1014 #[test]
1015 fn parse_minimal_spec() {
1016 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1017 assert_eq!(spec.endpoints().len(), 4);
1018 }
1019
1020 #[test]
1021 fn resolve_schema_fields() {
1022 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1023 let fields = spec.fields("CreateSecret").expect("fields");
1024 assert!(fields.len() >= 4);
1025
1026 let name_field = fields
1027 .iter()
1028 .find(|f| f.name == "name")
1029 .expect("name field");
1030 assert!(name_field.required);
1031 assert_eq!(name_field.type_info, TypeInfo::String);
1032
1033 let tags_field = fields
1034 .iter()
1035 .find(|f| f.name == "tags")
1036 .expect("tags field");
1037 assert!(!tags_field.required);
1038 assert_eq!(
1039 tags_field.type_info,
1040 TypeInfo::Array(Box::new(TypeInfo::String))
1041 );
1042 }
1043
1044 #[test]
1045 fn diff_schemas() {
1046 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1047 let diff = spec
1048 .diff_schemas("CreateSecret", "UpdateSecretVal")
1049 .expect("diff");
1050 assert!(!diff.removed.is_empty() || !diff.added.is_empty() || !diff.changed.is_empty());
1052 }
1053
1054 #[test]
1055 fn crud_grouping() {
1056 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1057 let groups = spec.group_by_crud_pattern();
1058 assert!(!groups.is_empty());
1059 }
1060
1061 #[test]
1062 fn endpoint_by_path() {
1063 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1064 let ep = spec.endpoint_by_path("/create-secret").expect("found");
1065 assert_eq!(ep.operation_id.as_deref(), Some("createSecret"));
1066 assert_eq!(ep.request_schema_ref.as_deref(), Some("CreateSecret"));
1067 }
1068
1069 #[test]
1070 fn reject_invalid_content() {
1071 let spec_str = "this is definitely not valid yaml or json {{{{";
1072 let result = Spec::from_str(spec_str);
1073 assert!(result.is_err());
1074 }
1075
1076 const ENUM_SPEC: &str = r#"
1079openapi: "3.0.0"
1080info:
1081 title: Enum Test API
1082 version: "1.0"
1083paths: {}
1084components:
1085 schemas:
1086 AccessPermission:
1087 type: object
1088 required:
1089 - permission
1090 properties:
1091 permission:
1092 type: string
1093 enum:
1094 - read
1095 - write
1096 - admin
1097 description: The permission level
1098 status:
1099 type: string
1100 enum:
1101 - active
1102 - inactive
1103 - pending
1104"#;
1105
1106 #[test]
1107 fn enum_values_populated() {
1108 let spec = Spec::from_str(ENUM_SPEC).expect("parse");
1109 let fields = spec.fields("AccessPermission").expect("fields");
1110
1111 let perm = fields
1112 .iter()
1113 .find(|f| f.name == "permission")
1114 .expect("permission field");
1115 assert_eq!(
1116 perm.enum_values,
1117 Some(vec![
1118 "read".to_string(),
1119 "write".to_string(),
1120 "admin".to_string()
1121 ])
1122 );
1123
1124 let status = fields
1125 .iter()
1126 .find(|f| f.name == "status")
1127 .expect("status field");
1128 assert_eq!(
1129 status.enum_values,
1130 Some(vec![
1131 "active".to_string(),
1132 "inactive".to_string(),
1133 "pending".to_string()
1134 ])
1135 );
1136 }
1137
1138 #[test]
1139 fn enum_values_none_when_absent() {
1140 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1141 let fields = spec.fields("CreateSecret").expect("fields");
1142 let name_field = fields
1143 .iter()
1144 .find(|f| f.name == "name")
1145 .expect("name field");
1146 assert!(name_field.enum_values.is_none());
1147 }
1148
1149 const ALLOF_SPEC: &str = r#"
1152openapi: "3.0.0"
1153info:
1154 title: AllOf Test API
1155 version: "1.0"
1156paths: {}
1157components:
1158 schemas:
1159 BaseResource:
1160 type: object
1161 required:
1162 - id
1163 properties:
1164 id:
1165 type: string
1166 description: Resource ID
1167 created_at:
1168 type: string
1169 format: date-time
1170 AuditFields:
1171 type: object
1172 properties:
1173 updated_by:
1174 type: string
1175 audit_trail:
1176 type: array
1177 items:
1178 type: string
1179 NamedResource:
1180 type: object
1181 allOf:
1182 - $ref: '#/components/schemas/BaseResource'
1183 required:
1184 - name
1185 properties:
1186 name:
1187 type: string
1188 description: Resource name
1189 FullResource:
1190 type: object
1191 allOf:
1192 - $ref: '#/components/schemas/NamedResource'
1193 - $ref: '#/components/schemas/AuditFields'
1194 required:
1195 - status
1196 properties:
1197 status:
1198 type: string
1199 enum:
1200 - active
1201 - archived
1202"#;
1203
1204 #[test]
1205 fn allof_single_ref_merges_fields() {
1206 let spec = Spec::from_str(ALLOF_SPEC).expect("parse");
1207 let fields = spec.fields("NamedResource").expect("fields");
1208 let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
1209 assert!(names.contains(&"id"), "should inherit id from BaseResource");
1210 assert!(
1211 names.contains(&"created_at"),
1212 "should inherit created_at from BaseResource"
1213 );
1214 assert!(names.contains(&"name"), "should have own property name");
1215 }
1216
1217 #[test]
1218 fn allof_nested_chain_merges_all() {
1219 let spec = Spec::from_str(ALLOF_SPEC).expect("parse");
1220 let fields = spec.fields("FullResource").expect("fields");
1221 let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
1222
1223 assert!(names.contains(&"id"), "should have id from BaseResource");
1225 assert!(
1226 names.contains(&"created_at"),
1227 "should have created_at from BaseResource"
1228 );
1229 assert!(
1231 names.contains(&"name"),
1232 "should have name from NamedResource"
1233 );
1234 assert!(
1236 names.contains(&"updated_by"),
1237 "should have updated_by from AuditFields"
1238 );
1239 assert!(
1240 names.contains(&"audit_trail"),
1241 "should have audit_trail from AuditFields"
1242 );
1243 assert!(names.contains(&"status"), "should have own status property");
1245 }
1246
1247 #[test]
1248 fn allof_nested_with_enum() {
1249 let spec = Spec::from_str(ALLOF_SPEC).expect("parse");
1250 let fields = spec.fields("FullResource").expect("fields");
1251 let status = fields
1252 .iter()
1253 .find(|f| f.name == "status")
1254 .expect("status field");
1255 assert_eq!(
1256 status.enum_values,
1257 Some(vec!["active".to_string(), "archived".to_string()])
1258 );
1259 }
1260
1261 const AKEYLESS_SPEC: &str = r#"
1264openapi: "3.0.0"
1265info:
1266 title: Akeyless API
1267 version: "2.0"
1268paths:
1269 /auth-method-create-aws-iam:
1270 post:
1271 operationId: authMethodCreateAwsIam
1272 responses:
1273 "200":
1274 description: success
1275 /auth-method-update-aws-iam:
1276 post:
1277 operationId: authMethodUpdateAwsIam
1278 responses:
1279 "200":
1280 description: success
1281 /create-auth-method-azure-ad:
1282 post:
1283 operationId: createAuthMethodAzureAd
1284 responses:
1285 "200":
1286 description: success
1287 /update-auth-method-azure-ad:
1288 post:
1289 operationId: updateAuthMethodAzureAd
1290 responses:
1291 "200":
1292 description: success
1293 /get-auth-method:
1294 post:
1295 operationId: getAuthMethod
1296 responses:
1297 "200":
1298 description: success
1299 /delete-auth-method:
1300 post:
1301 operationId: deleteAuthMethod
1302 responses:
1303 "200":
1304 description: success
1305 /target-create-aws:
1306 post:
1307 operationId: targetCreateAws
1308 responses:
1309 "200":
1310 description: success
1311 /target-update-aws:
1312 post:
1313 operationId: targetUpdateAws
1314 responses:
1315 "200":
1316 description: success
1317 /target-get:
1318 post:
1319 operationId: targetGet
1320 responses:
1321 "200":
1322 description: success
1323 /target-delete:
1324 post:
1325 operationId: targetDelete
1326 responses:
1327 "200":
1328 description: success
1329 /dynamic-secret-create-aws:
1330 post:
1331 operationId: dynamicSecretCreateAws
1332 responses:
1333 "200":
1334 description: success
1335 /dynamic-secret-update-aws:
1336 post:
1337 operationId: dynamicSecretUpdateAws
1338 responses:
1339 "200":
1340 description: success
1341 /dynamic-secret-get:
1342 post:
1343 operationId: dynamicSecretGet
1344 responses:
1345 "200":
1346 description: success
1347 /dynamic-secret-delete:
1348 post:
1349 operationId: dynamicSecretDelete
1350 responses:
1351 "200":
1352 description: success
1353 /create-secret:
1354 post:
1355 operationId: createSecret
1356 responses:
1357 "200":
1358 description: success
1359 /update-secret-val:
1360 post:
1361 operationId: updateSecretVal
1362 responses:
1363 "200":
1364 description: success
1365 /get-secret-value:
1366 post:
1367 operationId: getSecretValue
1368 responses:
1369 "200":
1370 description: success
1371 /describe-item:
1372 post:
1373 operationId: describeItem
1374 responses:
1375 "200":
1376 description: success
1377 /delete-item:
1378 post:
1379 operationId: deleteItem
1380 responses:
1381 "200":
1382 description: success
1383 /gateway-create-producer-aws:
1384 post:
1385 operationId: gatewayCreateProducerAws
1386 responses:
1387 "200":
1388 description: success
1389 /gateway-update-producer-aws:
1390 post:
1391 operationId: gatewayUpdateProducerAws
1392 responses:
1393 "200":
1394 description: success
1395 /rotated-secret-create-mysql:
1396 post:
1397 operationId: rotatedSecretCreateMysql
1398 responses:
1399 "200":
1400 description: success
1401 /rotated-secret-update-mysql:
1402 post:
1403 operationId: rotatedSecretUpdateMysql
1404 responses:
1405 "200":
1406 description: success
1407 /rotated-secret-get:
1408 post:
1409 operationId: rotatedSecretGet
1410 responses:
1411 "200":
1412 description: success
1413 /rotated-secret-delete:
1414 post:
1415 operationId: rotatedSecretDelete
1416 responses:
1417 "200":
1418 description: success
1419components:
1420 schemas: {}
1421"#;
1422
1423 #[test]
1424 fn rpc_grouper_auth_method_pattern_a() {
1425 let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
1426 let grouper = RpcCrudGrouper::akeyless_patterns();
1427 let groups = grouper.group_spec(&spec);
1428
1429 let aws_iam = groups
1430 .iter()
1431 .find(|g| g.base_name == "auth_method_aws_iam")
1432 .expect("auth_method_aws_iam group");
1433 assert!(aws_iam.create.is_some(), "should have create");
1434 assert_eq!(
1435 aws_iam.create.as_ref().unwrap().path,
1436 "/auth-method-create-aws-iam"
1437 );
1438 assert!(aws_iam.update.is_some(), "should have update");
1439 assert_eq!(
1440 aws_iam.update.as_ref().unwrap().path,
1441 "/auth-method-update-aws-iam"
1442 );
1443 }
1444
1445 #[test]
1446 fn rpc_grouper_auth_method_pattern_b() {
1447 let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
1448 let grouper = RpcCrudGrouper::akeyless_patterns();
1449 let groups = grouper.group_spec(&spec);
1450
1451 let azure = groups
1452 .iter()
1453 .find(|g| g.base_name == "auth_method_azure_ad")
1454 .expect("auth_method_azure_ad group");
1455 assert!(azure.create.is_some(), "should have create");
1456 assert_eq!(
1457 azure.create.as_ref().unwrap().path,
1458 "/create-auth-method-azure-ad"
1459 );
1460 assert!(azure.update.is_some(), "should have update");
1461 }
1462
1463 #[test]
1464 fn rpc_grouper_auth_method_shared_read_delete() {
1465 let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
1466 let grouper = RpcCrudGrouper::akeyless_patterns();
1467 let groups = grouper.group_spec(&spec);
1468
1469 let auth = groups
1470 .iter()
1471 .find(|g| g.base_name == "auth_method")
1472 .expect("auth_method group");
1473 assert!(auth.read.is_some(), "should have read");
1474 assert_eq!(auth.read.as_ref().unwrap().path, "/get-auth-method");
1475 assert!(auth.delete.is_some(), "should have delete");
1476 assert_eq!(auth.delete.as_ref().unwrap().path, "/delete-auth-method");
1477 }
1478
1479 #[test]
1480 fn rpc_grouper_targets() {
1481 let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
1482 let grouper = RpcCrudGrouper::akeyless_patterns();
1483 let groups = grouper.group_spec(&spec);
1484
1485 let target_aws = groups
1486 .iter()
1487 .find(|g| g.base_name == "target_aws")
1488 .expect("target_aws group");
1489 assert!(target_aws.create.is_some());
1490 assert_eq!(
1491 target_aws.create.as_ref().unwrap().path,
1492 "/target-create-aws"
1493 );
1494 assert!(target_aws.update.is_some());
1495 assert_eq!(
1496 target_aws.update.as_ref().unwrap().path,
1497 "/target-update-aws"
1498 );
1499
1500 let target = groups
1501 .iter()
1502 .find(|g| g.base_name == "target")
1503 .expect("target group");
1504 assert!(target.read.is_some());
1505 assert_eq!(target.read.as_ref().unwrap().path, "/target-get");
1506 assert!(target.delete.is_some());
1507 assert_eq!(target.delete.as_ref().unwrap().path, "/target-delete");
1508 }
1509
1510 #[test]
1511 fn rpc_grouper_dynamic_secrets() {
1512 let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
1513 let grouper = RpcCrudGrouper::akeyless_patterns();
1514 let groups = grouper.group_spec(&spec);
1515
1516 let ds_aws = groups
1517 .iter()
1518 .find(|g| g.base_name == "dynamic_secret_aws")
1519 .expect("dynamic_secret_aws group");
1520 assert!(ds_aws.create.is_some());
1521 assert_eq!(
1522 ds_aws.create.as_ref().unwrap().path,
1523 "/dynamic-secret-create-aws"
1524 );
1525 assert!(ds_aws.update.is_some());
1526
1527 let ds = groups
1528 .iter()
1529 .find(|g| g.base_name == "dynamic_secret")
1530 .expect("dynamic_secret group");
1531 assert!(ds.read.is_some());
1532 assert_eq!(ds.read.as_ref().unwrap().path, "/dynamic-secret-get");
1533 assert!(ds.delete.is_some());
1534 }
1535
1536 #[test]
1537 fn rpc_grouper_static_secrets() {
1538 let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
1539 let grouper = RpcCrudGrouper::akeyless_patterns();
1540 let groups = grouper.group_spec(&spec);
1541
1542 let ss = groups
1543 .iter()
1544 .find(|g| g.base_name == "static_secret")
1545 .expect("static_secret group");
1546 assert!(ss.create.is_some());
1547 assert_eq!(ss.create.as_ref().unwrap().path, "/create-secret");
1548 assert!(ss.update.is_some());
1549 assert_eq!(ss.update.as_ref().unwrap().path, "/update-secret-val");
1550 assert!(ss.read.is_some());
1551 assert_eq!(ss.read.as_ref().unwrap().path, "/get-secret-value");
1552 }
1553
1554 #[test]
1555 fn rpc_grouper_items() {
1556 let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
1557 let grouper = RpcCrudGrouper::akeyless_patterns();
1558 let groups = grouper.group_spec(&spec);
1559
1560 let item = groups
1561 .iter()
1562 .find(|g| g.base_name == "item")
1563 .expect("item group");
1564 assert!(item.read.is_some());
1565 assert_eq!(item.read.as_ref().unwrap().path, "/describe-item");
1566 assert!(item.delete.is_some());
1567 assert_eq!(item.delete.as_ref().unwrap().path, "/delete-item");
1568 }
1569
1570 #[test]
1571 fn rpc_grouper_gateway_producers() {
1572 let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
1573 let grouper = RpcCrudGrouper::akeyless_patterns();
1574 let groups = grouper.group_spec(&spec);
1575
1576 let gp_aws = groups
1577 .iter()
1578 .find(|g| g.base_name == "gateway_producer_aws")
1579 .expect("gateway_producer_aws group");
1580 assert!(gp_aws.create.is_some());
1581 assert_eq!(
1582 gp_aws.create.as_ref().unwrap().path,
1583 "/gateway-create-producer-aws"
1584 );
1585 assert!(gp_aws.update.is_some());
1586 assert_eq!(
1587 gp_aws.update.as_ref().unwrap().path,
1588 "/gateway-update-producer-aws"
1589 );
1590 }
1591
1592 #[test]
1593 fn rpc_grouper_rotated_secrets() {
1594 let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
1595 let grouper = RpcCrudGrouper::akeyless_patterns();
1596 let groups = grouper.group_spec(&spec);
1597
1598 let rs_mysql = groups
1599 .iter()
1600 .find(|g| g.base_name == "rotated_secret_mysql")
1601 .expect("rotated_secret_mysql group");
1602 assert!(rs_mysql.create.is_some());
1603 assert_eq!(
1604 rs_mysql.create.as_ref().unwrap().path,
1605 "/rotated-secret-create-mysql"
1606 );
1607 assert!(rs_mysql.update.is_some());
1608
1609 let rs = groups
1610 .iter()
1611 .find(|g| g.base_name == "rotated_secret")
1612 .expect("rotated_secret group");
1613 assert!(rs.read.is_some());
1614 assert_eq!(rs.read.as_ref().unwrap().path, "/rotated-secret-get");
1615 assert!(rs.delete.is_some());
1616 }
1617
1618 #[test]
1619 fn rpc_grouper_custom_patterns() {
1620 let spec_str = r#"
1621openapi: "3.0.0"
1622info:
1623 title: Custom API
1624 version: "1.0"
1625paths:
1626 /v1/resource/create:
1627 post:
1628 operationId: resourceCreate
1629 responses:
1630 "200":
1631 description: success
1632 /v1/resource/get:
1633 post:
1634 operationId: resourceGet
1635 responses:
1636 "200":
1637 description: success
1638components:
1639 schemas: {}
1640"#;
1641 let spec = Spec::from_str(spec_str).expect("parse");
1642 let grouper = RpcCrudGrouper::new()
1643 .pattern(RpcPattern::new(
1644 RpcCrudVerb::Create,
1645 "/v1/{resource}/create",
1646 "{0}",
1647 ))
1648 .pattern(RpcPattern::new(
1649 RpcCrudVerb::Read,
1650 "/v1/{resource}/get",
1651 "{0}",
1652 ));
1653 let groups = grouper.group_spec(&spec);
1654 assert_eq!(groups.len(), 1);
1655 assert_eq!(groups[0].base_name, "resource");
1656 assert!(groups[0].create.is_some());
1657 assert!(groups[0].read.is_some());
1658 }
1659
1660 #[test]
1661 fn rpc_grouper_default_patterns() {
1662 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1663 let grouper = RpcCrudGrouper::default_patterns();
1664 let groups = grouper.group_spec(&spec);
1665 assert!(!groups.is_empty());
1668 }
1669
1670 #[test]
1671 fn rpc_pattern_try_match_exact() {
1672 let pat = RpcPattern::new(RpcCrudVerb::Read, "/get-auth-method", "auth_method");
1673 assert_eq!(
1674 pat.try_match("/get-auth-method"),
1675 Some("auth_method".to_string())
1676 );
1677 assert_eq!(pat.try_match("/get-auth-methods"), None);
1678 assert_eq!(pat.try_match("/delete-auth-method"), None);
1679 }
1680
1681 #[test]
1682 fn rpc_pattern_try_match_with_resource() {
1683 let pat = RpcPattern::new(RpcCrudVerb::Create, "/create-{resource}", "{0}");
1684 assert_eq!(pat.try_match("/create-secret"), Some("secret".to_string()));
1685 assert_eq!(
1686 pat.try_match("/create-auth-method"),
1687 Some("auth_method".to_string())
1688 );
1689 }
1690
1691 #[test]
1692 fn rpc_pattern_try_match_prefix_and_suffix() {
1693 let pat = RpcPattern::new(
1694 RpcCrudVerb::Create,
1695 "/create-{resource}-target",
1696 "target_{0}",
1697 );
1698 assert_eq!(
1699 pat.try_match("/create-aws-target"),
1700 Some("target_aws".to_string())
1701 );
1702 assert_eq!(pat.try_match("/create-target"), None);
1703 }
1704
1705 #[test]
1706 fn rpc_grouper_default_impl() {
1707 let grouper = RpcCrudGrouper::default();
1709 assert!(grouper.group(&[]).is_empty());
1710 }
1711
1712 #[test]
1715 fn legacy_crud_grouping_unchanged() {
1716 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1717 let groups = spec.group_by_crud_pattern();
1718 assert!(!groups.is_empty());
1719 let has_create = groups.iter().any(|g| g.create.is_some());
1721 assert!(has_create, "at least one group should have a create");
1722 }
1723
1724 #[test]
1727 fn type_info_enum_variant_from_takumi() {
1728 let enum_type = TypeInfo::Enum {
1730 values: vec!["a".to_string(), "b".to_string()],
1731 underlying: Box::new(TypeInfo::String),
1732 };
1733 assert_eq!(
1734 enum_type,
1735 TypeInfo::Enum {
1736 values: vec!["a".to_string(), "b".to_string()],
1737 underlying: Box::new(TypeInfo::String),
1738 }
1739 );
1740 }
1741
1742 #[test]
1743 fn resolve_type_delegates_to_takumi() {
1744 let spec = Spec::from_str(ENUM_SPEC).expect("parse");
1745 let schema = spec.schema("AccessPermission").expect("schema");
1746 let perm_prop = &schema.properties["permission"];
1747 let type_info = spec.resolve_type(perm_prop);
1748 assert_eq!(
1750 type_info,
1751 TypeInfo::Enum {
1752 values: vec![
1753 "read".to_string(),
1754 "write".to_string(),
1755 "admin".to_string()
1756 ],
1757 underlying: Box::new(TypeInfo::String),
1758 }
1759 );
1760 }
1761
1762 #[test]
1767 fn parse_json_spec() {
1768 let json = r#"{
1769 "openapi": "3.0.0",
1770 "info": { "title": "JSON API", "version": "1.0" },
1771 "paths": {
1772 "/ping": {
1773 "get": {
1774 "operationId": "ping",
1775 "responses": { "200": { "description": "pong" } }
1776 }
1777 }
1778 },
1779 "components": {
1780 "schemas": {
1781 "Pong": {
1782 "type": "object",
1783 "properties": {
1784 "msg": { "type": "string" }
1785 }
1786 }
1787 }
1788 }
1789 }"#;
1790 let spec = Spec::from_str(json).expect("should parse JSON");
1791 assert_eq!(spec.endpoints().len(), 1);
1792 assert_eq!(spec.endpoints()[0].method, "get");
1793 assert_eq!(spec.schema_names(), vec!["Pong"]);
1794 }
1795
1796 #[test]
1797 fn parse_json_with_leading_whitespace() {
1798 let json = " \n\t {\"info\":{\"title\":\"Ws\",\"version\":\"1\"},\"paths\":{}}";
1799 let spec = Spec::from_str(json).expect("should handle leading whitespace before {");
1800 assert!(spec.endpoints().is_empty());
1801 }
1802
1803 #[test]
1804 fn parse_json_invalid_returns_error() {
1805 let result = Spec::from_str("{\"not_a_valid_spec\": true}");
1806 assert!(result.is_err());
1807 }
1808
1809 #[test]
1814 fn load_yaml_file() {
1815 use std::io::Write;
1816 let mut f = tempfile::NamedTempFile::new().expect("create temp");
1817 write!(f, "{MINIMAL_SPEC}").expect("write");
1818 let spec = Spec::load(f.path()).expect("load");
1819 assert_eq!(spec.endpoints().len(), 4);
1820 }
1821
1822 #[test]
1823 fn load_json_file() {
1824 use std::io::Write;
1825 let mut f = tempfile::NamedTempFile::with_suffix(".json").expect("create temp");
1826 let json = r#"{"openapi":"3.0.0","info":{"title":"F","version":"1"},"paths":{}}"#;
1827 write!(f, "{json}").expect("write");
1828 let spec = Spec::load(f.path()).expect("load json file");
1829 assert!(spec.endpoints().is_empty());
1830 }
1831
1832 #[test]
1833 fn load_nonexistent_file_returns_io_error() {
1834 let result = Spec::load(std::path::Path::new("/tmp/__no_such_file_openapi__.yaml"));
1835 assert!(result.is_err());
1836 let err = result.unwrap_err();
1837 assert!(
1838 matches!(err, ForgeError::Io(_)),
1839 "expected Io error, got: {err:?}"
1840 );
1841 }
1842
1843 #[test]
1848 fn schema_not_found_returns_error() {
1849 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1850 let err = spec.schema("NoSuchSchema").unwrap_err();
1851 assert!(
1852 matches!(err, ForgeError::SchemaNotFound(_)),
1853 "expected SchemaNotFound, got: {err:?}"
1854 );
1855 assert!(err.to_string().contains("NoSuchSchema"));
1856 }
1857
1858 #[test]
1859 fn schema_names_returns_all_names() {
1860 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1861 let names = spec.schema_names();
1862 assert!(names.contains(&"CreateSecret"));
1863 assert!(names.contains(&"GetSecretValue"));
1864 assert!(names.contains(&"UpdateSecretVal"));
1865 assert!(names.contains(&"DeleteItem"));
1866 assert!(names.contains(&"CreateSecretOutput"));
1867 assert!(names.contains(&"GetSecretValueOutput"));
1868 }
1869
1870 #[test]
1871 fn schema_names_empty_when_no_components() {
1872 let yaml = r#"
1873openapi: "3.0.0"
1874info:
1875 title: No Components
1876 version: "1.0"
1877paths: {}
1878"#;
1879 let spec = Spec::from_str(yaml).expect("parse");
1880 assert!(spec.schema_names().is_empty());
1881 }
1882
1883 #[test]
1888 fn resolve_schema_or_ref_type_ref_variant() {
1889 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1890 let sor = SchemaOrRef::Ref {
1891 ref_path: "#/components/schemas/CreateSecret".to_string(),
1892 };
1893 let ti = spec.resolve_schema_or_ref_type(&sor);
1894 assert_eq!(ti, TypeInfo::Object("CreateSecret".to_string()));
1895 }
1896
1897 #[test]
1898 fn resolve_schema_or_ref_type_ref_no_slash() {
1899 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1900 let sor = SchemaOrRef::Ref {
1901 ref_path: "Standalone".to_string(),
1902 };
1903 let ti = spec.resolve_schema_or_ref_type(&sor);
1904 assert_eq!(ti, TypeInfo::Object("Standalone".to_string()));
1905 }
1906
1907 #[test]
1908 fn resolve_schema_or_ref_type_schema_variant() {
1909 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1910 let schema = SchemaObject {
1911 schema_type: Some("string".to_string()),
1912 ..SchemaObject::default()
1913 };
1914 let sor = SchemaOrRef::Schema(Box::new(schema));
1915 let ti = spec.resolve_schema_or_ref_type(&sor);
1916 assert_eq!(ti, TypeInfo::String);
1917 }
1918
1919 #[test]
1924 fn diff_schemas_identical_schemas() {
1925 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1926 let diff = spec.diff_schemas("CreateSecret", "CreateSecret").expect("diff");
1927 assert!(diff.added.is_empty());
1928 assert!(diff.removed.is_empty());
1929 assert!(diff.changed.is_empty());
1930 }
1931
1932 #[test]
1933 fn diff_schemas_added_and_removed() {
1934 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1935 let diff = spec
1936 .diff_schemas("CreateSecret", "UpdateSecretVal")
1937 .expect("diff");
1938 assert!(
1939 diff.removed.contains(&"metadata".to_string())
1940 || diff.removed.contains(&"delete_protection".to_string()),
1941 "CreateSecret has fields not in UpdateSecretVal: {:?}",
1942 diff.removed
1943 );
1944 }
1945
1946 #[test]
1947 fn diff_schemas_with_type_change() {
1948 let yaml = r#"
1949openapi: "3.0.0"
1950info:
1951 title: Diff
1952 version: "1.0"
1953paths: {}
1954components:
1955 schemas:
1956 A:
1957 type: object
1958 required:
1959 - x
1960 properties:
1961 x:
1962 type: string
1963 B:
1964 type: object
1965 properties:
1966 x:
1967 type: integer
1968"#;
1969 let spec = Spec::from_str(yaml).expect("parse");
1970 let diff = spec.diff_schemas("A", "B").expect("diff");
1971 assert_eq!(diff.changed.len(), 1);
1972 assert_eq!(diff.changed[0].name, "x");
1973 assert_eq!(diff.changed[0].old_type, TypeInfo::String);
1974 assert_eq!(diff.changed[0].new_type, TypeInfo::Integer);
1975 assert!(diff.changed[0].required_changed);
1976 }
1977
1978 #[test]
1979 fn diff_schemas_first_not_found() {
1980 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1981 let err = spec.diff_schemas("Missing", "CreateSecret").unwrap_err();
1982 assert!(matches!(err, ForgeError::SchemaNotFound(_)));
1983 }
1984
1985 #[test]
1986 fn diff_schemas_second_not_found() {
1987 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1988 let err = spec.diff_schemas("CreateSecret", "Missing").unwrap_err();
1989 assert!(matches!(err, ForgeError::SchemaNotFound(_)));
1990 }
1991
1992 #[test]
1997 fn endpoint_by_path_not_found() {
1998 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
1999 assert!(spec.endpoint_by_path("/no-such-path").is_none());
2000 }
2001
2002 #[test]
2007 fn endpoints_multiple_methods() {
2008 let yaml = r#"
2009openapi: "3.0.0"
2010info:
2011 title: Multi
2012 version: "1.0"
2013paths:
2014 /resource:
2015 get:
2016 operationId: getResource
2017 responses:
2018 "200":
2019 description: ok
2020 post:
2021 operationId: createResource
2022 responses:
2023 "200":
2024 description: ok
2025 put:
2026 operationId: updateResource
2027 responses:
2028 "200":
2029 description: ok
2030 delete:
2031 operationId: deleteResource
2032 responses:
2033 "200":
2034 description: ok
2035 patch:
2036 operationId: patchResource
2037 responses:
2038 "200":
2039 description: ok
2040components:
2041 schemas: {}
2042"#;
2043 let spec = Spec::from_str(yaml).expect("parse");
2044 let eps = spec.endpoints();
2045 assert_eq!(eps.len(), 5);
2046 let methods: Vec<&str> = eps.iter().map(|e| e.method.as_str()).collect();
2047 assert!(methods.contains(&"get"));
2048 assert!(methods.contains(&"post"));
2049 assert!(methods.contains(&"put"));
2050 assert!(methods.contains(&"delete"));
2051 assert!(methods.contains(&"patch"));
2052 }
2053
2054 #[test]
2059 fn endpoints_empty_paths() {
2060 let yaml = r#"
2061openapi: "3.0.0"
2062info:
2063 title: Empty
2064 version: "1.0"
2065paths: {}
2066"#;
2067 let spec = Spec::from_str(yaml).expect("parse");
2068 assert!(spec.endpoints().is_empty());
2069 }
2070
2071 #[test]
2076 fn endpoint_summary_and_tags() {
2077 let yaml = r#"
2078openapi: "3.0.0"
2079info:
2080 title: Tags
2081 version: "1.0"
2082paths:
2083 /items:
2084 get:
2085 operationId: listItems
2086 summary: List all items
2087 tags:
2088 - items
2089 - public
2090 responses:
2091 "200":
2092 description: ok
2093components:
2094 schemas: {}
2095"#;
2096 let spec = Spec::from_str(yaml).expect("parse");
2097 let ep = spec.endpoint_by_path("/items").expect("found");
2098 assert_eq!(ep.summary.as_deref(), Some("List all items"));
2099 assert_eq!(ep.tags, vec!["items", "public"]);
2100 }
2101
2102 #[test]
2103 fn endpoint_response_schema_ref() {
2104 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
2105 let ep = spec.endpoint_by_path("/create-secret").expect("found");
2106 assert_eq!(ep.response_schema_ref.as_deref(), Some("CreateSecretOutput"));
2107 }
2108
2109 #[test]
2110 fn endpoint_no_request_body() {
2111 let yaml = r#"
2112openapi: "3.0.0"
2113info:
2114 title: NoBody
2115 version: "1.0"
2116paths:
2117 /health:
2118 get:
2119 operationId: healthCheck
2120 responses:
2121 "200":
2122 description: ok
2123components:
2124 schemas: {}
2125"#;
2126 let spec = Spec::from_str(yaml).expect("parse");
2127 let ep = spec.endpoint_by_path("/health").expect("found");
2128 assert!(ep.request_schema_ref.is_none());
2129 assert!(ep.response_schema_ref.is_none());
2130 }
2131
2132 #[test]
2137 fn response_ref_fallback_201() {
2138 let yaml = r#"
2139openapi: "3.0.0"
2140info:
2141 title: Resp201
2142 version: "1.0"
2143paths:
2144 /items:
2145 post:
2146 operationId: createItem
2147 responses:
2148 "201":
2149 description: created
2150 content:
2151 application/json:
2152 schema:
2153 $ref: '#/components/schemas/Item'
2154components:
2155 schemas:
2156 Item:
2157 type: object
2158 properties:
2159 id:
2160 type: string
2161"#;
2162 let spec = Spec::from_str(yaml).expect("parse");
2163 let ep = spec.endpoint_by_path("/items").expect("found");
2164 assert_eq!(ep.response_schema_ref.as_deref(), Some("Item"));
2165 }
2166
2167 #[test]
2168 fn response_ref_fallback_default() {
2169 let yaml = r#"
2170openapi: "3.0.0"
2171info:
2172 title: RespDefault
2173 version: "1.0"
2174paths:
2175 /items:
2176 get:
2177 operationId: getItem
2178 responses:
2179 default:
2180 description: default resp
2181 content:
2182 application/json:
2183 schema:
2184 $ref: '#/components/schemas/GenericResp'
2185components:
2186 schemas:
2187 GenericResp:
2188 type: object
2189 properties:
2190 message:
2191 type: string
2192"#;
2193 let spec = Spec::from_str(yaml).expect("parse");
2194 let ep = spec.endpoint_by_path("/items").expect("found");
2195 assert_eq!(ep.response_schema_ref.as_deref(), Some("GenericResp"));
2196 }
2197
2198 #[test]
2203 fn fields_missing_schema_returns_error() {
2204 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
2205 let err = spec.fields("DoesNotExist").unwrap_err();
2206 assert!(matches!(err, ForgeError::SchemaNotFound(_)));
2207 }
2208
2209 #[test]
2210 fn field_description_and_default() {
2211 let yaml = r#"
2212openapi: "3.0.0"
2213info:
2214 title: FieldMeta
2215 version: "1.0"
2216paths: {}
2217components:
2218 schemas:
2219 Config:
2220 type: object
2221 properties:
2222 timeout:
2223 type: integer
2224 description: Request timeout in ms
2225 default: 30000
2226 format: int32
2227"#;
2228 let spec = Spec::from_str(yaml).expect("parse");
2229 let fields = spec.fields("Config").expect("fields");
2230 let timeout = fields.iter().find(|f| f.name == "timeout").expect("timeout");
2231 assert_eq!(timeout.description.as_deref(), Some("Request timeout in ms"));
2232 assert_eq!(timeout.default, Some(serde_json::json!(30000)));
2233 assert_eq!(timeout.format.as_deref(), Some("int32"));
2234 }
2235
2236 #[test]
2237 fn field_required_vs_optional() {
2238 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
2239 let fields = spec.fields("CreateSecret").expect("fields");
2240 let name_f = fields.iter().find(|f| f.name == "name").unwrap();
2241 let value_f = fields.iter().find(|f| f.name == "value").unwrap();
2242 let token_f = fields.iter().find(|f| f.name == "token").unwrap();
2243 assert!(name_f.required);
2244 assert!(value_f.required);
2245 assert!(!token_f.required);
2246 }
2247
2248 #[test]
2253 fn allof_inline_schema_merged() {
2254 let yaml = r#"
2255openapi: "3.0.0"
2256info:
2257 title: AllOfInline
2258 version: "1.0"
2259paths: {}
2260components:
2261 schemas:
2262 Combined:
2263 type: object
2264 allOf:
2265 - type: object
2266 properties:
2267 from_inline:
2268 type: string
2269 properties:
2270 own_field:
2271 type: integer
2272"#;
2273 let spec = Spec::from_str(yaml).expect("parse");
2274 let fields = spec.fields("Combined").expect("fields");
2275 let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
2276 assert!(names.contains(&"from_inline"));
2277 assert!(names.contains(&"own_field"));
2278 }
2279
2280 #[test]
2281 fn allof_property_override_last_wins() {
2282 let yaml = r#"
2283openapi: "3.0.0"
2284info:
2285 title: AllOfOverride
2286 version: "1.0"
2287paths: {}
2288components:
2289 schemas:
2290 Base:
2291 type: object
2292 properties:
2293 field:
2294 type: string
2295 Override:
2296 type: object
2297 allOf:
2298 - $ref: '#/components/schemas/Base'
2299 required:
2300 - field
2301 properties:
2302 field:
2303 type: integer
2304"#;
2305 let spec = Spec::from_str(yaml).expect("parse");
2306 let fields = spec.fields("Override").expect("fields");
2307 let field = fields.iter().find(|f| f.name == "field").expect("field");
2308 assert_eq!(field.type_info, TypeInfo::Integer);
2309 assert!(field.required);
2310 }
2311
2312 #[test]
2317 fn crud_grouping_skips_unrecognized_verbs() {
2318 let yaml = r#"
2319openapi: "3.0.0"
2320info:
2321 title: Unknown Verbs
2322 version: "1.0"
2323paths:
2324 /custom-action:
2325 post:
2326 operationId: customAction
2327 responses:
2328 "200":
2329 description: ok
2330 /create-item:
2331 post:
2332 operationId: createItem
2333 responses:
2334 "200":
2335 description: ok
2336components:
2337 schemas: {}
2338"#;
2339 let spec = Spec::from_str(yaml).expect("parse");
2340 let groups = spec.group_by_crud_pattern();
2341 let has_custom = groups.iter().any(|g| g.base_name.contains("custom"));
2342 assert!(
2343 !has_custom,
2344 "custom-action should not create a CRUD group"
2345 );
2346 let has_item = groups.iter().any(|g| g.create.is_some());
2347 assert!(has_item);
2348 }
2349
2350 #[test]
2351 fn crud_grouping_empty_spec() {
2352 let yaml = r#"
2353openapi: "3.0.0"
2354info:
2355 title: Empty
2356 version: "1.0"
2357paths: {}
2358"#;
2359 let spec = Spec::from_str(yaml).expect("parse");
2360 let groups = spec.group_by_crud_pattern();
2361 assert!(groups.is_empty());
2362 }
2363
2364 #[test]
2365 fn crud_grouping_all_verb_types() {
2366 let yaml = r#"
2367openapi: "3.0.0"
2368info:
2369 title: All Verbs
2370 version: "1.0"
2371paths:
2372 /create-widget:
2373 post:
2374 operationId: createWidget
2375 responses:
2376 "200":
2377 description: ok
2378 /get-widget:
2379 post:
2380 operationId: getWidget
2381 responses:
2382 "200":
2383 description: ok
2384 /update-widget:
2385 post:
2386 operationId: updateWidget
2387 responses:
2388 "200":
2389 description: ok
2390 /delete-widget:
2391 post:
2392 operationId: deleteWidget
2393 responses:
2394 "200":
2395 description: ok
2396 /list-widgets:
2397 post:
2398 operationId: listWidgets
2399 responses:
2400 "200":
2401 description: ok
2402components:
2403 schemas: {}
2404"#;
2405 let spec = Spec::from_str(yaml).expect("parse");
2406 let groups = spec.group_by_crud_pattern();
2407 let widget = groups.iter().find(|g| g.base_name == "widget").expect("widget group");
2408 assert!(widget.create.is_some(), "create");
2409 assert!(widget.read.is_some(), "read");
2410 assert!(widget.update.is_some(), "update");
2411 assert!(widget.delete.is_some(), "delete");
2412 let list_group = groups.iter().find(|g| g.list.is_some());
2413 assert!(list_group.is_some(), "list group should exist");
2414 }
2415
2416 #[test]
2417 fn crud_grouping_add_and_remove_verbs() {
2418 let yaml = r#"
2419openapi: "3.0.0"
2420info:
2421 title: Add Remove
2422 version: "1.0"
2423paths:
2424 /add-user:
2425 post:
2426 operationId: addUser
2427 responses:
2428 "200":
2429 description: ok
2430 /remove-user:
2431 post:
2432 operationId: removeUser
2433 responses:
2434 "200":
2435 description: ok
2436 /describe-user:
2437 post:
2438 operationId: describeUser
2439 responses:
2440 "200":
2441 description: ok
2442components:
2443 schemas: {}
2444"#;
2445 let spec = Spec::from_str(yaml).expect("parse");
2446 let groups = spec.group_by_crud_pattern();
2447 let user = groups.iter().find(|g| g.base_name == "user").expect("user group");
2448 assert!(user.create.is_some(), "add- should map to create");
2449 assert!(user.delete.is_some(), "remove- should map to delete");
2450 assert!(user.read.is_some(), "describe- should map to read");
2451 }
2452
2453 #[test]
2458 fn detect_crud_verb_with_hyphenated_operation_id() {
2459 let (verb, base) = Spec::detect_crud_verb("create-auth-method");
2460 assert!(matches!(verb, CrudVerb::Create));
2461 assert_eq!(base, "auth-method");
2462 }
2463
2464 #[test]
2465 fn detect_crud_verb_no_match() {
2466 let (verb, base) = Spec::detect_crud_verb("custom-action");
2467 assert!(matches!(verb, CrudVerb::None));
2468 assert!(base.is_empty());
2469 }
2470
2471 #[test]
2472 fn strip_verb_prefix_all_verbs() {
2473 assert_eq!(Spec::strip_verb_prefix("create-foo"), "foo");
2474 assert_eq!(Spec::strip_verb_prefix("add-foo"), "foo");
2475 assert_eq!(Spec::strip_verb_prefix("get-foo"), "foo");
2476 assert_eq!(Spec::strip_verb_prefix("describe-foo"), "foo");
2477 assert_eq!(Spec::strip_verb_prefix("update-foo"), "foo");
2478 assert_eq!(Spec::strip_verb_prefix("delete-foo"), "foo");
2479 assert_eq!(Spec::strip_verb_prefix("remove-foo"), "foo");
2480 assert_eq!(Spec::strip_verb_prefix("list-foo"), "foo");
2481 }
2482
2483 #[test]
2484 fn strip_verb_prefix_no_match() {
2485 assert_eq!(Spec::strip_verb_prefix("custom-foo"), "");
2486 }
2487
2488 #[test]
2489 fn strip_verb_prefix_case_insensitive() {
2490 assert_eq!(Spec::strip_verb_prefix("Create-Foo"), "foo");
2491 assert_eq!(Spec::strip_verb_prefix("GET-bar"), "bar");
2492 }
2493
2494 #[test]
2499 fn rpc_pattern_case_insensitive_match() {
2500 let pat = RpcPattern::new(RpcCrudVerb::Create, "/Create-{resource}", "{0}");
2501 assert_eq!(
2502 pat.try_match("/create-secret"),
2503 Some("secret".to_string())
2504 );
2505 assert_eq!(
2506 pat.try_match("/CREATE-SECRET"),
2507 Some("secret".to_string())
2508 );
2509 }
2510
2511 #[test]
2512 fn rpc_pattern_empty_resource_returns_none() {
2513 let pat = RpcPattern::new(RpcCrudVerb::Create, "/create-{resource}", "{0}");
2514 assert_eq!(pat.try_match("/create-"), None);
2515 }
2516
2517 #[test]
2518 fn rpc_pattern_no_resource_placeholder_exact() {
2519 let pat = RpcPattern::new(RpcCrudVerb::Read, "/health-check", "health");
2520 assert_eq!(pat.try_match("/health-check"), Some("health".to_string()));
2521 assert_eq!(pat.try_match("/health-checks"), None);
2522 assert_eq!(pat.try_match("/other"), None);
2523 }
2524
2525 #[test]
2526 fn rpc_pattern_suffix_not_found() {
2527 let pat = RpcPattern::new(
2528 RpcCrudVerb::Create,
2529 "/create-{resource}-target",
2530 "target_{0}",
2531 );
2532 assert_eq!(pat.try_match("/create-aws-bucket"), None);
2533 }
2534
2535 #[test]
2536 fn rpc_pattern_hyphen_to_underscore_in_group() {
2537 let pat = RpcPattern::new(RpcCrudVerb::Create, "/create-{resource}", "{0}");
2538 assert_eq!(
2539 pat.try_match("/create-my-resource"),
2540 Some("my_resource".to_string())
2541 );
2542 }
2543
2544 #[test]
2545 fn rpc_pattern_prefix_no_match() {
2546 let pat = RpcPattern::new(RpcCrudVerb::Create, "/api/create-{resource}", "{0}");
2547 assert_eq!(pat.try_match("/create-foo"), None);
2548 }
2549
2550 #[test]
2555 fn rpc_grouper_patterns_method() {
2556 let grouper = RpcCrudGrouper::new().patterns(vec![
2557 RpcPattern::new(RpcCrudVerb::Create, "/make-{resource}", "{0}"),
2558 RpcPattern::new(RpcCrudVerb::Delete, "/destroy-{resource}", "{0}"),
2559 ]);
2560 let endpoints = vec![
2561 Endpoint {
2562 path: "/make-widget".to_string(),
2563 method: "post".to_string(),
2564 operation_id: Some("makeWidget".to_string()),
2565 summary: None,
2566 tags: vec![],
2567 request_schema_ref: None,
2568 response_schema_ref: None,
2569 },
2570 Endpoint {
2571 path: "/destroy-widget".to_string(),
2572 method: "post".to_string(),
2573 operation_id: Some("destroyWidget".to_string()),
2574 summary: None,
2575 tags: vec![],
2576 request_schema_ref: None,
2577 response_schema_ref: None,
2578 },
2579 ];
2580 let groups = grouper.group(&endpoints);
2581 assert_eq!(groups.len(), 1);
2582 assert_eq!(groups[0].base_name, "widget");
2583 assert!(groups[0].create.is_some());
2584 assert!(groups[0].delete.is_some());
2585 }
2586
2587 #[test]
2588 fn rpc_grouper_unmatched_endpoints_ignored() {
2589 let grouper = RpcCrudGrouper::new().pattern(RpcPattern::new(
2590 RpcCrudVerb::Create,
2591 "/create-{resource}",
2592 "{0}",
2593 ));
2594 let endpoints = vec![
2595 Endpoint {
2596 path: "/create-thing".to_string(),
2597 method: "post".to_string(),
2598 operation_id: None,
2599 summary: None,
2600 tags: vec![],
2601 request_schema_ref: None,
2602 response_schema_ref: None,
2603 },
2604 Endpoint {
2605 path: "/random-path".to_string(),
2606 method: "get".to_string(),
2607 operation_id: None,
2608 summary: None,
2609 tags: vec![],
2610 request_schema_ref: None,
2611 response_schema_ref: None,
2612 },
2613 ];
2614 let groups = grouper.group(&endpoints);
2615 assert_eq!(groups.len(), 1);
2616 assert_eq!(groups[0].base_name, "thing");
2617 }
2618
2619 #[test]
2620 fn rpc_grouper_empty_endpoints() {
2621 let grouper = RpcCrudGrouper::default_patterns();
2622 let groups = grouper.group(&[]);
2623 assert!(groups.is_empty());
2624 }
2625
2626 #[test]
2627 fn rpc_grouper_first_pattern_wins() {
2628 let grouper = RpcCrudGrouper::new()
2629 .pattern(RpcPattern::new(
2630 RpcCrudVerb::Create,
2631 "/create-{resource}",
2632 "specific_{0}",
2633 ))
2634 .pattern(RpcPattern::new(
2635 RpcCrudVerb::Create,
2636 "/create-{resource}",
2637 "fallback_{0}",
2638 ));
2639 let endpoints = vec![Endpoint {
2640 path: "/create-item".to_string(),
2641 method: "post".to_string(),
2642 operation_id: None,
2643 summary: None,
2644 tags: vec![],
2645 request_schema_ref: None,
2646 response_schema_ref: None,
2647 }];
2648 let groups = grouper.group(&endpoints);
2649 assert_eq!(groups[0].base_name, "specific_item");
2650 }
2651
2652 #[test]
2657 fn rpc_grouper_akeyless_list_auth_methods() {
2658 let spec_str = r#"
2659openapi: "3.0.0"
2660info:
2661 title: List Test
2662 version: "1.0"
2663paths:
2664 /list-auth-methods:
2665 post:
2666 operationId: listAuthMethods
2667 responses:
2668 "200":
2669 description: ok
2670 /list-targets:
2671 post:
2672 operationId: listTargets
2673 responses:
2674 "200":
2675 description: ok
2676 /list-items:
2677 post:
2678 operationId: listItems
2679 responses:
2680 "200":
2681 description: ok
2682 /list-dynamic-secrets:
2683 post:
2684 operationId: listDynamicSecrets
2685 responses:
2686 "200":
2687 description: ok
2688 /list-rotated-secrets:
2689 post:
2690 operationId: listRotatedSecrets
2691 responses:
2692 "200":
2693 description: ok
2694components:
2695 schemas: {}
2696"#;
2697 let spec = Spec::from_str(spec_str).expect("parse");
2698 let grouper = RpcCrudGrouper::akeyless_patterns();
2699 let groups = grouper.group_spec(&spec);
2700
2701 let auth = groups.iter().find(|g| g.base_name == "auth_method");
2702 assert!(auth.is_some(), "should have auth_method group for list-auth-methods");
2703 assert!(auth.unwrap().list.is_some());
2704
2705 let target = groups.iter().find(|g| g.base_name == "target");
2706 assert!(target.is_some(), "should have target group for list-targets");
2707 assert!(target.unwrap().list.is_some());
2708
2709 let item = groups.iter().find(|g| g.base_name == "item");
2710 assert!(item.is_some(), "should have item group for list-items");
2711 assert!(item.unwrap().list.is_some());
2712 }
2713
2714 #[test]
2719 fn enum_values_non_string_entries() {
2720 let yaml = r#"
2721openapi: "3.0.0"
2722info:
2723 title: MixedEnum
2724 version: "1.0"
2725paths: {}
2726components:
2727 schemas:
2728 MixedEnum:
2729 type: object
2730 properties:
2731 code:
2732 type: integer
2733 enum:
2734 - 100
2735 - 200
2736 - 300
2737"#;
2738 let spec = Spec::from_str(yaml).expect("parse");
2739 let fields = spec.fields("MixedEnum").expect("fields");
2740 let code = fields.iter().find(|f| f.name == "code").expect("code");
2741 let enums = code.enum_values.as_ref().expect("should have enum_values");
2742 assert_eq!(enums.len(), 3);
2743 assert!(enums.contains(&"100".to_string()));
2744 assert!(enums.contains(&"200".to_string()));
2745 assert!(enums.contains(&"300".to_string()));
2746 }
2747
2748 #[test]
2753 fn crud_grouping_uses_path_when_no_operation_id() {
2754 let yaml = r#"
2755openapi: "3.0.0"
2756info:
2757 title: No OpId
2758 version: "1.0"
2759paths:
2760 /create-thing:
2761 post:
2762 responses:
2763 "200":
2764 description: ok
2765components:
2766 schemas: {}
2767"#;
2768 let spec = Spec::from_str(yaml).expect("parse");
2769 let groups = spec.group_by_crud_pattern();
2770 assert!(!groups.is_empty(), "should still group by path when no operation_id");
2771 let has_create = groups.iter().any(|g| g.create.is_some());
2772 assert!(has_create);
2773 }
2774
2775 #[test]
2780 fn rpc_crud_verb_traits() {
2781 let verb = RpcCrudVerb::Create;
2782 let cloned = verb;
2783 assert_eq!(verb, cloned);
2784 assert_eq!(verb, RpcCrudVerb::Create);
2785 assert_ne!(verb, RpcCrudVerb::Delete);
2786 let dbg = format!("{verb:?}");
2787 assert!(dbg.contains("Create"));
2788 }
2789
2790 #[test]
2795 fn structs_are_debug_and_clone() {
2796 let ep = Endpoint {
2797 path: "/test".to_string(),
2798 method: "get".to_string(),
2799 operation_id: Some("testOp".to_string()),
2800 summary: Some("A summary".to_string()),
2801 tags: vec!["tag1".to_string()],
2802 request_schema_ref: None,
2803 response_schema_ref: Some("Output".to_string()),
2804 };
2805 let cloned = ep.clone();
2806 assert_eq!(cloned.path, "/test");
2807 let dbg = format!("{ep:?}");
2808 assert!(dbg.contains("testOp"));
2809
2810 let field = Field {
2811 name: "x".to_string(),
2812 type_info: TypeInfo::String,
2813 required: true,
2814 description: Some("desc".to_string()),
2815 default: Some(serde_json::json!("default")),
2816 format: Some("date".to_string()),
2817 enum_values: None,
2818 };
2819 let field_cloned = field.clone();
2820 assert_eq!(field_cloned.name, "x");
2821 let _ = format!("{field:?}");
2822
2823 let diff = SchemaDiff {
2824 added: vec!["a".to_string()],
2825 removed: vec!["b".to_string()],
2826 changed: vec![FieldChange {
2827 name: "c".to_string(),
2828 old_type: TypeInfo::String,
2829 new_type: TypeInfo::Integer,
2830 required_changed: true,
2831 }],
2832 };
2833 let diff_cloned = diff.clone();
2834 assert_eq!(diff_cloned.added, vec!["a"]);
2835 let _ = format!("{diff:?}");
2836
2837 let group = CrudGroup {
2838 base_name: "test".to_string(),
2839 create: Some(ep.clone()),
2840 read: None,
2841 update: None,
2842 delete: None,
2843 list: None,
2844 };
2845 let group_cloned = group.clone();
2846 assert_eq!(group_cloned.base_name, "test");
2847 let _ = format!("{group:?}");
2848 }
2849
2850 #[test]
2855 fn crud_group_endpoint_count_empty() {
2856 let group = CrudGroup::default();
2857 assert_eq!(group.endpoint_count(), 0);
2858 assert!(!group.is_complete());
2859 }
2860
2861 #[test]
2862 fn crud_group_endpoint_count_partial() {
2863 let ep = Endpoint {
2864 path: "/test".to_string(),
2865 method: "post".to_string(),
2866 operation_id: None,
2867 summary: None,
2868 tags: vec![],
2869 request_schema_ref: None,
2870 response_schema_ref: None,
2871 };
2872 let group = CrudGroup {
2873 base_name: "test".to_string(),
2874 create: Some(ep.clone()),
2875 read: Some(ep),
2876 ..CrudGroup::default()
2877 };
2878 assert_eq!(group.endpoint_count(), 2);
2879 assert!(!group.is_complete());
2880 }
2881
2882 #[test]
2883 fn crud_group_is_complete() {
2884 let ep = Endpoint {
2885 path: "/test".to_string(),
2886 method: "post".to_string(),
2887 operation_id: None,
2888 summary: None,
2889 tags: vec![],
2890 request_schema_ref: None,
2891 response_schema_ref: None,
2892 };
2893 let group = CrudGroup {
2894 base_name: "test".to_string(),
2895 create: Some(ep.clone()),
2896 read: Some(ep.clone()),
2897 update: Some(ep.clone()),
2898 delete: Some(ep.clone()),
2899 list: Some(ep),
2900 };
2901 assert!(group.is_complete());
2902 assert_eq!(group.endpoint_count(), 5);
2903 }
2904
2905 #[test]
2910 fn schema_names_with_empty_schemas() {
2911 let yaml = r#"
2912openapi: "3.0.0"
2913info:
2914 title: Empty Schemas
2915 version: "1.0"
2916paths: {}
2917components:
2918 schemas: {}
2919"#;
2920 let spec = Spec::from_str(yaml).expect("parse");
2921 assert!(spec.schema_names().is_empty());
2922 }
2923
2924 #[test]
2929 fn request_ref_non_json_content_type() {
2930 let yaml = r#"
2931openapi: "3.0.0"
2932info:
2933 title: NonJson
2934 version: "1.0"
2935paths:
2936 /upload:
2937 post:
2938 operationId: upload
2939 requestBody:
2940 content:
2941 multipart/form-data:
2942 schema:
2943 type: object
2944 responses:
2945 "200":
2946 description: ok
2947components:
2948 schemas: {}
2949"#;
2950 let spec = Spec::from_str(yaml).expect("parse");
2951 let ep = spec.endpoint_by_path("/upload").expect("found");
2952 assert!(
2953 ep.request_schema_ref.is_none(),
2954 "non-JSON content type should not extract schema ref"
2955 );
2956 }
2957
2958 #[test]
2963 fn response_without_content() {
2964 let yaml = r#"
2965openapi: "3.0.0"
2966info:
2967 title: NoContent
2968 version: "1.0"
2969paths:
2970 /delete:
2971 delete:
2972 operationId: deleteItem
2973 responses:
2974 "204":
2975 description: No Content
2976components:
2977 schemas: {}
2978"#;
2979 let spec = Spec::from_str(yaml).expect("parse");
2980 let ep = spec.endpoint_by_path("/delete").expect("found");
2981 assert!(ep.response_schema_ref.is_none());
2982 }
2983
2984 #[test]
2989 fn allof_ref_to_missing_schema_silently_skips() {
2990 let yaml = r#"
2991openapi: "3.0.0"
2992info:
2993 title: MissingRef
2994 version: "1.0"
2995paths: {}
2996components:
2997 schemas:
2998 Derived:
2999 type: object
3000 allOf:
3001 - $ref: '#/components/schemas/DoesNotExist'
3002 properties:
3003 own:
3004 type: string
3005"#;
3006 let spec = Spec::from_str(yaml).expect("parse");
3007 let fields = spec.fields("Derived").expect("fields");
3008 let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
3009 assert!(names.contains(&"own"));
3010 assert!(!names.contains(&"DoesNotExist"));
3011 }
3012
3013 #[test]
3018 fn from_str_trait_yaml() {
3019 let spec: Spec = MINIMAL_SPEC.parse().expect("FromStr should parse YAML");
3020 assert_eq!(spec.endpoints().len(), 4);
3021 }
3022
3023 #[test]
3024 fn from_str_trait_json() {
3025 let json = r#"{"openapi":"3.0.0","info":{"title":"J","version":"1"},"paths":{}}"#;
3026 let spec: Spec = json.parse().expect("FromStr should parse JSON");
3027 assert!(spec.endpoints().is_empty());
3028 }
3029
3030 #[test]
3031 fn from_str_trait_error() {
3032 let result: Result<Spec, _> = "{{totally broken".parse();
3033 assert!(result.is_err());
3034 }
3035
3036 #[test]
3037 fn parse_convenience_method() {
3038 let spec = Spec::parse(MINIMAL_SPEC).expect("parse");
3039 assert_eq!(spec.endpoints().len(), 4);
3040 }
3041
3042 #[test]
3047 fn spec_debug_and_clone() {
3048 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
3049 let cloned = spec.clone();
3050 assert_eq!(cloned.endpoints().len(), spec.endpoints().len());
3051 let dbg = format!("{spec:?}");
3052 assert!(dbg.contains("Spec"));
3053 }
3054
3055 #[test]
3060 fn rpc_crud_grouper_debug_and_clone() {
3061 let grouper = RpcCrudGrouper::default_patterns();
3062 let cloned = grouper.clone();
3063 let dbg = format!("{grouper:?}");
3064 assert!(dbg.contains("RpcCrudGrouper"));
3065 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
3067 let g1 = grouper.group_spec(&spec);
3068 let g2 = cloned.group_spec(&spec);
3069 assert_eq!(g1.len(), g2.len());
3070 }
3071
3072 #[test]
3077 fn rpc_pattern_debug_and_clone() {
3078 let pat = RpcPattern::new(RpcCrudVerb::Create, "/create-{resource}", "{0}");
3079 let cloned = pat.clone();
3080 assert_eq!(cloned.template, "/create-{resource}");
3081 assert_eq!(cloned.group_name, "{0}");
3082 assert_eq!(cloned.verb, RpcCrudVerb::Create);
3083 let dbg = format!("{pat:?}");
3084 assert!(dbg.contains("RpcPattern"));
3085 }
3086
3087 #[test]
3092 fn rpc_crud_verb_all_variants_eq() {
3093 let variants = [
3094 RpcCrudVerb::Create,
3095 RpcCrudVerb::Read,
3096 RpcCrudVerb::Update,
3097 RpcCrudVerb::Delete,
3098 RpcCrudVerb::List,
3099 ];
3100 for (i, v1) in variants.iter().enumerate() {
3101 for (j, v2) in variants.iter().enumerate() {
3102 if i == j {
3103 assert_eq!(v1, v2);
3104 } else {
3105 assert_ne!(v1, v2);
3106 }
3107 }
3108 }
3109 }
3110
3111 #[test]
3116 fn endpoints_request_and_response_refs_complete() {
3117 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
3118 let ep_create = spec.endpoint_by_path("/create-secret").unwrap();
3119 assert_eq!(ep_create.request_schema_ref.as_deref(), Some("CreateSecret"));
3120 assert_eq!(
3121 ep_create.response_schema_ref.as_deref(),
3122 Some("CreateSecretOutput")
3123 );
3124
3125 let ep_get = spec.endpoint_by_path("/get-secret-value").unwrap();
3126 assert_eq!(
3127 ep_get.request_schema_ref.as_deref(),
3128 Some("GetSecretValue")
3129 );
3130 assert_eq!(
3131 ep_get.response_schema_ref.as_deref(),
3132 Some("GetSecretValueOutput")
3133 );
3134
3135 let ep_update = spec.endpoint_by_path("/update-secret-val").unwrap();
3136 assert_eq!(
3137 ep_update.request_schema_ref.as_deref(),
3138 Some("UpdateSecretVal")
3139 );
3140 assert!(
3141 ep_update.response_schema_ref.is_none(),
3142 "update has no response ref"
3143 );
3144
3145 let ep_delete = spec.endpoint_by_path("/delete-item").unwrap();
3146 assert_eq!(ep_delete.request_schema_ref.as_deref(), Some("DeleteItem"));
3147 assert!(ep_delete.response_schema_ref.is_none());
3148 }
3149
3150 #[test]
3155 fn diff_schemas_symmetric_added_removed() {
3156 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
3157 let forward = spec
3158 .diff_schemas("CreateSecret", "UpdateSecretVal")
3159 .expect("diff");
3160 let reverse = spec
3161 .diff_schemas("UpdateSecretVal", "CreateSecret")
3162 .expect("diff");
3163 assert_eq!(forward.added, reverse.removed);
3164 assert_eq!(forward.removed, reverse.added);
3165 }
3166
3167 #[test]
3172 fn resolve_type_integer() {
3173 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
3174 let schema = SchemaObject {
3175 schema_type: Some("integer".to_string()),
3176 ..SchemaObject::default()
3177 };
3178 assert_eq!(spec.resolve_type(&schema), TypeInfo::Integer);
3179 }
3180
3181 #[test]
3182 fn resolve_type_boolean() {
3183 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
3184 let schema = SchemaObject {
3185 schema_type: Some("boolean".to_string()),
3186 ..SchemaObject::default()
3187 };
3188 assert_eq!(spec.resolve_type(&schema), TypeInfo::Boolean);
3189 }
3190
3191 #[test]
3192 fn resolve_type_array_of_integers() {
3193 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
3194 let item_schema = SchemaObject {
3195 schema_type: Some("integer".to_string()),
3196 ..SchemaObject::default()
3197 };
3198 let schema = SchemaObject {
3199 schema_type: Some("array".to_string()),
3200 items: Some(Box::new(item_schema)),
3201 ..SchemaObject::default()
3202 };
3203 assert_eq!(
3204 spec.resolve_type(&schema),
3205 TypeInfo::Array(Box::new(TypeInfo::Integer))
3206 );
3207 }
3208
3209 #[test]
3214 fn resolve_schema_or_ref_type_empty_ref() {
3215 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
3216 let sor = SchemaOrRef::Ref {
3217 ref_path: String::new(),
3218 };
3219 let ti = spec.resolve_schema_or_ref_type(&sor);
3220 assert_eq!(ti, TypeInfo::Object(String::new()));
3221 }
3222
3223 #[test]
3228 fn schema_lookup_returns_correct_schema() {
3229 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
3230 let schema = spec.schema("CreateSecret").unwrap();
3231 assert!(schema.properties.contains_key("name"));
3232 assert!(schema.properties.contains_key("value"));
3233 assert!(schema.required.contains(&"name".to_string()));
3234 }
3235
3236 #[test]
3241 fn rpc_grouper_group_spec_same_as_group() {
3242 let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
3243 let grouper = RpcCrudGrouper::akeyless_patterns();
3244 let via_spec = grouper.group_spec(&spec);
3245 let via_endpoints = grouper.group(&spec.endpoints());
3246 assert_eq!(via_spec.len(), via_endpoints.len());
3247 for (s, e) in via_spec.iter().zip(via_endpoints.iter()) {
3248 assert_eq!(s.base_name, e.base_name);
3249 }
3250 }
3251
3252 #[test]
3257 fn crud_group_verb_coverage() {
3258 let spec = Spec::from_str(AKEYLESS_SPEC).expect("parse");
3259 let grouper = RpcCrudGrouper::akeyless_patterns();
3260 let groups = grouper.group_spec(&spec);
3261
3262 let total_endpoints: usize = groups
3263 .iter()
3264 .map(|g| {
3265 [
3266 g.create.is_some(),
3267 g.read.is_some(),
3268 g.update.is_some(),
3269 g.delete.is_some(),
3270 g.list.is_some(),
3271 ]
3272 .iter()
3273 .filter(|&&v| v)
3274 .count()
3275 })
3276 .sum();
3277 assert!(
3278 total_endpoints > 0,
3279 "should have matched at least some endpoints"
3280 );
3281 }
3282
3283 #[test]
3288 fn rpc_grouper_multiple_verbs_same_resource() {
3289 let grouper = RpcCrudGrouper::default_patterns();
3290 let endpoints = vec![
3291 Endpoint {
3292 path: "/create-widget".to_string(),
3293 method: "post".to_string(),
3294 operation_id: None,
3295 summary: None,
3296 tags: vec![],
3297 request_schema_ref: None,
3298 response_schema_ref: None,
3299 },
3300 Endpoint {
3301 path: "/get-widget".to_string(),
3302 method: "get".to_string(),
3303 operation_id: None,
3304 summary: None,
3305 tags: vec![],
3306 request_schema_ref: None,
3307 response_schema_ref: None,
3308 },
3309 Endpoint {
3310 path: "/update-widget".to_string(),
3311 method: "put".to_string(),
3312 operation_id: None,
3313 summary: None,
3314 tags: vec![],
3315 request_schema_ref: None,
3316 response_schema_ref: None,
3317 },
3318 Endpoint {
3319 path: "/delete-widget".to_string(),
3320 method: "delete".to_string(),
3321 operation_id: None,
3322 summary: None,
3323 tags: vec![],
3324 request_schema_ref: None,
3325 response_schema_ref: None,
3326 },
3327 Endpoint {
3328 path: "/list-widget".to_string(),
3329 method: "get".to_string(),
3330 operation_id: None,
3331 summary: None,
3332 tags: vec![],
3333 request_schema_ref: None,
3334 response_schema_ref: None,
3335 },
3336 ];
3337 let groups = grouper.group(&endpoints);
3338 assert_eq!(groups.len(), 1);
3339 let w = &groups[0];
3340 assert_eq!(w.base_name, "widget");
3341 assert!(w.create.is_some());
3342 assert!(w.read.is_some());
3343 assert!(w.update.is_some());
3344 assert!(w.delete.is_some());
3345 assert!(w.list.is_some());
3346 }
3347
3348 #[test]
3353 fn forge_error_source_io() {
3354 use std::error::Error;
3355 let inner = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
3356 let err = ForgeError::Io(inner);
3357 assert!(err.source().is_some());
3358 }
3359
3360 #[test]
3361 fn forge_error_source_yaml() {
3362 use std::error::Error;
3363 let yaml_err = serde_yaml_ng::from_str::<serde_json::Value>("{{bad")
3364 .expect_err("should fail");
3365 let err = ForgeError::Yaml(yaml_err);
3366 assert!(err.source().is_some());
3367 }
3368
3369 #[test]
3370 fn forge_error_source_json() {
3371 use std::error::Error;
3372 let json_err = serde_json::from_str::<serde_json::Value>("not json")
3373 .expect_err("should fail");
3374 let err = ForgeError::Json(json_err);
3375 assert!(err.source().is_some());
3376 }
3377
3378 #[test]
3379 fn forge_error_source_schema_not_found() {
3380 use std::error::Error;
3381 let err = ForgeError::SchemaNotFound("X".into());
3382 assert!(err.source().is_none());
3383 }
3384
3385 #[test]
3386 fn forge_error_source_unresolved_ref() {
3387 use std::error::Error;
3388 let err = ForgeError::UnresolvedRef("X".into());
3389 assert!(err.source().is_none());
3390 }
3391
3392 #[test]
3393 fn forge_error_source_unsupported_version() {
3394 use std::error::Error;
3395 let err = ForgeError::UnsupportedVersion("2.0".into());
3396 assert!(err.source().is_none());
3397 }
3398
3399 #[test]
3404 fn detect_crud_verb_get() {
3405 let (verb, base) = Spec::detect_crud_verb("get-user");
3406 assert!(matches!(verb, CrudVerb::Read));
3407 assert_eq!(base, "user");
3408 }
3409
3410 #[test]
3411 fn detect_crud_verb_describe() {
3412 let (verb, base) = Spec::detect_crud_verb("describe-item");
3413 assert!(matches!(verb, CrudVerb::Read));
3414 assert_eq!(base, "item");
3415 }
3416
3417 #[test]
3418 fn detect_crud_verb_update() {
3419 let (verb, base) = Spec::detect_crud_verb("update-config");
3420 assert!(matches!(verb, CrudVerb::Update));
3421 assert_eq!(base, "config");
3422 }
3423
3424 #[test]
3425 fn detect_crud_verb_delete() {
3426 let (verb, base) = Spec::detect_crud_verb("delete-entry");
3427 assert!(matches!(verb, CrudVerb::Delete));
3428 assert_eq!(base, "entry");
3429 }
3430
3431 #[test]
3432 fn detect_crud_verb_remove() {
3433 let (verb, base) = Spec::detect_crud_verb("remove-member");
3434 assert!(matches!(verb, CrudVerb::Delete));
3435 assert_eq!(base, "member");
3436 }
3437
3438 #[test]
3439 fn detect_crud_verb_add() {
3440 let (verb, base) = Spec::detect_crud_verb("add-member");
3441 assert!(matches!(verb, CrudVerb::Create));
3442 assert_eq!(base, "member");
3443 }
3444
3445 #[test]
3446 fn detect_crud_verb_list() {
3447 let (verb, base) = Spec::detect_crud_verb("list-users");
3448 assert!(matches!(verb, CrudVerb::List));
3449 assert_eq!(base, "users");
3450 }
3451
3452 #[test]
3453 fn detect_crud_verb_camel_case() {
3454 let (verb, base) = Spec::detect_crud_verb("createUser");
3455 assert!(matches!(verb, CrudVerb::Create));
3456 assert!(!base.is_empty());
3457 }
3458
3459 #[test]
3464 fn fields_nested_object_type() {
3465 let yaml = r#"
3466openapi: "3.0.0"
3467info:
3468 title: Nested
3469 version: "1.0"
3470paths: {}
3471components:
3472 schemas:
3473 Outer:
3474 type: object
3475 properties:
3476 inner:
3477 type: object
3478 properties:
3479 value:
3480 type: string
3481"#;
3482 let spec = Spec::from_str(yaml).expect("parse");
3483 let fields = spec.fields("Outer").expect("fields");
3484 assert_eq!(fields.len(), 1);
3485 assert_eq!(fields[0].name, "inner");
3486 }
3487
3488 #[test]
3493 fn resolve_type_number() {
3494 let spec = Spec::from_str(MINIMAL_SPEC).expect("parse");
3495 let schema = SchemaObject {
3496 schema_type: Some("number".to_string()),
3497 ..SchemaObject::default()
3498 };
3499 let ti = spec.resolve_type(&schema);
3500 assert_eq!(ti, TypeInfo::Number);
3501 }
3502
3503 #[test]
3508 fn endpoint_method_ordering_is_deterministic() {
3509 let yaml = r#"
3510openapi: "3.0.0"
3511info:
3512 title: Order
3513 version: "1.0"
3514paths:
3515 /resource:
3516 get:
3517 operationId: getR
3518 responses:
3519 "200":
3520 description: ok
3521 post:
3522 operationId: postR
3523 responses:
3524 "200":
3525 description: ok
3526 put:
3527 operationId: putR
3528 responses:
3529 "200":
3530 description: ok
3531 delete:
3532 operationId: deleteR
3533 responses:
3534 "200":
3535 description: ok
3536 patch:
3537 operationId: patchR
3538 responses:
3539 "200":
3540 description: ok
3541components:
3542 schemas: {}
3543"#;
3544 let spec = Spec::from_str(yaml).expect("parse");
3545 let eps = spec.endpoints();
3546 let methods: Vec<&str> = eps.iter().map(|e| e.method.as_str()).collect();
3547 assert_eq!(methods, vec!["get", "post", "put", "delete", "patch"]);
3548 }
3549
3550 #[test]
3555 fn multiple_paths_all_enumerated() {
3556 let yaml = r#"
3557openapi: "3.0.0"
3558info:
3559 title: Multi
3560 version: "1.0"
3561paths:
3562 /a:
3563 get:
3564 operationId: getA
3565 responses:
3566 "200":
3567 description: ok
3568 /b:
3569 post:
3570 operationId: postB
3571 responses:
3572 "200":
3573 description: ok
3574 /c:
3575 put:
3576 operationId: putC
3577 responses:
3578 "200":
3579 description: ok
3580components:
3581 schemas: {}
3582"#;
3583 let spec = Spec::from_str(yaml).expect("parse");
3584 let eps = spec.endpoints();
3585 assert_eq!(eps.len(), 3);
3586 let paths: Vec<&str> = eps.iter().map(|e| e.path.as_str()).collect();
3587 assert!(paths.contains(&"/a"));
3588 assert!(paths.contains(&"/b"));
3589 assert!(paths.contains(&"/c"));
3590 }
3591
3592 #[test]
3597 #[allow(deprecated)]
3598 fn schema_or_ref_from_schema_ref_name_round_trip() {
3599 let schema = sekkei::Schema {
3600 ref_path: Some("#/components/schemas/Test".to_string()),
3601 ..sekkei::Schema::default()
3602 };
3603 let sor = SchemaOrRef::from_schema(&schema);
3604 assert_eq!(sor.ref_name(), Some("Test"));
3605 }
3606
3607 #[test]
3612 fn rpc_grouper_target_pattern_b() {
3613 let spec_str = r#"
3614openapi: "3.0.0"
3615info:
3616 title: Target B
3617 version: "1.0"
3618paths:
3619 /create-gke-target:
3620 post:
3621 operationId: createGkeTarget
3622 responses:
3623 "200":
3624 description: ok
3625 /update-gke-target:
3626 post:
3627 operationId: updateGkeTarget
3628 responses:
3629 "200":
3630 description: ok
3631components:
3632 schemas: {}
3633"#;
3634 let spec = Spec::from_str(spec_str).expect("parse");
3635 let grouper = RpcCrudGrouper::akeyless_patterns();
3636 let groups = grouper.group_spec(&spec);
3637 let tgt = groups
3638 .iter()
3639 .find(|g| g.base_name == "target_gke")
3640 .expect("target_gke group");
3641 assert!(tgt.create.is_some());
3642 assert!(tgt.update.is_some());
3643 }
3644
3645 #[test]
3650 fn rpc_grouper_dynamic_secret_pattern_b() {
3651 let spec_str = r#"
3652openapi: "3.0.0"
3653info:
3654 title: DynSec B
3655 version: "1.0"
3656paths:
3657 /create-dynamic-secret-mysql:
3658 post:
3659 operationId: createDynamicSecretMysql
3660 responses:
3661 "200":
3662 description: ok
3663 /update-dynamic-secret-mysql:
3664 post:
3665 operationId: updateDynamicSecretMysql
3666 responses:
3667 "200":
3668 description: ok
3669components:
3670 schemas: {}
3671"#;
3672 let spec = Spec::from_str(spec_str).expect("parse");
3673 let grouper = RpcCrudGrouper::akeyless_patterns();
3674 let groups = grouper.group_spec(&spec);
3675 let ds = groups
3676 .iter()
3677 .find(|g| g.base_name == "dynamic_secret_mysql")
3678 .expect("dynamic_secret_mysql group");
3679 assert!(ds.create.is_some());
3680 assert!(ds.update.is_some());
3681 }
3682
3683 #[test]
3688 fn rpc_grouper_rotated_secret_pattern_b() {
3689 let spec_str = r#"
3690openapi: "3.0.0"
3691info:
3692 title: RotSec B
3693 version: "1.0"
3694paths:
3695 /create-rotated-secret-postgres:
3696 post:
3697 operationId: createRotatedSecretPostgres
3698 responses:
3699 "200":
3700 description: ok
3701 /update-rotated-secret-postgres:
3702 post:
3703 operationId: updateRotatedSecretPostgres
3704 responses:
3705 "200":
3706 description: ok
3707components:
3708 schemas: {}
3709"#;
3710 let spec = Spec::from_str(spec_str).expect("parse");
3711 let grouper = RpcCrudGrouper::akeyless_patterns();
3712 let groups = grouper.group_spec(&spec);
3713 let rs = groups
3714 .iter()
3715 .find(|g| g.base_name == "rotated_secret_postgres")
3716 .expect("rotated_secret_postgres group");
3717 assert!(rs.create.is_some());
3718 assert!(rs.update.is_some());
3719 }
3720
3721 #[test]
3726 fn rpc_grouper_gateway_delete_producer() {
3727 let spec_str = r#"
3728openapi: "3.0.0"
3729info:
3730 title: GW Delete
3731 version: "1.0"
3732paths:
3733 /gateway-delete-producer-aws:
3734 post:
3735 operationId: gatewayDeleteProducerAws
3736 responses:
3737 "200":
3738 description: ok
3739components:
3740 schemas: {}
3741"#;
3742 let spec = Spec::from_str(spec_str).expect("parse");
3743 let grouper = RpcCrudGrouper::akeyless_patterns();
3744 let groups = grouper.group_spec(&spec);
3745 let gp = groups
3746 .iter()
3747 .find(|g| g.base_name == "gateway_producer_aws")
3748 .expect("gateway_producer_aws group");
3749 assert!(gp.delete.is_some());
3750 }
3751
3752 #[test]
3757 fn rpc_crud_verb_display_round_trip() {
3758 let verbs = [
3759 RpcCrudVerb::Create,
3760 RpcCrudVerb::Read,
3761 RpcCrudVerb::Update,
3762 RpcCrudVerb::Delete,
3763 RpcCrudVerb::List,
3764 ];
3765 for verb in verbs {
3766 let s = verb.to_string();
3767 let parsed: RpcCrudVerb = s.parse().expect("round-trip parse");
3768 assert_eq!(parsed, verb);
3769 }
3770 }
3771
3772 #[test]
3773 fn rpc_crud_verb_from_str_aliases() {
3774 assert_eq!("get".parse::<RpcCrudVerb>().unwrap(), RpcCrudVerb::Read);
3775 assert_eq!(
3776 "describe".parse::<RpcCrudVerb>().unwrap(),
3777 RpcCrudVerb::Read
3778 );
3779 assert_eq!(
3780 "remove".parse::<RpcCrudVerb>().unwrap(),
3781 RpcCrudVerb::Delete
3782 );
3783 }
3784
3785 #[test]
3786 fn rpc_crud_verb_from_str_case_insensitive() {
3787 assert_eq!(
3788 "CREATE".parse::<RpcCrudVerb>().unwrap(),
3789 RpcCrudVerb::Create
3790 );
3791 assert_eq!(
3792 "Delete".parse::<RpcCrudVerb>().unwrap(),
3793 RpcCrudVerb::Delete
3794 );
3795 }
3796
3797 #[test]
3798 fn rpc_crud_verb_from_str_invalid() {
3799 let err = "foobar".parse::<RpcCrudVerb>().unwrap_err();
3800 let msg = err.to_string();
3801 assert!(msg.contains("unknown CRUD verb"));
3802 }
3803}