use apiplant_abi::{FunctionManifest, HostApi, HttpMethod, Visibility};
use apiplant_core::App;
use serde_json::{json, Map, Value};
use crate::functions::{FunctionRegistry, HostBridge};
pub fn register_all(registry: &mut FunctionRegistry, app: &App) {
registry.register_builtin(
manifest(
ORGANIZATION_JOIN,
"Resolve the user being added to an organisation, by id or identity.",
),
organization_join,
organization_join_config(app),
);
registry.register_builtin(
manifest(
STRIPE_PRODUCT,
"Mirror a billing_product row into the payment provider.",
),
stripe_product,
String::new(),
);
registry.register_builtin(
manifest(
STRIPE_PRICE,
"Mirror a billing_price row into the payment provider.",
),
stripe_price,
String::new(),
);
}
pub const PREFIX: &str = "apiplant_";
pub const ORGANIZATION_JOIN: &str = "apiplant_organization_join";
pub const STRIPE_PRODUCT: &str = "apiplant_stripe_product";
pub const STRIPE_PRICE: &str = "apiplant_stripe_price";
fn manifest(name: &str, description: &str) -> FunctionManifest {
FunctionManifest {
name: name.into(),
version: env!("CARGO_PKG_VERSION").into(),
description: description.into(),
visibility: Visibility::Private,
role: "".into(),
method: HttpMethod::Post,
permission: "private".into(),
admin: "".into(),
config_schema: "".into(),
input_schema: "".into(),
output_schema: "".into(),
}
}
fn organization_join_config(app: &App) -> String {
let table = |name: &str| {
app.resources
.get(name)
.map(|r| format!("\"{}\"", r.table_name()))
};
let identity_field = app
.resources
.get("user")
.and_then(|r| r.auth.as_ref())
.map(|auth| auth.identity_field.clone())
.unwrap_or_else(|| "email".to_string());
json!({
"user_table": table("user"),
"membership_table": table("membership"),
"identity_field": identity_field,
})
.to_string()
}
pub fn organization_join(bridge: &HostBridge, input: &str) -> Result<String, String> {
let mut data: Map<String, Value> = match serde_json::from_str(input) {
Ok(Value::Object(map)) => map,
_ => return Ok(reject(400, "expected a JSON object")),
};
let config: Value = serde_json::from_str(&bridge.config()).unwrap_or(Value::Null);
let identity_field = config["identity_field"].as_str().unwrap_or("email");
let identity = data
.remove(identity_field)
.and_then(|v| v.as_str().map(str::to_string))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let user_id = match nonempty(data.get("user_id")) {
Some(id) => id,
None => {
let Some(identity) = identity else {
return Ok(reject(
422,
&format!("provide the member's `user_id` or their {identity_field}"),
));
};
let Some(user_table) = config["user_table"].as_str() else {
return Err("the `user` resource is missing".to_string());
};
let sql = format!(
"SELECT id::text AS id FROM {user_table} WHERE lower({identity_field}) = lower($1) LIMIT 1"
);
match first_column(bridge, &sql, vec![Value::String(identity.clone())], "id")? {
Some(id) => id,
None => {
return Ok(reject(
404,
&format!("nobody is registered with that {identity_field}"),
))
}
}
}
};
if let Some(membership_table) = config["membership_table"].as_str() {
let hook: Value = serde_json::from_str(&bridge.hook()).unwrap_or(Value::Null);
if let Some(org) = hook["organization_id"].as_str() {
let sql = format!(
"SELECT id::text AS id FROM {membership_table} \
WHERE organization_id = $1::uuid AND user_id = $2::uuid LIMIT 1"
);
let params = vec![
Value::String(org.to_string()),
Value::String(user_id.clone()),
];
if first_column(bridge, &sql, params, "id")?.is_some() {
return Ok(reject(
409,
"they are already a member of this organization",
));
}
}
}
data.insert("user_id".to_string(), Value::String(user_id));
Ok(json!({ "data": data }).to_string())
}
pub fn stripe_product(bridge: &HostBridge, input: &str) -> Result<String, String> {
let mut data: Map<String, Value> = match serde_json::from_str(input) {
Ok(Value::Object(map)) => map,
_ => return Ok(reject(400, "expected a JSON object")),
};
let hook: Value = serde_json::from_str(&bridge.hook()).unwrap_or(Value::Null);
let current = current_row(bridge, "billing_product", &hook)?;
let field = |name: &str| {
data.get(name)
.or_else(|| current.get(name))
.cloned()
.unwrap_or(Value::Null)
};
let request = json!({
"op": "product",
"stripe_product_id": string_of(&field("stripe_product_id")),
"name": string_of(&field("name")),
"description": string_of(&field("description")),
"active": field("active").as_bool().unwrap_or(true),
"metadata": field("features"),
});
match bridge.payments(request.to_string().as_str().into()) {
abi_stable::std_types::RResult::ROk(reply) => {
let reply: Value = serde_json::from_str(&reply.into_string()).unwrap_or(Value::Null);
if let Some(id) = reply.get("stripe_product_id").and_then(Value::as_str) {
data.insert(
"stripe_product_id".to_string(),
Value::String(id.to_string()),
);
}
Ok(json!({ "data": data }).to_string())
}
abi_stable::std_types::RResult::RErr(e) => Ok(reject(
502,
&format!(
"the payment provider refused this product: {}",
e.into_string()
),
)),
}
}
pub fn stripe_price(bridge: &HostBridge, input: &str) -> Result<String, String> {
let mut data: Map<String, Value> = match serde_json::from_str(input) {
Ok(Value::Object(map)) => map,
_ => return Ok(reject(400, "expected a JSON object")),
};
let hook: Value = serde_json::from_str(&bridge.hook()).unwrap_or(Value::Null);
let current = current_row(bridge, "billing_price", &hook)?;
let field = |name: &str| {
data.get(name)
.or_else(|| current.get(name))
.cloned()
.unwrap_or(Value::Null)
};
let product_id = string_of(&field("product_id"));
if product_id.is_empty() {
return Ok(reject(422, "a price needs the product it belongs to"));
}
let stripe_product_id = match product_stripe_id(bridge, &product_id)? {
Some(id) => id,
None => {
return Ok(reject(
409,
"that product has not been created in Stripe yet; save it again first",
))
}
};
let request = json!({
"op": "price",
"stripe_price_id": string_of(&field("stripe_price_id")),
"stripe_product_id": stripe_product_id,
"nickname": string_of(&field("nickname")),
"unit_amount": field("unit_amount").as_i64().unwrap_or(0),
"currency": string_of(&field("currency")),
"interval": string_of(&field("interval")),
"interval_count": field("interval_count").as_u64().unwrap_or(1),
"trial_days": field("trial_days").as_u64().unwrap_or(0),
"tax_behavior": string_of(&field("tax_behavior")),
"active": field("active").as_bool().unwrap_or(true),
});
match bridge.payments(request.to_string().as_str().into()) {
abi_stable::std_types::RResult::ROk(reply) => {
let reply: Value = serde_json::from_str(&reply.into_string()).unwrap_or(Value::Null);
if let Some(id) = reply.get("stripe_price_id").and_then(Value::as_str) {
data.insert("stripe_price_id".to_string(), Value::String(id.to_string()));
}
Ok(json!({ "data": data }).to_string())
}
abi_stable::std_types::RResult::RErr(e) => Ok(reject(
502,
&format!(
"the payment provider refused this price: {}",
e.into_string()
),
)),
}
}
fn current_row(bridge: &HostBridge, table: &str, hook: &Value) -> Result<Value, String> {
let Some(id) = hook.get("record_id").and_then(Value::as_str) else {
return Ok(Value::Null);
};
let sql = format!("SELECT * FROM {table} WHERE id = $1::uuid LIMIT 1");
let request = json!({ "sql": sql, "params": [id] }).to_string();
let raw = match bridge.query(request.as_str().into()) {
abi_stable::std_types::RResult::ROk(v) => v.into_string(),
abi_stable::std_types::RResult::RErr(e) => return Err(e.into_string()),
};
let rows: Value = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
Ok(rows.get(0).cloned().unwrap_or(Value::Null))
}
fn product_stripe_id(bridge: &HostBridge, product_id: &str) -> Result<Option<String>, String> {
let sql = "SELECT stripe_product_id FROM billing_product WHERE id = $1::uuid LIMIT 1";
let found = first_column(
bridge,
sql,
vec![Value::String(product_id.to_string())],
"stripe_product_id",
)?;
Ok(found.filter(|id| !id.is_empty()))
}
fn string_of(value: &Value) -> String {
match value {
Value::String(text) => text.trim().to_string(),
Value::Null => String::new(),
other => other.to_string(),
}
}
fn reject(status: u16, message: &str) -> String {
json!({ "error": { "status": status, "message": message } }).to_string()
}
fn nonempty(value: Option<&Value>) -> Option<String> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn first_column(
bridge: &HostBridge,
sql: &str,
params: Vec<Value>,
column: &str,
) -> Result<Option<String>, String> {
let request = json!({ "sql": sql, "params": params }).to_string();
let raw = match bridge.query(request.as_str().into()) {
abi_stable::std_types::RResult::ROk(v) => v.into_string(),
abi_stable::std_types::RResult::RErr(e) => return Err(e.into_string()),
};
let rows: Value = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
Ok(rows
.get(0)
.and_then(|row| row.get(column))
.and_then(Value::as_str)
.map(str::to_string))
}
#[cfg(test)]
mod tests {
use super::*;
use apiplant_core::defaults;
fn empty_app() -> App {
let dir = std::env::temp_dir().join(format!(
"apiplant-builtins-{}-{:?}",
std::process::id(),
std::time::SystemTime::now()
));
std::fs::create_dir_all(&dir).unwrap();
let app = App::load(&dir).unwrap();
std::fs::remove_dir_all(&dir).ok();
app
}
#[test]
fn every_builtin_lives_in_the_reserved_namespace() {
let app = empty_app();
let mut registry = FunctionRegistry::default();
register_all(&mut registry, &app);
let names: Vec<String> = registry
.iter()
.map(|f| f.manifest.name.to_string())
.collect();
assert!(!names.is_empty());
for name in &names {
assert!(
name.starts_with(PREFIX),
"`{name}` is missing the `{PREFIX}` prefix"
);
}
}
#[test]
fn the_membership_hook_resolves_to_a_registered_builtin() {
let membership = defaults::parse_builtin(defaults::MEMBERSHIP_TOML);
let hook = membership
.hook(apiplant_core::HookEvent::BeforeCreate)
.expect("membership declares a before_create hook");
assert_eq!(hook, ORGANIZATION_JOIN);
let mut registry = FunctionRegistry::default();
register_all(&mut registry, &empty_app());
assert!(registry.get(hook).is_some());
}
#[test]
fn builtins_are_not_exposed_over_http() {
let mut registry = FunctionRegistry::default();
register_all(&mut registry, &empty_app());
for f in registry.iter() {
assert_eq!(f.manifest.visibility, Visibility::Private);
}
}
}