use serde_json::Value;
use cratefield_core::{Audience, SurfaceDocument};
use crate::GenerateError;
pub const TABLES_MODULE: &str = "tables";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Verb {
List,
Read,
Create,
Replace,
Delete,
}
impl Verb {
fn method(self) -> &'static str {
match self {
Verb::List | Verb::Read => "GET",
Verb::Create => "POST",
Verb::Replace => "PUT",
Verb::Delete => "DELETE",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ColumnKind {
Text,
Integer,
Real,
Boolean,
Enum(Vec<String>),
Opaque,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColumnModel {
pub name: String,
pub kind: ColumnKind,
pub nullable: bool,
pub required: bool,
pub is_primary_key: bool,
pub filterable: bool,
pub sortable: bool,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
pub struct TableModel {
pub name: String,
pub audience: Audience,
pub list_path: Option<String>,
pub key_path: Option<String>,
pub schema: Option<Value>,
pub columns: Vec<ColumnModel>,
pub primary_key: Option<ColumnModel>,
pub row_shape_known: bool,
pub has_read: bool,
pub has_create: bool,
pub has_replace: bool,
pub has_delete: bool,
}
#[derive(Debug, Clone)]
pub struct Contract {
pub venture: String,
pub mount: String,
pub tables: Vec<TableModel>,
}
fn is_table_name(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
if !first.is_ascii_lowercase() {
return false;
}
if name.ends_with('_') || name.contains("__") {
return false;
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
fn split_action(name: &str) -> Option<(Verb, &str)> {
let (verb, table) = name.split_once('-')?;
let verb = match verb {
"list" => Verb::List,
"read" => Verb::Read,
"create" => Verb::Create,
"replace" => Verb::Replace,
"delete" => Verb::Delete,
_ => return None,
};
if table.is_empty() {
return None;
}
Some((verb, table))
}
fn key_path_of(table: &str, path: &str, action: &str) -> Result<String, GenerateError> {
let prefix = format!("/{table}/");
let placeholder =
path.strip_prefix(prefix.as_str())
.ok_or_else(|| GenerateError::UnreadableAction {
action: action.to_owned(),
why: format!("its single-row path `{path}` must start with `{prefix}`"),
})?;
let one_segment = !placeholder.is_empty()
&& !placeholder.contains('/')
&& placeholder.starts_with('{')
&& placeholder.ends_with('}');
if !one_segment {
return Err(GenerateError::UnreadableAction {
action: action.to_owned(),
why: format!(
"its single-row path `{path}` must end with one `{{key}}` placeholder \
segment after `{table}`"
),
});
}
Ok(path.to_owned())
}
fn list_path_of(table: &str, path: &str, action: &str) -> Result<String, GenerateError> {
if path != format!("/{table}") {
return Err(GenerateError::UnreadableAction {
action: action.to_owned(),
why: format!("its page path `{path}` must be exactly `/{table}`"),
});
}
Ok(path.to_owned())
}
const PK_MARKER: &str = "Primary key of `";
pub(crate) fn is_primary_key_marker(column_schema: &Value) -> bool {
column_schema
.get("description")
.and_then(Value::as_str)
.is_some_and(|description| description.starts_with(PK_MARKER))
}
pub(crate) fn column_nullable(column_schema: &Value) -> bool {
let type_nullable = match column_schema.get("type") {
Some(Value::Array(kinds)) => kinds.iter().any(|kind| kind == "null"),
Some(Value::String(kind)) => kind == "null",
_ => false,
};
type_nullable
|| column_schema
.get("enum")
.and_then(Value::as_array)
.is_some_and(|members| members.iter().any(Value::is_null))
}
fn scalar_kind(kind: &str) -> ColumnKind {
match kind {
"string" => ColumnKind::Text,
"integer" => ColumnKind::Integer,
"number" => ColumnKind::Real,
"boolean" => ColumnKind::Boolean,
_ => ColumnKind::Opaque,
}
}
pub(crate) fn dedup_keep_first<T: Clone + PartialEq>(values: Vec<T>) -> Vec<T> {
let mut kept: Vec<T> = Vec::with_capacity(values.len());
for value in values {
if !kept.contains(&value) {
kept.push(value);
}
}
kept
}
fn column_kind_of(column_schema: &Value) -> ColumnKind {
if let Some(members) = column_schema.get("enum").and_then(Value::as_array) {
let literals: Vec<String> = members
.iter()
.filter(|member| !member.is_null())
.map(|member| match member {
Value::String(text) => text.clone(),
other => other.to_string(),
})
.collect();
return ColumnKind::Enum(dedup_keep_first(literals));
}
match column_schema.get("type") {
Some(Value::String(kind)) => scalar_kind(kind),
Some(Value::Array(kinds)) => kinds
.iter()
.filter_map(Value::as_str)
.find(|kind| *kind != "null")
.map_or(ColumnKind::Opaque, scalar_kind),
_ => ColumnKind::Opaque,
}
}
fn column_of(name: &str, column_schema: &Value, required: bool) -> ColumnModel {
let kind = column_kind_of(column_schema);
let is_primary_key = is_primary_key_marker(column_schema);
let filterable = !matches!(kind, ColumnKind::Opaque);
ColumnModel {
name: name.to_owned(),
kind,
nullable: column_nullable(column_schema),
required,
is_primary_key,
filterable,
sortable: required || is_primary_key,
}
}
fn normalize(table: &mut TableModel) {
let Some(schema) = table.schema.as_ref() else {
return;
};
table.row_shape_known = true;
let required: Vec<&str> = schema
.get("required")
.and_then(Value::as_array)
.map(|values| values.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
let Some(properties) = schema.get("properties").and_then(Value::as_object) else {
return;
};
table.columns = properties
.iter()
.map(|(name, column_schema)| {
column_of(name, column_schema, required.contains(&name.as_str()))
})
.collect();
let mut marked = table.columns.iter().filter(|c| c.is_primary_key);
let key = match (marked.next(), marked.next()) {
(Some(single), None) => Some(single.clone()),
_ => None,
};
if table.key_path.is_some() {
table.primary_key = key;
}
}
pub fn extract(document: &SurfaceDocument) -> Result<Contract, GenerateError> {
if document.surface_api != cratefield_core::SURFACE_API {
return Err(GenerateError::UnsupportedSurfaceApi {
found: document.surface_api,
expected: cratefield_core::SURFACE_API,
});
}
let module = document
.modules
.iter()
.find(|module| module.name == TABLES_MODULE)
.ok_or_else(|| GenerateError::NoTablesModule {
modules: document
.modules
.iter()
.map(|module| module.name.clone())
.collect(),
})?;
let mut tables: Vec<TableModel> = Vec::new();
for action in &module.surface.actions {
let (verb, table_name) =
split_action(&action.name).ok_or_else(|| GenerateError::UnknownAction {
action: action.name.clone(),
})?;
if !is_table_name(table_name) {
return Err(GenerateError::UnsupportedTableName {
table: table_name.to_owned(),
});
}
if tables.iter_mut().all(|table| table.name != table_name) {
tables.push(TableModel {
name: table_name.to_owned(),
audience: action.audience,
list_path: None,
key_path: None,
schema: None,
columns: Vec::new(),
primary_key: None,
row_shape_known: false,
has_read: false,
has_create: false,
has_replace: false,
has_delete: false,
});
}
let table = tables
.iter_mut()
.find(|table| table.name == table_name)
.expect("the table was pushed on the line above when it was missing");
let method = action.method.as_str();
if method != verb.method() {
return Err(GenerateError::UnreadableAction {
action: action.name.clone(),
why: format!(
"its verb is served by `{}`, and the contract carries `{}`",
verb.method(),
method
),
});
}
match verb {
Verb::List => {
table.list_path = Some(list_path_of(table_name, &action.path, &action.name)?);
}
Verb::Read => {
table.key_path = Some(key_path_of(table_name, &action.path, &action.name)?);
table.has_read = true;
}
Verb::Create => {
table.has_create = true;
if let Some(schema) = &action.input {
table.schema = Some(schema.as_value().clone());
}
}
Verb::Replace => {
table.has_replace = true;
if table.schema.is_none()
&& let Some(schema) = &action.input
{
table.schema = Some(schema.as_value().clone());
}
}
Verb::Delete => table.has_delete = true,
}
}
for table in &mut tables {
normalize(table);
}
Ok(Contract {
venture: document.venture.name.clone(),
mount: format!("/v1/{}", module.name),
tables,
})
}