actr_cli/core/components/
service_discovery.rs1use anyhow::{Result, anyhow};
2use async_trait::async_trait;
3use heck::ToUpperCamelCase;
4use sha2::{Digest, Sha256};
5use std::path::PathBuf;
6use std::time::SystemTime;
7
8use crate::core::{
9 AvailabilityStatus, HealthStatus, MethodDefinition, ProtoFile, ServiceDefinition,
10 ServiceDetails, ServiceDiscovery, ServiceFilter, ServiceInfo,
11};
12
13#[derive(Clone)]
14struct CatalogEntry {
15 info: ServiceInfo,
16 tags: Vec<String>,
17 dependencies: Vec<String>,
18 proto_files: Vec<ProtoFile>,
19}
20
21pub struct NetworkServiceDiscovery {
22 catalog: Vec<CatalogEntry>,
23}
24
25impl NetworkServiceDiscovery {
26 pub fn new() -> Self {
27 let catalog = vec![
28 Self::build_entry(
29 "user-service",
30 "1.0.0",
31 "User profile and authentication service",
32 &["user", "auth"],
33 &[],
34 ),
35 Self::build_entry(
36 "order-service",
37 "1.2.0",
38 "Order management service",
39 &["order", "workflow"],
40 &["actr://user-service/"],
41 ),
42 Self::build_entry(
43 "payment-service",
44 "2.0.0",
45 "Payments and billing service",
46 &["payment", "billing"],
47 &["actr://order-service/"],
48 ),
49 ];
50
51 Self { catalog }
52 }
53
54 fn build_entry(
55 name: &str,
56 version: &str,
57 description: &str,
58 tags: &[&str],
59 dependencies: &[&str],
60 ) -> CatalogEntry {
61 let methods = Self::build_methods(name);
62 let info = ServiceInfo {
63 name: name.to_string(),
64 uri: format!("actr://{name}/"),
65 version: version.to_string(),
66 fingerprint: Self::fingerprint_for(name),
67 description: Some(description.to_string()),
68 methods: methods.clone(),
69 };
70 let proto_files = vec![Self::build_proto_file(name, &methods)];
71
72 CatalogEntry {
73 info,
74 tags: tags.iter().map(|tag| (*tag).to_string()).collect(),
75 dependencies: dependencies.iter().map(|dep| (*dep).to_string()).collect(),
76 proto_files,
77 }
78 }
79
80 fn build_methods(name: &str) -> Vec<MethodDefinition> {
81 let service_name = name.to_upper_camel_case();
82 vec![
83 MethodDefinition {
84 name: format!("Get{service_name}"),
85 input_type: format!("Get{service_name}Request"),
86 output_type: format!("Get{service_name}Response"),
87 },
88 MethodDefinition {
89 name: format!("List{service_name}"),
90 input_type: format!("List{service_name}Request"),
91 output_type: format!("List{service_name}Response"),
92 },
93 ]
94 }
95
96 fn build_proto_file(name: &str, methods: &[MethodDefinition]) -> ProtoFile {
97 let service_name = format!("{}Service", name.to_upper_camel_case());
98 let package_name = name.replace('-', "_");
99 let mut service_methods = String::new();
100 let mut message_defs = String::new();
101
102 for method in methods {
103 service_methods.push_str(&format!(
104 " rpc {} ({}) returns ({});\n",
105 method.name, method.input_type, method.output_type
106 ));
107 message_defs.push_str(&format!("message {} {{}}\n", method.input_type));
108 message_defs.push_str(&format!("message {} {{}}\n", method.output_type));
109 }
110
111 let content = format!(
112 "syntax = \"proto3\";\n\npackage {package_name};\n\nservice {service_name} {{\n{service_methods}}}\n\n{message_defs}",
113 );
114
115 ProtoFile {
116 name: format!("{name}.proto"),
117 path: PathBuf::from(format!("proto/{name}.proto")),
118 content,
119 services: vec![ServiceDefinition {
120 name: service_name,
121 methods: methods.to_vec(),
122 }],
123 }
124 }
125
126 fn fingerprint_for(name: &str) -> String {
127 let mut hasher = Sha256::new();
128 hasher.update(name.as_bytes());
129 let digest = hasher.finalize();
130 let hex = digest
131 .iter()
132 .map(|b| format!("{b:02x}"))
133 .collect::<String>();
134 format!("sha256:{hex}")
135 }
136
137 fn parse_actr_uri(&self, uri: &str) -> Result<String> {
138 if !uri.starts_with("actr://") {
139 return Err(anyhow!("Invalid actr:// URI: {uri}"));
140 }
141
142 let without_scheme = &uri["actr://".len()..];
143 let name_end = without_scheme
144 .find(|c| ['/', '?'].contains(&c))
145 .unwrap_or(without_scheme.len());
146 let name = without_scheme[..name_end].trim();
147 if name.is_empty() {
148 return Err(anyhow!("Invalid actr:// URI: {uri}"));
149 }
150
151 Ok(name.to_string())
152 }
153
154 fn matches_filter(entry: &CatalogEntry, filter: &ServiceFilter) -> bool {
155 if let Some(pattern) = &filter.name_pattern
156 && !Self::matches_pattern(&entry.info.name, pattern)
157 {
158 return false;
159 }
160
161 if let Some(version_range) = &filter.version_range
162 && entry.info.version != *version_range
163 {
164 return false;
165 }
166
167 if let Some(tags) = &filter.tags {
168 let has_all = tags.iter().all(|tag| entry.tags.iter().any(|t| t == tag));
169 if !has_all {
170 return false;
171 }
172 }
173
174 true
175 }
176
177 fn matches_pattern(value: &str, pattern: &str) -> bool {
178 if pattern == "*" {
179 return true;
180 }
181
182 let segments: Vec<&str> = pattern.split('*').collect();
183 if segments.len() == 1 {
184 return value == pattern;
185 }
186
187 if !pattern.starts_with('*')
188 && let Some(first) = segments.first()
189 && !value.starts_with(first)
190 {
191 return false;
192 }
193
194 if !pattern.ends_with('*')
195 && let Some(last) = segments.last()
196 && !value.ends_with(last)
197 {
198 return false;
199 }
200
201 let mut search_start = 0;
202 let end_limit = if !pattern.ends_with('*') {
203 value
204 .len()
205 .saturating_sub(segments.last().unwrap_or(&"").len())
206 } else {
207 value.len()
208 };
209
210 for (index, segment) in segments.iter().enumerate() {
211 if segment.is_empty() {
212 continue;
213 }
214 if index == 0 && !pattern.starts_with('*') {
215 search_start = segment.len();
216 continue;
217 }
218 if index == segments.len() - 1 && !pattern.ends_with('*') {
219 continue;
220 }
221 if let Some(found) = value[search_start..end_limit].find(segment) {
222 search_start += found + segment.len();
223 } else {
224 return false;
225 }
226 }
227
228 true
229 }
230
231 fn find_entry(&self, name: &str) -> Option<&CatalogEntry> {
232 self.catalog.iter().find(|entry| entry.info.name == name)
233 }
234}
235
236impl Default for NetworkServiceDiscovery {
237 fn default() -> Self {
238 Self::new()
239 }
240}
241
242#[async_trait]
243impl ServiceDiscovery for NetworkServiceDiscovery {
244 async fn discover_services(&self, filter: Option<&ServiceFilter>) -> Result<Vec<ServiceInfo>> {
245 let services = match filter {
246 Some(filter) => self
247 .catalog
248 .iter()
249 .filter(|entry| Self::matches_filter(entry, filter))
250 .map(|entry| entry.info.clone())
251 .collect(),
252 None => self
253 .catalog
254 .iter()
255 .map(|entry| entry.info.clone())
256 .collect(),
257 };
258 Ok(services)
259 }
260
261 async fn get_service_details(&self, uri: &str) -> Result<ServiceDetails> {
262 let name = self.parse_actr_uri(uri)?;
263 let entry = self
264 .find_entry(&name)
265 .ok_or_else(|| anyhow!("Service not found: {name}"))?;
266
267 Ok(ServiceDetails {
268 info: entry.info.clone(),
269 proto_files: entry.proto_files.clone(),
270 dependencies: entry.dependencies.clone(),
271 })
272 }
273
274 async fn check_service_availability(&self, uri: &str) -> Result<AvailabilityStatus> {
275 let name = self.parse_actr_uri(uri)?;
276 let available = self.find_entry(&name).is_some();
277
278 Ok(AvailabilityStatus {
279 is_available: available,
280 last_seen: available.then(SystemTime::now),
281 health: if available {
282 HealthStatus::Healthy
283 } else {
284 HealthStatus::Unknown
285 },
286 })
287 }
288
289 async fn get_service_proto(&self, uri: &str) -> Result<Vec<ProtoFile>> {
290 let name = self.parse_actr_uri(uri)?;
291 let entry = self
292 .find_entry(&name)
293 .ok_or_else(|| anyhow!("Service not found: {name}"))?;
294 Ok(entry.proto_files.clone())
295 }
296}