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 parse(self, path: &str, text: &str) -> Result<Value, String> {
147 let value = match self {
148 SpecKind::GraphQl => crate::graphql::from_sdl(text)?,
149 _ => serialize::from_text(text)?,
150 };
151 if self.render(path, &value).trim_end() != text.trim_end() {
152 return Err(format!(
153 "{path}: committed spec does not re-render byte-identically — \
154 regenerate it, or run the gate without --from-committed"
155 ));
156 }
157 Ok(value)
158 }
159
160 pub fn diff(self, old: &Value, new: &Value) -> Vec<Change> {
162 match self {
163 SpecKind::OpenApi => openapi::diff::diff(old, new),
164 SpecKind::AsyncApi => asyncapi::diff::diff(old, new),
165 SpecKind::GraphQl => graphql::diff::diff(old, new),
166 SpecKind::McpTools => mcp::diff(old, new),
167 }
168 }
169
170 pub fn empty(self) -> Value {
173 match self {
174 SpecKind::OpenApi => serde_json::json!({ "openapi": "3.1.0", "paths": {} }),
175 SpecKind::AsyncApi => {
176 serde_json::json!({ "asyncapi": "3.0.0", "channels": {}, "operations": {} })
177 }
178 SpecKind::GraphQl => serde_json::json!({ "types": {}, "roots": {} }),
179 SpecKind::McpTools => serde_json::json!({ "tools": [] }),
180 }
181 }
182
183 pub fn summarize(self, value: &Value) -> (usize, usize) {
185 let count = |v: &Value| v.as_object().map(|o| o.len()).unwrap_or(0);
186 match self {
187 SpecKind::OpenApi => (
188 value["paths"]
189 .as_object()
190 .map(|p| p.values().map(count).sum())
191 .unwrap_or(0),
192 count(&value["components"]["schemas"]),
193 ),
194 SpecKind::AsyncApi => (
195 count(&value["operations"]),
196 count(&value["components"]["schemas"]),
197 ),
198 SpecKind::GraphQl => (
199 value["roots"]
200 .as_object()
201 .map(|r| r.values().map(count).sum())
202 .unwrap_or(0),
203 count(&value["types"]),
204 ),
205 SpecKind::McpTools => (
206 value["tools"].as_array().map(|a| a.len()).unwrap_or(0),
207 value["tools"]
208 .as_array()
209 .map(|tools| {
210 tools
211 .iter()
212 .flat_map(|t| [&t["inputSchema"]["$defs"], &t["outputSchema"]["$defs"]])
213 .filter_map(|d| d.as_object())
214 .flat_map(|d| d.keys().cloned())
215 .collect::<std::collections::BTreeSet<_>>()
216 .len()
217 })
218 .unwrap_or(0),
219 ),
220 }
221 }
222}
223
224pub(crate) fn unknown_method(op: &Operation, dialect: &str, expected: &str) -> String {
226 format!(
227 "operation `{}` declares method `{}`, which {dialect} has no meaning for \
228 (expected one of {expected}) — fix the #[route] method or point this \
229 route at a spec file of the matching dialect",
230 op.operation_id, op.method
231 )
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn a_bare_spec_file_is_openapi() {
240 assert_eq!(SpecKind::for_path("specs/spec.yaml"), SpecKind::OpenApi);
241 assert_eq!(SpecKind::for_path("openapi.json"), SpecKind::OpenApi);
242 }
243
244 #[test]
245 fn extensions_and_filenames_name_the_other_dialects() {
246 assert_eq!(SpecKind::for_path("schema.graphql"), SpecKind::GraphQl);
247 assert_eq!(SpecKind::for_path("a/b/schema.sdl"), SpecKind::GraphQl);
248 assert_eq!(
249 SpecKind::for_path("specs/asyncapi.yaml"),
250 SpecKind::AsyncApi
251 );
252 assert_eq!(
253 SpecKind::for_path("specs/mcp-tools.json"),
254 SpecKind::McpTools
255 );
256 assert_eq!(SpecKind::for_path("tools.json"), SpecKind::McpTools);
257 }
258
259 #[test]
260 fn a_directory_name_does_not_decide_the_dialect() {
261 assert_eq!(SpecKind::for_path("graphql/spec.yaml"), SpecKind::OpenApi);
263 }
264
265 #[test]
266 fn parse_round_trips_a_rendered_document() {
267 let doc = serde_json::json!({
268 "openapi": "3.1.0",
269 "info": { "title": "Items", "version": "1.0" },
270 "paths": { "/items": { "get": {
271 "operationId": "listItems",
272 "responses": { "200": { "description": "OK" } },
273 } } },
274 });
275 for path in ["openapi.yaml", "openapi.json"] {
276 let rendered = SpecKind::OpenApi.render(path, &doc);
277 let parsed = SpecKind::OpenApi.parse(path, &rendered).expect("parses");
278 assert_eq!(SpecKind::OpenApi.render(path, &parsed), rendered);
279 }
280 }
281
282 #[test]
283 fn parse_rejects_text_that_does_not_re_render_identically() {
284 let err = SpecKind::OpenApi
286 .parse("openapi.yaml", "zebra: 1\nopenapi: 3.1.0\n")
287 .unwrap_err();
288 assert!(err.contains("byte-identically"), "{err}");
289 }
290
291 #[test]
292 fn parse_reads_generated_sdl() {
293 let doc = serde_json::json!({
294 "types": { "JSON": { "kind": "scalar" } },
295 "roots": { "Query": { "ping": { "type": "String!" } } },
296 });
297 let rendered = SpecKind::GraphQl.render("schema.graphql", &doc);
298 let parsed = SpecKind::GraphQl
299 .parse("schema.graphql", &rendered)
300 .expect("parses");
301 assert_eq!(parsed["roots"]["Query"]["ping"]["type"], "String!");
302 }
303
304 #[test]
305 fn dialect_names_round_trip() {
306 for k in SpecKind::ALL {
307 assert_eq!(SpecKind::parse_name(k.name()), Some(*k));
308 }
309 assert_eq!(SpecKind::parse_name("sdl"), Some(SpecKind::GraphQl));
310 assert_eq!(SpecKind::parse_name("nonsense"), None);
311 }
312
313 #[test]
314 fn the_extension_picks_the_serialization() {
315 let v = serde_json::json!({ "openapi": "3.1.0" });
316 assert!(SpecKind::OpenApi.render("a/spec.json", &v).starts_with('{'));
317 assert!(
318 SpecKind::OpenApi
319 .render("a/spec.yaml", &v)
320 .starts_with("openapi:")
321 );
322 let tools = serde_json::json!({ "tools": [] });
324 assert!(SpecKind::McpTools.render("tools", &tools).starts_with('{'));
325 assert!(
326 SpecKind::McpTools
327 .render("tools.yaml", &tools)
328 .starts_with("tools:")
329 );
330 }
331
332 #[test]
333 fn an_empty_document_diffs_as_all_additive() {
334 for kind in SpecKind::ALL {
335 let empty = kind.empty();
336 let changes = kind.diff(&empty, &empty);
337 assert!(changes.is_empty(), "{kind:?} got {changes:?}");
338 }
339 }
340}