Skip to main content

etdl_parser/
asyncapi.rs

1use crate::ast::{EtlDocument, ExternalRef, MessageRef};
2use crate::jsonptr;
3use serde_json::Value;
4use std::borrow::Cow;
5use std::collections::BTreeMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9pub struct AsyncApiRegistry {
10    documents: BTreeMap<String, AsyncApiDocument>,
11}
12
13struct AsyncApiDocument {
14    _path: PathBuf,
15    root: Value,
16}
17
18impl Default for AsyncApiRegistry {
19    fn default() -> Self {
20        Self::new()
21    }
22}
23
24impl AsyncApiRegistry {
25    pub fn new() -> Self {
26        AsyncApiRegistry {
27            documents: BTreeMap::new(),
28        }
29    }
30
31    pub fn load(&mut self, alias: &str, location: &str, base_dir: &Path) -> Result<(), String> {
32        let resolved_path = resolve_location(location, base_dir)?;
33        let content = fs::read_to_string(&resolved_path).map_err(|e| {
34            format!(
35                "cannot read AsyncAPI doc '{}': {}",
36                resolved_path.display(),
37                e
38            )
39        })?;
40
41        let root: Value = if resolved_path.extension().is_some_and(|ext| ext == "json") {
42            serde_json::from_str(&content).map_err(|e| {
43                format!(
44                    "invalid JSON in AsyncAPI doc '{}': {}",
45                    resolved_path.display(),
46                    e
47                )
48            })?
49        } else {
50            serde_yaml::from_str(&content).map_err(|e| {
51                format!(
52                    "invalid YAML in AsyncAPI doc '{}': {}",
53                    resolved_path.display(),
54                    e
55                )
56            })?
57        };
58
59        self.documents.insert(
60            alias.to_string(),
61            AsyncApiDocument {
62                _path: resolved_path,
63                root,
64            },
65        );
66        Ok(())
67    }
68
69    pub fn load_from_content(
70        &mut self,
71        alias: &str,
72        content: &str,
73        is_json: bool,
74    ) -> Result<(), String> {
75        let root: Value = if is_json {
76            serde_json::from_str(content)
77                .map_err(|e| format!("invalid JSON in AsyncAPI doc '{}': {}", alias, e))?
78        } else {
79            serde_yaml::from_str(content)
80                .map_err(|e| format!("invalid YAML in AsyncAPI doc '{}': {}", alias, e))?
81        };
82
83        self.documents.insert(
84            alias.to_string(),
85            AsyncApiDocument {
86                _path: PathBuf::from(alias),
87                root,
88            },
89        );
90        Ok(())
91    }
92
93    pub fn resolve(&self, ext_ref: &ExternalRef) -> Result<&Value, String> {
94        let doc = self.documents.get(&ext_ref.alias).ok_or_else(|| {
95            format!(
96                "import alias '{}' not found in loaded AsyncAPI documents",
97                ext_ref.alias
98            )
99        })?;
100
101        let pointer = &ext_ref.pointer;
102        jsonptr::resolve_json_pointer(&doc.root, pointer).ok_or_else(|| {
103            format!(
104                "JSON Pointer '{}' does not resolve in AsyncAPI document '{}'",
105                pointer, ext_ref.alias
106            )
107        })
108    }
109
110    pub fn resolve_ref(&self, alias: &str, pointer: &str) -> Result<&Value, String> {
111        let doc = self.documents.get(alias).ok_or_else(|| {
112            format!(
113                "import alias '{}' not found in loaded AsyncAPI documents",
114                alias
115            )
116        })?;
117
118        jsonptr::resolve_json_pointer(&doc.root, pointer).ok_or_else(|| {
119            format!(
120                "JSON Pointer '{}' does not resolve in AsyncAPI document '{}'",
121                pointer, alias
122            )
123        })
124    }
125
126    pub fn get_schema_for_path(
127        &self,
128        ext_ref: &ExternalRef,
129        path_segments: &[crate::ecel::PathSegment],
130    ) -> Result<Option<Value>, String> {
131        let message_value = self.resolve(ext_ref)?;
132        let Some(schema) = root_schema_for_path(message_value, path_segments) else {
133            return Ok(None);
134        };
135
136        let resolved = resolve_schema_path(schema, path_segments);
137        Ok(resolved)
138    }
139
140    /// Resolve a Message Reference (Section 5.3.4) to its AsyncAPI Message
141    /// Object-shaped value, regardless of whether it's an External
142    /// Reference (delegates to `resolve`) or an Internal Reference
143    /// (`#/components/messages/<id>`, looked up on `doc` and re-shaped into
144    /// the same `{name, payload, headers}` envelope an External Reference
145    /// would already carry, so callers don't need to branch on the
146    /// reference kind).
147    pub fn resolve_message<'a>(
148        &'a self,
149        doc: &EtlDocument,
150        msg_ref: &MessageRef,
151    ) -> Result<Cow<'a, Value>, String> {
152        match msg_ref {
153            MessageRef::External(ext_ref) => self.resolve(ext_ref).map(Cow::Borrowed),
154            MessageRef::Internal(int_ref) => {
155                let id = internal_message_id(&int_ref.pointer).ok_or_else(|| {
156                    format!(
157                        "internal reference '{}' is not of the form #/components/messages/<id>",
158                        int_ref.pointer
159                    )
160                })?;
161                let message = doc
162                    .components
163                    .as_ref()
164                    .and_then(|c| c.messages.as_ref())
165                    .and_then(|m| m.get(id))
166                    .ok_or_else(|| {
167                        format!(
168                            "internal reference '{}' does not resolve: no components.messages.{}",
169                            int_ref.pointer, id
170                        )
171                    })?;
172                let value = serde_json::to_value(message).map_err(|e| {
173                    format!("cannot convert inline message '{}' to JSON: {}", id, e)
174                })?;
175                Ok(Cow::Owned(value))
176            }
177        }
178    }
179
180    /// Like `get_schema_for_path`, but accepts either kind of Message
181    /// Reference (see `resolve_message`).
182    pub fn get_schema_for_message_ref(
183        &self,
184        doc: &EtlDocument,
185        msg_ref: &MessageRef,
186        path_segments: &[crate::ecel::PathSegment],
187    ) -> Result<Option<Value>, String> {
188        let message_value = self.resolve_message(doc, msg_ref)?;
189        let Some(schema) = root_schema_for_path(&message_value, path_segments) else {
190            return Ok(None);
191        };
192
193        Ok(resolve_schema_path(schema, path_segments))
194    }
195
196    /// Whether the terminal field of `path_segments` is listed in its
197    /// enclosing JSON Schema's `required` array — ordinary JSON Schema
198    /// semantics, used by ECEL's `defined()` (spec §6.4.1) to distinguish
199    /// "always present" (advisory: `defined()` on it is trivially `true`)
200    /// from "may be absent at runtime". Returns `None` if the path itself
201    /// doesn't resolve at all (a distinct, compile-time-error case — V-208
202    /// — handled by the caller via `get_schema_for_message_ref` returning
203    /// `None`, not by this method).
204    pub fn is_path_required(
205        &self,
206        doc: &EtlDocument,
207        msg_ref: &MessageRef,
208        path_segments: &[crate::ecel::PathSegment],
209    ) -> Result<Option<bool>, String> {
210        let message_value = self.resolve_message(doc, msg_ref)?;
211        let Some(root) = root_schema_for_path(&message_value, path_segments) else {
212            return Ok(None);
213        };
214        // Strip the same "message"/"payload"/"headers" root markers
215        // `resolve_schema_path` strips, to get to the real property chain.
216        let mut real_segments = path_segments;
217        while let Some(crate::ecel::PathSegment::Field(name)) = real_segments.first() {
218            if name == "message" || name == "payload" || name == "headers" {
219                real_segments = &real_segments[1..];
220            } else {
221                break;
222            }
223        }
224        if real_segments.is_empty() {
225            return Ok(None);
226        }
227        Ok(Some(is_required_along_path(root, real_segments)))
228    }
229}
230
231/// Picks the `payload` or `headers` root schema for a path, based on the
232/// segment immediately after `message` (spec §6.3: those are the only two
233/// root paths in scope). Falls back to `payload`/`schema` when the second
234/// segment is absent or unrecognized, preserving prior behavior for
235/// malformed/legacy call sites.
236fn root_schema_for_path<'a>(
237    message_value: &'a Value,
238    path_segments: &[crate::ecel::PathSegment],
239) -> Option<&'a Value> {
240    let second = path_segments.get(1).and_then(|seg| match seg {
241        crate::ecel::PathSegment::Field(name) => Some(name.as_str()),
242        _ => None,
243    });
244    match second {
245        Some("headers") => message_value.get("headers"),
246        _ => message_value
247            .get("payload")
248            .or_else(|| message_value.get("schema")),
249    }
250}
251
252/// Recursively checks whether every segment of `segments` is listed in its
253/// immediately-enclosing schema's `required` array. A `[*]`/index/quoted-key
254/// segment is always "required" in this sense (array elements and quoted
255/// keys have no `required`-array concept of their own) — only named `.field`
256/// segments off an `object` schema can be optional.
257fn is_required_along_path(schema: &Value, segments: &[crate::ecel::PathSegment]) -> bool {
258    let Some(first) = segments.first() else {
259        return true;
260    };
261    match first {
262        crate::ecel::PathSegment::Field(name) => {
263            let required = schema
264                .get("required")
265                .and_then(|r| r.as_array())
266                .is_some_and(|arr| arr.iter().any(|v| v.as_str() == Some(name.as_str())));
267            if !required {
268                return false;
269            }
270            match resolve_field(schema, name) {
271                Some(field_schema) if segments.len() > 1 => {
272                    is_required_along_path(&field_schema, &segments[1..])
273                }
274                _ => true,
275            }
276        }
277        crate::ecel::PathSegment::Wildcard | crate::ecel::PathSegment::Index(_) => {
278            match resolve_array_items(schema) {
279                Some(items_schema) if segments.len() > 1 => {
280                    is_required_along_path(&items_schema, &segments[1..])
281                }
282                _ => true,
283            }
284        }
285        crate::ecel::PathSegment::QuotedKey(_) => true,
286    }
287}
288
289/// Extracts `<id>` from an internal message-reference pointer of the form
290/// `#/components/messages/<id>`, or `None` if the pointer doesn't match
291/// that shape (e.g. it's a fault-tree `probabilitySource` pointer instead).
292fn internal_message_id(pointer: &str) -> Option<&str> {
293    let id = pointer.strip_prefix("#/components/messages/")?;
294    if id.is_empty() || id.contains('/') {
295        None
296    } else {
297        Some(id)
298    }
299}
300
301fn resolve_schema_path(schema: &Value, segments: &[crate::ecel::PathSegment]) -> Option<Value> {
302    if segments.is_empty() {
303        return Some(schema.clone());
304    }
305
306    let first = &segments[0];
307
308    match first {
309        crate::ecel::PathSegment::Field(name) => {
310            // `message`, `payload`, and `headers` are root markers, not
311            // real fields: `root_schema_for_path` (the caller's caller)
312            // already unwraps the message envelope down to the correct
313            // root schema — payload or headers, chosen by the segment
314            // right after `message` — before this function ever runs, so
315            // `schema` here already *is* what `message.payload` (or
316            // `message.headers`) denotes. Without stripping both marker
317            // segments, every `message.payload.<field>` / `message.headers.
318            // <field>` path (the two ECEL roots — spec §6.3) tried to
319            // resolve a literal field named `payload`/`headers` inside the
320            // already-unwrapped schema, which essentially never exists, so
321            // this always returned `None` -> the caller treated the type as
322            // `Unknown` -> V-204 type-checking silently never fired for any
323            // path operand. (`headers` parity was the second half of this
324            // fix — previously only `payload` was stripped here at all.)
325            if (name == "message" || name == "payload" || name == "headers")
326                && segments.len() > 1
327            {
328                return resolve_schema_path(schema, &segments[1..]);
329            }
330
331            let field_schema = resolve_field(schema, name)?;
332            if segments.len() == 1 {
333                Some(field_schema.clone())
334            } else {
335                resolve_schema_path(&field_schema, &segments[1..])
336            }
337        }
338        crate::ecel::PathSegment::Wildcard => {
339            let items_schema = resolve_array_items(schema)?;
340            if segments.len() == 1 {
341                Some(items_schema.clone())
342            } else {
343                resolve_schema_path(&items_schema, &segments[1..])
344            }
345        }
346        crate::ecel::PathSegment::Index(_) => {
347            let items_schema = resolve_array_items(schema)?;
348            if segments.len() == 1 {
349                Some(items_schema.clone())
350            } else {
351                resolve_schema_path(&items_schema, &segments[1..])
352            }
353        }
354        crate::ecel::PathSegment::QuotedKey(name) => {
355            let field_schema = resolve_field(schema, name)?;
356            if segments.len() == 1 {
357                Some(field_schema.clone())
358            } else {
359                resolve_schema_path(&field_schema, &segments[1..])
360            }
361        }
362    }
363}
364
365fn resolve_field(schema: &Value, name: &str) -> Option<Value> {
366    if let Some(properties) = schema.get("properties") {
367        if let Some(field) = properties.get(name) {
368            return Some(field.clone());
369        }
370    }
371
372    if let Some(obj) = schema.as_object() {
373        if let Some(field) = obj.get(name) {
374            return Some(field.clone());
375        }
376    }
377
378    None
379}
380
381fn resolve_array_items(schema: &Value) -> Option<Value> {
382    if let Some(items) = schema.get("items") {
383        return Some(items.clone());
384    }
385
386    if let Some(type_val) = schema.get("type") {
387        if type_val.as_str() == Some("array") {
388            if let Some(items) = schema.get("items") {
389                return Some(items.clone());
390            }
391        }
392    }
393
394    None
395}
396
397fn resolve_location(location: &str, base_dir: &Path) -> Result<PathBuf, String> {
398    if location.starts_with("http://") || location.starts_with("https://") {
399        return Err(format!(
400            "remote AsyncAPI imports not supported in this version: '{}'",
401            location
402        ));
403    }
404
405    let path = Path::new(location);
406
407    if path.is_absolute() {
408        // Absolute paths are allowed as-is (caller-provided and trusted).
409        return Ok(path.to_path_buf());
410    }
411
412    // Reject `..` escapes outside the project root (ETDL §12: local imports
413    // MUST NOT escape the project root).
414    if location.split('/').any(|seg| seg == "..") {
415        return Err(format!(
416            "AsyncAPI import '{}' must not contain '..' (path traversal outside the project root is forbidden)",
417            location
418        ));
419    }
420
421    Ok(base_dir.join(path))
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use std::path::Path;
428
429    #[test]
430    fn rejects_path_traversal() {
431        let base = Path::new("/proj");
432        assert!(resolve_location("../../etc/passwd", base).is_err());
433        assert!(resolve_location("./../secret.yaml", base).is_err());
434        assert!(resolve_location("a/../b.yaml", base).is_err());
435    }
436
437    #[test]
438    fn accepts_local_and_absolute() {
439        let base = Path::new("/proj");
440        assert_eq!(
441            resolve_location("api.yaml", base).unwrap(),
442            Path::new("/proj/api.yaml")
443        );
444        assert_eq!(
445            resolve_location("/etc/api.yaml", base).unwrap(),
446            Path::new("/etc/api.yaml")
447        );
448    }
449
450    #[test]
451    fn rejects_remote() {
452        let base = Path::new("/proj");
453        assert!(resolve_location("https://example.com/api.yaml", base).is_err());
454    }
455
456    #[test]
457    fn load_from_content_roundtrip() {
458        let mut registry = AsyncApiRegistry::new();
459        let yaml =
460            "asyncapi: '3.0.0'\ninfo:\n  title: t\n  version: '1'\nchannels: {}\ncomponents: {}\n";
461        registry.load_from_content("api", yaml, false).unwrap();
462        let ext = ExternalRef {
463            alias: "api".to_string(),
464            pointer: "/info/title".to_string(),
465        };
466        assert_eq!(registry.resolve(&ext).unwrap(), &serde_json::json!("t"));
467    }
468}