use crate::action::{Action, ActionMeta, DataQuery, Error, Page, Result};
use crate::ctx::{BoxFuture, Ctx, Dep};
use crate::model::{FieldMeta, Model, Valid, ValidationErrors};
use crate::value::FieldKind;
use serde::Serialize;
use serde_json::{Map, Value as Json, json};
pub fn model_schema<M: Model>() -> Json {
let mut properties = Map::new();
let mut required = Vec::new();
for f in M::fields() {
properties.insert(f.name.to_string(), field_schema(f));
if !f.optional {
required.push(f.name);
}
}
json!({ "type": "object", "title": M::NAME, "properties": properties, "required": required })
}
fn field_schema(f: &FieldMeta) -> Json {
let mut s = match f.kind {
FieldKind::Id | FieldKind::Integer => json!({ "type": "integer" }),
FieldKind::Decimal => {
let (precision, scale) = f.decimal_limits();
json!({
"type": "string",
"pattern": "^-?\\d+(\\.\\d+)?$",
"description": format!("Exact decimal number with '.' as the separator, at most {precision} digits and {scale} decimals")
})
}
FieldKind::Text | FieldKind::Custom { .. } => json!({ "type": "string" }),
FieldKind::Bool => json!({ "type": "boolean" }),
FieldKind::Date => json!({ "type": "string", "format": "date" }),
FieldKind::Time => json!({ "type": "string", "pattern": "^\\d{2}:\\d{2}(:\\d{2})?$" }),
FieldKind::DateTime => json!({ "type": "string", "format": "date-time" }),
FieldKind::LocalDateTime => {
json!({ "type": "string", "description": "Wall clock date and time with no time zone, YYYY-MM-DDTHH:MM:SS" })
}
FieldKind::Money => json!({
"type": "object",
"properties": { "minor": { "type": "integer", "description": "Amount in the smallest unit (cents, kuruÅŸ)" }, "currency": { "type": "string", "enum": ["Try", "Usd", "Eur"] } },
"required": ["minor", "currency"]
}),
FieldKind::Choices(options) => json!({
"type": "array",
"uniqueItems": true,
"items": { "type": "string", "enum": options.iter().map(|o| o.value).collect::<Vec<_>>() }
}),
FieldKind::Choice(options) => {
json!({ "type": "string", "enum": options.iter().map(|o| o.value).collect::<Vec<_>>() })
}
};
let o = s
.as_object_mut()
.expect("object schema");
o.insert("title".into(), json!(f.label));
for r in f.rules {
match r {
crate::model::Rule::MaxLen(n) => {
o.insert("maxLength".into(), json!(n));
}
crate::model::Rule::MinLen(n) => {
o.insert("minLength".into(), json!(n));
}
crate::model::Rule::Min(n) if f.kind == FieldKind::Integer => {
o.insert("minimum".into(), json!(n));
}
crate::model::Rule::Max(n) if f.kind == FieldKind::Integer => {
o.insert("maximum".into(), json!(n));
}
crate::model::Rule::Email => {
o.insert("format".into(), json!("email"));
}
crate::model::Rule::Embed => {
o.insert("format".into(), json!("uri"));
o.insert("pattern".into(), json!("^https://"));
}
_ => {}
}
}
if f.readonly || f.key {
o.insert("readOnly".into(), json!(true));
}
if f.optional {
s = json!({ "anyOf": [s, { "type": "null" }] });
}
s
}
#[diagnostic::on_unimplemented(
message = "`{Self}` cannot be the input of an API action",
note = "API inputs are `Valid<M>`, `DataQuery`, `()`, numbers, strings, booleans, entity ids or `#[params]` types"
)]
pub trait ApiInput: Sized + Send + 'static {
fn schema() -> Json;
fn from_json(value: Json) -> Result<Self>;
fn record(&self) -> Option<crate::audit::RecordRef> {
None
}
const READS: bool = false;
#[doc(hidden)]
fn clean(&mut self, _fields: &'static [FieldMeta], _ctx: &Ctx) {}
#[doc(hidden)]
fn check_with(&self, _ctx: &Ctx) -> Result<()> {
Ok(())
}
}
fn unreadable(e: serde_json::Error) -> Error {
let mut errors = ValidationErrors::default();
errors.add_general(format!("The request body could not be read: {e}"));
Error::Validation(errors)
}
impl<M: Model> ApiInput for Valid<M> {
fn schema() -> Json {
model_schema::<M>()
}
fn from_json(value: Json) -> Result<Self> {
let model: M = serde_json::from_value(value).map_err(unreadable)?;
Valid::new(model).map_err(Error::Validation)
}
fn check_with(&self, ctx: &Ctx) -> Result<()> {
let mut errors = ValidationErrors::default();
crate::model::check_embeds(&**self, ctx.csp(), &mut errors);
errors
.into_result()
.map_err(Error::Validation)
}
fn record(&self) -> Option<crate::audit::RecordRef> {
Some(crate::audit::RecordRef::of(&**self))
}
}
macro_rules! serde_input {
($($ty:ty => $schema:expr),* $(,)?) => {
$(impl ApiInput for $ty {
fn schema() -> Json {
$schema
}
fn from_json(value: Json) -> Result<Self> {
serde_json::from_value(value).map_err(unreadable)
}
})*
};
}
serde_input! {
() => json!({ "type": "null" }),
i64 => json!({ "type": "integer" }),
i32 => json!({ "type": "integer" }),
bool => json!({ "type": "boolean" }),
String => json!({ "type": "string" }),
}
impl ApiInput for DataQuery {
const READS: bool = true;
fn schema() -> Json {
json!({ "type": "object", "description": "Paging, sort and filters. See the runtime protocol." })
}
fn from_json(value: Json) -> Result<Self> {
serde_json::from_value(value).map_err(unreadable)
}
fn clean(&mut self, fields: &'static [FieldMeta], ctx: &Ctx) {
crate::privacy::strip_masked(self, fields, ctx);
}
}
pub trait ApiOutput: Serialize + Send + 'static {
fn schema() -> Json;
#[doc(hidden)]
fn fields() -> &'static [FieldMeta] {
&[]
}
fn protect(_value: &mut Json, _ctx: &Ctx) {}
}
impl<M: Model> ApiOutput for M {
fn fields() -> &'static [FieldMeta] {
M::fields()
}
fn schema() -> Json {
model_schema::<M>()
}
fn protect(value: &mut Json, ctx: &Ctx) {
crate::privacy::protect_object::<M>(value, ctx);
}
}
impl<M: Model> ApiOutput for Page<M> {
fn fields() -> &'static [FieldMeta] {
M::fields()
}
fn schema() -> Json {
json!({
"type": "object",
"properties": {
"items": { "type": "array", "items": model_schema::<M>() },
"total": { "type": ["integer", "null"] },
"next": { "type": ["string", "null"] }
},
"required": ["items"]
})
}
fn protect(value: &mut Json, ctx: &Ctx) {
if let Some(items) = value
.get_mut("items")
.and_then(Json::as_array_mut)
{
items
.iter_mut()
.for_each(|item| crate::privacy::protect_object::<M>(item, ctx));
}
}
}
impl<M: Model> ApiOutput for Vec<M> {
fn fields() -> &'static [FieldMeta] {
M::fields()
}
fn schema() -> Json {
json!({ "type": "array", "items": model_schema::<M>() })
}
fn protect(value: &mut Json, ctx: &Ctx) {
if let Some(items) = value.as_array_mut() {
items
.iter_mut()
.for_each(|item| crate::privacy::protect_object::<M>(item, ctx));
}
}
}
macro_rules! plain_output {
($($ty:ty => $schema:expr),* $(,)?) => {
$(impl ApiOutput for $ty {
fn schema() -> Json {
$schema
}
})*
};
}
plain_output! {
() => json!({ "type": "null" }),
i64 => json!({ "type": "integer" }),
bool => json!({ "type": "boolean" }),
String => json!({ "type": "string" }),
}
#[diagnostic::on_unimplemented(
message = "`{Self}` cannot be returned by an API action",
note = "API actions return `zeyn::Result<T>` where T is a model, `Page<M>`, `Vec<M>`, `()`, a number, a string or a boolean"
)]
pub trait ApiResult: Send + 'static {
fn schema() -> Json;
fn into_json(self) -> Result<Json>;
fn protect(_value: &mut Json, _ctx: &Ctx) {}
#[doc(hidden)]
fn fields() -> &'static [FieldMeta] {
&[]
}
}
impl<T: ApiOutput> ApiResult for Result<T> {
fn schema() -> Json {
T::schema()
}
fn into_json(self) -> Result<Json> {
let value = self?;
serde_json::to_value(value)
.map_err(|e| Error::Internal(format!("response could not be serialized: {e}")))
}
fn protect(value: &mut Json, ctx: &Ctx) {
T::protect(value, ctx);
}
fn fields() -> &'static [FieldMeta] {
T::fields()
}
}
#[derive(Clone, Copy)]
pub struct ApiEntry {
pub meta: ActionMeta,
pub call: fn(Ctx, Json) -> BoxFuture<Result<Json>>,
pub input: fn() -> Json,
pub output: fn() -> Json,
pub deps: fn() -> Vec<Dep>,
pub reads: bool,
}
fn call_json<A>(ctx: Ctx, body: Json) -> BoxFuture<Result<Json>>
where
A: Action,
A::Input: ApiInput,
A::Output: ApiResult,
{
Box::pin(async move {
let mut input = A::Input::from_json(body)?;
input.clean(<A::Output as ApiResult>::fields(), &ctx);
input.check_with(&ctx)?;
if let Some(record) = input.record() {
ctx.update_trail(|t| {
t.record.get_or_insert(record);
});
}
let mut value = A::call(ctx.clone(), input)
.await?
.into_json()?;
<A::Output as ApiResult>::protect(&mut value, &ctx);
Ok(value)
})
}
impl ApiEntry {
pub fn of<A>() -> Self
where
A: Action,
A::Input: ApiInput,
A::Output: ApiResult,
{
ApiEntry {
meta: A::META,
call: call_json::<A>,
input: A::Input::schema,
output: A::Output::schema,
deps: A::deps,
reads: A::Input::READS,
}
}
}