use super::design::*;
use serde_json::{Value, json};
fn field_schema(t: FieldType) -> Value {
match t {
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!({}),
}
}
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()
&& 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 design.wants_db() && design.endpoint_uses_request_dto(m, ep, design.wants_auth()) {
let name = if ep.method.is_update() && design.entity_has_default(&rb.entity) {
format!("{}UpdateRequest", rb.entity)
} else {
format!("{}Request", rb.entity)
};
entity_ref(&name)
} else {
entity_ref(&rb.entity)
};
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 });
}
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.field_type));
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 == e.name)
});
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 == e.name)
});
if needs_update_schema {
schemas.insert(
format!("{}UpdateRequest", e.name),
request_schema(design, e, true),
);
}
}
for sub in &m.subroutes {
walk_schemas(design, sub, schemas);
}
}
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(&Design::fk_column(&b.entity))
}) {
let col = Design::fk_column(&b.entity);
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())
{
properties.insert(f.name.clone(), field_schema(f.field_type));
if f.required {
required.push(Value::String(f.name.clone()));
}
}
json!({ "type": "object", "properties": properties, "required": required })
}
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);
}
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 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"
);
}
}