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, ChildLink, EmitShape, GenerationPlan, ManagedResource, MethodPlan, MethodShape,
53 PathParam, QueryParam, RequestParam, RequestType, ResourceHierarchy, ServicePlan,
54 SkippedMethod, extract_managed_resources, split_body_fields,
55};
56pub(crate) use types::{emit_shape, is_collection_method};
57
58mod types;
59
60pub fn analyze_metadata(metadata: &CodeGenMetadata) -> Result<GenerationPlan> {
70 let mut service_infos: Vec<_> = metadata.services.values().collect();
76 service_infos.sort_by(|a, b| a.name.cmp(&b.name));
77
78 let mut plans = Vec::new();
79 let mut skipped_methods = Vec::new();
80 for service_info in service_infos {
81 let (plan, skipped) = analyze_service(metadata, service_info)?;
82 plans.push(plan);
83 skipped_methods.extend(skipped);
84 }
85
86 let global_map = build_global_parent_map(&plans, metadata);
89 let mut services = plans
90 .into_iter()
91 .map(|mut plan| {
92 plan.hierarchy = derive_ordered_hierarchy(&plan, &global_map, metadata)?;
93 plan.resource_accessor_params = derive_resource_accessor_params(&plan);
94 Ok(plan)
95 })
96 .collect::<Result<Vec<_>>>()?;
97
98 assign_direct_children(&mut services);
101
102 Ok(GenerationPlan {
103 services,
104 skipped_methods,
105 })
106}
107
108type GlobalParentMap = HashMap<(String, String), String>;
113
114fn build_global_parent_map(plans: &[ServicePlan], metadata: &CodeGenMetadata) -> GlobalParentMap {
123 let mut map: GlobalParentMap = HashMap::new();
124
125 for plan in plans {
126 let managed_type = match plan.managed_resources.first() {
127 Some(r) => &r.descriptor.r#type,
128 None => continue,
129 };
130 if managed_type.is_empty() {
131 continue;
132 }
133
134 for method in &plan.methods {
135 if method.request_type != RequestType::List {
136 continue;
137 }
138 for param in method.query_parameters() {
139 let Some(ref rr) = param.resource_reference else {
140 continue;
141 };
142 if rr.child_type != *managed_type {
144 continue;
145 }
146 if let Some(parent_type) = resolve_parent_type_from_field(¶m.name, metadata) {
147 map.entry((parent_type, managed_type.clone()))
148 .or_insert_with(|| param.name.clone());
149 }
150 }
151 }
152 }
153
154 map
155}
156
157fn derive_ordered_hierarchy(
164 plan: &ServicePlan,
165 global_map: &GlobalParentMap,
166 metadata: &CodeGenMetadata,
167) -> Result<Vec<ResourceHierarchy>> {
168 let managed_type = match plan.managed_resources.first() {
169 Some(r) => r.descriptor.r#type.clone(),
170 None => return Ok(vec![]),
171 };
172
173 let mut ancestors: Vec<(usize, String, String)> = global_map
175 .iter()
176 .filter(|((_, child), _)| child == &managed_type)
177 .map(|((parent_type, _), field_name)| {
178 let depth = compute_depth(parent_type, global_map, &mut HashSet::new())?;
179 Ok((depth, parent_type.clone(), field_name.clone()))
180 })
181 .collect::<Result<Vec<_>>>()?;
182
183 if ancestors.is_empty() {
184 return Ok(vec![]);
185 }
186
187 ancestors.sort_by_key(|(depth, _, _)| *depth);
189
190 Ok(ancestors
191 .into_iter()
192 .map(|(_, parent_type, field_name)| {
193 let parent_singular = field_name.strip_suffix("_name").and_then(|stem| {
194 metadata
195 .resource_from_singular(stem)
196 .map(|_| stem.to_string())
197 });
198 ResourceHierarchy {
199 child_resource_type: managed_type.clone(),
200 parent_resource_type: parent_type,
201 parent_field_name: field_name,
202 parent_singular,
203 }
204 })
205 .collect())
206}
207
208fn compute_depth(
212 resource_type: &str,
213 map: &GlobalParentMap,
214 visited: &mut HashSet<String>,
215) -> Result<usize> {
216 if !visited.insert(resource_type.to_string()) {
217 return Err(crate::Error::Build(format!(
218 "Cycle detected in resource hierarchy at type: {resource_type}"
219 )));
220 }
221 let parent = map
222 .iter()
223 .find(|((_, child), _)| child == resource_type)
224 .map(|((parent, _), _)| parent.clone());
225
226 match parent {
227 None => Ok(0),
228 Some(parent_type) => Ok(1 + compute_depth(&parent_type, map, visited)?),
229 }
230}
231
232fn resolve_parent_type_from_field(field_name: &str, metadata: &CodeGenMetadata) -> Option<String> {
237 field_name
238 .strip_suffix("_name")
239 .and_then(|s| metadata.resource_from_singular(s))
240 .map(|rd| rd.r#type.clone())
241}
242
243pub(crate) fn is_standard_list_field(name: &str) -> bool {
250 matches!(
251 name,
252 "page_token" | "page_size" | "max_results" | "order_by" | "filter"
253 )
254}
255
256pub(crate) fn derive_accessor_parent_fields(plan: &ServicePlan) -> Vec<String> {
270 let Some(resource) = plan.managed_resources.first() else {
271 return vec![];
272 };
273
274 let resource_type = &resource.descriptor.r#type;
276 if !plan.hierarchy.is_empty() && !resource_type.is_empty() {
277 let annotation_parents: Vec<String> = plan
278 .hierarchy
279 .iter()
280 .filter(|h| &h.child_resource_type == resource_type)
281 .map(|h| h.parent_field_name.clone())
282 .collect();
283 if !annotation_parents.is_empty() {
284 return annotation_parents;
285 }
286 }
287
288 let has_explicit_name_field = !resource.descriptor.name_field.is_empty();
290
291 let get_path_param_name = plan
292 .methods
293 .iter()
294 .find(|m| m.request_type == RequestType::Get)
295 .and_then(|m| m.path_parameters().next().map(|p| p.name.clone()));
296
297 let parent_params: Vec<String> = plan
298 .methods
299 .iter()
300 .find(|m| m.request_type == RequestType::List)
301 .map(|m| {
302 m.parameters
303 .iter()
304 .filter(|p| !p.is_path_param() && !p.is_optional())
305 .filter(|p| matches!(p.field_type().base_type, BaseType::String))
306 .filter(|p| !is_standard_list_field(p.name()))
307 .map(|p| p.name().to_string())
308 .collect()
309 })
310 .unwrap_or_default();
311
312 let should_decompose = has_explicit_name_field
313 || (get_path_param_name.as_deref() == Some("name") && !parent_params.is_empty());
314
315 if should_decompose {
316 parent_params
317 } else {
318 vec![]
319 }
320}
321
322fn derive_resource_accessor_params(plan: &ServicePlan) -> Option<Vec<String>> {
329 let resource = plan.managed_resources.first()?;
330 let mut params = derive_accessor_parent_fields(plan);
331 params.push(format!("{}_name", resource.descriptor.singular));
332 Some(params)
333}
334
335fn assign_direct_children(services: &mut [ServicePlan]) {
341 let candidates: Vec<(Vec<String>, String, String)> = services
344 .iter()
345 .filter_map(|s| {
346 let params = s.resource_accessor_params.clone()?;
347 let singular = s.managed_resources.first()?.descriptor.singular.clone();
348 Some((params, s.base_path.clone(), singular))
349 })
350 .collect();
351
352 for plan in services.iter_mut() {
353 let Some(parent_params) = plan.resource_accessor_params.clone() else {
354 continue;
355 };
356 let mut children: Vec<ChildLink> = candidates
357 .iter()
358 .filter(|(child_params, child_base_path, _)| {
359 *child_base_path != plan.base_path
360 && child_params.len() == parent_params.len() + 1
361 && child_params[..parent_params.len()] == parent_params[..]
362 })
363 .map(
364 |(child_params, child_base_path, child_singular)| ChildLink {
365 child_singular: child_singular.clone(),
366 child_base_path: child_base_path.clone(),
367 child_accessor_params: child_params.clone(),
368 },
369 )
370 .collect();
371 children.sort_by(|a, b| a.child_singular.cmp(&b.child_singular));
372 plan.direct_children = children;
373 }
374}
375
376fn analyze_service(
380 metadata: &CodeGenMetadata,
381 info: &ServiceInfo,
382) -> Result<(ServicePlan, Vec<SkippedMethod>)> {
383 let handler_name = strings::service_to_handler_name(&info.name);
384 let base_path = strings::service_to_base_path(&info.name);
385
386 let mut method_plans = Vec::new();
387 let mut skipped = Vec::new();
388
389 for method in &info.methods {
390 if let Some(method_plan) = analyze_method(metadata, method)? {
391 method_plans.push(method_plan);
392 } else {
393 warn!(
394 "Skipping method {}.{} - incomplete metadata",
395 info.name, method.method_name
396 );
397 skipped.push(SkippedMethod {
398 service_name: info.name.clone(),
399 method_name: method.method_name.clone(),
400 reason: "missing HTTP annotation".to_string(),
401 });
402 }
403 }
404
405 let managed_resources = types::extract_managed_resources(metadata, &method_plans);
406
407 let service_has_resource = !managed_resources.is_empty();
411 for plan in &mut method_plans {
412 plan.shape = types::method_shape(
413 &plan.request_type,
414 &plan.metadata.method_name,
415 service_has_resource,
416 );
417 }
418
419 Ok((
420 ServicePlan {
421 service_name: info.name.clone(),
422 handler_name,
423 base_path,
424 package: info.package.clone(),
425 methods: method_plans,
426 managed_resources,
427 documentation: info.documentation.clone(),
428 hierarchy: vec![], resource_accessor_params: None, direct_children: vec![], },
432 skipped,
433 ))
434}
435
436pub(crate) fn analyze_method(
440 metadata: &CodeGenMetadata,
441 method: &MethodMetadata,
442) -> Result<Option<MethodPlan>> {
443 let http_method = match method.http_method() {
444 Some(m) => m.to_string(),
445 None => {
446 warn!(
447 "Method {}.{} missing HTTP info",
448 method.service_name, method.method_name
449 );
450 return Ok(None);
451 }
452 };
453
454 let planner = MethodPlanner::try_new(method, metadata)?;
455 let request_type = planner.request_type();
456 let has_response = planner.has_response();
457 let output_resource_type = planner.output_resource_type();
458 let http_pattern = planner.into_http_pattern();
459
460 let input_fields = metadata.get_message_fields(&method.input_type);
461 let (path_params, query_params, body_fields) = extract_request_fields(method, &input_fields)?;
462
463 let parameters = path_params
464 .into_iter()
465 .map(Into::into)
466 .chain(query_params.into_iter().map(Into::into))
467 .chain(body_fields.into_iter().map(Into::into))
468 .collect();
469
470 let has_request_body = types::request_has_body(&request_type);
471 let needs_request_parts = types::request_needs_request_parts(&request_type);
472 let scoped_verb = types::scoped_verb(&request_type);
473
474 Ok(Some(MethodPlan {
475 metadata: method.clone(),
476 handler_function_name: method.method_name.to_case(Case::Snake),
477 http_method,
478 parameters,
479 has_response,
480 request_type,
481 output_resource_type,
482 http_pattern,
483 has_request_body,
484 needs_request_parts,
485 scoped_verb,
486 shape: types::MethodShape::Unbound,
489 }))
490}
491
492fn extract_request_fields(
502 method: &MethodMetadata,
503 input_fields: &[MessageField],
504) -> Result<(Vec<PathParam>, Vec<QueryParam>, Vec<BodyField>)> {
505 let mut path_params = Vec::new();
506 let mut query_params = Vec::new();
507 let mut body_fields = Vec::new();
508
509 let path_param_names = method.http_pattern.parameter_names();
510 let body_spec = method.http_rule.body.as_str();
511
512 let fields_by_name: HashMap<&str, &MessageField> =
514 input_fields.iter().map(|f| (f.name.as_str(), f)).collect();
515
516 let mut processed_fields = HashSet::new();
517
518 for path_param_name in path_param_names {
522 if let Some(field) = fields_by_name.get(path_param_name.as_str()) {
523 path_params.push(PathParam {
526 name: field.name.clone(),
527 field_type: field.unified_type.clone(),
528 documentation: field.documentation.clone(),
529 });
530 processed_fields.insert(field.name.as_str());
531 } else {
532 warn!(
534 "Method {}.{}: URL path placeholder `{}` does not match any request field; \
535 the param will be omitted from the path",
536 method.service_name, method.method_name, path_param_name
537 );
538 }
539 }
540
541 for field in input_fields {
543 let field_name = field.name.as_str();
544
545 if processed_fields.contains(field_name) {
546 continue;
547 }
548
549 if field.field_behavior.contains(&FieldBehavior::OutputOnly) {
551 processed_fields.insert(field_name);
552 continue;
553 }
554
555 if matches!(field.unified_type.base_type, BaseType::OneOf(_)) {
557 body_fields.push(BodyField {
558 name: field.name.clone(),
559 field_type: field.unified_type.clone().optional(),
560 repeated: false,
561 required: false,
562 oneof_variants: field.oneof_variants.clone(),
563 documentation: field.documentation.clone(),
564 });
565 processed_fields.insert(field_name);
566 continue;
567 }
568
569 let is_body = match body_spec {
570 "*" => true,
571 "" => false,
572 specific => specific == field_name,
573 };
574
575 if is_body {
576 let required = field.field_behavior.contains(&FieldBehavior::Required);
577 let needs_optional_marker = !required
582 && !field.unified_type.is_repeated
583 && matches!(
584 field.unified_type.base_type,
585 BaseType::Message(_) | BaseType::OneOf(_)
586 );
587 let field_type = if needs_optional_marker {
588 field.unified_type.clone().optional()
589 } else {
590 field.unified_type.clone()
591 };
592 body_fields.push(BodyField {
593 name: field.name.clone(),
594 field_type,
595 repeated: field.unified_type.is_repeated,
596 required,
597 oneof_variants: None,
598 documentation: field.documentation.clone(),
599 });
600 } else {
601 query_params.push(QueryParam {
602 name: field.name.clone(),
603 field_type: field.unified_type.clone(),
604 documentation: field.documentation.clone(),
605 resource_reference: field.resource_reference.clone(),
606 });
607 }
608 processed_fields.insert(field_name);
609 }
610
611 Ok((path_params, query_params, body_fields))
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use crate::google::api::{HttpRule, ResourceDescriptor, http_rule::Pattern};
618 use crate::parsing::types::UnifiedType;
619 use crate::parsing::{CodeGenMetadata, HttpPattern, MessageInfo, MethodMetadata, ServiceInfo};
620 use std::collections::HashMap;
621
622 fn make_metadata_with_catalog() -> CodeGenMetadata {
623 let catalog_resource = ResourceDescriptor {
624 r#type: "example.io/Catalog".to_string(),
625 pattern: vec!["catalogs/{catalog}".to_string()],
626 name_field: "name".to_string(),
627 history: 0,
628 plural: "catalogs".to_string(),
629 singular: "catalog".to_string(),
630 style: vec![],
631 };
632 let catalog_info = MessageInfo {
633 name: "Catalog".to_string(),
634 fields: vec![],
635 resource_descriptor: Some(catalog_resource),
636 documentation: None,
637 };
638 let mut messages = HashMap::new();
639 messages.insert("Catalog".to_string(), catalog_info);
640 CodeGenMetadata {
641 messages,
642 ..Default::default()
643 }
644 }
645
646 fn make_get_method() -> MethodMetadata {
647 MethodMetadata {
648 service_name: "CatalogService".to_string(),
649 method_name: "GetCatalog".to_string(),
650 input_type: "GetCatalogRequest".to_string(),
651 output_type: "Catalog".to_string(),
652 operation: None,
653 http_rule: HttpRule {
654 selector: "".to_string(),
655 pattern: Some(Pattern::Get("/catalogs/{name}".to_string())),
656 body: "".to_string(),
657 response_body: "".to_string(),
658 additional_bindings: vec![],
659 },
660 http_pattern: HttpPattern::parse("/catalogs/{name}"),
661 documentation: None,
662 }
663 }
664
665 #[test]
666 fn test_managed_resources_extraction() {
667 let metadata = make_metadata_with_catalog();
668 let service_info = ServiceInfo {
669 name: "CatalogService".to_string(),
670 package: "example.catalogs.v1".to_string(),
671 documentation: None,
672 methods: vec![make_get_method()],
673 };
674 let (service_plan, skipped) = analyze_service(&metadata, &service_info).unwrap();
675
676 assert!(skipped.is_empty());
677 assert_eq!(service_plan.managed_resources.len(), 1);
678 assert_eq!(service_plan.managed_resources[0].type_name, "Catalog");
679 assert_eq!(
680 service_plan.managed_resources[0].descriptor.r#type,
681 "example.io/Catalog"
682 );
683 assert_eq!(
684 service_plan.managed_resources[0].descriptor.singular,
685 "catalog"
686 );
687 assert_eq!(
688 service_plan.managed_resources[0].descriptor.plural,
689 "catalogs"
690 );
691 }
692
693 #[test]
694 fn test_no_duplicate_managed_resources() {
695 let metadata = make_metadata_with_catalog();
696 let update_method = MethodMetadata {
697 service_name: "CatalogService".to_string(),
698 method_name: "UpdateCatalog".to_string(),
699 input_type: "UpdateCatalogRequest".to_string(),
700 output_type: "Catalog".to_string(),
701 operation: None,
702 http_rule: HttpRule {
703 selector: "".to_string(),
704 pattern: Some(Pattern::Patch("/catalogs/{name}".to_string())),
705 body: "*".to_string(),
706 response_body: "".to_string(),
707 additional_bindings: vec![],
708 },
709 http_pattern: HttpPattern::parse("/catalogs/{name}"),
710 documentation: None,
711 };
712 let service_info = ServiceInfo {
713 name: "CatalogService".to_string(),
714 package: "example.catalogs.v1".to_string(),
715 documentation: None,
716 methods: vec![make_get_method(), update_method],
717 };
718 let (service_plan, _skipped) = analyze_service(&metadata, &service_info).unwrap();
719
720 assert_eq!(service_plan.managed_resources.len(), 1);
721 assert_eq!(service_plan.managed_resources[0].type_name, "Catalog");
722 }
723
724 #[test]
725 fn test_analyze_method_missing_http_pattern_returns_none() {
726 let metadata = CodeGenMetadata::default();
727 let method = MethodMetadata {
728 service_name: "SomeService".to_string(),
729 method_name: "SomeMethod".to_string(),
730 input_type: "".to_string(),
731 output_type: "".to_string(),
732 operation: None,
733 http_rule: HttpRule {
734 selector: "".to_string(),
735 pattern: None,
736 body: "".to_string(),
737 response_body: "".to_string(),
738 additional_bindings: vec![],
739 },
740 http_pattern: HttpPattern::parse(""),
741 documentation: None,
742 };
743 let result = analyze_method(&metadata, &method).unwrap();
744 assert!(result.is_none());
745 }
746
747 fn make_string_field(name: &str, optional: bool) -> MessageField {
750 use crate::parsing::types::BaseType;
751 MessageField {
752 name: name.to_string(),
753 unified_type: UnifiedType {
754 base_type: BaseType::String,
755 is_optional: optional,
756 is_repeated: false,
757 },
758 documentation: None,
759 oneof_variants: None,
760 field_behavior: vec![],
761 is_sensitive: false,
762 resource_reference: None,
763 }
764 }
765
766 fn make_repeated_field(name: &str) -> MessageField {
767 use crate::parsing::types::BaseType;
768 MessageField {
769 name: name.to_string(),
770 unified_type: UnifiedType {
771 base_type: BaseType::String,
772 is_optional: false,
773 is_repeated: true,
774 },
775 documentation: None,
776 oneof_variants: None,
777 field_behavior: vec![],
778 is_sensitive: false,
779 resource_reference: None,
780 }
781 }
782
783 fn make_method_with_pattern(pattern: Pattern, body: &str, path: &str) -> MethodMetadata {
784 MethodMetadata {
785 service_name: "Svc".to_string(),
786 method_name: "Method".to_string(),
787 input_type: "".to_string(),
788 output_type: "".to_string(),
789 operation: None,
790 http_rule: HttpRule {
791 selector: "".to_string(),
792 pattern: Some(pattern),
793 body: body.to_string(),
794 response_body: "".to_string(),
795 additional_bindings: vec![],
796 },
797 http_pattern: HttpPattern::parse(path),
798 documentation: None,
799 }
800 }
801
802 #[test]
803 fn test_extract_path_params_in_url_order() {
804 let method =
805 make_method_with_pattern(Pattern::Get("/a/{x}/b/{y}".to_string()), "", "/a/{x}/b/{y}");
806 let fields = vec![make_string_field("y", false), make_string_field("x", false)];
807 let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
808 assert_eq!(path.len(), 2);
810 assert_eq!(path[0].name, "x");
811 assert_eq!(path[1].name, "y");
812 assert!(query.is_empty());
813 assert!(body.is_empty());
814 }
815
816 #[test]
817 fn test_extract_body_wildcard() {
818 let method = make_method_with_pattern(Pattern::Post("/items".to_string()), "*", "/items");
819 let fields = vec![
820 make_string_field("name", false),
821 make_string_field("description", true),
822 ];
823 let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
824 assert!(path.is_empty());
825 assert!(query.is_empty());
826 assert_eq!(body.len(), 2);
827 }
828
829 #[test]
830 fn test_extract_specific_body_field() {
831 let method = make_method_with_pattern(
832 Pattern::Patch("/items/{name}".to_string()),
833 "payload",
834 "/items/{name}",
835 );
836 let fields = vec![
837 make_string_field("name", false), make_string_field("payload", false), make_string_field("extra", true), ];
841 let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
842 assert_eq!(path.len(), 1);
843 assert_eq!(path[0].name, "name");
844 assert_eq!(body.len(), 1);
845 assert_eq!(body[0].name, "payload");
846 assert_eq!(query.len(), 1);
847 assert_eq!(query[0].name, "extra");
848 }
849
850 #[test]
851 fn test_extract_no_body_spec_all_query() {
852 let method = make_method_with_pattern(Pattern::Get("/items".to_string()), "", "/items");
853 let fields = vec![
854 make_string_field("filter", true),
855 make_string_field("page_size", true),
856 ];
857 let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
858 assert!(path.is_empty());
859 assert_eq!(query.len(), 2);
860 assert!(body.is_empty());
861 }
862
863 #[test]
864 fn test_extract_repeated_field_becomes_body_with_repeated_flag() {
865 let method = make_method_with_pattern(Pattern::Post("/items".to_string()), "*", "/items");
866 let fields = vec![make_repeated_field("tags")];
867 let (_, _, body) = extract_request_fields(&method, &fields).unwrap();
868 assert_eq!(body.len(), 1);
869 assert!(body[0].repeated);
870 }
871
872 fn make_three_level_metadata() -> (CodeGenMetadata, ServiceInfo, ServiceInfo, ServiceInfo) {
881 use crate::google::api::ResourceReference;
882 use crate::parsing::types::BaseType;
883
884 let mut messages = HashMap::new();
885
886 messages.insert(
888 "Catalog".to_string(),
889 MessageInfo {
890 name: "Catalog".to_string(),
891 fields: vec![],
892 resource_descriptor: Some(ResourceDescriptor {
893 r#type: "example.io/Catalog".to_string(),
894 pattern: vec!["catalogs/{catalog}".to_string()],
895 name_field: "name".to_string(),
896 history: 0,
897 plural: "catalogs".to_string(),
898 singular: "catalog".to_string(),
899 style: vec![],
900 }),
901 documentation: None,
902 },
903 );
904
905 messages.insert(
907 "Schema".to_string(),
908 MessageInfo {
909 name: "Schema".to_string(),
910 fields: vec![],
911 resource_descriptor: Some(ResourceDescriptor {
912 r#type: "example.io/Schema".to_string(),
913 pattern: vec!["schemas/{schema}".to_string()],
914 name_field: "name".to_string(),
915 history: 0,
916 plural: "schemas".to_string(),
917 singular: "schema".to_string(),
918 style: vec![],
919 }),
920 documentation: None,
921 },
922 );
923
924 messages.insert(
926 "Table".to_string(),
927 MessageInfo {
928 name: "Table".to_string(),
929 fields: vec![],
930 resource_descriptor: Some(ResourceDescriptor {
931 r#type: "example.io/Table".to_string(),
932 pattern: vec!["tables/{table}".to_string()],
933 name_field: "full_name".to_string(),
934 history: 0,
935 plural: "tables".to_string(),
936 singular: "table".to_string(),
937 style: vec![],
938 }),
939 documentation: None,
940 },
941 );
942
943 messages.insert(
945 "ListCatalogsRequest".to_string(),
946 MessageInfo {
947 name: "ListCatalogsRequest".to_string(),
948 fields: vec![],
949 resource_descriptor: None,
950 documentation: None,
951 },
952 );
953
954 messages.insert(
956 "ListSchemasRequest".to_string(),
957 MessageInfo {
958 name: "ListSchemasRequest".to_string(),
959 fields: vec![MessageField {
960 name: "catalog_name".to_string(),
961 unified_type: UnifiedType {
962 base_type: BaseType::String,
963 is_optional: false,
964 is_repeated: false,
965 },
966 documentation: None,
967 oneof_variants: None,
968 field_behavior: vec![crate::google::api::FieldBehavior::Required],
969 is_sensitive: false,
970 resource_reference: Some(ResourceReference {
971 r#type: String::new(),
972 child_type: "example.io/Schema".to_string(),
973 }),
974 }],
975 resource_descriptor: None,
976 documentation: None,
977 },
978 );
979
980 messages.insert(
982 "ListTablesRequest".to_string(),
983 MessageInfo {
984 name: "ListTablesRequest".to_string(),
985 fields: vec![
986 MessageField {
987 name: "catalog_name".to_string(),
988 unified_type: UnifiedType {
989 base_type: BaseType::String,
990 is_optional: false,
991 is_repeated: false,
992 },
993 documentation: None,
994 oneof_variants: None,
995 field_behavior: vec![crate::google::api::FieldBehavior::Required],
996 is_sensitive: false,
997 resource_reference: Some(ResourceReference {
998 r#type: String::new(),
999 child_type: "example.io/Table".to_string(),
1000 }),
1001 },
1002 MessageField {
1003 name: "schema_name".to_string(),
1004 unified_type: UnifiedType {
1005 base_type: BaseType::String,
1006 is_optional: false,
1007 is_repeated: false,
1008 },
1009 documentation: None,
1010 oneof_variants: None,
1011 field_behavior: vec![crate::google::api::FieldBehavior::Required],
1012 is_sensitive: false,
1013 resource_reference: Some(ResourceReference {
1014 r#type: String::new(),
1015 child_type: "example.io/Table".to_string(),
1016 }),
1017 },
1018 ],
1019 resource_descriptor: None,
1020 documentation: None,
1021 },
1022 );
1023
1024 let metadata = CodeGenMetadata {
1025 messages,
1026 ..Default::default()
1027 };
1028
1029 let catalog_svc = ServiceInfo {
1030 name: "CatalogService".to_string(),
1031 package: "example.v1".to_string(),
1032 documentation: None,
1033 methods: vec![
1034 MethodMetadata {
1035 service_name: "CatalogService".to_string(),
1036 method_name: "ListCatalogs".to_string(),
1037 input_type: "ListCatalogsRequest".to_string(),
1038 output_type: "ListCatalogsResponse".to_string(),
1039 operation: None,
1040 http_rule: HttpRule {
1041 selector: "".to_string(),
1042 pattern: Some(Pattern::Get("/catalogs".to_string())),
1043 body: "".to_string(),
1044 response_body: "".to_string(),
1045 additional_bindings: vec![],
1046 },
1047 http_pattern: HttpPattern::parse("/catalogs"),
1048 documentation: None,
1049 },
1050 MethodMetadata {
1051 service_name: "CatalogService".to_string(),
1052 method_name: "GetCatalog".to_string(),
1053 input_type: "GetCatalogRequest".to_string(),
1054 output_type: "Catalog".to_string(),
1055 operation: None,
1056 http_rule: HttpRule {
1057 selector: "".to_string(),
1058 pattern: Some(Pattern::Get("/catalogs/{name}".to_string())),
1059 body: "".to_string(),
1060 response_body: "".to_string(),
1061 additional_bindings: vec![],
1062 },
1063 http_pattern: HttpPattern::parse("/catalogs/{name}"),
1064 documentation: None,
1065 },
1066 ],
1067 };
1068
1069 let schema_svc = ServiceInfo {
1070 name: "SchemaService".to_string(),
1071 package: "example.v1".to_string(),
1072 documentation: None,
1073 methods: vec![
1074 MethodMetadata {
1075 service_name: "SchemaService".to_string(),
1076 method_name: "ListSchemas".to_string(),
1077 input_type: "ListSchemasRequest".to_string(),
1078 output_type: "ListSchemasResponse".to_string(),
1079 operation: None,
1080 http_rule: HttpRule {
1081 selector: "".to_string(),
1082 pattern: Some(Pattern::Get("/schemas".to_string())),
1083 body: "".to_string(),
1084 response_body: "".to_string(),
1085 additional_bindings: vec![],
1086 },
1087 http_pattern: HttpPattern::parse("/schemas"),
1088 documentation: None,
1089 },
1090 MethodMetadata {
1091 service_name: "SchemaService".to_string(),
1092 method_name: "GetSchema".to_string(),
1093 input_type: "GetSchemaRequest".to_string(),
1094 output_type: "Schema".to_string(),
1095 operation: None,
1096 http_rule: HttpRule {
1097 selector: "".to_string(),
1098 pattern: Some(Pattern::Get("/schemas/{full_name}".to_string())),
1099 body: "".to_string(),
1100 response_body: "".to_string(),
1101 additional_bindings: vec![],
1102 },
1103 http_pattern: HttpPattern::parse("/schemas/{full_name}"),
1104 documentation: None,
1105 },
1106 ],
1107 };
1108
1109 let table_svc = ServiceInfo {
1110 name: "TableService".to_string(),
1111 package: "example.v1".to_string(),
1112 documentation: None,
1113 methods: vec![
1114 MethodMetadata {
1115 service_name: "TableService".to_string(),
1116 method_name: "ListTables".to_string(),
1117 input_type: "ListTablesRequest".to_string(),
1118 output_type: "ListTablesResponse".to_string(),
1119 operation: None,
1120 http_rule: HttpRule {
1121 selector: "".to_string(),
1122 pattern: Some(Pattern::Get("/tables".to_string())),
1123 body: "".to_string(),
1124 response_body: "".to_string(),
1125 additional_bindings: vec![],
1126 },
1127 http_pattern: HttpPattern::parse("/tables"),
1128 documentation: None,
1129 },
1130 MethodMetadata {
1131 service_name: "TableService".to_string(),
1132 method_name: "GetTable".to_string(),
1133 input_type: "GetTableRequest".to_string(),
1134 output_type: "Table".to_string(),
1135 operation: None,
1136 http_rule: HttpRule {
1137 selector: "".to_string(),
1138 pattern: Some(Pattern::Get("/tables/{full_name}".to_string())),
1139 body: "".to_string(),
1140 response_body: "".to_string(),
1141 additional_bindings: vec![],
1142 },
1143 http_pattern: HttpPattern::parse("/tables/{full_name}"),
1144 documentation: None,
1145 },
1146 ],
1147 };
1148
1149 (metadata, catalog_svc, schema_svc, table_svc)
1150 }
1151
1152 fn make_plans_from_fixture() -> (Vec<ServicePlan>, CodeGenMetadata) {
1154 let (metadata, catalog_svc, schema_svc, table_svc) = make_three_level_metadata();
1155 let mut plans = Vec::new();
1156 for svc in &[&catalog_svc, &schema_svc, &table_svc] {
1157 let (plan, _) = analyze_service(&metadata, svc).unwrap();
1158 plans.push(plan);
1159 }
1160 (plans, metadata)
1161 }
1162
1163 #[test]
1164 fn test_build_global_parent_map() {
1165 let (plans, metadata) = make_plans_from_fixture();
1166 let map = build_global_parent_map(&plans, &metadata);
1167
1168 assert_eq!(
1170 map.get(&(
1171 "example.io/Catalog".to_string(),
1172 "example.io/Schema".to_string()
1173 )),
1174 Some(&"catalog_name".to_string()),
1175 "Catalog→Schema mapping missing"
1176 );
1177 assert_eq!(
1179 map.get(&(
1180 "example.io/Schema".to_string(),
1181 "example.io/Table".to_string()
1182 )),
1183 Some(&"schema_name".to_string()),
1184 "Schema→Table mapping missing"
1185 );
1186 assert_eq!(
1188 map.get(&(
1189 "example.io/Catalog".to_string(),
1190 "example.io/Table".to_string()
1191 )),
1192 Some(&"catalog_name".to_string()),
1193 "Catalog→Table flat-API mapping missing"
1194 );
1195 }
1196
1197 #[test]
1198 fn test_derive_ordered_hierarchy_three_levels() {
1199 let (plans, metadata) = make_plans_from_fixture();
1200 let map = build_global_parent_map(&plans, &metadata);
1201 let table_plan = plans
1202 .iter()
1203 .find(|p| p.service_name == "TableService")
1204 .unwrap();
1205 let hierarchy = derive_ordered_hierarchy(table_plan, &map, &metadata)
1206 .expect("no cycles in test fixture");
1207
1208 assert_eq!(hierarchy.len(), 2, "expected 2 ancestors for Table");
1209 assert_eq!(hierarchy[0].parent_field_name, "catalog_name");
1211 assert_eq!(hierarchy[1].parent_field_name, "schema_name");
1212 assert_eq!(hierarchy[0].parent_resource_type, "example.io/Catalog");
1213 assert_eq!(hierarchy[1].parent_resource_type, "example.io/Schema");
1214 assert_eq!(hierarchy[0].parent_singular, Some("catalog".to_string()));
1215 assert_eq!(hierarchy[1].parent_singular, Some("schema".to_string()));
1216 assert!(
1218 hierarchy
1219 .iter()
1220 .all(|h| h.child_resource_type == "example.io/Table")
1221 );
1222 }
1223
1224 #[test]
1225 fn test_derive_ordered_hierarchy_two_levels() {
1226 let (plans, metadata) = make_plans_from_fixture();
1227 let map = build_global_parent_map(&plans, &metadata);
1228 let schema_plan = plans
1229 .iter()
1230 .find(|p| p.service_name == "SchemaService")
1231 .unwrap();
1232 let hierarchy = derive_ordered_hierarchy(schema_plan, &map, &metadata)
1233 .expect("no cycles in test fixture");
1234
1235 assert_eq!(hierarchy.len(), 1);
1236 assert_eq!(hierarchy[0].parent_field_name, "catalog_name");
1237 assert_eq!(hierarchy[0].parent_resource_type, "example.io/Catalog");
1238 }
1239
1240 #[test]
1241 fn test_derive_ordered_hierarchy_root_resource() {
1242 let (plans, metadata) = make_plans_from_fixture();
1243 let map = build_global_parent_map(&plans, &metadata);
1244 let catalog_plan = plans
1245 .iter()
1246 .find(|p| p.service_name == "CatalogService")
1247 .unwrap();
1248 let hierarchy = derive_ordered_hierarchy(catalog_plan, &map, &metadata)
1249 .expect("no cycles in test fixture");
1250
1251 assert!(
1252 hierarchy.is_empty(),
1253 "root resource should have empty hierarchy"
1254 );
1255 }
1256
1257 #[test]
1258 fn test_compute_depth_cycle_guard() {
1259 let mut map: GlobalParentMap = HashMap::new();
1261 map.insert(("A".to_string(), "B".to_string()), "a_name".to_string());
1262 map.insert(("B".to_string(), "A".to_string()), "b_name".to_string());
1263
1264 assert!(compute_depth("A", &map, &mut HashSet::new()).is_err());
1265 assert!(compute_depth("B", &map, &mut HashSet::new()).is_err());
1266 }
1267
1268 #[test]
1269 fn test_full_analyze_metadata_hierarchy_ordering() {
1270 let (metadata, catalog_svc, schema_svc, table_svc) = make_three_level_metadata();
1271 let mut services_map = HashMap::new();
1272 services_map.insert("CatalogService".to_string(), catalog_svc);
1273 services_map.insert("SchemaService".to_string(), schema_svc);
1274 services_map.insert("TableService".to_string(), table_svc);
1275 let full_metadata = CodeGenMetadata {
1276 messages: metadata.messages,
1277 services: services_map,
1278 ..Default::default()
1279 };
1280
1281 let plan = analyze_metadata(&full_metadata).unwrap();
1282 let table_svc_plan = plan
1283 .services
1284 .iter()
1285 .find(|s| s.service_name == "TableService")
1286 .expect("TableService plan not found");
1287
1288 assert_eq!(table_svc_plan.hierarchy.len(), 2);
1289 assert_eq!(
1290 table_svc_plan.hierarchy[0].parent_field_name,
1291 "catalog_name"
1292 );
1293 assert_eq!(table_svc_plan.hierarchy[1].parent_field_name, "schema_name");
1294 }
1295
1296 #[test]
1297 fn resource_accessor_params_nested_and_flat() {
1298 let (metadata, catalog_svc, schema_svc, table_svc) = make_three_level_metadata();
1299 let mut services_map = HashMap::new();
1300 services_map.insert("CatalogService".to_string(), catalog_svc);
1301 services_map.insert("SchemaService".to_string(), schema_svc);
1302 services_map.insert("TableService".to_string(), table_svc);
1303 let full_metadata = CodeGenMetadata {
1304 messages: metadata.messages,
1305 services: services_map,
1306 ..Default::default()
1307 };
1308
1309 let plan = analyze_metadata(&full_metadata).unwrap();
1310 let params = |name: &str| {
1311 plan.services
1312 .iter()
1313 .find(|s| s.service_name == name)
1314 .unwrap()
1315 .resource_accessor_params
1316 .clone()
1317 };
1318
1319 assert_eq!(
1321 params("TableService"),
1322 Some(vec![
1323 "catalog_name".to_string(),
1324 "schema_name".to_string(),
1325 "table_name".to_string(),
1326 ])
1327 );
1328 assert_eq!(
1330 params("SchemaService"),
1331 Some(vec!["catalog_name".to_string(), "schema_name".to_string()])
1332 );
1333 assert_eq!(
1335 params("CatalogService"),
1336 Some(vec!["catalog_name".to_string()])
1337 );
1338 }
1339
1340 #[test]
1341 fn direct_children_via_accessor_param_prefix() {
1342 let (metadata, catalog_svc, schema_svc, table_svc) = make_three_level_metadata();
1343 let mut services_map = HashMap::new();
1344 services_map.insert("CatalogService".to_string(), catalog_svc);
1345 services_map.insert("SchemaService".to_string(), schema_svc);
1346 services_map.insert("TableService".to_string(), table_svc);
1347 let full_metadata = CodeGenMetadata {
1348 messages: metadata.messages,
1349 services: services_map,
1350 ..Default::default()
1351 };
1352
1353 let plan = analyze_metadata(&full_metadata).unwrap();
1354 let children = |name: &str| -> Vec<String> {
1355 plan.services
1356 .iter()
1357 .find(|s| s.service_name == name)
1358 .unwrap()
1359 .direct_children
1360 .iter()
1361 .map(|c| c.child_singular.clone())
1362 .collect()
1363 };
1364
1365 assert_eq!(children("CatalogService"), vec!["schema".to_string()]);
1368 assert_eq!(children("SchemaService"), vec!["table".to_string()]);
1370 assert!(children("TableService").is_empty());
1372
1373 let catalog = plan
1375 .services
1376 .iter()
1377 .find(|s| s.service_name == "CatalogService")
1378 .unwrap();
1379 let schema_link = &catalog.direct_children[0];
1380 let schema_base_path = plan
1382 .services
1383 .iter()
1384 .find(|s| s.service_name == "SchemaService")
1385 .unwrap()
1386 .base_path
1387 .clone();
1388 assert_eq!(schema_link.child_base_path, schema_base_path);
1389 assert_eq!(
1390 schema_link.child_accessor_params,
1391 vec!["catalog_name".to_string(), "schema_name".to_string()]
1392 );
1393 }
1394
1395 #[test]
1401 fn standard_list_fields_recognized() {
1402 for f in [
1403 "page_token",
1404 "page_size",
1405 "max_results",
1406 "order_by",
1407 "filter",
1408 ] {
1409 assert!(
1410 is_standard_list_field(f),
1411 "{f} should be a standard list field"
1412 );
1413 }
1414 for f in ["name", "catalog_name", "schema_name"] {
1415 assert!(
1416 !is_standard_list_field(f),
1417 "{f} should NOT be a standard list field"
1418 );
1419 }
1420 }
1421}