Skip to main content

apiplant_server/
functions.rs

1//! Loading and invoking dynamically-compiled function libraries.
2//!
3//! At boot the framework scans the app's `functions/` directory for artifacts
4//! `apiplant build` produced: shared libraries, loaded through the
5//! [`apiplant_abi`] contract, and `.js` modules, loaded into V8 isolates by
6//! [`apiplant_js`]. Both arrive as the same [`BoxedFunction`], so the difference
7//! stops at the loader.
8//!
9//! At request time the framework constructs a [`HostBridge`] (database + config +
10//! caller identity) and calls the function on a blocking worker so the function
11//! author never has to deal with async.
12
13use std::collections::BTreeMap;
14use std::path::Path;
15
16use abi_stable::library::{lib_header_from_raw_library, RawLibrary};
17use abi_stable::sabi_trait::TD_Opaque;
18use abi_stable::std_types::{RResult, RStr, RString};
19use apiplant_abi::{
20    BoxedFunction, FunctionManifest, FunctionMod_Ref, HostApi, HostApi_TO, LogLevel,
21};
22use apiplant_cache::Cache;
23use apiplant_db::Db;
24use apiplant_email::Mailer;
25
26/// A function implemented in the framework itself rather than loaded from a
27/// library: an ordinary Rust `fn` over the same [`HostBridge`] a dynamic
28/// function sees. See [`crate::builtins`].
29pub type BuiltinHandler = fn(&HostBridge, &str) -> Result<String, String>;
30
31/// Where a registered function's code lives. Nothing outside this module cares
32/// which it is: both arrive through [`LoadedFunction::invoke`].
33enum Body {
34    /// Loaded from a shared library in `functions/`.
35    Dynamic(BoxedFunction),
36    /// Compiled into the server (see [`crate::builtins`]).
37    Builtin(BuiltinHandler),
38}
39
40/// One loaded function plus its resolved config.
41pub struct LoadedFunction {
42    pub manifest: FunctionManifest,
43    /// Config JSON merged from `functions/<name>.toml` (or `{}` if absent).
44    /// For a built-in, whatever the framework handed it at registration.
45    pub config_json: String,
46    body: Body,
47}
48
49impl LoadedFunction {
50    /// Wrap an already-constructed function instance. Used by [`FunctionRegistry::load_dir`]
51    /// and by hosts that link functions in statically instead of loading `.so`s.
52    pub fn new(func: BoxedFunction, config_json: String) -> Self {
53        LoadedFunction {
54            manifest: func.manifest(),
55            config_json,
56            body: Body::Dynamic(func),
57        }
58    }
59
60    /// Wrap a built-in: a handler the framework provides, with a manifest it
61    /// declares rather than one read across the ABI.
62    pub fn builtin(
63        manifest: FunctionManifest,
64        handler: BuiltinHandler,
65        config_json: String,
66    ) -> Self {
67        LoadedFunction {
68            manifest,
69            config_json,
70            body: Body::Builtin(handler),
71        }
72    }
73
74    /// Invoke the function. Must be called from a blocking context (see
75    /// [`FunctionRegistry`] docs) because the host bridge blocks on the DB.
76    pub fn invoke(&self, bridge: HostBridge, input: &str) -> Result<String, String> {
77        match &self.body {
78            Body::Builtin(handler) => handler(&bridge, input),
79            Body::Dynamic(func) => {
80                let host = HostApi_TO::from_value(bridge, TD_Opaque);
81                match func.invoke(host, RStr::from_str(input)) {
82                    RResult::ROk(s) => Ok(s.into_string()),
83                    RResult::RErr(e) => Err(e.into_string()),
84                }
85            }
86        }
87    }
88}
89
90/// All loaded functions, keyed by manifest name.
91#[derive(Default)]
92pub struct FunctionRegistry {
93    functions: BTreeMap<String, LoadedFunction>,
94}
95
96impl FunctionRegistry {
97    /// The registry an app runs with: the framework's [built-ins](crate::builtins)
98    /// first, then everything in the app's `functions/` directory.
99    ///
100    /// Built-ins live in the reserved [`apiplant_`](crate::builtins::PREFIX)
101    /// namespace, so an app function can't shadow one by accident. Naming one
102    /// into that namespace on purpose still replaces the built-in — the escape
103    /// hatch for an app that wants the hook but not our version of it — and says
104    /// so in the log.
105    pub fn load(app: &apiplant_core::App) -> Self {
106        let mut registry = FunctionRegistry::default();
107        crate::builtins::register_all(&mut registry, app);
108        for (name, f) in Self::load_dir(&app.functions_dir).functions {
109            if registry.functions.contains_key(&name) {
110                tracing::warn!(function = %name, "app function replaces the built-in of the same name");
111            }
112            registry.functions.insert(name, f);
113        }
114        registry
115    }
116
117    /// Add a built-in under its manifest name. See [`crate::builtins`].
118    pub fn register_builtin(
119        &mut self,
120        manifest: FunctionManifest,
121        handler: BuiltinHandler,
122        config_json: String,
123    ) {
124        let loaded = LoadedFunction::builtin(manifest, handler, config_json);
125        self.functions
126            .insert(loaded.manifest.name.to_string(), loaded);
127    }
128
129    /// Scan a directory for function libraries and load them all. Missing dir =
130    /// empty registry. A single bad library is logged and skipped, never fatal.
131    pub fn load_dir(dir: &Path) -> Self {
132        let mut registry = FunctionRegistry::default();
133        let entries = match std::fs::read_dir(dir) {
134            Ok(e) => e,
135            Err(_) => {
136                tracing::info!(dir = %dir.display(), "no functions/ directory");
137                return registry;
138            }
139        };
140        for entry in entries.flatten() {
141            let path = entry.path();
142            // Two kinds of function artifact live here: a shared library, and
143            // the JavaScript `apiplant build` produced from a `.ts`. Loading is
144            // the only place the difference shows.
145            let loadable = matches!(
146                path.extension().and_then(|e| e.to_str()),
147                Some("so") | Some("dylib") | Some("dll") | Some(apiplant_js::EXTENSION)
148            );
149            if !loadable {
150                continue;
151            }
152            match Self::load_library(&path) {
153                Ok(loaded) => {
154                    for f in loaded {
155                        tracing::info!(
156                            function = %f.manifest.name,
157                            version = %f.manifest.version,
158                            library = %path.display(),
159                            "loaded function"
160                        );
161                        registry.functions.insert(f.manifest.name.to_string(), f);
162                    }
163                }
164                Err(e) => {
165                    tracing::error!(path = %path.display(), error = %e, "failed to load function")
166                }
167            }
168        }
169        registry
170    }
171
172    /// Load every function a library exports. One library commonly provides a
173    /// set of related functions — a resource's lifecycle hooks, say — each with
174    /// its own name and manifest.
175    ///
176    /// Two ABIs are accepted. A library built with `apiplant-function` exports an
177    /// [`abi_stable`] root module and is tried first; one written in C, Zig or Go
178    /// exports the [plain C symbols](apiplant_abi::c) instead. Both arrive here as
179    /// [`BoxedFunction`]s, so nothing downstream knows the difference.
180    fn load_library(path: &Path) -> Result<Vec<LoadedFunction>, String> {
181        // A `.js` never speaks either native ABI: it is a module for a V8
182        // isolate, and `apiplant_js` gives back the same `BoxedFunction`s, so
183        // everything below this line is shared with the compiled languages.
184        let exported = if path.extension().and_then(|e| e.to_str()) == Some(apiplant_js::EXTENSION)
185        {
186            apiplant_js::load(path)?.into()
187        } else {
188            Self::load_native(path)?
189        };
190        Self::wrap(path, exported)
191    }
192
193    /// Load a shared library through whichever of the two native ABIs it speaks.
194    fn load_native(path: &Path) -> Result<abi_stable::std_types::RVec<BoxedFunction>, String> {
195        let exported = match Self::open(path) {
196            Ok(module) => module.new_functions()(),
197            Err(rust_abi_error) => match crate::cabi::load(path)? {
198                Some(functions) => functions.into(),
199                // Not a C-ABI library either, so the original failure is the
200                // one worth reporting.
201                None => return Err(rust_abi_error),
202            },
203        };
204        Ok(exported)
205    }
206
207    /// Turn the functions a library exported into registry entries: check the
208    /// names, then resolve each one's config file.
209    fn wrap(
210        path: &Path,
211        exported: abi_stable::std_types::RVec<BoxedFunction>,
212    ) -> Result<Vec<LoadedFunction>, String> {
213        if exported.is_empty() {
214            return Err("library exports no functions".to_string());
215        }
216
217        let mut loaded: Vec<LoadedFunction> = Vec::with_capacity(exported.len());
218        for func in exported {
219            let manifest = func.manifest();
220            let name = manifest.name.to_string();
221            if loaded.iter().any(|f| f.manifest.name == manifest.name) {
222                return Err(format!("library exports two functions named `{name}`"));
223            }
224
225            // Per-deployment config: functions/<name>.toml → JSON. Each function
226            // in a library reads its own file.
227            let config_path = path.with_file_name(format!("{name}.toml"));
228            // Expanded like every other app-directory TOML, so a function's
229            // config can hold `api_key = "$STRIPE_KEY"` rather than the key.
230            let config_json = std::fs::read_to_string(&config_path)
231                .ok()
232                .and_then(|t| toml::from_str::<toml::Value>(&t).ok())
233                .map(|mut v| {
234                    apiplant_core::expand_document(&mut v, &format!("{name}.toml"));
235                    v
236                })
237                .and_then(|v| serde_json::to_string(&v).ok())
238                .unwrap_or_else(|| "{}".to_string());
239
240            loaded.push(LoadedFunction {
241                manifest,
242                config_json,
243                body: Body::Dynamic(func),
244            });
245        }
246        Ok(loaded)
247    }
248
249    /// Open one library and return its root module, with the ABI version and
250    /// layout checked.
251    ///
252    /// Deliberately *not* [`RootModule::load_from_file`]: that caches the first
253    /// library it ever loads in a process-wide static and hands the same root
254    /// module back for every later path, so an app with more than one library in
255    /// `functions/` would silently get the first one's functions repeatedly.
256    /// Going through the header directly keeps each library separate.
257    fn open(path: &Path) -> Result<FunctionMod_Ref, String> {
258        let library = RawLibrary::load_at(path).map_err(|e| e.to_string())?;
259
260        // The library must outlive every function it exports; abi_stable never
261        // unloads, so leaking it is the supported way to keep its code mapped.
262        let library: &'static RawLibrary = Box::leak(Box::new(library));
263
264        // SAFETY: `library` is leaked above, so the `&'static LibHeader` this
265        // returns stays valid for the rest of the process.
266        let header = unsafe { lib_header_from_raw_library(library).map_err(|e| e.to_string())? };
267        header
268            .init_root_module::<FunctionMod_Ref>()
269            .map_err(|e| e.to_string())
270    }
271
272    /// Add a function that wasn't loaded from disk, replacing any function of
273    /// the same name. Lets a host embed functions directly.
274    pub fn register(&mut self, func: BoxedFunction, config_json: String) {
275        let loaded = LoadedFunction::new(func, config_json);
276        self.functions
277            .insert(loaded.manifest.name.to_string(), loaded);
278    }
279
280    pub fn get(&self, name: &str) -> Option<&LoadedFunction> {
281        self.functions.get(name)
282    }
283
284    pub fn iter(&self) -> impl Iterator<Item = &LoadedFunction> {
285        self.functions.values()
286    }
287}
288
289/// Host-side services handed to a function for one invocation.
290///
291/// Constructed on the async side (capturing a runtime [`Handle`]) and moved into
292/// the blocking worker; its [`HostApi::query`] blocks on the async database via
293/// that handle, which is safe because functions run outside any async context.
294/// [`HostApi::send_email`] and [`HostApi::cache`] work the same way.
295pub struct HostBridge {
296    db: Db,
297    handle: tokio::runtime::Handle,
298    /// The app's configured mailer, when it has one. Built once at boot and
299    /// shared, so a function sending mail reuses pooled connections.
300    mailer: Option<Mailer>,
301    /// The app's configured cache, when it has one.
302    cache: Option<Cache>,
303    config_json: String,
304    principal_id: String,
305    /// Lifecycle-hook context JSON, or empty for a plain HTTP invocation.
306    hook_json: String,
307}
308
309impl HostBridge {
310    pub fn new(
311        db: Db,
312        handle: tokio::runtime::Handle,
313        config_json: String,
314        principal_id: String,
315    ) -> Self {
316        HostBridge {
317            db,
318            handle,
319            mailer: None,
320            cache: None,
321            config_json,
322            principal_id,
323            hook_json: String::new(),
324        }
325    }
326
327    /// Lend the function the app's email provider and cache.
328    ///
329    /// Both are optional and both stay `None` when the app configured neither,
330    /// which is what makes `send_email` and `cache` fail with "not configured"
331    /// rather than silently doing nothing.
332    pub fn with_services(mut self, mailer: Option<Mailer>, cache: Option<Cache>) -> Self {
333        self.mailer = mailer;
334        self.cache = cache;
335        self
336    }
337
338    /// Mark this invocation as a resource lifecycle hook and attach its context.
339    pub fn with_hook(mut self, hook_json: String) -> Self {
340        self.hook_json = hook_json;
341        self
342    }
343}
344
345impl HostApi for HostBridge {
346    fn query(&self, request: RStr<'_>) -> RResult<RString, RString> {
347        #[derive(serde::Deserialize)]
348        struct Req {
349            sql: String,
350            #[serde(default)]
351            params: Vec<serde_json::Value>,
352        }
353        let req: Req = match serde_json::from_str(request.as_str()) {
354            Ok(r) => r,
355            Err(e) => return RResult::RErr(format!("invalid query request: {e}").into()),
356        };
357        let result = self
358            .handle
359            .block_on(async { self.db.raw_json(&req.sql, &req.params).await });
360        match result {
361            Ok(v) => RResult::ROk(v.to_string().into()),
362            Err(e) => RResult::RErr(e.to_string().into()),
363        }
364    }
365
366    fn send_email(&self, request: RStr<'_>) -> RResult<RString, RString> {
367        let Some(mailer) = &self.mailer else {
368            return RResult::RErr(
369                "no email provider configured — set [email] provider in main.toml"
370                    .to_string()
371                    .into(),
372            );
373        };
374        let message: apiplant_email::Message = match serde_json::from_str(request.as_str()) {
375            Ok(m) => m,
376            Err(e) => return RResult::RErr(format!("invalid email: {e}").into()),
377        };
378        match self.handle.block_on(mailer.send(&message)) {
379            Ok(sent) => RResult::ROk(
380                serde_json::to_string(&sent)
381                    .unwrap_or_else(|_| "{}".to_string())
382                    .into(),
383            ),
384            Err(e) => RResult::RErr(e.to_string().into()),
385        }
386    }
387
388    fn cache(&self, request: RStr<'_>) -> RResult<RString, RString> {
389        let Some(cache) = &self.cache else {
390            return RResult::RErr(
391                "no cache configured — set [cache] url in main.toml"
392                    .to_string()
393                    .into(),
394            );
395        };
396        match self.handle.block_on(cache.execute(request.as_str())) {
397            Ok(value) => RResult::ROk(value.to_string().into()),
398            Err(e) => RResult::RErr(e.to_string().into()),
399        }
400    }
401
402    fn log(&self, level: LogLevel, message: RStr<'_>) {
403        let msg = message.as_str();
404        match level {
405            LogLevel::Trace => tracing::trace!(target: "apiplant::function", "{msg}"),
406            LogLevel::Debug => tracing::debug!(target: "apiplant::function", "{msg}"),
407            LogLevel::Info => tracing::info!(target: "apiplant::function", "{msg}"),
408            LogLevel::Warn => tracing::warn!(target: "apiplant::function", "{msg}"),
409            LogLevel::Error => tracing::error!(target: "apiplant::function", "{msg}"),
410        }
411    }
412
413    fn config(&self) -> RString {
414        self.config_json.clone().into()
415    }
416
417    fn principal_id(&self) -> RString {
418        self.principal_id.clone().into()
419    }
420
421    fn hook(&self) -> RString {
422        self.hook_json.clone().into()
423    }
424}