allora_runtime/dsl/component_builders.rs
1//! DSL runtime component builders: instantiate runtime components from validated specs.
2//! This module will host builders for multiple component types (Channel, Endpoint, Adapter, etc.).
3//! Each builder converts a spec (format-agnostic, already validated) into a concrete runtime type.
4//!
5//! # Purpose
6//! Bridge the gap between a format-specific parsed spec (e.g. YAML -> `ChannelSpec`) and the
7//! actual runtime component (e.g. `QueueChannel`). Parsing & validation happen elsewhere
8//! (under `spec/` parsers). Builders assume the spec is structurally valid and focus solely on
9//! instantiation and enforcing runtime constraints (like non-empty IDs).
10//!
11//! # Design Principles
12//! * One builder per component type (not per serialization format).
13//! * Builders accept only strongly typed specs (no raw YAML/JSON here).
14//! * Fail fast on remaining invariants (e.g. empty string ID) that are easier to check post-parse.
15//! * Keep side-effects minimal: no I/O, no global state modifications.
16//!
17//! # Usage Example
18//! ```rust
19//! use allora_runtime::spec::ChannelSpec;
20//! use allora_runtime::dsl::component_builders::build_channel_from_spec;
21//! use allora_core::Channel; // bring trait into scope for channel.id()
22//! let spec = ChannelSpec::queue().id("example-channel");
23//! let channel = build_channel_from_spec(spec).unwrap();
24//! assert_eq!(channel.id(), "example-channel");
25//! ```
26//!
27//! # Auto-generated IDs (Single vs Multi Build)
28//! * Single channel (`build_channel_from_spec` when `spec.channel_id()` is `None`): underlying
29//! `QueueChannel::with_random_id()` assigns a UUID-based id (`queue:<uuid>`).
30//! * Multi-channel (`build_channels_from_spec`) with missing ids: this module generates
31//! deterministic sequential ids of the form `channel:auto.<N>` starting at 1 and incrementing
32//! for each missing id within that build invocation. The sequence resets each time you call
33//! `build_channels_from_spec` (no global counter).
34//!
35//! Rationale: deterministic ids in multi-build scenarios improve testability and reproducibility
36//! without leaking global mutable state.
37//!
38//! # Uniqueness Enforcement
39//! * Duplicate provided ids (two specs supplying the same non-empty id) -> `Error::Serialization("duplicate channel.id '<id>'")`.
40//! * Empty id string -> `Error::Serialization("channel.id must not be empty")`.
41//! * Generated ids are checked against previously used ids in the same build to avoid collisions.
42//!
43//! # Extending Channel Kinds
44//! When additional kinds (e.g. `Kafka`, `Amqp`) are introduced, extend `ChannelKindSpec` and add
45//! match arms inside `build_channel_spec_internal`. Keep generation & uniqueness logic centralized
46//! so tests remain stable.
47//!
48//! # Internal Helper
49//! `build_channel_spec_internal` encapsulates ID resolution (provided vs generated), uniqueness
50//! checks, and final builder dispatch. It is intentionally private so external callers use only
51//! the stable public functions.
52//!
53//! # Error Semantics
54//! * `Error::Serialization` – structural or invariant violation (empty id, duplicate id).
55//! * `Error::Other` – reserved for future runtime construction failures.
56//!
57//! # Future Improvements
58//! * Shared trait for all component specs (e.g. `ComponentSpec` with `fn kind(&self)` + `fn id(&self)`)
59//! enabling generic multi-component builders.
60//! * Pluggable id generation strategy (configure prefix / starting counter).
61//! * Metrics hooks (time to build, count of auto-generated ids) gated behind a feature flag.
62//!
63//! This documentation focuses on current behavior while outlining evolution points to minimize
64//! refactors as new component types are added.
65
66use crate::adapter::Adapter;
67use crate::channel::ChannelRef;
68// already available via allora-core re-export
69use crate::spec::{HttpInboundAdapterSpec, HttpInboundAdaptersSpec};
70use crate::spec::{HttpOutboundAdapterSpec, HttpOutboundAdaptersSpec};
71use crate::{
72 spec::{ChannelKindSpec, ChannelSpec, ChannelsSpec, FilterSpec, FiltersSpec},
73 spec::{ServiceActivatorSpec, ServiceActivatorsSpec},
74 Channel, ClosureProcessor, Error, Filter, Result,
75};
76use allora_http::{HttpInboundAdapter, InboundHttpExt, Mep};
77use allora_http::{HttpOutboundAdapter, OutboundHttpExt};
78use hyper::Method;
79
80/// Helper: parse HTTP method string into `hyper::Method`.
81/// Accepts common verbs (GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD) in uppercase; falls back
82/// to custom method parsing via `Method::from_bytes` (defaulting to POST if invalid).
83fn parse_http_method(m: &str) -> Method {
84 match m {
85 "GET" => Method::GET,
86 "POST" => Method::POST,
87 "PUT" => Method::PUT,
88 "PATCH" => Method::PATCH,
89 "DELETE" => Method::DELETE,
90 "OPTIONS" => Method::OPTIONS,
91 "HEAD" => Method::HEAD,
92 other => Method::from_bytes(other.as_bytes()).unwrap_or(Method::POST),
93 }
94}
95
96// for inbound adapter DSL entry
97// Service runtime placeholder: processor representing service logic.
98pub type ServiceProcessor =
99 ClosureProcessor<Box<dyn Fn(&mut crate::Exchange) -> Result<()> + Send + Sync + 'static>>;
100
101use std::collections::HashSet;
102
103/// Internal helper: build a single channel from spec.
104/// When `used_ids` + `auto_ctr` provided, enforces uniqueness and generates deterministic auto IDs.
105fn build_channel_spec_internal(
106 spec: &ChannelSpec,
107 used_ids: Option<&mut HashSet<String>>,
108 auto_ctr: Option<&mut u64>,
109) -> Result<Box<dyn Channel>> {
110 // Resolve (possibly generated) id
111 let final_id: Option<String> = match (spec.channel_id(), used_ids) {
112 (Some(""), _) => return Err(Error::serialization("channel.id must not be empty")),
113 (Some(id), Some(used)) => {
114 if used.contains(id) {
115 return Err(Error::serialization(format!("duplicate channel.id '{id}'")));
116 }
117 used.insert(id.to_string());
118 Some(id.to_string())
119 }
120 (Some(id), None) => Some(id.to_string()), // no uniqueness enforcement
121 (None, Some(used)) => {
122 // generate deterministic channel:auto.N pattern
123 let ctr = auto_ctr.expect("auto counter must be provided when used_ids is set");
124 let mut gen = format!("channel:auto.{}", *ctr);
125 while used.contains(&gen) {
126 *ctr += 1;
127 gen = format!("channel:auto.{}", *ctr);
128 }
129 *ctr += 1;
130 used.insert(gen.clone());
131 Some(gen)
132 }
133 (None, None) => None, // let underlying builder generate UUID-based id
134 };
135
136 // Match kind -> builder (future kinds centralize here)
137 let channel: Box<dyn Channel> = match spec.kind() {
138 ChannelKindSpec::Queue => match final_id {
139 Some(id) => Box::new(crate::channel::QueueChannel::with_id(id)),
140 None => Box::new(crate::channel::QueueChannel::with_random_id()),
141 },
142 ChannelKindSpec::Direct => match final_id {
143 Some(id) => Box::new(crate::channel::DirectChannel::with_id(id)),
144 None => Box::new(crate::channel::DirectChannel::with_random_id()),
145 },
146 };
147 Ok(channel)
148}
149
150/// Build a concrete channel from a validated `ChannelSpec`.
151/// Delegates to internal helper without uniqueness / auto-ID tracking (builder handles UUID auto-id).
152pub fn build_channel_from_spec(spec: ChannelSpec) -> Result<Box<dyn Channel>> {
153 build_channel_spec_internal(&spec, None, None)
154}
155
156/// Build multiple concrete channels from a validated `ChannelsSpec`.
157/// Enforces uniqueness across provided IDs and generates deterministic auto IDs for missing ones.
158pub fn build_channels_from_spec(spec: ChannelsSpec) -> Result<Vec<Box<dyn Channel>>> {
159 let mut result: Vec<Box<dyn Channel>> = Vec::with_capacity(spec.channels().len());
160 let mut used: HashSet<String> = HashSet::new();
161 let mut auto_ctr: u64 = 1;
162 for ch in spec.channels() {
163 let built = build_channel_spec_internal(ch, Some(&mut used), Some(&mut auto_ctr))?;
164 result.push(built);
165 }
166 Ok(result)
167}
168
169/// Build a Filter from a validated `FilterSpec`.
170pub fn build_filter_from_spec(spec: FilterSpec) -> Result<Filter> {
171 let id_opt = spec.id().map(|s| s.to_string());
172 Filter::from_apl_with_id(id_opt, spec.when())
173}
174
175/// Build multiple Filters from FiltersSpec (collection). Returns Vec<Filter> preserving order.
176/// ID Strategy (mirrors channels):
177/// * Explicit non-empty `filter.id` values must be unique (error on duplicate).
178/// * Missing ids are generated deterministically as `filter:auto.N` starting at 1 (or the next
179/// number after the highest explicitly provided `filter:auto.X` id) within a single build invocation.
180/// * Users are discouraged from manually supplying IDs with the reserved `filter:auto.` prefix; if
181/// they do, generation will skip to the next available integer without scanning the entire set.
182/// * Generated ids are stored on the runtime `Filter` for diagnostics and future routing metadata.
183/// * Malformed reserved IDs (e.g. `filter:auto.bad`) are ignored for sequence advancement and a
184/// warning is emitted via `tracing::warn!`.
185pub fn build_filters_from_spec(spec: FiltersSpec) -> Result<Vec<Filter>> {
186 let mut result = Vec::with_capacity(spec.filters().len());
187 const AUTO_PREFIX: &str = "filter:auto.";
188 let mut used = HashSet::new();
189 let mut max_auto_explicit = 0u64;
190 // First pass: validate explicit ids & find highest reserved pattern
191 for f in spec.filters() {
192 if let Some(id) = f.id() {
193 if used.contains(id) {
194 return Err(Error::serialization(format!("duplicate filter.id '{id}'")));
195 }
196 if let Some(rest) = id.strip_prefix(AUTO_PREFIX) {
197 match rest.parse::<u64>() {
198 Ok(n) => max_auto_explicit = max_auto_explicit.max(n),
199 Err(_) => {
200 tracing::warn!(%id, "ignoring malformed reserved auto-id suffix; expected numeric after filter:auto.")
201 }
202 }
203 }
204 used.insert(id.to_string());
205 }
206 }
207 // Second pass: build filters, generate ids for missing ones
208 let mut auto_ctr = max_auto_explicit + 1;
209 for f in spec.filters() {
210 if let Some(id) = f.id() {
211 result.push(Filter::from_apl_with_id(Some(id.to_string()), f.when())?);
212 continue;
213 }
214 let gen_id = format!("{AUTO_PREFIX}{auto_ctr}");
215 auto_ctr += 1;
216 result.push(Filter::from_apl_with_id(Some(gen_id), f.when())?);
217 }
218 Ok(result)
219}
220
221/// Internal: validate required invariant fields on a `ServiceSpec`.
222fn validate_service_activator_spec(spec: &ServiceActivatorSpec) -> Result<()> {
223 if spec.from().is_empty() {
224 return Err(Error::serialization("service.from must not be empty"));
225 }
226 if spec.to().is_empty() {
227 return Err(Error::serialization("service.to must not be empty"));
228 }
229 if spec.ref_name().is_empty() {
230 return Err(Error::serialization("service.ref-name must not be empty"));
231 }
232 Ok(())
233}
234
235/// Internal: build a service processor closure setting headers.
236/// Always sets `service-activator.ref-name`; sets `service-activator.id` if provided.
237fn service_processor_with_headers(id_opt: Option<&str>, ref_name: &str) -> ServiceProcessor {
238 let ref_name_copy = ref_name.to_string();
239 let id_copy = id_opt.map(|s| s.to_string());
240 let proc_fn: Box<dyn Fn(&mut crate::Exchange) -> Result<()> + Send + Sync + 'static> =
241 Box::new(move |exchange: &mut crate::Exchange| {
242 if let Some(ref id) = id_copy {
243 exchange.in_msg.set_header("service-activator.id", id);
244 }
245 exchange
246 .in_msg
247 .set_header("service-activator.ref-name", ref_name_copy.as_str());
248 Ok(())
249 });
250 ClosureProcessor::new(proc_fn)
251}
252
253/// Build a single Service from a validated `ServiceSpec`.
254/// Currently materializes as a `ClosureProcessor` placeholder executing no-op logic.
255/// Future: compile & load user-provided implementation from `ref_name`.
256pub fn build_service_from_spec(spec: ServiceActivatorSpec) -> Result<ServiceProcessor> {
257 validate_service_activator_spec(&spec)?;
258 // For a single build we preserve existing behavior: only impl header if id absent.
259 Ok(service_processor_with_headers(spec.id(), spec.ref_name()))
260}
261
262/// Build multiple services from `ServicesSpec` preserving order.
263/// Auto-ID Strategy:
264/// * Explicit non-empty ids must be unique; duplicates -> error.
265/// * Missing ids generated deterministically as `service:auto.N` starting at 1 (or next after any explicit reserved pattern).
266pub fn build_service_activators_from_spec(
267 spec: ServiceActivatorsSpec,
268) -> Result<Vec<ServiceProcessor>> {
269 let mut result = Vec::with_capacity(spec.services_activators().len());
270 const AUTO_PREFIX: &str = "service:auto.";
271 let mut used = HashSet::new();
272 let mut max_auto_explicit = 0u64;
273 // First pass: explicit IDs & validation
274 for s in spec.services_activators() {
275 validate_service_activator_spec(s)?;
276 if let Some(id) = s.id() {
277 if used.contains(id) {
278 return Err(Error::serialization(format!("duplicate service.id '{id}'")));
279 }
280 if let Some(rest) = id.strip_prefix(AUTO_PREFIX) {
281 if let Ok(n) = rest.parse::<u64>() {
282 max_auto_explicit = max_auto_explicit.max(n);
283 }
284 }
285 used.insert(id.to_string());
286 }
287 }
288 // Second pass: build processors with generated IDs where missing
289 let mut auto_ctr = max_auto_explicit + 1;
290 for s in spec.services_activators() {
291 let id_final = match s.id() {
292 Some(id) => id.to_string(),
293 None => {
294 let gen = format!("{AUTO_PREFIX}{auto_ctr}");
295 auto_ctr += 1;
296 gen
297 }
298 };
299 let proc = service_processor_with_headers(Some(&id_final), s.ref_name());
300 result.push(proc);
301 }
302 Ok(result)
303}
304
305/// Internal helper: common base builder for HTTP inbound adapter to reduce duplication.
306fn http_inbound_builder_base(
307 host: &str,
308 port: u16,
309 path: &str,
310 channel: ChannelRef,
311) -> allora_http::HttpInboundBuilder {
312 Adapter::inbound()
313 .http()
314 .host(host)
315 .port(port)
316 .base_path(path)
317 .channel(channel)
318}
319
320/// Build a single HTTP inbound adapter from a validated `HttpInboundAdapterSpec`.
321///
322/// ID Handling:
323/// * If `spec.id()` is `Some("")` an error is returned (`http-inbound-adapter.id must not be empty`).
324/// * If `spec.id()` is `None` the underlying inbound builder will auto-generate an id (e.g. `http-inbound:<addr>`).
325/// * Provided non-empty ids are accepted as-is (no uniqueness enforcement here; collection build enforces uniqueness).
326///
327/// Channel Resolution:
328/// * `request-channel` must be a non-empty string and must resolve via `channel_lookup` or an error is returned.
329/// * If `reply-channel` is provided it must resolve; presence implies InOut MEP automatically.
330/// * If no `reply-channel` is provided, MEP is forced to `InOnly202` (fire-and-forget with 202 acknowledgement).
331///
332/// Errors Returned:
333/// * Empty `request-channel`.
334/// * Unknown `request-channel` id.
335/// * Unknown `reply-channel` id.
336/// * Empty adapter id when explicitly supplied.
337///
338/// Auto-ID Strategy (single build): if `spec.id()` absent, use `HttpInboundBuilder` default; if present, enforce non-empty.
339pub fn build_http_inbound_adapter_from_spec(
340 spec: HttpInboundAdapterSpec,
341 channel_lookup: &dyn Fn(&str) -> Option<ChannelRef>,
342) -> Result<HttpInboundAdapter> {
343 let req_id = spec.request_channel();
344 if req_id.is_empty() {
345 return Err(Error::serialization(
346 "http-inbound-adapter.request-channel must not be empty",
347 ));
348 }
349 let ch = channel_lookup(req_id).ok_or_else(|| {
350 Error::serialization(format!(
351 "unknown channel id '{}' for http-inbound-adapter.request-channel",
352 req_id
353 ))
354 })?;
355 let mut builder = http_inbound_builder_base(spec.host(), spec.port(), spec.path(), ch.clone());
356 if let Some(id) = spec.id() {
357 builder = builder.id(id);
358 }
359 if let Some(reply_id) = spec.reply_channel() {
360 let rc = channel_lookup(reply_id).ok_or_else(|| {
361 Error::serialization(format!(
362 "unknown channel id '{}' for http-inbound-adapter.reply-channel",
363 reply_id
364 ))
365 })?;
366 // Setting reply_channel automatically forces InOut MEP in builder.build(); no need to set .mep explicitly.
367 builder = builder.reply_channel(rc);
368 }
369 // Only set explicit MEP for fire-and-forget when no reply channel.
370 if spec.reply_channel().is_none() {
371 builder = builder.mep(Mep::InOnly202);
372 }
373 Ok(builder.build())
374}
375
376/// Build multiple HTTP inbound adapters from a validated `HttpInboundAdaptersSpec`.
377///
378/// ID Handling & Uniqueness:
379/// * Explicit non-empty ids must be unique; duplicates -> `Error::Serialization("duplicate http-inbound-adapter.id '<id>'")`.
380/// * Empty id string (explicit) -> error (`http-inbound-adapter.id must not be empty`).
381/// * Missing ids are generated deterministically as `http-inbound-adapter:auto.N` starting at 1 (or the next number after any explicitly supplied reserved `http-inbound-adapter:auto.<X>` ids) within this build invocation.
382/// * Generated ids skip values already used (explicit or previously generated) to avoid collisions.
383///
384/// Channel Resolution:
385/// * Each adapter must declare a non-empty `request-channel` that resolves via `channel_lookup`; otherwise an error is returned.
386/// * Optional `reply-channel` must resolve if present; its presence implies InOut MEP (request/reply) implicitly.
387/// * Absence of `reply-channel` forces MEP to `InOnly202` (fire-and-forget, 202 Accepted).
388///
389/// MEP Behavior:
390/// * InOut when `reply-channel` provided (adapter waits for correlated reply).
391/// * InOnly202 when `reply-channel` absent (adapter acknowledges immediately with 202).
392///
393/// Errors Returned:
394/// * Empty `request-channel`.
395/// * Unknown `request-channel` id.
396/// * Unknown `reply-channel` id.
397/// * Empty adapter id when explicitly supplied.
398/// * Duplicate explicit adapter id.
399///
400/// Order Preservation: Output vector preserves the input spec order.
401pub fn build_http_inbound_adapters_from_spec(
402 spec: HttpInboundAdaptersSpec,
403 channel_lookup: &dyn Fn(&str) -> Option<ChannelRef>,
404) -> Result<Vec<HttpInboundAdapter>> {
405 let mut result = Vec::with_capacity(spec.adapters().len());
406 const AUTO_PREFIX: &str = "http-inbound-adapter:auto.";
407 let mut used = HashSet::new();
408 let mut max_auto_explicit = 0u64;
409 // First pass: explicit id validation & reserved pattern tracking
410 for a in spec.adapters() {
411 let req_id = a.request_channel();
412 if req_id.is_empty() {
413 return Err(Error::serialization(
414 "http-inbound-adapter.request-channel must not be empty",
415 ));
416 }
417 if let Some(id) = a.id() {
418 if id.is_empty() {
419 return Err(Error::serialization(
420 "http-inbound-adapter.id must not be empty",
421 ));
422 }
423 if used.contains(id) {
424 return Err(Error::serialization(format!(
425 "duplicate http-inbound-adapter.id '{}'",
426 id
427 )));
428 }
429 if let Some(rest) = id.strip_prefix(AUTO_PREFIX) {
430 if let Ok(n) = rest.parse::<u64>() {
431 max_auto_explicit = max_auto_explicit.max(n);
432 }
433 }
434 used.insert(id.to_string());
435 }
436 }
437 // Second pass: build adapters with generated IDs for missing ones
438 let mut auto_ctr = max_auto_explicit + 1;
439 for a in spec.adapters() {
440 let id_final = match a.id() {
441 Some(id) => id.to_string(),
442 None => {
443 let gen = format!("{AUTO_PREFIX}{auto_ctr}");
444 auto_ctr += 1;
445 let mut candidate = gen;
446 while used.contains(&candidate) {
447 candidate = format!("{AUTO_PREFIX}{auto_ctr}");
448 auto_ctr += 1;
449 }
450 used.insert(candidate.clone());
451 candidate
452 }
453 };
454 let ch = channel_lookup(a.request_channel()).ok_or_else(|| {
455 Error::serialization(format!(
456 "unknown channel id '{}' for http-inbound-adapter.request-channel",
457 a.request_channel()
458 ))
459 })?;
460 let mut builder = http_inbound_builder_base(a.host(), a.port(), a.path(), ch.clone())
461 .id(id_final.clone());
462 if let Some(reply_id) = a.reply_channel() {
463 let rc = channel_lookup(reply_id).ok_or_else(|| {
464 Error::serialization(format!(
465 "unknown channel id '{}' for http-inbound-adapter.reply-channel",
466 reply_id
467 ))
468 })?;
469 // reply_channel implies InOut; builder will override MEP internally.
470 builder = builder.reply_channel(rc);
471 } else {
472 builder = builder.mep(Mep::InOnly202);
473 }
474 result.push(builder.build());
475 }
476 Ok(result)
477}
478
479/// Build a single HTTP outbound adapter from a validated `HttpOutboundAdapterSpec`.
480///
481/// ID Handling:
482/// * If `spec.id()` is `Some("")` an error is returned (`http-outbound-adapter.id must not be empty`).
483/// * If `spec.id()` is `None` the underlying outbound builder will auto-generate a UUID-based id.
484/// * Provided non-empty ids are accepted as-is (no uniqueness enforcement here; collection build enforces).
485///
486/// Method Handling:
487/// * Recognized verbs (GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD) are mapped directly.
488/// * Unrecognized verb strings are passed to `Method::from_bytes`; failure falls back to POST.
489///
490/// Errors:
491/// * Empty id string.
492/// * (Indirect) build errors from the adapter builder (e.g. missing host/port handled upstream by spec parser).
493pub fn build_http_outbound_adapter_from_spec(
494 spec: HttpOutboundAdapterSpec,
495) -> Result<HttpOutboundAdapter> {
496 if let Some(id) = spec.id() {
497 if id.is_empty() {
498 return Err(Error::serialization(
499 "http-outbound-adapter.id must not be empty",
500 ));
501 }
502 }
503 let mut builder = Adapter::outbound()
504 .http()
505 .host(spec.host())
506 .port(spec.port())
507 .base_path(spec.base_path());
508 if let Some(p) = spec.path() {
509 builder = builder.path(p);
510 }
511 if let Some(m) = spec.method() {
512 builder = builder.method(parse_http_method(m));
513 }
514 if !spec.use_out_msg() {
515 builder = builder.use_out_msg(false);
516 }
517 if let Some(id) = spec.id() {
518 builder = builder.id(id);
519 }
520 builder.build()
521}
522
523/// Build multiple HTTP outbound adapters from a validated `HttpOutboundAdaptersSpec`.
524///
525/// Uniqueness & Auto-ID Strategy (mirrors inbound):
526/// * Explicit non-empty ids must be unique; duplicates -> `Error::Serialization("duplicate http-outbound-adapter.id '<id>'")`.
527/// * Empty id string -> error.
528/// * Missing ids are generated deterministically as `http-outbound-adapter:auto.N` starting at 1
529/// (or after any explicitly provided reserved `http-outbound-adapter:auto.<X>` ids) within a single build invocation.
530/// * Generated ids skip already used values (including explicitly supplied ones and earlier generated ones).
531///
532/// Method Handling uses `parse_http_method` (see single builder).
533///
534/// Returned adapters preserve the input order.
535pub fn build_http_outbound_adapters_from_spec(
536 spec: HttpOutboundAdaptersSpec,
537) -> Result<Vec<HttpOutboundAdapter>> {
538 let mut result = Vec::with_capacity(spec.adapters().len());
539 const AUTO_PREFIX: &str = "http-outbound-adapter:auto.";
540 let mut used = HashSet::new();
541 let mut max_auto_explicit = 0u64;
542 // First pass: validate explicit ids & track highest explicit auto id suffix.
543 for a in spec.adapters() {
544 if let Some(id) = a.id() {
545 if id.is_empty() {
546 return Err(Error::serialization(
547 "http-outbound-adapter.id must not be empty",
548 ));
549 }
550 if used.contains(id) {
551 return Err(Error::serialization(format!(
552 "duplicate http-outbound-adapter.id '{}'",
553 id
554 )));
555 }
556 if let Some(rest) = id.strip_prefix(AUTO_PREFIX) {
557 if let Ok(n) = rest.parse::<u64>() {
558 max_auto_explicit = max_auto_explicit.max(n);
559 }
560 }
561 used.insert(id.to_string());
562 }
563 }
564 // Second pass: build adapters assigning deterministic auto ids where missing.
565 let mut auto_ctr = max_auto_explicit + 1;
566 for a in spec.adapters() {
567 let id_final = match a.id() {
568 Some(id) => id.to_string(),
569 None => {
570 let mut candidate = format!("{AUTO_PREFIX}{auto_ctr}");
571 auto_ctr += 1;
572 while used.contains(&candidate) {
573 candidate = format!("{AUTO_PREFIX}{auto_ctr}");
574 auto_ctr += 1;
575 }
576 used.insert(candidate.clone());
577 candidate
578 }
579 };
580 let mut builder = Adapter::outbound()
581 .http()
582 .host(a.host())
583 .port(a.port())
584 .base_path(a.base_path())
585 .id(id_final);
586 if let Some(p) = a.path() {
587 builder = builder.path(p);
588 }
589 if let Some(m) = a.method() {
590 builder = builder.method(parse_http_method(m));
591 }
592 if !a.use_out_msg() {
593 builder = builder.use_out_msg(false);
594 }
595 let built = builder.build()?;
596 result.push(built);
597 }
598 Ok(result)
599}