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}