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 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 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 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 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
231fn 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
252fn 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
289fn 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 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 return Ok(path.to_path_buf());
410 }
411
412 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}