use abi_stable::std_types::{RBox, RResult, RStr};
use apiplant_abi::{HostApi_TO, LogLevel};
pub struct Context<'a, 'h, C> {
host: &'a HostApi_TO<'h, RBox<()>>,
config: C,
principal_id: String,
hook: Option<Hook>,
}
impl<'a, 'h, C> Context<'a, 'h, C> {
#[doc(hidden)]
pub fn __new(
host: &'a HostApi_TO<'h, RBox<()>>,
config: C,
principal_id: String,
hook: Option<Hook>,
) -> Self {
Context {
host,
config,
principal_id,
hook,
}
}
pub fn hook(&self) -> Option<&Hook> {
self.hook.as_ref()
}
pub fn config(&self) -> &C {
&self.config
}
pub fn principal_id(&self) -> &str {
&self.principal_id
}
pub fn query(
&self,
sql: &str,
params: &[serde_json::Value],
) -> Result<Vec<serde_json::Value>, String> {
match self.raw(sql, params)? {
serde_json::Value::Array(rows) => Ok(rows),
other => Err(format!("expected rows, got {other}")),
}
}
pub fn query_one(
&self,
sql: &str,
params: &[serde_json::Value],
) -> Result<Option<serde_json::Value>, String> {
Ok(self.query(sql, params)?.into_iter().next())
}
pub fn execute(&self, sql: &str, params: &[serde_json::Value]) -> Result<u64, String> {
match self.raw(sql, params)? {
serde_json::Value::Object(map) => Ok(map
.get("rows_affected")
.and_then(|v| v.as_u64())
.unwrap_or(0)),
serde_json::Value::Array(rows) => Ok(rows.len() as u64),
_ => Ok(0),
}
}
fn raw(&self, sql: &str, params: &[serde_json::Value]) -> Result<serde_json::Value, String> {
let request = serde_json::json!({ "sql": sql, "params": params }).to_string();
match self.host.query(RStr::from_str(request.as_str())) {
RResult::ROk(s) => serde_json::from_str(s.as_str()).map_err(|e| e.to_string()),
RResult::RErr(e) => Err(e.into_string()),
}
}
pub fn send_email(&self, email: Email) -> Result<Sent, String> {
let request = serde_json::to_string(&email).map_err(|e| e.to_string())?;
match self.host.send_email(RStr::from_str(&request)) {
RResult::ROk(receipt) => serde_json::from_str(receipt.as_str())
.map_err(|e| format!("unreadable email receipt: {e}")),
RResult::RErr(e) => Err(e.into_string()),
}
}
pub fn cache_get(&self, key: &str) -> Result<Option<serde_json::Value>, String> {
let reply = self.cache(serde_json::json!({ "op": "get", "key": key }))?;
match reply.get("value") {
None | Some(serde_json::Value::Null) => Ok(None),
Some(value) => Ok(Some(value.clone())),
}
}
pub fn cache_get_as<T: serde::de::DeserializeOwned>(
&self,
key: &str,
) -> Result<Option<T>, String> {
Ok(self
.cache_get(key)?
.and_then(|value| serde_json::from_value(value).ok()))
}
pub fn cache_set<T: serde::Serialize>(
&self,
key: &str,
value: &T,
ttl_secs: Option<u64>,
) -> Result<(), String> {
let value = serde_json::to_value(value).map_err(|e| e.to_string())?;
self.cache(serde_json::json!({
"op": "set", "key": key, "value": value, "ttl": ttl_secs
}))?;
Ok(())
}
pub fn cache_delete(&self, key: &str) -> Result<bool, String> {
let reply = self.cache(serde_json::json!({ "op": "delete", "key": key }))?;
Ok(reply
.get("deleted")
.and_then(|v| v.as_bool())
.unwrap_or(false))
}
pub fn cache_incr(&self, key: &str, by: i64, ttl_secs: Option<u64>) -> Result<i64, String> {
let reply = self.cache(serde_json::json!({
"op": "incr", "key": key, "by": by, "ttl": ttl_secs
}))?;
Ok(reply.get("value").and_then(|v| v.as_i64()).unwrap_or(0))
}
pub fn cache_ttl(&self, key: &str) -> Result<Option<i64>, String> {
let reply = self.cache(serde_json::json!({ "op": "ttl", "key": key }))?;
Ok(reply.get("ttl").and_then(|v| v.as_i64()))
}
fn cache(&self, request: serde_json::Value) -> Result<serde_json::Value, String> {
match self.host.cache(RStr::from_str(&request.to_string())) {
RResult::ROk(reply) => serde_json::from_str(reply.as_str())
.map_err(|e| format!("unreadable cache reply: {e}")),
RResult::RErr(e) => Err(e.into_string()),
}
}
pub fn log(&self, level: LogLevel, message: &str) {
self.host.log(level, RStr::from_str(message));
}
pub fn info(&self, message: &str) {
self.log(LogLevel::Info, message);
}
pub fn warn(&self, message: &str) {
self.log(LogLevel::Warn, message);
}
pub fn error(&self, message: &str) {
self.log(LogLevel::Error, message);
}
pub fn debug(&self, message: &str) {
self.log(LogLevel::Debug, message);
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct Email {
pub to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub cc: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub bcc: Vec<String>,
pub subject: String,
#[serde(skip_serializing_if = "String::is_empty", default)]
pub text: String,
#[serde(skip_serializing_if = "String::is_empty", default)]
pub html: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub from: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub reply_to: Option<String>,
}
impl Email {
pub fn to(recipient: impl Into<String>) -> Email {
Email {
to: vec![recipient.into()],
..Email::default()
}
}
pub fn to_all<S: Into<String>>(recipients: impl IntoIterator<Item = S>) -> Email {
Email {
to: recipients.into_iter().map(Into::into).collect(),
..Email::default()
}
}
pub fn cc(mut self, address: impl Into<String>) -> Email {
self.cc.push(address.into());
self
}
pub fn bcc(mut self, address: impl Into<String>) -> Email {
self.bcc.push(address.into());
self
}
pub fn subject(mut self, subject: impl Into<String>) -> Email {
self.subject = subject.into();
self
}
pub fn text(mut self, body: impl Into<String>) -> Email {
self.text = body.into();
self
}
pub fn html(mut self, body: impl Into<String>) -> Email {
self.html = body.into();
self
}
pub fn from(mut self, address: impl Into<String>) -> Email {
self.from = Some(address.into());
self
}
pub fn reply_to(mut self, address: impl Into<String>) -> Email {
self.reply_to = Some(address.into());
self
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Sent {
pub provider: String,
pub id: String,
pub recipients: usize,
}
#[derive(Debug, Clone, Default, serde::Deserialize)]
#[serde(default)]
pub struct Hook {
pub event: String,
pub action: String,
pub phase: String,
pub resource: String,
pub url: String,
pub method: String,
pub query: std::collections::BTreeMap<String, String>,
pub authenticated: bool,
pub principal_id: Option<String>,
pub organization_id: Option<String>,
pub role: Option<String>,
#[serde(default)]
pub roles: Vec<String>,
pub record_id: Option<String>,
pub data: Option<serde_json::Value>,
pub row: Option<serde_json::Value>,
pub rows: Option<Vec<serde_json::Value>>,
}
impl Hook {
pub fn parse(json: &str) -> Option<Hook> {
if json.trim().is_empty() {
return None;
}
serde_json::from_str(json).ok()
}
pub fn is_before(&self) -> bool {
self.phase == "before"
}
pub fn is_after(&self) -> bool {
self.phase == "after"
}
pub fn data(&self) -> &serde_json::Value {
self.data.as_ref().unwrap_or(&serde_json::Value::Null)
}
pub fn row(&self) -> &serde_json::Value {
self.row.as_ref().unwrap_or(&serde_json::Value::Null)
}
pub fn rows(&self) -> &[serde_json::Value] {
self.rows.as_deref().unwrap_or(&[])
}
pub fn field(&self, name: &str) -> Option<&serde_json::Value> {
let subject = if self.data.is_some() {
self.data()
} else {
self.row()
};
subject.get(name)
}
}
pub mod reply {
use serde_json::{json, Value};
pub fn proceed() -> Value {
json!({})
}
pub fn replace(data: Value) -> Value {
json!({ "data": data })
}
pub fn abort(status: u16, message: impl Into<String>) -> Value {
json!({ "error": { "status": status, "message": message.into() } })
}
}
#[doc(hidden)]
pub fn invoke_handler<C, I, O, E, F>(
host: &HostApi_TO<'_, RBox<()>>,
input: RStr<'_>,
handler: F,
) -> RResult<abi_stable::std_types::RString, abi_stable::std_types::RString>
where
C: serde::de::DeserializeOwned + Default,
I: serde::de::DeserializeOwned,
O: serde::Serialize,
E: core::fmt::Display,
F: FnOnce(&Context<'_, '_, C>, I) -> Result<O, E>,
{
use abi_stable::std_types::RString;
use std::panic::{catch_unwind, AssertUnwindSafe};
let outcome = catch_unwind(AssertUnwindSafe(|| {
run_handler::<C, I, O, E, F>(host, input, handler)
}));
match outcome {
Ok(result) => result,
Err(payload) => RResult::RErr(RString::from(format!(
"{}{}",
apiplant_abi::INTERNAL_ERROR_PREFIX,
panic_message(&*payload)
))),
}
}
fn run_handler<C, I, O, E, F>(
host: &HostApi_TO<'_, RBox<()>>,
input: RStr<'_>,
handler: F,
) -> RResult<abi_stable::std_types::RString, abi_stable::std_types::RString>
where
C: serde::de::DeserializeOwned + Default,
I: serde::de::DeserializeOwned,
O: serde::Serialize,
E: core::fmt::Display,
F: FnOnce(&Context<'_, '_, C>, I) -> Result<O, E>,
{
use abi_stable::std_types::RString;
let config: C = serde_json::from_str(host.config().as_str()).unwrap_or_default();
let principal_id = host.principal_id().into_string();
let hook = Hook::parse(host.hook().as_str());
let input: I = match serde_json::from_str(input.as_str()) {
Ok(v) => v,
Err(e) => return RResult::RErr(RString::from(format!("invalid input: {e}"))),
};
let ctx = Context::__new(host, config, principal_id, hook);
match handler(&ctx, input) {
Ok(output) => match serde_json::to_string(&output) {
Ok(s) => RResult::ROk(RString::from(s)),
Err(e) => RResult::RErr(RString::from(format!("failed to serialize output: {e}"))),
},
Err(e) => RResult::RErr(RString::from(e.to_string())),
}
}
fn panic_message(payload: &(dyn core::any::Any + Send)) -> &str {
if let Some(s) = payload.downcast_ref::<&'static str>() {
s
} else if let Some(s) = payload.downcast_ref::<String>() {
s.as_str()
} else {
"panicked"
}
}
type Signature<C, I, O, E> = fn(C, I) -> Result<O, E>;
#[doc(hidden)]
pub struct Exported<C, I, O, E, F> {
manifest: apiplant_abi::FunctionManifest,
handler: F,
_signature: core::marker::PhantomData<Signature<C, I, O, E>>,
}
impl<C, I, O, E, F> Exported<C, I, O, E, F> {
pub fn new(manifest: apiplant_abi::FunctionManifest, handler: F) -> Self {
Exported {
manifest,
handler,
_signature: core::marker::PhantomData,
}
}
}
impl<C, I, O, E, F> apiplant_abi::Function for Exported<C, I, O, E, F>
where
C: serde::de::DeserializeOwned + Default,
I: serde::de::DeserializeOwned,
O: serde::Serialize,
E: core::fmt::Display,
F: Fn(&Context<'_, '_, C>, I) -> Result<O, E> + Send + Sync,
{
fn manifest(&self) -> apiplant_abi::FunctionManifest {
self.manifest.clone()
}
fn invoke(
&self,
host: HostApi_TO<'_, RBox<()>>,
input: RStr<'_>,
) -> RResult<abi_stable::std_types::RString, abi_stable::std_types::RString> {
invoke_handler(&host, input, &self.handler)
}
}
#[doc(hidden)]
#[cfg(feature = "schema")]
pub fn input_schema_json<C, I, O, E, F>(_handler: &F) -> String
where
F: Fn(&Context<'_, '_, C>, I) -> Result<O, E>,
I: schemars::JsonSchema,
{
serde_json::to_string(&schemars::schema_for!(I)).unwrap_or_default()
}
#[doc(hidden)]
#[cfg(feature = "schema")]
pub fn output_schema_json<C, I, O, E, F>(_handler: &F) -> String
where
F: Fn(&Context<'_, '_, C>, I) -> Result<O, E>,
O: schemars::JsonSchema,
{
serde_json::to_string(&schemars::schema_for!(O)).unwrap_or_default()
}
#[doc(hidden)]
#[cfg(not(feature = "schema"))]
pub fn input_schema_json<C, I, O, E, F>(_handler: &F) -> String
where
F: Fn(&Context<'_, '_, C>, I) -> Result<O, E>,
{
String::new()
}
#[doc(hidden)]
#[cfg(not(feature = "schema"))]
pub fn output_schema_json<C, I, O, E, F>(_handler: &F) -> String
where
F: Fn(&Context<'_, '_, C>, I) -> Result<O, E>,
{
String::new()
}
#[doc(hidden)]
pub fn derive_visibility(permission: &str) -> (apiplant_abi::Visibility, String) {
use apiplant_abi::{FunctionAccess, Visibility};
match FunctionAccess::parse(permission) {
Some(FunctionAccess::Public) => (Visibility::Public, String::new()),
Some(FunctionAccess::Authenticated) | Some(FunctionAccess::Member) => {
(Visibility::Authenticated, String::new())
}
Some(FunctionAccess::Role(role)) => (Visibility::RoleGated, role),
Some(FunctionAccess::Private) | None => (Visibility::Private, String::new()),
}
}
#[doc(hidden)]
#[derive(Default)]
pub struct AdminBuilder {
pub visible: Option<bool>,
pub roles: Vec<String>,
pub label: Option<String>,
pub group: Option<String>,
pub description: Option<String>,
pub confirm: Option<String>,
pub run_label: Option<String>,
pub order: Option<i64>,
}
impl AdminBuilder {
pub fn finish(self) -> String {
let mut object = serde_json::Map::new();
let mut put = |key: &str, value: Option<serde_json::Value>| {
if let Some(value) = value {
object.insert(key.to_string(), value);
}
};
put("visible", self.visible.map(serde_json::Value::from));
put("label", self.label.map(serde_json::Value::from));
put("group", self.group.map(serde_json::Value::from));
put("description", self.description.map(serde_json::Value::from));
put("confirm", self.confirm.map(serde_json::Value::from));
put("run_label", self.run_label.map(serde_json::Value::from));
put("order", self.order.map(serde_json::Value::from));
if !self.roles.is_empty() {
object.insert("roles".to_string(), serde_json::Value::from(self.roles));
}
if object.is_empty() {
return String::new();
}
serde_json::to_string(&object).unwrap_or_default()
}
}
pub mod prelude {
pub use crate::{reply, Context, Email, Hook, Sent};
pub use apiplant_abi::{HttpMethod, LogLevel, Visibility};
#[cfg(feature = "schema")]
pub use schemars::JsonSchema;
}
#[doc(hidden)]
pub mod __rt {
pub use crate::{
derive_visibility, input_schema_json, invoke_handler, output_schema_json, AdminBuilder,
Context, Exported, Hook,
};
pub use abi_stable::export_root_module;
pub use abi_stable::prefix_type::PrefixTypeTrait;
pub use abi_stable::sabi_extern_fn;
pub use abi_stable::sabi_trait::TD_Opaque;
pub use abi_stable::std_types::{RBox, RResult, RStr, RString, RVec};
pub use apiplant_abi::{
BoxedFunction, Function, FunctionManifest, FunctionMod, FunctionMod_Ref, Function_TO,
HostApi_TO, HttpMethod, Visibility,
};
}
#[macro_export]
macro_rules! function {
( $($definition:tt)* ) => {
$crate::functions! { { $($definition)* } }
};
}
#[macro_export]
macro_rules! functions {
(
$(
{
name: $name:expr,
$(version: $version:expr,)?
description: $description:expr,
method: $method:ident,
$(visibility: $visibility:ident,)?
$(permission: $permission:expr,)?
$(role: $role:expr,)?
$(admin: {
$(visible: $admin_visible:expr,)?
$(roles: $admin_roles:expr,)?
$(label: $admin_label:expr,)?
$(group: $admin_group:expr,)?
$(description: $admin_description:expr,)?
$(confirm: $admin_confirm:expr,)?
$(run_label: $admin_run_label:expr,)?
$(order: $admin_order:expr,)?
},)?
handler: $handler:path
$(,)?
}
),+
$(,)?
) => {
#[doc(hidden)]
pub mod __apiplant_generated_functions {
use super::*;
#[$crate::__rt::export_root_module]
fn __apiplant_root_module() -> $crate::__rt::FunctionMod_Ref {
use $crate::__rt::PrefixTypeTrait as _;
$crate::__rt::FunctionMod {
new_functions: __apiplant_new_functions,
}
.leak_into_prefix()
}
#[$crate::__rt::sabi_extern_fn]
fn __apiplant_new_functions() -> $crate::__rt::RVec<$crate::__rt::BoxedFunction> {
let mut exported = $crate::__rt::RVec::new();
$(
exported.push({
#[allow(unused_mut)]
let mut version =
$crate::__rt::RString::from(::core::env!("CARGO_PKG_VERSION"));
$( version = $crate::__rt::RString::from($version); )?
#[allow(unused_mut)]
let mut role = ::std::string::String::new();
$( role = ::std::string::String::from($role); )?
#[allow(unused_mut)]
let mut permission = ::std::string::String::new();
$( permission = ::std::string::String::from($permission); )?
#[allow(unused_mut)]
let mut declared_visibility:
::core::option::Option<$crate::__rt::Visibility> =
::core::option::Option::None;
$(
declared_visibility = ::core::option::Option::Some(
$crate::__rt::Visibility::$visibility,
);
)?
let (visibility, role) = match declared_visibility {
::core::option::Option::Some(visibility) => {
if permission.is_empty() {
permission = match visibility {
$crate::__rt::Visibility::Public =>
"public".to_string(),
$crate::__rt::Visibility::Authenticated =>
"authenticated".to_string(),
$crate::__rt::Visibility::Private =>
"private".to_string(),
$crate::__rt::Visibility::RoleGated =>
::std::format!("role:{}", role),
};
}
(visibility, role)
}
::core::option::Option::None => {
let (visibility, derived_role) =
$crate::__rt::derive_visibility(&permission);
let role = if role.is_empty() { derived_role } else { role };
(visibility, role)
}
};
#[allow(unused_mut)]
let mut admin = $crate::__rt::AdminBuilder::default();
$(
$( admin.visible = ::core::option::Option::Some($admin_visible); )?
$(
admin.roles = $admin_roles
.iter()
.map(|role| ::std::string::ToString::to_string(role))
.collect();
)?
$( admin.label =
::core::option::Option::Some($admin_label.to_string()); )?
$( admin.group =
::core::option::Option::Some($admin_group.to_string()); )?
$( admin.description =
::core::option::Option::Some($admin_description.to_string()); )?
$( admin.confirm =
::core::option::Option::Some($admin_confirm.to_string()); )?
$( admin.run_label =
::core::option::Option::Some($admin_run_label.to_string()); )?
$( admin.order = ::core::option::Option::Some($admin_order); )?
)?
let manifest = $crate::__rt::FunctionManifest {
name: $crate::__rt::RString::from($name),
version,
description: $crate::__rt::RString::from($description),
visibility,
role: $crate::__rt::RString::from(role),
method: $crate::__rt::HttpMethod::$method,
permission: $crate::__rt::RString::from(permission),
admin: $crate::__rt::RString::from(admin.finish()),
config_schema: $crate::__rt::RString::new(),
input_schema: $crate::__rt::RString::from(
$crate::__rt::input_schema_json(&$handler),
),
output_schema: $crate::__rt::RString::from(
$crate::__rt::output_schema_json(&$handler),
),
};
$crate::__rt::Function_TO::from_value(
$crate::__rt::Exported::new(manifest, $handler),
$crate::__rt::TD_Opaque,
)
});
)+
exported
}
}
};
}
#[cfg(test)]
mod tests {
use super::*;
use abi_stable::sabi_trait::TD_Opaque;
use abi_stable::std_types::{RResult, RStr, RString};
use apiplant_abi::{HostApi, HostApi_TO, LogLevel};
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
struct MockHost {
config_json: String,
principal_id: String,
hook_json: String,
query_result: Result<String, String>,
service_result: Result<String, String>,
requests: Mutex<Vec<String>>,
service_requests: Mutex<Vec<String>>,
logs: Mutex<Vec<(LogLevel, String)>>,
}
impl MockHost {
fn success(config_json: &str, principal_id: &str, response: serde_json::Value) -> Self {
Self {
config_json: config_json.into(),
principal_id: principal_id.into(),
hook_json: String::new(),
query_result: Ok(response.to_string()),
service_result: Ok("{}".to_string()),
requests: Mutex::new(Vec::new()),
service_requests: Mutex::new(Vec::new()),
logs: Mutex::new(Vec::new()),
}
}
fn with_hook(mut self, hook: serde_json::Value) -> Self {
self.hook_json = hook.to_string();
self
}
fn replying(mut self, reply: serde_json::Value) -> Self {
self.service_result = Ok(reply.to_string());
self
}
fn failing(mut self, error: &str) -> Self {
self.service_result = Err(error.to_string());
self
}
fn last_service_request(&self) -> serde_json::Value {
let requests = self.service_requests.lock().unwrap();
serde_json::from_str(requests.last().expect("no service request was made")).unwrap()
}
fn service(&self, request: RStr<'_>) -> RResult<RString, RString> {
self.service_requests
.lock()
.unwrap()
.push(request.as_str().to_string());
match &self.service_result {
Ok(reply) => RResult::ROk(RString::from(reply.as_str())),
Err(error) => RResult::RErr(RString::from(error.as_str())),
}
}
}
impl HostApi for MockHost {
fn query(&self, request: RStr<'_>) -> RResult<RString, RString> {
self.requests
.lock()
.unwrap()
.push(request.as_str().to_string());
match &self.query_result {
Ok(json) => RResult::ROk(RString::from(json.as_str())),
Err(err) => RResult::RErr(RString::from(err.as_str())),
}
}
fn log(&self, level: LogLevel, message: RStr<'_>) {
self.logs
.lock()
.unwrap()
.push((level, message.as_str().to_string()));
}
fn send_email(&self, request: RStr<'_>) -> RResult<RString, RString> {
self.service(request)
}
fn cache(&self, request: RStr<'_>) -> RResult<RString, RString> {
self.service(request)
}
fn config(&self) -> RString {
self.config_json.clone().into()
}
fn principal_id(&self) -> RString {
self.principal_id.clone().into()
}
fn hook(&self) -> RString {
self.hook_json.clone().into()
}
}
struct Shared(std::sync::Arc<MockHost>);
impl HostApi for Shared {
fn query(&self, request: RStr<'_>) -> RResult<RString, RString> {
self.0.query(request)
}
fn log(&self, level: LogLevel, message: RStr<'_>) {
self.0.log(level, message)
}
fn send_email(&self, request: RStr<'_>) -> RResult<RString, RString> {
self.0.send_email(request)
}
fn cache(&self, request: RStr<'_>) -> RResult<RString, RString> {
self.0.cache(request)
}
fn config(&self) -> RString {
self.0.config()
}
fn principal_id(&self) -> RString {
self.0.principal_id()
}
fn hook(&self) -> RString {
self.0.hook()
}
}
fn shared(mock: MockHost) -> (std::sync::Arc<MockHost>, HostApi_TO<'static, RBox<()>>) {
let mock = std::sync::Arc::new(mock);
let host = HostApi_TO::from_value(Shared(mock.clone()), TD_Opaque);
(mock, host)
}
#[derive(Deserialize)]
struct Config {
greeting: String,
}
impl Default for Config {
fn default() -> Self {
Self {
greeting: "Hello".into(),
}
}
}
#[derive(Deserialize)]
struct Input {
name: String,
}
#[derive(Serialize, serde::Deserialize, schemars::JsonSchema)]
struct Output {
message: String,
}
#[test]
fn context_bridges_queries_execution_and_principal_id() {
let host = MockHost::success("{}", "user-123", serde_json::json!([{ "n": 1 }]));
let host = HostApi_TO::from_value(host, TD_Opaque);
let ctx = Context::__new(&host, (), "user-123".into(), None);
let rows = ctx
.query("SELECT count(*) AS n", &[serde_json::json!(true)])
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(ctx.principal_id(), "user-123");
let request = &host.config().into_string();
assert_eq!(request, "{}");
}
#[test]
fn context_execute_and_logging_use_host_bridge() {
let host = MockHost::success("{}", "user-123", serde_json::json!({ "rows_affected": 3 }));
let host = HostApi_TO::from_value(host, TD_Opaque);
let ctx = Context::__new(&host, (), "user-123".into(), None);
assert_eq!(ctx.execute("DELETE FROM apiplant_post", &[]).unwrap(), 3);
ctx.warn("careful");
}
#[test]
fn send_email_hands_the_host_the_message_and_reads_the_receipt() {
let host = MockHost::success("{}", "u1", serde_json::json!([]))
.replying(serde_json::json!({ "provider": "ses", "id": "abc", "recipients": 2 }));
let host = HostApi_TO::from_value(host, TD_Opaque);
let ctx = Context::__new(&host, (), "u1".into(), None);
let sent = ctx
.send_email(
Email::to("Ann <ann@example.com>")
.cc("bo@example.com")
.subject("Welcome")
.text("Hello")
.reply_to("help@example.com"),
)
.unwrap();
assert_eq!(sent.provider, "ses");
assert_eq!(sent.id, "abc");
assert_eq!(sent.recipients, 2);
}
#[test]
fn an_email_only_carries_the_fields_it_was_given() {
let (mock, host) = shared(
MockHost::success("{}", "u1", serde_json::json!([]))
.replying(serde_json::json!({ "provider": "smtp", "id": "", "recipients": 1 })),
);
let ctx = Context::__new(&host, (), "u1".into(), None);
ctx.send_email(Email::to("ann@example.com").subject("Hi").text("Hello"))
.unwrap();
let request = mock.last_service_request();
assert_eq!(request["to"][0], "ann@example.com");
assert_eq!(request["subject"], "Hi");
assert_eq!(request["text"], "Hello");
assert!(request.get("html").is_none());
assert!(request.get("cc").is_none());
assert!(request.get("from").is_none());
}
#[test]
fn a_provider_failure_surfaces_as_an_error() {
let host = MockHost::success("{}", "u1", serde_json::json!([]))
.failing("sendgrid rejected the message (401): unauthorized");
let host = HostApi_TO::from_value(host, TD_Opaque);
let ctx = Context::__new(&host, (), "u1".into(), None);
let err = ctx
.send_email(Email::to("ann@example.com").subject("Hi").text("Hello"))
.unwrap_err();
assert!(err.contains("401"), "{err}");
}
#[test]
fn cache_get_distinguishes_a_hit_from_a_miss() {
let hit = MockHost::success("{}", "u1", serde_json::json!([]))
.replying(serde_json::json!({ "hit": true, "value": { "eur": 1.1 } }));
let hit = HostApi_TO::from_value(hit, TD_Opaque);
let ctx = Context::__new(&hit, (), "u1".into(), None);
assert_eq!(
ctx.cache_get("rates").unwrap(),
Some(serde_json::json!({ "eur": 1.1 }))
);
let miss = MockHost::success("{}", "u1", serde_json::json!([]))
.replying(serde_json::json!({ "hit": false, "value": null }));
let miss = HostApi_TO::from_value(miss, TD_Opaque);
let ctx = Context::__new(&miss, (), "u1".into(), None);
assert_eq!(ctx.cache_get("rates").unwrap(), None);
}
#[test]
fn cache_get_as_treats_an_unreadable_value_as_a_miss() {
#[derive(serde::Deserialize)]
struct Rates {
#[allow(dead_code)]
eur: f64,
}
let host = MockHost::success("{}", "u1", serde_json::json!([]))
.replying(serde_json::json!({ "hit": true, "value": { "old_shape": true } }));
let host = HostApi_TO::from_value(host, TD_Opaque);
let ctx = Context::__new(&host, (), "u1".into(), None);
assert!(ctx.cache_get_as::<Rates>("rates").unwrap().is_none());
}
#[test]
fn cache_writes_name_their_operation_key_and_ttl() {
let (mock, host) = shared(
MockHost::success("{}", "u1", serde_json::json!([])).replying(
serde_json::json!({ "ok": true, "deleted": true, "value": 3, "ttl": 42 }),
),
);
let ctx = Context::__new(&host, (), "u1".into(), None);
ctx.cache_set("rates", &serde_json::json!({ "eur": 1.1 }), Some(900))
.unwrap();
let request = mock.last_service_request();
assert_eq!(request["op"], "set");
assert_eq!(request["key"], "rates");
assert_eq!(request["value"]["eur"], 1.1);
assert_eq!(request["ttl"], 900);
ctx.cache_set("rates", &1, None).unwrap();
assert!(mock.last_service_request()["ttl"].is_null());
assert_eq!(ctx.cache_incr("hits", 1, Some(60)).unwrap(), 3);
assert_eq!(mock.last_service_request()["op"], "incr");
assert!(ctx.cache_delete("rates").unwrap());
assert_eq!(mock.last_service_request()["op"], "delete");
assert_eq!(ctx.cache_ttl("rates").unwrap(), Some(42));
}
#[test]
fn invoke_handler_uses_default_config_when_host_config_is_invalid() {
let host = MockHost::success("{not-json", "u1", serde_json::json!([]));
let host = HostApi_TO::from_value(host, TD_Opaque);
let result = invoke_handler::<Config, Input, Output, String, _>(
&host,
RStr::from_str(r#"{"name":"Ann"}"#),
|ctx, input| {
Ok(Output {
message: format!("{}, {}!", ctx.config().greeting, input.name),
})
},
);
let json = match result {
RResult::ROk(v) => v.into_string(),
RResult::RErr(e) => panic!("unexpected error: {}", e.into_string()),
};
assert!(json.contains("Hello, Ann!"));
}
#[test]
fn invoke_handler_rejects_invalid_input_json() {
let host = MockHost::success("{}", "u1", serde_json::json!([]));
let host = HostApi_TO::from_value(host, TD_Opaque);
let result = invoke_handler::<Config, Input, Output, String, _>(
&host,
RStr::from_str("{"),
|_ctx, _input| {
Ok(Output {
message: "never".into(),
})
},
);
match result {
RResult::ROk(v) => panic!("unexpected success: {}", v.into_string()),
RResult::RErr(e) => assert!(e.into_string().contains("invalid input")),
}
}
#[test]
fn invoke_handler_turns_a_panicking_handler_into_an_internal_error() {
let host = MockHost::success("{}", "u1", serde_json::json!([]));
let host = HostApi_TO::from_value(host, TD_Opaque);
let result = invoke_handler::<Config, Input, Output, String, _>(
&host,
RStr::from_str(r#"{"name":"Ann"}"#),
|_ctx, _input| panic!("handler exploded"),
);
match result {
RResult::ROk(v) => panic!("unexpected success: {}", v.into_string()),
RResult::RErr(e) => {
let msg = e.into_string();
let detail = msg
.strip_prefix(apiplant_abi::INTERNAL_ERROR_PREFIX)
.expect("a panic must be marked internal so the host answers 500, not 400");
assert_eq!(detail, "handler exploded");
}
}
}
#[test]
fn invoke_handler_catches_panics_from_unwrap_and_indexing() {
for (label, handler) in [
(
"unwrap",
Box::new(
|_: &Context<'_, '_, Config>, input: Input| -> Result<Output, String> {
let missing = input.name.strip_prefix("nonexistent-prefix");
Ok(Output {
message: missing.unwrap().to_string(),
})
},
)
as Box<dyn Fn(&Context<'_, '_, Config>, Input) -> Result<Output, String>>,
),
(
"index",
Box::new(
|_: &Context<'_, '_, Config>, input: Input| -> Result<Output, String> {
let empty: Vec<u8> = Vec::new();
let _ = empty[input.name.len()];
unreachable!()
},
),
),
] {
let host = MockHost::success("{}", "u1", serde_json::json!([]));
let host = HostApi_TO::from_value(host, TD_Opaque);
let result = invoke_handler::<Config, Input, Output, String, _>(
&host,
RStr::from_str(r#"{"name":"Ann"}"#),
handler,
);
match result {
RResult::ROk(_) => panic!("{label}: expected an error"),
RResult::RErr(e) => {
let msg = e.into_string();
assert!(
msg.starts_with(apiplant_abi::INTERNAL_ERROR_PREFIX),
"{label}: not marked internal: {msg}"
);
assert_ne!(
msg,
format!("{}panicked", apiplant_abi::INTERNAL_ERROR_PREFIX),
"{label}: panic message was lost"
);
}
}
}
}
#[test]
fn a_returned_error_is_not_marked_internal() {
let host = MockHost::success("{}", "u1", serde_json::json!([]));
let host = HostApi_TO::from_value(host, TD_Opaque);
let result = invoke_handler::<Config, Input, Output, String, _>(
&host,
RStr::from_str(r#"{"name":"Ann"}"#),
|_ctx, _input| Err("name is taken".to_string()),
);
match result {
RResult::ROk(_) => panic!("expected an error"),
RResult::RErr(e) => assert_eq!(e.into_string(), "name is taken"),
}
}
#[test]
fn a_panic_does_not_cross_the_abi_boundary() {
let manifest = apiplant_abi::FunctionManifest {
name: "boom".into(),
version: "0.0.0".into(),
description: RString::new(),
visibility: apiplant_abi::Visibility::Public,
role: RString::new(),
method: apiplant_abi::HttpMethod::Post,
permission: RString::new(),
admin: RString::new(),
config_schema: RString::new(),
input_schema: RString::new(),
output_schema: RString::new(),
};
let exported = Exported::<Config, Input, Output, String, _>::new(
manifest,
|_ctx: &Context<'_, '_, Config>, _input: Input| -> Result<Output, String> {
panic!("handler exploded")
},
);
let boxed: apiplant_abi::BoxedFunction =
apiplant_abi::Function_TO::from_value(exported, TD_Opaque);
assert_eq!(boxed.manifest().name.as_str(), "boom");
let host = HostApi_TO::from_value(
MockHost::success("{}", "u1", serde_json::json!([])),
TD_Opaque,
);
match boxed.invoke(host, RStr::from_str(r#"{"name":"Ann"}"#)) {
RResult::ROk(v) => panic!("unexpected success: {}", v.into_string()),
RResult::RErr(e) => assert!(e
.into_string()
.starts_with(apiplant_abi::INTERNAL_ERROR_PREFIX)),
}
}
fn hook_context() -> serde_json::Value {
serde_json::json!({
"event": "after_create",
"action": "create",
"phase": "after",
"resource": "post",
"url": "/api/post?draft=true",
"method": "POST",
"query": { "draft": "true" },
"authenticated": true,
"principal_id": "11111111-1111-1111-1111-111111111111",
"organization_id": "22222222-2222-2222-2222-222222222222",
"role": "admin",
"record_id": null,
"data": null,
"row": { "id": "33333333-3333-3333-3333-333333333333", "title": "Hi" },
"rows": null,
})
}
#[test]
fn context_exposes_hook_data_when_invoked_as_a_hook() {
let host = MockHost::success("{}", "u1", serde_json::json!([])).with_hook(hook_context());
let host = HostApi_TO::from_value(host, TD_Opaque);
let hook = Hook::parse(host.hook().as_str());
let ctx = Context::__new(&host, (), "u1".into(), hook);
let hook = ctx.hook().expect("hook context should be present");
assert_eq!(hook.event, "after_create");
assert_eq!(hook.action, "create");
assert!(hook.is_after());
assert!(!hook.is_before());
assert_eq!(hook.resource, "post");
assert_eq!(hook.url, "/api/post?draft=true");
assert_eq!(hook.method, "POST");
assert_eq!(hook.query.get("draft").map(String::as_str), Some("true"));
assert!(hook.authenticated);
assert_eq!(hook.role.as_deref(), Some("admin"));
assert!(hook.roles.is_empty());
assert!(hook.organization_id.is_some());
assert_eq!(hook.record_id, None);
assert_eq!(hook.row()["title"], "Hi");
assert!(hook.data().is_null());
assert!(hook.rows().is_empty());
assert_eq!(hook.field("title").and_then(|v| v.as_str()), Some("Hi"));
}
#[test]
fn hook_is_absent_for_plain_http_invocations() {
let host = MockHost::success("{}", "u1", serde_json::json!([]));
let host = HostApi_TO::from_value(host, TD_Opaque);
let ctx = Context::__new(&host, (), "u1".into(), Hook::parse(host.hook().as_str()));
assert!(ctx.hook().is_none());
assert!(Hook::parse("").is_none());
assert!(Hook::parse(" ").is_none());
assert!(Hook::parse("{not json").is_none());
}
#[test]
fn hook_reads_submitted_data_on_before_events_and_lists_on_after_list() {
let before = Hook::parse(
&serde_json::json!({
"event": "before_create",
"phase": "before",
"data": { "title": "Draft" },
})
.to_string(),
)
.unwrap();
assert!(before.is_before());
assert_eq!(
before.field("title").and_then(|v| v.as_str()),
Some("Draft")
);
assert!(before.row().is_null());
let listed = Hook::parse(
&serde_json::json!({
"event": "after_list",
"phase": "after",
"rows": [{ "id": "a" }, { "id": "b" }],
})
.to_string(),
)
.unwrap();
assert_eq!(listed.rows().len(), 2);
assert_eq!(listed.rows()[1]["id"], "b");
}
#[test]
fn hook_tolerates_missing_and_unknown_fields() {
let sparse = Hook::parse(r#"{"event":"before_delete","surprise":42}"#).unwrap();
assert_eq!(sparse.event, "before_delete");
assert_eq!(sparse.resource, "");
assert!(!sparse.authenticated);
assert!(sparse.principal_id.is_none());
}
#[test]
fn reply_helpers_build_the_host_protocol() {
assert_eq!(reply::proceed(), serde_json::json!({}));
assert_eq!(
reply::replace(serde_json::json!({ "title": "clean" })),
serde_json::json!({ "data": { "title": "clean" } })
);
assert_eq!(
reply::abort(422, "title is required"),
serde_json::json!({ "error": { "status": 422, "message": "title is required" } })
);
}
#[test]
fn invoke_handler_passes_hook_context_through_to_the_handler() {
let host = MockHost::success("{}", "u1", serde_json::json!([])).with_hook(hook_context());
let host = HostApi_TO::from_value(host, TD_Opaque);
let result = invoke_handler::<(), serde_json::Value, serde_json::Value, String, _>(
&host,
RStr::from_str(r#"{"id":"33333333-3333-3333-3333-333333333333","title":"Hi"}"#),
|ctx, input| {
let hook = ctx.hook().ok_or("expected a hook context")?;
assert_eq!(input["title"], "Hi");
Ok(reply::replace(serde_json::json!({
"event": hook.event,
"title": hook.row()["title"],
})))
},
);
let json = match result {
RResult::ROk(v) => v.into_string(),
RResult::RErr(e) => panic!("unexpected error: {}", e.into_string()),
};
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["data"]["event"], "after_create");
assert_eq!(value["data"]["title"], "Hi");
}
#[test]
fn exported_functions_carry_their_own_manifest_and_handler() {
use apiplant_abi::{Function, FunctionManifest, HttpMethod, Visibility};
fn manifest(name: &str) -> FunctionManifest {
FunctionManifest {
name: RString::from(name),
version: RString::from("1.0.0"),
description: RString::from("test"),
visibility: Visibility::Private,
role: RString::new(),
method: HttpMethod::Post,
permission: RString::new(),
admin: RString::new(),
config_schema: RString::new(),
input_schema: RString::new(),
output_schema: RString::new(),
}
}
let before: Exported<(), Input, Output, String, _> = Exported::new(
manifest("post_before_create"),
|_ctx: &Context<'_, '_, ()>, input: Input| {
Ok(Output {
message: format!("before {}", input.name),
})
},
);
let after: Exported<(), Vec<i64>, Output, String, _> = Exported::new(
manifest("post_after_list"),
|_ctx: &Context<'_, '_, ()>, rows: Vec<i64>| {
Ok(Output {
message: format!("after {}", rows.len()),
})
},
);
assert_eq!(before.manifest().name.as_str(), "post_before_create");
assert_eq!(after.manifest().name.as_str(), "post_after_list");
assert_eq!(before.manifest().version.as_str(), "1.0.0");
let new_host = || {
HostApi_TO::from_value(
MockHost::success("{}", "u1", serde_json::json!([])),
TD_Opaque,
)
};
let first = match before.invoke(new_host(), RStr::from_str(r#"{"name":"Ann"}"#)) {
RResult::ROk(v) => v.into_string(),
RResult::RErr(e) => panic!("unexpected error: {}", e.into_string()),
};
assert!(first.contains("before Ann"));
let second = match after.invoke(new_host(), RStr::from_str("[1,2,3]")) {
RResult::ROk(v) => v.into_string(),
RResult::RErr(e) => panic!("unexpected error: {}", e.into_string()),
};
assert!(second.contains("after 3"));
}
#[test]
fn exported_functions_are_abi_trait_objects() {
use apiplant_abi::{FunctionManifest, Function_TO, HttpMethod, Visibility};
let exported: Exported<Config, Input, Output, String, _> = Exported::new(
FunctionManifest {
name: RString::from("greet"),
version: RString::from("0.1.0"),
description: RString::from("test"),
visibility: Visibility::Public,
role: RString::new(),
method: HttpMethod::Post,
permission: RString::new(),
admin: RString::new(),
config_schema: RString::new(),
input_schema: RString::new(),
output_schema: RString::new(),
},
|ctx: &Context<'_, '_, Config>, input: Input| {
Ok(Output {
message: format!("{}, {}!", ctx.config().greeting, input.name),
})
},
);
let boxed = Function_TO::from_value(exported, TD_Opaque);
assert_eq!(boxed.manifest().name.as_str(), "greet");
let host = MockHost::success(r#"{"greeting":"Hi"}"#, "u1", serde_json::json!([]));
let host = HostApi_TO::from_value(host, TD_Opaque);
let reply = match boxed.invoke(host, RStr::from_str(r#"{"name":"Ann"}"#)) {
RResult::ROk(v) => v.into_string(),
RResult::RErr(e) => panic!("unexpected error: {}", e.into_string()),
};
assert!(reply.contains("Hi, Ann!"));
}
#[derive(Deserialize, schemars::JsonSchema)]
struct SchemaInput {
name: String,
}
#[derive(Serialize, schemars::JsonSchema)]
struct SchemaOutput {
ok: bool,
}
#[test]
fn schema_generation_is_typed() {
let handler =
|_ctx: &Context<'_, '_, ()>, input: SchemaInput| -> Result<SchemaOutput, String> {
Ok(SchemaOutput {
ok: !input.name.is_empty(),
})
};
let input_schema = input_schema_json::<(), SchemaInput, SchemaOutput, String, _>(&handler);
let output_schema =
output_schema_json::<(), SchemaInput, SchemaOutput, String, _>(&handler);
assert!(input_schema.contains("\"name\""));
assert!(output_schema.contains("\"ok\""));
}
}