use crate::{ColumnType, ServiceManifest, TableDefinition};
use serde_json::{json, Map, Value};
pub fn generate_openapi(manifest: &ServiceManifest, tenant_slug: &str) -> Value {
let mut paths = Map::new();
let mut schemas = Map::new();
for table in &manifest.tables {
let base_path = format!("/svc/{}/{}/{}", tenant_slug, manifest.name, table.name);
let item_path = format!("{}/:id", base_path);
let schema_name = to_pascal_case(&table.name);
let create_schema_name = format!("{}Create", schema_name);
schemas.insert(schema_name.clone(), table_to_schema(table));
schemas.insert(create_schema_name.clone(), table_to_create_schema(table));
paths.insert(
base_path.clone(),
json!({
"get": {
"summary": format!("List {}", table.name),
"operationId": format!("list_{}", table.name),
"tags": [table.name],
"parameters": list_parameters(table),
"responses": {
"200": {
"description": "Paginated list",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"results": {
"type": "array",
"items": { "$ref": format!("#/components/schemas/{}", schema_name) }
},
"pagination": { "$ref": "#/components/schemas/Pagination" }
}
}
}
}
}
}
},
"post": {
"summary": format!("Create {}", singular(&table.name)),
"operationId": format!("create_{}", singular(&table.name)),
"tags": [table.name],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": format!("#/components/schemas/{}", create_schema_name) }
}
}
},
"responses": {
"200": {
"description": "Created",
"content": {
"application/json": {
"schema": { "$ref": format!("#/components/schemas/{}", schema_name) }
}
}
}
}
}
}),
);
paths.insert(
item_path,
json!({
"get": {
"summary": format!("Get {} by ID", singular(&table.name)),
"operationId": format!("get_{}", singular(&table.name)),
"tags": [table.name],
"parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}],
"responses": {
"200": {
"description": "Found",
"content": {
"application/json": {
"schema": { "$ref": format!("#/components/schemas/{}", schema_name) }
}
}
},
"404": { "description": "Not found" }
}
},
"put": {
"summary": format!("Update {}", singular(&table.name)),
"operationId": format!("update_{}", singular(&table.name)),
"tags": [table.name],
"parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": format!("#/components/schemas/{}", create_schema_name) }
}
}
},
"responses": {
"200": {
"description": "Updated",
"content": {
"application/json": {
"schema": { "$ref": format!("#/components/schemas/{}", schema_name) }
}
}
}
}
},
"delete": {
"summary": format!("Delete {}", singular(&table.name)),
"operationId": format!("delete_{}", singular(&table.name)),
"tags": [table.name],
"parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}],
"responses": {
"200": { "description": "Deleted" }
}
}
}),
);
}
for route in &manifest.custom_routes {
let full_path = format!(
"/svc/{}/{}/_fn/{}",
tenant_slug, manifest.name, route.handler
);
let method = route.method.to_lowercase();
let operation_id = route.handler.clone();
let mut parameters = Vec::new();
for segment in route.path.split('/') {
if let Some(param) = segment.strip_prefix(':') {
parameters.push(json!({
"name": param,
"in": "path",
"required": true,
"schema": { "type": "string" }
}));
}
}
let mut operation = json!({
"summary": format!("Custom: {}", route.handler),
"operationId": operation_id,
"tags": ["custom"],
"parameters": parameters,
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": { "type": "object" }
}
}
}
}
});
if matches!(method.as_str(), "post" | "put" | "patch") {
operation["requestBody"] = json!({
"required": false,
"content": {
"application/json": {
"schema": { "type": "object" }
}
}
});
}
let path_entry = paths.entry(full_path).or_insert_with(|| json!({}));
if let Some(obj) = path_entry.as_object_mut() {
obj.insert(method, operation);
}
}
schemas.insert(
"Pagination".into(),
json!({
"type": "object",
"properties": {
"current_page": { "type": "integer" },
"per_page": { "type": "integer" },
"total_records": { "type": "integer" },
"total_pages": { "type": "integer" }
}
}),
);
json!({
"openapi": "3.1.0",
"info": {
"title": manifest.name,
"version": manifest.version.as_deref().unwrap_or("0.1.0")
},
"paths": Value::Object(paths),
"components": {
"schemas": Value::Object(schemas)
}
})
}
fn column_to_openapi_type(col_type: &ColumnType) -> Value {
match col_type {
ColumnType::Uuid => json!({"type": "string", "format": "uuid"}),
ColumnType::Text => json!({"type": "string"}),
ColumnType::Integer => json!({"type": "integer", "format": "int32"}),
ColumnType::BigInteger => json!({"type": "integer", "format": "int64"}),
ColumnType::Float => json!({"type": "number", "format": "float"}),
ColumnType::Double => json!({"type": "number", "format": "double"}),
ColumnType::Boolean => json!({"type": "boolean"}),
ColumnType::Timestamp => json!({"type": "string", "format": "date-time"}),
ColumnType::Date => json!({"type": "string", "format": "date"}),
ColumnType::Jsonb => json!({"type": "object"}),
}
}
fn table_to_schema(table: &TableDefinition) -> Value {
let mut properties = Map::new();
let mut required = Vec::new();
for col in &table.columns {
let mut prop = column_to_openapi_type(&col.column_type);
if col.nullable {
if let Some(obj) = prop.as_object_mut() {
obj.insert("nullable".into(), json!(true));
}
}
properties.insert(col.name.clone(), prop);
if !col.nullable {
required.push(json!(col.name));
}
}
json!({
"type": "object",
"properties": Value::Object(properties),
"required": required
})
}
fn table_to_create_schema(table: &TableDefinition) -> Value {
let mut properties = Map::new();
let mut required = Vec::new();
for col in &table.columns {
if col.auto_generate {
continue;
}
let mut prop = column_to_openapi_type(&col.column_type);
if col.nullable {
if let Some(obj) = prop.as_object_mut() {
obj.insert("nullable".into(), json!(true));
}
}
properties.insert(col.name.clone(), prop);
if !col.nullable && col.default_value.is_none() {
required.push(json!(col.name));
}
}
json!({
"type": "object",
"properties": Value::Object(properties),
"required": required
})
}
fn list_parameters(table: &TableDefinition) -> Vec<Value> {
let mut params = vec![
json!({"name": "page", "in": "query", "schema": {"type": "integer"}, "description": "Page number (default: 1)"}),
json!({"name": "per_page", "in": "query", "schema": {"type": "integer"}, "description": "Items per page (default: 50, max: 200)"}),
json!({"name": "sort", "in": "query", "schema": {"type": "string"}, "description": "Sort by column (prefix - for DESC)"}),
json!({"name": "filter", "in": "query", "schema": {"type": "string"}, "description": "JSON filter array"}),
];
for col in &table.columns {
params.push(json!({
"name": col.name,
"in": "query",
"schema": column_to_openapi_type(&col.column_type),
"description": format!("Filter by {}", col.name)
}));
}
params
}
fn to_pascal_case(s: &str) -> String {
s.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(c) => c.to_uppercase().to_string() + &chars.as_str().to_lowercase(),
None => String::new(),
}
})
.collect()
}
fn singular(s: &str) -> String {
if s.ends_with('s') && s.len() > 1 {
s[..s.len() - 1].to_string()
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
ColumnDefinition, ColumnType, CustomRouteDefinition, ServiceManifest, ServiceMode,
TableDefinition,
};
fn test_manifest() -> ServiceManifest {
ServiceManifest {
name: "test-service".into(),
version: Some("1.0.0".into()),
tables: vec![TableDefinition {
name: "todos".into(),
columns: vec![
ColumnDefinition {
name: "id".into(),
column_type: ColumnType::Uuid,
primary_key: true,
nullable: false,
auto_generate: true,
default_value: None,
references: None,
on_delete: None,
unique: false,
validations: vec![],
},
ColumnDefinition {
name: "title".into(),
column_type: ColumnType::Text,
primary_key: false,
nullable: false,
auto_generate: false,
default_value: None,
references: None,
on_delete: None,
unique: false,
validations: vec![],
},
ColumnDefinition {
name: "done".into(),
column_type: ColumnType::Boolean,
primary_key: false,
nullable: false,
auto_generate: false,
default_value: Some("false".into()),
references: None,
on_delete: None,
unique: false,
validations: vec![],
},
],
indexes: vec![],
soft_delete: false,
owner_field: None,
auth_required: false,
permission_area: None,
hooks: None,
}],
cells: vec![],
events: vec![],
subscriptions: vec![],
custom_routes: vec![],
mode: ServiceMode::Crud,
authorization: None,
on_migrate: None,
}
}
#[test]
fn test_generates_valid_openapi() {
let manifest = test_manifest();
let spec = generate_openapi(&manifest, "test-tenant");
assert_eq!(spec["openapi"], "3.1.0");
assert_eq!(spec["info"]["title"], "test-service");
assert_eq!(spec["info"]["version"], "1.0.0");
}
#[test]
fn test_generates_crud_paths() {
let manifest = test_manifest();
let spec = generate_openapi(&manifest, "acme");
let paths = spec["paths"].as_object().unwrap();
assert!(paths.contains_key("/svc/acme/test-service/todos"));
assert!(paths.contains_key("/svc/acme/test-service/todos/:id"));
}
#[test]
fn test_generates_schemas() {
let manifest = test_manifest();
let spec = generate_openapi(&manifest, "acme");
let schemas = spec["components"]["schemas"].as_object().unwrap();
assert!(schemas.contains_key("Todos"));
assert!(schemas.contains_key("TodosCreate"));
assert!(schemas.contains_key("Pagination"));
}
#[test]
fn test_create_schema_excludes_auto_generated() {
let manifest = test_manifest();
let spec = generate_openapi(&manifest, "acme");
let create_props = spec["components"]["schemas"]["TodosCreate"]["properties"]
.as_object()
.unwrap();
assert!(!create_props.contains_key("id"));
assert!(create_props.contains_key("title"));
}
#[test]
fn test_custom_routes() {
let mut manifest = test_manifest();
manifest.custom_routes.push(CustomRouteDefinition {
method: "POST".into(),
path: "/complete/:id".into(),
handler: "complete_todo".into(),
job: None,
});
let spec = generate_openapi(&manifest, "acme");
let paths = spec["paths"].as_object().unwrap();
assert!(paths.contains_key("/svc/acme/test-service/_fn/complete_todo"));
}
#[test]
fn test_default_version() {
let mut manifest = test_manifest();
manifest.version = None;
let spec = generate_openapi(&manifest, "acme");
assert_eq!(spec["info"]["version"], "0.1.0");
}
#[test]
fn test_local_slug() {
let manifest = test_manifest();
let spec = generate_openapi(&manifest, "local");
let paths = spec["paths"].as_object().unwrap();
assert!(paths.contains_key("/svc/local/test-service/todos"));
}
}