1use std::collections::{HashMap, HashSet};
40
41use convert_case::{Case, Casing};
42use tracing::warn;
43
44use crate::Result;
45use crate::google::api::FieldBehavior;
46use crate::parsing::types::BaseType;
47use crate::parsing::{CodeGenMetadata, MessageField, MethodMetadata, ServiceInfo};
48use crate::utils::strings;
49
50pub(crate) use types::MethodPlanner;
51pub use types::{
52 BodyField, GenerationPlan, ManagedResource, MethodPlan, PathParam, QueryParam, RequestParam,
53 RequestType, ResourceHierarchy, ServicePlan, SkippedMethod, extract_managed_resources,
54 split_body_fields,
55};
56
57mod types;
58
59pub fn analyze_metadata(metadata: &CodeGenMetadata) -> Result<GenerationPlan> {
69 let mut plans = Vec::new();
71 let mut skipped_methods = Vec::new();
72 for service_info in metadata.services.values() {
73 let (plan, skipped) = analyze_service(metadata, service_info)?;
74 plans.push(plan);
75 skipped_methods.extend(skipped);
76 }
77
78 let global_map = build_global_parent_map(&plans, metadata);
80 let services = plans
81 .into_iter()
82 .map(|mut plan| {
83 plan.hierarchy = derive_ordered_hierarchy(&plan, &global_map, metadata)?;
84 Ok(plan)
85 })
86 .collect::<Result<Vec<_>>>()?;
87
88 Ok(GenerationPlan {
89 services,
90 skipped_methods,
91 })
92}
93
94type GlobalParentMap = HashMap<(String, String), String>;
99
100fn build_global_parent_map(plans: &[ServicePlan], metadata: &CodeGenMetadata) -> GlobalParentMap {
109 let mut map: GlobalParentMap = HashMap::new();
110
111 for plan in plans {
112 let managed_type = match plan.managed_resources.first() {
113 Some(r) => &r.descriptor.r#type,
114 None => continue,
115 };
116 if managed_type.is_empty() {
117 continue;
118 }
119
120 for method in &plan.methods {
121 if method.request_type != RequestType::List {
122 continue;
123 }
124 for param in method.query_parameters() {
125 let Some(ref rr) = param.resource_reference else {
126 continue;
127 };
128 if rr.child_type != *managed_type {
130 continue;
131 }
132 if let Some(parent_type) = resolve_parent_type_from_field(¶m.name, metadata) {
133 map.entry((parent_type, managed_type.clone()))
134 .or_insert_with(|| param.name.clone());
135 }
136 }
137 }
138 }
139
140 map
141}
142
143fn derive_ordered_hierarchy(
150 plan: &ServicePlan,
151 global_map: &GlobalParentMap,
152 metadata: &CodeGenMetadata,
153) -> Result<Vec<ResourceHierarchy>> {
154 let managed_type = match plan.managed_resources.first() {
155 Some(r) => r.descriptor.r#type.clone(),
156 None => return Ok(vec![]),
157 };
158
159 let mut ancestors: Vec<(usize, String, String)> = global_map
161 .iter()
162 .filter(|((_, child), _)| child == &managed_type)
163 .map(|((parent_type, _), field_name)| {
164 let depth = compute_depth(parent_type, global_map, &mut HashSet::new())?;
165 Ok((depth, parent_type.clone(), field_name.clone()))
166 })
167 .collect::<Result<Vec<_>>>()?;
168
169 if ancestors.is_empty() {
170 return Ok(vec![]);
171 }
172
173 ancestors.sort_by_key(|(depth, _, _)| *depth);
175
176 Ok(ancestors
177 .into_iter()
178 .map(|(_, parent_type, field_name)| {
179 let parent_singular = field_name
180 .strip_suffix("_name")
181 .and_then(|s| metadata.resource_from_singular(s))
182 .map(|_| field_name.strip_suffix("_name").unwrap().to_string());
183 ResourceHierarchy {
184 child_resource_type: managed_type.clone(),
185 parent_resource_type: parent_type,
186 parent_field_name: field_name,
187 parent_singular,
188 }
189 })
190 .collect())
191}
192
193fn compute_depth(
197 resource_type: &str,
198 map: &GlobalParentMap,
199 visited: &mut HashSet<String>,
200) -> Result<usize> {
201 if !visited.insert(resource_type.to_string()) {
202 return Err(crate::Error::Build(format!(
203 "Cycle detected in resource hierarchy at type: {resource_type}"
204 )));
205 }
206 let parent = map
207 .iter()
208 .find(|((_, child), _)| child == resource_type)
209 .map(|((parent, _), _)| parent.clone());
210
211 match parent {
212 None => Ok(0),
213 Some(parent_type) => Ok(1 + compute_depth(&parent_type, map, visited)?),
214 }
215}
216
217fn resolve_parent_type_from_field(field_name: &str, metadata: &CodeGenMetadata) -> Option<String> {
222 field_name
223 .strip_suffix("_name")
224 .and_then(|s| metadata.resource_from_singular(s))
225 .map(|rd| rd.r#type.clone())
226}
227
228fn analyze_service(
232 metadata: &CodeGenMetadata,
233 info: &ServiceInfo,
234) -> Result<(ServicePlan, Vec<SkippedMethod>)> {
235 let handler_name = strings::service_to_handler_name(&info.name);
236 let base_path = strings::service_to_base_path(&info.name);
237
238 let mut method_plans = Vec::new();
239 let mut skipped = Vec::new();
240
241 for method in &info.methods {
242 if let Some(method_plan) = analyze_method(metadata, method)? {
243 method_plans.push(method_plan);
244 } else {
245 warn!(
246 "Skipping method {}.{} - incomplete metadata",
247 info.name, method.method_name
248 );
249 skipped.push(SkippedMethod {
250 service_name: info.name.clone(),
251 method_name: method.method_name.clone(),
252 reason: "missing HTTP annotation".to_string(),
253 });
254 }
255 }
256
257 let managed_resources = types::extract_managed_resources(metadata, &method_plans);
258
259 Ok((
260 ServicePlan {
261 service_name: info.name.clone(),
262 handler_name,
263 base_path,
264 package: info.package.clone(),
265 methods: method_plans,
266 managed_resources,
267 documentation: info.documentation.clone(),
268 hierarchy: vec![], },
270 skipped,
271 ))
272}
273
274pub(crate) fn analyze_method(
278 metadata: &CodeGenMetadata,
279 method: &MethodMetadata,
280) -> Result<Option<MethodPlan>> {
281 let http_method = match method.http_method() {
282 Some(m) => m.to_string(),
283 None => {
284 warn!(
285 "Method {}.{} missing HTTP info",
286 method.service_name, method.method_name
287 );
288 return Ok(None);
289 }
290 };
291
292 let planner = MethodPlanner::try_new(method, metadata)?;
293 let request_type = planner.request_type();
294 let has_response = planner.has_response();
295 let output_resource_type = planner.output_resource_type();
296 let http_pattern = planner.into_http_pattern();
297
298 let input_fields = metadata.get_message_fields(&method.input_type);
299 let (path_params, query_params, body_fields) = extract_request_fields(method, &input_fields)?;
300
301 let parameters = path_params
302 .into_iter()
303 .map(Into::into)
304 .chain(query_params.into_iter().map(Into::into))
305 .chain(body_fields.into_iter().map(Into::into))
306 .collect();
307
308 Ok(Some(MethodPlan {
309 metadata: method.clone(),
310 handler_function_name: method.method_name.to_case(Case::Snake),
311 http_method,
312 parameters,
313 has_response,
314 request_type,
315 output_resource_type,
316 http_pattern,
317 }))
318}
319
320fn extract_request_fields(
330 method: &MethodMetadata,
331 input_fields: &[MessageField],
332) -> Result<(Vec<PathParam>, Vec<QueryParam>, Vec<BodyField>)> {
333 let mut path_params = Vec::new();
334 let mut query_params = Vec::new();
335 let mut body_fields = Vec::new();
336
337 let path_param_names = method.http_pattern.parameter_names();
338 let body_spec = method.http_rule.body.as_str();
339
340 let fields_by_name: HashMap<&str, &MessageField> =
342 input_fields.iter().map(|f| (f.name.as_str(), f)).collect();
343
344 let mut processed_fields = HashSet::new();
345
346 for path_param_name in path_param_names {
348 if let Some(field) = fields_by_name.get(path_param_name.as_str()) {
349 path_params.push(PathParam {
352 name: field.name.clone(),
353 field_type: field.unified_type.clone(),
354 documentation: field.documentation.clone(),
355 });
356 processed_fields.insert(field.name.as_str());
357 }
358 }
359
360 for field in input_fields {
362 let field_name = field.name.as_str();
363
364 if processed_fields.contains(field_name) {
365 continue;
366 }
367
368 if field.field_behavior.contains(&FieldBehavior::OutputOnly) {
370 processed_fields.insert(field_name);
371 continue;
372 }
373
374 if matches!(field.unified_type.base_type, BaseType::OneOf(_)) {
376 body_fields.push(BodyField {
377 name: field.name.clone(),
378 field_type: field.unified_type.clone().optional(),
379 repeated: false,
380 oneof_variants: field.oneof_variants.clone(),
381 documentation: field.documentation.clone(),
382 });
383 processed_fields.insert(field_name);
384 continue;
385 }
386
387 let is_body = match body_spec {
388 "*" => true,
389 "" => false,
390 specific => specific == field_name,
391 };
392
393 if is_body {
394 body_fields.push(BodyField {
395 name: field.name.clone(),
396 field_type: field.unified_type.clone(),
397 repeated: field.unified_type.is_repeated,
398 oneof_variants: None,
399 documentation: field.documentation.clone(),
400 });
401 } else {
402 query_params.push(QueryParam {
403 name: field.name.clone(),
404 field_type: field.unified_type.clone(),
405 documentation: field.documentation.clone(),
406 resource_reference: field.resource_reference.clone(),
407 });
408 }
409 processed_fields.insert(field_name);
410 }
411
412 Ok((path_params, query_params, body_fields))
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418 use crate::google::api::{HttpRule, ResourceDescriptor, http_rule::Pattern};
419 use crate::parsing::types::UnifiedType;
420 use crate::parsing::{CodeGenMetadata, HttpPattern, MessageInfo, MethodMetadata, ServiceInfo};
421 use std::collections::HashMap;
422
423 fn make_metadata_with_catalog() -> CodeGenMetadata {
424 let catalog_resource = ResourceDescriptor {
425 r#type: "example.io/Catalog".to_string(),
426 pattern: vec!["catalogs/{catalog}".to_string()],
427 name_field: "name".to_string(),
428 history: 0,
429 plural: "catalogs".to_string(),
430 singular: "catalog".to_string(),
431 style: vec![],
432 };
433 let catalog_info = MessageInfo {
434 name: "Catalog".to_string(),
435 fields: vec![],
436 resource_descriptor: Some(catalog_resource),
437 documentation: None,
438 };
439 let mut messages = HashMap::new();
440 messages.insert("Catalog".to_string(), catalog_info);
441 CodeGenMetadata {
442 messages,
443 ..Default::default()
444 }
445 }
446
447 fn make_get_method() -> MethodMetadata {
448 MethodMetadata {
449 service_name: "CatalogService".to_string(),
450 method_name: "GetCatalog".to_string(),
451 input_type: "GetCatalogRequest".to_string(),
452 output_type: "Catalog".to_string(),
453 operation: None,
454 http_rule: HttpRule {
455 selector: "".to_string(),
456 pattern: Some(Pattern::Get("/catalogs/{name}".to_string())),
457 body: "".to_string(),
458 response_body: "".to_string(),
459 additional_bindings: vec![],
460 },
461 http_pattern: HttpPattern::parse("/catalogs/{name}"),
462 documentation: None,
463 }
464 }
465
466 #[test]
467 fn test_managed_resources_extraction() {
468 let metadata = make_metadata_with_catalog();
469 let service_info = ServiceInfo {
470 name: "CatalogService".to_string(),
471 package: "example.catalogs.v1".to_string(),
472 documentation: None,
473 methods: vec![make_get_method()],
474 };
475 let (service_plan, skipped) = analyze_service(&metadata, &service_info).unwrap();
476
477 assert!(skipped.is_empty());
478 assert_eq!(service_plan.managed_resources.len(), 1);
479 assert_eq!(service_plan.managed_resources[0].type_name, "Catalog");
480 assert_eq!(
481 service_plan.managed_resources[0].descriptor.r#type,
482 "example.io/Catalog"
483 );
484 assert_eq!(
485 service_plan.managed_resources[0].descriptor.singular,
486 "catalog"
487 );
488 assert_eq!(
489 service_plan.managed_resources[0].descriptor.plural,
490 "catalogs"
491 );
492 }
493
494 #[test]
495 fn test_no_duplicate_managed_resources() {
496 let metadata = make_metadata_with_catalog();
497 let update_method = MethodMetadata {
498 service_name: "CatalogService".to_string(),
499 method_name: "UpdateCatalog".to_string(),
500 input_type: "UpdateCatalogRequest".to_string(),
501 output_type: "Catalog".to_string(),
502 operation: None,
503 http_rule: HttpRule {
504 selector: "".to_string(),
505 pattern: Some(Pattern::Patch("/catalogs/{name}".to_string())),
506 body: "*".to_string(),
507 response_body: "".to_string(),
508 additional_bindings: vec![],
509 },
510 http_pattern: HttpPattern::parse("/catalogs/{name}"),
511 documentation: None,
512 };
513 let service_info = ServiceInfo {
514 name: "CatalogService".to_string(),
515 package: "example.catalogs.v1".to_string(),
516 documentation: None,
517 methods: vec![make_get_method(), update_method],
518 };
519 let (service_plan, _skipped) = analyze_service(&metadata, &service_info).unwrap();
520
521 assert_eq!(service_plan.managed_resources.len(), 1);
522 assert_eq!(service_plan.managed_resources[0].type_name, "Catalog");
523 }
524
525 #[test]
526 fn test_analyze_method_missing_http_pattern_returns_none() {
527 let metadata = CodeGenMetadata::default();
528 let method = MethodMetadata {
529 service_name: "SomeService".to_string(),
530 method_name: "SomeMethod".to_string(),
531 input_type: "".to_string(),
532 output_type: "".to_string(),
533 operation: None,
534 http_rule: HttpRule {
535 selector: "".to_string(),
536 pattern: None,
537 body: "".to_string(),
538 response_body: "".to_string(),
539 additional_bindings: vec![],
540 },
541 http_pattern: HttpPattern::parse(""),
542 documentation: None,
543 };
544 let result = analyze_method(&metadata, &method).unwrap();
545 assert!(result.is_none());
546 }
547
548 fn make_string_field(name: &str, optional: bool) -> MessageField {
551 use crate::parsing::types::BaseType;
552 MessageField {
553 name: name.to_string(),
554 unified_type: UnifiedType {
555 base_type: BaseType::String,
556 is_optional: optional,
557 is_repeated: false,
558 },
559 documentation: None,
560 oneof_variants: None,
561 field_behavior: vec![],
562 is_sensitive: false,
563 resource_reference: None,
564 }
565 }
566
567 fn make_repeated_field(name: &str) -> MessageField {
568 use crate::parsing::types::BaseType;
569 MessageField {
570 name: name.to_string(),
571 unified_type: UnifiedType {
572 base_type: BaseType::String,
573 is_optional: false,
574 is_repeated: true,
575 },
576 documentation: None,
577 oneof_variants: None,
578 field_behavior: vec![],
579 is_sensitive: false,
580 resource_reference: None,
581 }
582 }
583
584 fn make_method_with_pattern(pattern: Pattern, body: &str, path: &str) -> MethodMetadata {
585 MethodMetadata {
586 service_name: "Svc".to_string(),
587 method_name: "Method".to_string(),
588 input_type: "".to_string(),
589 output_type: "".to_string(),
590 operation: None,
591 http_rule: HttpRule {
592 selector: "".to_string(),
593 pattern: Some(pattern),
594 body: body.to_string(),
595 response_body: "".to_string(),
596 additional_bindings: vec![],
597 },
598 http_pattern: HttpPattern::parse(path),
599 documentation: None,
600 }
601 }
602
603 #[test]
604 fn test_extract_path_params_in_url_order() {
605 let method =
606 make_method_with_pattern(Pattern::Get("/a/{x}/b/{y}".to_string()), "", "/a/{x}/b/{y}");
607 let fields = vec![make_string_field("y", false), make_string_field("x", false)];
608 let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
609 assert_eq!(path.len(), 2);
611 assert_eq!(path[0].name, "x");
612 assert_eq!(path[1].name, "y");
613 assert!(query.is_empty());
614 assert!(body.is_empty());
615 }
616
617 #[test]
618 fn test_extract_body_wildcard() {
619 let method = make_method_with_pattern(Pattern::Post("/items".to_string()), "*", "/items");
620 let fields = vec![
621 make_string_field("name", false),
622 make_string_field("description", true),
623 ];
624 let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
625 assert!(path.is_empty());
626 assert!(query.is_empty());
627 assert_eq!(body.len(), 2);
628 }
629
630 #[test]
631 fn test_extract_specific_body_field() {
632 let method = make_method_with_pattern(
633 Pattern::Patch("/items/{name}".to_string()),
634 "payload",
635 "/items/{name}",
636 );
637 let fields = vec![
638 make_string_field("name", false), make_string_field("payload", false), make_string_field("extra", true), ];
642 let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
643 assert_eq!(path.len(), 1);
644 assert_eq!(path[0].name, "name");
645 assert_eq!(body.len(), 1);
646 assert_eq!(body[0].name, "payload");
647 assert_eq!(query.len(), 1);
648 assert_eq!(query[0].name, "extra");
649 }
650
651 #[test]
652 fn test_extract_no_body_spec_all_query() {
653 let method = make_method_with_pattern(Pattern::Get("/items".to_string()), "", "/items");
654 let fields = vec![
655 make_string_field("filter", true),
656 make_string_field("page_size", true),
657 ];
658 let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
659 assert!(path.is_empty());
660 assert_eq!(query.len(), 2);
661 assert!(body.is_empty());
662 }
663
664 #[test]
665 fn test_extract_repeated_field_becomes_body_with_repeated_flag() {
666 let method = make_method_with_pattern(Pattern::Post("/items".to_string()), "*", "/items");
667 let fields = vec![make_repeated_field("tags")];
668 let (_, _, body) = extract_request_fields(&method, &fields).unwrap();
669 assert_eq!(body.len(), 1);
670 assert!(body[0].repeated);
671 }
672
673 fn make_three_level_metadata() -> (CodeGenMetadata, ServiceInfo, ServiceInfo, ServiceInfo) {
682 use crate::google::api::ResourceReference;
683 use crate::parsing::types::BaseType;
684
685 let mut messages = HashMap::new();
686
687 messages.insert(
689 "Catalog".to_string(),
690 MessageInfo {
691 name: "Catalog".to_string(),
692 fields: vec![],
693 resource_descriptor: Some(ResourceDescriptor {
694 r#type: "example.io/Catalog".to_string(),
695 pattern: vec!["catalogs/{catalog}".to_string()],
696 name_field: "name".to_string(),
697 history: 0,
698 plural: "catalogs".to_string(),
699 singular: "catalog".to_string(),
700 style: vec![],
701 }),
702 documentation: None,
703 },
704 );
705
706 messages.insert(
708 "Schema".to_string(),
709 MessageInfo {
710 name: "Schema".to_string(),
711 fields: vec![],
712 resource_descriptor: Some(ResourceDescriptor {
713 r#type: "example.io/Schema".to_string(),
714 pattern: vec!["schemas/{schema}".to_string()],
715 name_field: "name".to_string(),
716 history: 0,
717 plural: "schemas".to_string(),
718 singular: "schema".to_string(),
719 style: vec![],
720 }),
721 documentation: None,
722 },
723 );
724
725 messages.insert(
727 "Table".to_string(),
728 MessageInfo {
729 name: "Table".to_string(),
730 fields: vec![],
731 resource_descriptor: Some(ResourceDescriptor {
732 r#type: "example.io/Table".to_string(),
733 pattern: vec!["tables/{table}".to_string()],
734 name_field: "full_name".to_string(),
735 history: 0,
736 plural: "tables".to_string(),
737 singular: "table".to_string(),
738 style: vec![],
739 }),
740 documentation: None,
741 },
742 );
743
744 messages.insert(
746 "ListCatalogsRequest".to_string(),
747 MessageInfo {
748 name: "ListCatalogsRequest".to_string(),
749 fields: vec![],
750 resource_descriptor: None,
751 documentation: None,
752 },
753 );
754
755 messages.insert(
757 "ListSchemasRequest".to_string(),
758 MessageInfo {
759 name: "ListSchemasRequest".to_string(),
760 fields: vec![MessageField {
761 name: "catalog_name".to_string(),
762 unified_type: UnifiedType {
763 base_type: BaseType::String,
764 is_optional: false,
765 is_repeated: false,
766 },
767 documentation: None,
768 oneof_variants: None,
769 field_behavior: vec![crate::google::api::FieldBehavior::Required],
770 is_sensitive: false,
771 resource_reference: Some(ResourceReference {
772 r#type: String::new(),
773 child_type: "example.io/Schema".to_string(),
774 }),
775 }],
776 resource_descriptor: None,
777 documentation: None,
778 },
779 );
780
781 messages.insert(
783 "ListTablesRequest".to_string(),
784 MessageInfo {
785 name: "ListTablesRequest".to_string(),
786 fields: vec![
787 MessageField {
788 name: "catalog_name".to_string(),
789 unified_type: UnifiedType {
790 base_type: BaseType::String,
791 is_optional: false,
792 is_repeated: false,
793 },
794 documentation: None,
795 oneof_variants: None,
796 field_behavior: vec![crate::google::api::FieldBehavior::Required],
797 is_sensitive: false,
798 resource_reference: Some(ResourceReference {
799 r#type: String::new(),
800 child_type: "example.io/Table".to_string(),
801 }),
802 },
803 MessageField {
804 name: "schema_name".to_string(),
805 unified_type: UnifiedType {
806 base_type: BaseType::String,
807 is_optional: false,
808 is_repeated: false,
809 },
810 documentation: None,
811 oneof_variants: None,
812 field_behavior: vec![crate::google::api::FieldBehavior::Required],
813 is_sensitive: false,
814 resource_reference: Some(ResourceReference {
815 r#type: String::new(),
816 child_type: "example.io/Table".to_string(),
817 }),
818 },
819 ],
820 resource_descriptor: None,
821 documentation: None,
822 },
823 );
824
825 let metadata = CodeGenMetadata {
826 messages,
827 ..Default::default()
828 };
829
830 let catalog_svc = ServiceInfo {
831 name: "CatalogService".to_string(),
832 package: "example.v1".to_string(),
833 documentation: None,
834 methods: vec![
835 MethodMetadata {
836 service_name: "CatalogService".to_string(),
837 method_name: "ListCatalogs".to_string(),
838 input_type: "ListCatalogsRequest".to_string(),
839 output_type: "ListCatalogsResponse".to_string(),
840 operation: None,
841 http_rule: HttpRule {
842 selector: "".to_string(),
843 pattern: Some(Pattern::Get("/catalogs".to_string())),
844 body: "".to_string(),
845 response_body: "".to_string(),
846 additional_bindings: vec![],
847 },
848 http_pattern: HttpPattern::parse("/catalogs"),
849 documentation: None,
850 },
851 MethodMetadata {
852 service_name: "CatalogService".to_string(),
853 method_name: "GetCatalog".to_string(),
854 input_type: "GetCatalogRequest".to_string(),
855 output_type: "Catalog".to_string(),
856 operation: None,
857 http_rule: HttpRule {
858 selector: "".to_string(),
859 pattern: Some(Pattern::Get("/catalogs/{name}".to_string())),
860 body: "".to_string(),
861 response_body: "".to_string(),
862 additional_bindings: vec![],
863 },
864 http_pattern: HttpPattern::parse("/catalogs/{name}"),
865 documentation: None,
866 },
867 ],
868 };
869
870 let schema_svc = ServiceInfo {
871 name: "SchemaService".to_string(),
872 package: "example.v1".to_string(),
873 documentation: None,
874 methods: vec![
875 MethodMetadata {
876 service_name: "SchemaService".to_string(),
877 method_name: "ListSchemas".to_string(),
878 input_type: "ListSchemasRequest".to_string(),
879 output_type: "ListSchemasResponse".to_string(),
880 operation: None,
881 http_rule: HttpRule {
882 selector: "".to_string(),
883 pattern: Some(Pattern::Get("/schemas".to_string())),
884 body: "".to_string(),
885 response_body: "".to_string(),
886 additional_bindings: vec![],
887 },
888 http_pattern: HttpPattern::parse("/schemas"),
889 documentation: None,
890 },
891 MethodMetadata {
892 service_name: "SchemaService".to_string(),
893 method_name: "GetSchema".to_string(),
894 input_type: "GetSchemaRequest".to_string(),
895 output_type: "Schema".to_string(),
896 operation: None,
897 http_rule: HttpRule {
898 selector: "".to_string(),
899 pattern: Some(Pattern::Get("/schemas/{full_name}".to_string())),
900 body: "".to_string(),
901 response_body: "".to_string(),
902 additional_bindings: vec![],
903 },
904 http_pattern: HttpPattern::parse("/schemas/{full_name}"),
905 documentation: None,
906 },
907 ],
908 };
909
910 let table_svc = ServiceInfo {
911 name: "TableService".to_string(),
912 package: "example.v1".to_string(),
913 documentation: None,
914 methods: vec![
915 MethodMetadata {
916 service_name: "TableService".to_string(),
917 method_name: "ListTables".to_string(),
918 input_type: "ListTablesRequest".to_string(),
919 output_type: "ListTablesResponse".to_string(),
920 operation: None,
921 http_rule: HttpRule {
922 selector: "".to_string(),
923 pattern: Some(Pattern::Get("/tables".to_string())),
924 body: "".to_string(),
925 response_body: "".to_string(),
926 additional_bindings: vec![],
927 },
928 http_pattern: HttpPattern::parse("/tables"),
929 documentation: None,
930 },
931 MethodMetadata {
932 service_name: "TableService".to_string(),
933 method_name: "GetTable".to_string(),
934 input_type: "GetTableRequest".to_string(),
935 output_type: "Table".to_string(),
936 operation: None,
937 http_rule: HttpRule {
938 selector: "".to_string(),
939 pattern: Some(Pattern::Get("/tables/{full_name}".to_string())),
940 body: "".to_string(),
941 response_body: "".to_string(),
942 additional_bindings: vec![],
943 },
944 http_pattern: HttpPattern::parse("/tables/{full_name}"),
945 documentation: None,
946 },
947 ],
948 };
949
950 (metadata, catalog_svc, schema_svc, table_svc)
951 }
952
953 fn make_plans_from_fixture() -> (Vec<ServicePlan>, CodeGenMetadata) {
955 let (metadata, catalog_svc, schema_svc, table_svc) = make_three_level_metadata();
956 let mut plans = Vec::new();
957 for svc in &[&catalog_svc, &schema_svc, &table_svc] {
958 let (plan, _) = analyze_service(&metadata, svc).unwrap();
959 plans.push(plan);
960 }
961 (plans, metadata)
962 }
963
964 #[test]
965 fn test_build_global_parent_map() {
966 let (plans, metadata) = make_plans_from_fixture();
967 let map = build_global_parent_map(&plans, &metadata);
968
969 assert_eq!(
971 map.get(&(
972 "example.io/Catalog".to_string(),
973 "example.io/Schema".to_string()
974 )),
975 Some(&"catalog_name".to_string()),
976 "Catalog→Schema mapping missing"
977 );
978 assert_eq!(
980 map.get(&(
981 "example.io/Schema".to_string(),
982 "example.io/Table".to_string()
983 )),
984 Some(&"schema_name".to_string()),
985 "Schema→Table mapping missing"
986 );
987 assert_eq!(
989 map.get(&(
990 "example.io/Catalog".to_string(),
991 "example.io/Table".to_string()
992 )),
993 Some(&"catalog_name".to_string()),
994 "Catalog→Table flat-API mapping missing"
995 );
996 }
997
998 #[test]
999 fn test_derive_ordered_hierarchy_three_levels() {
1000 let (plans, metadata) = make_plans_from_fixture();
1001 let map = build_global_parent_map(&plans, &metadata);
1002 let table_plan = plans
1003 .iter()
1004 .find(|p| p.service_name == "TableService")
1005 .unwrap();
1006 let hierarchy = derive_ordered_hierarchy(table_plan, &map, &metadata)
1007 .expect("no cycles in test fixture");
1008
1009 assert_eq!(hierarchy.len(), 2, "expected 2 ancestors for Table");
1010 assert_eq!(hierarchy[0].parent_field_name, "catalog_name");
1012 assert_eq!(hierarchy[1].parent_field_name, "schema_name");
1013 assert_eq!(hierarchy[0].parent_resource_type, "example.io/Catalog");
1014 assert_eq!(hierarchy[1].parent_resource_type, "example.io/Schema");
1015 assert_eq!(hierarchy[0].parent_singular, Some("catalog".to_string()));
1016 assert_eq!(hierarchy[1].parent_singular, Some("schema".to_string()));
1017 assert!(
1019 hierarchy
1020 .iter()
1021 .all(|h| h.child_resource_type == "example.io/Table")
1022 );
1023 }
1024
1025 #[test]
1026 fn test_derive_ordered_hierarchy_two_levels() {
1027 let (plans, metadata) = make_plans_from_fixture();
1028 let map = build_global_parent_map(&plans, &metadata);
1029 let schema_plan = plans
1030 .iter()
1031 .find(|p| p.service_name == "SchemaService")
1032 .unwrap();
1033 let hierarchy = derive_ordered_hierarchy(schema_plan, &map, &metadata)
1034 .expect("no cycles in test fixture");
1035
1036 assert_eq!(hierarchy.len(), 1);
1037 assert_eq!(hierarchy[0].parent_field_name, "catalog_name");
1038 assert_eq!(hierarchy[0].parent_resource_type, "example.io/Catalog");
1039 }
1040
1041 #[test]
1042 fn test_derive_ordered_hierarchy_root_resource() {
1043 let (plans, metadata) = make_plans_from_fixture();
1044 let map = build_global_parent_map(&plans, &metadata);
1045 let catalog_plan = plans
1046 .iter()
1047 .find(|p| p.service_name == "CatalogService")
1048 .unwrap();
1049 let hierarchy = derive_ordered_hierarchy(catalog_plan, &map, &metadata)
1050 .expect("no cycles in test fixture");
1051
1052 assert!(
1053 hierarchy.is_empty(),
1054 "root resource should have empty hierarchy"
1055 );
1056 }
1057
1058 #[test]
1059 fn test_compute_depth_cycle_guard() {
1060 let mut map: GlobalParentMap = HashMap::new();
1062 map.insert(("A".to_string(), "B".to_string()), "a_name".to_string());
1063 map.insert(("B".to_string(), "A".to_string()), "b_name".to_string());
1064
1065 assert!(compute_depth("A", &map, &mut HashSet::new()).is_err());
1066 assert!(compute_depth("B", &map, &mut HashSet::new()).is_err());
1067 }
1068
1069 #[test]
1070 fn test_full_analyze_metadata_hierarchy_ordering() {
1071 let (metadata, catalog_svc, schema_svc, table_svc) = make_three_level_metadata();
1072 let mut services_map = HashMap::new();
1073 services_map.insert("CatalogService".to_string(), catalog_svc);
1074 services_map.insert("SchemaService".to_string(), schema_svc);
1075 services_map.insert("TableService".to_string(), table_svc);
1076 let full_metadata = CodeGenMetadata {
1077 messages: metadata.messages,
1078 services: services_map,
1079 ..Default::default()
1080 };
1081
1082 let plan = analyze_metadata(&full_metadata).unwrap();
1083 let table_svc_plan = plan
1084 .services
1085 .iter()
1086 .find(|s| s.service_name == "TableService")
1087 .expect("TableService plan not found");
1088
1089 assert_eq!(table_svc_plan.hierarchy.len(), 2);
1090 assert_eq!(
1091 table_svc_plan.hierarchy[0].parent_field_name,
1092 "catalog_name"
1093 );
1094 assert_eq!(table_svc_plan.hierarchy[1].parent_field_name, "schema_name");
1095 }
1096}