1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum ProcedureType {
11 Query,
12 Command,
13 Subscription,
14 Stream,
15 Upload,
16}
17
18impl std::fmt::Display for ProcedureType {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 match self {
21 Self::Query => write!(f, "query"),
22 Self::Command => write!(f, "command"),
23 Self::Subscription => write!(f, "subscription"),
24 Self::Stream => write!(f, "stream"),
25 Self::Upload => write!(f, "upload"),
26 }
27 }
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum TransportPreference {
33 Http,
34 Sse,
35 Ws,
36 Ipc,
37}
38
39impl std::fmt::Display for TransportPreference {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 match self {
42 Self::Http => write!(f, "http"),
43 Self::Sse => write!(f, "sse"),
44 Self::Ws => write!(f, "ws"),
45 Self::Ipc => write!(f, "ipc"),
46 }
47 }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct TransportConfig {
52 pub prefer: TransportPreference,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub fallback: Option<Vec<TransportPreference>>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ContextSchema {
59 pub extract: String,
60 pub schema: Value,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct Manifest {
65 pub version: u32,
66 #[serde(default)]
67 pub context: BTreeMap<String, ContextSchema>,
68 pub procedures: BTreeMap<String, ProcedureSchema>,
69 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
70 pub channels: BTreeMap<String, ChannelSchema>,
71 #[serde(default, rename = "transportDefaults")]
72 pub transport_defaults: BTreeMap<String, TransportConfig>,
73}
74
75impl Manifest {
76 pub fn validate_context_refs(&self) -> Result<(), Vec<String>> {
77 let mut errors = vec![];
78 for (proc_name, schema) in &self.procedures {
79 if let Some(ctx_keys) = &schema.context {
80 for key in ctx_keys {
81 if !self.context.contains_key(key) {
82 errors.push(format!("Procedure '{proc_name}' references undefined context '{key}'"));
83 }
84 }
85 }
86 }
87 if errors.is_empty() { Ok(()) } else { Err(errors) }
88 }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
93#[serde(untagged)]
94pub enum CacheHint {
95 Config { ttl: u64 },
96 Disabled(bool),
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ProcedureSchema {
101 #[serde(rename = "kind", alias = "type")]
102 pub proc_type: ProcedureType,
103 pub input: Value,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub output: Option<Value>,
106 #[serde(default, skip_serializing_if = "Option::is_none", rename = "chunkOutput")]
107 pub chunk_output: Option<Value>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub error: Option<Value>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub invalidates: Option<Vec<InvalidateTarget>>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub context: Option<Vec<String>>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub transport: Option<TransportConfig>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub suppress: Option<Vec<String>>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub cache: Option<CacheHint>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct InvalidateTarget {
124 pub query: String,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub mapping: Option<BTreeMap<String, MappingValue>>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct MappingValue {
131 pub from: String,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub each: Option<bool>,
134}
135
136impl ProcedureSchema {
137 pub fn effective_output(&self) -> Option<&Value> {
139 self.chunk_output.as_ref().or(self.output.as_ref())
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use serde_json::json;
147
148 #[test]
149 fn deserialize_v1_manifest() {
150 let json = r#"{
151 "version": 1,
152 "procedures": {
153 "getUser": { "type": "query", "input": {}, "output": {} },
154 "createUser": { "type": "command", "input": {}, "output": {} }
155 }
156 }"#;
157 let m: Manifest = serde_json::from_str(json).unwrap();
158 assert_eq!(m.version, 1);
159 assert_eq!(m.procedures["getUser"].proc_type, ProcedureType::Query);
160 assert_eq!(m.procedures["createUser"].proc_type, ProcedureType::Command);
161 }
162
163 #[test]
164 fn deserialize_v2_manifest() {
165 let json = r#"{
166 "version": 2,
167 "context": {},
168 "procedures": {
169 "getUser": { "kind": "query", "input": {}, "output": {} },
170 "onCount": { "kind": "subscription", "input": {}, "output": {} }
171 },
172 "transportDefaults": {}
173 }"#;
174 let m: Manifest = serde_json::from_str(json).unwrap();
175 assert_eq!(m.version, 2);
176 assert_eq!(m.procedures["getUser"].proc_type, ProcedureType::Query);
177 assert_eq!(m.procedures["onCount"].proc_type, ProcedureType::Subscription);
178 }
179
180 #[test]
181 fn deserialize_stream_manifest() {
182 let json = r#"{
183 "version": 2,
184 "context": {},
185 "procedures": {
186 "countStream": { "kind": "stream", "input": {}, "chunkOutput": {} }
187 },
188 "transportDefaults": {}
189 }"#;
190 let m: Manifest = serde_json::from_str(json).unwrap();
191 assert_eq!(m.procedures["countStream"].proc_type, ProcedureType::Stream);
192 assert!(m.procedures["countStream"].chunk_output.is_some());
193 assert!(m.procedures["countStream"].output.is_none());
194 }
195
196 #[test]
197 fn effective_output_returns_chunk_output_for_stream() {
198 let schema = ProcedureSchema {
199 proc_type: ProcedureType::Stream,
200 input: Value::Object(Default::default()),
201 output: None,
202 chunk_output: Some(json!({"properties": {"n": {"type": "int32"}}})),
203 error: None,
204 invalidates: None,
205 context: None,
206 transport: None,
207 suppress: None,
208 cache: None,
209 };
210 assert!(schema.effective_output().is_some());
211 assert_eq!(schema.effective_output(), schema.chunk_output.as_ref());
212 }
213
214 #[test]
215 fn effective_output_returns_output_for_query() {
216 let schema = ProcedureSchema {
217 proc_type: ProcedureType::Query,
218 input: Value::Object(Default::default()),
219 output: Some(json!({"properties": {"msg": {"type": "string"}}})),
220 chunk_output: None,
221 error: None,
222 invalidates: None,
223 context: None,
224 transport: None,
225 suppress: None,
226 cache: None,
227 };
228 assert!(schema.effective_output().is_some());
229 assert_eq!(schema.effective_output(), schema.output.as_ref());
230 }
231
232 #[test]
233 fn deserialize_upload_manifest() {
234 let json = r#"{
235 "version": 2,
236 "context": {},
237 "procedures": {
238 "uploadVideo": { "kind": "upload", "input": {}, "output": {} }
239 },
240 "transportDefaults": {}
241 }"#;
242 let m: Manifest = serde_json::from_str(json).unwrap();
243 assert_eq!(m.procedures["uploadVideo"].proc_type, ProcedureType::Upload);
244 assert!(m.procedures["uploadVideo"].output.is_some());
245 }
246
247 #[test]
248 fn serialize_outputs_kind() {
249 let m = Manifest {
250 version: 2,
251 context: BTreeMap::new(),
252 procedures: BTreeMap::from([(
253 "test".to_string(),
254 ProcedureSchema {
255 proc_type: ProcedureType::Command,
256 input: Value::Object(Default::default()),
257 output: Some(Value::Object(Default::default())),
258 chunk_output: None,
259 error: None,
260 invalidates: None,
261 context: None,
262 transport: None,
263 suppress: None,
264 cache: None,
265 },
266 )]),
267 channels: BTreeMap::new(),
268 transport_defaults: BTreeMap::new(),
269 };
270 let json = serde_json::to_string(&m).unwrap();
271 assert!(json.contains(r#""kind":"command""#));
272 assert!(!json.contains(r#""type""#));
273 }
274
275 #[test]
276 fn deserialize_invalidates() {
277 let json = r#"{
278 "version": 2,
279 "context": {},
280 "procedures": {
281 "getPost": { "kind": "query", "input": {}, "output": {} },
282 "updatePost": {
283 "kind": "command",
284 "input": {},
285 "output": {},
286 "invalidates": [
287 { "query": "getPost" },
288 { "query": "listPosts", "mapping": { "authorId": { "from": "userId" } } }
289 ]
290 }
291 },
292 "transportDefaults": {}
293 }"#;
294 let m: Manifest = serde_json::from_str(json).unwrap();
295 let inv = m.procedures["updatePost"].invalidates.as_ref().unwrap();
296 assert_eq!(inv.len(), 2);
297 assert_eq!(inv[0].query, "getPost");
298 assert!(inv[0].mapping.is_none());
299 assert_eq!(inv[1].query, "listPosts");
300 let mapping = inv[1].mapping.as_ref().unwrap();
301 assert_eq!(mapping["authorId"].from, "userId");
302 assert!(mapping["authorId"].each.is_none());
303 }
304
305 #[test]
306 fn deserialize_invalidates_with_each() {
307 let json = r#"{
308 "version": 2,
309 "procedures": {
310 "bulkUpdate": {
311 "kind": "command",
312 "input": {},
313 "output": {},
314 "invalidates": [
315 { "query": "getUser", "mapping": { "userId": { "from": "userIds", "each": true } } }
316 ]
317 }
318 }
319 }"#;
320 let m: Manifest = serde_json::from_str(json).unwrap();
321 let inv = m.procedures["bulkUpdate"].invalidates.as_ref().unwrap();
322 let mapping = inv[0].mapping.as_ref().unwrap();
323 assert_eq!(mapping["userId"].from, "userIds");
324 assert_eq!(mapping["userId"].each, Some(true));
325 }
326
327 #[test]
328 fn deserialize_manifest_with_context() {
329 let json = r#"{
330 "version": 2,
331 "context": {
332 "auth": { "extract": "extractAuth", "schema": { "properties": { "userId": { "type": "string" } } } }
333 },
334 "procedures": {
335 "getPost": { "kind": "query", "input": {}, "output": {}, "context": ["auth"] }
336 }
337 }"#;
338 let m: Manifest = serde_json::from_str(json).unwrap();
339 assert!(m.context.contains_key("auth"));
340 assert_eq!(m.context["auth"].extract, "extractAuth");
341 let ctx = m.procedures["getPost"].context.as_ref().unwrap();
342 assert_eq!(ctx, &vec!["auth".to_string()]);
343 }
344
345 #[test]
346 fn validate_context_refs_pass() {
347 let m = Manifest {
348 version: 2,
349 context: BTreeMap::from([(
350 "auth".to_string(),
351 ContextSchema { extract: "extractAuth".to_string(), schema: json!({}) },
352 )]),
353 procedures: BTreeMap::from([(
354 "getPost".to_string(),
355 ProcedureSchema {
356 proc_type: ProcedureType::Query,
357 input: json!({}),
358 output: Some(json!({})),
359 chunk_output: None,
360 error: None,
361 invalidates: None,
362 context: Some(vec!["auth".to_string()]),
363 transport: None,
364 suppress: None,
365 cache: None,
366 },
367 )]),
368 channels: BTreeMap::new(),
369 transport_defaults: BTreeMap::new(),
370 };
371 assert!(m.validate_context_refs().is_ok());
372 }
373
374 #[test]
375 fn validate_context_refs_fail() {
376 let m = Manifest {
377 version: 2,
378 context: BTreeMap::new(),
379 procedures: BTreeMap::from([(
380 "getPost".to_string(),
381 ProcedureSchema {
382 proc_type: ProcedureType::Query,
383 input: json!({}),
384 output: Some(json!({})),
385 chunk_output: None,
386 error: None,
387 invalidates: None,
388 context: Some(vec!["auth".to_string()]),
389 transport: None,
390 suppress: None,
391 cache: None,
392 },
393 )]),
394 channels: BTreeMap::new(),
395 transport_defaults: BTreeMap::new(),
396 };
397 let err = m.validate_context_refs().unwrap_err();
398 assert_eq!(err.len(), 1);
399 assert!(err[0].contains("getPost"));
400 assert!(err[0].contains("auth"));
401 }
402
403 #[test]
404 fn context_field_in_procedure_schema() {
405 let schema = ProcedureSchema {
406 proc_type: ProcedureType::Query,
407 input: json!({}),
408 output: Some(json!({})),
409 chunk_output: None,
410 error: None,
411 invalidates: None,
412 context: Some(vec!["auth".to_string()]),
413 transport: None,
414 suppress: None,
415 cache: None,
416 };
417 assert_eq!(schema.context.as_ref().unwrap(), &vec!["auth".to_string()]);
418 }
419
420 #[test]
421 fn deserialize_command_without_invalidates() {
422 let json = r#"{
423 "version": 2,
424 "procedures": {
425 "deleteUser": { "kind": "command", "input": {}, "output": {} }
426 }
427 }"#;
428 let m: Manifest = serde_json::from_str(json).unwrap();
429 assert!(m.procedures["deleteUser"].invalidates.is_none());
430 }
431
432 #[test]
433 fn deserialize_transport_defaults() {
434 let json = r#"{
435 "version": 2,
436 "context": {},
437 "procedures": {
438 "getUser": { "kind": "query", "input": {}, "output": {} }
439 },
440 "transportDefaults": {
441 "query": { "prefer": "http" },
442 "subscription": { "prefer": "ws", "fallback": ["sse", "http"] }
443 }
444 }"#;
445 let m: Manifest = serde_json::from_str(json).unwrap();
446 assert_eq!(m.transport_defaults.len(), 2);
447 assert_eq!(m.transport_defaults["query"].prefer, TransportPreference::Http);
448 let sub = &m.transport_defaults["subscription"];
449 assert_eq!(sub.prefer, TransportPreference::Ws);
450 assert_eq!(sub.fallback.as_ref().unwrap().len(), 2);
451 }
452
453 #[test]
454 fn deserialize_procedure_transport() {
455 let json = r#"{
456 "version": 2,
457 "procedures": {
458 "live": { "kind": "subscription", "input": {}, "output": {}, "transport": { "prefer": "ws", "fallback": ["http"] } }
459 }
460 }"#;
461 let m: Manifest = serde_json::from_str(json).unwrap();
462 let t = m.procedures["live"].transport.as_ref().unwrap();
463 assert_eq!(t.prefer, TransportPreference::Ws);
464 assert_eq!(t.fallback.as_ref().unwrap(), &vec![TransportPreference::Http]);
465 }
466
467 #[test]
468 fn backward_compat_empty_transport() {
469 let json = r#"{
470 "version": 2,
471 "procedures": {
472 "getUser": { "kind": "query", "input": {}, "output": {} }
473 },
474 "transportDefaults": {}
475 }"#;
476 let m: Manifest = serde_json::from_str(json).unwrap();
477 assert!(m.transport_defaults.is_empty());
478 }
479
480 #[test]
481 fn suppress_roundtrip() {
482 let schema = ProcedureSchema {
483 proc_type: ProcedureType::Query,
484 input: Value::Object(Default::default()),
485 output: Some(Value::Object(Default::default())),
486 chunk_output: None,
487 error: None,
488 invalidates: None,
489 context: None,
490 transport: None,
491 suppress: Some(vec!["unused".into()]),
492 cache: None,
493 };
494 let json = serde_json::to_string(&schema).unwrap();
495 assert!(json.contains(r#""suppress":["unused"]"#));
496 let deserialized: ProcedureSchema = serde_json::from_str(&json).unwrap();
497 assert_eq!(deserialized.suppress, Some(vec!["unused".to_string()]));
498 }
499
500 #[test]
501 fn suppress_omitted_when_none() {
502 let schema = ProcedureSchema {
503 proc_type: ProcedureType::Query,
504 input: Value::Object(Default::default()),
505 output: Some(Value::Object(Default::default())),
506 chunk_output: None,
507 error: None,
508 invalidates: None,
509 context: None,
510 transport: None,
511 suppress: None,
512 cache: None,
513 };
514 let json = serde_json::to_string(&schema).unwrap();
515 assert!(!json.contains("suppress"));
516 }
517
518 #[test]
519 fn cache_hint_config_roundtrip() {
520 let json = r#"{ "ttl": 30 }"#;
521 let hint: CacheHint = serde_json::from_str(json).unwrap();
522 assert_eq!(hint, CacheHint::Config { ttl: 30 });
523 let serialized = serde_json::to_string(&hint).unwrap();
524 assert!(serialized.contains("\"ttl\":30"));
525 }
526
527 #[test]
528 fn cache_hint_disabled_roundtrip() {
529 let hint: CacheHint = serde_json::from_str("false").unwrap();
530 assert_eq!(hint, CacheHint::Disabled(false));
531 let serialized = serde_json::to_string(&hint).unwrap();
532 assert_eq!(serialized, "false");
533 }
534
535 #[test]
536 fn cache_hint_omitted() {
537 let json = r#"{
538 "version": 2,
539 "procedures": {
540 "getUser": { "kind": "query", "input": {}, "output": {} }
541 }
542 }"#;
543 let m: Manifest = serde_json::from_str(json).unwrap();
544 assert!(m.procedures["getUser"].cache.is_none());
545 }
546
547 #[test]
548 fn cache_hint_in_manifest() {
549 let json = r#"{
550 "version": 2,
551 "procedures": {
552 "getUser": { "kind": "query", "input": {}, "output": {}, "cache": { "ttl": 60 } },
553 "listPosts": { "kind": "query", "input": {}, "output": {}, "cache": false }
554 }
555 }"#;
556 let m: Manifest = serde_json::from_str(json).unwrap();
557 assert_eq!(m.procedures["getUser"].cache, Some(CacheHint::Config { ttl: 60 }));
558 assert_eq!(m.procedures["listPosts"].cache, Some(CacheHint::Disabled(false)));
559 }
560}
561
562#[derive(Debug, Clone, Serialize, Deserialize)]
563pub struct ChannelSchema {
564 pub input: Value,
565 pub incoming: BTreeMap<String, IncomingSchema>,
566 pub outgoing: BTreeMap<String, Value>,
567 #[serde(default, skip_serializing_if = "Option::is_none")]
568 pub transport: Option<TransportConfig>,
569}
570
571#[derive(Debug, Clone, Serialize, Deserialize)]
572pub struct IncomingSchema {
573 pub input: Value,
574 pub output: Value,
575 #[serde(default, skip_serializing_if = "Option::is_none")]
576 pub error: Option<Value>,
577}