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
37/// Reserved name prefix. Every built-in wears it so that an app naming a
38/// function of its own can never collide with one by accident — and so that a
39/// hook pointing at `apiplant_…` is visibly the framework's, not the app's.
40pub const PREFIX: &str = "apiplant_";
41
42/// Name of the membership `before_create` built-in, as
43/// [`MEMBERSHIP_TOML`](apiplant_core::defaults::MEMBERSHIP_TOML) declares it.
44pub const ORGANIZATION_JOIN: &str = "apiplant_organization_join";
45
46/// A built-in's manifest: private, POST, version-locked to the framework.
47fn manifest(name: &str, description: &str) -> FunctionManifest {
48    FunctionManifest {
49        name: name.into(),
50        version: env!("CARGO_PKG_VERSION").into(),
51        description: description.into(),
52        visibility: Visibility::Private,
53        role: "".into(),
54        method: HttpMethod::Post,
55        permission: "private".into(),
56        admin: "".into(),
57        config_schema: "".into(),
58        input_schema: "".into(),
59        output_schema: "".into(),
60    }
61}
62
63/// What [`organization_join`] needs to know about *this* app: the physical
64/// tables to query and which user column is the identity people type.
65///
66/// Passed as the function's config because that is how a function receives
67/// deployment facts — a built-in gets it from the loaded schema instead of from
68/// a `functions/<name>.toml`.
69fn organization_join_config(app: &App) -> String {
70    let table = |name: &str| {
71        app.resources
72            .get(name)
73            .map(|r| format!("\"{}\"", r.table_name()))
74    };
75    let identity_field = app
76        .resources
77        .get("user")
78        .and_then(|r| r.auth.as_ref())
79        .map(|auth| auth.identity_field.clone())
80        .unwrap_or_else(|| "email".to_string());
81    json!({
82        "user_table": table("user"),
83        "membership_table": table("membership"),
84        "identity_field": identity_field,
85    })
86    .to_string()
87}
88
89/// `before_create` on `membership`: work out *who* is being added.
90///
91/// The submitted body may name the person either way:
92///
93/// * `user_id` — used as given,
94/// * `email` (whatever the app's identity field is) — looked up here.
95///
96/// The lookup belongs on this side of the API. A member listing users only sees
97/// the people they already share an organisation with (see the `user` model's
98/// `read = "member"`), so the person doing the adding cannot resolve an outsider's
99/// address to an id — which is exactly who they are trying to add. Doing it in a
100/// hook keeps that asymmetry: the address is resolved for the one purpose it was
101/// given for, and nothing about the account comes back.
102///
103/// Rejects, rather than letting the insert fail later:
104///
105/// | Situation | Status |
106/// |-----------|--------|
107/// | neither `user_id` nor an identity | `422` |
108/// | no account with that identity | `404` |
109/// | already a member of this organisation | `409` |
110pub fn organization_join(bridge: &HostBridge, input: &str) -> Result<String, String> {
111    let mut data: Map<String, Value> = match serde_json::from_str(input) {
112        Ok(Value::Object(map)) => map,
113        _ => return Ok(reject(400, "expected a JSON object")),
114    };
115    let config: Value = serde_json::from_str(&bridge.config()).unwrap_or(Value::Null);
116    let identity_field = config["identity_field"].as_str().unwrap_or("email");
117
118    // The identity is an instruction to this hook, not a column on `membership`.
119    let identity = data
120        .remove(identity_field)
121        .and_then(|v| v.as_str().map(str::to_string))
122        .map(|s| s.trim().to_string())
123        .filter(|s| !s.is_empty());
124
125    let user_id = match nonempty(data.get("user_id")) {
126        Some(id) => id,
127        None => {
128            let Some(identity) = identity else {
129                return Ok(reject(
130                    422,
131                    &format!("provide the member's `user_id` or their {identity_field}"),
132                ));
133            };
134            let Some(user_table) = config["user_table"].as_str() else {
135                return Err("the `user` resource is missing".to_string());
136            };
137            let sql = format!(
138                "SELECT id::text AS id FROM {user_table} WHERE lower({identity_field}) = lower($1) LIMIT 1"
139            );
140            match first_column(bridge, &sql, vec![Value::String(identity.clone())], "id")? {
141                Some(id) => id,
142                // Deliberately the same shape of answer as a wrong address on a
143                // login form: it says nothing about who else has an account.
144                None => {
145                    return Ok(reject(
146                        404,
147                        &format!("nobody is registered with that {identity_field}"),
148                    ))
149                }
150            }
151        }
152    };
153
154    // A second membership row in the same organisation is never what the caller
155    // meant, and it would double the person in every listing.
156    if let Some(membership_table) = config["membership_table"].as_str() {
157        let hook: Value = serde_json::from_str(&bridge.hook()).unwrap_or(Value::Null);
158        if let Some(org) = hook["organization_id"].as_str() {
159            let sql = format!(
160                "SELECT id::text AS id FROM {membership_table} \
161                 WHERE organization_id = $1::uuid AND user_id = $2::uuid LIMIT 1"
162            );
163            let params = vec![
164                Value::String(org.to_string()),
165                Value::String(user_id.clone()),
166            ];
167            if first_column(bridge, &sql, params, "id")?.is_some() {
168                return Ok(reject(
169                    409,
170                    "they are already a member of this organization",
171                ));
172            }
173        }
174    }
175
176    data.insert("user_id".to_string(), Value::String(user_id));
177    Ok(json!({ "data": data }).to_string())
178}
179
180/// A hook rejection in the [protocol](crate::hooks) the host understands.
181fn reject(status: u16, message: &str) -> String {
182    json!({ "error": { "status": status, "message": message } }).to_string()
183}
184
185fn nonempty(value: Option<&Value>) -> Option<String> {
186    value
187        .and_then(Value::as_str)
188        .map(str::trim)
189        .filter(|s| !s.is_empty())
190        .map(str::to_string)
191}
192
193/// Run a query and read one column out of its first row, if there is one.
194fn first_column(
195    bridge: &HostBridge,
196    sql: &str,
197    params: Vec<Value>,
198    column: &str,
199) -> Result<Option<String>, String> {
200    let request = json!({ "sql": sql, "params": params }).to_string();
201    let raw = match bridge.query(request.as_str().into()) {
202        abi_stable::std_types::RResult::ROk(v) => v.into_string(),
203        abi_stable::std_types::RResult::RErr(e) => return Err(e.into_string()),
204    };
205    let rows: Value = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
206    Ok(rows
207        .get(0)
208        .and_then(|row| row.get(column))
209        .and_then(Value::as_str)
210        .map(str::to_string))
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use apiplant_core::defaults;
217
218    /// The smallest possible app: built-in resources, nothing else.
219    fn empty_app() -> App {
220        let dir = std::env::temp_dir().join(format!(
221            "apiplant-builtins-{}-{:?}",
222            std::process::id(),
223            std::time::SystemTime::now()
224        ));
225        std::fs::create_dir_all(&dir).unwrap();
226        let app = App::load(&dir).unwrap();
227        std::fs::remove_dir_all(&dir).ok();
228        app
229    }
230
231    /// The namespace is the whole point of the prefix: check it holds for every
232    /// built-in, not just the one that exists today.
233    #[test]
234    fn every_builtin_lives_in_the_reserved_namespace() {
235        let app = empty_app();
236        let mut registry = FunctionRegistry::default();
237        register_all(&mut registry, &app);
238
239        let names: Vec<String> = registry
240            .iter()
241            .map(|f| f.manifest.name.to_string())
242            .collect();
243        assert!(!names.is_empty());
244        for name in &names {
245            assert!(
246                name.starts_with(PREFIX),
247                "`{name}` is missing the `{PREFIX}` prefix"
248            );
249        }
250    }
251
252    /// A built-in referenced by a built-in resource must actually be registered,
253    /// or every write to that resource fails closed with a 500.
254    #[test]
255    fn the_membership_hook_resolves_to_a_registered_builtin() {
256        let membership = defaults::parse_builtin(defaults::MEMBERSHIP_TOML);
257        let hook = membership
258            .hook(apiplant_core::HookEvent::BeforeCreate)
259            .expect("membership declares a before_create hook");
260        assert_eq!(hook, ORGANIZATION_JOIN);
261
262        let mut registry = FunctionRegistry::default();
263        register_all(&mut registry, &empty_app());
264        assert!(registry.get(hook).is_some());
265    }
266
267    #[test]
268    fn builtins_are_not_exposed_over_http() {
269        let mut registry = FunctionRegistry::default();
270        register_all(&mut registry, &empty_app());
271        for f in registry.iter() {
272            assert_eq!(f.manifest.visibility, Visibility::Private);
273        }
274    }
275}