allora_runtime/spec/
http_inbound_adapter_spec_yaml.rs

1//! YAML parser for HttpInboundAdapterSpec (v1).
2//! Expects structure defined in `schema/v1/http-inbound-adapter.schema.yml`.
3//! Performs structural validation: required non-empty `host`, `path`, `methods` (non-empty list),
4//! `request-channel`; numeric range for `port`; optional non-empty `id`, `reply-channel`.
5//!
6//! # Accepted Shape (Informal)
7//! ```yaml
8//! version: 1
9//! http-inbound-adapter:
10//!   id: http.receiveGateway          # optional
11//!   host: 127.0.0.1                  # required
12//!   port: 8080                       # required (1-65535)
13//!   path: /receiveGateway            # required (starts with '/')
14//!   methods: [ POST ]                # required, non-empty list, allowed verbs
15//!   request-channel: receiveChannel  # required
16//!   reply-channel: replyChannel      # optional
17//! ```
18//!
19//! # Validation Responsibilities
20//! * Validate top-level `version` (must equal 1).
21//! * Ensure mapping exists under `http-inbound-adapter`.
22//! * Enforce required keys and non-empty string semantics.
23//! * Enforce port integer & range (1-65535).
24//! * Enforce methods is non-empty sequence of allowed verbs.
25//! * Enforce path starts with '/'.
26//! * Defer uniqueness of `id` to future builder / collection.
27//!
28//! # Errors
29//! * Missing fields -> `Error::Serialization` with descriptive messages.
30//! * Wrong types -> `Error::Serialization` (e.g. non-string method).
31//! * Unsupported method -> `Error::Serialization`.
32//! * Out-of-range port -> `Error::Serialization`.
33//!
34//! # Non-Goals
35//! * Network binding validation (performed at runtime instantiation).
36//! * Channel existence checks.
37//! * Reply correlation semantics.
38//!
39//! See collection parser for aggregation logic: `HttpInboundAdaptersSpecYamlParser`.
40
41use crate::error::{Error, Result};
42use crate::spec::version::validate_version;
43use crate::spec::HttpInboundAdapterSpec;
44use serde_yaml::Value as YamlValue;
45
46pub struct HttpInboundAdapterSpecYamlParser;
47
48impl HttpInboundAdapterSpecYamlParser {
49    pub fn parse_value(yaml: &YamlValue) -> Result<HttpInboundAdapterSpec> {
50        let _v = validate_version(yaml)?;
51        let root = yaml
52            .get("http-inbound-adapter")
53            .ok_or_else(|| Error::serialization("missing 'http-inbound-adapter'"))?;
54        if !root.is_mapping() {
55            return Err(Error::serialization(
56                "'http-inbound-adapter' must be a mapping",
57            ));
58        }
59        // host
60        let host_val = root
61            .get("host")
62            .ok_or_else(|| Error::serialization("http-inbound-adapter.host required"))?;
63        let host_str = host_val
64            .as_str()
65            .ok_or_else(|| Error::serialization("http-inbound-adapter.host must be string"))?;
66        if host_str.is_empty() {
67            return Err(Error::serialization(
68                "http-inbound-adapter.host must not be empty",
69            ));
70        }
71        // port
72        let port_val = root
73            .get("port")
74            .ok_or_else(|| Error::serialization("http-inbound-adapter.port required"))?;
75        let port_num = port_val
76            .as_u64()
77            .ok_or_else(|| Error::serialization("http-inbound-adapter.port must be integer"))?;
78        if port_num == 0 || port_num > 65535 {
79            return Err(Error::serialization(
80                "http-inbound-adapter.port out of range (1-65535)",
81            ));
82        }
83        let port_u16 = port_num as u16;
84        // path
85        let path_val = root
86            .get("path")
87            .ok_or_else(|| Error::serialization("http-inbound-adapter.path required"))?;
88        let path_str = path_val
89            .as_str()
90            .ok_or_else(|| Error::serialization("http-inbound-adapter.path must be string"))?;
91        if path_str.is_empty() {
92            return Err(Error::serialization(
93                "http-inbound-adapter.path must not be empty",
94            ));
95        }
96        if !path_str.starts_with('/') {
97            return Err(Error::serialization(
98                "http-inbound-adapter.path must start with '/'",
99            ));
100        }
101        // methods
102        let methods_val = root
103            .get("methods")
104            .ok_or_else(|| Error::serialization("http-inbound-adapter.methods required"))?;
105        if !methods_val.is_sequence() {
106            return Err(Error::serialization(
107                "http-inbound-adapter.methods must be a sequence",
108            ));
109        }
110        let seq = methods_val.as_sequence().unwrap();
111        if seq.is_empty() {
112            return Err(Error::serialization(
113                "http-inbound-adapter.methods sequence must not be empty",
114            ));
115        }
116        const ALLOWED: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"];
117        let mut methods: Vec<String> = Vec::with_capacity(seq.len());
118        for m in seq {
119            let ms = m.as_str().ok_or_else(|| {
120                Error::serialization("http-inbound-adapter.method must be string")
121            })?;
122            if !ALLOWED.contains(&ms) {
123                return Err(Error::serialization(format!(
124                    "unsupported http-inbound-adapter.method '{ms}'"
125                )));
126            }
127            methods.push(ms.to_string());
128        }
129        // request-channel
130        let req_val = root
131            .get("request-channel")
132            .ok_or_else(|| Error::serialization("http-inbound-adapter.request-channel required"))?;
133        let req_str = req_val.as_str().ok_or_else(|| {
134            Error::serialization("http-inbound-adapter.request-channel must be string")
135        })?;
136        if req_str.is_empty() {
137            return Err(Error::serialization(
138                "http-inbound-adapter.request-channel must not be empty",
139            ));
140        }
141        // reply-channel (optional)
142        let reply_opt = root
143            .get("reply-channel")
144            .and_then(|v| v.as_str())
145            .map(|s| s.to_string());
146        if let Some(ref rc) = reply_opt {
147            if rc.is_empty() {
148                return Err(Error::serialization(
149                    "http-inbound-adapter.reply-channel must not be empty",
150                ));
151            }
152        }
153        // id (optional)
154        let id_opt = root
155            .get("id")
156            .and_then(|v| v.as_str())
157            .map(|s| s.to_string());
158        if let Some(ref idv) = id_opt {
159            if idv.is_empty() {
160                return Err(Error::serialization(
161                    "http-inbound-adapter.id must not be empty",
162                ));
163            }
164        }
165        Ok(match (id_opt, reply_opt) {
166            (Some(id), Some(reply)) => HttpInboundAdapterSpec::with_id_reply(
167                id, host_str, port_u16, path_str, methods, req_str, reply,
168            ),
169            (Some(id), None) => {
170                HttpInboundAdapterSpec::with_id(id, host_str, port_u16, path_str, methods, req_str)
171            }
172            (None, Some(reply)) => HttpInboundAdapterSpec::with_reply(
173                host_str, port_u16, path_str, methods, req_str, reply,
174            ),
175            (None, None) => {
176                HttpInboundAdapterSpec::new(host_str, port_u16, path_str, methods, req_str)
177            }
178        })
179    }
180    pub fn parse_str(raw: &str) -> Result<HttpInboundAdapterSpec> {
181        let val: YamlValue = serde_yaml::from_str(raw)
182            .map_err(|e| Error::serialization(format!("yaml parse error: {e}")))?;
183        Self::parse_value(&val)
184    }
185}