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>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub public_read: bool,
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>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<serde_json::Value>,
}
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, PartialEq, Eq)]
pub(crate) struct JoinLink {
pub child_table: String,
pub child_fk: String,
pub parent_table: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TenantPath {
pub joins: Vec<JoinLink>,
pub anchor_table: String,
pub tenant_fk: String,
pub entity_table: String,
}
impl TenantPath {
pub(crate) fn join_sql(&self) -> String {
self.joins
.iter()
.map(|j| {
format!(
" JOIN {p} ON {c}.{fk} = {p}.id",
p = j.parent_table,
c = j.child_table,
fk = j.child_fk,
)
})
.collect()
}
pub(crate) fn tenant_col(&self) -> String {
format!("{}.{}", self.anchor_table, self.tenant_fk)
}
}
#[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",
}
}
pub fn is_update(self) -> bool {
matches!(self, HttpMethod::PUT | HttpMethod::PATCH)
}
}
#[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))
}
}
pub(crate) const AUTH_IDENTITY_FK_COLUMN: &str = "user_id";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TenantShape {
PathScoped { fk_param: String },
MembershipSet,
Collection,
None,
}
pub struct HandlerRef {
pub rel_path: String,
pub is_flat: bool,
pub owned_desc: &'static str,
pub leak_desc: &'static str,
pub suggestion: String,
}
pub(crate) const TENANT_SCOPED_SUGGESTION: &str = "call a scoped accessor instead — path-scoped routes: all_for/get_for/remove_for with the tenant id; flat (membership-set) routes: all_for_memberships/get_for_memberships/update_for_memberships/remove_for_memberships (and create_for_memberships) with the session user's id (_user.0.id)";
impl Design {
pub fn tenant_owned_handlers(&self) -> Vec<HandlerRef> {
let mut out = Vec::new();
if self.tenancy.is_none() {
return out;
}
for m in &self.modules {
self.collect_owned_handlers(&format!("crates/routes/{}/src", m.name), m, &mut out);
}
out
}
fn collect_owned_handlers(&self, src_rel: &str, m: &ModuleDesign, out: &mut Vec<HandlerRef>) {
let owned: Vec<&Entity> = m
.entities
.iter()
.filter(|e| self.tenant_path(&e.name).is_some())
.collect();
if !owned.is_empty() {
let is_flat = owned
.iter()
.any(|e| super::genroute::entity_is_flat_tenant_owned(e, self));
out.push(HandlerRef {
rel_path: format!("{src_rel}/handlers.rs"),
is_flat,
owned_desc: "a tenant-owned",
leak_desc: "another tenant's rows",
suggestion: TENANT_SCOPED_SUGGESTION.to_string(),
});
}
for sub in &m.subroutes {
self.collect_owned_handlers(
&format!("{src_rel}/subroutes/{}", sub.name.replace('-', "_")),
sub,
out,
);
}
}
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 auth_model(&self) -> AuthModel {
self.auth
.as_ref()
.map(|a| a.model)
.unwrap_or(AuthModel::None)
}
pub(crate) fn test_auth_header(&self) -> &'static str {
match self.auth_model() {
AuthModel::Jwt => "authorization",
_ => "cookie",
}
}
pub(crate) fn test_credential_role(&self) -> &str {
fn first_gate(m: &ModuleDesign) -> Option<&str> {
m.endpoints
.iter()
.find_map(|ep| ep.required_roles.first())
.map(String::as_str)
.or_else(|| m.subroutes.iter().find_map(first_gate))
}
self.modules
.iter()
.find_map(first_gate)
.or_else(|| {
self.auth
.as_ref()
.and_then(|a| a.roles.first())
.map(String::as_str)
})
.unwrap_or("admin")
}
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 server_publishable_broadcast(&self) -> Option<&str> {
self.realtime
.as_ref()?
.broadcast
.iter()
.find(|t| matches!(t.scope, RealtimeScope::None | RealtimeScope::Auth))
.map(|t| t.name.as_str())
}
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()))?;
let mut design: Self =
serde_json::from_str(&raw).map_err(|e| format!("invalid design.json: {e}"))?;
design.normalize_tenant_detail_routes();
Ok(design)
}
pub(crate) fn normalize_tenant_detail_routes(&mut self) {
let Some(tenancy) = self.tenancy.as_ref() else {
return;
};
let entity = tenancy.entity.clone();
let fk_token = format!("{{{}}}", Self::fk_column(&entity));
for m in &mut self.modules {
Self::normalize_own_detail_routes(m, &entity, &fk_token);
}
}
fn normalize_own_detail_routes(m: &mut ModuleDesign, entity: &str, fk_token: &str) {
if m.entities.iter().any(|e| e.name == entity) {
for ep in &mut m.endpoints {
if ep.path.contains("{id}") {
ep.path = ep.path.replace("{id}", fk_token);
}
}
}
for sub in &mut m.subroutes {
Self::normalize_own_detail_routes(sub, entity, fk_token);
}
}
pub(crate) fn tenant_path(&self, entity: &str) -> Option<TenantPath> {
let tenancy = self.tenancy.as_ref()?;
if entity == tenancy.entity {
return None; }
let mut chains = self.tenant_path_chains(
entity,
&tenancy.entity,
&mut std::collections::BTreeSet::new(),
);
if chains.len() != 1 {
return None;
}
let joins = chains.pop().expect("exactly one chain");
Some(TenantPath {
anchor_table: joins
.last()
.map(|j| j.parent_table.clone())
.unwrap_or_else(|| self.table_name(entity)),
tenant_fk: Self::fk_column(&tenancy.entity),
entity_table: self.table_name(entity),
joins,
})
}
fn tenant_path_chains(
&self,
entity: &str,
tenant: &str,
visited: &mut std::collections::BTreeSet<String>,
) -> Vec<Vec<JoinLink>> {
let Some(e) = self.find_entity(entity) else {
return Vec::new();
};
if e.belongs_to.iter().any(|b| b.entity == tenant) {
return vec![Vec::new()]; }
if !visited.insert(entity.to_string()) {
return Vec::new(); }
let mut found = Vec::new();
for b in &e.belongs_to {
for rest in self.tenant_path_chains(&b.entity, tenant, visited) {
let mut chain = vec![JoinLink {
child_table: self.table_name(entity),
child_fk: Self::fk_column(&b.entity),
parent_table: self.table_name(&b.entity),
}];
chain.extend(rest);
found.push(chain);
}
}
visited.remove(entity);
found
}
pub(crate) fn tenant_path_branch_count(&self, entity: &str) -> usize {
let Some(t) = self.tenancy.as_ref() else {
return 0;
};
if entity == t.entity {
return 0;
}
self.tenant_path_chains(entity, &t.entity, &mut std::collections::BTreeSet::new())
.len()
}
pub fn tenant_owned(&self) -> Vec<(&str, &str)> {
if self.tenancy.is_none() {
return Vec::new();
}
let mut owned = Vec::new();
for module in &self.modules {
collect_tenant_owned(self, module, &mut owned);
}
owned
}
pub fn fk_column(target: &str) -> String {
format!("{}_id", Self::to_snake(target))
}
pub(crate) fn endpoint_tenant_shape(
&self,
module: &ModuleDesign,
ep: &Endpoint,
) -> TenantShape {
let Some(tenancy) = self.tenancy.as_ref() else {
return TenantShape::None;
};
let is_tenant_module = module.entities.iter().any(|e| e.name == tenancy.entity);
let owns_tenant_entity = module
.entities
.iter()
.any(|e| self.tenant_path(&e.name).is_some());
if !is_tenant_module && !owns_tenant_entity {
return TenantShape::None;
}
let fk_param = Self::fk_column(&tenancy.entity);
let mount = module.effective_mount();
let mount = mount.strip_suffix('/').unwrap_or(&mount);
let resolved = format!("{mount}{}", ep.path);
let fk_token = format!("{{{fk_param}}}");
if is_tenant_module {
if ep.path == "/" && matches!(ep.method, HttpMethod::POST | HttpMethod::GET) {
return TenantShape::Collection;
}
if resolved.contains(&fk_token) || ep.path.contains("{id}") {
return TenantShape::PathScoped { fk_param };
}
}
if resolved.contains(&fk_token) {
return TenantShape::PathScoped { fk_param };
}
if owns_tenant_entity {
return TenantShape::MembershipSet;
}
TenantShape::None
}
pub(crate) fn is_identity_fk(b: &BelongsTo) -> bool {
Self::fk_column(&b.entity) == AUTH_IDENTITY_FK_COLUMN
}
pub(crate) fn has_identity_fk(e: &Entity) -> bool {
e.belongs_to.iter().any(Self::is_identity_fk)
}
pub(crate) fn entity_is_per_user_owned(&self, e: &Entity) -> bool {
self.wants_auth() && Self::has_identity_fk(e) && self.tenant_path(&e.name).is_none()
}
pub(crate) fn entity_is_public_read(&self, entity: &str) -> bool {
self.find_entity(entity)
.is_some_and(|e| e.public_read && self.entity_is_per_user_owned(e))
}
pub(crate) fn endpoint_is_public_read_get(&self, m: &ModuleDesign, ep: &Endpoint) -> bool {
matches!(ep.method, HttpMethod::GET)
&& ep.required_roles.is_empty()
&& endpoint_repo_entity_strict(m, ep)
.is_some_and(|entity| self.entity_is_public_read(entity))
}
pub(crate) fn endpoint_omits_identity_fk(&self, m: &ModuleDesign, ep: &Endpoint) -> bool {
self.wants_auth()
&& ep.is_guarded()
&& ep.request_body.as_ref().is_some_and(|rb| {
m.entities
.iter()
.find(|e| e.name == rb.entity)
.is_some_and(Self::has_identity_fk)
})
}
fn request_entity<'a>(&self, m: &'a ModuleDesign, ep: &Endpoint) -> Option<&'a Entity> {
let rb = ep.request_body.as_ref()?;
m.entities.iter().find(|e| e.name == rb.entity)
}
pub(crate) fn endpoint_omits_defaulted_field(&self, m: &ModuleDesign, ep: &Endpoint) -> bool {
self.request_entity(m, ep)
.is_some_and(|e| e.fields.iter().any(|f| f.default.is_some()))
}
pub(crate) fn entity_has_default(&self, entity: &str) -> bool {
self.find_entity(entity)
.is_some_and(|e| e.fields.iter().any(|f| f.default.is_some()))
}
pub(crate) fn entity_path_fk_columns(&self, entity_name: &str) -> Vec<String> {
let Some(e) = self.find_entity(entity_name) else {
return Vec::new();
};
e.belongs_to
.iter()
.map(|b| Self::fk_column(&b.entity))
.filter(|col| self.any_body_endpoint_resolved_path_has(entity_name, col))
.collect()
}
fn any_body_endpoint_resolved_path_has(&self, entity_name: &str, col: &str) -> bool {
fn walk(m: &ModuleDesign, entity_name: &str, token: &str, prefix: &str) -> bool {
let mount = m.effective_mount();
let mount = mount.strip_suffix('/').unwrap_or(&mount);
let base = format!("{prefix}{mount}");
m.endpoints.iter().any(|ep| {
ep.request_body
.as_ref()
.is_some_and(|rb| rb.entity == entity_name)
&& format!("{base}{}", ep.path).contains(token)
}) || m
.subroutes
.iter()
.any(|s| walk(s, entity_name, token, &base))
}
let token = format!("{{{col}}}");
self.modules
.iter()
.any(|m| walk(m, entity_name, &token, ""))
}
pub(crate) fn endpoint_omits_path_fk(&self, m: &ModuleDesign, ep: &Endpoint) -> bool {
self.request_entity(m, ep)
.is_some_and(|e| !self.entity_path_fk_columns(&e.name).is_empty())
}
pub(crate) fn endpoint_uses_request_dto(
&self,
m: &ModuleDesign,
ep: &Endpoint,
auth: bool,
) -> bool {
(auth && self.endpoint_omits_identity_fk(m, ep))
|| self.endpoint_omits_defaulted_field(m, ep)
|| self.endpoint_omits_path_fk(m, ep)
}
pub(crate) fn entity_generates_request_dto(&self, entity: &str) -> bool {
if !self.wants_db() {
return false;
}
let auth = self.wants_auth();
fn walk(design: &Design, m: &ModuleDesign, entity: &str, auth: bool) -> bool {
m.endpoints.iter().any(|ep| {
ep.request_body
.as_ref()
.is_some_and(|rb| rb.entity == entity)
&& design.endpoint_uses_request_dto(m, ep, auth)
}) || m.subroutes.iter().any(|s| walk(design, s, entity, auth))
}
self.modules.iter().any(|m| walk(self, m, entity, auth))
}
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 path_param_key_type(&self, param: &str) -> &'static str {
fn find_name<'a>(m: &'a ModuleDesign, param: &str) -> Option<&'a str> {
m.entities
.iter()
.map(|e| e.name.as_str())
.find(|n| Design::fk_column(n) == param)
.or_else(|| m.subroutes.iter().find_map(|s| find_name(s, param)))
}
match self.modules.iter().find_map(|m| find_name(m, param)) {
Some(name) => self.target_key_rust_type(name),
None => "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 collection_path(ep: &Endpoint) -> Option<String> {
let p = ep.path.as_str();
let brace = p.rfind('{')?;
let cut = p[..brace].rfind('/').unwrap_or(0);
Some(if cut == 0 {
"/".to_string()
} else {
p[..cut].to_string()
})
}
fn creator_at<'a>(m: &'a ModuleDesign, path: &str) -> Option<&'a Endpoint> {
m.endpoints
.iter()
.find(|ep| ep.method == HttpMethod::POST && ep.path == path && ep.request_body.is_some())
}
pub(crate) fn endpoint_repo_entity<'a>(m: &'a ModuleDesign, ep: &'a Endpoint) -> Option<&'a str> {
endpoint_repo_entity_strict(m, ep).or_else(|| m.entities.first().map(|e| e.name.as_str()))
}
pub(crate) fn endpoint_repo_entity_strict<'a>(
m: &'a ModuleDesign,
ep: &'a Endpoint,
) -> Option<&'a str> {
if m.entities.is_empty() {
return None;
}
ep.request_body
.as_ref()
.map(|rb| rb.entity.as_str())
.or(ep.success.entity.as_deref())
.or_else(|| {
collection_path(ep)
.and_then(|coll| creator_at(m, &coll))
.and_then(|c| c.request_body.as_ref())
.map(|rb| rb.entity.as_str())
})
}
fn collect_tenant_owned<'a>(
design: &Design,
module: &'a ModuleDesign,
out: &mut Vec<(&'a str, &'a str)>,
) {
for entity in &module.entities {
if design.tenant_path(&entity.name).is_some() {
out.push((module.name.as_str(), entity.name.as_str()));
}
}
for subroute in &module.subroutes {
collect_tenant_owned(design, subroute, 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")]);
}
fn org_account_contact() -> Design {
serde_json::from_str(
r#"{ "name": "org-api", "contract_version": 1,
"auth": { "model": "session", "roles": ["owner", "member"] },
"dependencies": ["db", "auth"],
"tenancy": { "entity": "Org", "member_roles": ["owner", "member"] },
"modules": [
{ "name": "orgs",
"entities": [{ "name": "Org", "fields": [
{ "name": "id", "type": "integer" },
{ "name": "name", "type": "string" } ]}],
"endpoints": [{ "operation_id": "list_orgs", "method": "GET", "path": "/",
"success": { "status": 200, "entity": "Org", "list": true } }] },
{ "name": "accounts",
"entities": [{ "name": "Account",
"belongs_to": [{ "entity": "Org" }],
"fields": [{ "name": "id", "type": "integer" },
{ "name": "name", "type": "string" }] }],
"endpoints": [{ "operation_id": "list_accounts", "method": "GET", "path": "/",
"success": { "status": 200, "entity": "Account", "list": true } }] },
{ "name": "contacts",
"entities": [{ "name": "Contact",
"belongs_to": [{ "entity": "Account" }],
"fields": [{ "name": "id", "type": "integer" },
{ "name": "email", "type": "string" }] }],
"endpoints": [{ "operation_id": "list_contacts", "method": "GET", "path": "/",
"success": { "status": 200, "entity": "Contact", "list": true } }] }
]
}"#,
)
.unwrap()
}
fn diamond_design() -> Design {
serde_json::from_str(
r#"{ "name": "diamond-api", "contract_version": 1,
"auth": { "model": "session", "roles": ["owner", "member"] },
"dependencies": ["db", "auth"],
"tenancy": { "entity": "Org", "member_roles": ["owner", "member"] },
"modules": [
{ "name": "orgs",
"entities": [{ "name": "Org", "fields": [
{ "name": "id", "type": "integer" },
{ "name": "name", "type": "string" } ]}],
"endpoints": [{ "operation_id": "list_orgs", "method": "GET", "path": "/",
"success": { "status": 200, "entity": "Org", "list": true } }] },
{ "name": "accounts",
"entities": [{ "name": "Account",
"belongs_to": [{ "entity": "Org" }],
"fields": [{ "name": "id", "type": "integer" },
{ "name": "name", "type": "string" }] }],
"endpoints": [{ "operation_id": "list_accounts", "method": "GET", "path": "/",
"success": { "status": 200, "entity": "Account", "list": true } }] },
{ "name": "regions",
"entities": [{ "name": "Region",
"belongs_to": [{ "entity": "Org" }],
"fields": [{ "name": "id", "type": "integer" },
{ "name": "name", "type": "string" }] }],
"endpoints": [{ "operation_id": "list_regions", "method": "GET", "path": "/",
"success": { "status": 200, "entity": "Region", "list": true } }] },
{ "name": "contacts",
"entities": [{ "name": "Contact",
"belongs_to": [{ "entity": "Account" }, { "entity": "Region" }],
"fields": [{ "name": "id", "type": "integer" },
{ "name": "email", "type": "string" }] }],
"endpoints": [{ "operation_id": "list_contacts", "method": "GET", "path": "/",
"success": { "status": 200, "entity": "Contact", "list": true } }] }
]
}"#,
)
.unwrap()
}
fn cyclic_belongs_to_design() -> Design {
serde_json::from_str(
r#"{ "name": "cycle-api", "contract_version": 1,
"auth": { "model": "session", "roles": ["owner", "member"] },
"dependencies": ["db", "auth"],
"tenancy": { "entity": "Org", "member_roles": ["owner", "member"] },
"modules": [
{ "name": "orgs",
"entities": [{ "name": "Org", "fields": [
{ "name": "id", "type": "integer" } ]}],
"endpoints": [{ "operation_id": "list_orgs", "method": "GET", "path": "/",
"success": { "status": 200, "entity": "Org", "list": true } }] },
{ "name": "as",
"entities": [{ "name": "A",
"belongs_to": [{ "entity": "B" }],
"fields": [{ "name": "id", "type": "integer" }] }],
"endpoints": [{ "operation_id": "list_as", "method": "GET", "path": "/",
"success": { "status": 200, "entity": "A", "list": true } }] },
{ "name": "bs",
"entities": [{ "name": "B",
"belongs_to": [{ "entity": "A" }],
"fields": [{ "name": "id", "type": "integer" }] }],
"endpoints": [{ "operation_id": "list_bs", "method": "GET", "path": "/",
"success": { "status": 200, "entity": "B", "list": true } }] }
]
}"#,
)
.unwrap()
}
#[test]
fn tenant_path_direct_child_has_no_joins() {
let d = org_account_contact();
let p = d.tenant_path("Account").expect("Account is tenant-owned");
assert!(p.joins.is_empty(), "direct child = zero joins");
assert_eq!(p.tenant_fk, "org_id");
assert_eq!(p.anchor_table, d.table_name("Account"));
assert_eq!(p.entity_table, d.table_name("Account"));
}
#[test]
fn tenant_path_grandchild_joins_through_parent() {
let d = org_account_contact();
let p = d
.tenant_path("Contact")
.expect("Contact is transitively tenant-owned");
assert_eq!(p.joins.len(), 1);
assert_eq!(p.joins[0].child_table, d.table_name("Contact"));
assert_eq!(p.joins[0].child_fk, "account_id");
assert_eq!(p.joins[0].parent_table, d.table_name("Account"));
assert_eq!(p.anchor_table, d.table_name("Account"));
assert_eq!(p.entity_table, d.table_name("Contact"));
assert_eq!(p.tenant_fk, "org_id");
}
#[test]
fn tenant_path_none_for_unowned_entity() {
let d = org_account_contact();
assert!(
d.tenant_path("Org").is_none(),
"the tenant itself is not tenant-owned"
);
}
#[test]
fn tenant_path_ambiguous_diamond_raises_jc0545() {
let d = diamond_design();
let diags = crate::platform::questions::validate(&d);
assert!(
diags.iter().any(|x| x.question.contains("JC0545")),
"diamond → JC0545"
);
assert!(
d.tenant_path("Contact").is_none(),
"ambiguous resolves to None"
);
assert_eq!(
d.tenant_path_branch_count("Contact"),
2,
"two distinct chains"
);
}
#[test]
fn tenant_path_cycle_does_not_hang() {
let d = cyclic_belongs_to_design();
let _ = d.tenant_path("A"); }
#[test]
fn grandchild_flat_route_is_membership_set_not_none() {
let d = org_account_contact();
let contacts = &d.modules[2];
assert_eq!(
d.endpoint_tenant_shape(contacts, &contacts.endpoints[0]),
TenantShape::MembershipSet,
);
}
#[test]
fn direct_child_shape_unchanged() {
let d = org_account_contact();
let accounts = &d.modules[1];
assert_eq!(
d.endpoint_tenant_shape(accounts, &accounts.endpoints[0]),
TenantShape::MembershipSet,
);
}
#[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 tenant_shape_classifies_by_route() {
let d: Design = serde_json::from_str(
r#"{ "name": "clubs-api", "contract_version": 1,
"auth": { "model": "session", "roles": ["owner", "member"] },
"dependencies": ["db", "auth"],
"tenancy": { "entity": "Club", "member_roles": ["owner", "member"] },
"modules": [
{ "name": "clubs",
"entities": [{ "name": "Club", "fields": [
{ "name": "id", "type": "integer" },
{ "name": "name", "type": "string" } ]}],
"endpoints": [
{ "operation_id": "create_club", "method": "POST", "path": "/",
"request_body": { "entity": "Club" },
"success": { "status": 201, "entity": "Club" } },
{ "operation_id": "list_clubs", "method": "GET", "path": "/",
"success": { "status": 200, "entity": "Club", "list": true } },
{ "operation_id": "get_club", "method": "GET", "path": "/{club_id}",
"success": { "status": 200, "entity": "Club" } },
{ "operation_id": "delete_club", "method": "DELETE", "path": "/{club_id}",
"success": { "status": 204 } },
{ "operation_id": "get_club_conventional", "method": "GET", "path": "/{id}",
"success": { "status": 200, "entity": "Club" } } ] },
{ "name": "books", "mount": "/clubs/{club_id}",
"entities": [{ "name": "Book",
"belongs_to": [{ "entity": "Club" }],
"fields": [{ "name": "id", "type": "integer" },
{ "name": "title", "type": "string" }] }],
"endpoints": [
{ "operation_id": "create_book", "method": "POST", "path": "/",
"request_body": { "entity": "Book" },
"success": { "status": 201, "entity": "Book" } },
{ "operation_id": "list_books", "method": "GET", "path": "/",
"success": { "status": 200, "entity": "Book", "list": true } },
{ "operation_id": "get_book", "method": "GET", "path": "/{id}",
"success": { "status": 200, "entity": "Book" } } ] },
{ "name": "customers",
"entities": [{ "name": "Customer",
"belongs_to": [{ "entity": "Club" }],
"fields": [{ "name": "id", "type": "integer" },
{ "name": "email", "type": "string" }] }],
"endpoints": [
{ "operation_id": "list_customers", "method": "GET", "path": "/",
"success": { "status": 200, "entity": "Customer", "list": true } },
{ "operation_id": "get_customer", "method": "GET", "path": "/{id}",
"success": { "status": 200, "entity": "Customer" } } ] }
] }"#,
)
.unwrap();
let clubs = &d.modules[0];
let books = &d.modules[1];
let customers = &d.modules[2];
assert!(matches!(
d.endpoint_tenant_shape(clubs, &clubs.endpoints[0]),
TenantShape::Collection
));
assert!(matches!(
d.endpoint_tenant_shape(clubs, &clubs.endpoints[1]),
TenantShape::Collection
));
assert!(matches!(
d.endpoint_tenant_shape(clubs, &clubs.endpoints[2]),
TenantShape::PathScoped { fk_param } if fk_param == "club_id"
));
assert!(matches!(
d.endpoint_tenant_shape(clubs, &clubs.endpoints[3]),
TenantShape::PathScoped { .. }
));
assert!(matches!(
d.endpoint_tenant_shape(clubs, &clubs.endpoints[4]),
TenantShape::PathScoped { fk_param } if fk_param == "club_id"
));
assert!(matches!(
d.endpoint_tenant_shape(books, &books.endpoints[2]),
TenantShape::PathScoped { fk_param } if fk_param == "club_id"
));
assert!(matches!(
d.endpoint_tenant_shape(customers, &customers.endpoints[1]),
TenantShape::MembershipSet
));
let plain: Design = serde_json::from_str(MINIMAL).unwrap();
let m = &plain.modules[0];
assert!(matches!(
plain.endpoint_tenant_shape(m, &m.endpoints[0]),
TenantShape::None
));
}
#[test]
fn normalize_renames_only_the_tenant_module_own_detail_route() {
let mut d: Design = serde_json::from_str(
r#"{ "name": "clubs-api", "contract_version": 1,
"auth": { "model": "session", "roles": ["owner", "member"] },
"dependencies": ["db", "auth"],
"tenancy": { "entity": "Club", "member_roles": ["owner", "member"] },
"modules": [
{ "name": "clubs",
"entities": [{ "name": "Club", "fields": [
{ "name": "id", "type": "integer" },
{ "name": "name", "type": "string" } ]}],
"endpoints": [
{ "operation_id": "create_club", "method": "POST", "path": "/",
"request_body": { "entity": "Club" },
"success": { "status": 201, "entity": "Club" } },
{ "operation_id": "get_club", "method": "GET", "path": "/{id}",
"success": { "status": 200, "entity": "Club" } },
{ "operation_id": "delete_club", "method": "DELETE", "path": "/{id}",
"success": { "status": 204 } } ] },
{ "name": "books", "mount": "/clubs/{club_id}",
"entities": [{ "name": "Book",
"belongs_to": [{ "entity": "Club" }],
"fields": [{ "name": "id", "type": "integer" },
{ "name": "title", "type": "string" }] }],
"endpoints": [
{ "operation_id": "get_book", "method": "GET", "path": "/{id}",
"success": { "status": 200, "entity": "Book" } } ] },
{ "name": "customers",
"entities": [{ "name": "Customer",
"belongs_to": [{ "entity": "Club" }],
"fields": [{ "name": "id", "type": "integer" },
{ "name": "email", "type": "string" }] }],
"endpoints": [
{ "operation_id": "get_customer", "method": "GET", "path": "/{id}",
"success": { "status": 200, "entity": "Customer" } } ] }
] }"#,
)
.unwrap();
d.normalize_tenant_detail_routes();
assert_eq!(
d.modules[0].endpoints[1].path, "/{club_id}",
"GET tenant detail"
);
assert_eq!(
d.modules[0].endpoints[2].path, "/{club_id}",
"DELETE tenant detail"
);
assert_eq!(d.modules[0].endpoints[0].path, "/");
assert_eq!(
d.modules[1].endpoints[0].path, "/{id}",
"nested child untouched"
);
assert_eq!(
d.modules[2].endpoints[0].path, "/{id}",
"flat child untouched"
);
let before = d.clone();
d.normalize_tenant_detail_routes();
assert_eq!(
d.modules[0].endpoints[1].path,
before.modules[0].endpoints[1].path
);
let mut plain: Design = serde_json::from_str(MINIMAL).unwrap();
let snapshot = plain.clone();
plain.normalize_tenant_detail_routes();
assert_eq!(
plain.modules[0].endpoints[0].path,
snapshot.modules[0].endpoints[0].path
);
}
#[test]
fn field_default_round_trips_and_defaults_to_none() {
let f: Field =
serde_json::from_str(r#"{ "name": "confirmed", "type": "boolean", "default": false }"#)
.unwrap();
assert_eq!(f.default, Some(serde_json::json!(false)));
let back = serde_json::to_value(&f).unwrap();
assert_eq!(back["default"], serde_json::json!(false));
let plain: Field =
serde_json::from_str(r#"{ "name": "title", "type": "string" }"#).unwrap();
assert!(plain.default.is_none());
let back = serde_json::to_value(&plain).unwrap();
assert!(
back.get("default").is_none(),
"absent default is not serialized: {back}"
);
}
#[test]
fn endpoint_omits_defaulted_field_detects_a_default() {
let d: Design = serde_json::from_str(
r#"{ "name": "news", "contract_version": 0, "dependencies": ["db"],
"modules": [{ "name": "subs",
"entities": [{ "name": "Subscriber", "fields": [
{ "name": "email", "type": "string" },
{ "name": "confirmed", "type": "boolean", "default": false } ] }],
"endpoints": [{ "operation_id": "create_subscriber", "method": "POST", "path": "/",
"request_body": { "entity": "Subscriber" },
"success": { "status": 201, "entity": "Subscriber" } }] }] }"#,
)
.unwrap();
let m = &d.modules[0];
let ep = &m.endpoints[0];
assert!(d.endpoint_omits_defaulted_field(m, ep));
assert!(
d.endpoint_uses_request_dto(m, ep, false),
"auth-independent"
);
}
#[test]
fn entity_path_fk_columns_finds_the_path_redundant_parent() {
let d: Design = serde_json::from_str(
r#"{ "name": "habits", "contract_version": 0, "dependencies": ["db"],
"modules": [{ "name": "habits",
"entities": [
{ "name": "Habit", "fields": [{ "name": "name", "type": "string" }] },
{ "name": "Checkin", "belongs_to": [{ "entity": "Habit" }],
"fields": [{ "name": "note", "type": "string" }] } ],
"endpoints": [
{ "operation_id": "create_habit", "method": "POST", "path": "/",
"request_body": { "entity": "Habit" },
"success": { "status": 201, "entity": "Habit" } },
{ "operation_id": "create_checkin", "method": "POST", "path": "/{habit_id}/checkins",
"request_body": { "entity": "Checkin" },
"success": { "status": 201, "entity": "Checkin" } }] }] }"#,
)
.unwrap();
assert_eq!(d.entity_path_fk_columns("Checkin"), vec!["habit_id"]);
assert!(d.entity_path_fk_columns("Habit").is_empty());
let m = &d.modules[0];
let create_checkin = &m.endpoints[1];
assert!(d.endpoint_omits_path_fk(m, create_checkin));
assert!(d.endpoint_uses_request_dto(m, create_checkin, false));
}
#[test]
fn entity_path_fk_columns_is_mount_aware() {
let mount: Design = serde_json::from_str(
r#"{ "name": "clubs", "contract_version": 0, "dependencies": ["db"],
"modules": [
{ "name": "clubs",
"entities": [{ "name": "Club", "fields": [{ "name": "name", "type": "string" }] }],
"endpoints": [] },
{ "name": "books", "mount": "/clubs/{club_id}",
"entities": [{ "name": "Book", "belongs_to": [{ "entity": "Club" }],
"fields": [{ "name": "title", "type": "string" }] }],
"endpoints": [
{ "operation_id": "create_book", "method": "POST", "path": "/",
"request_body": { "entity": "Book" },
"success": { "status": 201, "entity": "Book" } }] } ] }"#,
)
.unwrap();
assert_eq!(mount.entity_path_fk_columns("Book"), vec!["club_id"]);
let ep_path: Design = serde_json::from_str(
r#"{ "name": "lib", "contract_version": 0, "dependencies": ["db"],
"modules": [
{ "name": "library",
"entities": [
{ "name": "Club", "fields": [{ "name": "name", "type": "string" }] },
{ "name": "Book", "belongs_to": [{ "entity": "Club" }],
"fields": [{ "name": "title", "type": "string" }] }],
"endpoints": [
{ "operation_id": "create_book", "method": "POST", "path": "/{club_id}/books",
"request_body": { "entity": "Book" },
"success": { "status": 201, "entity": "Book" } }] } ] }"#,
)
.unwrap();
assert_eq!(ep_path.entity_path_fk_columns("Book"), vec!["club_id"]);
}
#[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\"")
);
assert!(
s.contains("A server-owned default value"),
"published schema must document the `default` field key"
);
}
#[test]
fn public_read_defaults_false_and_round_trips() {
let e: Entity = serde_json::from_str(
r#"{ "name": "Post", "fields": [ { "name": "title", "type": "string" } ] }"#,
)
.unwrap();
assert!(!e.public_read, "absent key defaults to false");
let back = serde_json::to_value(&e).unwrap();
assert!(
back.get("public_read").is_none(),
"false is not serialized: {back}"
);
let on: Entity = serde_json::from_str(
r#"{ "name": "Post", "public_read": true,
"fields": [ { "name": "title", "type": "string" } ] }"#,
)
.unwrap();
assert!(on.public_read);
assert_eq!(
serde_json::to_value(&on).unwrap()["public_read"],
serde_json::json!(true),
"an opted-in entity keeps the flag across a round trip"
);
}
#[test]
fn published_schema_accepts_public_read() {
let s = include_str!("../../../../docs/contracts/design-schema.json");
assert!(
s.contains("\"public_read\""),
"published schema must admit the entity public_read key (#105)"
);
}
#[test]
fn entity_is_public_read_requires_the_per_user_shape() {
let src = r#"{
"name": "feed", "contract_version": 1,
"auth": { "model": "session", "roles": ["admin"] },
"dependencies": ["db", "auth"],
"modules": [{
"name": "posts",
"entities": [
{ "name": "Post", "public_read": true,
"belongs_to": [{ "entity": "User" }],
"fields": [{ "name": "title", "type": "string" }] },
{ "name": "Draft",
"belongs_to": [{ "entity": "User" }],
"fields": [{ "name": "title", "type": "string" }] },
{ "name": "Tag", "public_read": true,
"fields": [{ "name": "label", "type": "string" }] },
{ "name": "User", "fields": [{ "name": "email", "type": "string" }] }
],
"endpoints": [
{ "operation_id": "list_posts", "method": "GET", "path": "/",
"success": { "status": 200, "entity": "Post", "list": true } }
]
}]
}"#;
let d: Design = serde_json::from_str(src).unwrap();
assert!(
d.entity_is_public_read("Post"),
"public_read + identity fk + auth + no tenancy → public-read"
);
assert!(
!d.entity_is_public_read("Draft"),
"per-user owned but NOT opted in → owner-scoped as before"
);
assert!(
!d.entity_is_public_read("Tag"),
"opted in but no identity fk → not public-read"
);
assert!(!d.entity_is_public_read("Nope"), "unknown entity → false");
let mut no_auth = d.clone();
no_auth.auth = None;
no_auth.dependencies.retain(|dep| dep != "auth");
assert!(!no_auth.entity_is_public_read("Post"));
let mut tenant: Design = serde_json::from_str(src).unwrap();
tenant.tenancy = Some(
serde_json::from_str(r#"{ "entity": "Org", "member_roles": ["owner"] }"#).unwrap(),
);
tenant.modules[0].entities.push(
serde_json::from_str(
r#"{ "name": "Org", "fields": [{ "name": "label", "type": "string" }] }"#,
)
.unwrap(),
);
tenant.modules[0].entities[0]
.belongs_to
.push(serde_json::from_str(r#"{ "entity": "Org" }"#).unwrap());
assert!(!tenant.entity_is_public_read("Post"));
}
}