use anyhow::Result;
use gize_core::naming::table_name;
use gize_core::{Api, FieldType, Manifest, ModelSpec};
use serde_json::{Value, json};
pub fn operations(manifest: &Manifest) -> Result<Vec<(String, String)>> {
let prefix = api_prefix(manifest);
let mut ops = Vec::new();
for module in &manifest.modules {
let table = &module.name;
ops.push(("get".into(), format!("{prefix}/{table}")));
ops.push(("post".into(), format!("{prefix}/{table}")));
ops.push(("get".into(), format!("{prefix}/{table}/{{id}}")));
ops.push(("put".into(), format!("{prefix}/{table}/{{id}}")));
ops.push(("delete".into(), format!("{prefix}/{table}/{{id}}")));
if table == "users" {
ops.push(("post".into(), format!("{prefix}/users/register")));
ops.push(("post".into(), format!("{prefix}/users/login")));
ops.push(("get".into(), format!("{prefix}/users/me")));
}
}
Ok(ops)
}
fn api_prefix(manifest: &Manifest) -> String {
manifest
.api
.as_ref()
.map(Api::mount_path)
.unwrap_or_default()
}
pub fn spec_json(manifest: &Manifest) -> Result<Value> {
let prefix = api_prefix(manifest);
let mut paths = serde_json::Map::new();
let mut schemas = serde_json::Map::new();
for module in &manifest.modules {
let model = module.model_spec()?;
let table = table_name(&model.name);
let name = &model.name;
let hidden: &[&str] = if table == "users" { &["password"] } else { &[] };
schemas.insert(name.clone(), model_response_schema(&model, hidden));
schemas.insert(format!("{name}Page"), page_schema(name));
schemas.insert(format!("Create{name}"), dto_schema(&model));
schemas.insert(format!("Update{name}"), dto_schema(&model));
let guard_reads = table == "users";
paths.insert(
format!("{prefix}/{table}"),
collection_path(&table, name, guard_reads),
);
paths.insert(
format!("{prefix}/{table}/{{id}}"),
item_path(&table, name, guard_reads),
);
if table == "users" {
schemas.insert("LoginRequest".into(), login_request_schema());
schemas.insert("TokenResponse".into(), token_response_schema());
paths.insert(format!("{prefix}/users/register"), register_path());
paths.insert(format!("{prefix}/users/login"), login_path());
paths.insert(format!("{prefix}/users/me"), me_path());
}
}
Ok(json!({
"openapi": "3.0.3",
"info": {
"title": format!("{} API", manifest.project.name),
"version": "0.1.0",
"description": "Generated by Gize (see ADR-010)."
},
"components": {
"securitySchemes": {
"bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT" }
},
"schemas": Value::Object(schemas)
},
"paths": Value::Object(paths)
}))
}
fn field_schema(ty: FieldType) -> Value {
match ty {
FieldType::String => json!({ "type": "string" }),
FieldType::Bool => json!({ "type": "boolean" }),
FieldType::I32 => json!({ "type": "integer", "format": "int32" }),
FieldType::I64 => json!({ "type": "integer", "format": "int64" }),
FieldType::F64 => json!({ "type": "number", "format": "double" }),
FieldType::Uuid => json!({ "type": "string", "format": "uuid" }),
FieldType::DateTime => json!({ "type": "string", "format": "date-time" }),
}
}
fn model_response_schema(model: &ModelSpec, hidden: &[&str]) -> Value {
let mut props = serde_json::Map::new();
props.insert("id".into(), field_schema(FieldType::Uuid));
for f in &model.fields {
if hidden.contains(&f.name.as_str()) {
continue;
}
props.insert(f.name.clone(), field_schema(f.ty));
}
props.insert("created_at".into(), field_schema(FieldType::DateTime));
props.insert("updated_at".into(), field_schema(FieldType::DateTime));
json!({ "type": "object", "properties": Value::Object(props) })
}
fn dto_schema(model: &ModelSpec) -> Value {
let mut props = serde_json::Map::new();
let mut required = Vec::new();
for f in &model.fields {
props.insert(f.name.clone(), field_schema(f.ty));
required.push(Value::String(f.name.clone()));
}
json!({ "type": "object", "properties": Value::Object(props), "required": required })
}
fn login_request_schema() -> Value {
json!({
"type": "object",
"properties": { "email": { "type": "string", "format": "email" }, "password": { "type": "string" } },
"required": ["email", "password"]
})
}
fn token_response_schema() -> Value {
json!({ "type": "object", "properties": { "token": { "type": "string" } }, "required": ["token"] })
}
fn page_schema(name: &str) -> Value {
json!({
"type": "object",
"required": ["items", "page", "per_page", "total"],
"properties": {
"items": { "type": "array", "items": schema_ref(name) },
"page": { "type": "integer", "format": "int32", "minimum": 1 },
"per_page": { "type": "integer", "format": "int32", "minimum": 1, "maximum": 100 },
"total": { "type": "integer", "format": "int64", "minimum": 0 }
}
})
}
fn page_params() -> Value {
json!([
{
"name": "page", "in": "query", "required": false,
"description": "1-based page number.",
"schema": { "type": "integer", "format": "int32", "minimum": 1, "default": 1 }
},
{
"name": "per_page", "in": "query", "required": false,
"description": "Items per page.",
"schema": {
"type": "integer", "format": "int32", "minimum": 1, "maximum": 100, "default": 25
}
}
])
}
fn schema_ref(name: &str) -> Value {
json!({ "$ref": format!("#/components/schemas/{name}") })
}
fn json_content(schema: Value) -> Value {
json!({ "content": { "application/json": { "schema": schema } } })
}
fn add_response(op: &mut Value, code: &str, description: &str) {
if let Some(responses) = op.get_mut("responses").and_then(|r| r.as_object_mut()) {
responses.insert(code.to_string(), json!({ "description": description }));
}
}
fn guard_read(op: &mut Value) {
op["security"] = json!([{ "bearerAuth": [] }]);
add_response(op, "401", "unauthorized");
add_response(op, "403", "forbidden");
}
fn collection_path(table: &str, name: &str, guard_reads: bool) -> Value {
let mut get_op = json!({
"tags": [table],
"summary": format!("List {table}"),
"parameters": page_params(),
"responses": {
"200": json_content(schema_ref(&format!("{name}Page"))),
"422": { "description": "invalid page or per_page" }
}
});
let mut post_op = json!({
"tags": [table],
"summary": format!("Create a {name}"),
"security": [{ "bearerAuth": [] }],
"requestBody": json_content(schema_ref(&format!("Create{name}"))),
"responses": {
"201": json_content(schema_ref(name)),
"401": { "description": "unauthorized" },
"409": { "description": "conflict" },
"422": { "description": "validation failed" }
}
});
if guard_reads {
guard_read(&mut get_op);
add_response(&mut post_op, "403", "forbidden");
}
json!({ "get": get_op, "post": post_op })
}
fn item_path(table: &str, name: &str, guard_reads: bool) -> Value {
let id_param = json!([{
"name": "id", "in": "path", "required": true,
"schema": { "type": "string", "format": "uuid" }
}]);
let mut get_op = json!({
"tags": [table],
"summary": format!("Get a {name}"),
"parameters": id_param,
"responses": {
"200": json_content(schema_ref(name)),
"404": { "description": "not found" }
}
});
let mut put_op = json!({
"tags": [table],
"summary": format!("Update a {name}"),
"security": [{ "bearerAuth": [] }],
"parameters": id_param,
"requestBody": json_content(schema_ref(&format!("Update{name}"))),
"responses": {
"200": json_content(schema_ref(name)),
"401": { "description": "unauthorized" },
"404": { "description": "not found" },
"409": { "description": "conflict" },
"422": { "description": "validation failed" }
}
});
let mut delete_op = json!({
"tags": [table],
"summary": format!("Delete a {name}"),
"security": [{ "bearerAuth": [] }],
"parameters": id_param,
"responses": {
"204": { "description": "deleted" },
"401": { "description": "unauthorized" },
"404": { "description": "not found" }
}
});
if guard_reads {
guard_read(&mut get_op);
add_response(&mut put_op, "403", "forbidden");
add_response(&mut delete_op, "403", "forbidden");
}
json!({ "get": get_op, "put": put_op, "delete": delete_op })
}
fn register_path() -> Value {
json!({
"post": {
"tags": ["users"],
"summary": "Register a new user and receive a token",
"requestBody": json_content(schema_ref("CreateUser")),
"responses": {
"201": json_content(schema_ref("TokenResponse")),
"409": { "description": "conflict" },
"422": { "description": "validation failed" }
}
}
})
}
fn login_path() -> Value {
json!({
"post": {
"tags": ["users"],
"summary": "Exchange credentials for a token",
"requestBody": json_content(schema_ref("LoginRequest")),
"responses": {
"200": json_content(schema_ref("TokenResponse")),
"401": { "description": "invalid credentials" },
"422": { "description": "validation failed" }
}
}
})
}
fn me_path() -> Value {
json!({
"get": {
"tags": ["users"],
"summary": "Get the authenticated user's own record",
"security": [{ "bearerAuth": [] }],
"responses": {
"200": json_content(schema_ref("User")),
"401": { "description": "unauthorized" },
"404": { "description": "not found" }
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use gize_core::{Module, Relation};
fn blog_manifest() -> Manifest {
let mut m = Manifest::new("blog");
m.upsert_module(Module {
name: "users".to_string(),
fields: vec![
"name:String".to_string(),
"email:String".to_string(),
"password:String".to_string(),
"is_admin:bool".to_string(),
],
belongs_to: vec![],
});
m.upsert_module(Module {
name: "posts".to_string(),
fields: vec!["title:String".to_string()],
belongs_to: vec![Relation {
field: "author".to_string(),
target: "users".to_string(),
}],
});
m
}
#[test]
fn emits_crud_and_auth_paths() {
let spec = spec_json(&blog_manifest()).unwrap();
let paths = spec["paths"].as_object().unwrap();
for p in [
"/users",
"/users/{id}",
"/users/register",
"/users/login",
"/posts",
"/posts/{id}",
] {
assert!(paths.contains_key(p), "missing path {p}");
}
assert!(paths["/posts"]["post"].get("security").is_some());
assert!(paths["/posts"]["get"].get("security").is_none());
assert!(paths["/users"]["get"].get("security").is_some());
assert!(paths["/users/{id}"]["get"].get("security").is_some());
assert!(paths["/users"]["get"]["responses"].get("403").is_some());
assert!(paths["/users"]["post"]["responses"].get("403").is_some());
assert!(
paths["/users/{id}"]["delete"]["responses"]
.get("403")
.is_some()
);
assert!(paths["/users/register"]["post"].get("security").is_none());
assert!(paths["/users/login"]["post"].get("security").is_none());
assert!(paths.contains_key("/users/me"), "missing path /users/me");
assert!(paths["/users/me"]["get"].get("security").is_some());
assert_eq!(
paths["/users/me"]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
["$ref"],
"#/components/schemas/User"
);
}
#[test]
fn list_routes_document_the_page_envelope() {
let spec = spec_json(&blog_manifest()).unwrap();
let list = &spec["paths"]["/posts"]["get"];
let names: Vec<_> = list["parameters"]
.as_array()
.unwrap()
.iter()
.map(|p| p["name"].as_str().unwrap())
.collect();
assert_eq!(names, ["page", "per_page"]);
assert_eq!(list["parameters"][1]["schema"]["maximum"], 100);
assert_eq!(
list["responses"]["200"]["content"]["application/json"]["schema"]["$ref"],
"#/components/schemas/PostPage"
);
assert!(list["responses"].get("422").is_some());
let page = &spec["components"]["schemas"]["PostPage"];
assert_eq!(
page["properties"]["items"]["items"]["$ref"],
"#/components/schemas/Post"
);
assert!(spec["components"]["schemas"].get("UserPage").is_some());
}
#[test]
fn user_response_hides_password_but_dto_keeps_it() {
let spec = spec_json(&blog_manifest()).unwrap();
let schemas = &spec["components"]["schemas"];
assert!(schemas["User"]["properties"].get("password").is_none());
assert!(schemas["User"]["properties"].get("is_admin").is_some());
assert!(
schemas["CreateUser"]["properties"]
.get("password")
.is_some()
);
}
#[test]
fn relationship_fk_is_in_the_schema() {
let spec = spec_json(&blog_manifest()).unwrap();
let post = &spec["components"]["schemas"]["Post"]["properties"];
assert!(
post.get("author_id").is_some(),
"FK column should appear in the schema"
);
assert_eq!(post["author_id"]["format"], "uuid");
}
#[test]
fn operations_match_paths() {
let m = blog_manifest();
let ops = operations(&m).unwrap();
let spec = spec_json(&m).unwrap();
let paths = spec["paths"].as_object().unwrap();
for (method, path) in ops {
assert!(
paths.get(&path).and_then(|p| p.get(&method)).is_some(),
"spec missing {method} {path}"
);
}
}
}