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 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>,
pub modules: Vec<ModuleDesign>,
}
#[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 = "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 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>,
pub success: Success,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<ErrorCase>,
}
#[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",
}
}
}
#[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 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_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");
}
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 {
fn find<'a>(m: &'a ModuleDesign, target: &str) -> Option<&'a Entity> {
m.entities
.iter()
.find(|e| e.name == target)
.or_else(|| m.subroutes.iter().find_map(|s| find(s, target)))
}
self.modules
.iter()
.find_map(|m| find(m, target))
.and_then(|e| e.fields.iter().find(|f| f.name == "id"))
.map(|f| f.field_type.rust_type())
.unwrap_or("i64")
}
}
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": "kolli-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 } }] }
]
}"#;
#[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 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 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])
);
assert!(
s.contains("\"belongs_to\"")
&& s.contains("\"tenancy\"")
&& s.contains("\"jobs\"")
&& s.contains("\"on_delete\"")
&& s.contains("\"unique\"")
&& s.contains("\"values\"")
);
}
}