1use serde_json::Value;
7
8use crate::{DeclaredOp, SpecKind, serialize};
9
10pub 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
53fn 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
88fn 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
113fn 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
151fn 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; let mut depth = 0i32;
164 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 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
226fn 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 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 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
291fn 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
321fn 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 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 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 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 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 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 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}