1use std::collections::HashMap;
17
18use prost::Message;
19
20use crate::descriptor::{
21 self, field_type, DescriptorProto, FieldDescriptorProto, FileDescriptorSet,
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 {
486 let gt = u64r.gt.unwrap_or(0);
487 let gte = u64r.gte.unwrap_or(0);
488 let min_val = gt.max(gte);
489 let lt = u64r.lt;
490 let lte = u64r.lte;
491 let max_val = lt.or(lte);
492
493 let fits_in_json = max_val.is_some_and(|m| m <= JSON_SAFE_INT_MAX);
494
495 if fits_in_json || msg_required {
496 return Some(FieldConstraint {
497 field: camel_name,
498 min: if fits_in_json && min_val > 0 {
499 Some(min_val)
500 } else {
501 None
502 },
503 max: if fits_in_json { max_val } else { None },
504 signed_min: None,
505 signed_max: None,
506 pattern: None,
507 enum_values: Vec::new(),
508 required: msg_required,
509 is_uuid: false,
510 is_numeric: fits_in_json,
511 });
512 }
513 }
514
515 if let Some(er) = &rules.r#enum {
517 let enum_required = er.not_in.contains(&0);
518 if enum_required || msg_required {
519 return Some(FieldConstraint {
520 field: camel_name,
521 min: None,
522 max: None,
523 signed_min: None,
524 signed_max: None,
525 pattern: None,
526 enum_values: Vec::new(),
527 required: enum_required || msg_required,
528 is_uuid: false,
529 is_numeric: false,
530 });
531 }
532 }
533
534 if msg_required {
536 let is_uuid = field_type_id == field_type::MESSAGE
537 && field
538 .type_name
539 .as_deref()
540 .is_some_and(|t| t.ends_with(".UUID")); return Some(FieldConstraint {
543 field: camel_name,
544 min: None,
545 max: None,
546 signed_min: None,
547 signed_max: None,
548 pattern: None,
549 enum_values: Vec::new(),
550 required: true,
551 is_uuid,
552 is_numeric: false,
553 });
554 }
555
556 None
557}
558
559fn extract_enum_rewrites(fdset: &FileDescriptorSet) -> (Vec<EnumRewrite>, HashMap<String, String>) {
561 let mut prefix_enums: Vec<(String, String, Vec<String>)> = Vec::new();
562
563 for file in &fdset.file {
564 let package = file.package.as_deref().unwrap_or("");
565 for enum_desc in &file.enum_type {
566 let enum_name = enum_desc.name.as_deref().unwrap_or("");
567 let fqn = format!(".{package}.{enum_name}");
568
569 let values: Vec<&str> = enum_desc
570 .value
571 .iter()
572 .filter_map(|v| v.name.as_deref())
573 .collect();
574
575 let Some(detected_prefix) = detect_enum_prefix(&values) else {
576 continue;
577 };
578
579 if values.iter().all(|v| v.starts_with(&detected_prefix)) {
580 let stripped: Vec<String> = values
581 .iter()
582 .map(|v| v[detected_prefix.len()..].to_lowercase())
583 .collect();
584 prefix_enums.push((fqn, detected_prefix, stripped));
585 }
586 }
587 }
588
589 if prefix_enums.is_empty() {
590 return (Vec::new(), HashMap::new());
591 }
592
593 let mut enum_value_map = HashMap::new();
595 for file in &fdset.file {
596 for enum_desc in &file.enum_type {
597 let values: Vec<&str> = enum_desc
598 .value
599 .iter()
600 .filter_map(|v| v.name.as_deref())
601 .collect();
602
603 let Some(detected_prefix) = detect_enum_prefix(&values) else {
604 continue;
605 };
606
607 for raw in &values {
608 if let Some(suffix) = raw.strip_prefix(detected_prefix.as_str()) {
609 enum_value_map.insert(raw.to_string(), suffix.to_lowercase());
610 }
611 }
612 }
613 }
614
615 let mut rewrites = Vec::new();
617
618 for file in &fdset.file {
619 let package = file.package.as_deref().unwrap_or("");
620 collect_enum_rewrite_fields(&mut rewrites, package, &file.message_type, &prefix_enums);
621 }
622
623 (rewrites, enum_value_map)
624}
625
626fn collect_enum_rewrite_fields(
628 rewrites: &mut Vec<EnumRewrite>,
629 parent_path: &str,
630 messages: &[DescriptorProto],
631 prefix_enums: &[(String, String, Vec<String>)],
632) {
633 for msg in messages {
634 let msg_name = msg.name.as_deref().unwrap_or("");
635 let schema = format!("{parent_path}.{msg_name}");
636
637 for field in &msg.field {
638 if field.r#type != Some(field_type::ENUM) {
639 continue;
640 }
641
642 let Some(type_name) = field.type_name.as_deref() else {
643 continue;
644 };
645
646 if let Some((_, _, stripped_values)) =
647 prefix_enums.iter().find(|(fqn, _, _)| fqn == type_name)
648 {
649 let field_name = snake_to_lower_camel(field.name.as_deref().unwrap_or(""));
650
651 rewrites.push(EnumRewrite {
652 schema: schema.clone(),
653 field: field_name,
654 values: stripped_values.clone(),
655 });
656 }
657 }
658
659 collect_enum_rewrite_fields(rewrites, &schema, &msg.nested_type, prefix_enums);
660 }
661}
662
663fn detect_enum_prefix(values: &[&str]) -> Option<String> {
667 if values.is_empty() {
668 return None;
669 }
670
671 let first = values[0];
672 let common_len = first
673 .char_indices()
674 .find(|&(i, _)| values[1..].iter().any(|v| !v[..].starts_with(&first[..=i])))
675 .map_or(first.len(), |(i, _)| i);
676
677 let prefix = &first[..common_len];
678 let last_underscore = prefix.rfind('_')?;
679 let prefix = &first[..=last_underscore];
680
681 if prefix.len() < 3 {
682 return None;
683 }
684
685 Some(prefix.to_string())
686}
687
688fn extract_redirect_paths(fdset: &FileDescriptorSet) -> Vec<String> {
690 let mut redirect_types: Vec<String> = Vec::new();
691 for file in &fdset.file {
692 let package = file.package.as_deref().unwrap_or("");
693 collect_redirect_message_types(&mut redirect_types, package, &file.message_type);
694 }
695
696 if redirect_types.is_empty() {
697 return Vec::new();
698 }
699
700 let mut paths = Vec::new();
701 for file in &fdset.file {
702 for service in &file.service {
703 for method in &service.method {
704 let output_type = method.output_type.as_deref().unwrap_or("");
705 if !redirect_types.iter().any(|t| t == output_type) {
706 continue;
707 }
708
709 if let Some((_, path)) = descriptor::extract_http_pattern(method) {
710 paths.push(path.to_string());
711 }
712 }
713 }
714 }
715
716 paths
717}
718
719fn collect_redirect_message_types(
721 result: &mut Vec<String>,
722 package: &str,
723 messages: &[DescriptorProto],
724) {
725 for msg in messages {
726 let msg_name = msg.name.as_deref().unwrap_or("");
727 let has_redirect_url = msg
728 .field
729 .iter()
730 .any(|f| f.name.as_deref() == Some("redirect_url"));
731
732 if has_redirect_url {
733 result.push(format!(".{package}.{msg_name}"));
734 }
735
736 collect_redirect_message_types(result, package, &msg.nested_type);
737 }
738}
739
740fn detect_uuid_schema(fdset: &FileDescriptorSet) -> Option<String> {
742 for file in &fdset.file {
743 let package = file.package.as_deref().unwrap_or("");
744 for msg in &file.message_type {
745 let msg_name = msg.name.as_deref().unwrap_or("");
746
747 if msg.field.len() != 1 {
748 continue;
749 }
750 let field = &msg.field[0];
751 if field.name.as_deref() != Some("value") || field.r#type != Some(field_type::STRING) {
752 continue;
753 }
754
755 let has_uuid_pattern = field
756 .options
757 .as_ref()
758 .and_then(|o| o.rules.as_ref())
759 .and_then(|r| r.string.as_ref())
760 .and_then(|s| s.pattern.as_deref())
761 .is_some_and(|p| p.contains("0-9a-fA-F"));
762
763 if has_uuid_pattern {
764 return Some(format!("{package}.{msg_name}"));
765 }
766 }
767 }
768 None
769}
770
771#[allow(clippy::case_sensitive_file_extension_comparisons)] fn extract_path_param_constraints(fdset: &FileDescriptorSet) -> Vec<PathParamInfo> {
774 let mut messages: HashMap<String, &[FieldDescriptorProto]> = HashMap::new();
775 for file in &fdset.file {
776 let package = file.package.as_deref().unwrap_or("");
777 collect_message_fields(&mut messages, package, &file.message_type);
778 }
779
780 let mut result = Vec::new();
781
782 for file in &fdset.file {
783 for service in &file.service {
784 for method in &service.method {
785 let Some((_, path)) = descriptor::extract_http_pattern(method) else {
786 continue;
787 };
788
789 let param_names: Vec<&str> = path
790 .split('{')
791 .skip(1)
792 .filter_map(|s| s.split('}').next())
793 .collect();
794
795 if param_names.is_empty() {
796 continue;
797 }
798
799 let input_type = method.input_type.as_deref().unwrap_or("");
800 let fields = messages.get(input_type).copied().unwrap_or_default();
801
802 let params: Vec<PathParamConstraint> = param_names
803 .iter()
804 .filter_map(|¶m| {
805 let root_field = param.split('.').next().unwrap_or(param);
806 let field = fields
807 .iter()
808 .find(|f| f.name.as_deref() == Some(root_field))?;
809
810 let is_uuid = field.r#type == Some(field_type::MESSAGE)
811 && field
812 .type_name
813 .as_deref()
814 .is_some_and(|t| t.ends_with(".UUID")); let (min, max) = field
817 .options
818 .as_ref()
819 .and_then(|o| o.rules.as_ref())
820 .and_then(|rules| rules.string.as_ref())
821 .map(|s| (s.min_len, s.max_len))
822 .unwrap_or_default();
823
824 Some(PathParamConstraint {
825 name: param
826 .split('.')
827 .enumerate()
828 .map(|(i, seg)| {
829 if i == 0 {
830 snake_to_lower_camel(seg)
831 } else {
832 seg.to_string()
833 }
834 })
835 .collect::<Vec<_>>()
836 .join("."),
837 description: None,
838 is_uuid,
839 min,
840 max,
841 })
842 })
843 .collect();
844
845 if !params.is_empty() {
846 let gnostic_path = convert_path_template_to_camel(path);
847 result.push(PathParamInfo {
848 path: gnostic_path,
849 params,
850 });
851 }
852 }
853 }
854 }
855
856 result
857}
858
859fn convert_path_template_to_camel(path: &str) -> String {
861 let mut result = String::with_capacity(path.len());
862 let mut rest = path;
863
864 while let Some(start) = rest.find('{') {
865 let Some(end) = rest[start..].find('}') else {
866 break;
867 };
868 let end = start + end;
869
870 result.push_str(&rest[..=start]);
871 let var = &rest[start + 1..end];
872
873 if let Some((root, suffix)) = var.split_once('.') {
874 result.push_str(&snake_to_lower_camel(root));
875 result.push('.');
876 result.push_str(suffix);
877 } else {
878 result.push_str(&snake_to_lower_camel(var));
879 }
880
881 result.push('}');
882 rest = &rest[end + 1..];
883 }
884
885 result.push_str(rest);
886 result
887}
888
889fn collect_message_fields<'a>(
891 result: &mut HashMap<String, &'a [FieldDescriptorProto]>,
892 parent_path: &str,
893 messages: &'a [DescriptorProto],
894) {
895 for msg in messages {
896 let msg_name = msg.name.as_deref().unwrap_or("");
897 let fqn = format!(".{parent_path}.{msg_name}");
898 result.insert(fqn.clone(), &msg.field);
899 collect_message_fields(result, &fqn[1..], &msg.nested_type);
900 }
901}
902
903pub(crate) fn snake_to_lower_camel(s: &str) -> String {
905 let mut result = String::new();
906 let mut capitalize_next = false;
907 for c in s.chars() {
908 if c == '_' {
909 capitalize_next = true;
910 } else if capitalize_next {
911 result.push(c.to_ascii_uppercase());
912 capitalize_next = false;
913 } else {
914 result.push(c);
915 }
916 }
917 result
918}
919
920#[cfg(test)]
921mod tests {
922 use prost::Message as _;
923
924 use crate::descriptor::*;
925
926 use super::*;
927
928 fn make_field(name: &str, ty: i32) -> FieldDescriptorProto {
929 FieldDescriptorProto {
930 name: Some(name.to_string()),
931 r#type: Some(ty),
932 type_name: None,
933 options: None,
934 }
935 }
936
937 fn make_service_with_http(
938 service_name: &str,
939 method_name: &str,
940 pattern: HttpPattern,
941 server_streaming: bool,
942 ) -> ServiceDescriptorProto {
943 ServiceDescriptorProto {
944 name: Some(service_name.to_string()),
945 method: vec![MethodDescriptorProto {
946 name: Some(method_name.to_string()),
947 input_type: Some(".test.v1.Request".to_string()),
948 output_type: Some(".test.v1.Response".to_string()),
949 options: Some(MethodOptions {
950 http: Some(HttpRule {
951 pattern: Some(pattern),
952 body: String::new(),
953 }),
954 }),
955 client_streaming: None,
956 server_streaming: Some(server_streaming),
957 }],
958 }
959 }
960
961 fn make_fdset_with_services(services: Vec<ServiceDescriptorProto>) -> FileDescriptorSet {
962 FileDescriptorSet {
963 file: vec![FileDescriptorProto {
964 name: Some("test.proto".to_string()),
965 package: Some("test.v1".to_string()),
966 message_type: vec![DescriptorProto {
967 name: Some("Request".to_string()),
968 field: vec![make_field("name", field_type::STRING)],
969 nested_type: vec![],
970 }],
971 enum_type: vec![],
972 service: services,
973 }],
974 }
975 }
976
977 #[test]
978 fn discover_extracts_streaming_ops() {
979 let fdset = make_fdset_with_services(vec![make_service_with_http(
980 "TestService",
981 "ListItems",
982 HttpPattern::Get("/v1/items".to_string()),
983 true,
984 )]);
985 let bytes = fdset.encode_to_vec();
986 let metadata = discover(&bytes).unwrap();
987
988 assert_eq!(metadata.streaming_ops.len(), 1);
989 assert_eq!(metadata.streaming_ops[0].method, "get");
990 assert_eq!(metadata.streaming_ops[0].path, "/v1/items");
991 }
992
993 #[test]
994 fn discover_skips_non_streaming() {
995 let fdset = make_fdset_with_services(vec![make_service_with_http(
996 "TestService",
997 "GetItem",
998 HttpPattern::Get("/v1/items/{id}".to_string()),
999 false,
1000 )]);
1001 let bytes = fdset.encode_to_vec();
1002 let metadata = discover(&bytes).unwrap();
1003
1004 assert!(metadata.streaming_ops.is_empty());
1005 }
1006
1007 #[test]
1008 fn discover_extracts_operation_ids() {
1009 let fdset = make_fdset_with_services(vec![make_service_with_http(
1010 "ItemService",
1011 "CreateItem",
1012 HttpPattern::Post("/v1/items".to_string()),
1013 false,
1014 )]);
1015 let bytes = fdset.encode_to_vec();
1016 let metadata = discover(&bytes).unwrap();
1017
1018 assert_eq!(metadata.operation_ids.len(), 1);
1019 assert_eq!(metadata.operation_ids[0].method_name, "CreateItem");
1020 assert_eq!(
1021 metadata.operation_ids[0].operation_id,
1022 "ItemService_CreateItem"
1023 );
1024 }
1025
1026 #[test]
1027 fn resolve_operation_ids_success() {
1028 let fdset = make_fdset_with_services(vec![make_service_with_http(
1029 "AuthService",
1030 "Authenticate",
1031 HttpPattern::Post("/v1/auth/authenticate".to_string()),
1032 false,
1033 )]);
1034 let bytes = fdset.encode_to_vec();
1035 let metadata = discover(&bytes).unwrap();
1036
1037 let resolved = resolve_operation_ids(&metadata, &["Authenticate"]).unwrap();
1038 assert_eq!(resolved, vec!["AuthService_Authenticate"]);
1039 }
1040
1041 #[test]
1042 fn resolve_operation_ids_missing() {
1043 let fdset = make_fdset_with_services(vec![]);
1044 let bytes = fdset.encode_to_vec();
1045 let metadata = discover(&bytes).unwrap();
1046
1047 let result = resolve_operation_ids(&metadata, &["NonExistent"]);
1048 assert!(result.is_err());
1049 }
1050
1051 #[test]
1052 fn resolve_qualified_service_method() {
1053 let fdset = FileDescriptorSet {
1054 file: vec![FileDescriptorProto {
1055 name: Some("test.proto".to_string()),
1056 package: Some("test.v1".to_string()),
1057 message_type: vec![DescriptorProto {
1058 name: Some("Request".to_string()),
1059 field: vec![make_field("name", field_type::STRING)],
1060 nested_type: vec![],
1061 }],
1062 enum_type: vec![],
1063 service: vec![
1064 make_service_with_http(
1065 "AuthService",
1066 "Delete",
1067 HttpPattern::Delete("/v1/auth".to_string()),
1068 false,
1069 ),
1070 make_service_with_http(
1071 "UserService",
1072 "Delete",
1073 HttpPattern::Delete("/v1/users".to_string()),
1074 false,
1075 ),
1076 ],
1077 }],
1078 };
1079 let bytes = fdset.encode_to_vec();
1080 let metadata = discover(&bytes).unwrap();
1081
1082 let resolved = resolve_operation_ids(&metadata, &["AuthService.Delete"]).unwrap();
1084 assert_eq!(resolved, vec!["AuthService_Delete"]);
1085
1086 let resolved = resolve_operation_ids(&metadata, &["UserService.Delete"]).unwrap();
1087 assert_eq!(resolved, vec!["UserService_Delete"]);
1088 }
1089
1090 #[test]
1091 fn resolve_ambiguous_bare_name_errors() {
1092 let fdset = FileDescriptorSet {
1093 file: vec![FileDescriptorProto {
1094 name: Some("test.proto".to_string()),
1095 package: Some("test.v1".to_string()),
1096 message_type: vec![DescriptorProto {
1097 name: Some("Request".to_string()),
1098 field: vec![make_field("name", field_type::STRING)],
1099 nested_type: vec![],
1100 }],
1101 enum_type: vec![],
1102 service: vec![
1103 make_service_with_http(
1104 "AuthService",
1105 "Delete",
1106 HttpPattern::Delete("/v1/auth".to_string()),
1107 false,
1108 ),
1109 make_service_with_http(
1110 "UserService",
1111 "Delete",
1112 HttpPattern::Delete("/v1/users".to_string()),
1113 false,
1114 ),
1115 ],
1116 }],
1117 };
1118 let bytes = fdset.encode_to_vec();
1119 let metadata = discover(&bytes).unwrap();
1120
1121 let result = resolve_operation_ids(&metadata, &["Delete"]);
1123 assert!(result.is_err());
1124 let err = result.unwrap_err();
1125 let err_msg = err.to_string();
1126 assert!(
1127 err_msg.contains("ambiguous"),
1128 "error should mention ambiguity: {err_msg}"
1129 );
1130 assert!(
1131 err_msg.contains("Service.Method"),
1132 "error should suggest qualified syntax: {err_msg}"
1133 );
1134 }
1135
1136 #[test]
1137 fn snake_to_lower_camel_basic() {
1138 assert_eq!(snake_to_lower_camel("device_id"), "deviceId");
1139 assert_eq!(snake_to_lower_camel("user_id"), "userId");
1140 assert_eq!(snake_to_lower_camel("name"), "name");
1141 assert_eq!(snake_to_lower_camel("client_version"), "clientVersion");
1142 }
1143
1144 #[test]
1145 fn convert_path_template_to_camel_works() {
1146 assert_eq!(
1147 convert_path_template_to_camel("/v1/sessions/{device_id}"),
1148 "/v1/sessions/{deviceId}"
1149 );
1150 assert_eq!(
1151 convert_path_template_to_camel("/v1/users/{user_id.value}"),
1152 "/v1/users/{userId.value}"
1153 );
1154 assert_eq!(convert_path_template_to_camel("/v1/items"), "/v1/items");
1155 }
1156
1157 #[test]
1158 fn detect_enum_prefix_common() {
1159 let values = ["HEALTH_STATUS_HEALTHY", "HEALTH_STATUS_UNHEALTHY"];
1160 assert_eq!(
1161 detect_enum_prefix(&values),
1162 Some("HEALTH_STATUS_".to_string())
1163 );
1164 }
1165
1166 #[test]
1167 fn detect_enum_prefix_none_for_no_common() {
1168 let values = ["FOO", "BAR"];
1169 assert_eq!(detect_enum_prefix(&values), None);
1170 }
1171
1172 #[test]
1173 fn detect_enum_prefix_empty() {
1174 let values: &[&str] = &[];
1175 assert_eq!(detect_enum_prefix(values), None);
1176 }
1177
1178 #[test]
1179 fn enum_rewrites_detected() {
1180 let fdset = FileDescriptorSet {
1181 file: vec![FileDescriptorProto {
1182 name: Some("test.proto".to_string()),
1183 package: Some("test.v1".to_string()),
1184 message_type: vec![DescriptorProto {
1185 name: Some("Response".to_string()),
1186 field: vec![FieldDescriptorProto {
1187 name: Some("status".to_string()),
1188 r#type: Some(field_type::ENUM),
1189 type_name: Some(".test.v1.Status".to_string()),
1190 options: None,
1191 }],
1192 nested_type: vec![],
1193 }],
1194 enum_type: vec![EnumDescriptorProto {
1195 name: Some("Status".to_string()),
1196 value: vec![
1197 EnumValueDescriptorProto {
1198 name: Some("STATUS_UNSPECIFIED".to_string()),
1199 number: Some(0),
1200 },
1201 EnumValueDescriptorProto {
1202 name: Some("STATUS_ACTIVE".to_string()),
1203 number: Some(1),
1204 },
1205 ],
1206 }],
1207 service: vec![],
1208 }],
1209 };
1210 let bytes = fdset.encode_to_vec();
1211 let metadata = discover(&bytes).unwrap();
1212
1213 assert_eq!(metadata.enum_rewrites.len(), 1);
1214 assert_eq!(metadata.enum_rewrites[0].schema, "test.v1.Response");
1215 assert_eq!(metadata.enum_rewrites[0].field, "status");
1216 assert_eq!(
1217 metadata.enum_rewrites[0].values,
1218 vec!["unspecified", "active"]
1219 );
1220 }
1221
1222 #[test]
1223 fn redirect_paths_detected() {
1224 let fdset = FileDescriptorSet {
1225 file: vec![FileDescriptorProto {
1226 name: Some("test.proto".to_string()),
1227 package: Some("test.v1".to_string()),
1228 message_type: vec![DescriptorProto {
1229 name: Some("RedirectResponse".to_string()),
1230 field: vec![make_field("redirect_url", field_type::STRING)],
1231 nested_type: vec![],
1232 }],
1233 enum_type: vec![],
1234 service: vec![ServiceDescriptorProto {
1235 name: Some("TestService".to_string()),
1236 method: vec![MethodDescriptorProto {
1237 name: Some("DoRedirect".to_string()),
1238 input_type: Some(".test.v1.Request".to_string()),
1239 output_type: Some(".test.v1.RedirectResponse".to_string()),
1240 options: Some(MethodOptions {
1241 http: Some(HttpRule {
1242 pattern: Some(HttpPattern::Get("/v1/redirect".to_string())),
1243 body: String::new(),
1244 }),
1245 }),
1246 client_streaming: None,
1247 server_streaming: None,
1248 }],
1249 }],
1250 }],
1251 };
1252 let bytes = fdset.encode_to_vec();
1253 let metadata = discover(&bytes).unwrap();
1254
1255 assert_eq!(metadata.redirect_paths, vec!["/v1/redirect"]);
1256 }
1257
1258 #[test]
1259 fn nested_message_constraints_use_qualified_path() {
1260 let fdset = FileDescriptorSet {
1261 file: vec![FileDescriptorProto {
1262 name: Some("test.proto".to_string()),
1263 package: Some("test.v1".to_string()),
1264 message_type: vec![DescriptorProto {
1265 name: Some("Outer".to_string()),
1266 field: vec![FieldDescriptorProto {
1267 name: Some("name".to_string()),
1268 r#type: Some(field_type::STRING),
1269 type_name: None,
1270 options: Some(FieldOptions {
1271 rules: Some(FieldRules {
1272 string: Some(StringRules {
1273 min_len: Some(1),
1274 max_len: Some(100),
1275 pattern: None,
1276 r#in: vec![],
1277 uuid: None,
1278 }),
1279 ..Default::default()
1280 }),
1281 }),
1282 }],
1283 nested_type: vec![DescriptorProto {
1284 name: Some("Inner".to_string()),
1285 field: vec![FieldDescriptorProto {
1286 name: Some("value".to_string()),
1287 r#type: Some(field_type::STRING),
1288 type_name: None,
1289 options: Some(FieldOptions {
1290 rules: Some(FieldRules {
1291 string: Some(StringRules {
1292 min_len: Some(3),
1293 max_len: None,
1294 pattern: None,
1295 r#in: vec![],
1296 uuid: None,
1297 }),
1298 ..Default::default()
1299 }),
1300 }),
1301 }],
1302 nested_type: vec![],
1303 }],
1304 }],
1305 enum_type: vec![],
1306 service: vec![],
1307 }],
1308 };
1309 let bytes = fdset.encode_to_vec();
1310 let metadata = discover(&bytes).unwrap();
1311
1312 let outer = metadata
1314 .field_constraints
1315 .iter()
1316 .find(|c| c.schema == "test.v1.Outer");
1317 assert!(outer.is_some(), "Outer constraint should exist");
1318
1319 let inner = metadata
1321 .field_constraints
1322 .iter()
1323 .find(|c| c.schema == "test.v1.Outer.Inner");
1324 assert!(
1325 inner.is_some(),
1326 "Nested Inner constraint should use fully qualified path: {:?}",
1327 metadata
1328 .field_constraints
1329 .iter()
1330 .map(|c| &c.schema)
1331 .collect::<Vec<_>>()
1332 );
1333
1334 let wrong = metadata
1336 .field_constraints
1337 .iter()
1338 .find(|c| c.schema == "test.v1.Inner");
1339 assert!(
1340 wrong.is_none(),
1341 "Should not have bare 'test.v1.Inner' schema"
1342 );
1343 }
1344
1345 #[test]
1346 fn nested_message_fields_use_qualified_fqn() {
1347 let fdset = FileDescriptorSet {
1348 file: vec![FileDescriptorProto {
1349 name: Some("test.proto".to_string()),
1350 package: Some("test.v1".to_string()),
1351 message_type: vec![DescriptorProto {
1352 name: Some("Outer".to_string()),
1353 field: vec![make_field("name", field_type::STRING)],
1354 nested_type: vec![DescriptorProto {
1355 name: Some("Inner".to_string()),
1356 field: vec![make_field("value", field_type::STRING)],
1357 nested_type: vec![],
1358 }],
1359 }],
1360 enum_type: vec![],
1361 service: vec![ServiceDescriptorProto {
1362 name: Some("Svc".to_string()),
1363 method: vec![MethodDescriptorProto {
1364 name: Some("Do".to_string()),
1365 input_type: Some(".test.v1.Outer.Inner".to_string()),
1366 output_type: Some(".test.v1.Outer".to_string()),
1367 options: Some(MethodOptions {
1368 http: Some(HttpRule {
1369 pattern: Some(HttpPattern::Get("/v1/outer/{value}".to_string())),
1370 body: String::new(),
1371 }),
1372 }),
1373 client_streaming: None,
1374 server_streaming: None,
1375 }],
1376 }],
1377 }],
1378 };
1379 let bytes = fdset.encode_to_vec();
1380 let metadata = discover(&bytes).unwrap();
1381
1382 assert!(
1384 !metadata.path_param_constraints.is_empty(),
1385 "should find path params via nested message FQN"
1386 );
1387 }
1388
1389 #[test]
1390 fn nested_enum_rewrites_use_qualified_schema() {
1391 let fdset = FileDescriptorSet {
1392 file: vec![FileDescriptorProto {
1393 name: Some("test.proto".to_string()),
1394 package: Some("test.v1".to_string()),
1395 message_type: vec![DescriptorProto {
1396 name: Some("Outer".to_string()),
1397 field: vec![],
1398 nested_type: vec![DescriptorProto {
1399 name: Some("Inner".to_string()),
1400 field: vec![FieldDescriptorProto {
1401 name: Some("status".to_string()),
1402 r#type: Some(field_type::ENUM),
1403 type_name: Some(".test.v1.Status".to_string()),
1404 options: None,
1405 }],
1406 nested_type: vec![],
1407 }],
1408 }],
1409 enum_type: vec![EnumDescriptorProto {
1410 name: Some("Status".to_string()),
1411 value: vec![
1412 EnumValueDescriptorProto {
1413 name: Some("STATUS_UNSPECIFIED".to_string()),
1414 number: Some(0),
1415 },
1416 EnumValueDescriptorProto {
1417 name: Some("STATUS_ACTIVE".to_string()),
1418 number: Some(1),
1419 },
1420 ],
1421 }],
1422 service: vec![],
1423 }],
1424 };
1425 let bytes = fdset.encode_to_vec();
1426 let metadata = discover(&bytes).unwrap();
1427
1428 assert_eq!(metadata.enum_rewrites.len(), 1);
1429 assert_eq!(
1431 metadata.enum_rewrites[0].schema, "test.v1.Outer.Inner",
1432 "nested enum rewrite should use fully qualified schema path"
1433 );
1434 }
1435
1436 #[test]
1437 fn int32_boundary_values_no_overflow() {
1438 let fdset = FileDescriptorSet {
1440 file: vec![FileDescriptorProto {
1441 name: Some("test.proto".to_string()),
1442 package: Some("test.v1".to_string()),
1443 message_type: vec![DescriptorProto {
1444 name: Some("Request".to_string()),
1445 field: vec![FieldDescriptorProto {
1446 name: Some("count".to_string()),
1447 r#type: Some(field_type::INT32),
1448 type_name: None,
1449 options: Some(FieldOptions {
1450 rules: Some(FieldRules {
1451 int32: Some(Int32Rules {
1452 gte: Some(-100),
1453 lte: Some(100),
1454 gt: None,
1455 lt: None,
1456 }),
1457 ..Default::default()
1458 }),
1459 }),
1460 }],
1461 nested_type: vec![],
1462 }],
1463 enum_type: vec![],
1464 service: vec![],
1465 }],
1466 };
1467 let bytes = fdset.encode_to_vec();
1468 let metadata = discover(&bytes).unwrap();
1469
1470 assert_eq!(metadata.field_constraints.len(), 1);
1471 let fc = &metadata.field_constraints[0].fields[0];
1472 assert_eq!(fc.signed_min, Some(-100));
1473 assert_eq!(fc.signed_max, Some(100));
1474 assert!(fc.is_numeric);
1475 }
1476
1477 #[test]
1478 fn uint32_lt_zero_no_underflow() {
1479 let fdset = FileDescriptorSet {
1481 file: vec![FileDescriptorProto {
1482 name: Some("test.proto".to_string()),
1483 package: Some("test.v1".to_string()),
1484 message_type: vec![DescriptorProto {
1485 name: Some("Request".to_string()),
1486 field: vec![FieldDescriptorProto {
1487 name: Some("count".to_string()),
1488 r#type: Some(field_type::UINT32),
1489 type_name: None,
1490 options: Some(FieldOptions {
1491 rules: Some(FieldRules {
1492 uint32: Some(UInt32Rules {
1493 lt: Some(0),
1494 lte: None,
1495 gt: None,
1496 gte: None,
1497 }),
1498 ..Default::default()
1499 }),
1500 }),
1501 }],
1502 nested_type: vec![],
1503 }],
1504 enum_type: vec![],
1505 service: vec![],
1506 }],
1507 };
1508 let bytes = fdset.encode_to_vec();
1509 let metadata = discover(&bytes).unwrap();
1511 assert_eq!(metadata.field_constraints.len(), 1);
1512 let fc = &metadata.field_constraints[0].fields[0];
1513 assert_eq!(fc.max, Some(0)); }
1515}