Skip to main content

soothfast_spec/
providers.rs

1//! Declared-surface providers: parse each spec dialect into [`DeclaredOp`]s.
2//! YAML converts to `serde_json::Value` so one walker serves YAML and JSON;
3//! GraphQL SDL uses a hand-rolled field extractor (we need field names, not
4//! an AST — dependency policy).
5
6use serde_json::Value;
7
8use crate::{DeclaredOp, SpecKind, serialize};
9
10/// Parse one spec document into the operations it declares, dispatching on
11/// dialect: OpenAPI, AsyncAPI, and MCP tool schemas are YAML-or-JSON;
12/// GraphQL is SDL text.
13pub fn parse(kind: SpecKind, text: &str) -> Result<Vec<DeclaredOp>, String> {
14    match kind {
15        SpecKind::OpenApi => openapi(&serialize::from_text(text)?),
16        SpecKind::AsyncApi => asyncapi(&serialize::from_text(text)?),
17        SpecKind::McpTools => mcp_tools(&serialize::from_text(text)?),
18        SpecKind::GraphQl => graphql(text),
19    }
20}
21
22const HTTP_METHODS: &[&str] = &[
23    "get", "put", "post", "delete", "options", "head", "patch", "trace",
24];
25
26fn openapi(doc: &Value) -> Result<Vec<DeclaredOp>, String> {
27    let paths = doc["paths"]
28        .as_object()
29        .ok_or("OpenAPI spec has no `paths` object")?;
30    let mut ops = Vec::new();
31    for (path, methods) in paths {
32        let Some(methods) = methods.as_object() else {
33            continue;
34        };
35        for (method, op) in methods {
36            if !HTTP_METHODS.contains(&method.as_str()) {
37                continue;
38            }
39            let operation = op["operationId"]
40                .as_str()
41                .map(str::to_string)
42                .unwrap_or_else(|| format!("{} {path}", method.to_uppercase()));
43            ops.push(DeclaredOp {
44                operation,
45                method: method.to_uppercase(),
46                path: path.clone(),
47            });
48        }
49    }
50    Ok(ops)
51}
52
53/// AsyncAPI, in either of its two incompatible layouts.
54///
55/// 2.x hangs `publish`/`subscribe` off the channel; 3.0 moved them to a
56/// top-level `operations` map keyed by operation id, with `action: send` or
57/// `receive` and a `$ref` back to the channel. Both are still in the wild —
58/// generated files are 3.0, hand-authored ones are frequently 2.x — so the
59/// reader takes whichever the document actually uses.
60fn asyncapi(doc: &Value) -> Result<Vec<DeclaredOp>, String> {
61    if let Some(operations) = doc["operations"].as_object() {
62        return Ok(asyncapi_v3(doc, operations));
63    }
64    let channels = doc["channels"]
65        .as_object()
66        .ok_or("AsyncAPI spec has no `channels` or `operations` object")?;
67    let mut ops = Vec::new();
68    for (channel, body) in channels {
69        for verb in ["subscribe", "publish"] {
70            let op = &body[verb];
71            if op.is_null() {
72                continue;
73            }
74            let operation = op["operationId"]
75                .as_str()
76                .map(str::to_string)
77                .unwrap_or_else(|| format!("{channel}.{verb}"));
78            ops.push(DeclaredOp {
79                operation,
80                method: verb.to_uppercase(),
81                path: channel.clone(),
82            });
83        }
84    }
85    Ok(ops)
86}
87
88/// 3.0 operations, reported in 2.x's vocabulary.
89///
90/// `send`/`receive` are written from the application's point of view and
91/// `publish`/`subscribe` from the client's, so they are opposites: an
92/// operation the application *sends* is one a client *subscribes* to.
93/// Reporting the 2.x spelling keeps one `#[route(method = ...)]` annotation
94/// matching the same surface whichever version the file is written in.
95fn asyncapi_v3(doc: &Value, operations: &serde_json::Map<String, Value>) -> Vec<DeclaredOp> {
96    operations
97        .iter()
98        .map(|(id, op)| {
99            let method = match op["action"].as_str() {
100                Some("send") => "SUBSCRIBE",
101                Some("receive") => "PUBLISH",
102                _ => "SEND",
103            };
104            DeclaredOp {
105                operation: id.clone(),
106                method: method.to_string(),
107                path: channel_address(doc, &op["channel"]),
108            }
109        })
110        .collect()
111}
112
113/// The address of the channel an operation points at, following the one
114/// `$ref` hop a 3.0 operation always takes to reach it.
115fn channel_address(doc: &Value, channel: &Value) -> String {
116    let resolved = match channel["$ref"].as_str() {
117        Some(pointer) => {
118            let mut node = doc;
119            for token in pointer.trim_start_matches('#').split('/').skip(1) {
120                if token.is_empty() {
121                    continue;
122                }
123                node = &node[token.replace("~1", "/").replace("~0", "~")];
124            }
125            node
126        }
127        None => channel,
128    };
129    resolved["address"]
130        .as_str()
131        .or_else(|| channel["$ref"].as_str())
132        .unwrap_or_default()
133        .to_string()
134}
135
136fn mcp_tools(doc: &Value) -> Result<Vec<DeclaredOp>, String> {
137    let tools = doc["tools"]
138        .as_array()
139        .ok_or("MCP tool spec has no `tools` array")?;
140    Ok(tools
141        .iter()
142        .filter_map(|t| t["name"].as_str())
143        .map(|name| DeclaredOp {
144            operation: name.to_string(),
145            method: "TOOL".into(),
146            path: name.to_string(),
147        })
148        .collect())
149}
150
151/// Field names of the root operation types. Line-oriented: enough for
152/// reconciliation, deliberately not a GraphQL parser.
153fn graphql(text: &str) -> Result<Vec<DeclaredOp>, String> {
154    let (query_ty, mutation_ty, subscription_ty) = root_type_names(text);
155    let type_tags = [
156        (format!("type {query_ty}"), "QUERY"),
157        (format!("type {mutation_ty}"), "MUTATION"),
158        (format!("type {subscription_ty}"), "SUBSCRIPTION"),
159    ];
160
161    let mut ops = Vec::new();
162    let mut current: Option<&str> = None; // QUERY / MUTATION / SUBSCRIPTION
163    let mut depth = 0i32;
164    // Field arguments sit at the same brace depth as the field itself, often
165    // spanning lines — track parens too so a field is only extracted when a
166    // line starts outside any still-open argument list.
167    let mut paren_depth = 0i32;
168    let mut in_description = false;
169    for raw in text.lines() {
170        let line = raw.trim();
171        if line.is_empty() {
172            continue;
173        }
174        let Some(line) = strip_description(line, &mut in_description) else {
175            continue;
176        };
177        let line = line.split('#').next().unwrap_or("").trim();
178        if line.is_empty() {
179            continue;
180        }
181        if current.is_none() {
182            for (ty, tag) in &type_tags {
183                if line.starts_with(ty.as_str())
184                    && line[ty.len()..].trim_start().starts_with(['{', '@'])
185                        | line[ty.len()..].is_empty()
186                        | line[ty.len()..].starts_with(' ')
187                {
188                    current = Some(tag);
189                    depth = 0;
190                    paren_depth = 0;
191                }
192            }
193        }
194        if let Some(tag) = current {
195            let starts_fresh = paren_depth == 0;
196            depth += line.matches('{').count() as i32;
197            depth -= line.matches('}').count() as i32;
198            paren_depth += line.matches('(').count() as i32;
199            paren_depth -= line.matches(')').count() as i32;
200            // A field line directly inside the type block: `name(args): Type`
201            // or `name: Type` — not a continuation of an open argument list.
202            if depth == 1 && starts_fresh && !line.contains("type ") {
203                let name: String = line
204                    .chars()
205                    .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
206                    .collect();
207                if !name.is_empty() && (line.contains(':') || line.contains('(')) {
208                    ops.push(DeclaredOp {
209                        operation: name.clone(),
210                        method: tag.into(),
211                        path: name,
212                    });
213                }
214            }
215            if depth <= 0 && line.contains('}') {
216                current = None;
217            }
218        }
219    }
220    if ops.is_empty() {
221        return Err("no Query/Mutation/Subscription fields found in SDL".into());
222    }
223    Ok(ops)
224}
225
226/// Root operation type names, from an explicit `schema { query: X
227/// mutation: Y subscription: Z }` definition when present. GraphQL only
228/// requires the canonical `Query`/`Mutation`/`Subscription` names when
229/// there's no explicit schema block (typical hand-written SDL) — generated
230/// schemas commonly use other names for their root types (e.g.
231/// async-graphql's merged-object pattern produces `QueryRoot`), and an
232/// explicit `schema { }` block is exactly how SDL says so.
233fn root_type_names(text: &str) -> (String, String, String) {
234    let mut query = "Query".to_string();
235    let mut mutation = "Mutation".to_string();
236    let mut subscription = "Subscription".to_string();
237    let mut in_schema = false;
238    let mut in_description = false;
239    let mut depth = 0i32;
240    for raw in text.lines() {
241        let line = raw.trim();
242        if line.is_empty() {
243            continue;
244        }
245        let Some(line) = strip_description(line, &mut in_description) else {
246            continue;
247        };
248        let line = line.split('#').next().unwrap_or("").trim();
249        if line.is_empty() {
250            continue;
251        }
252        if !in_schema {
253            // `schema`, never part of a longer identifier (`schemaVersion`)
254            // or a field (`schema: String`) — optionally followed by a
255            // directive (`schema @link(...) {`, common in Apollo Federation
256            // subgraphs) before the opening brace, on this line or the next.
257            if line == "schema"
258                || line.starts_with("schema ")
259                || line.starts_with("schema@")
260                || line.starts_with("schema{")
261            {
262                in_schema = true;
263                depth = 0;
264            } else {
265                continue;
266            }
267        }
268        depth += line.matches('{').count() as i32;
269        depth -= line.matches('}').count() as i32;
270        // Scan for each root-type keyword independently rather than
271        // splitting the whole line on its first `:` — a compact single-line
272        // block (`schema { query: Q, mutation: M }`) has more than one
273        // `key: Value` pair, and a naive whole-line split only ever finds
274        // the first.
275        for (keyword, slot) in [
276            ("query", &mut query),
277            ("mutation", &mut mutation),
278            ("subscription", &mut subscription),
279        ] {
280            if let Some(value) = find_root_type_value(line, keyword) {
281                *slot = value;
282            }
283        }
284        if depth <= 0 && line.contains('}') {
285            break;
286        }
287    }
288    (query, mutation, subscription)
289}
290
291/// Find a whole-word `keyword` in `line` followed (after optional
292/// whitespace) by `:`, and return the identifier that follows it — the
293/// value of one `key: Value` pair in a `schema { }` block.
294fn find_root_type_value(line: &str, keyword: &str) -> Option<String> {
295    let bytes = line.as_bytes();
296    let is_word_byte = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
297    let mut start = 0;
298    while let Some(rel) = line[start..].find(keyword) {
299        let idx = start + rel;
300        let before_ok = idx == 0 || !is_word_byte(bytes[idx - 1]);
301        let after = idx + keyword.len();
302        let after_ok = after >= bytes.len() || !is_word_byte(bytes[after]);
303        if before_ok && after_ok {
304            let rest = line[after..].trim_start();
305            if let Some(rest) = rest.strip_prefix(':') {
306                let value: String = rest
307                    .trim_start()
308                    .chars()
309                    .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
310                    .collect();
311                if !value.is_empty() {
312                    return Some(value);
313                }
314            }
315        }
316        start = idx + keyword.len();
317    }
318    None
319}
320
321/// Strips a `"""..."""` description down to whatever real SDL (if any)
322/// follows its closing marker on the same line — a closing `"""` sharing a
323/// line with the next field/keyword is common formatter output and must not
324/// silently drop that content. `None` means the whole line was consumed by
325/// (or is still inside) a description; the caller should skip it entirely.
326fn strip_description<'a>(line: &'a str, in_description: &mut bool) -> Option<&'a str> {
327    if *in_description {
328        let idx = line.find("\"\"\"")?;
329        *in_description = false;
330        let rest = line[idx + 3..].trim();
331        return (!rest.is_empty()).then_some(rest);
332    }
333    if let Some(rest) = line.strip_prefix("\"\"\"") {
334        return match rest.find("\"\"\"") {
335            None => {
336                *in_description = true;
337                None
338            }
339            Some(idx) => {
340                let rest = rest[idx + 3..].trim();
341                (!rest.is_empty()).then_some(rest)
342            }
343        };
344    }
345    Some(line)
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn openapi_yaml() {
354        let spec = "\
355openapi: 3.0.0
356paths:
357  /items:
358    get:
359      operationId: listItems
360    post:
361      operationId: createItem
362  /items/{id}:
363    get:
364      operationId: getItem
365    parameters: []
366";
367        let ops = parse(SpecKind::OpenApi, spec).unwrap();
368        assert_eq!(ops.len(), 3);
369        assert!(
370            ops.iter()
371                .any(|o| o.operation == "getItem" && o.method == "GET" && o.path == "/items/{id}")
372        );
373    }
374
375    #[test]
376    fn graphql_sdl() {
377        let sdl = "\
378type Item { id: ID! }
379
380type Query {
381  item(id: ID!): Item
382  items: [Item!]!
383}
384
385type Mutation {
386  createItem(name: String!): Item
387}
388";
389        let ops = parse(SpecKind::GraphQl, sdl).unwrap();
390        assert_eq!(ops.len(), 3);
391        assert!(
392            ops.iter()
393                .any(|o| o.operation == "item" && o.method == "QUERY")
394        );
395        assert!(
396            ops.iter()
397                .any(|o| o.operation == "createItem" && o.method == "MUTATION")
398        );
399    }
400
401    #[test]
402    fn graphql_sdl_with_custom_root_type_names() {
403        // async-graphql's merged-object pattern (and other generators)
404        // commonly name root types after the Rust struct, not the GraphQL
405        // canonical `Query`/`Subscription` — an explicit `schema { }` block
406        // is how the SDL says which types those actually are.
407        let sdl = "\
408type QueryRoot {
409  item(id: ID!): Item
410}
411
412type SubscriptionRoot {
413  itemAdded: Item
414}
415
416schema {
417  query: QueryRoot
418  subscription: SubscriptionRoot
419}
420";
421        let ops = parse(SpecKind::GraphQl, sdl).unwrap();
422        assert_eq!(ops.len(), 2);
423        assert!(
424            ops.iter()
425                .any(|o| o.operation == "item" && o.method == "QUERY")
426        );
427        assert!(
428            ops.iter()
429                .any(|o| o.operation == "itemAdded" && o.method == "SUBSCRIPTION")
430        );
431    }
432
433    #[test]
434    fn graphql_sdl_ignores_multiline_arg_lists_and_doc_comments() {
435        // A field's own multi-line argument list (`items(\n  first: Int,\n
436        // after: String\n): ...`) sits at the same brace depth as the field
437        // itself — `first`/`after` must not be misread as sibling fields.
438        // `"""..."""` descriptions (field- or arg-level) can contain
439        // arbitrary prose with colons/parens that must not be read as SDL
440        // syntax either.
441        let sdl = "\
442type Query {
443  \"\"\"
444  Batch items: paginated, cursor-based.
445  \"\"\"
446  items(
447    \"\"\"
448    Max items to return (e.g. 100); omitted = every match.
449    \"\"\"
450    first: Int,
451    after: String
452  ): ItemConnection!
453  \"\"\" single-line description \"\"\"
454  ping: Boolean!
455}
456";
457        let ops = parse(SpecKind::GraphQl, sdl).unwrap();
458        let names: Vec<&str> = ops.iter().map(|o| o.operation.as_str()).collect();
459        assert_eq!(names, vec!["items", "ping"], "got {names:?}");
460    }
461
462    #[test]
463    fn graphql_sdl_with_compact_single_line_schema_block() {
464        // A whole-line `split_once(':')` would grab "schema { query" as the
465        // key on this line and never see `mutation`, and the schema block's
466        // braces net to zero on this one line too — both must not cause the
467        // scan to stop before every root type is found.
468        let sdl = "\
469type QueryRoot {
470  item: Item
471}
472type MutationRoot {
473  createItem: Item
474}
475schema { query: QueryRoot, mutation: MutationRoot }
476";
477        let ops = parse(SpecKind::GraphQl, sdl).unwrap();
478        assert!(
479            ops.iter()
480                .any(|o| o.operation == "item" && o.method == "QUERY")
481        );
482        assert!(
483            ops.iter()
484                .any(|o| o.operation == "createItem" && o.method == "MUTATION")
485        );
486    }
487
488    #[test]
489    fn graphql_sdl_with_directive_on_schema_block() {
490        // Apollo Federation subgraphs commonly decorate the schema block
491        // with a directive before its opening brace.
492        let sdl = "\
493type QueryRoot {
494  item: Item
495}
496schema @link(url: \"https://specs.apollo.dev/link/v1.0\") {
497  query: QueryRoot
498}
499";
500        let ops = parse(SpecKind::GraphQl, sdl).unwrap();
501        assert!(
502            ops.iter()
503                .any(|o| o.operation == "item" && o.method == "QUERY")
504        );
505    }
506
507    #[test]
508    fn graphql_sdl_ignores_schema_keyword_inside_a_doc_comment() {
509        // Prose that happens to start a line with the word "schema" inside
510        // a description must not be mistaken for a real schema block.
511        let sdl = "\
512\"\"\"
513schema
514query: NotARealType
515\"\"\"
516type Query {
517  item: Item
518}
519";
520        let ops = parse(SpecKind::GraphQl, sdl).unwrap();
521        assert!(
522            ops.iter()
523                .any(|o| o.operation == "item" && o.method == "QUERY")
524        );
525    }
526
527    #[test]
528    fn graphql_sdl_keeps_content_after_a_closing_description_marker() {
529        // A closing `"""` sharing a line with the next field is valid
530        // formatter output and must not swallow that field.
531        let sdl = "\
532type Query {
533  \"\"\"
534  A field.
535  \"\"\" ping: Boolean!
536}
537";
538        let ops = parse(SpecKind::GraphQl, sdl).unwrap();
539        let names: Vec<&str> = ops.iter().map(|o| o.operation.as_str()).collect();
540        assert_eq!(names, vec!["ping"], "got {names:?}");
541    }
542
543    #[test]
544    fn mcp_tools_json() {
545        let spec = r#"{ "tools": [ { "name": "get_item", "inputSchema": {} }, { "name": "list_items" } ] }"#;
546        let ops = parse(SpecKind::McpTools, spec).unwrap();
547        assert_eq!(ops.len(), 2);
548        assert_eq!(ops[0].method, "TOOL");
549    }
550
551    #[test]
552    fn asyncapi_channels() {
553        let spec = "\
554asyncapi: 2.6.0
555channels:
556  prices:
557    subscribe:
558      operationId: streamPrices
559";
560        let ops = parse(SpecKind::AsyncApi, spec).unwrap();
561        assert_eq!(ops.len(), 1);
562        assert_eq!(ops[0].operation, "streamPrices");
563        assert_eq!(ops[0].method, "SUBSCRIBE");
564        assert_eq!(ops[0].path, "prices");
565    }
566}