use std::sync::OnceLock;
use serde_json::{Map, Value, json};
use umbral::migrate::{Column, ModelMeta};
use umbral::orm::SqlType;
use umbral::prelude::*;
use umbral::web::{Html, IntoResponse, Json, Response, StatusCode, header};
use umbral_casing::pascal_case_from_ident;
const SWAGGER_UI_HTML: &str = include_str!("../templates/swagger_ui.html");
#[derive(Debug, Clone)]
pub struct OpenApiPlugin {
base_path: String,
title: String,
version: String,
description: Option<String>,
extra_exclude: Vec<String>,
}
impl Default for OpenApiPlugin {
fn default() -> Self {
Self::new()
}
}
impl OpenApiPlugin {
pub fn new() -> Self {
Self {
base_path: "/openapi".to_string(),
title: "umbral API".to_string(),
version: "0.0.1".to_string(),
description: None,
extra_exclude: Vec::new(),
}
}
pub fn at(mut self, path: &str) -> Self {
let trimmed = path.trim_end_matches('/');
self.base_path = if trimmed.is_empty() {
"/".to_string()
} else {
trimmed.to_string()
};
self
}
pub fn title(mut self, s: impl Into<String>) -> Self {
self.title = s.into();
self
}
pub fn version(mut self, s: impl Into<String>) -> Self {
self.version = s.into();
self
}
pub fn description(mut self, s: impl Into<String>) -> Self {
self.description = Some(s.into());
self
}
pub fn exclude<I, S>(mut self, tables: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
for t in tables {
self.extra_exclude.push(t.into());
}
self
}
fn is_exposed(&self, table: &str) -> bool {
!self.extra_exclude.iter().any(|t| t == table)
}
fn spec_url(&self) -> String {
if self.base_path == "/" {
"/openapi.json".to_string()
} else {
format!("{}/openapi.json", self.base_path)
}
}
fn ui_route(&self) -> String {
if self.base_path == "/" {
"/".to_string()
} else {
format!("{}/", self.base_path)
}
}
}
static CONFIG: OnceLock<OpenApiPlugin> = OnceLock::new();
pub fn spec_url() -> Option<String> {
CONFIG.get().map(|cfg| cfg.spec_url())
}
impl Plugin for OpenApiPlugin {
fn name(&self) -> &'static str {
"openapi"
}
fn dependencies(&self) -> &'static [&'static str] {
&["rest"]
}
fn routes(&self) -> Router {
let _ = CONFIG.set(self.clone());
umbral::routes::init_openapi_spec_url(self.spec_url());
let mut router = Router::new()
.route(&self.spec_url(), get(spec_handler))
.route(&self.ui_route(), get(swagger_ui_handler));
if self.base_path != "/" {
router = router.route(&self.base_path, get(swagger_ui_handler));
}
router
}
}
async fn spec_handler() -> Response {
let cfg = CONFIG.get().expect("OpenApiPlugin::routes was called");
let spec = build_spec(cfg);
(
StatusCode::OK,
[(header::CONTENT_TYPE, "application/json")],
Json(spec),
)
.into_response()
}
async fn swagger_ui_handler() -> Response {
let cfg = CONFIG.get().expect("OpenApiPlugin::routes was called");
let body = SWAGGER_UI_HTML.replace("{SPEC_URL}", &cfg.spec_url());
Html(body).into_response()
}
fn build_spec(cfg: &OpenApiPlugin) -> Value {
let mut schemas = Map::new();
let mut paths = Map::new();
let mut table_to_schema: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for plugin in umbral::migrate::registered_plugins() {
for model in umbral::migrate::models_for_plugin(&plugin) {
table_to_schema.insert(model.table.clone(), pascal_case_from_ident(&model.name));
}
}
let rest_base = umbral_rest::registered_base_path().to_owned();
for plugin in umbral::migrate::registered_plugins() {
for model in umbral::migrate::models_for_plugin(&plugin) {
if !umbral_rest::is_exposed(&model.table) {
continue;
}
if !cfg.is_exposed(&model.table) {
continue;
}
let schema_name = pascal_case_from_ident(&model.name);
schemas.insert(schema_name.clone(), model_schema(&model, &table_to_schema));
let mut list_params = Vec::new();
list_params.extend(pagination_parameters_for_style(
umbral_rest::registered_pagination_style(),
));
if umbral_rest::search_enabled_for(&model.table) {
list_params.push(search_parameter());
}
list_params.push(fields_parameter(&model));
if model.fields.iter().any(|c| c.fk_target.is_some()) {
list_params.push(include_parameter(&model));
}
if umbral_rest::filters_enabled_for(&model.table) {
list_params.extend(filter_parameters(&model));
}
paths.insert(
format!("{}/{}/", rest_base, model.table),
collection_paths(&model.table, &schema_name, &list_params),
);
let mut item_params = vec![fields_parameter(&model)];
if model.fields.iter().any(|c| c.fk_target.is_some()) {
item_params.push(include_parameter(&model));
}
paths.insert(
format!("{}/{}/{{id}}", rest_base, model.table),
item_paths(&model.table, &schema_name, &item_params),
);
}
}
if let Some(entries) = umbral::routes::registered_openapi_paths() {
for (path, item) in entries {
paths.insert(path.clone(), item.clone());
}
}
for action in umbral_rest::registered_action_schemas() {
let path = if action.detail {
format!(
"{}/{}/{{id}}/{}/",
action.base_path, action.table, action.name
)
} else {
format!("{}/{}/{}/", action.base_path, action.table, action.name)
};
paths.insert(path, action_path_item(&action));
}
let mut info = Map::new();
info.insert("title".into(), Value::String(cfg.title.clone()));
info.insert("version".into(), Value::String(cfg.version.clone()));
if let Some(desc) = &cfg.description {
info.insert("description".into(), Value::String(desc.clone()));
}
let mut security_schemes = Map::new();
let mut security: Vec<Value> = Vec::new();
for (name, scheme) in umbral_rest::registered_security_schemes() {
security.push(json!({ name.clone(): [] }));
security_schemes.insert(name, scheme);
}
let mut components = Map::new();
components.insert("schemas".into(), Value::Object(schemas));
if !security_schemes.is_empty() {
components.insert("securitySchemes".into(), Value::Object(security_schemes));
}
let mut document = Map::new();
document.insert("openapi".into(), Value::String("3.0.3".into()));
document.insert("info".into(), Value::Object(info));
document.insert("paths".into(), Value::Object(paths));
document.insert("components".into(), Value::Object(components));
if !security.is_empty() {
document.insert("security".into(), Value::Array(security));
}
Value::Object(document)
}
fn action_path_item(a: &umbral_rest::ActionSchema) -> Value {
let mut op = Map::new();
op.insert(
"operationId".into(),
Value::String(format!("{}_{}", a.table, a.name)),
);
op.insert("tags".into(), json!([a.table]));
op.insert(
"summary".into(),
Value::String(format!("`{}` action on {}", a.name, a.table)),
);
if a.detail {
op.insert(
"parameters".into(),
json!([{
"name": "id", "in": "path", "required": true,
"schema": { "type": "string" },
"description": "Primary key of the target row"
}]),
);
}
if let Some(input) = &a.input_schema {
op.insert(
"requestBody".into(),
json!({ "required": true, "content": { "application/json": { "schema": input } } }),
);
}
let mut ok = Map::new();
ok.insert("description".into(), Value::String("Action result".into()));
if let Some(output) = &a.output_schema {
ok.insert(
"content".into(),
json!({ "application/json": { "schema": output } }),
);
}
op.insert("responses".into(), json!({ "200": Value::Object(ok) }));
let mut item = Map::new();
item.insert(a.method.to_lowercase(), Value::Object(op));
Value::Object(item)
}
fn model_schema(
model: &ModelMeta,
table_to_schema: &std::collections::HashMap<String, String>,
) -> Value {
let mut properties = Map::new();
let mut required: Vec<Value> = Vec::new();
for col in &model.fields {
if umbral_rest::is_hidden(&model.table, &col.name) {
continue;
}
properties.insert(
col.name.clone(),
column_schema_with_refs(col, table_to_schema),
);
if !col.nullable && !col.primary_key && !col.auto_now && !col.auto_now_add && !col.noform {
required.push(Value::String(col.name.clone()));
}
}
for rel in &model.m2m_relations {
let target_schema = table_to_schema
.get(&rel.target_table)
.cloned()
.unwrap_or_else(|| pascal_case_from_ident(&rel.target_name));
let mut prop = serde_json::Map::new();
prop.insert("type".into(), Value::String("array".into()));
let (item_ty, item_fmt) = umbral::migrate::pk_meta_for_table(&rel.target_table)
.map(|(_, pk_ty)| openapi_type(pk_ty))
.unwrap_or(("integer", Some("int64")));
let items = match item_fmt {
Some(f) => json!({ "type": item_ty, "format": f }),
None => json!({ "type": item_ty }),
};
prop.insert("items".into(), items);
prop.insert(
"description".into(),
Value::String(format!(
"Many-to-many relation to {}. Send an array of child ids on \
create / update; the framework writes the junction table.",
target_schema,
)),
);
prop.insert("x-umbral-m2m".into(), Value::Bool(true));
prop.insert(
"x-umbral-m2m-target".into(),
Value::String(target_schema.clone()),
);
prop.insert(
"x-umbral-m2m-target-table".into(),
Value::String(rel.target_table.clone()),
);
if table_to_schema.contains_key(&rel.target_table) {
prop.insert(
"x-umbral-m2m-target-ref".into(),
Value::String(format!("#/components/schemas/{target_schema}")),
);
}
properties.insert(rel.field_name.clone(), Value::Object(prop));
}
let mut obj = Map::new();
obj.insert("type".into(), Value::String("object".into()));
obj.insert("properties".into(), Value::Object(properties));
if !required.is_empty() {
obj.insert("required".into(), Value::Array(required));
}
Value::Object(obj)
}
fn column_schema_with_refs(
col: &Column,
table_to_schema: &std::collections::HashMap<String, String>,
) -> Value {
let mut value = column_schema(col);
if let Some(target_table) = &col.fk_target {
if let Some(schema_name) = table_to_schema.get(target_table) {
if let Some(obj) = value.as_object_mut() {
obj.insert(
"x-umbral-fk-ref".into(),
Value::String(format!("#/components/schemas/{schema_name}")),
);
}
}
}
value
}
fn column_schema(col: &Column) -> Value {
let (ty, format) = openapi_type(umbral::migrate::fk_effective_type(col));
let mut obj = Map::new();
obj.insert("type".into(), Value::String(ty.into()));
if let Some(f) = format {
obj.insert("format".into(), Value::String(f.into()));
}
if col.nullable {
obj.insert("nullable".into(), Value::Bool(true));
}
if !col.help.is_empty() {
obj.insert("description".into(), Value::String(col.help.clone()));
}
if !col.example.is_empty() {
obj.insert("example".into(), Value::String(col.example.clone()));
}
if let Some(min) = col.min {
obj.insert(
"minimum".into(),
Value::Number(serde_json::Number::from(min)),
);
}
if let Some(max) = col.max {
obj.insert(
"maximum".into(),
Value::Number(serde_json::Number::from(max)),
);
}
if let Some(fmt) = col.text_format.as_deref() {
match fmt {
"email" => {
obj.insert("format".into(), Value::String("email".into()));
}
"url" => {
obj.insert("format".into(), Value::String("uri".into()));
}
"slug" => {
obj.insert("pattern".into(), Value::String("^[A-Za-z0-9_-]+$".into()));
}
_ => {}
}
}
if !col.choices.is_empty() && !col.is_multichoice {
obj.insert(
"enum".into(),
Value::Array(col.choices.iter().cloned().map(Value::String).collect()),
);
}
if col.max_length > 0 {
obj.insert(
"maxLength".into(),
Value::Number(serde_json::Number::from(col.max_length)),
);
}
if !col.default.is_empty() {
obj.insert("default".into(), Value::String(col.default.clone()));
}
if col.is_multichoice {
obj.insert("x-umbral-multichoice".into(), Value::Bool(true));
obj.insert(
"x-umbral-choices".into(),
Value::Array(col.choices.iter().cloned().map(Value::String).collect()),
);
}
if !col.choice_labels.is_empty() {
obj.insert(
"x-umbral-choice-labels".into(),
Value::Array(
col.choice_labels
.iter()
.cloned()
.map(Value::String)
.collect(),
),
);
}
if let Some(target) = &col.fk_target {
obj.insert("x-umbral-fk-target".into(), Value::String(target.clone()));
}
if col.is_string_repr {
obj.insert("x-umbral-string-repr".into(), Value::Bool(true));
}
if col.auto_now_add {
obj.insert("x-umbral-auto-now-add".into(), Value::Bool(true));
}
if col.auto_now {
obj.insert("x-umbral-auto-now".into(), Value::Bool(true));
}
if col.noform {
obj.insert("readOnly".into(), Value::Bool(true));
obj.insert("x-umbral-noform".into(), Value::Bool(true));
}
if col.noedit {
obj.insert("x-umbral-noedit".into(), Value::Bool(true));
}
Value::Object(obj)
}
fn openapi_type(ty: SqlType) -> (&'static str, Option<&'static str>) {
match ty {
SqlType::SmallInt => ("integer", Some("int32")),
SqlType::Integer => ("integer", Some("int32")),
SqlType::BigInt => ("integer", Some("int64")),
SqlType::Real => ("number", Some("float")),
SqlType::Double => ("number", Some("double")),
SqlType::Boolean => ("boolean", None),
SqlType::Text => ("string", None),
SqlType::Date => ("string", Some("date")),
SqlType::Time => ("string", Some("time")),
SqlType::Timestamptz => ("string", Some("date-time")),
SqlType::Uuid => ("string", Some("uuid")),
SqlType::Json => ("object", None),
SqlType::Array(_) => ("array", None),
SqlType::Inet | SqlType::Cidr | SqlType::MacAddr => ("string", None),
SqlType::FullText => ("string", None),
SqlType::Xml | SqlType::Ltree | SqlType::Bit => ("string", None),
SqlType::ForeignKey => ("integer", Some("int64")),
SqlType::Bytes => ("array", Some("byte")),
SqlType::Decimal => ("string", Some("decimal")),
}
}
fn search_parameter() -> Value {
json!({
"name": "search",
"in": "query",
"required": false,
"description": "Free-text search across every searchable column. \
Text columns match via case-insensitive substring; \
numeric / FK / Boolean columns match exactly when \
the term parses as that type. Multiple matches are \
ORed.",
"schema": { "type": "string" },
"x-umbral-search": true,
})
}
fn fields_parameter(model: &ModelMeta) -> Value {
let columns: Vec<Value> = model
.fields
.iter()
.filter(|c| !umbral_rest::is_hidden(&model.table, &c.name))
.map(|c| Value::String(c.name.clone()))
.collect();
json!({
"name": "fields",
"in": "query",
"required": false,
"description": "Comma-separated list of column names to include in the \
response. Unknown names are silently dropped; an empty \
value falls back to the full row (BUG-81). Composes \
with hide / transform / computed — hide always wins, \
the rest are returned iff in the list.",
"schema": { "type": "string" },
"x-umbral-fields": true,
"x-umbral-fields-columns": Value::Array(columns),
})
}
fn include_parameter(model: &ModelMeta) -> Value {
let fks: Vec<Value> = model
.fields
.iter()
.filter(|c| c.fk_target.is_some())
.filter(|c| !umbral_rest::is_hidden(&model.table, &c.name))
.map(|c| Value::String(c.name.clone()))
.collect();
json!({
"name": "include",
"in": "query",
"required": false,
"description": "Comma-separated list of foreign-key columns to expand \
in the response. Each named FK gets replaced with the \
full related-row JSON object (one batched IN(...) query \
per FK — no N+1). Unknown or non-FK names return a 400. \
Example: `?include=user,billing_address`.",
"schema": { "type": "string" },
"x-umbral-include": true,
"x-umbral-include-fks": Value::Array(fks),
})
}
fn pagination_parameters_for_style(style: umbral_rest::PaginationStyle) -> Vec<Value> {
match style {
umbral_rest::PaginationStyle::PageNumber => vec![
json!({
"name": "page",
"in": "query",
"required": false,
"description": "1-indexed page number. Defaults to 1 when omitted.",
"schema": { "type": "integer", "format": "int32", "minimum": 1, "default": 1 },
"x-umbral-pagination": "page",
}),
json!({
"name": "page_size",
"in": "query",
"required": false,
"description": "Rows per page. Capped at 100. Default 20.",
"schema": {
"type": "integer", "format": "int32",
"minimum": 1, "maximum": 100, "default": 20,
},
"x-umbral-pagination": "page_size",
}),
],
umbral_rest::PaginationStyle::LimitOffset => vec![
json!({
"name": "limit",
"in": "query",
"required": false,
"description": "Maximum rows to return. Defaults to the configured page size.",
"schema": { "type": "integer", "format": "int32", "minimum": 1 },
"x-umbral-pagination": "limit",
}),
json!({
"name": "offset",
"in": "query",
"required": false,
"description": "Number of rows to skip from the start of the result set. Defaults to 0.",
"schema": { "type": "integer", "format": "int32", "minimum": 0, "default": 0 },
"x-umbral-pagination": "offset",
}),
],
umbral_rest::PaginationStyle::None | umbral_rest::PaginationStyle::Custom => vec![],
}
}
fn filter_parameters(model: &ModelMeta) -> Vec<Value> {
let mut out: Vec<Value> = Vec::new();
for col in &model.fields {
if col.primary_key {
continue;
}
let lookups = umbral_rest::filtering::applicable_lookups(col);
for lookup in lookups {
let name = if lookup == "eq" {
col.name.clone()
} else {
format!("{}__{}", col.name, lookup)
};
out.push(filter_parameter(col, lookup, &name));
}
}
out
}
fn filter_parameter(col: &Column, lookup: &str, name: &str) -> Value {
let (schema, description) = match lookup {
"in" => (
json!({ "type": "string" }),
format!(
"Comma-separated `{}` values; matches rows where the column is in the set.",
col.name,
),
),
"isnull" => (
json!({ "type": "boolean" }),
format!(
"`true` matches rows where `{}` IS NULL; `false` matches IS NOT NULL.",
col.name,
),
),
"contains" | "icontains" | "startswith" => {
let phrase = match lookup {
"contains" => "case-sensitive substring",
"icontains" => "case-insensitive substring",
"startswith" => "case-sensitive prefix",
_ => unreachable!(),
};
(
json!({ "type": "string" }),
format!(
"Matches rows where `{}` contains the given {phrase}.",
col.name
),
)
}
_ => {
let (ty, format) = openapi_type(umbral::migrate::fk_effective_type(col));
let mut schema_obj = Map::new();
schema_obj.insert("type".into(), Value::String(ty.into()));
if let Some(f) = format {
schema_obj.insert("format".into(), Value::String(f.into()));
}
let phrase = match lookup {
"eq" => "equals the value",
"ne" => "does not equal the value",
"gte" => "is greater than or equal to the value",
"lte" => "is less than or equal to the value",
"gt" => "is greater than the value",
"lt" => "is less than the value",
_ => "matches the value",
};
(
Value::Object(schema_obj),
format!("Matches rows where `{}` {phrase}.", col.name),
)
}
};
json!({
"name": name,
"in": "query",
"required": false,
"description": description,
"schema": schema,
"x-umbral-filter-field": col.name,
"x-umbral-filter-lookup": lookup,
})
}
fn collection_paths(table: &str, schema_name: &str, filter_params: &[Value]) -> Value {
let mut get_op = Map::new();
get_op.insert(
"operationId".into(),
Value::String(format!("list_{}", table)),
);
get_op.insert("tags".into(), json!([table]));
if !filter_params.is_empty() {
get_op.insert("parameters".into(), Value::Array(filter_params.to_vec()));
}
get_op.insert(
"responses".into(),
json!({
"200": {
"description": "List of rows",
"content": {
"application/json": {
"schema": list_envelope(schema_name)
}
}
}
}),
);
json!({
"get": Value::Object(get_op),
"post": {
"operationId": format!("create_{}", table),
"tags": [table],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": schema_ref(schema_name)
}
}
},
"responses": {
"201": {
"description": "Row created",
"content": {
"application/json": {
"schema": schema_ref(schema_name)
}
}
},
"400": { "description": "Invalid input" }
}
}
})
}
fn item_paths(table: &str, schema_name: &str, retrieve_query_params: &[Value]) -> Value {
let id_param = json!({
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string" }
});
let mut get_op = Map::new();
get_op.insert(
"operationId".into(),
Value::String(format!("retrieve_{}", table)),
);
get_op.insert("tags".into(), json!([table]));
if !retrieve_query_params.is_empty() {
get_op.insert(
"parameters".into(),
Value::Array(retrieve_query_params.to_vec()),
);
}
get_op.insert(
"responses".into(),
json!({
"200": {
"description": "Row found",
"content": {
"application/json": {
"schema": schema_ref(schema_name)
}
}
},
"404": { "description": "Not found" }
}),
);
json!({
"parameters": [id_param],
"get": Value::Object(get_op),
"put": {
"operationId": format!("update_{}", table),
"tags": [table],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": schema_ref(schema_name)
}
}
},
"responses": {
"200": {
"description": "Row updated",
"content": {
"application/json": {
"schema": schema_ref(schema_name)
}
}
},
"404": { "description": "Not found" }
}
},
"patch": {
"operationId": format!("partial_update_{}", table),
"tags": [table],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": schema_ref(schema_name)
}
}
},
"responses": {
"200": {
"description": "Row partially updated",
"content": {
"application/json": {
"schema": schema_ref(schema_name)
}
}
},
"404": { "description": "Not found" }
}
},
"delete": {
"operationId": format!("destroy_{}", table),
"tags": [table],
"responses": {
"204": { "description": "Row deleted" },
"404": { "description": "Not found" }
}
}
})
}
fn schema_ref(name: &str) -> Value {
json!({ "$ref": format!("#/components/schemas/{}", name) })
}
fn list_envelope(schema_name: &str) -> Value {
json!({
"type": "object",
"properties": {
"results": {
"type": "array",
"items": schema_ref(schema_name)
},
"count": { "type": "integer" }
},
"required": ["results", "count"]
})
}
#[doc(hidden)]
pub fn test_spec_url(p: &OpenApiPlugin) -> String {
p.spec_url()
}
#[doc(hidden)]
pub fn test_ui_route(p: &OpenApiPlugin) -> String {
p.ui_route()
}
#[cfg(test)]
mod tests {
use super::*;
use umbral::migrate::Column;
use umbral::orm::SqlType;
fn base_col(name: &str, ty: SqlType) -> Column {
Column {
name: name.into(),
ty,
primary_key: false,
nullable: false,
fk_target: None,
noform: false,
db_constraint: true,
noedit: false,
is_string_repr: false,
max_length: 0,
choices: Vec::new(),
choice_labels: Vec::new(),
default: String::new(),
is_multichoice: false,
unique: false,
on_delete: ::umbral::orm::FkAction::NoAction,
on_update: ::umbral::orm::FkAction::NoAction,
index: false,
auto_now_add: false,
auto_now: false,
help: String::new(),
example: String::new(),
widget: None,
supported_backends: Vec::new(),
min: None,
max: None,
text_format: ::core::option::Option::None,
slug_from: ::core::option::Option::None,
}
}
#[test]
fn choices_render_as_openapi_enum_with_labels_extension() {
let mut col = base_col("status", SqlType::Text);
col.choices = vec!["draft".into(), "published".into(), "archived".into()];
col.choice_labels = vec!["Draft".into(), "Published".into(), "Archived".into()];
let schema = column_schema(&col);
assert_eq!(schema["type"], "string");
assert_eq!(
schema["enum"],
serde_json::json!(["draft", "published", "archived"])
);
assert_eq!(
schema["x-umbral-choice-labels"],
serde_json::json!(["Draft", "Published", "Archived"])
);
}
#[test]
fn multichoice_skips_enum_and_uses_vendor_extension() {
let mut col = base_col("tags", SqlType::Text);
col.choices = vec!["rust".into(), "python".into()];
col.is_multichoice = true;
let schema = column_schema(&col);
assert!(
schema.get("enum").is_none(),
"multichoice columns should not declare a flat enum (value is a CSV subset)"
);
assert_eq!(schema["x-umbral-multichoice"], true);
assert_eq!(
schema["x-umbral-choices"],
serde_json::json!(["rust", "python"])
);
}
#[test]
fn max_length_and_default_surface_as_standard_openapi_keys() {
let mut col = base_col("title", SqlType::Text);
col.max_length = 50;
col.default = "untitled".into();
let schema = column_schema(&col);
assert_eq!(schema["maxLength"], 50);
assert_eq!(schema["default"], "untitled");
}
#[test]
fn fk_target_emits_vendor_extension_for_playground_navigation() {
let mut col = base_col("author_id", SqlType::ForeignKey);
col.fk_target = Some("auth_user".into());
let schema = column_schema(&col);
assert_eq!(schema["type"], "integer");
assert_eq!(schema["format"], "int64");
assert_eq!(schema["x-umbral-fk-target"], "auth_user");
}
#[test]
fn noform_renders_as_read_only_and_carries_vendor_extension() {
let mut col = base_col("internal_token", SqlType::Text);
col.noform = true;
let schema = column_schema(&col);
assert_eq!(schema["readOnly"], true);
assert_eq!(schema["x-umbral-noform"], true);
}
#[test]
fn noedit_does_NOT_render_as_read_only() {
let mut col = base_col("email", SqlType::Text);
col.noedit = true;
let schema = column_schema(&col);
assert!(
schema.get("readOnly").is_none(),
"noedit must NOT contaminate the API request-body contract; \
got readOnly in schema: {schema:?}"
);
assert_eq!(schema["x-umbral-noedit"], true);
}
#[test]
fn plain_column_keeps_minimal_schema_no_extensions() {
let col = base_col("body", SqlType::Text);
let schema = column_schema(&col);
let obj = schema.as_object().expect("object");
assert_eq!(
obj.len(),
1,
"plain column should only have `type`: {obj:?}"
);
assert_eq!(schema["type"], "string");
}
#[test]
fn help_attribute_flows_to_openapi_description() {
let mut col = base_col("status", SqlType::Text);
col.help = "Workflow step. Set by editors on Save.".to_string();
let schema = column_schema(&col);
assert_eq!(
schema["description"], "Workflow step. Set by editors on Save.",
"help should round-trip to OpenAPI description; got: {schema:?}",
);
}
#[test]
fn empty_help_omits_description() {
let col = base_col("body", SqlType::Text);
let schema = column_schema(&col);
assert!(
schema.get("description").is_none(),
"empty help should omit description; got: {schema:?}",
);
}
#[test]
fn example_attribute_flows_to_openapi_example() {
let mut col = base_col("status", SqlType::Text);
col.example = "published".to_string();
let schema = column_schema(&col);
assert_eq!(
schema["example"], "published",
"example should round-trip; got: {schema:?}",
);
}
#[test]
fn empty_example_omits_example() {
let col = base_col("body", SqlType::Text);
let schema = column_schema(&col);
assert!(
schema.get("example").is_none(),
"empty example should omit example key; got: {schema:?}",
);
}
fn note_model() -> ModelMeta {
let mut id = base_col("id", SqlType::BigInt);
id.primary_key = true;
let mut published_at = base_col("published_at", SqlType::Timestamptz);
published_at.nullable = true;
ModelMeta {
name: "Note".to_string(),
table: "note".to_string(),
fields: vec![
id,
base_col("title", SqlType::Text),
base_col("views", SqlType::Integer),
published_at,
],
display: "Note".to_string(),
icon: "database".to_string(),
database: None,
singleton: false,
unique_together: Vec::new(),
indexes: Vec::new(),
ordering: Vec::new(),
m2m_relations: Vec::new(),
soft_delete: false,
app_label: "app".to_string(),
}
}
#[test]
fn filter_parameters_skips_primary_key() {
let params = filter_parameters(¬e_model());
let names: Vec<&str> = params.iter().map(|p| p["name"].as_str().unwrap()).collect();
assert!(
!names.iter().any(|n| *n == "id" || n.starts_with("id__")),
"PK column should be skipped; got {names:?}",
);
}
#[test]
fn filter_parameters_eq_uses_bare_column_name_no_suffix() {
let params = filter_parameters(¬e_model());
let bare_title = params
.iter()
.find(|p| p["name"] == "title")
.expect("title eq parameter should be present");
assert_eq!(bare_title["x-umbral-filter-lookup"], "eq");
assert_eq!(bare_title["x-umbral-filter-field"], "title");
assert_eq!(bare_title["schema"]["type"], "string");
}
#[test]
fn filter_parameters_in_is_string_typed_with_csv_description() {
let params = filter_parameters(¬e_model());
let title_in = params
.iter()
.find(|p| p["name"] == "title__in")
.expect("title__in parameter should be present");
assert_eq!(title_in["schema"]["type"], "string");
assert!(
title_in["description"]
.as_str()
.unwrap()
.to_lowercase()
.contains("comma"),
"__in description should mention the comma-separated format",
);
}
#[test]
fn filter_parameters_isnull_only_on_nullable_columns() {
let params = filter_parameters(¬e_model());
let isnull_params: Vec<&str> = params
.iter()
.filter_map(|p| p["name"].as_str())
.filter(|n| n.ends_with("__isnull"))
.collect();
assert_eq!(
isnull_params,
vec!["published_at__isnull"],
"isnull lookup should only appear for nullable columns; got {isnull_params:?}",
);
}
#[test]
fn filter_parameters_range_lookups_only_on_numeric_or_temporal() {
let params = filter_parameters(¬e_model());
let has_gte = |field: &str| params.iter().any(|p| p["name"] == format!("{field}__gte"));
assert!(has_gte("views"), "integer column gets gte");
assert!(has_gte("published_at"), "timestamp column gets gte");
assert!(
!has_gte("title"),
"text column must NOT get gte; got {params:?}",
);
}
#[test]
fn filter_parameters_string_lookups_only_on_text() {
let params = filter_parameters(¬e_model());
let has_contains = |field: &str| {
params
.iter()
.any(|p| p["name"] == format!("{field}__contains"))
};
assert!(has_contains("title"), "text column gets contains");
assert!(
!has_contains("views"),
"integer column must NOT get contains; got {params:?}",
);
}
#[test]
fn collection_paths_omits_parameters_array_when_no_filters() {
let value = collection_paths("note", "Note", &[]);
let get_op = &value["get"];
assert!(
get_op.get("parameters").is_none(),
"no filters → no parameters key; got {get_op:?}",
);
}
#[test]
fn collection_paths_includes_parameters_when_filters_present() {
let filter_params = filter_parameters(¬e_model());
let value = collection_paths("note", "Note", &filter_params);
let params = value["get"]["parameters"]
.as_array()
.expect("parameters array should be present when filters land");
assert!(!params.is_empty());
assert!(
params.iter().all(|p| p["in"] == "query"),
"every filter parameter is in: query",
);
}
#[test]
fn fields_parameter_lists_model_columns() {
let param = fields_parameter(¬e_model());
assert_eq!(param["name"], "fields");
assert_eq!(param["in"], "query");
assert_eq!(param["x-umbral-fields"], true);
let cols = param["x-umbral-fields-columns"]
.as_array()
.expect("x-umbral-fields-columns should be a list");
let names: Vec<&str> = cols.iter().filter_map(|v| v.as_str()).collect();
assert!(names.contains(&"title"));
assert!(names.contains(&"views"));
assert!(
!names.is_empty(),
"every column should land in the enum so the playground can offer it",
);
}
#[test]
fn item_paths_advertises_fields_query_param_on_retrieve() {
let value = item_paths("note", "Note", &[fields_parameter(¬e_model())]);
let get_params = value["get"]["parameters"]
.as_array()
.expect("retrieve op should carry its query parameters");
assert!(
get_params.iter().any(|p| p["name"] == "fields"),
"fields parameter should be on the retrieve op; got {get_params:?}",
);
}
#[test]
fn fk_column_emits_schema_ref_when_target_known() {
let mut col = base_col("author", SqlType::ForeignKey);
col.fk_target = Some("auth_user".into());
let mut map = std::collections::HashMap::new();
map.insert("auth_user".to_string(), "AuthUser".to_string());
let schema = column_schema_with_refs(&col, &map);
assert_eq!(
schema["x-umbral-fk-target"], "auth_user",
"the table-name vendor extension stays for backward compat",
);
assert_eq!(
schema["x-umbral-fk-ref"], "#/components/schemas/AuthUser",
"the JSON pointer to the target schema should be emitted",
);
}
#[test]
fn fk_column_without_known_target_omits_schema_ref() {
let mut col = base_col("author", SqlType::ForeignKey);
col.fk_target = Some("unknown_table".into());
let map = std::collections::HashMap::new();
let schema = column_schema_with_refs(&col, &map);
assert!(
schema.get("x-umbral-fk-ref").is_none(),
"unknown FK target → no ref emitted; got: {schema:?}",
);
}
#[test]
fn m2m_relation_lands_in_model_schema_with_target_extension() {
let mut model = note_model();
model.m2m_relations.push(umbral::migrate::M2MRelation {
field_name: "tags".to_string(),
target_table: "tag".to_string(),
target_name: "Tag".to_string(),
});
let mut tts = std::collections::HashMap::new();
tts.insert("tag".to_string(), "Tag".to_string());
let schema = model_schema(&model, &tts);
let tags_prop = &schema["properties"]["tags"];
assert_eq!(tags_prop["type"], "array");
assert_eq!(tags_prop["items"]["type"], "integer");
assert_eq!(tags_prop["x-umbral-m2m"], true);
assert_eq!(tags_prop["x-umbral-m2m-target"], "Tag");
assert_eq!(tags_prop["x-umbral-m2m-target-table"], "tag");
assert_eq!(
tags_prop["x-umbral-m2m-target-ref"],
"#/components/schemas/Tag",
);
let required = schema["required"].as_array();
if let Some(req) = required {
assert!(!req.iter().any(|v| v == "tags"));
}
}
#[test]
fn auto_now_columns_are_optional_in_the_request_schema() {
let mut model = note_model();
let mut created = base_col("created_at", SqlType::Timestamptz);
created.auto_now_add = true;
let mut updated = base_col("updated_at", SqlType::Timestamptz);
updated.auto_now = true;
model.fields.push(created);
model.fields.push(updated);
let schema = model_schema(&model, &std::collections::HashMap::new());
assert_eq!(
schema["properties"]["created_at"]["x-umbral-auto-now-add"],
true
);
assert_eq!(schema["properties"]["updated_at"]["x-umbral-auto-now"], true);
assert!(
schema["properties"]["created_at"].get("readOnly").is_none(),
"auto_now_add must not be readOnly; got {}",
schema["properties"]["created_at"],
);
assert!(
schema["properties"]["updated_at"].get("readOnly").is_none(),
"auto_now must not be readOnly; got {}",
schema["properties"]["updated_at"],
);
let required = schema["required"].as_array().expect("required array");
let names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
assert!(
!names.contains(&"created_at"),
"auto_now_add should drop out of required; got {names:?}",
);
assert!(
!names.contains(&"updated_at"),
"auto_now should drop out of required; got {names:?}",
);
}
#[test]
fn pagination_parameters_per_style() {
use umbral_rest::PaginationStyle;
let none_params = pagination_parameters_for_style(PaginationStyle::None);
assert!(
none_params.is_empty(),
"NoPagination should emit no pagination params; got {none_params:?}"
);
let custom_params = pagination_parameters_for_style(PaginationStyle::Custom);
assert!(
custom_params.is_empty(),
"Custom pagination should emit no params; got {custom_params:?}"
);
let page_params = pagination_parameters_for_style(PaginationStyle::PageNumber);
assert_eq!(page_params.len(), 2, "PageNumber should emit 2 params");
assert_eq!(page_params[0]["name"], "page");
assert_eq!(page_params[0]["in"], "query");
assert_eq!(page_params[0]["schema"]["type"], "integer");
assert_eq!(page_params[0]["schema"]["minimum"], 1);
assert_eq!(page_params[0]["schema"]["default"], 1);
assert_eq!(page_params[0]["x-umbral-pagination"], "page");
assert_eq!(page_params[1]["name"], "page_size");
assert_eq!(page_params[1]["schema"]["maximum"], 100);
assert_eq!(page_params[1]["x-umbral-pagination"], "page_size");
let lo_params = pagination_parameters_for_style(PaginationStyle::LimitOffset);
assert_eq!(lo_params.len(), 2, "LimitOffset should emit 2 params");
assert_eq!(lo_params[0]["name"], "limit");
assert_eq!(lo_params[0]["x-umbral-pagination"], "limit");
assert_eq!(lo_params[1]["name"], "offset");
assert_eq!(lo_params[1]["x-umbral-pagination"], "offset");
assert_eq!(lo_params[1]["schema"]["minimum"], 0);
}
}