Skip to main content

seam_codegen/
manifest.rs

1/* src/cli/codegen/src/manifest.rs */
2
3use 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/// Cache hint for query procedures: `{ "ttl": 30 }` or `false`.
92#[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  /// Return the effective output schema: chunkOutput for streams, output for others.
138  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}