use super::{
AuthorizationConfig, CustomRouteDefinition, HandlerType, SubscriptionDefinition,
TableDefinition,
};
use crate::features;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ServiceManifest {
pub name: String,
pub version: Option<String>,
pub tables: Vec<TableDefinition>,
#[serde(default)]
pub cells: Vec<CellDefinition>,
#[serde(default)]
pub events: Vec<EventDefinition>,
#[serde(default)]
pub subscriptions: Vec<SubscriptionDefinition>,
#[serde(default)]
pub custom_routes: Vec<CustomRouteDefinition>,
#[serde(default)]
pub mode: ServiceMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authorization: Option<AuthorizationConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_migrate: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publishes: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required_platform_features: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub graphql: bool,
#[serde(default, skip_serializing_if = "RoutingMode::is_legacy")]
pub routing: RoutingMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub errors: Option<ErrorProfile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openapi: Option<OpenApiOptions>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub components: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct OpenApiOptions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub servers: Vec<ServerDoc>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<TagDoc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub security_scheme: Option<String>,
}
impl OpenApiOptions {
pub const DEFAULT_SECURITY_SCHEME: &'static str = "bearer_auth";
pub fn security_scheme(&self) -> &str {
self.security_scheme
.as_deref()
.unwrap_or(Self::DEFAULT_SECURITY_SCHEME)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerDoc {
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TagDoc {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ErrorProfile {
pub key: String,
}
impl ErrorProfile {
pub fn is_valid_key(key: &str) -> bool {
let mut bytes = key.bytes();
key.len() <= 64
&& bytes
.next()
.is_some_and(|b| b.is_ascii_lowercase() || b == b'_')
&& bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RoutingMode {
#[default]
Legacy,
Strict,
}
impl RoutingMode {
pub fn is_legacy(&self) -> bool {
*self == RoutingMode::Legacy
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ServiceMode {
#[default]
Crud,
Wasm,
Container,
Web,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CellDefinition {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventDefinition {
pub name: String,
pub table: String,
pub action: EventAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EventAction {
Create,
Update,
Delete,
}
impl ServiceManifest {
pub fn get_table(&self, name: &str) -> Option<&TableDefinition> {
self.tables.iter().find(|t| t.name == name)
}
pub fn requires_wasm(&self) -> bool {
if self.mode == ServiceMode::Wasm {
return true;
}
if self.on_migrate.is_some() {
return true;
}
if self.tables.iter().any(|t| t.hooks.is_some()) {
return true;
}
if !self.custom_routes.is_empty() {
return true;
}
if self
.subscriptions
.iter()
.any(|s| matches!(s.handler_type, HandlerType::Wasm))
{
return true;
}
false
}
pub fn declared_wasm_handlers(&self) -> Vec<&str> {
let mut handlers: Vec<&str> = Vec::new();
if let Some(ref h) = self.on_migrate {
handlers.push(h.as_str());
}
for route in &self.custom_routes {
handlers.push(route.handler.as_str());
if let Some(reducer) = route.sse.as_ref().and_then(|sse| sse.reducer.as_deref()) {
handlers.push(reducer);
}
}
for sub in &self.subscriptions {
if matches!(sub.handler_type, HandlerType::Wasm) {
handlers.push(sub.handler.as_str());
}
}
for table in &self.tables {
if let Some(ref hooks) = table.hooks {
for h in [
hooks.before_create.as_deref(),
hooks.after_create.as_deref(),
hooks.before_update.as_deref(),
hooks.after_update.as_deref(),
hooks.before_delete.as_deref(),
hooks.after_delete.as_deref(),
]
.iter()
.flatten()
{
handlers.push(h);
}
}
}
handlers.sort();
handlers.dedup();
handlers
}
pub fn http_route(&self, handler: &str) -> Option<&CustomRouteDefinition> {
self.http_route_index(handler)
.and_then(|index| self.custom_routes.get(index))
}
pub fn http_route_index(&self, handler: &str) -> Option<usize> {
self.custom_routes.iter().position(|r| r.handler == handler)
}
pub fn is_posture_aware(&self) -> bool {
self.custom_routes.iter().any(|r| r.posture.is_some())
}
pub(crate) fn service_features(&self) -> impl Iterator<Item = &'static str> {
[
(self.routing == RoutingMode::Strict).then_some(features::ROUTE_MATCHING),
self.errors.is_some().then_some(features::ERROR_PROFILE),
]
.into_iter()
.flatten()
}
pub fn deploy_hash(&self) -> serde_json::Result<String> {
use sha2::{Digest, Sha256};
serde_json::to_vec(self).map(|bytes| hex::encode(Sha256::digest(bytes)))
}
pub fn schema_hash(&self) -> serde_json::Result<String> {
use sha2::{Digest, Sha256};
let digest = [
serde_json::to_string(&self.tables)?,
serde_json::to_string(&self.custom_routes)?,
serde_json::to_string(&self.subscriptions)?,
serde_json::to_string(&self.authorization)?,
serde_json::to_string(&self.on_migrate)?,
]
.iter()
.fold(Sha256::new(), |hasher, part| hasher.chain_update(part))
.finalize();
Ok(hex::encode(digest))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::fixtures::sample_manifest;
use crate::manifest::{
ColumnDefinition, ColumnType, ForeignKeyAction, JobConfig, RoutePosture, TableHooks,
};
use std::collections::HashMap;
#[test]
fn test_manifest_serialization_roundtrip() {
let manifest = sample_manifest();
let json = serde_json::to_string(&manifest).unwrap();
let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name, "test-service");
assert_eq!(parsed.tables.len(), 1);
assert_eq!(parsed.tables[0].columns.len(), 3);
}
#[test]
fn routing_is_serialized_only_when_strict() {
let mut manifest = sample_manifest();
let json = serde_json::to_value(&manifest).unwrap();
assert!(json.get("routing").is_none(), "{json}");
manifest.routing = RoutingMode::Strict;
let json = serde_json::to_value(&manifest).unwrap();
assert_eq!(json["routing"], "strict");
let parsed: ServiceManifest = serde_json::from_value(json).unwrap();
assert_eq!(parsed.routing, RoutingMode::Strict);
}
#[test]
fn error_profile_keys_are_snake_case_identifiers() {
for key in ["error_message", "_e", "e1", &"e".repeat(64)] {
assert!(ErrorProfile::is_valid_key(key), "{key}");
}
for key in [
"",
"1e",
"Error",
"error-message",
"error message",
&"e".repeat(65),
] {
assert!(!ErrorProfile::is_valid_key(key), "{key}");
}
let mut manifest = sample_manifest();
assert!(serde_json::to_value(&manifest)
.unwrap()
.get("errors")
.is_none());
manifest.errors = Some(ErrorProfile {
key: "error_message".into(),
});
assert_eq!(
serde_json::to_value(&manifest).unwrap()["errors"],
serde_json::json!({"key": "error_message"})
);
}
#[test]
fn test_get_table() {
let manifest = sample_manifest();
assert!(manifest.get_table("todos").is_some());
assert!(manifest.get_table("nonexistent").is_none());
}
#[test]
fn test_schema_hash_deterministic() {
let m1 = sample_manifest();
let m2 = sample_manifest();
assert_eq!(
m1.schema_hash().expect("schema hash"),
m2.schema_hash().expect("schema hash")
);
}
#[test]
fn test_schema_hash_changes() {
let mut m1 = sample_manifest();
let m2 = sample_manifest();
m1.tables[0].columns.push(ColumnDefinition {
name: "extra".into(),
column_type: ColumnType::Text,
primary_key: false,
nullable: true,
auto_generate: false,
default_value: None,
references: None,
on_delete: None,
unique: false,
validations: vec![],
});
assert_ne!(
m1.schema_hash().expect("schema hash"),
m2.schema_hash().expect("schema hash")
);
}
#[test]
fn test_fk_serialization() {
let manifest = sample_manifest();
let json = serde_json::to_string(&manifest).unwrap();
assert!(json.contains("\"references\":\"users.id\""));
assert!(json.contains("\"on_delete\":\"cascade\""));
let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
let user_id_col = &parsed.tables[0].columns[2];
assert_eq!(user_id_col.references.as_deref(), Some("users.id"));
assert_eq!(user_id_col.on_delete, Some(ForeignKeyAction::Cascade));
}
#[test]
fn test_service_mode_default() {
let json = r#"{"name":"svc","tables":[]}"#;
let m: ServiceManifest = serde_json::from_str(json).unwrap();
assert_eq!(m.mode, ServiceMode::Crud);
}
#[test]
fn test_on_migrate_defaults_to_none() {
let json = r#"{"name":"svc","tables":[]}"#;
let m: ServiceManifest = serde_json::from_str(json).unwrap();
assert!(m.on_migrate.is_none());
}
#[test]
fn test_on_migrate_serialization_roundtrip() {
let mut m = sample_manifest();
m.on_migrate = Some("handle_on_migrate".into());
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains("\"on_migrate\":\"handle_on_migrate\""));
let parsed: ServiceManifest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.on_migrate.as_deref(), Some("handle_on_migrate"));
}
#[test]
fn test_on_migrate_skipped_when_none() {
let m = sample_manifest();
let json = serde_json::to_string(&m).unwrap();
assert!(!json.contains("on_migrate"));
}
#[test]
fn deploy_hash_covers_fields_schema_hash_ignores() {
let m1 = sample_manifest();
let mut m2 = sample_manifest();
let hash = |m: &ServiceManifest| m.deploy_hash().expect("hash");
assert_eq!(hash(&m1), hash(&m2));
m2.publishes = Some(vec!["acme.orders.>".into()]);
assert_ne!(hash(&m1), hash(&m2));
assert_eq!(
m1.schema_hash().expect("schema hash"),
m2.schema_hash().expect("schema hash")
);
}
#[test]
fn test_on_migrate_changes_schema_hash() {
let m1 = sample_manifest();
let mut m2 = sample_manifest();
m2.on_migrate = Some("handle_on_migrate".into());
assert_ne!(
m1.schema_hash().expect("schema hash"),
m2.schema_hash().expect("schema hash")
);
}
#[test]
fn test_requires_wasm_false_for_plain_crud() {
let m = sample_manifest();
assert!(!m.requires_wasm());
}
#[test]
fn test_requires_wasm_true_for_wasm_mode() {
let mut m = sample_manifest();
m.mode = ServiceMode::Wasm;
assert!(m.requires_wasm());
}
#[test]
fn test_requires_wasm_true_for_on_migrate() {
let mut m = sample_manifest();
m.on_migrate = Some("handle_on_migrate".into());
assert!(m.requires_wasm());
}
#[test]
fn test_requires_wasm_true_for_table_hooks() {
let mut m = sample_manifest();
m.tables[0].hooks = Some(TableHooks {
before_create: Some("validate".into()),
..Default::default()
});
assert!(m.requires_wasm());
}
#[test]
fn test_requires_wasm_true_for_custom_routes() {
let mut m = sample_manifest();
m.custom_routes.push(CustomRouteDefinition {
method: "GET".into(),
path: "/hello".into(),
handler: "hello".into(),
job: None,
posture: None,
forward_auth: false,
..Default::default()
});
assert!(m.requires_wasm());
}
#[test]
fn test_requires_wasm_true_for_wasm_subscription() {
let mut m = sample_manifest();
m.subscriptions.push(SubscriptionDefinition {
subject: "events.foo".into(),
handler: "on_foo".into(),
handler_type: HandlerType::Wasm,
config: HashMap::new(),
stream: None,
});
assert!(m.requires_wasm());
}
#[test]
fn test_requires_wasm_false_for_non_wasm_subscription() {
let mut m = sample_manifest();
m.subscriptions.push(SubscriptionDefinition {
subject: "events.foo".into(),
handler: "on_foo".into(),
handler_type: HandlerType::Webhook,
config: HashMap::new(),
stream: None,
});
assert!(!m.requires_wasm());
}
#[test]
fn test_declared_wasm_handlers_collects_all_sources() {
let mut m = sample_manifest();
m.on_migrate = Some("on_mig".into());
m.custom_routes.push(CustomRouteDefinition {
method: "GET".into(),
path: "/h".into(),
handler: "route_h".into(),
job: None,
posture: None,
forward_auth: false,
..Default::default()
});
m.subscriptions.push(SubscriptionDefinition {
subject: "x".into(),
handler: "sub_h".into(),
handler_type: HandlerType::Wasm,
config: HashMap::new(),
stream: None,
});
m.subscriptions.push(SubscriptionDefinition {
subject: "y".into(),
handler: "wh_ignored".into(),
handler_type: HandlerType::Webhook,
config: HashMap::new(),
stream: None,
});
m.tables[0].hooks = Some(TableHooks {
before_create: Some("bc".into()),
after_create: Some("ac".into()),
before_delete: Some("bc".into()), ..Default::default()
});
let handlers = m.declared_wasm_handlers();
assert!(handlers.contains(&"on_mig"));
assert!(handlers.contains(&"route_h"));
assert!(handlers.contains(&"sub_h"));
assert!(handlers.contains(&"bc"));
assert!(handlers.contains(&"ac"));
assert!(!handlers.contains(&"wh_ignored"));
assert_eq!(handlers.iter().filter(|h| **h == "bc").count(), 1);
}
#[test]
fn http_route_exposes_only_custom_routes() {
let mut m = sample_manifest();
m.on_migrate = Some("on_mig".into());
m.custom_routes.push(CustomRouteDefinition {
method: "GET".into(),
path: "/h".into(),
handler: "route_h".into(),
job: None,
posture: None,
forward_auth: false,
..Default::default()
});
m.subscriptions.push(SubscriptionDefinition {
subject: "evt.sub".into(),
handler: "sub_h".into(),
handler_type: HandlerType::Wasm,
config: HashMap::new(),
stream: None,
});
m.tables[0].hooks = Some(TableHooks {
after_create: Some("hook_h".into()),
..Default::default()
});
assert_eq!(
m.http_route("route_h").map(|r| r.handler.as_str()),
Some("route_h")
);
assert!(m.http_route("sub_h").is_none());
assert!(m.http_route("on_mig").is_none());
assert!(m.http_route("hook_h").is_none());
assert!(m.http_route("unknown").is_none());
}
#[test]
fn http_route_allows_handler_declared_as_both_route_and_subscription() {
let mut m = sample_manifest();
m.custom_routes.push(CustomRouteDefinition {
method: "POST".into(),
path: "/dual".into(),
handler: "dual".into(),
job: Some(JobConfig::default()),
posture: None,
forward_auth: false,
..Default::default()
});
m.subscriptions.push(SubscriptionDefinition {
subject: "evt.dual".into(),
handler: "dual".into(),
handler_type: HandlerType::Wasm,
config: HashMap::new(),
stream: None,
});
let route = m
.http_route("dual")
.expect("dual-purpose handler stays HTTP-invocable");
assert!(route.job.is_some());
}
#[test]
fn posture_aware_detects_any_declared_route() {
let mut m = sample_manifest();
assert!(!m.is_posture_aware());
m.custom_routes.push(CustomRouteDefinition {
method: "GET".into(),
path: "/a".into(),
handler: "a".into(),
job: None,
posture: None,
forward_auth: false,
..Default::default()
});
assert!(!m.is_posture_aware());
m.custom_routes.push(CustomRouteDefinition {
method: "GET".into(),
path: "/b".into(),
handler: "b".into(),
job: None,
posture: Some(RoutePosture::Authenticated),
forward_auth: false,
..Default::default()
});
assert!(m.is_posture_aware());
}
#[test]
fn manifest_publishes_and_features_default_absent() {
let json = serde_json::to_value(sample_manifest()).unwrap();
assert!(json.get("publishes").is_none());
assert!(json.get("required_platform_features").is_none());
let legacy = r#"{"name":"s","tables":[],"mode":"crud"}"#;
let parsed: ServiceManifest = serde_json::from_str(legacy).unwrap();
assert!(parsed.publishes.is_none());
assert!(parsed.required_platform_features.is_empty());
}
}