use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug, Clone, Deserialize)]
pub struct Resource {
#[serde(rename = "resource")]
pub meta: ResourceMeta,
#[serde(default)]
pub fields: BTreeMap<String, Field>,
#[serde(default)]
pub permissions: Permissions,
#[serde(default)]
pub hooks: Hooks,
#[serde(default)]
pub auth: Option<AuthSpec>,
#[serde(default)]
pub admin: ResourceAdmin,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ResourceMeta {
pub name: String,
pub table: Option<String>,
#[serde(default = "yes")]
pub timestamps: bool,
#[serde(default = "default_owner_field")]
pub owner_field: String,
#[serde(default = "default_scope")]
pub scope: Scope,
}
fn yes() -> bool {
true
}
fn default_owner_field() -> String {
"owner_id".to_string()
}
fn default_scope() -> Scope {
Scope::Organization
}
impl Resource {
pub fn table_name(&self) -> String {
self.meta
.table
.clone()
.unwrap_or_else(|| format!("apiplant_{}", self.meta.name))
}
pub fn hook(&self, event: HookEvent) -> Option<&str> {
self.hooks.get(event)
}
pub fn auth_hook(&self, event: AuthEvent) -> Option<&str> {
self.hooks.get_auth(event)
}
pub fn validate(&self) -> crate::Result<()> {
for (event, function) in self.hooks.iter() {
if function.trim().is_empty() {
return Err(crate::Error::Schema {
resource: self.meta.name.clone(),
message: format!("hook `{}` names an empty function", event.as_str()),
});
}
}
for (event, function) in self.hooks.auth_iter() {
if function.trim().is_empty() {
return Err(crate::Error::Schema {
resource: self.meta.name.clone(),
message: format!("hook `{}` names an empty function", event.as_str()),
});
}
if self.meta.name != "user" {
return Err(crate::Error::Schema {
resource: self.meta.name.clone(),
message: format!(
"hook `{}` only exists on the `user` resource, which owns the auth endpoints",
event.as_str()
),
});
}
}
for (fname, field) in &self.fields {
if fname == "id" {
return Err(crate::Error::Schema {
resource: self.meta.name.clone(),
message: "`id` is reserved and added automatically".into(),
});
}
if field.ty == FieldType::Reference && field.references.is_none() {
return Err(crate::Error::Schema {
resource: self.meta.name.clone(),
message: format!("field `{fname}` is a reference without `references`"),
});
}
if field.admin.format != ContentFormat::Plain
&& !matches!(field.ty, FieldType::Text | FieldType::String)
{
return Err(crate::Error::Schema {
resource: self.meta.name.clone(),
message: format!(
"field `{fname}` sets [admin] format = \"{}\" but is not a text field",
field.admin.format.as_str()
),
});
}
}
for column in &self.admin.columns {
if !self.fields.contains_key(column) && column != "id" {
return Err(crate::Error::Schema {
resource: self.meta.name.clone(),
message: format!("[admin] columns names unknown field `{column}`"),
});
}
}
for (key, declared) in [
("display_field", &self.admin.display_field),
("search_field", &self.admin.search_field),
] {
if let Some(field) = declared {
let Some(declared_field) = self.fields.get(field) else {
return Err(crate::Error::Schema {
resource: self.meta.name.clone(),
message: format!("[admin] {key} names unknown field `{field}`"),
});
};
if key == "search_field"
&& !matches!(declared_field.ty, FieldType::String | FieldType::Text)
{
return Err(crate::Error::Schema {
resource: self.meta.name.clone(),
message: format!(
"[admin] search_field names `{field}`, which is not a text field and cannot be searched"
),
});
}
}
}
Ok(())
}
pub fn admin_label(&self) -> String {
self.admin
.label
.clone()
.unwrap_or_else(|| titleize(&self.meta.name))
}
pub fn admin_plural(&self) -> String {
self.admin
.plural
.clone()
.unwrap_or_else(|| pluralize(&self.admin_label()))
}
pub fn admin_display_field(&self) -> Option<String> {
if let Some(declared) = &self.admin.display_field {
if self.fields.contains_key(declared) {
return Some(declared.clone());
}
}
const PREFERRED: [&str; 7] = ["name", "title", "label", "slug", "code", "number", "email"];
for candidate in PREFERRED {
if let Some(field) = self.fields.get(candidate) {
if !field.hidden && matches!(field.ty, FieldType::String | FieldType::Text) {
return Some(candidate.to_string());
}
}
}
self.fields
.iter()
.find(|(_, field)| !field.hidden && field.ty == FieldType::String)
.map(|(name, _)| name.clone())
}
pub fn admin_search_field(&self) -> Option<String> {
let candidate = match &self.admin.search_field {
Some(declared) if self.fields.contains_key(declared) => Some(declared.clone()),
Some(_) | None => self.admin_display_field(),
};
candidate.filter(|name| {
self.fields
.get(name)
.is_some_and(|field| matches!(field.ty, FieldType::String | FieldType::Text))
})
}
pub fn admin_columns(&self) -> Vec<String> {
let declared: Vec<String> = self
.admin
.columns
.iter()
.filter(|name| self.fields.contains_key(*name))
.cloned()
.collect();
if !declared.is_empty() {
return declared;
}
let display = self.admin_display_field();
let mut columns: Vec<String> = display.iter().cloned().collect();
for (name, field) in &self.fields {
if columns.len() >= 5 {
break;
}
let skip = field.hidden
|| !field.admin.visible
|| Some(name) == display.as_ref()
|| name == "organization_id"
|| matches!(field.ty, FieldType::Json | FieldType::Text);
if !skip {
columns.push(name.clone());
}
}
columns
}
pub fn references(&self) -> Vec<Reference> {
self.fields
.iter()
.filter_map(|(name, field)| {
if field.ty != FieldType::Reference {
return None;
}
let target = field.references.clone()?;
Some(Reference {
field: name.clone(),
target,
relation: relation_name(name).to_string(),
on_delete: field.on_delete.unwrap_or(OnDelete::Restrict),
required: field.required,
})
})
.collect()
}
pub fn reference_by_relation(&self, relation: &str) -> Option<Reference> {
self.references()
.into_iter()
.find(|r| r.relation == relation)
}
pub fn is_org_scoped(&self) -> bool {
self.meta.scope == Scope::Organization
}
pub fn org_column(&self) -> Option<&'static str> {
if self.is_org_scoped() {
Some("organization_id")
} else if self.meta.name == "organization" {
Some("id")
} else {
None
}
}
pub fn load(path: &Path) -> crate::Result<Self> {
let text = std::fs::read_to_string(path).map_err(|e| crate::Error::Io {
path: path.to_path_buf(),
source: e,
})?;
let source = path.file_name().unwrap_or_default().to_string_lossy();
let resource: Resource =
crate::env::parse_toml(&text, &source).map_err(|e| crate::Error::Toml {
path: path.to_path_buf(),
source: e,
})?;
resource.validate()?;
Ok(resource)
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ResourceAdmin {
pub visible: Option<bool>,
pub roles: Vec<String>,
pub label: Option<String>,
pub plural: Option<String>,
pub group: Option<String>,
pub display_field: Option<String>,
pub columns: Vec<String>,
pub search_field: Option<String>,
pub order: i64,
}
impl ResourceAdmin {
pub fn is_visible(&self, resource_name: &str) -> bool {
self.visible.unwrap_or(!is_auth_resource(resource_name))
}
}
pub fn is_auth_resource(name: &str) -> bool {
matches!(
name,
"user" | "organization" | "membership" | "membership_role" | "api_key" | "oauth_connection"
)
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct FieldAdmin {
pub visible: bool,
pub readonly: bool,
pub label: Option<String>,
pub help: Option<String>,
pub widget: Widget,
pub options: Vec<String>,
pub placeholder: Option<String>,
pub format: ContentFormat,
}
impl Default for FieldAdmin {
fn default() -> Self {
FieldAdmin {
visible: true,
readonly: false,
label: None,
help: None,
widget: Widget::Auto,
options: Vec::new(),
placeholder: None,
format: ContentFormat::Plain,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContentFormat {
#[default]
Plain,
Markdown,
Html,
}
impl ContentFormat {
pub fn as_str(self) -> &'static str {
match self {
ContentFormat::Plain => "plain",
ContentFormat::Markdown => "markdown",
ContentFormat::Html => "html",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Widget {
Auto,
Text,
Textarea,
Select,
Email,
Url,
Password,
Color,
Date,
DateTime,
Json,
Switch,
}
impl Widget {
pub fn as_str(self) -> &'static str {
match self {
Widget::Auto => "auto",
Widget::Text => "text",
Widget::Textarea => "textarea",
Widget::Select => "select",
Widget::Email => "email",
Widget::Url => "url",
Widget::Password => "password",
Widget::Color => "color",
Widget::Date => "date",
Widget::DateTime => "date_time",
Widget::Json => "json",
Widget::Switch => "switch",
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Field {
#[serde(rename = "type")]
pub ty: FieldType,
#[serde(default)]
pub references: Option<String>,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub unique: bool,
#[serde(default)]
pub hidden: bool,
#[serde(default)]
pub default: Option<serde_json::Value>,
pub max_length: Option<u32>,
#[serde(default)]
pub on_delete: Option<OnDelete>,
#[serde(default)]
pub admin: FieldAdmin,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FieldType {
String,
Text,
Integer,
BigInt,
Float,
Boolean,
Uuid,
Timestamp,
Json,
Reference,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OnDelete {
Restrict,
SetNull,
Cascade,
NoAction,
}
impl OnDelete {
pub fn to_sql(self) -> &'static str {
match self {
OnDelete::Restrict => "RESTRICT",
OnDelete::SetNull => "SET NULL",
OnDelete::Cascade => "CASCADE",
OnDelete::NoAction => "NO ACTION",
}
}
}
#[derive(Debug, Clone)]
pub struct Reference {
pub field: String,
pub target: String,
pub relation: String,
pub on_delete: OnDelete,
pub required: bool,
}
pub fn relation_name(field: &str) -> &str {
field.strip_suffix("_id").unwrap_or(field)
}
pub fn titleize(name: &str) -> String {
let spaced = name.trim_end_matches("_id").replace('_', " ");
let mut chars = spaced.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => spaced,
}
}
pub fn pluralize(label: &str) -> String {
let lower = label.to_lowercase();
if lower.ends_with('s')
|| lower.ends_with("x")
|| lower.ends_with("ch")
|| lower.ends_with("sh")
{
format!("{label}es")
} else if lower.ends_with('y')
&& !lower.ends_with("ay")
&& !lower.ends_with("ey")
&& !lower.ends_with("oy")
&& !lower.ends_with("uy")
{
format!("{}ies", &label[..label.len() - 1])
} else {
format!("{label}s")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Scope {
Organization,
Global,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Access {
Public,
Authenticated,
Member,
Role(String),
Owner,
Private,
}
impl Access {
pub fn parse(s: &str) -> Access {
match s {
"public" => Access::Public,
"authenticated" => Access::Authenticated,
"member" => Access::Member,
"owner" => Access::Owner,
"private" => Access::Private,
other => other
.strip_prefix("role:")
.map(|role| Access::Role(role.to_string()))
.unwrap_or(Access::Private),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(from = "PermissionsRaw")]
pub struct Permissions {
pub list: Access,
pub read: Access,
pub create: Access,
pub update: Access,
pub delete: Access,
}
impl Default for Permissions {
fn default() -> Self {
Permissions {
list: Access::Member,
read: Access::Member,
create: Access::Member,
update: Access::Member,
delete: Access::Member,
}
}
}
#[derive(Deserialize)]
struct PermissionsRaw {
list: Option<String>,
read: Option<String>,
create: Option<String>,
update: Option<String>,
delete: Option<String>,
}
impl From<PermissionsRaw> for Permissions {
fn from(r: PermissionsRaw) -> Self {
let d = Permissions::default();
Permissions {
list: r.list.map(|s| Access::parse(&s)).unwrap_or(d.list),
read: r.read.map(|s| Access::parse(&s)).unwrap_or(d.read),
create: r.create.map(|s| Access::parse(&s)).unwrap_or(d.create),
update: r.update.map(|s| Access::parse(&s)).unwrap_or(d.update),
delete: r.delete.map(|s| Access::parse(&s)).unwrap_or(d.delete),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum HookEvent {
BeforeList,
AfterList,
BeforeRead,
AfterRead,
BeforeCreate,
AfterCreate,
BeforeUpdate,
AfterUpdate,
BeforeDelete,
AfterDelete,
}
impl HookEvent {
pub const ALL: [HookEvent; 10] = [
HookEvent::BeforeList,
HookEvent::AfterList,
HookEvent::BeforeRead,
HookEvent::AfterRead,
HookEvent::BeforeCreate,
HookEvent::AfterCreate,
HookEvent::BeforeUpdate,
HookEvent::AfterUpdate,
HookEvent::BeforeDelete,
HookEvent::AfterDelete,
];
pub fn as_str(self) -> &'static str {
match self {
HookEvent::BeforeList => "before_list",
HookEvent::AfterList => "after_list",
HookEvent::BeforeRead => "before_read",
HookEvent::AfterRead => "after_read",
HookEvent::BeforeCreate => "before_create",
HookEvent::AfterCreate => "after_create",
HookEvent::BeforeUpdate => "before_update",
HookEvent::AfterUpdate => "after_update",
HookEvent::BeforeDelete => "before_delete",
HookEvent::AfterDelete => "after_delete",
}
}
pub fn action(self) -> &'static str {
match self {
HookEvent::BeforeList | HookEvent::AfterList => "list",
HookEvent::BeforeRead | HookEvent::AfterRead => "read",
HookEvent::BeforeCreate | HookEvent::AfterCreate => "create",
HookEvent::BeforeUpdate | HookEvent::AfterUpdate => "update",
HookEvent::BeforeDelete | HookEvent::AfterDelete => "delete",
}
}
pub fn phase(self) -> &'static str {
if self.is_before() {
"before"
} else {
"after"
}
}
pub fn is_before(self) -> bool {
matches!(
self,
HookEvent::BeforeList
| HookEvent::BeforeRead
| HookEvent::BeforeCreate
| HookEvent::BeforeUpdate
| HookEvent::BeforeDelete
)
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Hooks {
pub before_list: Option<String>,
pub after_list: Option<String>,
pub before_read: Option<String>,
pub after_read: Option<String>,
pub before_create: Option<String>,
pub after_create: Option<String>,
pub before_update: Option<String>,
pub after_update: Option<String>,
pub before_delete: Option<String>,
pub after_delete: Option<String>,
pub before_register: Option<String>,
pub after_register: Option<String>,
pub before_login: Option<String>,
pub after_login: Option<String>,
pub before_api_key: Option<String>,
pub after_api_key: Option<String>,
}
impl Hooks {
pub fn get(&self, event: HookEvent) -> Option<&str> {
let slot = match event {
HookEvent::BeforeList => &self.before_list,
HookEvent::AfterList => &self.after_list,
HookEvent::BeforeRead => &self.before_read,
HookEvent::AfterRead => &self.after_read,
HookEvent::BeforeCreate => &self.before_create,
HookEvent::AfterCreate => &self.after_create,
HookEvent::BeforeUpdate => &self.before_update,
HookEvent::AfterUpdate => &self.after_update,
HookEvent::BeforeDelete => &self.before_delete,
HookEvent::AfterDelete => &self.after_delete,
};
slot.as_deref()
}
pub fn iter(&self) -> impl Iterator<Item = (HookEvent, &str)> {
HookEvent::ALL
.into_iter()
.filter_map(|event| self.get(event).map(|name| (event, name)))
}
pub fn is_empty(&self) -> bool {
self.iter().next().is_none() && self.auth_iter().next().is_none()
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct AuthSpec {
pub identity_field: String,
pub password_field: String,
pub oauth_providers: Vec<String>,
}
impl Default for AuthSpec {
fn default() -> Self {
AuthSpec {
identity_field: "email".to_string(),
password_field: "password_hash".to_string(),
oauth_providers: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum AuthEvent {
BeforeRegister,
AfterRegister,
BeforeLogin,
AfterLogin,
BeforeApiKey,
AfterApiKey,
}
impl AuthEvent {
pub const ALL: [AuthEvent; 6] = [
AuthEvent::BeforeRegister,
AuthEvent::AfterRegister,
AuthEvent::BeforeLogin,
AuthEvent::AfterLogin,
AuthEvent::BeforeApiKey,
AuthEvent::AfterApiKey,
];
pub fn as_str(self) -> &'static str {
match self {
AuthEvent::BeforeRegister => "before_register",
AuthEvent::AfterRegister => "after_register",
AuthEvent::BeforeLogin => "before_login",
AuthEvent::AfterLogin => "after_login",
AuthEvent::BeforeApiKey => "before_api_key",
AuthEvent::AfterApiKey => "after_api_key",
}
}
pub fn action(self) -> &'static str {
match self {
AuthEvent::BeforeRegister | AuthEvent::AfterRegister => "register",
AuthEvent::BeforeLogin | AuthEvent::AfterLogin => "login",
AuthEvent::BeforeApiKey | AuthEvent::AfterApiKey => "api_key",
}
}
pub fn phase(self) -> &'static str {
if self.is_before() {
"before"
} else {
"after"
}
}
pub fn is_before(self) -> bool {
matches!(
self,
AuthEvent::BeforeRegister | AuthEvent::BeforeLogin | AuthEvent::BeforeApiKey
)
}
}
impl Hooks {
pub fn get_auth(&self, event: AuthEvent) -> Option<&str> {
let slot = match event {
AuthEvent::BeforeRegister => &self.before_register,
AuthEvent::AfterRegister => &self.after_register,
AuthEvent::BeforeLogin => &self.before_login,
AuthEvent::AfterLogin => &self.after_login,
AuthEvent::BeforeApiKey => &self.before_api_key,
AuthEvent::AfterApiKey => &self.after_api_key,
};
slot.as_deref()
}
pub fn auth_iter(&self) -> impl Iterator<Item = (AuthEvent, &str)> {
AuthEvent::ALL
.into_iter()
.filter_map(|event| self.get_auth(event).map(|name| (event, name)))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_resource(src: &str) -> Resource {
let resource: Resource = toml::from_str(src).unwrap();
resource.validate().unwrap();
resource
}
#[test]
fn access_parser_and_permissions_defaults_match_org_membership_model() {
assert_eq!(Access::parse("public"), Access::Public);
assert_eq!(Access::parse("authenticated"), Access::Authenticated);
assert_eq!(Access::parse("member"), Access::Member);
assert_eq!(Access::parse("owner"), Access::Owner);
assert_eq!(Access::parse("role:admin"), Access::Role("admin".into()));
assert_eq!(Access::parse("wat"), Access::Private);
let defaults = Permissions::default();
assert_eq!(defaults.list, Access::Member);
assert_eq!(defaults.read, Access::Member);
assert_eq!(defaults.create, Access::Member);
assert_eq!(defaults.update, Access::Member);
assert_eq!(defaults.delete, Access::Member);
}
#[test]
fn validate_rejects_reserved_id_and_dangling_reference_definition() {
let reserved_id: Resource = toml::from_str(
r#"
[resource]
name = "bad"
[fields.id]
type = "string"
"#,
)
.unwrap();
assert!(reserved_id.validate().is_err());
let missing_target: Resource = toml::from_str(
r#"
[resource]
name = "bad_ref"
[fields.owner_id]
type = "reference"
"#,
)
.unwrap();
assert!(missing_target.validate().is_err());
}
#[test]
fn content_format_is_only_allowed_on_text_fields() {
let good: Resource = toml::from_str(
r#"
[resource]
name = "article"
[fields.body]
type = "text"
[fields.body.admin]
format = "markdown"
"#,
)
.unwrap();
good.validate().unwrap();
assert_eq!(good.fields["body"].admin.format, ContentFormat::Markdown);
let bad: Resource = toml::from_str(
r#"
[resource]
name = "article"
[fields.published]
type = "boolean"
[fields.published.admin]
format = "html"
"#,
)
.unwrap();
assert!(bad.validate().is_err());
}
#[test]
fn references_derive_relation_names_and_default_on_delete() {
let resource = parse_resource(
r#"
[resource]
name = "comment"
[fields.post_id]
type = "reference"
references = "post"
required = true
[fields.author_id]
type = "reference"
references = "user"
on_delete = "cascade"
"#,
);
assert_eq!(relation_name("owner_id"), "owner");
assert_eq!(relation_name("slug"), "slug");
let refs = resource.references();
assert_eq!(refs.len(), 2);
let post = refs.iter().find(|rf| rf.field == "post_id").unwrap();
assert_eq!(post.target, "post");
assert_eq!(post.relation, "post");
assert_eq!(post.on_delete, OnDelete::Restrict);
assert!(post.required);
let author = resource.reference_by_relation("author").unwrap();
assert_eq!(author.field, "author_id");
assert_eq!(author.on_delete, OnDelete::Cascade);
}
#[test]
fn hooks_parse_per_event_and_iterate_in_lifecycle_order() {
let resource = parse_resource(
r#"
[resource]
name = "post"
[fields.title]
type = "string"
[hooks]
before_create = "validate_post"
after_create = "notify"
after_list = "redact"
"#,
);
assert_eq!(
resource.hook(HookEvent::BeforeCreate),
Some("validate_post")
);
assert_eq!(resource.hook(HookEvent::AfterCreate), Some("notify"));
assert_eq!(resource.hook(HookEvent::AfterList), Some("redact"));
assert_eq!(resource.hook(HookEvent::BeforeUpdate), None);
assert!(!resource.hooks.is_empty());
let declared: Vec<_> = resource.hooks.iter().collect();
assert_eq!(
declared,
vec![
(HookEvent::AfterList, "redact"),
(HookEvent::BeforeCreate, "validate_post"),
(HookEvent::AfterCreate, "notify"),
]
);
}
#[test]
fn hook_events_expose_wire_names_actions_and_phases() {
assert_eq!(HookEvent::BeforeCreate.as_str(), "before_create");
assert_eq!(HookEvent::BeforeCreate.action(), "create");
assert_eq!(HookEvent::BeforeCreate.phase(), "before");
assert!(HookEvent::BeforeCreate.is_before());
assert_eq!(HookEvent::AfterList.as_str(), "after_list");
assert_eq!(HookEvent::AfterList.action(), "list");
assert_eq!(HookEvent::AfterList.phase(), "after");
assert!(!HookEvent::AfterList.is_before());
for event in HookEvent::ALL {
let resource = parse_resource(&format!(
"[resource]\nname = \"post\"\n\n[hooks]\n{} = \"h\"\n",
event.as_str()
));
assert_eq!(resource.hook(event), Some("h"), "{}", event.as_str());
assert_eq!(resource.hooks.iter().count(), 1);
}
}
#[test]
fn hooks_reject_typos_and_empty_function_names() {
let typo = toml::from_str::<Resource>(
r#"
[resource]
name = "post"
[hooks]
befor_create = "oops"
"#,
);
assert!(typo.is_err(), "unknown hook keys must not be ignored");
let empty: Resource = toml::from_str(
r#"
[resource]
name = "post"
[hooks]
after_delete = " "
"#,
)
.unwrap();
assert!(empty.validate().is_err());
}
#[test]
fn auth_events_expose_wire_names_actions_and_phases() {
assert_eq!(AuthEvent::BeforeLogin.as_str(), "before_login");
assert_eq!(AuthEvent::BeforeLogin.action(), "login");
assert_eq!(AuthEvent::BeforeLogin.phase(), "before");
assert!(AuthEvent::BeforeLogin.is_before());
assert_eq!(AuthEvent::AfterApiKey.action(), "api_key");
assert_eq!(AuthEvent::AfterApiKey.phase(), "after");
assert!(!AuthEvent::AfterApiKey.is_before());
for event in AuthEvent::ALL {
let resource = parse_resource(&format!(
"[resource]\nname = \"user\"\n\n[hooks]\nafter_create = \"c\"\n{} = \"h\"\n",
event.as_str()
));
assert_eq!(resource.auth_hook(event), Some("h"), "{}", event.as_str());
assert_eq!(resource.hook(HookEvent::AfterCreate), Some("c"));
assert_eq!(resource.hooks.auth_iter().count(), 1);
assert_eq!(resource.hooks.iter().count(), 1);
}
}
#[test]
fn auth_hooks_are_absent_by_default_and_reject_typos_and_empty_names() {
let plain =
parse_resource("[resource]\nname = \"user\"\n\n[hooks]\nafter_create = \"c\"\n");
assert_eq!(plain.auth_hook(AuthEvent::BeforeLogin), None);
assert!(parse_resource("[resource]\nname = \"user\"\n")
.hooks
.is_empty());
let typo = toml::from_str::<Resource>(
"[resource]\nname = \"user\"\n\n[hooks]\nbefore_signin = \"oops\"\n",
);
assert!(typo.is_err(), "unknown auth hook keys must not be ignored");
let empty: Resource =
toml::from_str("[resource]\nname = \"user\"\n\n[hooks]\nafter_login = \" \"\n")
.unwrap();
assert!(empty.validate().is_err());
}
#[test]
fn auth_hooks_are_rejected_on_any_resource_but_user() {
let stray: Resource =
toml::from_str("[resource]\nname = \"post\"\n\n[hooks]\nbefore_login = \"h\"\n")
.unwrap();
let message = stray.validate().unwrap_err().to_string();
assert!(message.contains("before_login"), "{message}");
assert!(message.contains("user"), "{message}");
}
#[test]
fn resources_have_no_hooks_by_default() {
let resource = parse_resource("[resource]\nname = \"post\"\n");
assert!(resource.hooks.is_empty());
assert!(HookEvent::ALL
.into_iter()
.all(|event| resource.hook(event).is_none()));
}
#[test]
fn admin_visibility_defaults_hide_auth_resources_but_nothing_else() {
let post = parse_resource("[resource]\nname = \"post\"\n");
assert!(post.admin.is_visible("post"));
let user = parse_resource(crate::defaults::USER_TOML);
assert!(!user.admin.is_visible("user"));
assert!(!parse_resource(crate::defaults::MEMBERSHIP_TOML)
.admin
.is_visible("membership"));
let replaced = parse_resource(
"[resource]\nname = \"user\"\nscope = \"global\"\n\n[fields.email]\ntype = \"string\"\n",
);
assert!(!replaced.admin.is_visible("user"));
let opted_in = parse_resource(
"[resource]\nname = \"user\"\nscope = \"global\"\n\n[admin]\nvisible = true\n",
);
assert!(opted_in.admin.is_visible("user"));
}
#[test]
fn admin_labels_are_inferred_and_overridable() {
let inferred = parse_resource("[resource]\nname = \"purchase_order\"\n");
assert_eq!(inferred.admin_label(), "Purchase order");
assert_eq!(inferred.admin_plural(), "Purchase orders");
let overridden = parse_resource(
"[resource]\nname = \"person\"\n\n[admin]\nlabel = \"Person\"\nplural = \"People\"\n",
);
assert_eq!(overridden.admin_plural(), "People");
assert_eq!(titleize("owner_id"), "Owner");
assert_eq!(titleize("total_cents"), "Total cents");
assert_eq!(pluralize("Category"), "Categories");
assert_eq!(pluralize("Address"), "Addresses");
assert_eq!(pluralize("Day"), "Days");
assert_eq!(pluralize("Product"), "Products");
}
#[test]
fn display_field_prefers_conventional_names_then_any_string() {
let conventional = parse_resource(
r#"
[resource]
name = "product"
[fields.sku]
type = "string"
[fields.name]
type = "string"
"#,
);
assert_eq!(conventional.admin_display_field().as_deref(), Some("name"));
let only_odd_names =
parse_resource("[resource]\nname = \"blob\"\n\n[fields.zzz]\ntype = \"string\"\n");
assert_eq!(only_odd_names.admin_display_field().as_deref(), Some("zzz"));
let nothing_stringy =
parse_resource("[resource]\nname = \"tick\"\n\n[fields.count]\ntype = \"integer\"\n");
assert_eq!(nothing_stringy.admin_display_field(), None);
let declared = parse_resource(
r#"
[resource]
name = "product"
[admin]
display_field = "sku"
[fields.sku]
type = "string"
[fields.name]
type = "string"
"#,
);
assert_eq!(declared.admin_display_field().as_deref(), Some("sku"));
assert_eq!(declared.admin_search_field().as_deref(), Some("sku"));
}
#[test]
fn inferred_columns_skip_blobs_and_dashboard_hidden_fields() {
let resource = parse_resource(
r#"
[resource]
name = "product"
[fields.name]
type = "string"
[fields.status]
type = "string"
[fields.description]
type = "text"
[fields.attributes]
type = "json"
[fields.secret_ratio]
type = "float"
[fields.secret_ratio.admin]
visible = false
"#,
);
assert_eq!(resource.admin_columns(), vec!["name", "status"]);
let declared = parse_resource(
r#"
[resource]
name = "product"
[admin]
columns = ["status", "name"]
[fields.name]
type = "string"
[fields.status]
type = "string"
"#,
);
assert_eq!(declared.admin_columns(), vec!["status", "name"]);
}
#[test]
fn admin_section_rejects_columns_naming_fields_that_do_not_exist() {
let bad_column: Resource = toml::from_str(
"[resource]\nname = \"post\"\n\n[admin]\ncolumns = [\"nope\"]\n\n[fields.title]\ntype = \"string\"\n",
)
.unwrap();
assert!(bad_column.validate().is_err());
let bad_display: Resource = toml::from_str(
"[resource]\nname = \"post\"\n\n[admin]\ndisplay_field = \"nope\"\n\n[fields.title]\ntype = \"string\"\n",
)
.unwrap();
assert!(bad_display.validate().is_err());
let unsearchable: Resource = toml::from_str(
"[resource]\nname = \"tick\"\n\n[admin]\nsearch_field = \"count\"\n\n[fields.count]\ntype = \"integer\"\n",
)
.unwrap();
assert!(unsearchable.validate().is_err());
let named_by_a_number = parse_resource(
"[resource]\nname = \"tick\"\n\n[admin]\ndisplay_field = \"count\"\n\n[fields.count]\ntype = \"integer\"\n",
);
assert_eq!(
named_by_a_number.admin_display_field().as_deref(),
Some("count")
);
assert_eq!(named_by_a_number.admin_search_field(), None);
assert!(toml::from_str::<Resource>(
"[resource]\nname = \"post\"\n\n[admin]\nvisibel = true\n"
)
.is_err());
}
#[test]
fn field_admin_carries_widget_options_and_visibility() {
let resource = parse_resource(
r#"
[resource]
name = "product"
[fields.status]
type = "string"
[fields.status.admin]
label = "Lifecycle"
widget = "select"
options = ["draft", "active|Live"]
readonly = true
"#,
);
let status = &resource.fields["status"];
assert_eq!(status.admin.label.as_deref(), Some("Lifecycle"));
assert_eq!(status.admin.widget, Widget::Select);
assert_eq!(status.admin.widget.as_str(), "select");
assert_eq!(status.admin.options, vec!["draft", "active|Live"]);
assert!(status.admin.readonly);
assert!(status.admin.visible);
assert_eq!(resource.fields["status"].admin.help, None);
}
#[test]
fn org_column_reflects_resource_scope() {
let org_scoped = parse_resource(
r#"
[resource]
name = "post"
[fields.title]
type = "string"
"#,
);
let global = parse_resource(
r#"
[resource]
name = "plan"
scope = "global"
[fields.name]
type = "string"
"#,
);
let organization = parse_resource(crate::defaults::ORGANIZATION_TOML);
assert_eq!(org_scoped.org_column(), Some("organization_id"));
assert_eq!(global.org_column(), None);
assert_eq!(organization.org_column(), Some("id"));
}
}