use super::design::*;
use serde_json::{Value, json};
fn field_schema(f: &Field) -> Value {
let mut schema = match f.field_type {
FieldType::String => json!({ "type": "string" }),
FieldType::Integer => json!({ "type": "integer", "format": "int64" }),
FieldType::Float => json!({ "type": "number", "format": "double" }),
FieldType::Boolean => json!({ "type": "boolean" }),
FieldType::Datetime => json!({ "type": "string", "format": "date-time" }),
FieldType::Uuid => json!({ "type": "string", "format": "uuid" }),
FieldType::Json => json!({}),
};
if let Some(mn) = f.min {
schema["minimum"] = json!(mn);
}
if let Some(mx) = f.max {
schema["maximum"] = json!(mx);
}
if let Some(mn) = f.min_len {
schema["minLength"] = json!(mn);
}
if let Some(mx) = f.max_len {
schema["maxLength"] = json!(mx);
}
if Design::field_is_write_only(f) {
schema["writeOnly"] = json!(true);
}
schema
}
fn entity_ref(name: &str) -> Value {
json!({ "$ref": format!("#/components/schemas/{name}") })
}
fn success_schema(s: &Success) -> Option<Value> {
let inner = s.entity.as_deref().map(entity_ref)?;
Some(if s.list {
json!({ "type": "array", "items": inner })
} else {
inner
})
}
fn security_scheme_name(design: &Design) -> Option<&'static str> {
match design.auth_model() {
AuthModel::Jwt => Some("bearerAuth"),
AuthModel::Session => Some("cookieAuth"),
AuthModel::None => None,
}
}
fn operation(design: &Design, m: &ModuleDesign, ep: &Endpoint) -> Value {
let mut op = json!({ "operationId": ep.operation_id, "responses": {} });
if ep.is_guarded()
&& !design.endpoint_is_public_read_get(m, ep)
&& let Some(scheme) = security_scheme_name(design)
{
op["security"] = json!([{ scheme: [] }]);
}
let params: Vec<Value> = {
let mut out = Vec::new();
let mut rest = ep.path.as_str();
while let Some(start) = rest.find('{') {
let Some(end_rel) = rest[start..].find('}') else {
break;
};
out.push(json!({
"name": rest[start + 1..start + end_rel],
"in": "path",
"required": true,
"schema": { "type": "integer", "format": "int64" },
}));
rest = &rest[start + end_rel + 1..];
}
out
};
if !params.is_empty() {
op["parameters"] = Value::Array(params);
}
if let Some(ref rb) = ep.request_body {
let schema = if rb.is_inline() {
entity_ref(&format!("{}Request", to_pascal(&ep.operation_id)))
} else if design.wants_db() && design.endpoint_uses_request_dto(m, ep, design.wants_auth())
{
let entity = rb.entity.as_deref().expect("entity body");
let name = if ep.method.is_update() && design.entity_has_default(entity) {
format!("{entity}UpdateRequest")
} else {
format!("{entity}Request")
};
entity_ref(&name)
} else {
entity_ref(rb.entity.as_deref().expect("entity body"))
};
op["requestBody"] = json!({
"required": true,
"content": { "application/json": { "schema": schema } },
});
}
let mut response = json!({ "description": "success" });
if let Some(schema) = success_schema(&ep.success) {
response["content"] = json!({ "application/json": { "schema": schema } });
}
op["responses"][ep.success.status.to_string()] = response;
for ec in &ep.errors {
op["responses"][ec.status.to_string()] = json!({ "description": ec.when });
}
if ep.method == HttpMethod::POST
&& op["responses"].get("409").is_none()
&& let Some(rb) = &ep.request_body
&& let Some(entity) = rb.entity.as_deref()
&& let Some(group) = m
.entities
.iter()
.find(|e| e.name == entity)
.and_then(|e| e.unique.first())
{
let cols = group.join(", ");
op["responses"]["409"] =
json!({ "description": format!("a row with the same ({cols}) already exists") });
}
op
}
fn walk_paths(
design: &Design,
m: &ModuleDesign,
prefix: &str,
paths: &mut serde_json::Map<String, Value>,
) {
let base = format!("{}{}", prefix, m.effective_mount());
for ep in &m.endpoints {
let full = format!("{}{}", base.trim_end_matches('/'), ep.path);
let entry = paths.entry(full).or_insert_with(|| json!({}));
entry[ep.method.builder_fn()] = operation(design, m, ep);
}
for sub in &m.subroutes {
walk_paths(design, sub, &base, paths);
}
}
fn walk_schemas(design: &Design, m: &ModuleDesign, schemas: &mut serde_json::Map<String, Value>) {
for e in &m.entities {
let mut properties = serde_json::Map::new();
let mut required = Vec::new();
for f in &e.fields {
properties.insert(f.name.clone(), field_schema(f));
if f.required {
required.push(Value::String(f.name.clone()));
}
}
schemas.insert(
e.name.clone(),
json!({ "type": "object", "properties": properties, "required": required }),
);
let needs_request_schema = design.wants_db()
&& m.endpoints.iter().any(|ep| {
design.endpoint_uses_request_dto(m, ep, design.wants_auth())
&& ep
.request_body
.as_ref()
.is_some_and(|rb| rb.entity.as_deref() == Some(e.name.as_str()))
});
if needs_request_schema {
schemas.insert(
format!("{}Request", e.name),
request_schema(design, e, false),
);
}
let needs_update_schema = design.wants_db()
&& e.fields.iter().any(|f| f.default.is_some())
&& m.endpoints.iter().any(|ep| {
ep.method.is_update()
&& design.endpoint_uses_request_dto(m, ep, design.wants_auth())
&& ep
.request_body
.as_ref()
.is_some_and(|rb| rb.entity.as_deref() == Some(e.name.as_str()))
});
if needs_update_schema {
schemas.insert(
format!("{}UpdateRequest", e.name),
request_schema(design, e, true),
);
}
}
for ep in &m.endpoints {
if let Some(rb) = ep.request_body.as_ref()
&& rb.is_inline()
{
schemas.insert(
format!("{}Request", to_pascal(&ep.operation_id)),
inline_request_schema(&rb.fields),
);
}
}
for sub in &m.subroutes {
walk_schemas(design, sub, schemas);
}
}
fn inline_request_schema(fields: &[Field]) -> Value {
let mut properties = serde_json::Map::new();
let mut required = Vec::new();
for f in fields {
properties.insert(f.name.clone(), field_schema(f));
if f.required {
required.push(Value::String(f.name.clone()));
}
}
json!({ "type": "object", "properties": properties, "required": required })
}
fn request_schema(design: &Design, e: &Entity, for_update: bool) -> Value {
let mut properties = serde_json::Map::new();
let mut required = Vec::new();
let omit_identity = design.wants_auth();
let path_fks = design.entity_path_fk_columns(&e.name);
for b in e.belongs_to.iter().filter(|b| {
!(omit_identity && design.is_identity_fk(b)) && !path_fks.contains(&b.fk_column())
}) {
let col = b.fk_column();
let schema = match design.target_key_rust_type(&b.entity) {
"String" => json!({ "type": "string" }),
_ => json!({ "type": "integer", "format": "int64" }),
};
properties.insert(col.clone(), schema);
if b.on_delete != OnDelete::SetNull {
required.push(Value::String(col));
}
}
for f in e
.fields
.iter()
.filter(|f| (for_update || f.default.is_none()) && !Design::field_is_now_default(f))
{
properties.insert(f.name.clone(), field_schema(f));
if f.required {
required.push(Value::String(f.name.clone()));
}
}
json!({ "type": "object", "properties": properties, "required": required })
}
fn member_surface_paths(design: &Design, paths: &mut serde_json::Map<String, Value>) {
let Some(tenancy) = design.tenancy.as_ref() else {
return;
};
if !design.wants_db() || !design.wants_auth() {
return;
}
fn base_of(m: &ModuleDesign, prefix: &str, entity: &str) -> Option<String> {
let base = format!("{}{}", prefix, m.effective_mount());
if m.entities.iter().any(|e| e.name == entity) {
return Some(base);
}
m.subroutes.iter().find_map(|s| base_of(s, &base, entity))
}
let Some(base) = design
.modules
.iter()
.find_map(|m| base_of(m, "", &tenancy.entity))
else {
return;
};
let entity = &tenancy.entity;
let snake = Design::to_snake(entity);
let fk = Design::fk_column(entity);
let admin = tenancy
.member_roles
.first()
.map(String::as_str)
.unwrap_or("member");
let fk_schema = match design.target_key_rust_type(entity) {
"String" => json!({ "type": "string" }),
_ => json!({ "type": "integer", "format": "int64" }),
};
let role_schema = if tenancy.member_roles.is_empty() {
json!({ "type": "string" })
} else {
json!({ "type": "string", "enum": tenancy.member_roles })
};
let member_schema = json!({
"type": "object",
"properties": {
"id": { "type": "integer", "format": "int64" },
"user_id": { "type": "string" },
"role": role_schema.clone(),
},
"required": ["id", "user_id", "role"],
});
let add_schema = json!({
"type": "object",
"properties": { "user_id": { "type": "string" }, "role": role_schema.clone() },
"required": ["user_id", "role"],
});
let fk_param = json!({ "name": fk, "in": "path", "required": true, "schema": fk_schema });
let user_param = json!({ "name": "user_id", "in": "path", "required": true, "schema": { "type": "string" } });
let security = security_scheme_name(design).map(|scheme| json!([{ scheme: [] }]));
let not_member = format!("caller is not a member of this {snake} (membership guard)");
let not_admin = format!("caller does not hold the admin role `{admin}`");
let bad_role = "role is not one of the declared member_roles";
let mut list = json!({
"operationId": format!("list_{snake}_members"),
"parameters": [fk_param.clone()],
"responses": {
"200": {
"description": "success",
"content": { "application/json": {
"schema": { "type": "array", "items": member_schema } } },
},
"404": { "description": not_member.clone() },
},
});
let mut add = json!({
"operationId": format!("add_{snake}_member"),
"parameters": [fk_param.clone()],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": add_schema.clone() } },
},
"responses": {
"201": {
"description": "success",
"content": { "application/json": { "schema": add_schema } },
},
"403": { "description": not_admin.clone() },
"404": { "description": not_member.clone() },
"409": { "description": "user is already a member" },
"422": { "description": bad_role },
},
});
let mut set_role = json!({
"operationId": format!("set_{snake}_member_role"),
"parameters": [fk_param.clone(), user_param.clone()],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": {
"type": "object",
"properties": { "role": role_schema },
"required": ["role"],
} } },
},
"responses": {
"204": { "description": "success" },
"403": { "description": not_admin },
"404": { "description": format!("{not_member}, or no such member") },
"409": { "description": format!("cannot demote the last {admin}") },
"422": { "description": bad_role },
},
});
let mut remove = json!({
"operationId": format!("remove_{snake}_member"),
"parameters": [fk_param, user_param],
"responses": {
"204": { "description": "success" },
"403": {
"description": format!(
"removing another member requires the admin role `{admin}` (self-removal is open to any member)"
),
},
"404": { "description": format!("{not_member}, or no such member") },
"409": { "description": format!("cannot remove the last {admin}") },
},
});
if let Some(sec) = security {
for op in [&mut list, &mut add, &mut set_role, &mut remove] {
op["security"] = sec.clone();
}
}
let collection = format!("{}/{{{fk}}}/members", base.trim_end_matches('/'));
let item = format!("{collection}/{{user_id}}");
let entry = paths.entry(collection).or_insert_with(|| json!({}));
entry["get"] = list;
entry["post"] = add;
let entry = paths.entry(item).or_insert_with(|| json!({}));
entry["patch"] = set_role;
entry["delete"] = remove;
}
pub fn document(design: &Design) -> Value {
let mut paths = serde_json::Map::new();
let mut schemas = serde_json::Map::new();
for m in &design.modules {
walk_paths(design, m, "", &mut paths);
walk_schemas(design, m, &mut schemas);
}
member_surface_paths(design, &mut paths);
let mut components = json!({ "schemas": schemas });
if let Some(name) = security_scheme_name(design) {
let scheme = match design.auth_model() {
AuthModel::Jwt => json!({ "type": "http", "scheme": "bearer", "bearerFormat": "JWT" }),
_ => json!({ "type": "apiKey", "in": "cookie", "name": "jerrycan_session" }),
};
components["securitySchemes"] = json!({ name: scheme });
}
json!({
"openapi": "3.1.0",
"info": {
"title": design.name,
"version": "0.1.0",
"description": design.description.clone().unwrap_or_default(),
},
"paths": paths,
"components": components,
})
}
pub fn document_json(design: &Design) -> String {
let mut s = serde_json::to_string_pretty(&document(design)).expect("openapi serializes");
s.push('\n');
s
}
#[cfg(test)]
mod tests {
use super::*;
const GOLDEN: &str = include_str!("../../../../conformance/designs/todo-api.design.json");
const REFERENCE_SLICE: &str =
include_str!("../../../../conformance/designs/reference-slice.design.json");
fn doc() -> Value {
document(&serde_json::from_str::<Design>(GOLDEN).unwrap())
}
#[test]
fn jwt_design_advertises_bearer_security_on_guarded_ops() {
let d = document(&serde_json::from_str::<Design>(REFERENCE_SLICE).unwrap());
let scheme = &d["components"]["securitySchemes"]["bearerAuth"];
assert_eq!(scheme["type"], "http");
assert_eq!(scheme["scheme"], "bearer");
assert_eq!(scheme["bearerFormat"], "JWT");
assert!(
d["components"]["securitySchemes"]["cookieAuth"].is_null(),
"jwt advertises bearer, never cookie: {}",
d["components"]["securitySchemes"]
);
assert_eq!(
d["paths"]["/leads/"]["post"]["security"],
json!([{ "bearerAuth": [] }])
);
assert!(
d["paths"]["/users/register"]["post"]
.get("security")
.is_none(),
"public op carries no security: {}",
d["paths"]["/users/register"]["post"]
);
}
#[test]
fn session_design_advertises_cookie_security_on_guarded_ops() {
let d = document(
&serde_json::from_str::<Design>(crate::platform::genroute::tests::SERVER_FK).unwrap(),
);
let scheme = &d["components"]["securitySchemes"]["cookieAuth"];
assert_eq!(scheme["type"], "apiKey");
assert_eq!(scheme["in"], "cookie");
assert_eq!(scheme["name"], "jerrycan_session");
assert!(
d["components"]["securitySchemes"]["bearerAuth"].is_null(),
"session advertises cookie, never bearer"
);
assert_eq!(
d["paths"]["/users/"]["get"]["security"],
json!([{ "cookieAuth": [] }])
);
}
#[test]
fn public_read_get_advertises_no_security_but_writes_keep_it() {
let d = document(
&serde_json::from_str::<Design>(crate::platform::genroute::tests::PUBLIC_READ).unwrap(),
);
assert!(
d["paths"]["/posts/"]["get"].get("security").is_none(),
"a public_read GET must not advertise a credential: {}",
d["paths"]["/posts/"]["get"]
);
assert!(
d["paths"]["/posts/{id}"]["get"].get("security").is_none(),
"the public detail GET carries none either: {}",
d["paths"]["/posts/{id}"]["get"]
);
for (path, method) in [
("/posts/", "post"),
("/posts/{id}", "put"),
("/posts/{id}", "delete"),
] {
assert_eq!(
d["paths"][path][method]["security"],
json!([{ "cookieAuth": [] }]),
"write {method} {path} keeps its security stanza"
);
}
assert_eq!(
d["paths"]["/posts/drafts"]["get"]["security"],
json!([{ "cookieAuth": [] }]),
"a guarded non-public_read GET keeps its stanza"
);
}
#[test]
fn constraints_ride_into_schemas() {
let d = document(
&serde_json::from_str::<Design>(crate::platform::genroute::tests::CONSTRAINT_DTO)
.unwrap(),
);
let item = &d["components"]["schemas"]["Item"]["properties"];
assert_eq!(item["quantity"]["minimum"], json!(1), "entity minimum");
assert_eq!(item["quantity"]["maximum"], json!(600), "entity maximum");
assert_eq!(item["code"]["maxLength"], json!(5), "entity maxLength");
assert_eq!(item["note"]["minLength"], json!(2), "entity minLength");
let req = &d["components"]["schemas"]["ItemRequest"]["properties"];
assert_eq!(req["quantity"]["minimum"], json!(1), "request DTO minimum");
assert_eq!(
req["quantity"]["maximum"],
json!(600),
"request DTO maximum"
);
assert_eq!(req["code"]["maxLength"], json!(5), "request DTO maxLength");
let upd = &d["components"]["schemas"]["ItemUpdateRequest"]["properties"];
assert_eq!(upd["code"]["maxLength"], json!(5), "update DTO maxLength");
assert!(
item["status"].get("enum").is_none(),
"values must NOT backfill to enum (would diff existing documents): {}",
item["status"]
);
let plain = document_json(&serde_json::from_str::<Design>(GOLDEN).unwrap());
for kw in ["minimum", "maximum", "minLength", "maxLength"] {
assert!(
!plain.contains(kw),
"unconstrained golden must not gain `{kw}`"
);
}
}
#[test]
fn write_only_fields_are_marked_writeonly_in_the_document() {
let d = document(
&serde_json::from_str::<Design>(crate::platform::genroute::tests::WRITE_ONLY).unwrap(),
);
let account = &d["components"]["schemas"]["Account"]["properties"];
assert_eq!(
account["api_token"]["writeOnly"],
json!(true),
"an explicit write_only field is writeOnly"
);
assert_eq!(
account["password_hash"]["writeOnly"],
json!(true),
"a password_hash column is auto-marked writeOnly"
);
assert!(
account["email"].get("writeOnly").is_none(),
"a normal field gains no writeOnly: {}",
account["email"]
);
assert!(
d["components"]["schemas"]["AccountRequest"]["properties"]["api_token"].is_object(),
"the write_only field stays in the request schema (input)"
);
let plain = document_json(&serde_json::from_str::<Design>(GOLDEN).unwrap());
assert!(
!plain.contains("writeOnly"),
"a design with no write_only/password_hash gains no writeOnly"
);
}
#[test]
fn role_gated_get_on_a_public_read_entity_keeps_security() {
let mut design: Design =
serde_json::from_str(crate::platform::genroute::tests::PUBLIC_READ).unwrap();
design.modules[1]
.endpoints
.iter_mut()
.find(|ep| ep.operation_id == "list_posts")
.unwrap()
.required_roles = vec!["user".to_string()];
let d = document(&design);
assert_eq!(
d["paths"]["/posts/"]["get"]["security"],
json!([{ "cookieAuth": [] }]),
"a role-gated GET keeps its guard AND its advertised credential: {}",
d["paths"]["/posts/"]["get"]
);
}
#[test]
fn entityless_authed_get_beside_a_public_read_entity_keeps_security() {
let mut design: Design =
serde_json::from_str(crate::platform::genroute::tests::PUBLIC_READ).unwrap();
let stats: Endpoint = serde_json::from_str(
r#"{ "operation_id": "get_stats", "method": "GET", "path": "/stats",
"auth_required": true, "success": { "status": 200 } }"#,
)
.unwrap();
design.modules[1].endpoints.push(stats);
let d = document(&design);
assert_eq!(
d["paths"]["/posts/stats"]["get"]["security"],
json!([{ "cookieAuth": [] }]),
"an entity-less auth_required GET keeps its advertised credential: {}",
d["paths"]["/posts/stats"]["get"]
);
assert!(
d["paths"]["/posts/"]["get"].get("security").is_none(),
"the explicit public_read list GET still carries no stanza: {}",
d["paths"]["/posts/"]["get"]
);
}
#[test]
fn no_auth_design_emits_no_security() {
let d = doc();
assert!(
d["components"].get("securitySchemes").is_none(),
"none model adds no securitySchemes: {}",
d["components"]
);
assert!(
d["paths"]["/todos/"]["post"].get("security").is_none(),
"none model adds no per-op security"
);
}
#[test]
fn document_shape_is_openapi_31() {
let d = doc();
assert_eq!(d["openapi"], "3.1.0");
assert_eq!(d["info"]["title"], "todo-api");
assert!(d["paths"].is_object());
assert!(d["components"]["schemas"]["Todo"].is_object());
}
#[test]
fn paths_carry_operations_params_and_responses() {
let d = doc();
let show = &d["paths"]["/todos/{id}"]["get"];
assert_eq!(show["operationId"], "show_todo");
assert_eq!(show["parameters"][0]["name"], "id");
assert_eq!(show["parameters"][0]["in"], "path");
assert_eq!(show["parameters"][0]["schema"]["type"], "integer");
assert!(
show["responses"]["200"]["content"]["application/json"]["schema"]["$ref"]
.as_str()
.unwrap()
.ends_with("Todo")
);
assert_eq!(show["responses"]["404"]["description"], "unknown id");
let list = &d["paths"]["/todos/"]["get"];
assert_eq!(
list["responses"]["200"]["content"]["application/json"]["schema"]["type"],
"array"
);
let create = &d["paths"]["/todos/"]["post"];
assert!(
create["requestBody"]["content"]["application/json"]["schema"]["$ref"]
.as_str()
.unwrap()
.ends_with("Todo")
);
assert!(create["responses"]["201"].is_object());
assert!(d["paths"]["/todos/comments/"]["get"].is_object());
}
#[test]
fn guarded_identity_fk_request_schema_omits_user_id() {
let d = document(
&serde_json::from_str::<Design>(crate::platform::genroute::tests::SERVER_FK).unwrap(),
);
let create = &d["paths"]["/collections/"]["post"];
assert_eq!(
create["requestBody"]["content"]["application/json"]["schema"]["$ref"],
"#/components/schemas/CollectionRequest"
);
let req = &d["components"]["schemas"]["CollectionRequest"];
assert!(req["properties"]["title"].is_object(), "{req}");
assert!(
req["properties"].get("user_id").is_none(),
"request schema must omit the server-owned fk: {req}"
);
let breq = &d["components"]["schemas"]["BookmarkRequest"];
assert_eq!(breq["properties"]["collection_id"]["type"], "integer");
assert!(
breq["required"]
.as_array()
.unwrap()
.iter()
.any(|v| v == "collection_id"),
"{breq}"
);
assert!(breq["properties"].get("user_id").is_none(), "{breq}");
let import = &d["paths"]["/collections/import"]["post"];
assert_eq!(
import["requestBody"]["content"]["application/json"]["schema"]["$ref"],
"#/components/schemas/Collection"
);
let entity = &d["components"]["schemas"]["Collection"];
assert!(entity["properties"]["title"].is_object(), "{entity}");
}
#[test]
fn defaulted_fields_omitted_from_request_schema() {
let d = document(
&serde_json::from_str::<Design>(crate::platform::genroute::tests::DEFAULTS).unwrap(),
);
let create = &d["paths"]["/subscribers/"]["post"];
assert_eq!(
create["requestBody"]["content"]["application/json"]["schema"]["$ref"],
"#/components/schemas/SubscriberRequest"
);
let req = &d["components"]["schemas"]["SubscriberRequest"];
assert!(req["properties"]["email"].is_object(), "{req}");
assert!(
req["properties"].get("confirmed").is_none()
&& req["properties"].get("status").is_none(),
"request schema must omit server-owned defaults: {req}"
);
let entity = &d["components"]["schemas"]["Subscriber"]["properties"];
assert!(entity["confirmed"].is_object() && entity["status"].is_object());
}
#[test]
fn nested_parent_fk_omitted_from_request_schema() {
let d = document(
&serde_json::from_str::<Design>(crate::platform::genroute::tests::NESTED_FK).unwrap(),
);
let create = &d["paths"]["/habits/{habit_id}/checkins"]["post"];
assert_eq!(
create["requestBody"]["content"]["application/json"]["schema"]["$ref"],
"#/components/schemas/CheckinRequest"
);
let req = &d["components"]["schemas"]["CheckinRequest"];
assert!(req["properties"]["note"].is_object(), "{req}");
assert!(
req["properties"].get("habit_id").is_none(),
"path-redundant fk must not be in the request schema: {req}"
);
assert!(
d["components"]["schemas"].get("HabitRequest").is_none(),
"a top-level create needs no Request schema: {}",
d["components"]["schemas"]
);
}
#[test]
fn memory_mode_request_schema_is_the_plain_entity_not_a_dto() {
const MEMORY_IDENTITY_FK: &str = r#"{
"name": "memnotes", "contract_version": 1,
"auth": { "model": "session", "roles": ["admin"] },
"dependencies": ["auth"],
"modules": [{
"name": "notes",
"entities": [
{ "name": "User", "fields": [{ "name": "email", "type": "string" }] },
{ "name": "Folder", "fields": [{ "name": "title", "type": "string" }] },
{ "name": "Note",
"belongs_to": [
{ "entity": "User", "on_delete": "cascade" },
{ "entity": "Folder", "on_delete": "cascade" }
],
"fields": [{ "name": "body", "type": "string" }] }
],
"endpoints": [
{ "operation_id": "create_note", "method": "POST", "path": "/",
"auth_required": true,
"request_body": { "entity": "Note" },
"success": { "status": 201, "entity": "Note" } }
]
}]
}"#;
let design: Design = serde_json::from_str(MEMORY_IDENTITY_FK).unwrap();
assert!(!design.wants_db(), "fixture must be memory mode");
let d = document(&design);
assert_eq!(
d["paths"]["/notes/"]["post"]["requestBody"]["content"]["application/json"]["schema"]["$ref"],
"#/components/schemas/Note",
"memory-mode request body is the plain entity: {}",
d["paths"]["/notes/"]["post"]
);
assert!(
d["components"]["schemas"].get("NoteRequest").is_none(),
"memory mode mints no request DTO component: {}",
d["components"]["schemas"]
);
}
#[test]
fn entity_schemas_map_field_types() {
let d = doc();
let todo = &d["components"]["schemas"]["Todo"]["properties"];
assert_eq!(todo["title"]["type"], "string");
assert_eq!(todo["done"]["type"], "boolean");
let required = d["components"]["schemas"]["Todo"]["required"]
.as_array()
.unwrap();
assert!(required.iter().any(|v| v == "title"));
assert!(
!required.iter().any(|v| v == "done"),
"optional fields are not required"
);
}
#[test]
fn tenancy_document_advertises_the_member_surface() {
let design: Design = serde_json::from_str(crate::platform::design::tests::V1_FULL).unwrap();
let d = document(&design);
let list = &d["paths"]["/workspaces/{workspace_id}/members"]["get"];
assert_eq!(
list["operationId"], "list_workspace_members",
"list op: {d}"
);
assert_eq!(list["security"], json!([{ "bearerAuth": [] }]));
let item = &list["responses"]["200"]["content"]["application/json"]["schema"]["items"];
assert_eq!(item["required"], json!(["id", "user_id", "role"]));
assert_eq!(
item["properties"]["role"]["enum"],
json!(["owner", "member"])
);
assert_eq!(list["parameters"][0]["name"], "workspace_id");
assert_eq!(list["parameters"][0]["schema"]["type"], "integer");
let add = &d["paths"]["/workspaces/{workspace_id}/members"]["post"];
assert_eq!(add["operationId"], "add_workspace_member");
let body = &add["requestBody"]["content"]["application/json"]["schema"];
assert_eq!(body["required"], json!(["user_id", "role"]));
assert!(add["responses"]["201"].is_object(), "add is a 201: {add}");
for status in ["403", "404", "409", "422"] {
assert!(
add["responses"][status].is_object(),
"add advertises {status}: {add}"
);
}
let set = &d["paths"]["/workspaces/{workspace_id}/members/{user_id}"]["patch"];
assert_eq!(set["operationId"], "set_workspace_member_role");
assert_eq!(set["parameters"][1]["name"], "user_id");
assert_eq!(set["parameters"][1]["schema"]["type"], "string");
assert!(set["responses"]["204"].is_object());
assert_eq!(
set["responses"]["409"]["description"], "cannot demote the last owner",
"the last-admin lockout is contract-visible: {set}"
);
let remove = &d["paths"]["/workspaces/{workspace_id}/members/{user_id}"]["delete"];
assert_eq!(remove["operationId"], "remove_workspace_member");
assert!(remove["responses"]["204"].is_object());
assert_eq!(
remove["responses"]["409"]["description"],
"cannot remove the last owner"
);
assert!(
remove["responses"]["403"]["description"]
.as_str()
.unwrap()
.contains("self-removal"),
"DELETE documents the self-removal exception: {remove}"
);
}
#[test]
fn non_tenancy_documents_have_no_member_paths() {
let d = doc();
assert!(
d["paths"]
.as_object()
.unwrap()
.keys()
.all(|k| !k.contains("/members")),
"no member paths without tenancy: {d}"
);
let design: Design = serde_json::from_str(crate::platform::design::tests::V1_FULL).unwrap();
let mut stripped = design.clone();
stripped.tenancy = None;
let with = document(&design);
let without = document(&stripped);
let with_paths = with["paths"].as_object().unwrap();
let without_paths = without["paths"].as_object().unwrap();
let extra: Vec<&str> = with_paths
.keys()
.filter(|k| !without_paths.contains_key(*k))
.map(String::as_str)
.collect();
assert_eq!(
extra,
vec![
"/workspaces/{workspace_id}/members",
"/workspaces/{workspace_id}/members/{user_id}"
],
"the ONLY path delta is the member surface"
);
for (k, v) in without_paths {
assert_eq!(
v, &with_paths[k],
"shared path `{k}` must be identical with/without tenancy"
);
}
}
#[test]
fn composite_unique_create_documents_a_409() {
const LIKES: &str = r#"{
"name": "likes-api", "contract_version": 1,
"dependencies": ["db"],
"modules": [{
"name": "engagement",
"entities": [
{ "name": "Post", "fields": [{ "name": "title", "type": "string" }] },
{ "name": "Like",
"belongs_to": [{ "entity": "Post" }],
"unique": [["post_id", "reaction"]],
"fields": [{ "name": "reaction", "type": "string" }] }
],
"endpoints": [
{ "operation_id": "create_post", "method": "POST", "path": "/posts",
"request_body": { "entity": "Post" },
"success": { "status": 201, "entity": "Post" } },
{ "operation_id": "create_like", "method": "POST", "path": "/likes",
"request_body": { "entity": "Like" },
"success": { "status": 201, "entity": "Like" } }
]
}]
}"#;
let d = document(&serde_json::from_str::<Design>(LIKES).unwrap());
let like = &d["paths"]["/engagement/likes"]["post"];
assert_eq!(
like["responses"]["409"]["description"],
"a row with the same (post_id, reaction) already exists",
"the composite-unique create must document a 409 naming the columns: {like}"
);
let post = &d["paths"]["/engagement/posts"]["post"];
assert!(
post["responses"].get("409").is_none(),
"a plain create carries no composite-unique 409: {post}"
);
}
}