1use serde_json::Value;
9
10use crate::SpecKind;
11use crate::compat::Change;
12use crate::schema::route_sig::RouteShape;
13use crate::serialize;
14use crate::{asyncapi, graphql, mcp, openapi};
15
16#[derive(Debug, Clone, Default)]
18pub struct Info {
19 pub title: String,
20 pub version: String,
21 pub description: Option<String>,
22 pub servers: Vec<String>,
24}
25
26#[derive(Debug, Clone)]
28pub struct Operation {
29 pub operation_id: String,
30 pub method: String,
33 pub path: String,
35 pub summary: Option<String>,
37 pub shape: RouteShape,
38}
39
40#[derive(Debug, Clone, Default)]
42pub struct Document {
43 pub value: Value,
44 pub conflicts: Vec<String>,
47 pub notes: Vec<String>,
50}
51
52impl SpecKind {
53 pub const ALL: &'static [SpecKind] = &[
55 SpecKind::OpenApi,
56 SpecKind::AsyncApi,
57 SpecKind::GraphQl,
58 SpecKind::McpTools,
59 ];
60
61 pub fn name(self) -> &'static str {
63 match self {
64 SpecKind::OpenApi => "openapi",
65 SpecKind::AsyncApi => "asyncapi",
66 SpecKind::GraphQl => "graphql",
67 SpecKind::McpTools => "mcp",
68 }
69 }
70
71 pub fn parse_name(s: &str) -> Option<SpecKind> {
73 SpecKind::ALL.iter().copied().find(|k| {
74 k.name() == s
75 || matches!(
77 (k, s),
78 (SpecKind::GraphQl, "graphql-sdl" | "sdl")
79 | (SpecKind::McpTools, "mcp-tools" | "tools")
80 | (SpecKind::OpenApi, "openapi-3.1")
81 | (SpecKind::AsyncApi, "asyncapi-3.0")
82 )
83 })
84 }
85
86 pub fn for_path(path: &str) -> SpecKind {
94 let lower = path.to_ascii_lowercase();
95 let file = lower.rsplit('/').next().unwrap_or(&lower);
96 if file.ends_with(".graphql") || file.ends_with(".graphqls") || file.ends_with(".sdl") {
97 return SpecKind::GraphQl;
98 }
99 if file.contains("asyncapi") {
100 return SpecKind::AsyncApi;
101 }
102 if file.contains("mcp") || file.contains("tools") {
103 return SpecKind::McpTools;
104 }
105 SpecKind::OpenApi
106 }
107
108 pub fn document(self, info: &Info, ops: &[Operation]) -> Document {
110 match self {
111 SpecKind::OpenApi => openapi::document(info, ops),
112 SpecKind::AsyncApi => asyncapi::document(info, ops),
113 SpecKind::GraphQl => graphql::document(info, ops),
114 SpecKind::McpTools => mcp::document(info, ops),
115 }
116 }
117
118 pub fn render(self, path: &str, value: &Value) -> String {
123 if self == SpecKind::GraphQl {
124 return graphql::to_sdl(value);
125 }
126 let json = match path.rsplit('.').next() {
127 Some(ext) if ext.eq_ignore_ascii_case("json") => true,
128 Some(ext) if ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml") => {
129 false
130 }
131 _ => self == SpecKind::McpTools,
133 };
134 if json {
135 serialize::to_json(value)
136 } else {
137 serialize::to_yaml(value)
138 }
139 }
140
141 pub fn diff(self, old: &Value, new: &Value) -> Vec<Change> {
143 match self {
144 SpecKind::OpenApi => openapi::diff::diff(old, new),
145 SpecKind::AsyncApi => asyncapi::diff::diff(old, new),
146 SpecKind::GraphQl => graphql::diff::diff(old, new),
147 SpecKind::McpTools => mcp::diff(old, new),
148 }
149 }
150
151 pub fn empty(self) -> Value {
154 match self {
155 SpecKind::OpenApi => serde_json::json!({ "openapi": "3.1.0", "paths": {} }),
156 SpecKind::AsyncApi => {
157 serde_json::json!({ "asyncapi": "3.0.0", "channels": {}, "operations": {} })
158 }
159 SpecKind::GraphQl => serde_json::json!({ "types": {}, "roots": {} }),
160 SpecKind::McpTools => serde_json::json!({ "tools": [] }),
161 }
162 }
163
164 pub fn summarize(self, value: &Value) -> (usize, usize) {
166 let count = |v: &Value| v.as_object().map(|o| o.len()).unwrap_or(0);
167 match self {
168 SpecKind::OpenApi => (
169 value["paths"]
170 .as_object()
171 .map(|p| p.values().map(count).sum())
172 .unwrap_or(0),
173 count(&value["components"]["schemas"]),
174 ),
175 SpecKind::AsyncApi => (
176 count(&value["operations"]),
177 count(&value["components"]["schemas"]),
178 ),
179 SpecKind::GraphQl => (
180 value["roots"]
181 .as_object()
182 .map(|r| r.values().map(count).sum())
183 .unwrap_or(0),
184 count(&value["types"]),
185 ),
186 SpecKind::McpTools => (
187 value["tools"].as_array().map(|a| a.len()).unwrap_or(0),
188 value["tools"]
189 .as_array()
190 .map(|tools| {
191 tools
192 .iter()
193 .flat_map(|t| [&t["inputSchema"]["$defs"], &t["outputSchema"]["$defs"]])
194 .filter_map(|d| d.as_object())
195 .flat_map(|d| d.keys().cloned())
196 .collect::<std::collections::BTreeSet<_>>()
197 .len()
198 })
199 .unwrap_or(0),
200 ),
201 }
202 }
203}
204
205pub(crate) fn unknown_method(op: &Operation, dialect: &str, expected: &str) -> String {
207 format!(
208 "operation `{}` declares method `{}`, which {dialect} has no meaning for \
209 (expected one of {expected}) — fix the #[route] method or point this \
210 route at a spec file of the matching dialect",
211 op.operation_id, op.method
212 )
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn a_bare_spec_file_is_openapi() {
221 assert_eq!(SpecKind::for_path("specs/spec.yaml"), SpecKind::OpenApi);
222 assert_eq!(SpecKind::for_path("openapi.json"), SpecKind::OpenApi);
223 }
224
225 #[test]
226 fn extensions_and_filenames_name_the_other_dialects() {
227 assert_eq!(SpecKind::for_path("schema.graphql"), SpecKind::GraphQl);
228 assert_eq!(SpecKind::for_path("a/b/schema.sdl"), SpecKind::GraphQl);
229 assert_eq!(
230 SpecKind::for_path("specs/asyncapi.yaml"),
231 SpecKind::AsyncApi
232 );
233 assert_eq!(
234 SpecKind::for_path("specs/mcp-tools.json"),
235 SpecKind::McpTools
236 );
237 assert_eq!(SpecKind::for_path("tools.json"), SpecKind::McpTools);
238 }
239
240 #[test]
241 fn a_directory_name_does_not_decide_the_dialect() {
242 assert_eq!(SpecKind::for_path("graphql/spec.yaml"), SpecKind::OpenApi);
244 }
245
246 #[test]
247 fn dialect_names_round_trip() {
248 for k in SpecKind::ALL {
249 assert_eq!(SpecKind::parse_name(k.name()), Some(*k));
250 }
251 assert_eq!(SpecKind::parse_name("sdl"), Some(SpecKind::GraphQl));
252 assert_eq!(SpecKind::parse_name("nonsense"), None);
253 }
254
255 #[test]
256 fn the_extension_picks_the_serialization() {
257 let v = serde_json::json!({ "openapi": "3.1.0" });
258 assert!(SpecKind::OpenApi.render("a/spec.json", &v).starts_with('{'));
259 assert!(
260 SpecKind::OpenApi
261 .render("a/spec.yaml", &v)
262 .starts_with("openapi:")
263 );
264 let tools = serde_json::json!({ "tools": [] });
266 assert!(SpecKind::McpTools.render("tools", &tools).starts_with('{'));
267 assert!(
268 SpecKind::McpTools
269 .render("tools.yaml", &tools)
270 .starts_with("tools:")
271 );
272 }
273
274 #[test]
275 fn an_empty_document_diffs_as_all_additive() {
276 for kind in SpecKind::ALL {
277 let empty = kind.empty();
278 let changes = kind.diff(&empty, &empty);
279 assert!(changes.is_empty(), "{kind:?} got {changes:?}");
280 }
281 }
282}