use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Design {
pub name: String,
pub contract_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cors: Option<CorsDesign>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<Auth>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dependencies: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenancy: Option<Tenancy>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub jobs: Vec<JobDesign>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage: Option<StorageDesign>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub realtime: Option<RealtimeDesign>,
pub modules: Vec<ModuleDesign>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CorsDesign {
pub origins: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub methods: Vec<HttpMethod>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub headers: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub allow_credentials: bool,
}
impl CorsDesign {
pub fn is_any(&self) -> bool {
self.origins.iter().any(|o| o == "*")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RealtimeDesign {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub changes: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub broadcast: Vec<RealtimeTopic>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub presence: Vec<RealtimeTopic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RealtimeTopic {
pub name: String,
pub scope: RealtimeScope,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RealtimeScope {
None,
Tenant,
Auth,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Auth {
pub model: AuthModel,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub roles: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuthModel {
None,
Session,
Jwt,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ModuleDesign {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mount: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entities: Vec<Entity>,
pub endpoints: Vec<Endpoint>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub subroutes: Vec<ModuleDesign>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dependencies: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Entity {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub table: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub belongs_to: Vec<BelongsTo>,
pub fields: Vec<Field>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Field {
pub name: String,
#[serde(rename = "type")]
pub field_type: FieldType,
#[serde(default = "default_true")]
pub required: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub unique: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub index: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub values: Option<Vec<String>>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FieldType {
String,
Integer,
Float,
Boolean,
Datetime,
Uuid,
Json,
}
impl FieldType {
pub fn rust_type(self) -> &'static str {
match self {
FieldType::String | FieldType::Datetime | FieldType::Uuid => "String",
FieldType::Integer => "i64",
FieldType::Float => "f64",
FieldType::Boolean => "bool",
FieldType::Json => "serde_json::Value",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BelongsTo {
pub entity: String,
#[serde(default)]
pub on_delete: OnDelete,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OnDelete {
Cascade,
SetNull,
#[default]
Restrict,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Tenancy {
pub entity: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub member_roles: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct JobDesign {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schedule: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub queue: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StorageDesign {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_path: Option<String>,
pub buckets: Vec<BucketDesign>,
}
impl StorageDesign {
pub fn effective_base_path(&self) -> String {
self.base_path
.clone()
.unwrap_or_else(|| "/storage".to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BucketDesign {
pub name: String,
pub visibility: Visibility,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub owner_prefix: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_size: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_mime: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Visibility {
Public,
Private,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Endpoint {
pub operation_id: String,
pub method: HttpMethod,
pub path: String,
#[serde(default)]
pub auth_required: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required_roles: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub public: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_body: Option<RequestBody>,
#[serde(default, skip_serializing_if = "ProbePolicy::is_auto")]
pub probe: ProbePolicy,
pub success: Success,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<ErrorCase>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProbePolicy {
#[default]
Auto,
Skip,
}
impl ProbePolicy {
pub fn is_auto(&self) -> bool {
matches!(self, ProbePolicy::Auto)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum HttpMethod {
GET,
POST,
PUT,
PATCH,
DELETE,
}
impl HttpMethod {
pub fn builder_fn(self) -> &'static str {
match self {
HttpMethod::GET => "get",
HttpMethod::POST => "post",
HttpMethod::PUT => "put",
HttpMethod::PATCH => "patch",
HttpMethod::DELETE => "delete",
}
}
pub fn as_http_const(self) -> &'static str {
match self {
HttpMethod::GET => "GET",
HttpMethod::POST => "POST",
HttpMethod::PUT => "PUT",
HttpMethod::PATCH => "PATCH",
HttpMethod::DELETE => "DELETE",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RequestBody {
pub entity: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Success {
pub status: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entity: Option<String>,
#[serde(default)]
pub list: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ErrorCase {
pub status: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
pub when: String,
}
impl Endpoint {
pub fn is_guarded(&self) -> bool {
self.auth_required || !self.required_roles.is_empty()
}
pub fn declares_signature_auth(&self) -> bool {
self.errors
.iter()
.any(|e| (400..500).contains(&e.status) && e.when.to_lowercase().contains("signature"))
}
}
impl ModuleDesign {
pub fn effective_mount(&self) -> String {
self.mount
.clone()
.unwrap_or_else(|| format!("/{}", self.name))
}
}
impl Design {
pub fn base_prefix(&self) -> &str {
match self.base_path.as_deref() {
None | Some("") | Some("/") => "",
Some(p) => p,
}
}
pub fn wants_db(&self) -> bool {
self.dependencies.iter().any(|d| d == "db")
}
pub fn wants_validate(&self) -> bool {
self.dependencies.iter().any(|d| d == "validate")
}
pub fn wants_auth(&self) -> bool {
self.auth
.as_ref()
.map(|a| a.model != AuthModel::None)
.unwrap_or(false)
|| self.dependencies.iter().any(|d| d == "auth")
}
pub fn wants_observe(&self) -> bool {
self.dependencies.iter().any(|d| d == "observe")
}
pub fn wants_jobs(&self) -> bool {
!self.jobs.is_empty()
}
pub fn wants_storage(&self) -> bool {
self.storage.as_ref().is_some_and(|s| !s.buckets.is_empty())
}
pub fn wants_realtime(&self) -> bool {
self.realtime.as_ref().is_some_and(|r| {
!r.changes.is_empty() || !r.broadcast.is_empty() || !r.presence.is_empty()
})
}
pub fn parse_size(s: &str) -> Option<u64> {
let (num, mult) = if let Some(n) = s.strip_suffix("GB") {
(n, 1024 * 1024 * 1024)
} else if let Some(n) = s.strip_suffix("MB") {
(n, 1024 * 1024)
} else if let Some(n) = s.strip_suffix("KB") {
(n, 1024)
} else if let Some(n) = s.strip_suffix('B') {
(n, 1)
} else {
(s, 1)
};
num.parse::<u64>().ok().and_then(|n| n.checked_mul(mult))
}
pub fn wants_oauth(&self) -> bool {
self.dependencies.iter().any(|d| d == "oauth")
}
pub fn facade_features(&self) -> Vec<&'static str> {
let mut features = Vec::new();
if self.wants_db() {
features.push("db");
}
if self.wants_validate() {
features.push("validate");
}
if self.wants_auth() {
features.push("auth");
}
if self.wants_observe() {
features.push("observe");
}
if self.wants_jobs() {
features.push("jobs");
}
if self.wants_oauth() {
features.push("oauth");
}
if self.wants_storage() {
features.push("storage-s3");
}
if self.wants_realtime() {
features.push("realtime");
}
features
}
pub fn from_path(path: &std::path::Path) -> Result<Self, String> {
let raw = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
serde_json::from_str(&raw).map_err(|e| format!("invalid design.json: {e}"))
}
pub fn tenant_owned(&self) -> Vec<(&str, &str)> {
let Some(tenancy) = self.tenancy.as_ref() else {
return Vec::new();
};
let mut owned = Vec::new();
for module in &self.modules {
collect_tenant_owned(module, &tenancy.entity, &mut owned);
}
owned
}
pub fn fk_column(target: &str) -> String {
format!("{}_id", Self::to_snake(target))
}
pub fn to_snake(name: &str) -> String {
let mut snake = String::with_capacity(name.len() + 2);
for (i, ch) in name.char_indices() {
if i > 0 && ch.is_ascii_uppercase() {
snake.push('_');
}
snake.push(ch.to_ascii_lowercase());
}
snake
}
pub fn target_key_rust_type(&self, target: &str) -> &'static str {
self.find_entity(target)
.and_then(|e| e.fields.iter().find(|f| f.name == "id"))
.map(|f| f.field_type.rust_type())
.unwrap_or("i64")
}
pub fn find_entity(&self, name: &str) -> Option<&Entity> {
fn find<'a>(m: &'a ModuleDesign, name: &str) -> Option<&'a Entity> {
m.entities
.iter()
.find(|e| e.name == name)
.or_else(|| m.subroutes.iter().find_map(|s| find(s, name)))
}
self.modules.iter().find_map(|m| find(m, name))
}
pub fn table_name(&self, entity: &str) -> String {
self.find_entity(entity)
.and_then(|e| e.table.clone())
.unwrap_or_else(|| Self::default_table_name(entity))
}
pub fn default_table_name(entity: &str) -> String {
pluralize(&Self::to_snake(entity))
}
}
fn pluralize(word: &str) -> String {
if word.ends_with("ch")
|| word.ends_with("sh")
|| word.ends_with('s')
|| word.ends_with('x')
|| word.ends_with('z')
{
return format!("{word}es");
}
if let Some(stem) = word.strip_suffix('y')
&& !stem.ends_with(['a', 'e', 'i', 'o', 'u'])
{
return format!("{stem}ies");
}
format!("{word}s")
}
const RUST_KEYWORDS: &[&str] = &[
"as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern",
"false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub",
"ref", "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
"where", "while",
];
const UNESCAPABLE_KEYWORDS: &[&str] = &["crate", "self", "Self", "super"];
pub(crate) fn is_rust_keyword(name: &str) -> bool {
RUST_KEYWORDS.contains(&name)
}
pub(crate) fn can_be_rust_ident(name: &str) -> bool {
!UNESCAPABLE_KEYWORDS.contains(&name)
}
pub(crate) fn rust_ident(name: &str) -> String {
if is_rust_keyword(name) {
format!("r#{name}")
} else {
name.to_string()
}
}
fn collect_tenant_owned<'a>(
module: &'a ModuleDesign,
tenant: &str,
out: &mut Vec<(&'a str, &'a str)>,
) {
for entity in &module.entities {
if entity.belongs_to.iter().any(|b| b.entity == tenant) {
out.push((module.name.as_str(), entity.name.as_str()));
}
}
for subroute in &module.subroutes {
collect_tenant_owned(subroute, tenant, out);
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
pub(crate) const MINIMAL: &str = r#"{
"name": "demo-api",
"contract_version": 0,
"auth": { "model": "session", "roles": ["admin"] },
"dependencies": ["db"],
"modules": [{
"name": "todos",
"entities": [{ "name": "Todo", "fields": [
{ "name": "title", "type": "string" },
{ "name": "done", "type": "boolean", "required": false }
]}],
"endpoints": [
{ "operation_id": "list_todos", "method": "GET", "path": "/",
"success": { "status": 200, "entity": "Todo", "list": true } },
{ "operation_id": "create_todo", "method": "POST", "path": "/",
"request_body": { "entity": "Todo" },
"success": { "status": 201, "entity": "Todo" } },
{ "operation_id": "delete_todo", "method": "DELETE", "path": "/{id}",
"required_roles": ["admin"],
"success": { "status": 204 },
"errors": [{ "status": 404, "code": "JC0404", "when": "unknown id" }] }
],
"subroutes": [{
"name": "comments",
"endpoints": [{ "operation_id": "list_comments", "method": "GET", "path": "/",
"success": { "status": 200 } }]
}]
}]
}"#;
pub(crate) const V1_FULL: &str = r#"{
"name": "reference-mini", "contract_version": 1,
"auth": { "model": "jwt", "roles": ["owner", "member"] },
"dependencies": ["db", "auth"],
"tenancy": { "entity": "Workspace", "member_roles": ["owner", "member"] },
"jobs": [{ "name": "expire_trials", "schedule": "0 * * * *" }],
"modules": [
{ "name": "workspaces",
"entities": [{ "name": "Workspace", "fields": [
{ "name": "id", "type": "integer" },
{ "name": "plan", "type": "string", "values": ["trial", "pro"] }
]}],
"endpoints": [{ "operation_id": "list_workspaces", "method": "GET",
"path": "/", "success": { "status": 200, "entity": "Workspace", "list": true } }] },
{ "name": "leads",
"entities": [{ "name": "Lead",
"belongs_to": [{ "entity": "Workspace", "on_delete": "cascade" }],
"fields": [
{ "name": "id", "type": "integer" },
{ "name": "phone", "type": "string", "unique": true, "index": true },
{ "name": "custom", "type": "json", "required": false }
]}],
"endpoints": [{ "operation_id": "list_leads", "method": "GET",
"path": "/", "success": { "status": 200, "entity": "Lead", "list": true } }] }
]
}"#;
pub(crate) const V2_STORAGE: &str = r#"{
"name": "files-app", "contract_version": 2,
"auth": { "model": "session", "roles": ["owner", "member"] },
"dependencies": ["db", "auth"],
"tenancy": { "entity": "Org", "member_roles": ["owner", "member"] },
"storage": { "buckets": [
{ "name": "avatars", "visibility": "public", "owner": "User",
"max_size": "5MB", "allowed_mime": ["image/*"] },
{ "name": "invoices", "visibility": "private", "owner": "Org",
"owner_prefix": true, "max_size": "20MB" }
]},
"modules": [
{ "name": "orgs",
"entities": [
{ "name": "Org", "fields": [
{ "name": "id", "type": "integer" },
{ "name": "plan", "type": "string" } ] },
{ "name": "User", "fields": [
{ "name": "id", "type": "integer" },
{ "name": "email", "type": "string" } ] }
],
"endpoints": [{ "operation_id": "list_orgs", "method": "GET", "path": "/",
"success": { "status": 200, "entity": "Org", "list": true } }] }
]
}"#;
pub(crate) const V2_REALTIME: &str = r#"{
"name": "rt-app", "contract_version": 2,
"auth": { "model": "jwt", "roles": ["owner", "member"] },
"dependencies": ["db", "auth"],
"tenancy": { "entity": "Workspace", "member_roles": ["owner", "member"] },
"realtime": {
"changes": ["Lead"],
"broadcast": [{ "name": "deal_room", "scope": "tenant" }],
"presence": [{ "name": "editors", "scope": "tenant" }]
},
"modules": [
{ "name": "workspaces",
"entities": [{ "name": "Workspace", "fields": [
{ "name": "id", "type": "integer" }, { "name": "name", "type": "string" } ]}],
"endpoints": [{ "operation_id": "list_workspaces", "method": "GET", "path": "/",
"success": { "status": 200, "entity": "Workspace", "list": true } }] },
{ "name": "leads",
"entities": [{ "name": "Lead",
"belongs_to": [{ "entity": "Workspace", "on_delete": "cascade" }],
"fields": [{ "name": "id", "type": "integer" },
{ "name": "phone", "type": "string" }] }],
"endpoints": [{ "operation_id": "list_leads", "method": "GET", "path": "/",
"success": { "status": 200, "entity": "Lead", "list": true } }] }
]
}"#;
#[test]
fn realtime_block_round_trips_and_gates_the_facade_feature() {
let d: Design = serde_json::from_str(V2_REALTIME).unwrap();
assert!(d.wants_realtime());
let rt = d.realtime.as_ref().unwrap();
assert_eq!(rt.changes, vec!["Lead"]);
assert_eq!(rt.broadcast[0].name, "deal_room");
assert_eq!(rt.broadcast[0].scope, RealtimeScope::Tenant);
let feats = d.facade_features();
assert!(feats.contains(&"realtime"), "{feats:?}");
assert_eq!(
feats.last(),
Some(&"realtime"),
"realtime is appended last (after storage): {feats:?}"
);
let back = serde_json::to_string(&d).unwrap();
let re: Design = serde_json::from_str(&back).unwrap();
assert!(re.wants_realtime());
let plain: Design = serde_json::from_str(MINIMAL).unwrap();
assert!(!plain.wants_realtime());
assert!(!plain.facade_features().contains(&"realtime"));
}
#[test]
fn published_schema_accepts_the_realtime_block() {
let s = include_str!("../../../../docs/contracts/design-schema.json");
assert!(
s.contains("\"realtime\"") && s.contains("\"broadcast\"") && s.contains("\"presence\"")
);
}
#[test]
fn cors_block_round_trips_and_maps_onto_core_config() {
let d: Design = serde_json::from_str(
r#"{ "name": "api", "contract_version": 0, "dependencies": [],
"cors": {
"origins": ["https://app.example", "https://admin.example"],
"methods": ["GET", "POST", "PUT", "PATCH", "DELETE"],
"headers": ["content-type", "authorization"],
"allow_credentials": true
},
"modules": [{ "name": "m", "endpoints": [
{ "operation_id": "list_m", "method": "GET", "path": "/",
"success": { "status": 200 } }] }] }"#,
)
.unwrap();
let cors = d.cors.as_ref().expect("cors block parses");
assert_eq!(
cors.origins,
["https://app.example", "https://admin.example"]
);
assert_eq!(
cors.methods,
[
HttpMethod::GET,
HttpMethod::POST,
HttpMethod::PUT,
HttpMethod::PATCH,
HttpMethod::DELETE
]
);
assert_eq!(cors.headers, ["content-type", "authorization"]);
assert!(cors.allow_credentials);
assert!(!cors.is_any(), "an explicit allowlist is not `any`");
let back = serde_json::to_string(&d).unwrap();
let re: Design = serde_json::from_str(&back).unwrap();
assert_eq!(re.cors.as_ref().unwrap().origins, cors.origins);
let any: Design = serde_json::from_str(
r#"{ "name": "api", "contract_version": 0, "dependencies": [],
"cors": { "origins": ["*"] },
"modules": [{ "name": "m", "endpoints": [
{ "operation_id": "list_m", "method": "GET", "path": "/",
"success": { "status": 200 } }] }] }"#,
)
.unwrap();
assert!(any.cors.as_ref().unwrap().is_any());
let val = serde_json::to_value(any.cors.as_ref().unwrap()).unwrap();
assert!(val.get("methods").is_none() && val.get("headers").is_none());
assert!(
val.get("allow_credentials").is_none(),
"false allow_credentials is not serialized: {val}"
);
let plain: Design = serde_json::from_str(MINIMAL).unwrap();
assert!(plain.cors.is_none());
assert!(
!plain.facade_features().iter().any(|f| f == &"cors"),
"cors is unconditional in core — it adds no facade feature"
);
}
#[test]
fn published_schema_accepts_the_cors_block() {
let s = include_str!("../../../../docs/contracts/design-schema.json");
assert!(
s.contains("\"cors\"")
&& s.contains("\"origins\"")
&& s.contains("\"allow_credentials\"")
);
}
#[test]
fn v2_storage_block_round_trips_and_gates_wants_storage() {
let d: Design = serde_json::from_str(V2_STORAGE).unwrap();
assert_eq!(d.contract_version, 2);
let s = d.storage.as_ref().unwrap();
assert_eq!(s.buckets.len(), 2);
assert_eq!(s.buckets[0].name, "avatars");
assert_eq!(s.buckets[0].visibility, Visibility::Public);
assert_eq!(s.buckets[0].owner.as_deref(), Some("User"));
assert!(!s.buckets[0].owner_prefix, "owner_prefix defaults false");
assert!(s.buckets[1].owner_prefix);
assert!(d.wants_storage());
let back = serde_json::to_string(&d).unwrap();
let re: Design = serde_json::from_str(&back).unwrap();
assert!(re.wants_storage(), "storage survives a round trip");
let v0: Design = serde_json::from_str(MINIMAL).unwrap();
assert!(v0.storage.is_none() && !v0.wants_storage());
}
#[test]
fn storage_base_path_defaults_to_storage_and_round_trips_an_override() {
let d: Design = serde_json::from_str(V2_STORAGE).unwrap();
assert_eq!(
d.storage.as_ref().unwrap().effective_base_path(),
"/storage",
"absent base_path defaults to /storage"
);
let back = serde_json::to_value(d.storage.as_ref().unwrap()).unwrap();
assert!(
back.get("base_path").is_none(),
"absent base_path is not serialized: {back}"
);
let mut d2 = d;
d2.storage.as_mut().unwrap().base_path = Some("/files".into());
assert_eq!(d2.storage.as_ref().unwrap().effective_base_path(), "/files");
let s = serde_json::to_string(&d2).unwrap();
let re: Design = serde_json::from_str(&s).unwrap();
assert_eq!(
re.storage.as_ref().unwrap().base_path.as_deref(),
Some("/files")
);
}
#[test]
fn wants_storage_appends_the_storage_s3_facade_feature_last() {
let d: Design = serde_json::from_str(V2_STORAGE).unwrap();
let feats = d.facade_features();
assert_eq!(
feats.last(),
Some(&"storage-s3"),
"storage-s3 appended last: {feats:?}"
);
assert!(feats.contains(&"db") && feats.contains(&"auth"));
let no: Design = serde_json::from_str(V1_FULL).unwrap();
assert!(!no.facade_features().contains(&"storage-s3"));
}
#[test]
fn parse_size_handles_the_documented_suffixes() {
assert_eq!(Design::parse_size("5MB"), Some(5 * 1024 * 1024));
assert_eq!(Design::parse_size("20MB"), Some(20 * 1024 * 1024));
assert_eq!(Design::parse_size("512KB"), Some(512 * 1024));
assert_eq!(Design::parse_size("1GB"), Some(1024 * 1024 * 1024));
assert_eq!(Design::parse_size("123B"), Some(123));
assert_eq!(Design::parse_size("123"), Some(123), "bare number = bytes");
assert_eq!(
Design::parse_size("5mb"),
None,
"suffixes are uppercase (schema-validated)"
);
assert_eq!(Design::parse_size("lots"), None);
}
#[test]
fn parse_size_refuses_overflow_instead_of_panicking_or_wrapping() {
assert_eq!(Design::parse_size("99999999999999GB"), None, "overflow");
assert_eq!(Design::parse_size("18446744073709551615B"), Some(u64::MAX));
assert_eq!(Design::parse_size("18446744073709551616B"), None);
}
#[test]
fn v1_design_round_trips_with_new_constructs() {
let d: Design = serde_json::from_str(V1_FULL).unwrap();
assert_eq!(d.contract_version, 1);
assert_eq!(d.tenancy.as_ref().unwrap().entity, "Workspace");
assert_eq!(d.jobs[0].name, "expire_trials");
let lead = &d.modules[1].entities[0];
assert_eq!(lead.belongs_to[0].entity, "Workspace");
assert_eq!(lead.belongs_to[0].on_delete, OnDelete::Cascade);
assert!(lead.fields[1].unique && lead.fields[1].index);
assert_eq!(
d.modules[0].entities[0].fields[1]
.values
.as_ref()
.unwrap()
.len(),
2
);
let back = serde_json::to_string(&d).unwrap();
let _re: Design = serde_json::from_str(&back).unwrap();
}
#[test]
fn wants_jobs_gates_on_declared_jobs_and_adds_the_facade_feature() {
let with_jobs: Design = serde_json::from_str(V1_FULL).unwrap();
assert!(with_jobs.wants_jobs(), "a declared job must set wants_jobs");
assert!(
with_jobs.facade_features().contains(&"jobs"),
"wants_jobs must surface the `jobs` facade feature so the app enables it: {:?}",
with_jobs.facade_features()
);
let no_jobs: Design = serde_json::from_str(MINIMAL).unwrap();
assert!(!no_jobs.wants_jobs());
assert!(!no_jobs.facade_features().contains(&"jobs"));
}
#[test]
fn wants_oauth_gates_on_the_dependency_and_appends_the_facade_feature() {
let s = r#"{ "name": "x", "contract_version": 1,
"dependencies": ["db", "auth", "oauth"],
"modules": [{ "name": "m", "endpoints": [
{ "operation_id": "go", "method": "GET", "path": "/go",
"success": { "status": 302 } }] }] }"#;
let d: Design = serde_json::from_str(s).unwrap();
assert!(
d.wants_oauth(),
"the `oauth` dependency must set wants_oauth"
);
let feats = d.facade_features();
assert!(
feats.contains(&"oauth"),
"wants_oauth must surface the `oauth` facade feature: {feats:?}"
);
assert_eq!(
feats.last(),
Some(&"oauth"),
"oauth is appended last: {feats:?}"
);
let no_oauth: Design = serde_json::from_str(MINIMAL).unwrap();
assert!(!no_oauth.wants_oauth());
assert!(!no_oauth.facade_features().contains(&"oauth"));
}
#[test]
fn v0_designs_still_parse_unchanged() {
let d: Design = serde_json::from_str(MINIMAL).unwrap();
assert_eq!(d.contract_version, 0);
assert!(d.tenancy.is_none() && d.jobs.is_empty());
assert!(d.modules[0].entities[0].belongs_to.is_empty());
}
#[test]
fn tenant_owned_walks_modules_and_subroutes() {
let mut d: Design = serde_json::from_str(V1_FULL).unwrap();
let sub: ModuleDesign = serde_json::from_str(
r#"{
"name": "notes",
"entities": [{ "name": "Note",
"belongs_to": [{ "entity": "Workspace" }],
"fields": [{ "name": "body", "type": "string" }] }],
"endpoints": [{ "operation_id": "list_notes", "method": "GET", "path": "/",
"success": { "status": 200 } }]
}"#,
)
.unwrap();
d.modules[1].subroutes.push(sub);
assert_eq!(d.tenant_owned(), vec![("leads", "Lead"), ("notes", "Note")]);
}
#[test]
fn fk_column_is_snake_target_id() {
assert_eq!(Design::fk_column("Workspace"), "workspace_id");
assert_eq!(Design::fk_column("ApiKey"), "api_key_id");
assert_eq!(Design::to_snake("ApiKey"), "api_key");
assert_eq!(Design::to_snake("Lead"), "lead");
}
#[test]
fn table_name_snake_cases_and_pluralizes_by_default() {
let d: Design = serde_json::from_str(MINIMAL).unwrap();
assert_eq!(d.table_name("EnergySummary"), "energy_summaries");
assert_eq!(d.table_name("CaptureSession"), "capture_sessions");
assert_eq!(d.table_name("MediaItem"), "media_items");
assert_eq!(d.table_name("ApiKey"), "api_keys");
assert_eq!(d.table_name("Todo"), "todos");
assert_eq!(d.table_name("Class"), "classes");
assert_eq!(d.table_name("Box"), "boxes");
assert_eq!(d.table_name("Dish"), "dishes");
assert_eq!(d.table_name("Batch"), "batches");
assert_eq!(d.table_name("Gateway"), "gateways", "vowel+y → +s");
assert_eq!(d.table_name("Company"), "companies", "consonant+y → ies");
}
#[test]
fn table_override_is_used_verbatim() {
let d: Design = serde_json::from_str(
r#"{ "name": "x", "contract_version": 1, "dependencies": ["db"],
"modules": [{ "name": "m",
"entities": [{ "name": "EnergySummary", "table": "legacy_energy",
"fields": [{ "name": "kwh", "type": "float" }] }],
"endpoints": [{ "operation_id": "list_it", "method": "GET", "path": "/",
"success": { "status": 200 } }] }] }"#,
)
.unwrap();
assert_eq!(d.table_name("EnergySummary"), "legacy_energy");
assert_eq!(d.table_name("MediaItem"), "media_items");
}
#[test]
fn target_key_rust_type_resolves_pk_across_the_tree() {
let d: Design = serde_json::from_str(V1_FULL).unwrap();
assert_eq!(d.target_key_rust_type("Workspace"), "i64");
assert_eq!(d.target_key_rust_type("Nonexistent"), "i64");
}
#[test]
fn minimal_design_round_trips() {
let d: Design = serde_json::from_str(MINIMAL).unwrap();
assert_eq!(d.name, "demo-api");
assert_eq!(d.modules[0].endpoints.len(), 3);
assert_eq!(d.modules[0].subroutes[0].name, "comments");
assert!(d.modules[0].entities[0].fields[0].required); assert!(!d.modules[0].entities[0].fields[1].required);
let back = serde_json::to_string(&d).unwrap();
let _re: Design = serde_json::from_str(&back).unwrap(); }
#[test]
fn unknown_fields_are_rejected_like_additional_properties_false() {
let bad = MINIMAL.replacen(
"\"name\": \"demo-api\",",
"\"name\": \"demo-api\", \"surprise\": 1,",
1,
);
assert!(serde_json::from_str::<Design>(&bad).is_err());
}
#[test]
fn method_enum_rejects_options() {
let bad = MINIMAL.replace("\"GET\"", "\"OPTIONS\"");
assert!(serde_json::from_str::<Design>(&bad).is_err());
}
#[test]
fn public_endpoint_flag_round_trips_defaults_false_and_skips_when_false() {
let pub_ep: Endpoint = serde_json::from_str(
r#"{ "operation_id": "register", "method": "POST", "path": "/register",
"public": true, "success": { "status": 201 } }"#,
)
.unwrap();
assert!(pub_ep.public, "public: true must deserialize");
let back = serde_json::to_value(&pub_ep).unwrap();
assert_eq!(back["public"], serde_json::json!(true), "round trips");
let plain: Endpoint = serde_json::from_str(
r#"{ "operation_id": "list", "method": "GET", "path": "/",
"success": { "status": 200 } }"#,
)
.unwrap();
assert!(!plain.public, "absent public defaults to false");
let back = serde_json::to_value(&plain).unwrap();
assert!(
back.get("public").is_none(),
"public: false must be skipped on serialize: {back}"
);
}
#[test]
fn probe_policy_defaults_to_auto_and_round_trips_skip() {
let plain: Endpoint = serde_json::from_str(
r#"{ "operation_id": "list", "method": "GET", "path": "/",
"success": { "status": 200 } }"#,
)
.unwrap();
assert_eq!(plain.probe, ProbePolicy::Auto);
assert!(plain.probe.is_auto());
let back = serde_json::to_value(&plain).unwrap();
assert!(
back.get("probe").is_none(),
"auto is not serialized: {back}"
);
let skip: Endpoint = serde_json::from_str(
r#"{ "operation_id": "login", "method": "POST", "path": "/login",
"public": true, "probe": "skip", "success": { "status": 200 } }"#,
)
.unwrap();
assert_eq!(skip.probe, ProbePolicy::Skip);
assert_eq!(
serde_json::to_value(&skip).unwrap()["probe"],
serde_json::json!("skip")
);
}
#[test]
fn published_schema_accepts_v1_constructs() {
let s = include_str!("../../../../docs/contracts/design-schema.json");
let v: serde_json::Value = serde_json::from_str(s).unwrap();
assert_eq!(
v["properties"]["contract_version"]["enum"],
serde_json::json!([0, 1, 2])
);
assert!(
s.contains("\"belongs_to\"")
&& s.contains("\"tenancy\"")
&& s.contains("\"jobs\"")
&& s.contains("\"on_delete\"")
&& s.contains("\"unique\"")
&& s.contains("\"values\"")
);
assert!(
s.contains("\"storage\"")
&& s.contains("\"buckets\"")
&& s.contains("\"owner_prefix\"")
);
}
}