1use super::{
2 AuthorizationConfig, CustomRouteDefinition, HandlerType, SubscriptionDefinition,
3 TableDefinition,
4};
5use crate::features;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::BTreeMap;
9
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
13pub struct ServiceManifest {
14 pub name: String,
15 pub version: Option<String>,
16 pub tables: Vec<TableDefinition>,
17 #[serde(default)]
18 pub cells: Vec<CellDefinition>,
19 #[serde(default)]
20 pub events: Vec<EventDefinition>,
21 #[serde(default)]
22 pub subscriptions: Vec<SubscriptionDefinition>,
23 #[serde(default)]
24 pub custom_routes: Vec<CustomRouteDefinition>,
25 #[serde(default)]
26 pub mode: ServiceMode,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub authorization: Option<AuthorizationConfig>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub on_migrate: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub publishes: Option<Vec<String>>,
43 #[serde(default, skip_serializing_if = "Vec::is_empty")]
49 pub required_platform_features: Vec<String>,
50 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
53 pub graphql: bool,
54 #[serde(default, skip_serializing_if = "RoutingMode::is_legacy")]
57 pub routing: RoutingMode,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub errors: Option<ErrorProfile>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub openapi: Option<OpenApiOptions>,
66 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
69 pub components: BTreeMap<String, Value>,
70}
71
72#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
73pub struct OpenApiOptions {
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub title: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub version: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub description: Option<String>,
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
84 pub servers: Vec<ServerDoc>,
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub tags: Vec<TagDoc>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub security_scheme: Option<String>,
92}
93
94impl OpenApiOptions {
95 pub const DEFAULT_SECURITY_SCHEME: &'static str = "bearer_auth";
96
97 pub fn security_scheme(&self) -> &str {
98 self.security_scheme
99 .as_deref()
100 .unwrap_or(Self::DEFAULT_SECURITY_SCHEME)
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct ServerDoc {
106 pub url: String,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub description: Option<String>,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct TagDoc {
113 pub name: String,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub description: Option<String>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub struct ErrorProfile {
120 pub key: String,
121}
122
123impl ErrorProfile {
124 pub fn is_valid_key(key: &str) -> bool {
126 let mut bytes = key.bytes();
127 key.len() <= 64
128 && bytes
129 .next()
130 .is_some_and(|b| b.is_ascii_lowercase() || b == b'_')
131 && bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
132 }
133}
134
135#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum RoutingMode {
138 #[default]
139 Legacy,
140 Strict,
141}
142
143impl RoutingMode {
144 pub fn is_legacy(&self) -> bool {
145 *self == RoutingMode::Legacy
146 }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
150#[serde(rename_all = "lowercase")]
151pub enum ServiceMode {
152 #[default]
153 Crud,
154 Wasm,
155 Container,
156 Web,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct CellDefinition {
161 pub name: String,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct EventDefinition {
166 pub name: String,
167 pub table: String,
168 pub action: EventAction,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172#[serde(rename_all = "lowercase")]
173pub enum EventAction {
174 Create,
175 Update,
176 Delete,
177}
178
179impl ServiceManifest {
180 pub fn get_table(&self, name: &str) -> Option<&TableDefinition> {
182 self.tables.iter().find(|t| t.name == name)
183 }
184
185 pub fn requires_wasm(&self) -> bool {
193 if self.mode == ServiceMode::Wasm {
194 return true;
195 }
196 if self.on_migrate.is_some() {
197 return true;
198 }
199 if self.tables.iter().any(|t| t.hooks.is_some()) {
200 return true;
201 }
202 if !self.custom_routes.is_empty() {
203 return true;
204 }
205 if self
206 .subscriptions
207 .iter()
208 .any(|s| matches!(s.handler_type, HandlerType::Wasm))
209 {
210 return true;
211 }
212 false
213 }
214
215 pub fn declared_wasm_handlers(&self) -> Vec<&str> {
219 let mut handlers: Vec<&str> = Vec::new();
220 if let Some(ref h) = self.on_migrate {
221 handlers.push(h.as_str());
222 }
223 for route in &self.custom_routes {
224 handlers.push(route.handler.as_str());
225 if let Some(reducer) = route.sse.as_ref().and_then(|sse| sse.reducer.as_deref()) {
226 handlers.push(reducer);
227 }
228 }
229 for sub in &self.subscriptions {
230 if matches!(sub.handler_type, HandlerType::Wasm) {
231 handlers.push(sub.handler.as_str());
232 }
233 }
234 for table in &self.tables {
235 if let Some(ref hooks) = table.hooks {
236 for h in [
237 hooks.before_create.as_deref(),
238 hooks.after_create.as_deref(),
239 hooks.before_update.as_deref(),
240 hooks.after_update.as_deref(),
241 hooks.before_delete.as_deref(),
242 hooks.after_delete.as_deref(),
243 ]
244 .iter()
245 .flatten()
246 {
247 handlers.push(h);
248 }
249 }
250 }
251 handlers.sort();
252 handlers.dedup();
253 handlers
254 }
255
256 pub fn http_route(&self, handler: &str) -> Option<&CustomRouteDefinition> {
264 self.http_route_index(handler)
265 .and_then(|index| self.custom_routes.get(index))
266 }
267
268 pub fn http_route_index(&self, handler: &str) -> Option<usize> {
270 self.custom_routes.iter().position(|r| r.handler == handler)
271 }
272
273 pub fn is_posture_aware(&self) -> bool {
278 self.custom_routes.iter().any(|r| r.posture.is_some())
279 }
280
281 pub(crate) fn service_features(&self) -> impl Iterator<Item = &'static str> {
283 [
284 (self.routing == RoutingMode::Strict).then_some(features::ROUTE_MATCHING),
285 self.errors.is_some().then_some(features::ERROR_PROFILE),
286 ]
287 .into_iter()
288 .flatten()
289 }
290
291 pub fn deploy_hash(&self) -> serde_json::Result<String> {
294 use sha2::{Digest, Sha256};
295 serde_json::to_vec(self).map(|bytes| hex::encode(Sha256::digest(bytes)))
296 }
297
298 pub fn schema_hash(&self) -> serde_json::Result<String> {
300 use sha2::{Digest, Sha256};
301 let digest = [
302 serde_json::to_string(&self.tables)?,
303 serde_json::to_string(&self.custom_routes)?,
304 serde_json::to_string(&self.subscriptions)?,
305 serde_json::to_string(&self.authorization)?,
306 serde_json::to_string(&self.on_migrate)?,
307 ]
308 .iter()
309 .fold(Sha256::new(), |hasher, part| hasher.chain_update(part))
310 .finalize();
311 Ok(hex::encode(digest))
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::manifest::fixtures::sample_manifest;
319 use crate::manifest::{
320 ColumnDefinition, ColumnType, ForeignKeyAction, JobConfig, RoutePosture, TableHooks,
321 };
322 use std::collections::HashMap;
323
324 #[test]
325 fn test_manifest_serialization_roundtrip() {
326 let manifest = sample_manifest();
327 let json = serde_json::to_string(&manifest).unwrap();
328 let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
329 assert_eq!(parsed.name, "test-service");
330 assert_eq!(parsed.tables.len(), 1);
331 assert_eq!(parsed.tables[0].columns.len(), 3);
332 }
333
334 #[test]
335 fn routing_is_serialized_only_when_strict() {
336 let mut manifest = sample_manifest();
337 let json = serde_json::to_value(&manifest).unwrap();
338 assert!(json.get("routing").is_none(), "{json}");
339 manifest.routing = RoutingMode::Strict;
340 let json = serde_json::to_value(&manifest).unwrap();
341 assert_eq!(json["routing"], "strict");
342 let parsed: ServiceManifest = serde_json::from_value(json).unwrap();
343 assert_eq!(parsed.routing, RoutingMode::Strict);
344 }
345
346 #[test]
347 fn error_profile_keys_are_snake_case_identifiers() {
348 for key in ["error_message", "_e", "e1", &"e".repeat(64)] {
349 assert!(ErrorProfile::is_valid_key(key), "{key}");
350 }
351 for key in [
352 "",
353 "1e",
354 "Error",
355 "error-message",
356 "error message",
357 &"e".repeat(65),
358 ] {
359 assert!(!ErrorProfile::is_valid_key(key), "{key}");
360 }
361 let mut manifest = sample_manifest();
362 assert!(serde_json::to_value(&manifest)
363 .unwrap()
364 .get("errors")
365 .is_none());
366 manifest.errors = Some(ErrorProfile {
367 key: "error_message".into(),
368 });
369 assert_eq!(
370 serde_json::to_value(&manifest).unwrap()["errors"],
371 serde_json::json!({"key": "error_message"})
372 );
373 }
374
375 #[test]
376 fn test_get_table() {
377 let manifest = sample_manifest();
378 assert!(manifest.get_table("todos").is_some());
379 assert!(manifest.get_table("nonexistent").is_none());
380 }
381
382 #[test]
383 fn test_schema_hash_deterministic() {
384 let m1 = sample_manifest();
385 let m2 = sample_manifest();
386 assert_eq!(
387 m1.schema_hash().expect("schema hash"),
388 m2.schema_hash().expect("schema hash")
389 );
390 }
391
392 #[test]
393 fn test_schema_hash_changes() {
394 let mut m1 = sample_manifest();
395 let m2 = sample_manifest();
396 m1.tables[0].columns.push(ColumnDefinition {
397 name: "extra".into(),
398 column_type: ColumnType::Text,
399 primary_key: false,
400 nullable: true,
401 auto_generate: false,
402 default_value: None,
403 references: None,
404 on_delete: None,
405 unique: false,
406 validations: vec![],
407 });
408 assert_ne!(
409 m1.schema_hash().expect("schema hash"),
410 m2.schema_hash().expect("schema hash")
411 );
412 }
413
414 #[test]
415 fn test_fk_serialization() {
416 let manifest = sample_manifest();
417 let json = serde_json::to_string(&manifest).unwrap();
418 assert!(json.contains("\"references\":\"users.id\""));
419 assert!(json.contains("\"on_delete\":\"cascade\""));
420
421 let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
422 let user_id_col = &parsed.tables[0].columns[2];
423 assert_eq!(user_id_col.references.as_deref(), Some("users.id"));
424 assert_eq!(user_id_col.on_delete, Some(ForeignKeyAction::Cascade));
425 }
426
427 #[test]
428 fn test_service_mode_default() {
429 let json = r#"{"name":"svc","tables":[]}"#;
430 let m: ServiceManifest = serde_json::from_str(json).unwrap();
431 assert_eq!(m.mode, ServiceMode::Crud);
432 }
433
434 #[test]
435 fn test_on_migrate_defaults_to_none() {
436 let json = r#"{"name":"svc","tables":[]}"#;
437 let m: ServiceManifest = serde_json::from_str(json).unwrap();
438 assert!(m.on_migrate.is_none());
439 }
440
441 #[test]
442 fn test_on_migrate_serialization_roundtrip() {
443 let mut m = sample_manifest();
444 m.on_migrate = Some("handle_on_migrate".into());
445 let json = serde_json::to_string(&m).unwrap();
446 assert!(json.contains("\"on_migrate\":\"handle_on_migrate\""));
447 let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
448 assert_eq!(parsed.on_migrate.as_deref(), Some("handle_on_migrate"));
449 }
450
451 #[test]
452 fn test_on_migrate_skipped_when_none() {
453 let m = sample_manifest();
454 let json = serde_json::to_string(&m).unwrap();
455 assert!(!json.contains("on_migrate"));
456 }
457
458 #[test]
459 fn deploy_hash_covers_fields_schema_hash_ignores() {
460 let m1 = sample_manifest();
461 let mut m2 = sample_manifest();
462 let hash = |m: &ServiceManifest| m.deploy_hash().expect("hash");
463 assert_eq!(hash(&m1), hash(&m2));
464 m2.publishes = Some(vec!["acme.orders.>".into()]);
465 assert_ne!(hash(&m1), hash(&m2));
466 assert_eq!(
467 m1.schema_hash().expect("schema hash"),
468 m2.schema_hash().expect("schema hash")
469 );
470 }
471
472 #[test]
473 fn test_on_migrate_changes_schema_hash() {
474 let m1 = sample_manifest();
475 let mut m2 = sample_manifest();
476 m2.on_migrate = Some("handle_on_migrate".into());
477 assert_ne!(
478 m1.schema_hash().expect("schema hash"),
479 m2.schema_hash().expect("schema hash")
480 );
481 }
482
483 #[test]
484 fn test_requires_wasm_false_for_plain_crud() {
485 let m = sample_manifest();
486 assert!(!m.requires_wasm());
487 }
488
489 #[test]
490 fn test_requires_wasm_true_for_wasm_mode() {
491 let mut m = sample_manifest();
492 m.mode = ServiceMode::Wasm;
493 assert!(m.requires_wasm());
494 }
495
496 #[test]
497 fn test_requires_wasm_true_for_on_migrate() {
498 let mut m = sample_manifest();
499 m.on_migrate = Some("handle_on_migrate".into());
500 assert!(m.requires_wasm());
501 }
502
503 #[test]
504 fn test_requires_wasm_true_for_table_hooks() {
505 let mut m = sample_manifest();
506 m.tables[0].hooks = Some(TableHooks {
507 before_create: Some("validate".into()),
508 ..Default::default()
509 });
510 assert!(m.requires_wasm());
511 }
512
513 #[test]
514 fn test_requires_wasm_true_for_custom_routes() {
515 let mut m = sample_manifest();
516 m.custom_routes.push(CustomRouteDefinition {
517 method: "GET".into(),
518 path: "/hello".into(),
519 handler: "hello".into(),
520 job: None,
521 posture: None,
522 forward_auth: false,
523 ..Default::default()
524 });
525 assert!(m.requires_wasm());
526 }
527
528 #[test]
529 fn test_requires_wasm_true_for_wasm_subscription() {
530 let mut m = sample_manifest();
531 m.subscriptions.push(SubscriptionDefinition {
532 subject: "events.foo".into(),
533 handler: "on_foo".into(),
534 handler_type: HandlerType::Wasm,
535 config: HashMap::new(),
536 stream: None,
537 });
538 assert!(m.requires_wasm());
539 }
540
541 #[test]
542 fn test_requires_wasm_false_for_non_wasm_subscription() {
543 let mut m = sample_manifest();
544 m.subscriptions.push(SubscriptionDefinition {
545 subject: "events.foo".into(),
546 handler: "on_foo".into(),
547 handler_type: HandlerType::Webhook,
548 config: HashMap::new(),
549 stream: None,
550 });
551 assert!(!m.requires_wasm());
552 }
553
554 #[test]
555 fn test_declared_wasm_handlers_collects_all_sources() {
556 let mut m = sample_manifest();
557 m.on_migrate = Some("on_mig".into());
558 m.custom_routes.push(CustomRouteDefinition {
559 method: "GET".into(),
560 path: "/h".into(),
561 handler: "route_h".into(),
562 job: None,
563 posture: None,
564 forward_auth: false,
565 ..Default::default()
566 });
567 m.subscriptions.push(SubscriptionDefinition {
568 subject: "x".into(),
569 handler: "sub_h".into(),
570 handler_type: HandlerType::Wasm,
571 config: HashMap::new(),
572 stream: None,
573 });
574 m.subscriptions.push(SubscriptionDefinition {
575 subject: "y".into(),
576 handler: "wh_ignored".into(),
577 handler_type: HandlerType::Webhook,
578 config: HashMap::new(),
579 stream: None,
580 });
581 m.tables[0].hooks = Some(TableHooks {
582 before_create: Some("bc".into()),
583 after_create: Some("ac".into()),
584 before_delete: Some("bc".into()), ..Default::default()
586 });
587 let handlers = m.declared_wasm_handlers();
588 assert!(handlers.contains(&"on_mig"));
589 assert!(handlers.contains(&"route_h"));
590 assert!(handlers.contains(&"sub_h"));
591 assert!(handlers.contains(&"bc"));
592 assert!(handlers.contains(&"ac"));
593 assert!(!handlers.contains(&"wh_ignored"));
594 assert_eq!(handlers.iter().filter(|h| **h == "bc").count(), 1);
596 }
597
598 #[test]
599 fn http_route_exposes_only_custom_routes() {
600 let mut m = sample_manifest();
601 m.on_migrate = Some("on_mig".into());
602 m.custom_routes.push(CustomRouteDefinition {
603 method: "GET".into(),
604 path: "/h".into(),
605 handler: "route_h".into(),
606 job: None,
607 posture: None,
608 forward_auth: false,
609 ..Default::default()
610 });
611 m.subscriptions.push(SubscriptionDefinition {
612 subject: "evt.sub".into(),
613 handler: "sub_h".into(),
614 handler_type: HandlerType::Wasm,
615 config: HashMap::new(),
616 stream: None,
617 });
618 m.tables[0].hooks = Some(TableHooks {
619 after_create: Some("hook_h".into()),
620 ..Default::default()
621 });
622
623 assert_eq!(
624 m.http_route("route_h").map(|r| r.handler.as_str()),
625 Some("route_h")
626 );
627 assert!(m.http_route("sub_h").is_none());
628 assert!(m.http_route("on_mig").is_none());
629 assert!(m.http_route("hook_h").is_none());
630 assert!(m.http_route("unknown").is_none());
631 }
632
633 #[test]
634 fn http_route_allows_handler_declared_as_both_route_and_subscription() {
635 let mut m = sample_manifest();
636 m.custom_routes.push(CustomRouteDefinition {
637 method: "POST".into(),
638 path: "/dual".into(),
639 handler: "dual".into(),
640 job: Some(JobConfig::default()),
641 posture: None,
642 forward_auth: false,
643 ..Default::default()
644 });
645 m.subscriptions.push(SubscriptionDefinition {
646 subject: "evt.dual".into(),
647 handler: "dual".into(),
648 handler_type: HandlerType::Wasm,
649 config: HashMap::new(),
650 stream: None,
651 });
652
653 let route = m
654 .http_route("dual")
655 .expect("dual-purpose handler stays HTTP-invocable");
656 assert!(route.job.is_some());
657 }
658
659 #[test]
660 fn posture_aware_detects_any_declared_route() {
661 let mut m = sample_manifest();
662 assert!(!m.is_posture_aware());
663 m.custom_routes.push(CustomRouteDefinition {
664 method: "GET".into(),
665 path: "/a".into(),
666 handler: "a".into(),
667 job: None,
668 posture: None,
669 forward_auth: false,
670 ..Default::default()
671 });
672 assert!(!m.is_posture_aware());
673 m.custom_routes.push(CustomRouteDefinition {
674 method: "GET".into(),
675 path: "/b".into(),
676 handler: "b".into(),
677 job: None,
678 posture: Some(RoutePosture::Authenticated),
679 forward_auth: false,
680 ..Default::default()
681 });
682 assert!(m.is_posture_aware());
683 }
684
685 #[test]
686 fn manifest_publishes_and_features_default_absent() {
687 let json = serde_json::to_value(sample_manifest()).unwrap();
688 assert!(json.get("publishes").is_none());
689 assert!(json.get("required_platform_features").is_none());
690 let legacy = r#"{"name":"s","tables":[],"mode":"crud"}"#;
691 let parsed: ServiceManifest = serde_json::from_str(legacy).unwrap();
692 assert!(parsed.publishes.is_none());
693 assert!(parsed.required_platform_features.is_empty());
694 }
695}