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}