gize-openapi 0.7.0

OpenAPI spec generation for Gize (Beta phase; see ADR-010).
Documentation
//! OpenAPI spec generation for Gize (ADR-010).
//!
//! The spec is generated from the **manifest** (`gize.toml`) — the same source of truth the
//! CRUD, auth and admin generators use — so it matches the generated routes *by construction*
//! rather than by hand-kept annotations. Output is an OpenAPI 3.0.3 document as
//! `serde_json::Value`; the CLI writes it to `openapi.json` and the app serves it.

use anyhow::Result;
use gize_core::naming::table_name;
use gize_core::{FieldType, Manifest, ModelSpec};
use serde_json::{Value, json};

/// The set of `(method, path)` operations Gize generates for a module. Used both to build the
/// spec and by the parity test that checks the spec against the router.
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)
}

/// Render the full OpenAPI document for a project from its manifest.
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;

        // Component schemas: the response model plus the Create/Update request bodies.
        // `users` hides `password` in responses (it holds a hash; see the generated model).
        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)
    }))
}

/// The OpenAPI schema fragment for a scalar field type.
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" }),
    }
}

/// The response schema for a model: `id`, its (visible) fields, and timestamps.
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) })
}

/// The request-body schema for a Create/Update DTO: every declared field, all required.
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 } } })
}

/// `/<table>` — list (public) and create (guarded).
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" }
            }
        }
    })
}

/// `/<table>/{id}` — show (public), update and delete (guarded).
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}");
        }
        // Write ops are guarded, reads are not.
        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}"
            );
        }
    }
}