use crate::case::to_camel_case;
use crate::config::{load_from_pool, resolve, KvStoreConfig, ResolvedEntity, ResolvedModel};
use crate::state::AppState;
use crate::store::list_package_ids;
use axum::extract::State;
use axum::Json;
use std::collections::HashMap;
use utoipa::openapi::path::{
HttpMethod, Operation, OperationBuilder, Parameter, ParameterBuilder, ParameterIn,
PathItemBuilder, PathsBuilder,
};
use utoipa::openapi::request_body::RequestBodyBuilder;
use utoipa::openapi::response::{Response, ResponsesBuilder};
use utoipa::openapi::schema::{ObjectBuilder, Schema, SchemaType, Type};
use utoipa::openapi::server::{ServerBuilder, ServerVariableBuilder};
use utoipa::openapi::{Content, Info, OpenApi, OpenApiBuilder, RefOr, Required};
fn build_server() -> utoipa::openapi::server::Server {
ServerBuilder::new()
.url("http://{host}:{port}")
.parameter(
"host",
ServerVariableBuilder::new()
.default_value("localhost")
.description(Some("API host")),
)
.parameter(
"port",
ServerVariableBuilder::new()
.default_value("3000")
.description(Some("API port")),
)
.build()
}
fn json_object_schema() -> Schema {
Schema::Object(
ObjectBuilder::new()
.schema_type(SchemaType::new(Type::Object))
.description(Some(
"JSON object; keys may be in camelCase (e.g. entity fields).",
))
.into(),
)
}
fn column_schema_from_pg_type(pg_type: Option<&str>) -> Schema {
let t = pg_type.unwrap_or("").to_lowercase();
if t.ends_with("[]") || t.starts_with('_') {
let element_type = t.trim_end_matches("[]").trim_start_matches('_');
let item_schema = column_schema_from_pg_type(Some(element_type));
return Schema::Array(
utoipa::openapi::schema::ArrayBuilder::new()
.items(RefOr::T(item_schema))
.build(),
);
}
if t.contains("int") || t.contains("serial") {
return Schema::Object(
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(SchemaType::new(Type::Integer))
.into(),
);
}
if t.contains("bool") {
return Schema::Object(
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(SchemaType::new(Type::Boolean))
.into(),
);
}
if t.contains("uuid") {
return Schema::Object(
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(SchemaType::new(Type::String))
.format(Some(utoipa::openapi::schema::SchemaFormat::KnownFormat(
utoipa::openapi::schema::KnownFormat::Uuid,
)))
.into(),
);
}
if t.contains("numeric")
|| t.contains("decimal")
|| t.contains("real")
|| t.contains("double")
|| t.contains("float")
{
return Schema::Object(
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(SchemaType::new(Type::Number))
.into(),
);
}
if t.contains("timestamp") || t.contains("date") {
return Schema::Object(
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(SchemaType::new(Type::String))
.format(Some(utoipa::openapi::schema::SchemaFormat::KnownFormat(
utoipa::openapi::schema::KnownFormat::DateTime,
)))
.into(),
);
}
Schema::Object(
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(SchemaType::new(Type::String))
.into(),
)
}
fn entity_body_schema(entity: &ResolvedEntity, for_create: bool) -> Schema {
let mut builder = utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(SchemaType::new(Type::Object))
.description(Some(format!(
"Fields from architect._sys_columns for table {} (API uses camelCase).",
entity.table_id
)));
let mut required = Vec::new();
for col in &entity.columns {
if entity.sensitive_columns.contains(&col.name) {
continue;
}
let camel = to_camel_case(&col.name);
let prop_schema = column_schema_from_pg_type(col.pg_type.as_deref());
builder = builder.property(camel.clone(), RefOr::T(prop_schema));
if for_create && !col.nullable && !col.has_default {
required.push(camel);
}
}
for r in &required {
builder = builder.required(r.clone());
}
Schema::Object(builder.into())
}
fn default_responses() -> ResponsesBuilder {
ResponsesBuilder::new()
.response("200", Response::new("OK"))
.response("201", Response::new("Created"))
.response("204", Response::new("No Content"))
.response("400", Response::new("Bad Request"))
.response("404", Response::new("Not Found"))
}
fn x_tenant_id_header() -> Parameter {
ParameterBuilder::new()
.name("X-Tenant-ID")
.parameter_in(ParameterIn::Header)
.required(Required::True)
.description(Some(
"Tenant id; must match a tenant in architect._sys_tenants (e.g. default-mode-1, default-mode-3).",
))
.schema(Some(RefOr::T(Schema::Object(
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(SchemaType::new(Type::String))
.into(),
))))
.build()
}
fn package_id_param() -> Parameter {
ParameterBuilder::new()
.name("packageId")
.parameter_in(ParameterIn::Path)
.required(Required::True)
.description(Some("Package id from architect._sys_packages."))
.schema(Some(RefOr::T(Schema::Object(
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(SchemaType::new(Type::String))
.into(),
))))
.build()
}
fn list_operation(
entity: &ResolvedEntity,
op_suffix: &str,
include_package_id_param: bool,
) -> Operation {
let mut params = vec![x_tenant_id_header()];
if include_package_id_param {
params.push(package_id_param());
}
params.extend(vec![
ParameterBuilder::new()
.name("limit")
.parameter_in(ParameterIn::Query)
.required(Required::False)
.description(Some("Max number of items to return"))
.schema(Some(RefOr::T(Schema::Object(
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(SchemaType::new(Type::Integer))
.into(),
))))
.build(),
ParameterBuilder::new()
.name("offset")
.parameter_in(ParameterIn::Query)
.required(Required::False)
.description(Some("Number of items to skip"))
.schema(Some(RefOr::T(Schema::Object(
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(SchemaType::new(Type::Integer))
.into(),
))))
.build(),
ParameterBuilder::new()
.name("include")
.parameter_in(ParameterIn::Query)
.required(Required::False)
.description(Some(
"Comma-separated related entity path segments to include",
))
.schema(Some(RefOr::T(Schema::Object(
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(SchemaType::new(Type::String))
.into(),
))))
.build(),
]);
for col in &entity.columns {
if entity.sensitive_columns.contains(&col.name) {
continue;
}
let camel = to_camel_case(&col.name);
let schema = column_schema_from_pg_type(col.pg_type.as_deref());
params.push(
ParameterBuilder::new()
.name(camel)
.parameter_in(ParameterIn::Query)
.required(Required::False)
.description(Some(format!("Filter by {} (from _sys_columns)", col.name)))
.schema(Some(RefOr::T(schema)))
.build(),
);
}
OperationBuilder::new()
.summary(Some(format!("List {}", entity.path_segment)))
.description(Some(format!(
"List {} with optional filters, pagination (limit, offset), and includes.",
entity.path_segment
)))
.operation_id(Some(format!("list_{}{}", entity.path_segment, op_suffix)))
.parameters(Some(params))
.responses(default_responses().build())
.build()
}
fn create_operation(
entity: &ResolvedEntity,
op_suffix: &str,
include_package_id_param: bool,
) -> Operation {
let mut params = vec![x_tenant_id_header()];
if include_package_id_param {
params.push(package_id_param());
}
let body = RequestBodyBuilder::new()
.description(Some(format!(
"JSON object with {} fields from _sys_columns (camelCase). PK may be omitted if DB default exists.",
entity.path_segment
)))
.content(
"application/json",
Content::new(Some(RefOr::T(entity_body_schema(entity, true)))),
)
.required(Some(Required::True))
.build();
OperationBuilder::new()
.summary(Some(format!("Create {}", entity.path_segment)))
.description(Some(format!("Create a single {}", entity.path_segment)))
.operation_id(Some(format!("create_{}{}", entity.path_segment, op_suffix)))
.parameters(Some(params))
.request_body(Some(body))
.responses(
ResponsesBuilder::new()
.response("201", Response::new("Created"))
.response("400", Response::new("Bad Request"))
.build(),
)
.build()
}
fn read_operation(
entity: &ResolvedEntity,
op_suffix: &str,
include_package_id_param: bool,
) -> Operation {
let mut params = vec![x_tenant_id_header()];
if include_package_id_param {
params.push(package_id_param());
}
let id_param = ParameterBuilder::new()
.name("id")
.parameter_in(ParameterIn::Path)
.required(Required::True)
.description(Some(
"Entity ID (UUID, integer, or text depending on table PK)",
))
.schema(Some(RefOr::T(Schema::Object(
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(SchemaType::new(Type::String))
.into(),
))))
.build();
let include_param = ParameterBuilder::new()
.name("include")
.parameter_in(ParameterIn::Query)
.required(Required::False)
.description(Some(
"Comma-separated related entity path segments to include",
))
.schema(Some(RefOr::T(Schema::Object(
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(SchemaType::new(Type::String))
.into(),
))))
.build();
params.push(id_param);
params.push(include_param);
OperationBuilder::new()
.summary(Some(format!("Get {} by id", entity.path_segment)))
.description(Some(format!("Get a single {} by id.", entity.path_segment)))
.operation_id(Some(format!("read_{}{}", entity.path_segment, op_suffix)))
.parameters(Some(params))
.responses(default_responses().build())
.build()
}
fn update_operation(
entity: &ResolvedEntity,
op_suffix: &str,
include_package_id_param: bool,
) -> Operation {
let mut params = vec![x_tenant_id_header()];
if include_package_id_param {
params.push(package_id_param());
}
let id_param = ParameterBuilder::new()
.name("id")
.parameter_in(ParameterIn::Path)
.required(Required::True)
.description(Some("Entity ID"))
.schema(Some(RefOr::T(Schema::Object(
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(SchemaType::new(Type::String))
.into(),
))))
.build();
params.push(id_param);
let body = RequestBodyBuilder::new()
.description(Some(
"JSON object with fields from _sys_columns to update (camelCase, partial).",
))
.content(
"application/json",
Content::new(Some(RefOr::T(entity_body_schema(entity, false)))),
)
.required(Some(Required::True))
.build();
OperationBuilder::new()
.summary(Some(format!("Update {} by id", entity.path_segment)))
.description(Some(format!(
"Update a single {} by id.",
entity.path_segment
)))
.operation_id(Some(format!("update_{}{}", entity.path_segment, op_suffix)))
.parameters(Some(params))
.request_body(Some(body))
.responses(default_responses().build())
.build()
}
fn delete_operation(
entity: &ResolvedEntity,
op_suffix: &str,
include_package_id_param: bool,
) -> Operation {
let mut params = vec![x_tenant_id_header()];
if include_package_id_param {
params.push(package_id_param());
}
let id_param = ParameterBuilder::new()
.name("id")
.parameter_in(ParameterIn::Path)
.required(Required::True)
.description(Some("Entity ID"))
.schema(Some(RefOr::T(Schema::Object(
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(SchemaType::new(Type::String))
.into(),
))))
.build();
params.push(id_param);
OperationBuilder::new()
.summary(Some(format!("Delete {} by id", entity.path_segment)))
.description(Some(format!(
"Delete a single {} by id.",
entity.path_segment
)))
.operation_id(Some(format!("delete_{}{}", entity.path_segment, op_suffix)))
.parameters(Some(params))
.responses(
ResponsesBuilder::new()
.response("204", Response::new("No Content"))
.response("400", Response::new("Bad Request"))
.response("404", Response::new("Not Found"))
.build(),
)
.build()
}
fn bulk_create_operation(
entity: &ResolvedEntity,
op_suffix: &str,
include_package_id_param: bool,
) -> Operation {
let mut params = vec![x_tenant_id_header()];
if include_package_id_param {
params.push(package_id_param());
}
let item_schema = entity_body_schema(entity, true);
let body = RequestBodyBuilder::new()
.description(Some(
"JSON array of objects; each has shape from _sys_columns (same as create body).",
))
.content(
"application/json",
Content::new(Some(RefOr::T(Schema::Array(
utoipa::openapi::schema::ArrayBuilder::new()
.items(RefOr::T(item_schema))
.build(),
)))),
)
.required(Some(Required::True))
.build();
OperationBuilder::new()
.summary(Some(format!("Bulk create {}", entity.path_segment)))
.description(Some(format!("Create multiple {}.", entity.path_segment)))
.operation_id(Some(format!(
"bulk_create_{}{}",
entity.path_segment, op_suffix
)))
.parameters(Some(params))
.request_body(Some(body))
.responses(
ResponsesBuilder::new()
.response("201", Response::new("Created"))
.response("400", Response::new("Bad Request"))
.build(),
)
.build()
}
fn bulk_update_operation(
entity: &ResolvedEntity,
op_suffix: &str,
include_package_id_param: bool,
) -> Operation {
let mut params = vec![x_tenant_id_header()];
if include_package_id_param {
params.push(package_id_param());
}
let item_schema = entity_body_schema(entity, false);
let body = RequestBodyBuilder::new()
.description(Some(
"JSON array of objects; each must include id and fields from _sys_columns to update (camelCase, partial).",
))
.content(
"application/json",
Content::new(Some(RefOr::T(Schema::Array(
utoipa::openapi::schema::ArrayBuilder::new()
.items(RefOr::T(item_schema))
.build(),
)))),
)
.required(Some(Required::True))
.build();
OperationBuilder::new()
.summary(Some(format!("Bulk update {}", entity.path_segment)))
.description(Some(format!("Update multiple {}.", entity.path_segment)))
.operation_id(Some(format!(
"bulk_update_{}{}",
entity.path_segment, op_suffix
)))
.parameters(Some(params))
.request_body(Some(body))
.responses(default_responses().build())
.build()
}
fn add_entity_paths(
mut builder: PathsBuilder,
base: &str,
model: &ResolvedModel,
use_package_param: bool,
package_id_literal: Option<&str>,
) -> PathsBuilder {
let path_prefix = if use_package_param {
match package_id_literal {
Some(pkg) => format!("{}/package/{}", base, pkg),
None => format!("{}/package/{{packageId}}", base),
}
} else {
base.to_string()
};
let op_suffix = if use_package_param { "_package" } else { "" };
for entity in &model.entities {
let seg = &entity.path_segment;
let list_path = format!("{}/{}", path_prefix, seg);
let by_id_path = format!("{}/{}/{{id}}", path_prefix, seg);
let bulk_path = format!("{}/{}/bulk", path_prefix, seg);
let has_list = entity.operations.iter().any(|o| o == "read");
let has_create = entity.operations.iter().any(|o| o == "create");
if has_list || has_create {
let mut list_item = PathItemBuilder::new();
if has_list {
list_item = list_item.operation(
HttpMethod::Get,
list_operation(entity, op_suffix, use_package_param),
);
}
if has_create {
list_item = list_item.operation(
HttpMethod::Post,
create_operation(entity, op_suffix, use_package_param),
);
}
builder = builder.path(list_path, list_item.build());
}
let has_read = entity.operations.iter().any(|o| o == "read");
let has_update = entity.operations.iter().any(|o| o == "update");
let has_delete = entity.operations.iter().any(|o| o == "delete");
if has_read || has_update || has_delete {
let mut by_id_item = PathItemBuilder::new();
if has_read {
by_id_item = by_id_item.operation(
HttpMethod::Get,
read_operation(entity, op_suffix, use_package_param),
);
}
if has_update {
by_id_item = by_id_item.operation(
HttpMethod::Patch,
update_operation(entity, op_suffix, use_package_param),
);
}
if has_delete {
by_id_item = by_id_item.operation(
HttpMethod::Delete,
delete_operation(entity, op_suffix, use_package_param),
);
}
builder = builder.path(by_id_path, by_id_item.build());
}
let has_bulk_create = entity.operations.iter().any(|o| o == "bulk_create");
let has_bulk_update = entity.operations.iter().any(|o| o == "bulk_update");
if has_bulk_create || has_bulk_update {
let mut bulk_item = PathItemBuilder::new();
if has_bulk_create {
bulk_item = bulk_item.operation(
HttpMethod::Post,
bulk_create_operation(entity, op_suffix, use_package_param),
);
}
if has_bulk_update {
bulk_item = bulk_item.operation(
HttpMethod::Patch,
bulk_update_operation(entity, op_suffix, use_package_param),
);
}
builder = builder.path(bulk_path, bulk_item.build());
}
if !use_package_param && !entity.extensible_columns.is_empty() {
let (xf_get, xf_put, xf_delete) = extensible_fields_operations(entity);
builder = builder.path(
format!("{}/{}/extensible-fields", path_prefix, seg),
PathItemBuilder::new()
.operation(HttpMethod::Get, xf_get)
.operation(HttpMethod::Put, xf_put)
.operation(HttpMethod::Delete, xf_delete)
.build(),
);
let (idx_get, idx_post) = extensible_indexes_operations(entity);
builder = builder.path(
format!("{}/{}/extensible-fields/indexes", path_prefix, seg),
PathItemBuilder::new()
.operation(HttpMethod::Get, idx_get)
.operation(HttpMethod::Post, idx_post)
.build(),
);
}
}
builder
}
fn extensible_fields_operations(entity: &ResolvedEntity) -> (Operation, Operation, Operation) {
let seg = &entity.path_segment;
let get = OperationBuilder::new()
.summary(Some("Get extensible-field registry"))
.description(Some(
"Return the tenant's extensible-field registry document for this entity (or {} when unset).",
))
.operation_id(Some(format!("get_extensible_fields_{}", seg)))
.parameters(Some(vec![x_tenant_id_header()]))
.responses(default_responses().build())
.build();
let put = OperationBuilder::new()
.summary(Some("Replace extensible-field registry"))
.description(Some(
"Validate and replace the tenant's registry. Body maps each extensible column to its field definitions, e.g. {\"attributes\":[{\"key\":\"warrantyMonths\",\"type\":\"int\",\"filterable\":true,\"sortable\":true}]}.",
))
.operation_id(Some(format!("put_extensible_fields_{}", seg)))
.parameters(Some(vec![x_tenant_id_header()]))
.request_body(Some(
RequestBodyBuilder::new()
.description(Some("Registry document: { \"<column>\": [ field definitions ] }"))
.content(
"application/json",
Content::new(Some(RefOr::T(Schema::Object(
ObjectBuilder::new().schema_type(SchemaType::new(Type::Object)).into(),
)))),
)
.required(Some(Required::True))
.build(),
))
.responses(default_responses().build())
.build();
let delete = OperationBuilder::new()
.summary(Some("Clear extensible-field registry"))
.description(Some(
"Delete the tenant's registry document for this entity.",
))
.operation_id(Some(format!("delete_extensible_fields_{}", seg)))
.parameters(Some(vec![x_tenant_id_header()]))
.responses(default_responses().build())
.build();
(get, put, delete)
}
fn extensible_indexes_operations(entity: &ResolvedEntity) -> (Operation, Operation) {
let seg = &entity.path_segment;
let get = OperationBuilder::new()
.summary(Some("Suggested indexes for extensible fields"))
.description(Some(
"Return CREATE INDEX statements for the tenant's filterable/sortable extensible fields. Review before applying (large-table DDL is heavy).",
))
.operation_id(Some(format!("get_extensible_field_indexes_{}", seg)))
.parameters(Some(vec![x_tenant_id_header()]))
.responses(default_responses().build())
.build();
let post = OperationBuilder::new()
.summary(Some("Apply extensible-field indexes"))
.description(Some(
"Apply the suggested indexes to the tenant's data table. Best-effort and idempotent; returns applied statements and any errors.",
))
.operation_id(Some(format!("apply_extensible_field_indexes_{}", seg)))
.parameters(Some(vec![x_tenant_id_header()]))
.responses(default_responses().build())
.build();
(get, post)
}
fn kv_namespace_param() -> Parameter {
ParameterBuilder::new()
.name("namespace")
.parameter_in(ParameterIn::Path)
.required(Required::True)
.description(Some("KV store namespace (from _sys_kv_stores)."))
.schema(Some(RefOr::T(Schema::Object(
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(SchemaType::new(Type::String))
.into(),
))))
.build()
}
fn kv_list_keys_operation() -> Operation {
OperationBuilder::new()
.summary(Some("List KV keys in namespace"))
.description(Some(
"List all keys and values in the given package and namespace.",
))
.operation_id(Some("kv_list_keys"))
.parameters(Some(vec![
x_tenant_id_header(),
package_id_param(),
kv_namespace_param(),
]))
.responses(default_responses().build())
.build()
}
fn kv_key_param() -> Parameter {
ParameterBuilder::new()
.name("key")
.parameter_in(ParameterIn::Path)
.required(Required::True)
.description(Some("KV key"))
.schema(Some(RefOr::T(Schema::Object(
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(SchemaType::new(Type::String))
.into(),
))))
.build()
}
fn kv_key_operations() -> (Operation, Operation, Operation) {
let get_op = OperationBuilder::new()
.summary(Some("Get KV value by key"))
.description(Some("Get value for key in package and namespace."))
.operation_id(Some("kv_get"))
.parameters(Some(vec![
x_tenant_id_header(),
package_id_param(),
kv_namespace_param(),
kv_key_param(),
]))
.responses(default_responses().build())
.build();
let put_op = OperationBuilder::new()
.summary(Some("Set KV value (upsert)"))
.description(Some(
"Set or overwrite value for key. Body is arbitrary JSON.",
))
.operation_id(Some("kv_put"))
.parameters(Some(vec![
x_tenant_id_header(),
package_id_param(),
kv_namespace_param(),
kv_key_param(),
]))
.request_body(Some(
RequestBodyBuilder::new()
.description(Some("JSON value (string, number, object, or array)"))
.content(
"application/json",
Content::new(Some(RefOr::T(json_object_schema()))),
)
.required(Some(Required::True))
.build(),
))
.responses(
ResponsesBuilder::new()
.response("200", Response::new("OK"))
.response("400", Response::new("Bad Request"))
.build(),
)
.build();
let delete_op = OperationBuilder::new()
.summary(Some("Delete KV key"))
.description(Some("Delete key. Returns 204 No Content."))
.operation_id(Some("kv_delete"))
.parameters(Some(vec![
x_tenant_id_header(),
package_id_param(),
kv_namespace_param(),
kv_key_param(),
]))
.responses(
ResponsesBuilder::new()
.response("204", Response::new("No Content"))
.response("404", Response::new("Not Found"))
.build(),
)
.build();
(get_op, put_op, delete_op)
}
fn add_kv_paths(
mut builder: PathsBuilder,
base: &str,
package_kv_stores: &HashMap<String, Vec<KvStoreConfig>>,
) -> PathsBuilder {
for (package_id, stores) in package_kv_stores {
if stores.is_empty() {
continue;
}
let list_path = format!("{}/package/{}/kv/{{namespace}}", base, package_id);
let key_path = format!("{}/package/{}/kv/{{namespace}}/{{key}}", base, package_id);
let list_item = PathItemBuilder::new().operation(HttpMethod::Get, kv_list_keys_operation());
builder = builder.path(list_path, list_item.build());
let (get_op, put_op, delete_op) = kv_key_operations();
let key_item = PathItemBuilder::new()
.operation(HttpMethod::Get, get_op)
.operation(HttpMethod::Put, put_op)
.operation(HttpMethod::Delete, delete_op);
builder = builder.path(key_path, key_item.build());
}
builder
}
fn add_config_paths(mut builder: PathsBuilder, base: &str) -> PathsBuilder {
let install_path = format!("{}/config/package", base);
let install_op = OperationBuilder::new()
.summary(Some("Install package"))
.description(Some(
"Upload a package zip. Zip must contain manifest.json (id, name, version, schema) at root and config JSON files. Use multipart/form-data with field 'file' or 'package' (ZIP file).",
))
.operation_id(Some("config_install_package"))
.parameters(Some(vec![x_tenant_id_header()]))
.request_body(Some(
RequestBodyBuilder::new()
.description(Some("Multipart form with 'file' or 'package' field containing the ZIP."))
.content(
"multipart/form-data",
Content::new(Some(RefOr::T(Schema::Object(
ObjectBuilder::new()
.schema_type(SchemaType::new(Type::Object))
.property(
"file",
Schema::Object(
ObjectBuilder::new()
.schema_type(SchemaType::new(Type::String))
.format(Some(utoipa::openapi::schema::SchemaFormat::KnownFormat(
utoipa::openapi::schema::KnownFormat::Binary,
)))
.description(Some("ZIP file (manifest.json + config JSONs)"))
.into(),
),
)
.into(),
)))),
)
.required(Some(Required::True))
.build(),
))
.responses(
ResponsesBuilder::new()
.response("200", Response::new("OK"))
.response("400", Response::new("Bad Request"))
.build(),
)
.build();
let install_item = PathItemBuilder::new().operation(HttpMethod::Post, install_op);
builder = builder.path(install_path, install_item.build());
let uninstall_path = format!("{}/config/package/{{packageId}}", base);
let uninstall_op = OperationBuilder::new()
.summary(Some("Uninstall package"))
.description(Some(
"Revert migrations for the package, delete all _sys_* config and KV data, remove package record.",
))
.operation_id(Some("config_uninstall_package"))
.parameters(Some(vec![x_tenant_id_header(), package_id_param()]))
.responses(
ResponsesBuilder::new()
.response("200", Response::new("OK"))
.response("404", Response::new("Not Found"))
.build(),
)
.build();
let uninstall_item = PathItemBuilder::new().operation(HttpMethod::Delete, uninstall_op);
builder = builder.path(uninstall_path, uninstall_item.build());
let config_kinds = [
("schemas", "Schema definitions"),
("enums", "Enum types"),
("tables", "Table definitions"),
("columns", "Column definitions"),
("indexes", "Index definitions"),
("relationships", "Relationship definitions"),
("api_entities", "API entity definitions"),
("kv_stores", "KV store definitions"),
];
for (kind, description) in config_kinds {
let path = format!("{}/config/{}", base, kind);
let get_op = OperationBuilder::new()
.summary(Some(format!("Get {}", kind)))
.description(Some(format!(
"Get {} (from _sys_{}). {}",
description, kind, "X-Tenant-ID required."
)))
.operation_id(Some(format!("config_get_{}", kind)))
.parameters(Some(vec![x_tenant_id_header()]))
.responses(default_responses().build())
.build();
let post_body = RequestBodyBuilder::new()
.description(Some(format!("JSON array of {} records.", description)))
.content(
"application/json",
Content::new(Some(RefOr::T(Schema::Array(
utoipa::openapi::schema::ArrayBuilder::new()
.items(RefOr::T(json_object_schema()))
.into(),
)))),
)
.required(Some(Required::True))
.build();
let post_op = OperationBuilder::new()
.summary(Some(format!("Replace {}", kind)))
.description(Some(format!(
"Replace {} for the default package. Runs migrations when rows change.",
kind
)))
.operation_id(Some(format!("config_post_{}", kind)))
.parameters(Some(vec![x_tenant_id_header()]))
.request_body(Some(post_body))
.responses(default_responses().build())
.build();
let item = PathItemBuilder::new()
.operation(HttpMethod::Get, get_op)
.operation(HttpMethod::Post, post_op);
builder = builder.path(path, item.build());
}
builder
}
pub fn build_spec(
default_model: &ResolvedModel,
base_path: &str,
package_models: &HashMap<String, ResolvedModel>,
package_kv_stores: &HashMap<String, Vec<KvStoreConfig>>,
) -> OpenApi {
let server = build_server();
let mut builder = PathsBuilder::new();
builder = add_config_paths(builder, base_path);
builder = add_entity_paths(builder, base_path, default_model, false, None);
for (package_id, model) in package_models {
if !model.entities.is_empty() {
builder = add_entity_paths(builder, base_path, model, true, Some(package_id.as_str()));
}
}
builder = add_kv_paths(builder, base_path, package_kv_stores);
let paths = builder.build();
OpenApiBuilder::new()
.info(
Info::builder()
.title("Architect API")
.version(env!("CARGO_PKG_VERSION"))
.description(Some("Config APIs (package install/uninstall, schemas, enums, tables, etc.) and entity CRUD + package-scoped entity and KV APIs."))
.build(),
)
.servers(Some(vec![server]))
.paths(paths)
.build()
}
pub async fn spec_handler(State(state): State<AppState>) -> Json<OpenApi> {
let default_model = state.model.read().expect("model read lock").clone();
let base_path = "/api/v1";
let package_ids = list_package_ids(&state.pool).await.unwrap_or_default();
let mut package_models: HashMap<String, ResolvedModel> = HashMap::new();
let mut package_kv_stores: HashMap<String, Vec<KvStoreConfig>> = HashMap::new();
for package_id in package_ids {
if let Ok(config) = load_from_pool(&state.pool, &package_id).await {
if let Ok(model) = resolve(&config) {
package_models.insert(package_id.clone(), model);
}
package_kv_stores.insert(package_id, config.kv_stores);
}
}
let spec = build_spec(
&default_model,
base_path,
&package_models,
&package_kv_stores,
);
Json(spec)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::resolved::{PkType, ResolvedEntity, ResolvedModel};
use std::collections::{HashMap, HashSet};
fn entity(seg: &str, extensible_columns: Vec<String>) -> ResolvedEntity {
ResolvedEntity {
table_id: seg.to_string(),
schema_name: "public".into(),
table_name: seg.to_string(),
path_segment: seg.to_string(),
pk_columns: vec!["id".into()],
pk_type: PkType::Uuid,
columns: vec![],
operations: vec![
"read".into(),
"create".into(),
"update".into(),
"delete".into(),
],
sensitive_columns: HashSet::new(),
includes: vec![],
validation: HashMap::new(),
events: vec![],
archive_field: None,
package_id: "_default".into(),
audit_log: false,
parent_ref_column: None,
versioning: None,
mcp: None,
extensible_columns,
}
}
#[test]
fn spec_lists_extensible_field_paths_only_for_extensible_entities() {
let model = ResolvedModel {
entities: vec![
entity("products", vec!["attributes".into()]),
entity("orders", vec![]),
],
entity_by_path: HashMap::new(),
};
let spec = build_spec(&model, "/api/v1", &HashMap::new(), &HashMap::new());
let json = serde_json::to_string(&spec).expect("serialize spec");
assert!(json.contains("/api/v1/products/extensible-fields"));
assert!(json.contains("/api/v1/products/extensible-fields/indexes"));
assert!(!json.contains("/api/v1/orders/extensible-fields"));
}
}