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_ai::Ai;
23use apiplant_cache::Cache;
24use apiplant_db::Db;
25use apiplant_email::Mailer;
26use apiplant_payments::Payments;
27
28/// A function implemented in the framework itself rather than loaded from a
29/// library: an ordinary Rust `fn` over the same [`HostBridge`] a dynamic
30/// function sees. See [`crate::builtins`].
31pub type BuiltinHandler = fn(&HostBridge, &str) -> Result<String, String>;
32
33/// Where a registered function's code lives. Nothing outside this module cares
34/// which it is: both arrive through [`LoadedFunction::invoke`].
35enum Body {
36    /// Loaded from a shared library in `functions/`.
37    Dynamic(BoxedFunction),
38    /// Compiled into the server (see [`crate::builtins`]).
39    Builtin(BuiltinHandler),
40}
41
42/// One loaded function plus its resolved config.
43pub struct LoadedFunction {
44    pub manifest: FunctionManifest,
45    /// Config JSON merged from `functions/<name>.toml` (or `{}` if absent).
46    /// For a built-in, whatever the framework handed it at registration.
47    pub config_json: String,
48    body: Body,
49}
50
51impl LoadedFunction {
52    /// Wrap an already-constructed function instance. Used by [`FunctionRegistry::load_dir`]
53    /// and by hosts that link functions in statically instead of loading `.so`s.
54    pub fn new(func: BoxedFunction, config_json: String) -> Self {
55        LoadedFunction {
56            manifest: func.manifest(),
57            config_json,
58            body: Body::Dynamic(func),
59        }
60    }
61
62    /// Wrap a built-in: a handler the framework provides, with a manifest it
63    /// declares rather than one read across the ABI.
64    pub fn builtin(
65        manifest: FunctionManifest,
66        handler: BuiltinHandler,
67        config_json: String,
68    ) -> Self {
69        LoadedFunction {
70            manifest,
71            config_json,
72            body: Body::Builtin(handler),
73        }
74    }
75
76    /// Invoke the function. Must be called from a blocking context (see
77    /// [`FunctionRegistry`] docs) because the host bridge blocks on the DB.
78    pub fn invoke(&self, bridge: HostBridge, input: &str) -> Result<String, String> {
79        match &self.body {
80            Body::Builtin(handler) => handler(&bridge, input),
81            Body::Dynamic(func) => {
82                let host = HostApi_TO::from_value(bridge, TD_Opaque);
83                match func.invoke(host, RStr::from_str(input)) {
84                    RResult::ROk(s) => Ok(s.into_string()),
85                    RResult::RErr(e) => Err(e.into_string()),
86                }
87            }
88        }
89    }
90}
91
92/// All loaded functions, keyed by manifest name.
93#[derive(Default)]
94pub struct FunctionRegistry {
95    functions: BTreeMap<String, LoadedFunction>,
96}
97
98impl FunctionRegistry {
99    /// The registry an app runs with: the framework's [built-ins](crate::builtins)
100    /// first, then everything in the app's `functions/` directory.
101    ///
102    /// Built-ins live in the reserved [`apiplant_`](crate::builtins::PREFIX)
103    /// namespace, so an app function can't shadow one by accident. Naming one
104    /// into that namespace on purpose still replaces the built-in — the escape
105    /// hatch for an app that wants the hook but not our version of it — and says
106    /// so in the log.
107    pub fn load(app: &apiplant_core::App) -> Self {
108        let mut registry = FunctionRegistry::default();
109        crate::builtins::register_all(&mut registry, app);
110        for (name, f) in Self::load_dir(&app.functions_dir).functions {
111            if registry.functions.contains_key(&name) {
112                tracing::warn!(function = %name, "app function replaces the built-in of the same name");
113            }
114            registry.functions.insert(name, f);
115        }
116        registry
117    }
118
119    /// Add a built-in under its manifest name. See [`crate::builtins`].
120    pub fn register_builtin(
121        &mut self,
122        manifest: FunctionManifest,
123        handler: BuiltinHandler,
124        config_json: String,
125    ) {
126        let loaded = LoadedFunction::builtin(manifest, handler, config_json);
127        self.functions
128            .insert(loaded.manifest.name.to_string(), loaded);
129    }
130
131    /// Scan a directory for function libraries and load them all. Missing dir =
132    /// empty registry. A single bad library is logged and skipped, never fatal.
133    pub fn load_dir(dir: &Path) -> Self {
134        let mut registry = FunctionRegistry::default();
135        let entries = match std::fs::read_dir(dir) {
136            Ok(e) => e,
137            Err(_) => {
138                tracing::info!(dir = %dir.display(), "no functions/ directory");
139                return registry;
140            }
141        };
142        for entry in entries.flatten() {
143            let path = entry.path();
144            // Two kinds of function artifact live here: a shared library, and
145            // the JavaScript `apiplant build` produced from a `.ts`. Loading is
146            // the only place the difference shows.
147            let loadable = matches!(
148                path.extension().and_then(|e| e.to_str()),
149                Some("so") | Some("dylib") | Some("dll") | Some(apiplant_js::EXTENSION)
150            );
151            if !loadable {
152                continue;
153            }
154            match Self::load_library(&path) {
155                Ok(loaded) => {
156                    for f in loaded {
157                        tracing::info!(
158                            function = %f.manifest.name,
159                            version = %f.manifest.version,
160                            library = %path.display(),
161                            "loaded function"
162                        );
163                        registry.functions.insert(f.manifest.name.to_string(), f);
164                    }
165                }
166                Err(e) => {
167                    tracing::error!(path = %path.display(), error = %e, "failed to load function")
168                }
169            }
170        }
171        registry
172    }
173
174    /// Load every function a library exports. One library commonly provides a
175    /// set of related functions — a resource's lifecycle hooks, say — each with
176    /// its own name and manifest.
177    ///
178    /// Two ABIs are accepted. A library built with `apiplant-function` exports an
179    /// [`abi_stable`] root module and is tried first; one written in C, Zig or Go
180    /// exports the [plain C symbols](apiplant_abi::c) instead. Both arrive here as
181    /// [`BoxedFunction`]s, so nothing downstream knows the difference.
182    fn load_library(path: &Path) -> Result<Vec<LoadedFunction>, String> {
183        // A `.js` never speaks either native ABI: it is a module for a V8
184        // isolate, and `apiplant_js` gives back the same `BoxedFunction`s, so
185        // everything below this line is shared with the compiled languages.
186        let exported = if path.extension().and_then(|e| e.to_str()) == Some(apiplant_js::EXTENSION)
187        {
188            apiplant_js::load(path)?.into()
189        } else {
190            Self::load_native(path)?
191        };
192        Self::wrap(path, exported)
193    }
194
195    /// Load a shared library through whichever of the two native ABIs it speaks.
196    fn load_native(path: &Path) -> Result<abi_stable::std_types::RVec<BoxedFunction>, String> {
197        let exported = match Self::open(path) {
198            Ok(module) => module.new_functions()(),
199            Err(rust_abi_error) => match crate::cabi::load(path)? {
200                Some(functions) => functions.into(),
201                // Not a C-ABI library either, so the original failure is the
202                // one worth reporting.
203                None => return Err(rust_abi_error),
204            },
205        };
206        Ok(exported)
207    }
208
209    /// Turn the functions a library exported into registry entries: check the
210    /// names, then resolve each one's config file.
211    fn wrap(
212        path: &Path,
213        exported: abi_stable::std_types::RVec<BoxedFunction>,
214    ) -> Result<Vec<LoadedFunction>, String> {
215        if exported.is_empty() {
216            return Err("library exports no functions".to_string());
217        }
218
219        let mut loaded: Vec<LoadedFunction> = Vec::with_capacity(exported.len());
220        for func in exported {
221            let manifest = func.manifest();
222            let name = manifest.name.to_string();
223            if loaded.iter().any(|f| f.manifest.name == manifest.name) {
224                return Err(format!("library exports two functions named `{name}`"));
225            }
226
227            // Per-deployment config: functions/<name>.toml → JSON. Each function
228            // in a library reads its own file.
229            let config_path = path.with_file_name(format!("{name}.toml"));
230            // Expanded like every other app-directory TOML, so a function's
231            // config can hold `api_key = "$STRIPE_KEY"` rather than the key.
232            let config_json = std::fs::read_to_string(&config_path)
233                .ok()
234                .and_then(|t| toml::from_str::<toml::Value>(&t).ok())
235                .map(|mut v| {
236                    apiplant_core::expand_document(&mut v, &format!("{name}.toml"));
237                    v
238                })
239                .and_then(|v| serde_json::to_string(&v).ok())
240                .unwrap_or_else(|| "{}".to_string());
241
242            loaded.push(LoadedFunction {
243                manifest,
244                config_json,
245                body: Body::Dynamic(func),
246            });
247        }
248        Ok(loaded)
249    }
250
251    /// Open one library and return its root module, with the ABI version and
252    /// layout checked.
253    ///
254    /// Deliberately *not* [`RootModule::load_from_file`]: that caches the first
255    /// library it ever loads in a process-wide static and hands the same root
256    /// module back for every later path, so an app with more than one library in
257    /// `functions/` would silently get the first one's functions repeatedly.
258    /// Going through the header directly keeps each library separate.
259    fn open(path: &Path) -> Result<FunctionMod_Ref, String> {
260        let library = RawLibrary::load_at(path).map_err(|e| e.to_string())?;
261
262        // The library must outlive every function it exports; abi_stable never
263        // unloads, so leaking it is the supported way to keep its code mapped.
264        let library: &'static RawLibrary = Box::leak(Box::new(library));
265
266        // SAFETY: `library` is leaked above, so the `&'static LibHeader` this
267        // returns stays valid for the rest of the process.
268        let header = unsafe { lib_header_from_raw_library(library).map_err(|e| e.to_string())? };
269        header
270            .init_root_module::<FunctionMod_Ref>()
271            .map_err(|e| e.to_string())
272    }
273
274    /// Add a function that wasn't loaded from disk, replacing any function of
275    /// the same name. Lets a host embed functions directly.
276    pub fn register(&mut self, func: BoxedFunction, config_json: String) {
277        let loaded = LoadedFunction::new(func, config_json);
278        self.functions
279            .insert(loaded.manifest.name.to_string(), loaded);
280    }
281
282    pub fn get(&self, name: &str) -> Option<&LoadedFunction> {
283        self.functions.get(name)
284    }
285
286    pub fn iter(&self) -> impl Iterator<Item = &LoadedFunction> {
287        self.functions.values()
288    }
289}
290
291/// Host-side services handed to a function for one invocation.
292///
293/// Constructed on the async side (capturing a runtime [`Handle`]) and moved into
294/// the blocking worker; its [`HostApi::query`] blocks on the async database via
295/// that handle, which is safe because functions run outside any async context.
296/// [`HostApi::send_email`] and [`HostApi::cache`] work the same way.
297pub struct HostBridge {
298    db: Db,
299    handle: tokio::runtime::Handle,
300    /// The app's configured mailer, when it has one. Built once at boot and
301    /// shared, so a function sending mail reuses pooled connections.
302    mailer: Option<Mailer>,
303    /// The app's configured cache, when it has one.
304    cache: Option<Cache>,
305    /// The app's configured payment provider, when it has one.
306    payments: Option<Payments>,
307    /// The app's configured AI assistant, when it has one.
308    ai: Option<Ai>,
309    /// Where [`HostApi::emit`] sends what a function produces mid-invocation,
310    /// when this call is being streamed to somebody. `None` for every other
311    /// invocation, which is what makes `emit` a no-op there rather than an
312    /// error a function has to guard against.
313    chunks: Option<tokio::sync::mpsc::UnboundedSender<String>>,
314    config_json: String,
315    principal_id: String,
316    /// Lifecycle-hook context JSON, or empty for a plain HTTP invocation.
317    hook_json: String,
318}
319
320impl HostBridge {
321    pub fn new(
322        db: Db,
323        handle: tokio::runtime::Handle,
324        config_json: String,
325        principal_id: String,
326    ) -> Self {
327        HostBridge {
328            db,
329            handle,
330            mailer: None,
331            cache: None,
332            payments: None,
333            ai: None,
334            chunks: None,
335            config_json,
336            principal_id,
337            hook_json: String::new(),
338        }
339    }
340
341    /// Lend the function the app's email provider, cache, payments and AI
342    /// assistant.
343    ///
344    /// All four are optional and each stays `None` when the app configured
345    /// none, which is what makes `send_email`, `cache`, `payments` and `ai`
346    /// fail with "not configured" rather than silently doing nothing.
347    pub fn with_services(
348        mut self,
349        mailer: Option<Mailer>,
350        cache: Option<Cache>,
351        payments: Option<Payments>,
352        ai: Option<Ai>,
353    ) -> Self {
354        self.mailer = mailer;
355        self.cache = cache;
356        self.payments = payments;
357        self.ai = ai;
358        self
359    }
360
361    /// Stream this invocation: everything the function `emit`s goes to
362    /// `chunks` as it is produced, rather than nowhere.
363    pub fn streaming(mut self, chunks: tokio::sync::mpsc::UnboundedSender<String>) -> Self {
364        self.chunks = Some(chunks);
365        self
366    }
367
368    /// Ask the assistant and pass every token to whoever is listening, on its
369    /// way to assembling the complete answer.
370    ///
371    /// This is what makes a *function* able to stream a model's output rather
372    /// than only relay it: the function still gets one return value, and its
373    /// caller still gets the answer as it is written. Without it, a function
374    /// wrapping the assistant — to check permissions, to look something up
375    /// first, to log the exchange — would turn a streaming provider into a
376    /// blocking endpoint, and nobody would wrap it.
377    async fn relay(
378        &self,
379        ai: &apiplant_ai::Ai,
380        request: &apiplant_ai::ChatRequest,
381    ) -> Result<apiplant_ai::ChatReply, apiplant_ai::AiError> {
382        use futures_util::StreamExt;
383
384        let mut stream = Box::pin(ai.stream(request).await?);
385        let mut text = String::new();
386        let mut done = apiplant_ai::Done::default();
387        while let Some(event) = stream.next().await {
388            match event? {
389                apiplant_ai::Event::Delta(delta) => {
390                    text.push_str(&delta);
391                    // A caller who has closed the connection stops the
392                    // generation: there is nobody left to read it, and the
393                    // provider is still being paid by the token.
394                    if !self.emit(abi_stable::std_types::RStr::from_str(&delta)) {
395                        break;
396                    }
397                }
398                // The model's thinking is not the answer, so it is neither
399                // returned to the function nor forwarded: a function that
400                // relays a stream is relaying a reply.
401                apiplant_ai::Event::Reasoning(_) => {}
402                apiplant_ai::Event::Done(end) => {
403                    done = end;
404                    break;
405                }
406            }
407        }
408        Ok(apiplant_ai::ChatReply {
409            text,
410            reasoning: String::new(),
411            provider: ai.provider().as_str().to_string(),
412            model: request
413                .model
414                .clone()
415                .unwrap_or_else(|| ai.model().to_string()),
416            done,
417            tool_calls: Vec::new(),
418        })
419    }
420
421    /// Mark this invocation as a resource lifecycle hook and attach its context.
422    pub fn with_hook(mut self, hook_json: String) -> Self {
423        self.hook_json = hook_json;
424        self
425    }
426}
427
428impl HostApi for HostBridge {
429    fn query(&self, request: RStr<'_>) -> RResult<RString, RString> {
430        #[derive(serde::Deserialize)]
431        struct Req {
432            sql: String,
433            #[serde(default)]
434            params: Vec<serde_json::Value>,
435        }
436        let req: Req = match serde_json::from_str(request.as_str()) {
437            Ok(r) => r,
438            Err(e) => return RResult::RErr(format!("invalid query request: {e}").into()),
439        };
440        let result = self
441            .handle
442            .block_on(async { self.db.raw_json(&req.sql, &req.params).await });
443        match result {
444            Ok(v) => RResult::ROk(v.to_string().into()),
445            Err(e) => RResult::RErr(e.to_string().into()),
446        }
447    }
448
449    fn send_email(&self, request: RStr<'_>) -> RResult<RString, RString> {
450        let Some(mailer) = &self.mailer else {
451            return RResult::RErr(
452                "no email provider configured — set [email] provider in main.toml"
453                    .to_string()
454                    .into(),
455            );
456        };
457        let message: apiplant_email::Message = match serde_json::from_str(request.as_str()) {
458            Ok(m) => m,
459            Err(e) => return RResult::RErr(format!("invalid email: {e}").into()),
460        };
461        match self.handle.block_on(mailer.send(&message)) {
462            Ok(sent) => RResult::ROk(
463                serde_json::to_string(&sent)
464                    .unwrap_or_else(|_| "{}".to_string())
465                    .into(),
466            ),
467            Err(e) => RResult::RErr(e.to_string().into()),
468        }
469    }
470
471    fn cache(&self, request: RStr<'_>) -> RResult<RString, RString> {
472        let Some(cache) = &self.cache else {
473            return RResult::RErr(
474                "no cache configured — set [cache] url in main.toml"
475                    .to_string()
476                    .into(),
477            );
478        };
479        match self.handle.block_on(cache.execute(request.as_str())) {
480            Ok(value) => RResult::ROk(value.to_string().into()),
481            Err(e) => RResult::RErr(e.to_string().into()),
482        }
483    }
484
485    fn payments(&self, request: RStr<'_>) -> RResult<RString, RString> {
486        let Some(payments) = &self.payments else {
487            return RResult::RErr(
488                "no payment provider configured — set [payments] provider in main.toml"
489                    .to_string()
490                    .into(),
491            );
492        };
493        match self.handle.block_on(payments.execute(request.as_str())) {
494            Ok(value) => RResult::ROk(value.to_string().into()),
495            Err(e) => RResult::RErr(e.to_string().into()),
496        }
497    }
498
499    fn ai(&self, request: RStr<'_>) -> RResult<RString, RString> {
500        let Some(ai) = &self.ai else {
501            return RResult::RErr(
502                "no ai provider configured — set [ai] provider in main.toml"
503                    .to_string()
504                    .into(),
505            );
506        };
507        let raw: serde_json::Value = match serde_json::from_str(request.as_str()) {
508            Ok(r) => r,
509            Err(e) => return RResult::RErr(format!("invalid chat request: {e}").into()),
510        };
511        // `stream` is an instruction to the host, not part of the conversation:
512        // it says "forward the answer to my caller as it arrives", and only
513        // means anything when somebody is listening.
514        let forward = raw.get("stream").and_then(serde_json::Value::as_bool) == Some(true);
515        let request: apiplant_ai::ChatRequest = match serde_json::from_value(raw) {
516            Ok(r) => r,
517            Err(e) => return RResult::RErr(format!("invalid chat request: {e}").into()),
518        };
519
520        let result = match (forward, &self.chunks) {
521            (true, Some(_)) => self.handle.block_on(self.relay(ai, &request)),
522            _ => self.handle.block_on(ai.chat(&request)),
523        };
524        match result {
525            Ok(reply) => RResult::ROk(
526                serde_json::to_string(&reply)
527                    .unwrap_or_else(|_| "{}".to_string())
528                    .into(),
529            ),
530            Err(e) => RResult::RErr(e.to_string().into()),
531        }
532    }
533
534    fn emit(&self, chunk: RStr<'_>) -> bool {
535        match &self.chunks {
536            // A closed channel is a caller who hung up: the one thing a
537            // streaming function genuinely wants to hear about.
538            Some(chunks) => chunks.send(chunk.as_str().to_string()).is_ok(),
539            // No channel means nobody asked for this call to be streamed. The
540            // chunk is dropped, and the answer is still `true` — because what
541            // a function does with `false` is *stop working*, and on a plain
542            // invocation the return value is exactly what the caller is
543            // waiting for. "Nobody is streaming" and "everybody has left" look
544            // the same to a handler otherwise, and only one of them is a
545            // reason to give up.
546            None => true,
547        }
548    }
549
550    fn log(&self, level: LogLevel, message: RStr<'_>) {
551        let msg = message.as_str();
552        match level {
553            LogLevel::Trace => tracing::trace!(target: "apiplant::function", "{msg}"),
554            LogLevel::Debug => tracing::debug!(target: "apiplant::function", "{msg}"),
555            LogLevel::Info => tracing::info!(target: "apiplant::function", "{msg}"),
556            LogLevel::Warn => tracing::warn!(target: "apiplant::function", "{msg}"),
557            LogLevel::Error => tracing::error!(target: "apiplant::function", "{msg}"),
558        }
559    }
560
561    fn config(&self) -> RString {
562        self.config_json.clone().into()
563    }
564
565    fn principal_id(&self) -> RString {
566        self.principal_id.clone().into()
567    }
568
569    fn hook(&self) -> RString {
570        self.hook_json.clone().into()
571    }
572}