1use std::collections::HashMap;
17
18use prost::Message;
19
20use crate::descriptor::{
21 self, DescriptorProto, FieldDescriptorProto, FileDescriptorSet, field_type,
22};
23use crate::error;
24
25#[derive(Debug, Clone)]
30pub struct StreamingOp {
31 pub method: String,
33 pub path: String,
35}
36
37#[derive(Debug, Default)]
43#[non_exhaustive]
44pub struct ProtoMetadata {
45 pub(crate) streaming_ops: Vec<StreamingOp>,
47
48 pub(crate) operation_ids: Vec<OperationEntry>,
50
51 pub(crate) field_constraints: Vec<SchemaConstraints>,
53
54 pub(crate) enum_rewrites: Vec<EnumRewrite>,
56
57 pub(crate) redirect_paths: Vec<String>,
59
60 pub(crate) uuid_schema: Option<String>,
62
63 pub(crate) path_param_constraints: Vec<PathParamInfo>,
65
66 pub(crate) enum_value_map: HashMap<String, String>,
68}
69
70impl ProtoMetadata {
71 #[must_use]
73 pub fn streaming_ops(&self) -> &[StreamingOp] {
74 &self.streaming_ops
75 }
76
77 #[must_use]
79 pub fn operation_ids(&self) -> &[OperationEntry] {
80 &self.operation_ids
81 }
82
83 #[must_use]
85 pub fn field_constraints(&self) -> &[SchemaConstraints] {
86 &self.field_constraints
87 }
88
89 #[must_use]
91 pub fn enum_rewrites(&self) -> &[EnumRewrite] {
92 &self.enum_rewrites
93 }
94
95 #[must_use]
97 pub fn redirect_paths(&self) -> &[String] {
98 &self.redirect_paths
99 }
100
101 #[must_use]
103 pub fn uuid_schema(&self) -> Option<&str> {
104 self.uuid_schema.as_deref()
105 }
106
107 #[must_use]
109 pub fn path_param_constraints(&self) -> &[PathParamInfo] {
110 &self.path_param_constraints
111 }
112
113 #[must_use]
115 pub fn enum_value_map(&self) -> &HashMap<String, String> {
116 &self.enum_value_map
117 }
118}
119
120#[derive(Debug, Clone)]
122pub struct OperationEntry {
123 pub method_name: String,
125 pub operation_id: String,
127}
128
129#[derive(Debug, Clone)]
131pub struct SchemaConstraints {
132 pub schema: String,
134 pub fields: Vec<FieldConstraint>,
136}
137
138#[derive(Debug, Clone)]
140pub struct EnumRewrite {
141 pub schema: String,
143 pub field: String,
145 pub values: Vec<String>,
147}
148
149#[derive(Debug, Clone)]
151pub struct PathParamInfo {
152 pub path: String,
154 pub params: Vec<PathParamConstraint>,
156}
157
158#[derive(Debug, Clone)]
160pub struct PathParamConstraint {
161 pub name: String,
163 pub description: Option<String>,
165 pub is_uuid: bool,
167 pub min: Option<u64>,
169 pub max: Option<u64>,
171}
172
173#[derive(Debug, Clone)]
175pub struct FieldConstraint {
176 pub field: String,
178 pub min: Option<u64>,
180 pub max: Option<u64>,
182 pub pattern: Option<String>,
184 pub enum_values: Vec<String>,
186 pub required: bool,
188 pub is_uuid: bool,
190 pub is_numeric: bool,
192 pub signed_min: Option<i64>,
195 pub signed_max: Option<i64>,
198}
199
200pub fn discover(descriptor_bytes: &[u8]) -> error::Result<ProtoMetadata> {
210 let fdset = FileDescriptorSet::decode(descriptor_bytes)?;
211
212 let streaming_ops = extract_streaming_ops(&fdset);
213 let operation_ids = extract_operation_ids(&fdset);
214 let field_constraints = extract_field_constraints(&fdset);
215 let (enum_rewrites, enum_value_map) = extract_enum_rewrites(&fdset);
216 let redirect_paths = extract_redirect_paths(&fdset);
217 let uuid_schema = detect_uuid_schema(&fdset);
218 let path_param_constraints = extract_path_param_constraints(&fdset);
219
220 Ok(ProtoMetadata {
221 streaming_ops,
222 operation_ids,
223 field_constraints,
224 enum_rewrites,
225 redirect_paths,
226 uuid_schema,
227 path_param_constraints,
228 enum_value_map,
229 })
230}
231
232pub fn resolve_operation_ids(
246 metadata: &ProtoMetadata,
247 method_names: &[&str],
248) -> error::Result<Vec<String>> {
249 method_names
250 .iter()
251 .map(|name| resolve_single_operation_id(metadata, name))
252 .collect()
253}
254
255fn resolve_single_operation_id(metadata: &ProtoMetadata, name: &str) -> error::Result<String> {
260 if let Some((service, method)) = name.split_once('.') {
262 let qualified_id = format!("{service}_{method}");
263 return metadata
264 .operation_ids
265 .iter()
266 .find(|e| e.operation_id == qualified_id)
267 .map(|e| e.operation_id.clone())
268 .ok_or_else(|| error::Error::MethodNotFound {
269 method: name.to_string(),
270 });
271 }
272
273 let matches: Vec<&OperationEntry> = metadata
275 .operation_ids
276 .iter()
277 .filter(|e| e.method_name == *name)
278 .collect();
279
280 match matches.len() {
281 0 => Err(error::Error::MethodNotFound {
282 method: name.to_string(),
283 }),
284 1 => Ok(matches[0].operation_id.clone()),
285 _ => {
286 let services: Vec<&str> = matches.iter().map(|e| e.operation_id.as_str()).collect();
287 Err(error::Error::AmbiguousMethodName {
288 method: name.to_string(),
289 candidates: services.iter().map(ToString::to_string).collect(),
290 })
291 }
292 }
293}
294
295fn extract_streaming_ops(fdset: &FileDescriptorSet) -> Vec<StreamingOp> {
297 let mut ops = Vec::new();
298
299 for file in &fdset.file {
300 for service in &file.service {
301 for method in &service.method {
302 if !method.server_streaming.unwrap_or(false) {
303 continue;
304 }
305
306 let Some((http_method, path)) = descriptor::extract_http_pattern(method) else {
307 continue;
308 };
309
310 ops.push(StreamingOp {
311 method: http_method.to_string(),
312 path: path.to_string(),
313 });
314 }
315 }
316 }
317
318 ops
319}
320
321fn extract_operation_ids(fdset: &FileDescriptorSet) -> Vec<OperationEntry> {
323 let mut entries = Vec::new();
324
325 for file in &fdset.file {
326 for service in &file.service {
327 let service_name = service.name.as_deref().unwrap_or("");
328
329 for method in &service.method {
330 if method
331 .options
332 .as_ref()
333 .and_then(|o| o.http.as_ref())
334 .and_then(|h| h.pattern.as_ref())
335 .is_none()
336 {
337 continue;
338 }
339
340 let method_name = method.name.as_deref().unwrap_or("");
341 entries.push(OperationEntry {
342 method_name: method_name.to_string(),
343 operation_id: format!("{service_name}_{method_name}"),
344 });
345 }
346 }
347 }
348
349 entries
350}
351
352fn extract_field_constraints(fdset: &FileDescriptorSet) -> Vec<SchemaConstraints> {
354 let mut result = Vec::new();
355
356 for file in &fdset.file {
357 let package = file.package.as_deref().unwrap_or("");
358 collect_message_constraints(&mut result, package, &file.message_type);
359 }
360
361 result
362}
363
364fn collect_message_constraints(
366 result: &mut Vec<SchemaConstraints>,
367 parent_path: &str,
368 messages: &[DescriptorProto],
369) {
370 for msg in messages {
371 let msg_name = msg.name.as_deref().unwrap_or("");
372 let schema = format!("{parent_path}.{msg_name}");
373
374 let fields: Vec<FieldConstraint> =
375 msg.field.iter().filter_map(field_to_constraint).collect();
376
377 if !fields.is_empty() {
378 result.push(SchemaConstraints {
379 schema: schema.clone(),
380 fields,
381 });
382 }
383
384 collect_message_constraints(result, &schema, &msg.nested_type);
385 }
386}
387
388#[allow(
390 clippy::too_many_lines,
391 clippy::case_sensitive_file_extension_comparisons
392)]
393fn field_to_constraint(field: &FieldDescriptorProto) -> Option<FieldConstraint> {
394 const JSON_SAFE_INT_MAX: u64 = 9_007_199_254_740_991;
395
396 let rules = field.options.as_ref()?.rules.as_ref()?;
397 let proto_name = field.name.as_deref().unwrap_or("");
398 let camel_name = snake_to_lower_camel(proto_name);
399 let field_type_id = field.r#type.unwrap_or(0);
400
401 let msg_required = rules
402 .message
403 .as_ref()
404 .and_then(|m| m.required)
405 .unwrap_or(false);
406
407 if let Some(sr) = &rules.string {
409 let has_content = sr.min_len.is_some()
410 || sr.max_len.is_some()
411 || sr.pattern.is_some()
412 || !sr.r#in.is_empty()
413 || sr.uuid.unwrap_or(false);
414
415 if has_content || msg_required {
416 let implied_required = sr.min_len.unwrap_or(0) >= 1 || !sr.r#in.is_empty();
417 return Some(FieldConstraint {
418 field: camel_name,
419 min: sr.min_len,
420 max: sr.max_len,
421 signed_min: None,
422 signed_max: None,
423 pattern: sr.pattern.clone(),
424 enum_values: sr.r#in.clone(),
425 required: msg_required || implied_required,
426 is_uuid: sr.uuid.unwrap_or(false),
427 is_numeric: false,
428 });
429 }
430 }
431
432 if let Some(ir) = &rules.int32 {
434 let min = ir
435 .gte
436 .map(i64::from)
437 .or(ir.gt.map(|v| i64::from(v).saturating_add(1)));
438 let max = ir
439 .lte
440 .map(i64::from)
441 .or(ir.lt.map(|v| i64::from(v).saturating_sub(1)));
442 if min.is_some() || max.is_some() {
443 return Some(FieldConstraint {
444 field: camel_name,
445 min: None,
446 max: None,
447 signed_min: min,
448 signed_max: max,
449 pattern: None,
450 enum_values: Vec::new(),
451 required: msg_required,
452 is_uuid: false,
453 is_numeric: true,
454 });
455 }
456 }
457
458 if let Some(ur) = &rules.uint32 {
460 let min = ur
461 .gte
462 .map(u64::from)
463 .or(ur.gt.map(|v| u64::from(v).saturating_add(1)));
464 let max = ur
465 .lte
466 .map(u64::from)
467 .or(ur.lt.map(|v| u64::from(v).saturating_sub(1)));
468 if min.is_some() || max.is_some() {
469 return Some(FieldConstraint {
470 field: camel_name,
471 min,
472 max,
473 signed_min: None,
474 signed_max: None,
475 pattern: None,
476 enum_values: Vec::new(),
477 required: msg_required,
478 is_uuid: false,
479 is_numeric: true,
480 });
481 }
482 }
483
484 if let Some(u64r) = &rules.uint64 {
487 let min = u64r.gte.or(u64r.gt.map(|v| v.saturating_add(1)));
488 let max = u64r.lte.or(u64r.lt.map(|v| v.saturating_sub(1)));
489 let min_val = min.unwrap_or(0);
490 let max_val = max;
491
492 let fits_in_json = max_val.is_some_and(|m| m <= JSON_SAFE_INT_MAX);
493
494 if fits_in_json || msg_required {
495 return Some(FieldConstraint {
496 field: camel_name,
497 min: if fits_in_json && min_val > 0 {
498 Some(min_val)
499 } else {
500 None
501 },
502 max: if fits_in_json { max_val } else { None },
503 signed_min: None,
504 signed_max: None,
505 pattern: None,
506 enum_values: Vec::new(),
507 required: msg_required,
508 is_uuid: false,
509 is_numeric: fits_in_json,
510 });
511 }
512 }
513
514 if let Some(er) = &rules.r#enum {
516 let enum_required = er.not_in.contains(&0);
517 if enum_required || msg_required {
518 return Some(FieldConstraint {
519 field: camel_name,
520 min: None,
521 max: None,
522 signed_min: None,
523 signed_max: None,
524 pattern: None,
525 enum_values: Vec::new(),
526 required: enum_required || msg_required,
527 is_uuid: false,
528 is_numeric: false,
529 });
530 }
531 }
532
533 if msg_required {
535 let is_uuid = field_type_id == field_type::MESSAGE
536 && field
537 .type_name
538 .as_deref()
539 .is_some_and(|t| t.ends_with(".UUID")); return Some(FieldConstraint {
542 field: camel_name,
543 min: None,
544 max: None,
545 signed_min: None,
546 signed_max: None,
547 pattern: None,
548 enum_values: Vec::new(),
549 required: true,
550 is_uuid,
551 is_numeric: false,
552 });
553 }
554
555 None
556}
557
558fn extract_enum_rewrites(fdset: &FileDescriptorSet) -> (Vec<EnumRewrite>, HashMap<String, String>) {
560 let mut prefix_enums: Vec<(String, String, Vec<String>)> = Vec::new();
561
562 for file in &fdset.file {
563 let package = file.package.as_deref().unwrap_or("");
564 for enum_desc in &file.enum_type {
565 let enum_name = enum_desc.name.as_deref().unwrap_or("");
566 let fqn = format!(".{package}.{enum_name}");
567
568 let values: Vec<&str> = enum_desc
569 .value
570 .iter()
571 .filter_map(|v| v.name.as_deref())
572 .collect();
573
574 let Some(detected_prefix) = detect_enum_prefix(&values) else {
575 continue;
576 };
577
578 if values.iter().all(|v| v.starts_with(&detected_prefix)) {
579 let stripped: Vec<String> = values
580 .iter()
581 .map(|v| v[detected_prefix.len()..].to_lowercase())
582 .collect();
583 prefix_enums.push((fqn, detected_prefix, stripped));
584 }
585 }
586 }
587
588 if prefix_enums.is_empty() {
589 return (Vec::new(), HashMap::new());
590 }
591
592 let mut enum_value_map = HashMap::new();
594 for file in &fdset.file {
595 for enum_desc in &file.enum_type {
596 let values: Vec<&str> = enum_desc
597 .value
598 .iter()
599 .filter_map(|v| v.name.as_deref())
600 .collect();
601
602 let Some(detected_prefix) = detect_enum_prefix(&values) else {
603 continue;
604 };
605
606 for raw in &values {
607 if let Some(suffix) = raw.strip_prefix(detected_prefix.as_str()) {
608 enum_value_map.insert(raw.to_string(), suffix.to_lowercase());
609 }
610 }
611 }
612 }
613
614 let mut rewrites = Vec::new();
616
617 for file in &fdset.file {
618 let package = file.package.as_deref().unwrap_or("");
619 collect_enum_rewrite_fields(&mut rewrites, package, &file.message_type, &prefix_enums);
620 }
621
622 (rewrites, enum_value_map)
623}
624
625fn collect_enum_rewrite_fields(
627 rewrites: &mut Vec<EnumRewrite>,
628 parent_path: &str,
629 messages: &[DescriptorProto],
630 prefix_enums: &[(String, String, Vec<String>)],
631) {
632 for msg in messages {
633 let msg_name = msg.name.as_deref().unwrap_or("");
634 let schema = format!("{parent_path}.{msg_name}");
635
636 for field in &msg.field {
637 if field.r#type != Some(field_type::ENUM) {
638 continue;
639 }
640
641 let Some(type_name) = field.type_name.as_deref() else {
642 continue;
643 };
644
645 if let Some((_, _, stripped_values)) =
646 prefix_enums.iter().find(|(fqn, _, _)| fqn == type_name)
647 {
648 let field_name = snake_to_lower_camel(field.name.as_deref().unwrap_or(""));
649
650 rewrites.push(EnumRewrite {
651 schema: schema.clone(),
652 field: field_name,
653 values: stripped_values.clone(),
654 });
655 }
656 }
657
658 collect_enum_rewrite_fields(rewrites, &schema, &msg.nested_type, prefix_enums);
659 }
660}
661
662fn detect_enum_prefix(values: &[&str]) -> Option<String> {
666 if values.is_empty() {
667 return None;
668 }
669
670 let first = values[0];
671 let common_len = first
672 .char_indices()
673 .find(|&(i, _)| values[1..].iter().any(|v| !v[..].starts_with(&first[..=i])))
674 .map_or(first.len(), |(i, _)| i);
675
676 let prefix = &first[..common_len];
677 let last_underscore = prefix.rfind('_')?;
678 let prefix = &first[..=last_underscore];
679
680 if prefix.len() < 3 {
681 return None;
682 }
683
684 Some(prefix.to_string())
685}
686
687fn extract_redirect_paths(fdset: &FileDescriptorSet) -> Vec<String> {
689 let mut redirect_types: Vec<String> = Vec::new();
690 for file in &fdset.file {
691 let package = file.package.as_deref().unwrap_or("");
692 collect_redirect_message_types(&mut redirect_types, package, &file.message_type);
693 }
694
695 if redirect_types.is_empty() {
696 return Vec::new();
697 }
698
699 let mut paths = Vec::new();
700 for file in &fdset.file {
701 for service in &file.service {
702 for method in &service.method {
703 let output_type = method.output_type.as_deref().unwrap_or("");
704 if !redirect_types.iter().any(|t| t == output_type) {
705 continue;
706 }
707
708 if let Some((_, path)) = descriptor::extract_http_pattern(method) {
709 paths.push(path.to_string());
710 }
711 }
712 }
713 }
714
715 paths
716}
717
718fn collect_redirect_message_types(
720 result: &mut Vec<String>,
721 package: &str,
722 messages: &[DescriptorProto],
723) {
724 for msg in messages {
725 let msg_name = msg.name.as_deref().unwrap_or("");
726 let has_redirect_url = msg
727 .field
728 .iter()
729 .any(|f| f.name.as_deref() == Some("redirect_url"));
730
731 if has_redirect_url {
732 result.push(format!(".{package}.{msg_name}"));
733 }
734
735 collect_redirect_message_types(result, package, &msg.nested_type);
736 }
737}
738
739fn detect_uuid_schema(fdset: &FileDescriptorSet) -> Option<String> {
741 for file in &fdset.file {
742 let package = file.package.as_deref().unwrap_or("");
743 for msg in &file.message_type {
744 let msg_name = msg.name.as_deref().unwrap_or("");
745
746 if msg.field.len() != 1 {
747 continue;
748 }
749 let field = &msg.field[0];
750 if field.name.as_deref() != Some("value") || field.r#type != Some(field_type::STRING) {
751 continue;
752 }
753
754 let has_uuid_pattern = field
755 .options
756 .as_ref()
757 .and_then(|o| o.rules.as_ref())
758 .and_then(|r| r.string.as_ref())
759 .and_then(|s| s.pattern.as_deref())
760 .is_some_and(|p| p.contains("0-9a-fA-F"));
761
762 if has_uuid_pattern {
763 return Some(format!("{package}.{msg_name}"));
764 }
765 }
766 }
767 None
768}
769
770#[allow(clippy::case_sensitive_file_extension_comparisons)] fn extract_path_param_constraints(fdset: &FileDescriptorSet) -> Vec<PathParamInfo> {
773 let mut messages: HashMap<String, &[FieldDescriptorProto]> = HashMap::new();
774 for file in &fdset.file {
775 let package = file.package.as_deref().unwrap_or("");
776 collect_message_fields(&mut messages, package, &file.message_type);
777 }
778
779 let mut result = Vec::new();
780
781 for file in &fdset.file {
782 for service in &file.service {
783 for method in &service.method {
784 let Some((_, path)) = descriptor::extract_http_pattern(method) else {
785 continue;
786 };
787
788 let param_names: Vec<&str> = path
789 .split('{')
790 .skip(1)
791 .filter_map(|s| s.split('}').next())
792 .collect();
793
794 if param_names.is_empty() {
795 continue;
796 }
797
798 let input_type = method.input_type.as_deref().unwrap_or("");
799 let fields = messages.get(input_type).copied().unwrap_or_default();
800
801 let params: Vec<PathParamConstraint> = param_names
802 .iter()
803 .filter_map(|¶m| {
804 let root_field = param.split('.').next().unwrap_or(param);
805 let field = fields
806 .iter()
807 .find(|f| f.name.as_deref() == Some(root_field))?;
808
809 let is_uuid = field.r#type == Some(field_type::MESSAGE)
810 && field
811 .type_name
812 .as_deref()
813 .is_some_and(|t| t.ends_with(".UUID")); let (min, max) = field
816 .options
817 .as_ref()
818 .and_then(|o| o.rules.as_ref())
819 .and_then(|rules| rules.string.as_ref())
820 .map(|s| (s.min_len, s.max_len))
821 .unwrap_or_default();
822
823 Some(PathParamConstraint {
824 name: param
825 .split('.')
826 .enumerate()
827 .map(|(i, seg)| {
828 if i == 0 {
829 snake_to_lower_camel(seg)
830 } else {
831 seg.to_string()
832 }
833 })
834 .collect::<Vec<_>>()
835 .join("."),
836 description: None,
837 is_uuid,
838 min,
839 max,
840 })
841 })
842 .collect();
843
844 if !params.is_empty() {
845 let gnostic_path = convert_path_template_to_camel(path);
846 result.push(PathParamInfo {
847 path: gnostic_path,
848 params,
849 });
850 }
851 }
852 }
853 }
854
855 result
856}
857
858fn convert_path_template_to_camel(path: &str) -> String {
860 let mut result = String::with_capacity(path.len());
861 let mut rest = path;
862
863 while let Some(start) = rest.find('{') {
864 let Some(end) = rest[start..].find('}') else {
865 break;
866 };
867 let end = start + end;
868
869 result.push_str(&rest[..=start]);
870 let var = &rest[start + 1..end];
871
872 if let Some((root, suffix)) = var.split_once('.') {
873 result.push_str(&snake_to_lower_camel(root));
874 result.push('.');
875 result.push_str(suffix);
876 } else {
877 result.push_str(&snake_to_lower_camel(var));
878 }
879
880 result.push('}');
881 rest = &rest[end + 1..];
882 }
883
884 result.push_str(rest);
885 result
886}
887
888fn collect_message_fields<'a>(
890 result: &mut HashMap<String, &'a [FieldDescriptorProto]>,
891 parent_path: &str,
892 messages: &'a [DescriptorProto],
893) {
894 for msg in messages {
895 let msg_name = msg.name.as_deref().unwrap_or("");
896 let fqn = format!(".{parent_path}.{msg_name}");
897 result.insert(fqn.clone(), &msg.field);
898 collect_message_fields(result, &fqn[1..], &msg.nested_type);
899 }
900}
901
902pub(crate) fn snake_to_lower_camel(s: &str) -> String {
904 let mut result = String::new();
905 let mut capitalize_next = false;
906 for c in s.chars() {
907 if c == '_' {
908 capitalize_next = true;
909 } else if capitalize_next {
910 result.push(c.to_ascii_uppercase());
911 capitalize_next = false;
912 } else {
913 result.push(c);
914 }
915 }
916 result
917}
918
919#[cfg(test)]
920mod tests {
921 use prost::Message as _;
922
923 use crate::descriptor::*;
924
925 use super::*;
926
927 fn make_field(name: &str, ty: i32) -> FieldDescriptorProto {
928 FieldDescriptorProto {
929 name: Some(name.to_string()),
930 r#type: Some(ty),
931 type_name: None,
932 options: None,
933 }
934 }
935
936 fn make_service_with_http(
937 service_name: &str,
938 method_name: &str,
939 pattern: HttpPattern,
940 server_streaming: bool,
941 ) -> ServiceDescriptorProto {
942 ServiceDescriptorProto {
943 name: Some(service_name.to_string()),
944 method: vec![MethodDescriptorProto {
945 name: Some(method_name.to_string()),
946 input_type: Some(".test.v1.Request".to_string()),
947 output_type: Some(".test.v1.Response".to_string()),
948 options: Some(MethodOptions {
949 http: Some(HttpRule {
950 pattern: Some(pattern),
951 body: String::new(),
952 }),
953 }),
954 client_streaming: None,
955 server_streaming: Some(server_streaming),
956 }],
957 }
958 }
959
960 fn make_fdset_with_services(services: Vec<ServiceDescriptorProto>) -> FileDescriptorSet {
961 FileDescriptorSet {
962 file: vec![FileDescriptorProto {
963 name: Some("test.proto".to_string()),
964 package: Some("test.v1".to_string()),
965 message_type: vec![DescriptorProto {
966 name: Some("Request".to_string()),
967 field: vec![make_field("name", field_type::STRING)],
968 nested_type: vec![],
969 }],
970 enum_type: vec![],
971 service: services,
972 }],
973 }
974 }
975
976 #[test]
977 fn discover_extracts_streaming_ops() {
978 let fdset = make_fdset_with_services(vec![make_service_with_http(
979 "TestService",
980 "ListItems",
981 HttpPattern::Get("/v1/items".to_string()),
982 true,
983 )]);
984 let bytes = fdset.encode_to_vec();
985 let metadata = discover(&bytes).unwrap();
986
987 assert_eq!(metadata.streaming_ops.len(), 1);
988 assert_eq!(metadata.streaming_ops[0].method, "get");
989 assert_eq!(metadata.streaming_ops[0].path, "/v1/items");
990 }
991
992 #[test]
993 fn discover_skips_non_streaming() {
994 let fdset = make_fdset_with_services(vec![make_service_with_http(
995 "TestService",
996 "GetItem",
997 HttpPattern::Get("/v1/items/{id}".to_string()),
998 false,
999 )]);
1000 let bytes = fdset.encode_to_vec();
1001 let metadata = discover(&bytes).unwrap();
1002
1003 assert!(metadata.streaming_ops.is_empty());
1004 }
1005
1006 #[test]
1007 fn discover_extracts_operation_ids() {
1008 let fdset = make_fdset_with_services(vec![make_service_with_http(
1009 "ItemService",
1010 "CreateItem",
1011 HttpPattern::Post("/v1/items".to_string()),
1012 false,
1013 )]);
1014 let bytes = fdset.encode_to_vec();
1015 let metadata = discover(&bytes).unwrap();
1016
1017 assert_eq!(metadata.operation_ids.len(), 1);
1018 assert_eq!(metadata.operation_ids[0].method_name, "CreateItem");
1019 assert_eq!(
1020 metadata.operation_ids[0].operation_id,
1021 "ItemService_CreateItem"
1022 );
1023 }
1024
1025 #[test]
1026 fn resolve_operation_ids_success() {
1027 let fdset = make_fdset_with_services(vec![make_service_with_http(
1028 "AuthService",
1029 "Authenticate",
1030 HttpPattern::Post("/v1/auth/authenticate".to_string()),
1031 false,
1032 )]);
1033 let bytes = fdset.encode_to_vec();
1034 let metadata = discover(&bytes).unwrap();
1035
1036 let resolved = resolve_operation_ids(&metadata, &["Authenticate"]).unwrap();
1037 assert_eq!(resolved, vec!["AuthService_Authenticate"]);
1038 }
1039
1040 #[test]
1041 fn resolve_operation_ids_missing() {
1042 let fdset = make_fdset_with_services(vec![]);
1043 let bytes = fdset.encode_to_vec();
1044 let metadata = discover(&bytes).unwrap();
1045
1046 let result = resolve_operation_ids(&metadata, &["NonExistent"]);
1047 assert!(result.is_err());
1048 }
1049
1050 #[test]
1051 fn resolve_qualified_service_method() {
1052 let fdset = FileDescriptorSet {
1053 file: vec![FileDescriptorProto {
1054 name: Some("test.proto".to_string()),
1055 package: Some("test.v1".to_string()),
1056 message_type: vec![DescriptorProto {
1057 name: Some("Request".to_string()),
1058 field: vec![make_field("name", field_type::STRING)],
1059 nested_type: vec![],
1060 }],
1061 enum_type: vec![],
1062 service: vec![
1063 make_service_with_http(
1064 "AuthService",
1065 "Delete",
1066 HttpPattern::Delete("/v1/auth".to_string()),
1067 false,
1068 ),
1069 make_service_with_http(
1070 "UserService",
1071 "Delete",
1072 HttpPattern::Delete("/v1/users".to_string()),
1073 false,
1074 ),
1075 ],
1076 }],
1077 };
1078 let bytes = fdset.encode_to_vec();
1079 let metadata = discover(&bytes).unwrap();
1080
1081 let resolved = resolve_operation_ids(&metadata, &["AuthService.Delete"]).unwrap();
1083 assert_eq!(resolved, vec!["AuthService_Delete"]);
1084
1085 let resolved = resolve_operation_ids(&metadata, &["UserService.Delete"]).unwrap();
1086 assert_eq!(resolved, vec!["UserService_Delete"]);
1087 }
1088
1089 #[test]
1090 fn resolve_ambiguous_bare_name_errors() {
1091 let fdset = FileDescriptorSet {
1092 file: vec![FileDescriptorProto {
1093 name: Some("test.proto".to_string()),
1094 package: Some("test.v1".to_string()),
1095 message_type: vec![DescriptorProto {
1096 name: Some("Request".to_string()),
1097 field: vec![make_field("name", field_type::STRING)],
1098 nested_type: vec![],
1099 }],
1100 enum_type: vec![],
1101 service: vec![
1102 make_service_with_http(
1103 "AuthService",
1104 "Delete",
1105 HttpPattern::Delete("/v1/auth".to_string()),
1106 false,
1107 ),
1108 make_service_with_http(
1109 "UserService",
1110 "Delete",
1111 HttpPattern::Delete("/v1/users".to_string()),
1112 false,
1113 ),
1114 ],
1115 }],
1116 };
1117 let bytes = fdset.encode_to_vec();
1118 let metadata = discover(&bytes).unwrap();
1119
1120 let result = resolve_operation_ids(&metadata, &["Delete"]);
1122 assert!(result.is_err());
1123 let err = result.unwrap_err();
1124 let err_msg = err.to_string();
1125 assert!(
1126 err_msg.contains("ambiguous"),
1127 "error should mention ambiguity: {err_msg}"
1128 );
1129 assert!(
1130 err_msg.contains("Service.Method"),
1131 "error should suggest qualified syntax: {err_msg}"
1132 );
1133 }
1134
1135 #[test]
1136 fn snake_to_lower_camel_basic() {
1137 assert_eq!(snake_to_lower_camel("device_id"), "deviceId");
1138 assert_eq!(snake_to_lower_camel("user_id"), "userId");
1139 assert_eq!(snake_to_lower_camel("name"), "name");
1140 assert_eq!(snake_to_lower_camel("client_version"), "clientVersion");
1141 }
1142
1143 #[test]
1144 fn convert_path_template_to_camel_works() {
1145 assert_eq!(
1146 convert_path_template_to_camel("/v1/sessions/{device_id}"),
1147 "/v1/sessions/{deviceId}"
1148 );
1149 assert_eq!(
1150 convert_path_template_to_camel("/v1/users/{user_id.value}"),
1151 "/v1/users/{userId.value}"
1152 );
1153 assert_eq!(convert_path_template_to_camel("/v1/items"), "/v1/items");
1154 }
1155
1156 #[test]
1157 fn detect_enum_prefix_common() {
1158 let values = ["HEALTH_STATUS_HEALTHY", "HEALTH_STATUS_UNHEALTHY"];
1159 assert_eq!(
1160 detect_enum_prefix(&values),
1161 Some("HEALTH_STATUS_".to_string())
1162 );
1163 }
1164
1165 #[test]
1166 fn detect_enum_prefix_none_for_no_common() {
1167 let values = ["FOO", "BAR"];
1168 assert_eq!(detect_enum_prefix(&values), None);
1169 }
1170
1171 #[test]
1172 fn detect_enum_prefix_empty() {
1173 let values: &[&str] = &[];
1174 assert_eq!(detect_enum_prefix(values), None);
1175 }
1176
1177 #[test]
1178 fn enum_rewrites_detected() {
1179 let fdset = FileDescriptorSet {
1180 file: vec![FileDescriptorProto {
1181 name: Some("test.proto".to_string()),
1182 package: Some("test.v1".to_string()),
1183 message_type: vec![DescriptorProto {
1184 name: Some("Response".to_string()),
1185 field: vec![FieldDescriptorProto {
1186 name: Some("status".to_string()),
1187 r#type: Some(field_type::ENUM),
1188 type_name: Some(".test.v1.Status".to_string()),
1189 options: None,
1190 }],
1191 nested_type: vec![],
1192 }],
1193 enum_type: vec![EnumDescriptorProto {
1194 name: Some("Status".to_string()),
1195 value: vec![
1196 EnumValueDescriptorProto {
1197 name: Some("STATUS_UNSPECIFIED".to_string()),
1198 number: Some(0),
1199 },
1200 EnumValueDescriptorProto {
1201 name: Some("STATUS_ACTIVE".to_string()),
1202 number: Some(1),
1203 },
1204 ],
1205 }],
1206 service: vec![],
1207 }],
1208 };
1209 let bytes = fdset.encode_to_vec();
1210 let metadata = discover(&bytes).unwrap();
1211
1212 assert_eq!(metadata.enum_rewrites.len(), 1);
1213 assert_eq!(metadata.enum_rewrites[0].schema, "test.v1.Response");
1214 assert_eq!(metadata.enum_rewrites[0].field, "status");
1215 assert_eq!(
1216 metadata.enum_rewrites[0].values,
1217 vec!["unspecified", "active"]
1218 );
1219 }
1220
1221 #[test]
1222 fn redirect_paths_detected() {
1223 let fdset = FileDescriptorSet {
1224 file: vec![FileDescriptorProto {
1225 name: Some("test.proto".to_string()),
1226 package: Some("test.v1".to_string()),
1227 message_type: vec![DescriptorProto {
1228 name: Some("RedirectResponse".to_string()),
1229 field: vec![make_field("redirect_url", field_type::STRING)],
1230 nested_type: vec![],
1231 }],
1232 enum_type: vec![],
1233 service: vec![ServiceDescriptorProto {
1234 name: Some("TestService".to_string()),
1235 method: vec![MethodDescriptorProto {
1236 name: Some("DoRedirect".to_string()),
1237 input_type: Some(".test.v1.Request".to_string()),
1238 output_type: Some(".test.v1.RedirectResponse".to_string()),
1239 options: Some(MethodOptions {
1240 http: Some(HttpRule {
1241 pattern: Some(HttpPattern::Get("/v1/redirect".to_string())),
1242 body: String::new(),
1243 }),
1244 }),
1245 client_streaming: None,
1246 server_streaming: None,
1247 }],
1248 }],
1249 }],
1250 };
1251 let bytes = fdset.encode_to_vec();
1252 let metadata = discover(&bytes).unwrap();
1253
1254 assert_eq!(metadata.redirect_paths, vec!["/v1/redirect"]);
1255 }
1256
1257 #[test]
1258 fn nested_message_constraints_use_qualified_path() {
1259 let fdset = FileDescriptorSet {
1260 file: vec![FileDescriptorProto {
1261 name: Some("test.proto".to_string()),
1262 package: Some("test.v1".to_string()),
1263 message_type: vec![DescriptorProto {
1264 name: Some("Outer".to_string()),
1265 field: vec![FieldDescriptorProto {
1266 name: Some("name".to_string()),
1267 r#type: Some(field_type::STRING),
1268 type_name: None,
1269 options: Some(FieldOptions {
1270 rules: Some(FieldRules {
1271 string: Some(StringRules {
1272 min_len: Some(1),
1273 max_len: Some(100),
1274 pattern: None,
1275 r#in: vec![],
1276 uuid: None,
1277 }),
1278 ..Default::default()
1279 }),
1280 }),
1281 }],
1282 nested_type: vec![DescriptorProto {
1283 name: Some("Inner".to_string()),
1284 field: vec![FieldDescriptorProto {
1285 name: Some("value".to_string()),
1286 r#type: Some(field_type::STRING),
1287 type_name: None,
1288 options: Some(FieldOptions {
1289 rules: Some(FieldRules {
1290 string: Some(StringRules {
1291 min_len: Some(3),
1292 max_len: None,
1293 pattern: None,
1294 r#in: vec![],
1295 uuid: None,
1296 }),
1297 ..Default::default()
1298 }),
1299 }),
1300 }],
1301 nested_type: vec![],
1302 }],
1303 }],
1304 enum_type: vec![],
1305 service: vec![],
1306 }],
1307 };
1308 let bytes = fdset.encode_to_vec();
1309 let metadata = discover(&bytes).unwrap();
1310
1311 let outer = metadata
1313 .field_constraints
1314 .iter()
1315 .find(|c| c.schema == "test.v1.Outer");
1316 assert!(outer.is_some(), "Outer constraint should exist");
1317
1318 let inner = metadata
1320 .field_constraints
1321 .iter()
1322 .find(|c| c.schema == "test.v1.Outer.Inner");
1323 assert!(
1324 inner.is_some(),
1325 "Nested Inner constraint should use fully qualified path: {:?}",
1326 metadata
1327 .field_constraints
1328 .iter()
1329 .map(|c| &c.schema)
1330 .collect::<Vec<_>>()
1331 );
1332
1333 let wrong = metadata
1335 .field_constraints
1336 .iter()
1337 .find(|c| c.schema == "test.v1.Inner");
1338 assert!(
1339 wrong.is_none(),
1340 "Should not have bare 'test.v1.Inner' schema"
1341 );
1342 }
1343
1344 #[test]
1345 fn nested_message_fields_use_qualified_fqn() {
1346 let fdset = FileDescriptorSet {
1347 file: vec![FileDescriptorProto {
1348 name: Some("test.proto".to_string()),
1349 package: Some("test.v1".to_string()),
1350 message_type: vec![DescriptorProto {
1351 name: Some("Outer".to_string()),
1352 field: vec![make_field("name", field_type::STRING)],
1353 nested_type: vec![DescriptorProto {
1354 name: Some("Inner".to_string()),
1355 field: vec![make_field("value", field_type::STRING)],
1356 nested_type: vec![],
1357 }],
1358 }],
1359 enum_type: vec![],
1360 service: vec![ServiceDescriptorProto {
1361 name: Some("Svc".to_string()),
1362 method: vec![MethodDescriptorProto {
1363 name: Some("Do".to_string()),
1364 input_type: Some(".test.v1.Outer.Inner".to_string()),
1365 output_type: Some(".test.v1.Outer".to_string()),
1366 options: Some(MethodOptions {
1367 http: Some(HttpRule {
1368 pattern: Some(HttpPattern::Get("/v1/outer/{value}".to_string())),
1369 body: String::new(),
1370 }),
1371 }),
1372 client_streaming: None,
1373 server_streaming: None,
1374 }],
1375 }],
1376 }],
1377 };
1378 let bytes = fdset.encode_to_vec();
1379 let metadata = discover(&bytes).unwrap();
1380
1381 assert!(
1383 !metadata.path_param_constraints.is_empty(),
1384 "should find path params via nested message FQN"
1385 );
1386 }
1387
1388 #[test]
1389 fn nested_enum_rewrites_use_qualified_schema() {
1390 let fdset = FileDescriptorSet {
1391 file: vec![FileDescriptorProto {
1392 name: Some("test.proto".to_string()),
1393 package: Some("test.v1".to_string()),
1394 message_type: vec![DescriptorProto {
1395 name: Some("Outer".to_string()),
1396 field: vec![],
1397 nested_type: vec![DescriptorProto {
1398 name: Some("Inner".to_string()),
1399 field: vec![FieldDescriptorProto {
1400 name: Some("status".to_string()),
1401 r#type: Some(field_type::ENUM),
1402 type_name: Some(".test.v1.Status".to_string()),
1403 options: None,
1404 }],
1405 nested_type: vec![],
1406 }],
1407 }],
1408 enum_type: vec![EnumDescriptorProto {
1409 name: Some("Status".to_string()),
1410 value: vec![
1411 EnumValueDescriptorProto {
1412 name: Some("STATUS_UNSPECIFIED".to_string()),
1413 number: Some(0),
1414 },
1415 EnumValueDescriptorProto {
1416 name: Some("STATUS_ACTIVE".to_string()),
1417 number: Some(1),
1418 },
1419 ],
1420 }],
1421 service: vec![],
1422 }],
1423 };
1424 let bytes = fdset.encode_to_vec();
1425 let metadata = discover(&bytes).unwrap();
1426
1427 assert_eq!(metadata.enum_rewrites.len(), 1);
1428 assert_eq!(
1430 metadata.enum_rewrites[0].schema, "test.v1.Outer.Inner",
1431 "nested enum rewrite should use fully qualified schema path"
1432 );
1433 }
1434
1435 #[test]
1436 fn int32_boundary_values_no_overflow() {
1437 let fdset = FileDescriptorSet {
1439 file: vec![FileDescriptorProto {
1440 name: Some("test.proto".to_string()),
1441 package: Some("test.v1".to_string()),
1442 message_type: vec![DescriptorProto {
1443 name: Some("Request".to_string()),
1444 field: vec![FieldDescriptorProto {
1445 name: Some("count".to_string()),
1446 r#type: Some(field_type::INT32),
1447 type_name: None,
1448 options: Some(FieldOptions {
1449 rules: Some(FieldRules {
1450 int32: Some(Int32Rules {
1451 gte: Some(-100),
1452 lte: Some(100),
1453 gt: None,
1454 lt: None,
1455 }),
1456 ..Default::default()
1457 }),
1458 }),
1459 }],
1460 nested_type: vec![],
1461 }],
1462 enum_type: vec![],
1463 service: vec![],
1464 }],
1465 };
1466 let bytes = fdset.encode_to_vec();
1467 let metadata = discover(&bytes).unwrap();
1468
1469 assert_eq!(metadata.field_constraints.len(), 1);
1470 let fc = &metadata.field_constraints[0].fields[0];
1471 assert_eq!(fc.signed_min, Some(-100));
1472 assert_eq!(fc.signed_max, Some(100));
1473 assert!(fc.is_numeric);
1474 }
1475
1476 #[test]
1477 fn uint32_lt_zero_no_underflow() {
1478 let fdset = FileDescriptorSet {
1480 file: vec![FileDescriptorProto {
1481 name: Some("test.proto".to_string()),
1482 package: Some("test.v1".to_string()),
1483 message_type: vec![DescriptorProto {
1484 name: Some("Request".to_string()),
1485 field: vec![FieldDescriptorProto {
1486 name: Some("count".to_string()),
1487 r#type: Some(field_type::UINT32),
1488 type_name: None,
1489 options: Some(FieldOptions {
1490 rules: Some(FieldRules {
1491 uint32: Some(UInt32Rules {
1492 lt: Some(0),
1493 lte: None,
1494 gt: None,
1495 gte: None,
1496 }),
1497 ..Default::default()
1498 }),
1499 }),
1500 }],
1501 nested_type: vec![],
1502 }],
1503 enum_type: vec![],
1504 service: vec![],
1505 }],
1506 };
1507 let bytes = fdset.encode_to_vec();
1508 let metadata = discover(&bytes).unwrap();
1510 assert_eq!(metadata.field_constraints.len(), 1);
1511 let fc = &metadata.field_constraints[0].fields[0];
1512 assert_eq!(fc.max, Some(0)); }
1514
1515 #[test]
1516 fn uint64_exclusive_bounds_converted_to_inclusive() {
1517 let fdset = FileDescriptorSet {
1520 file: vec![FileDescriptorProto {
1521 name: Some("test.proto".to_string()),
1522 package: Some("test.v1".to_string()),
1523 message_type: vec![DescriptorProto {
1524 name: Some("Request".to_string()),
1525 field: vec![FieldDescriptorProto {
1526 name: Some("content_size".to_string()),
1527 r#type: Some(field_type::UINT64),
1528 type_name: None,
1529 options: Some(FieldOptions {
1530 rules: Some(FieldRules {
1531 uint64: Some(UInt64Rules {
1532 gt: Some(0),
1533 gte: None,
1534 lt: None,
1535 lte: Some(10_485_760),
1536 }),
1537 ..Default::default()
1538 }),
1539 }),
1540 }],
1541 nested_type: vec![],
1542 }],
1543 enum_type: vec![],
1544 service: vec![],
1545 }],
1546 };
1547 let bytes = fdset.encode_to_vec();
1548 let metadata = discover(&bytes).unwrap();
1549
1550 assert_eq!(metadata.field_constraints.len(), 1);
1551 let fc = &metadata.field_constraints[0].fields[0];
1552 assert_eq!(fc.field, "contentSize");
1553 assert_eq!(fc.min, Some(1), "gt:0 should become minimum:1");
1554 assert_eq!(fc.max, Some(10_485_760));
1555 assert!(fc.is_numeric);
1556 }
1557}