1use apiplant_abi::{FunctionManifest, HostApi, HttpMethod, Visibility};
19use apiplant_core::App;
20use serde_json::{json, Map, Value};
21
22use crate::functions::{FunctionRegistry, HostBridge};
23
24pub fn register_all(registry: &mut FunctionRegistry, app: &App) {
27 registry.register_builtin(
28 manifest(
29 ORGANIZATION_JOIN,
30 "Resolve the user being added to an organisation, by id or identity.",
31 ),
32 organization_join,
33 organization_join_config(app),
34 );
35
36 registry.register_builtin(
41 manifest(
42 STRIPE_PRODUCT,
43 "Mirror a billing_product row into the payment provider.",
44 ),
45 stripe_product,
46 String::new(),
47 );
48 registry.register_builtin(
49 manifest(
50 STRIPE_PRICE,
51 "Mirror a billing_price row into the payment provider.",
52 ),
53 stripe_price,
54 String::new(),
55 );
56}
57
58pub const PREFIX: &str = "apiplant_";
62
63pub const ORGANIZATION_JOIN: &str = "apiplant_organization_join";
66
67pub const STRIPE_PRODUCT: &str = "apiplant_stripe_product";
71
72pub const STRIPE_PRICE: &str = "apiplant_stripe_price";
76
77fn manifest(name: &str, description: &str) -> FunctionManifest {
79 FunctionManifest {
80 name: name.into(),
81 version: env!("CARGO_PKG_VERSION").into(),
82 description: description.into(),
83 visibility: Visibility::Private,
84 role: "".into(),
85 method: HttpMethod::Post,
86 permission: "private".into(),
87 admin: "".into(),
88 config_schema: "".into(),
89 input_schema: "".into(),
90 output_schema: "".into(),
91 }
92}
93
94fn organization_join_config(app: &App) -> String {
101 let table = |name: &str| {
102 app.resources
103 .get(name)
104 .map(|r| format!("\"{}\"", r.table_name()))
105 };
106 let identity_field = app
107 .resources
108 .get("user")
109 .and_then(|r| r.auth.as_ref())
110 .map(|auth| auth.identity_field.clone())
111 .unwrap_or_else(|| "email".to_string());
112 json!({
113 "user_table": table("user"),
114 "membership_table": table("membership"),
115 "identity_field": identity_field,
116 })
117 .to_string()
118}
119
120pub fn organization_join(bridge: &HostBridge, input: &str) -> Result<String, String> {
142 let mut data: Map<String, Value> = match serde_json::from_str(input) {
143 Ok(Value::Object(map)) => map,
144 _ => return Ok(reject(400, "expected a JSON object")),
145 };
146 let config: Value = serde_json::from_str(&bridge.config()).unwrap_or(Value::Null);
147 let identity_field = config["identity_field"].as_str().unwrap_or("email");
148
149 let identity = data
151 .remove(identity_field)
152 .and_then(|v| v.as_str().map(str::to_string))
153 .map(|s| s.trim().to_string())
154 .filter(|s| !s.is_empty());
155
156 let user_id = match nonempty(data.get("user_id")) {
157 Some(id) => id,
158 None => {
159 let Some(identity) = identity else {
160 return Ok(reject(
161 422,
162 &format!("provide the member's `user_id` or their {identity_field}"),
163 ));
164 };
165 let Some(user_table) = config["user_table"].as_str() else {
166 return Err("the `user` resource is missing".to_string());
167 };
168 let sql = format!(
169 "SELECT id::text AS id FROM {user_table} WHERE lower({identity_field}) = lower($1) LIMIT 1"
170 );
171 match first_column(bridge, &sql, vec![Value::String(identity.clone())], "id")? {
172 Some(id) => id,
173 None => {
176 return Ok(reject(
177 404,
178 &format!("nobody is registered with that {identity_field}"),
179 ))
180 }
181 }
182 }
183 };
184
185 if let Some(membership_table) = config["membership_table"].as_str() {
188 let hook: Value = serde_json::from_str(&bridge.hook()).unwrap_or(Value::Null);
189 if let Some(org) = hook["organization_id"].as_str() {
190 let sql = format!(
191 "SELECT id::text AS id FROM {membership_table} \
192 WHERE organization_id = $1::uuid AND user_id = $2::uuid LIMIT 1"
193 );
194 let params = vec![
195 Value::String(org.to_string()),
196 Value::String(user_id.clone()),
197 ];
198 if first_column(bridge, &sql, params, "id")?.is_some() {
199 return Ok(reject(
200 409,
201 "they are already a member of this organization",
202 ));
203 }
204 }
205 }
206
207 data.insert("user_id".to_string(), Value::String(user_id));
208 Ok(json!({ "data": data }).to_string())
209}
210
211pub fn stripe_product(bridge: &HostBridge, input: &str) -> Result<String, String> {
220 let mut data: Map<String, Value> = match serde_json::from_str(input) {
221 Ok(Value::Object(map)) => map,
222 _ => return Ok(reject(400, "expected a JSON object")),
223 };
224 let hook: Value = serde_json::from_str(&bridge.hook()).unwrap_or(Value::Null);
225
226 let current = current_row(bridge, "billing_product", &hook)?;
230 let field = |name: &str| {
231 data.get(name)
232 .or_else(|| current.get(name))
233 .cloned()
234 .unwrap_or(Value::Null)
235 };
236
237 let request = json!({
238 "op": "product",
239 "stripe_product_id": string_of(&field("stripe_product_id")),
240 "name": string_of(&field("name")),
241 "description": string_of(&field("description")),
242 "active": field("active").as_bool().unwrap_or(true),
246 "metadata": field("features"),
247 });
248
249 match bridge.payments(request.to_string().as_str().into()) {
250 abi_stable::std_types::RResult::ROk(reply) => {
251 let reply: Value = serde_json::from_str(&reply.into_string()).unwrap_or(Value::Null);
252 if let Some(id) = reply.get("stripe_product_id").and_then(Value::as_str) {
253 data.insert(
254 "stripe_product_id".to_string(),
255 Value::String(id.to_string()),
256 );
257 }
258 Ok(json!({ "data": data }).to_string())
259 }
260 abi_stable::std_types::RResult::RErr(e) => Ok(reject(
261 502,
262 &format!(
263 "the payment provider refused this product: {}",
264 e.into_string()
265 ),
266 )),
267 }
268}
269
270pub fn stripe_price(bridge: &HostBridge, input: &str) -> Result<String, String> {
279 let mut data: Map<String, Value> = match serde_json::from_str(input) {
280 Ok(Value::Object(map)) => map,
281 _ => return Ok(reject(400, "expected a JSON object")),
282 };
283 let hook: Value = serde_json::from_str(&bridge.hook()).unwrap_or(Value::Null);
284 let current = current_row(bridge, "billing_price", &hook)?;
285 let field = |name: &str| {
286 data.get(name)
287 .or_else(|| current.get(name))
288 .cloned()
289 .unwrap_or(Value::Null)
290 };
291
292 let product_id = string_of(&field("product_id"));
295 if product_id.is_empty() {
296 return Ok(reject(422, "a price needs the product it belongs to"));
297 }
298 let stripe_product_id = match product_stripe_id(bridge, &product_id)? {
299 Some(id) => id,
300 None => {
301 return Ok(reject(
302 409,
303 "that product has not been created in Stripe yet; save it again first",
304 ))
305 }
306 };
307
308 let request = json!({
309 "op": "price",
310 "stripe_price_id": string_of(&field("stripe_price_id")),
311 "stripe_product_id": stripe_product_id,
312 "nickname": string_of(&field("nickname")),
313 "unit_amount": field("unit_amount").as_i64().unwrap_or(0),
314 "currency": string_of(&field("currency")),
315 "interval": string_of(&field("interval")),
316 "interval_count": field("interval_count").as_u64().unwrap_or(1),
317 "trial_days": field("trial_days").as_u64().unwrap_or(0),
318 "tax_behavior": string_of(&field("tax_behavior")),
319 "active": field("active").as_bool().unwrap_or(true),
320 });
321
322 match bridge.payments(request.to_string().as_str().into()) {
323 abi_stable::std_types::RResult::ROk(reply) => {
324 let reply: Value = serde_json::from_str(&reply.into_string()).unwrap_or(Value::Null);
325 if let Some(id) = reply.get("stripe_price_id").and_then(Value::as_str) {
326 data.insert("stripe_price_id".to_string(), Value::String(id.to_string()));
327 }
328 Ok(json!({ "data": data }).to_string())
329 }
330 abi_stable::std_types::RResult::RErr(e) => Ok(reject(
331 502,
332 &format!(
333 "the payment provider refused this price: {}",
334 e.into_string()
335 ),
336 )),
337 }
338}
339
340fn current_row(bridge: &HostBridge, table: &str, hook: &Value) -> Result<Value, String> {
348 let Some(id) = hook.get("record_id").and_then(Value::as_str) else {
349 return Ok(Value::Null);
350 };
351 let sql = format!("SELECT * FROM {table} WHERE id = $1::uuid LIMIT 1");
352 let request = json!({ "sql": sql, "params": [id] }).to_string();
353 let raw = match bridge.query(request.as_str().into()) {
354 abi_stable::std_types::RResult::ROk(v) => v.into_string(),
355 abi_stable::std_types::RResult::RErr(e) => return Err(e.into_string()),
356 };
357 let rows: Value = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
358 Ok(rows.get(0).cloned().unwrap_or(Value::Null))
359}
360
361fn product_stripe_id(bridge: &HostBridge, product_id: &str) -> Result<Option<String>, String> {
363 let sql = "SELECT stripe_product_id FROM billing_product WHERE id = $1::uuid LIMIT 1";
367 let found = first_column(
368 bridge,
369 sql,
370 vec![Value::String(product_id.to_string())],
371 "stripe_product_id",
372 )?;
373 Ok(found.filter(|id| !id.is_empty()))
374}
375
376fn string_of(value: &Value) -> String {
379 match value {
380 Value::String(text) => text.trim().to_string(),
381 Value::Null => String::new(),
382 other => other.to_string(),
383 }
384}
385
386fn reject(status: u16, message: &str) -> String {
388 json!({ "error": { "status": status, "message": message } }).to_string()
389}
390
391fn nonempty(value: Option<&Value>) -> Option<String> {
392 value
393 .and_then(Value::as_str)
394 .map(str::trim)
395 .filter(|s| !s.is_empty())
396 .map(str::to_string)
397}
398
399fn first_column(
401 bridge: &HostBridge,
402 sql: &str,
403 params: Vec<Value>,
404 column: &str,
405) -> Result<Option<String>, String> {
406 let request = json!({ "sql": sql, "params": params }).to_string();
407 let raw = match bridge.query(request.as_str().into()) {
408 abi_stable::std_types::RResult::ROk(v) => v.into_string(),
409 abi_stable::std_types::RResult::RErr(e) => return Err(e.into_string()),
410 };
411 let rows: Value = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
412 Ok(rows
413 .get(0)
414 .and_then(|row| row.get(column))
415 .and_then(Value::as_str)
416 .map(str::to_string))
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422 use apiplant_core::defaults;
423
424 fn empty_app() -> App {
426 let dir = std::env::temp_dir().join(format!(
427 "apiplant-builtins-{}-{:?}",
428 std::process::id(),
429 std::time::SystemTime::now()
430 ));
431 std::fs::create_dir_all(&dir).unwrap();
432 let app = App::load(&dir).unwrap();
433 std::fs::remove_dir_all(&dir).ok();
434 app
435 }
436
437 #[test]
440 fn every_builtin_lives_in_the_reserved_namespace() {
441 let app = empty_app();
442 let mut registry = FunctionRegistry::default();
443 register_all(&mut registry, &app);
444
445 let names: Vec<String> = registry
446 .iter()
447 .map(|f| f.manifest.name.to_string())
448 .collect();
449 assert!(!names.is_empty());
450 for name in &names {
451 assert!(
452 name.starts_with(PREFIX),
453 "`{name}` is missing the `{PREFIX}` prefix"
454 );
455 }
456 }
457
458 #[test]
461 fn the_membership_hook_resolves_to_a_registered_builtin() {
462 let membership = defaults::parse_builtin(defaults::MEMBERSHIP_TOML);
463 let hook = membership
464 .hook(apiplant_core::HookEvent::BeforeCreate)
465 .expect("membership declares a before_create hook");
466 assert_eq!(hook, ORGANIZATION_JOIN);
467
468 let mut registry = FunctionRegistry::default();
469 register_all(&mut registry, &empty_app());
470 assert!(registry.get(hook).is_some());
471 }
472
473 #[test]
474 fn builtins_are_not_exposed_over_http() {
475 let mut registry = FunctionRegistry::default();
476 register_all(&mut registry, &empty_app());
477 for f in registry.iter() {
478 assert_eq!(f.manifest.visibility, Visibility::Private);
479 }
480 }
481}