Skip to main content

spikard_cli/codegen/asyncapi/
spec_parser.rs

1//! `AsyncAPI` v3 specification parsing and extraction.
2//!
3//! This module handles parsing `AsyncAPI` v3 specs and extracting structured data
4//! for code generation, including channels, messages, operations, and metadata.
5
6use anyhow::{Context, Result};
7use asyncapiv3::spec::{AsyncApiSpec, AsyncApiV3Spec};
8use serde_json::Value;
9use std::collections::{HashMap, HashSet};
10use std::fs;
11use std::path::Path;
12
13/// Message definition with schema and examples
14#[derive(Debug, Clone)]
15pub struct MessageDefinition {
16    pub schema: Value,
17    pub examples: Vec<Value>,
18}
19
20/// Message operation metadata from `AsyncAPI` spec
21#[derive(Debug, Clone)]
22pub struct MessageOperationMetadata {
23    pub name: String,
24    pub action: String,
25    pub replies: Vec<String>,
26}
27
28/// Channel operation metadata from `AsyncAPI` spec
29#[derive(Debug, Clone)]
30#[allow(dead_code)]
31pub struct ChannelOperation {
32    pub name: String,
33    pub action: String,
34    pub messages: Vec<String>,
35    pub replies: Vec<String>,
36}
37
38/// Parse an `AsyncAPI` v3 specification file
39///
40/// Supports both JSON and YAML formats
41pub fn parse_asyncapi_schema(path: &Path) -> Result<AsyncApiV3Spec> {
42    let content =
43        fs::read_to_string(path).with_context(|| format!("Failed to read AsyncAPI file: {}", path.display()))?;
44
45    let spec: AsyncApiSpec = if path.extension().and_then(|s| s.to_str()) == Some("json") {
46        serde_json::from_str(&content)
47            .with_context(|| format!("Failed to parse AsyncAPI JSON from {}", path.display()))?
48    } else {
49        serde_saphyr::from_str(&content)
50            .with_context(|| format!("Failed to parse AsyncAPI YAML from {}", path.display()))?
51    };
52
53    match spec {
54        AsyncApiSpec::V3_0_0(v3_spec) => Ok(v3_spec),
55    }
56}
57
58/// Extract message schemas from `AsyncAPI` spec for fixture generation
59///
60/// Returns a map of message name -> JSON Schema for generating test fixtures
61pub fn extract_message_schemas(spec: &AsyncApiV3Spec) -> Result<HashMap<String, MessageDefinition>> {
62    use asyncapiv3::spec::common::Either;
63    use asyncapiv3::spec::{channel::Channel, message::Message};
64
65    let mut schemas = HashMap::new();
66    let spec_doc = serde_json::to_value(spec).context("Failed to serialize AsyncAPI spec for $ref resolution")?;
67
68    for (message_name, message_ref_or) in &spec.components.messages {
69        tracing::debug!("Processing message: {}", message_name);
70
71        match message_ref_or {
72            Either::Right(message) => {
73                if let Some(definition) = build_message_definition(message, message_name, &spec_doc)? {
74                    schemas.insert(message_name.clone(), definition);
75                }
76            }
77            Either::Left(reference) => {
78                if let Some(message) = resolve_ref_as::<Message>(&spec_doc, &reference.reference) {
79                    if let Some(definition) = build_message_definition(&message, message_name, &spec_doc)? {
80                        schemas.insert(message_name.clone(), definition);
81                    }
82                } else {
83                    tracing::debug!(
84                        "Skipping unresolved message reference: {} -> {}",
85                        message_name,
86                        reference.reference
87                    );
88                }
89            }
90        }
91    }
92
93    for (channel_name, channel_ref_or) in &spec.channels {
94        tracing::debug!("Processing channel: {}", channel_name);
95
96        match channel_ref_or {
97            Either::Right(channel) => {
98                process_channel_messages(channel_name, channel, &spec_doc, &mut schemas)?;
99            }
100            Either::Left(reference) => {
101                if let Some(channel) = resolve_ref_as::<Channel>(&spec_doc, &reference.reference) {
102                    process_channel_messages(channel_name, &channel, &spec_doc, &mut schemas)?;
103                } else {
104                    tracing::debug!("Skipping unresolved channel reference: {}", reference.reference);
105                }
106            }
107        }
108    }
109
110    Ok(schemas)
111}
112
113fn process_channel_messages(
114    channel_name: &str,
115    channel: &asyncapiv3::spec::channel::Channel,
116    spec_doc: &Value,
117    schemas: &mut HashMap<String, MessageDefinition>,
118) -> Result<()> {
119    use asyncapiv3::spec::common::Either;
120    use asyncapiv3::spec::message::Message;
121
122    for (msg_name, msg_ref_or) in &channel.messages {
123        let full_name = format!("{}_{}", channel_name.trim_start_matches('/'), msg_name);
124        match msg_ref_or {
125            Either::Right(message) => {
126                if let Some(definition) = build_message_definition(message, &full_name, spec_doc)? {
127                    schemas.insert(full_name, definition);
128                }
129            }
130            Either::Left(reference) => {
131                if let Some(message) = resolve_ref_as::<Message>(spec_doc, &reference.reference) {
132                    if let Some(definition) = build_message_definition(&message, &full_name, spec_doc)? {
133                        schemas.insert(full_name, definition);
134                    }
135                } else {
136                    tracing::debug!(
137                        "Channel {} message {} unresolved reference: {}",
138                        channel_name,
139                        msg_name,
140                        reference.reference
141                    );
142                }
143            }
144        }
145    }
146
147    Ok(())
148}
149
150fn build_message_definition(
151    message: &asyncapiv3::spec::message::Message,
152    message_name: &str,
153    spec_doc: &Value,
154) -> Result<Option<MessageDefinition>> {
155    let schema = match extract_schema_from_message(message, message_name, spec_doc)? {
156        Some(schema) => schema,
157        None => return Ok(None),
158    };
159    let schema = resolve_schema_tree(spec_doc, &schema, 32);
160
161    let mut examples: Vec<Value> = Vec::new();
162    for example in &message.examples {
163        if !example.payload.is_empty() {
164            examples.push(sorted_payload_to_value(&example.payload)?);
165        }
166    }
167
168    if examples.is_empty() {
169        examples = generate_example_from_schema(&schema)?;
170    }
171
172    Ok(Some(MessageDefinition { schema, examples }))
173}
174
175/// Convert an `AsyncAPI` message example payload to a JSON object with deterministic key order.
176///
177/// `asyncapiv3::spec::message::MessageExample::payload` is a `HashMap<String, serde_json::Value>`,
178/// so its iteration order is randomized per process and does not reflect the source document's
179/// key order. Sorting keys before building the JSON object keeps generated fixtures byte-identical
180/// across runs regardless of the upstream `HashMap`'s iteration order.
181fn sorted_payload_to_value(payload: &HashMap<String, Value>) -> Result<Value> {
182    let mut keys: Vec<&String> = payload.keys().collect();
183    keys.sort();
184
185    let mut object = serde_json::Map::with_capacity(payload.len());
186    for key in keys {
187        let value = payload
188            .get(key)
189            .cloned()
190            .context("AsyncAPI message example payload key vanished during sort")?;
191        object.insert(key.clone(), value);
192    }
193
194    Ok(Value::Object(object))
195}
196
197/// Extract JSON Schema from an `AsyncAPI` Message object
198fn extract_schema_from_message(
199    message: &asyncapiv3::spec::message::Message,
200    message_name: &str,
201    spec_doc: &Value,
202) -> Result<Option<Value>> {
203    use asyncapiv3::spec::common::Either;
204
205    let payload = if let Some(payload_ref_or) = &message.payload {
206        payload_ref_or
207    } else {
208        tracing::debug!("Message {} has no payload", message_name);
209        return Ok(None);
210    };
211
212    match payload {
213        Either::Right(schema_or_multiformat) => match schema_or_multiformat {
214            Either::Left(schema) => {
215                let schema_json =
216                    serde_json::to_value(schema).context("Failed to serialize schemars::Schema to JSON")?;
217                Ok(Some(schema_json))
218            }
219            Either::Right(multi_format) => Ok(Some(multi_format.schema.clone())),
220        },
221        Either::Left(reference) => {
222            if let Some(resolved) = resolve_ref_value(spec_doc, &reference.reference) {
223                Ok(Some(normalize_schema_ref_value(resolved)))
224            } else {
225                tracing::debug!(
226                    "Message {} payload has unresolved reference: {}",
227                    message_name,
228                    reference.reference
229                );
230                Ok(None)
231            }
232        }
233    }
234}
235
236/// Generate example data from JSON Schema
237///
238/// Creates a simple valid example based on the schema properties
239pub fn generate_example_from_schema(schema: &Value) -> Result<Vec<Value>> {
240    let mut examples = Vec::new();
241
242    if let Some(schema_examples) = schema.get("examples").and_then(|e| e.as_array()) {
243        examples.extend(schema_examples.clone());
244    }
245
246    if examples.is_empty()
247        && schema
248            .get("type")
249            .and_then(|value| value.as_str())
250            .is_some_and(|ty| ty.eq_ignore_ascii_case("array"))
251    {
252        if let Some(items) = schema.get("items") {
253            let generated = generate_example_from_schema(items)?;
254            let template = generated
255                .into_iter()
256                .next()
257                .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
258            let min_items = schema.get("minItems").and_then(serde_json::Value::as_u64).unwrap_or(1);
259            let mut target_len = usize::try_from(min_items).unwrap_or(usize::MAX);
260            if target_len == 0 {
261                target_len = 1;
262            }
263            let capped_len = target_len.min(5);
264            let mut array_values = Vec::new();
265            for _ in 0..capped_len {
266                array_values.push(template.clone());
267            }
268            examples.push(Value::Array(array_values));
269        } else {
270            examples.push(Value::Array(vec![]));
271        }
272    }
273
274    if examples.is_empty()
275        && let Some(obj) = schema.get("properties").and_then(|p| p.as_object())
276    {
277        let mut example = serde_json::Map::new();
278
279        for (prop_name, prop_schema) in obj {
280            let example_value = if let Some(const_val) = prop_schema.get("const") {
281                const_val.clone()
282            } else if let Some(type_str) = prop_schema.get("type").and_then(|t| t.as_str()) {
283                match type_str {
284                    "string" => {
285                        if let Some(format) = prop_schema.get("format").and_then(|f| f.as_str()) {
286                            match format {
287                                "date-time" => Value::String("2024-01-15T10:30:00Z".to_string()),
288                                "date" => Value::String("2024-01-15".to_string()),
289                                "time" => Value::String("10:30:00".to_string()),
290                                "email" => Value::String("user@example.com".to_string()),
291                                "uri" => Value::String("https://example.com".to_string()),
292                                "uuid" => Value::String("550e8400-e29b-41d4-a716-446655440000".to_string()),
293                                _ => Value::String(format!("example_{prop_name}")),
294                            }
295                        } else {
296                            Value::String(format!("example_{prop_name}"))
297                        }
298                    }
299                    "number" => Value::Number(
300                        serde_json::Number::from_f64(std::f64::consts::PI)
301                            .unwrap_or_else(|| serde_json::Number::from(314)),
302                    ),
303                    "integer" => Value::Number(serde_json::Number::from(42)),
304                    "boolean" => Value::Bool(true),
305                    _ => Value::Null,
306                }
307            } else {
308                Value::Null
309            };
310
311            example.insert(prop_name.clone(), example_value);
312        }
313
314        examples.push(Value::Object(example));
315    }
316
317    if examples.is_empty() {
318        examples.push(Value::Object(serde_json::Map::new()));
319    }
320
321    Ok(examples)
322}
323
324/// Protocol types supported by `AsyncAPI`
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326pub enum Protocol {
327    WebSocket,
328    Sse,
329    Http,
330    Kafka,
331    Mqtt,
332    Amqp,
333    Other,
334}
335
336impl Protocol {
337    /// Detect protocol from `AsyncAPI` server definition
338    #[must_use]
339    pub fn from_protocol_string(protocol: &str) -> Self {
340        match protocol.to_lowercase().as_str() {
341            "ws" | "wss" | "websocket" | "websockets" => Self::WebSocket,
342            "sse" | "server-sent-events" => Self::Sse,
343            "http" | "https" => Self::Http,
344            "kafka" => Self::Kafka,
345            "mqtt" => Self::Mqtt,
346            "amqp" => Self::Amqp,
347            _ => Self::Other,
348        }
349    }
350
351    #[must_use]
352    pub const fn as_str(&self) -> &'static str {
353        match self {
354            Self::WebSocket => "websocket",
355            Self::Sse => "sse",
356            Self::Http => "http",
357            Self::Kafka => "kafka",
358            Self::Mqtt => "mqtt",
359            Self::Amqp => "amqp",
360            Self::Other => "other",
361        }
362    }
363}
364
365impl std::fmt::Display for Protocol {
366    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367        write!(f, "{}", self.as_str())
368    }
369}
370
371/// Determine primary protocol from `AsyncAPI` spec
372pub fn detect_primary_protocol(spec: &AsyncApiV3Spec) -> Result<Protocol> {
373    use asyncapiv3::spec::common::Either;
374    use asyncapiv3::spec::server::Server;
375
376    let spec_doc =
377        serde_json::to_value(spec).context("Failed to serialize AsyncAPI spec for server $ref resolution")?;
378
379    for server_or_ref in spec.servers.values() {
380        match server_or_ref {
381            Either::Right(server) => {
382                let protocol = Protocol::from_protocol_string(&server.protocol);
383                tracing::debug!("Detected protocol: {:?} from '{}'", protocol, server.protocol);
384                return Ok(protocol);
385            }
386            Either::Left(reference) => {
387                if let Some(server) = resolve_ref_as::<Server>(&spec_doc, &reference.reference) {
388                    let protocol = Protocol::from_protocol_string(&server.protocol);
389                    tracing::debug!(
390                        "Detected protocol: {:?} from referenced '{}'",
391                        protocol,
392                        server.protocol
393                    );
394                    return Ok(protocol);
395                }
396                tracing::debug!("Skipping unresolved server reference: {}", reference.reference);
397            }
398        }
399    }
400
401    tracing::warn!("Could not determine protocol from spec, defaulting to WebSocket");
402    Ok(Protocol::WebSocket)
403}
404
405/// Decode JSON pointer segments
406pub fn decode_pointer_segment(segment: &str) -> String {
407    segment.replace("~1", "/").replace("~0", "~")
408}
409
410fn reference_to_pointer(reference: &str) -> Option<String> {
411    let raw = reference.strip_prefix("#/")?;
412    let mut pointer = String::new();
413    for segment in raw.split('/') {
414        pointer.push('/');
415        pointer.push_str(&decode_pointer_segment(segment));
416    }
417    Some(pointer)
418}
419
420fn resolve_ref_value(document: &Value, reference: &str) -> Option<Value> {
421    let mut current = reference.to_string();
422    let mut visited = HashSet::new();
423
424    for _ in 0..32 {
425        if !visited.insert(current.clone()) {
426            return None;
427        }
428
429        let pointer = reference_to_pointer(&current)?;
430        let value = document.pointer(&pointer)?;
431
432        if let Some(next_ref) = value.get("$ref").and_then(Value::as_str) {
433            current = next_ref.to_string();
434            continue;
435        }
436
437        return Some(value.clone());
438    }
439
440    None
441}
442
443fn resolve_ref_as<T>(document: &Value, reference: &str) -> Option<T>
444where
445    T: serde::de::DeserializeOwned,
446{
447    let value = resolve_ref_value(document, reference)?;
448    serde_json::from_value(value).ok()
449}
450
451fn normalize_schema_ref_value(value: Value) -> Value {
452    if let Some(obj) = value.as_object()
453        && obj.get("schemaFormat").is_some()
454        && let Some(schema) = obj.get("schema")
455    {
456        return schema.clone();
457    }
458    value
459}
460
461fn resolve_schema_tree(document: &Value, schema: &Value, remaining_depth: usize) -> Value {
462    if remaining_depth == 0 {
463        return schema.clone();
464    }
465
466    if let Some(reference) = schema.get("$ref").and_then(Value::as_str)
467        && let Some(resolved) = resolve_ref_value(document, reference)
468    {
469        return resolve_schema_tree(document, &normalize_schema_ref_value(resolved), remaining_depth - 1);
470    }
471
472    match schema {
473        Value::Object(map) => {
474            let mut resolved = serde_json::Map::new();
475            for (key, value) in map {
476                resolved.insert(key.clone(), resolve_schema_tree(document, value, remaining_depth - 1));
477            }
478            Value::Object(resolved)
479        }
480        Value::Array(items) => Value::Array(
481            items
482                .iter()
483                .map(|item| resolve_schema_tree(document, item, remaining_depth - 1))
484                .collect(),
485        ),
486        _ => schema.clone(),
487    }
488}
489
490/// Resolve channel reference to channel path
491pub fn resolve_channel_from_ref(reference: &str) -> Option<String> {
492    let raw = reference.strip_prefix("#/channels/")?;
493    let decoded = raw.split('/').map(decode_pointer_segment).collect::<Vec<_>>().join("/");
494    let normalized = decoded.trim_start_matches('/').to_string();
495    Some(format!("/{normalized}"))
496}
497
498/// Resolve message reference to message name
499pub fn resolve_message_from_ref(reference: &str) -> Option<String> {
500    if let Some(name) = reference.strip_prefix("#/components/messages/") {
501        return Some(name.to_string());
502    }
503
504    if let Some(rest) = reference.strip_prefix("#/channels/") {
505        let mut parts = rest.split('/');
506        let channel = parts.next()?;
507        if parts.next()? != "messages" {
508            return None;
509        }
510        let message = parts.next()?;
511        let channel_name = decode_pointer_segment(channel);
512        let slug = channel_name.trim_start_matches('/').replace('/', "_");
513        return Some(format!("{}_{}", slug, decode_pointer_segment(message)));
514    }
515
516    None
517}
518
519/// Get operation action name as string
520pub const fn operation_action_name(action: &asyncapiv3::spec::operation::OperationAction) -> &'static str {
521    use asyncapiv3::spec::operation::OperationAction;
522    match action {
523        OperationAction::Send => "send",
524        OperationAction::Receive => "receive",
525    }
526}
527
528/// Collect message channel addresses from spec
529pub fn collect_message_channels(spec: &AsyncApiV3Spec) -> (HashMap<String, String>, HashMap<String, String>) {
530    use asyncapiv3::spec::common::Either;
531
532    let mut map = HashMap::new();
533    let mut aliases = HashMap::new();
534
535    for (channel_path, channel_ref_or) in &spec.channels {
536        let address = match channel_ref_or {
537            Either::Right(channel) => channel.address.clone().unwrap_or_else(|| channel_path.clone()),
538            Either::Left(_) => continue,
539        };
540        let normalized_address = if address.starts_with('/') {
541            address.clone()
542        } else {
543            format!("/{address}")
544        };
545
546        if let Either::Right(channel) = channel_ref_or {
547            for (message_name, message_ref) in &channel.messages {
548                let slug = channel_path.trim_start_matches('/').replace('/', "_");
549                let inline_key = format!("{slug}_{message_name}");
550                match message_ref {
551                    Either::Right(_) => {
552                        map.entry(inline_key.clone())
553                            .or_insert_with(|| normalized_address.clone());
554                    }
555                    Either::Left(reference) => {
556                        let target =
557                            resolve_message_from_ref(&reference.reference).unwrap_or_else(|| message_name.clone());
558                        map.entry(target.clone()).or_insert_with(|| normalized_address.clone());
559                        aliases.insert(inline_key, target);
560                    }
561                }
562            }
563        }
564    }
565
566    (map, aliases)
567}
568
569/// Collect message operations from spec
570pub fn collect_message_operations(
571    spec: &AsyncApiV3Spec,
572    aliases: &HashMap<String, String>,
573) -> HashMap<String, Vec<MessageOperationMetadata>> {
574    use asyncapiv3::spec::common::Either;
575
576    let mut map: HashMap<String, Vec<MessageOperationMetadata>> = HashMap::new();
577
578    for (op_name, operation_ref) in &spec.operations {
579        let operation = match operation_ref {
580            Either::Right(op) => op,
581            Either::Left(_) => continue,
582        };
583
584        let replies: Vec<String> = if let Some(Either::Right(reply)) = &operation.reply {
585            reply
586                .messages
587                .iter()
588                .filter_map(|reference| resolve_message_from_ref(&reference.reference))
589                .collect()
590        } else {
591            Vec::new()
592        };
593
594        if let Some(message_refs) = &operation.messages {
595            for reference in message_refs {
596                if let Some(name) = resolve_message_from_ref(&reference.reference) {
597                    let resolved_name = aliases.get(&name).cloned().unwrap_or(name.clone());
598                    map.entry(resolved_name).or_default().push(MessageOperationMetadata {
599                        name: op_name.clone(),
600                        action: operation_action_name(&operation.action).to_string(),
601                        replies: replies.clone(),
602                    });
603                }
604            }
605        }
606    }
607
608    map
609}
610
611/// Collect channel operations from spec
612pub fn collect_channel_operations(spec: &AsyncApiV3Spec) -> HashMap<String, Vec<ChannelOperation>> {
613    use asyncapiv3::spec::common::Either;
614
615    let mut map: HashMap<String, Vec<ChannelOperation>> = HashMap::new();
616
617    for (op_name, operation_ref) in &spec.operations {
618        let operation = match operation_ref {
619            Either::Right(op) => op,
620            Either::Left(_) => continue,
621        };
622
623        let channel_path = match resolve_channel_from_ref(&operation.channel.reference) {
624            Some(path) => path,
625            None => continue,
626        };
627
628        let messages = operation
629            .messages
630            .as_ref()
631            .map(|refs| {
632                refs.iter()
633                    .filter_map(|reference| resolve_message_from_ref(&reference.reference))
634                    .collect::<Vec<_>>()
635            })
636            .unwrap_or_default();
637
638        let replies = if let Some(Either::Right(reply)) = &operation.reply {
639            reply
640                .messages
641                .iter()
642                .filter_map(|reference| resolve_message_from_ref(&reference.reference))
643                .collect::<Vec<_>>()
644        } else {
645            Vec::new()
646        };
647
648        map.entry(channel_path.clone()).or_default().push(ChannelOperation {
649            name: op_name.clone(),
650            action: operation_action_name(&operation.action).to_string(),
651            messages,
652            replies,
653        });
654    }
655
656    map
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    #[test]
664    fn test_protocol_detection() {
665        assert_eq!(Protocol::from_protocol_string("ws"), Protocol::WebSocket);
666        assert_eq!(Protocol::from_protocol_string("wss"), Protocol::WebSocket);
667        assert_eq!(Protocol::from_protocol_string("websocket"), Protocol::WebSocket);
668        assert_eq!(Protocol::from_protocol_string("sse"), Protocol::Sse);
669        assert_eq!(Protocol::from_protocol_string("server-sent-events"), Protocol::Sse);
670        assert_eq!(Protocol::from_protocol_string("http"), Protocol::Http);
671        assert_eq!(Protocol::from_protocol_string("https"), Protocol::Http);
672        assert_eq!(Protocol::from_protocol_string("kafka"), Protocol::Kafka);
673        assert_eq!(Protocol::from_protocol_string("unknown"), Protocol::Other);
674    }
675
676    #[test]
677    fn test_decode_pointer_segment() {
678        assert_eq!(decode_pointer_segment("hello~1world"), "hello/world");
679        assert_eq!(decode_pointer_segment("test~0value"), "test~value");
680    }
681
682    #[test]
683    fn test_resolve_message_from_ref_components() {
684        let result = resolve_message_from_ref("#/components/messages/UserMessage");
685        assert_eq!(result, Some("UserMessage".to_string()));
686    }
687
688    #[test]
689    fn test_reference_to_pointer_decodes_json_pointer_segments() {
690        let pointer = reference_to_pointer("#/channels/user~1signedup/messages/user~0created");
691        assert_eq!(
692            pointer,
693            Some("/channels/user/signedup/messages/user~created".to_string())
694        );
695    }
696
697    #[test]
698    fn test_resolve_ref_value_follows_nested_local_refs() {
699        let doc = serde_json::json!({
700            "components": {
701                "schemas": {
702                    "A": { "$ref": "#/components/schemas/B" },
703                    "B": { "type": "object", "properties": { "id": { "type": "string" } } }
704                }
705            }
706        });
707
708        let resolved = resolve_ref_value(&doc, "#/components/schemas/A").expect("resolved schema");
709        assert_eq!(resolved["type"], "object");
710        assert!(resolved["properties"].get("id").is_some());
711    }
712
713    #[test]
714    fn test_detect_primary_protocol_resolves_server_refs() {
715        let spec_value = serde_json::json!({
716            "asyncapi": "3.0.0",
717            "info": { "title": "Test", "version": "1.0.0" },
718            "servers": {
719                "default": { "$ref": "#/components/servers/wsServer" }
720            },
721            "channels": {},
722            "operations": {},
723            "components": {
724                "servers": {
725                    "wsServer": {
726                        "host": "example.com",
727                        "protocol": "wss"
728                    }
729                }
730            }
731        });
732
733        let spec = match serde_json::from_value::<AsyncApiSpec>(spec_value).expect("valid asyncapi spec") {
734            AsyncApiSpec::V3_0_0(v3) => v3,
735        };
736
737        let protocol = detect_primary_protocol(&spec).expect("protocol detection");
738        assert_eq!(protocol, Protocol::WebSocket);
739    }
740}