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