Skip to main content

apiplant_server/
builtins.rs

1//! Functions the framework ships with.
2//!
3//! A built-in is an ordinary Rust `fn` registered in the [function
4//! registry](crate::functions::FunctionRegistry) under a manifest of its own. It
5//! sees the same [`HostBridge`] a dynamically-loaded function does — database,
6//! config, caller, hook context — so anything a built-in does, an app could have
7//! written itself as a `functions/` library, and an app that ships a function
8//! with the same name replaces it.
9//!
10//! They exist for the logic that has to live *behind* the API rather than in
11//! front of it. [`organization_join`] is the example: turning an email address
12//! into a member of an organisation needs a user lookup that the person doing
13//! the adding is deliberately not allowed to perform themselves.
14//!
15//! Built-ins are `private`: they have no HTTP endpoint and are reached only as a
16//! resource's lifecycle hook.
17
18use apiplant_abi::{FunctionManifest, HostApi, HttpMethod, Visibility};
19use apiplant_core::App;
20use serde_json::{json, Map, Value};
21
22use crate::functions::{FunctionRegistry, HostBridge};
23
24/// Register every built-in into a fresh registry. Called by
25/// [`FunctionRegistry::load`].
26pub 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    // The two catalogue hooks are registered whether or not payments are
37    // configured. An app with no provider has no `billing_*` resources, so
38    // nothing points at them — and one that turns payments on gets working
39    // hooks without the registry having to be rebuilt.
40    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
58/// Reserved name prefix. Every built-in wears it so that an app naming a
59/// function of its own can never collide with one by accident — and so that a
60/// hook pointing at `apiplant_…` is visibly the framework's, not the app's.
61pub const PREFIX: &str = "apiplant_";
62
63/// Name of the membership `before_create` built-in, as
64/// [`MEMBERSHIP_TOML`](apiplant_core::defaults::MEMBERSHIP_TOML) declares it.
65pub const ORGANIZATION_JOIN: &str = "apiplant_organization_join";
66
67/// Name of the `billing_product` catalogue hook, as
68/// [`BILLING_PRODUCT_TOML`](apiplant_core::defaults::BILLING_PRODUCT_TOML)
69/// declares it.
70pub const STRIPE_PRODUCT: &str = "apiplant_stripe_product";
71
72/// Name of the `billing_price` catalogue hook, as
73/// [`BILLING_PRICE_TOML`](apiplant_core::defaults::BILLING_PRICE_TOML)
74/// declares it.
75pub const STRIPE_PRICE: &str = "apiplant_stripe_price";
76
77/// A built-in's manifest: private, POST, version-locked to the framework.
78fn 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
94/// What [`organization_join`] needs to know about *this* app: the physical
95/// tables to query and which user column is the identity people type.
96///
97/// Passed as the function's config because that is how a function receives
98/// deployment facts — a built-in gets it from the loaded schema instead of from
99/// a `functions/<name>.toml`.
100fn 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
120/// `before_create` on `membership`: work out *who* is being added.
121///
122/// The submitted body may name the person either way:
123///
124/// * `user_id` — used as given,
125/// * `email` (whatever the app's identity field is) — looked up here.
126///
127/// The lookup belongs on this side of the API. A member listing users only sees
128/// the people they already share an organisation with (see the `user` model's
129/// `read = "member"`), so the person doing the adding cannot resolve an outsider's
130/// address to an id — which is exactly who they are trying to add. Doing it in a
131/// hook keeps that asymmetry: the address is resolved for the one purpose it was
132/// given for, and nothing about the account comes back.
133///
134/// Rejects, rather than letting the insert fail later:
135///
136/// | Situation | Status |
137/// |-----------|--------|
138/// | neither `user_id` nor an identity | `422` |
139/// | no account with that identity | `404` |
140/// | already a member of this organisation | `409` |
141pub 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    // The identity is an instruction to this hook, not a column on `membership`.
150    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                // Deliberately the same shape of answer as a wrong address on a
174                // login form: it says nothing about who else has an account.
175                None => {
176                    return Ok(reject(
177                        404,
178                        &format!("nobody is registered with that {identity_field}"),
179                    ))
180                }
181            }
182        }
183    };
184
185    // A second membership row in the same organisation is never what the caller
186    // meant, and it would double the person in every listing.
187    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
211/// `before_create` / `before_update` on `billing_product`: create or update
212/// the product in Stripe, and write its id into the row being saved.
213///
214/// Running *before* the write is what makes this safe. If Stripe refuses —
215/// a bad key, a rejected name, an outage — the hook rejects and no row is
216/// committed, so the catalogue never contains a plan that cannot be bought.
217/// The other order would leave a product in the app that silently charges
218/// nothing.
219pub 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    // On an update the body carries only what changed — a rename sends
227    // `{"name": …}` and nothing else — but Stripe wants the whole product. So
228    // the row as it stands is read back and the edit is overlaid on it.
229    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        // A row that says nothing about `active` is active: that is the
243        // column's default, and the alternative is archiving a plan in Stripe
244        // because somebody renamed it.
245        "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
270/// `before_create` / `before_update` on `billing_price`: create the price in
271/// Stripe, or replace it when the change is one Stripe won't apply in place.
272///
273/// A Stripe price is immutable in its amount, currency, interval, trial and
274/// tax behaviour. Changing any of them mints a *new* price and archives the
275/// old one, and the id written back here is the new one — so the row is
276/// always pointing at something buyable. Anything already subscribed stays on
277/// the old price at the old amount, which is what the customer agreed to.
278pub 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    // The price belongs to a product, and the product's Stripe id lives on
293    // *its* row — so it has to be read, not guessed.
294    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
340/// The row a `before_update` is editing, or `null` on a create.
341///
342/// The hook context carries the *submitted* body and the record's id, not the
343/// record — which is right for a hook that validates an edit, and not enough
344/// for one that has to restate the whole object to Stripe. A rename that
345/// arrived alone would otherwise be sent as a product with no amount and no
346/// description.
347fn 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
361/// The Stripe id of the product a price points at.
362fn product_stripe_id(bridge: &HostBridge, product_id: &str) -> Result<Option<String>, String> {
363    // The physical table is the resource's own, and `billing_product` is a
364    // built-in whose name an app can override but whose table it cannot —
365    // see `Resource::table_name`.
366    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
376/// A JSON value as the string a Stripe request wants: `null` and a non-string
377/// both become `""`, which every field here reads as "not given".
378fn 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
386/// A hook rejection in the [protocol](crate::hooks) the host understands.
387fn 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
399/// Run a query and read one column out of its first row, if there is one.
400fn 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    /// The smallest possible app: built-in resources, nothing else.
425    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    /// The namespace is the whole point of the prefix: check it holds for every
438    /// built-in, not just the one that exists today.
439    #[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    /// A built-in referenced by a built-in resource must actually be registered,
459    /// or every write to that resource fails closed with a 500.
460    #[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}