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