use anyhow::Result;
use gize_core::naming::table_name;
use gize_core::{FieldType, Manifest, ModelSpec};
use serde_json::{Value, json};
pub fn operations(manifest: &Manifest) -> Result<Vec<(String, String)>> {
let mut ops = Vec::new();
for module in &manifest.modules {
let table = &module.name;
ops.push(("get".into(), format!("/{table}")));
ops.push(("post".into(), format!("/{table}")));
ops.push(("get".into(), format!("/{table}/{{id}}")));
ops.push(("put".into(), format!("/{table}/{{id}}")));
ops.push(("delete".into(), format!("/{table}/{{id}}")));
if table == "users" {
ops.push(("post".into(), "/users/register".into()));
ops.push(("post".into(), "/users/login".into()));
}
}
Ok(ops)
}
pub fn spec_json(manifest: &Manifest) -> Result<Value> {
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!("Create{name}"), dto_schema(&model));
schemas.insert(format!("Update{name}"), dto_schema(&model));
paths.insert(format!("/{table}"), collection_path(&table, name));
paths.insert(format!("/{table}/{{id}}"), item_path(&table, name));
if table == "users" {
schemas.insert("LoginRequest".into(), login_request_schema());
schemas.insert("TokenResponse".into(), token_response_schema());
paths.insert("/users/register".into(), register_path());
paths.insert("/users/login".into(), login_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 schema_ref(name: &str) -> Value {
json!({ "$ref": format!("#/components/schemas/{name}") })
}
fn json_content(schema: Value) -> Value {
json!({ "content": { "application/json": { "schema": schema } } })
}
fn collection_path(table: &str, name: &str) -> Value {
json!({
"get": {
"tags": [table],
"summary": format!("List {table}"),
"responses": {
"200": json_content(json!({ "type": "array", "items": schema_ref(name) }))
}
},
"post": {
"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" }
}
}
})
}
fn item_path(table: &str, name: &str) -> Value {
let id_param = json!([{
"name": "id", "in": "path", "required": true,
"schema": { "type": "string", "format": "uuid" }
}]);
json!({
"get": {
"tags": [table],
"summary": format!("Get a {name}"),
"parameters": id_param,
"responses": {
"200": json_content(schema_ref(name)),
"404": { "description": "not found" }
}
},
"put": {
"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" }
}
},
"delete": {
"tags": [table],
"summary": format!("Delete a {name}"),
"security": [{ "bearerAuth": [] }],
"parameters": id_param,
"responses": {
"204": { "description": "deleted" },
"401": { "description": "unauthorized" },
"404": { "description": "not found" }
}
}
})
}
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" }
}
}
})
}
#[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());
}
#[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}"
);
}
}
}