apiplant_abi/lib.rs
1//! # apiplant-abi
2//!
3//! The stable, C-compatible ABI contract that sits between the `apiplant`
4//! host and every dynamically-loaded *function* library (`.so`/`.dylib`/`.dll`).
5//!
6//! Both sides depend *only* on this crate and on [`abi_stable`], which means a
7//! function compiled against version `X` of this crate keeps working against a
8//! host compiled against the same major version — no matter which compiler,
9//! allocator or std version each side was built with. That is the whole point
10//! of routing everything through `#[repr(C)]` + [`StableAbi`] types.
11//!
12//! ## The shape of a function
13//!
14//! A function library exports exactly one root module ([`FunctionMod`]) whose
15//! `new_functions` constructor yields one or more [`Function`] trait objects —
16//! a single library can provide a whole set of independently-named functions.
17//! For each of them the host:
18//!
19//! 1. reads its [`FunctionManifest`] (name, visibility, HTTP method, config
20//! schema),
21//! 2. mounts it on an HTTP endpoint according to its [`Visibility`],
22//! 3. on each request calls [`Function::invoke`], passing a [`HostApi`] handle
23//! (database access, logging, resolved config) plus a JSON input string,
24//! 4. returns the JSON the function produced.
25//!
26//! Everything crosses the boundary as JSON ([`RString`]) or as small
27//! `#[repr(C)]` enums. Nothing sea-orm / ntex / tokio ever touches the ABI, so
28//! the contract stays tiny and genuinely stable.
29//!
30// abi_stable's macros generate types (`Function_TO`, `FunctionMod_Ref`, …) and
31// impls that trip these lints; the naming/scoping conventions are the crate's.
32#![allow(non_camel_case_types, non_local_definitions)]
33
34pub mod c;
35
36/// `apiplant.h` — the C declaration of this ABI, embedded so a binary with no
37/// checkout beside it can still hand the header to a C or Zig compiler.
38pub const HEADER: &str = include_str!("../include/apiplant.h");
39
40#[cfg(feature = "manifest-json")]
41mod manifest_json;
42#[cfg(feature = "manifest-json")]
43pub use manifest_json::manifest_from_json;
44
45use abi_stable::{
46 declare_root_module_statics,
47 library::RootModule,
48 package_version_strings, sabi_trait,
49 std_types::{RResult, RStr, RString},
50 StableAbi,
51};
52
53/// Who is allowed to call a function's (or resource's) endpoint.
54#[repr(u8)]
55#[derive(Debug, Clone, Copy, PartialEq, Eq, StableAbi)]
56pub enum Visibility {
57 /// Anyone, no authentication required.
58 Public,
59 /// Any authenticated principal (session, api-key or oauth).
60 Authenticated,
61 /// Authenticated *and* holding a specific role (see [`FunctionManifest::role`]).
62 RoleGated,
63 /// Never exposed over HTTP; only callable internally by other functions.
64 Private,
65}
66
67/// HTTP verb a function endpoint responds to.
68#[repr(u8)]
69#[derive(Debug, Clone, Copy, PartialEq, Eq, StableAbi)]
70pub enum HttpMethod {
71 Get,
72 Post,
73 Put,
74 Delete,
75}
76
77/// Severity for [`HostApi::log`].
78#[repr(u8)]
79#[derive(Debug, Clone, Copy, PartialEq, Eq, StableAbi)]
80pub enum LogLevel {
81 Trace,
82 Debug,
83 Info,
84 Warn,
85 Error,
86}
87
88/// Static description of a function, read once at load time.
89#[repr(C)]
90#[derive(Debug, Clone, StableAbi)]
91pub struct FunctionManifest {
92 /// URL-safe identifier, e.g. `"greet"` → mounted at `/functions/greet`.
93 pub name: RString,
94 /// Semver of the function itself (independent of the ABI version).
95 pub version: RString,
96 /// Human description, surfaced in generated API docs.
97 pub description: RString,
98 /// Access-control policy for the generated endpoint.
99 pub visibility: Visibility,
100 /// Required role name when `visibility == RoleGated`, else empty.
101 pub role: RString,
102 /// HTTP method the endpoint answers.
103 pub method: HttpMethod,
104 /// Access policy in the same string grammar a resource's `[permissions]`
105 /// uses — `"public"`, `"authenticated"`, `"member"`, `"role:admin"`,
106 /// `"private"`/`"none"`. Empty means "derive it from `visibility` and `role`", which
107 /// is what a function that predates this field gets.
108 ///
109 /// This exists because [`Visibility`] cannot express `member` — "anyone in
110 /// the active organisation" — which is the level most operator-facing
111 /// actions actually want, and because sharing one grammar with resources
112 /// means an app has exactly one thing to learn about access.
113 pub permission: RString,
114 /// Dashboard presentation as a JSON object, or empty for the defaults:
115 ///
116 /// ```json
117 /// { "visible": true, "roles": ["admin"], "label": "Reindex catalogue",
118 /// "group": "Maintenance", "confirm": "Reindex every product?",
119 /// "run_label": "Reindex", "order": 10 }
120 /// ```
121 ///
122 /// Carried as JSON rather than as struct fields so that adding a
123 /// presentation knob later never changes this struct's layout — and so
124 /// never invalidates an already-compiled function library.
125 pub admin: RString,
126 /// Optional JSON-Schema describing the function's config object. Empty = none.
127 pub config_schema: RString,
128 /// Optional JSON-Schema for the request body. Empty = untyped. Surfaced in
129 /// the generated OpenAPI document so function I/O is typed in the docs.
130 pub input_schema: RString,
131 /// Optional JSON-Schema for the response body. Empty = untyped.
132 pub output_schema: RString,
133}
134
135/// A function's effective access policy — the resolved form of
136/// [`FunctionManifest::permission`], falling back to [`Visibility`].
137///
138/// Deliberately *not* `StableAbi`: it is derived on the host from fields that
139/// are, so it can grow a variant without touching the wire contract.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum FunctionAccess {
142 /// Anyone, authenticated or not.
143 Public,
144 /// Any authenticated principal.
145 Authenticated,
146 /// Any member of the caller's active organisation.
147 Member,
148 /// A member holding this role in the active organisation.
149 Role(String),
150 /// Not exposed over HTTP at all.
151 Private,
152}
153
154impl FunctionAccess {
155 /// Parse the string grammar shared with a resource's `[permissions]`.
156 /// Returns `None` for anything unrecognised, so a caller can decide whether
157 /// a typo is an error (at build time) or should close the door (at load).
158 pub fn parse(value: &str) -> Option<FunctionAccess> {
159 match value.trim() {
160 "public" => Some(FunctionAccess::Public),
161 "authenticated" => Some(FunctionAccess::Authenticated),
162 "member" => Some(FunctionAccess::Member),
163 "private" | "none" => Some(FunctionAccess::Private),
164 other => other
165 .strip_prefix("role:")
166 .filter(|role| !role.is_empty())
167 .map(|role| FunctionAccess::Role(role.to_string())),
168 }
169 }
170
171 /// The canonical string form, round-tripping [`FunctionAccess::parse`].
172 pub fn as_string(&self) -> String {
173 match self {
174 FunctionAccess::Public => "public".to_string(),
175 FunctionAccess::Authenticated => "authenticated".to_string(),
176 FunctionAccess::Member => "member".to_string(),
177 FunctionAccess::Role(role) => format!("role:{role}"),
178 FunctionAccess::Private => "private".to_string(),
179 }
180 }
181
182 /// Whether the endpoint is reachable without credentials.
183 pub fn is_public(&self) -> bool {
184 matches!(self, FunctionAccess::Public)
185 }
186}
187
188impl FunctionManifest {
189 /// The policy the host enforces for this function.
190 ///
191 /// An explicit, parseable `permission` wins. Otherwise the legacy
192 /// `visibility` + `role` pair is used, so libraries compiled before
193 /// `permission` existed keep exactly the access they always had. An
194 /// *unparseable* `permission` collapses to [`FunctionAccess::Private`] —
195 /// the safe direction, matching how the rest of apiplant treats a typo in
196 /// an access string.
197 pub fn access(&self) -> FunctionAccess {
198 if !self.permission.is_empty() {
199 return FunctionAccess::parse(self.permission.as_str())
200 .unwrap_or(FunctionAccess::Private);
201 }
202 match self.visibility {
203 Visibility::Public => FunctionAccess::Public,
204 Visibility::Authenticated => FunctionAccess::Authenticated,
205 Visibility::Private => FunctionAccess::Private,
206 Visibility::RoleGated => FunctionAccess::Role(self.role.to_string()),
207 }
208 }
209}
210
211/// Services the host lends to a function for the duration of one invocation.
212///
213/// Implemented on the host side and handed across the boundary as an
214/// [`abi_stable`] trait object ([`HostApi_TO`]). Every method is synchronous
215/// from the function's point of view — the host is responsible for bridging to
216/// its async database internally (functions run on a blocking worker), so
217/// function authors never touch `async`.
218#[sabi_trait]
219pub trait HostApi: Send + Sync {
220 /// Run a query. `request` is a JSON object of the form
221 /// `{ "sql": "...", "params": [ ... ] }` and the reply is a JSON array of
222 /// row objects (for `SELECT`) or `{ "rows_affected": n }`.
223 fn query(&self, request: RStr<'_>) -> RResult<RString, RString>;
224
225 /// Emit a structured log line through the host's `tracing` subscriber.
226 fn log(&self, level: LogLevel, message: RStr<'_>);
227
228 /// Send an email through whichever provider the app configured.
229 ///
230 /// `request` is the message as a JSON object; the reply is a receipt:
231 ///
232 /// ```json
233 /// // request
234 /// { "to": "ann@example.com", "cc": [], "subject": "Hi", "text": "Hello",
235 /// "html": "<p>Hello</p>", "from": null, "reply_to": null }
236 /// // reply
237 /// { "provider": "sendgrid", "id": "…", "recipients": 1 }
238 /// ```
239 ///
240 /// `to`/`cc`/`bcc` each accept a bare string, `"Ann <ann@example.com>"`, an
241 /// `{ "email": …, "name": … }` object, or a list of any of those. `from`
242 /// and `reply_to` default to the app's `[email]` configuration.
243 ///
244 /// Errors when the app configured no provider, when the message can't be
245 /// sent (no recipient, no sender), or when the provider refused it.
246 fn send_email(&self, request: RStr<'_>) -> RResult<RString, RString>;
247
248 /// Run one cache operation against the app's Redis, if it configured one.
249 ///
250 /// `request` names the operation; the reply's shape depends on it:
251 ///
252 /// ```json
253 /// { "op": "get", "key": "k" } → { "hit": true, "value": … }
254 /// { "op": "set", "key": "k", "value": …, "ttl": 60 } → { "ok": true }
255 /// { "op": "delete", "key": "k" } → { "deleted": true }
256 /// { "op": "exists", "key": "k" } → { "exists": true }
257 /// { "op": "incr", "key": "k", "by": 1, "ttl": 60 } → { "value": 3 }
258 /// { "op": "ttl", "key": "k" } → { "ttl": 42 }
259 /// ```
260 ///
261 /// Errors when no cache is configured, when the request isn't one of the
262 /// operations above, or when Redis is unreachable. Cached data is by
263 /// definition reconstructible, so treating an error as a miss is a valid
264 /// and usually correct thing for a function to do.
265 fn cache(&self, request: RStr<'_>) -> RResult<RString, RString>;
266
267 /// Run one payment operation against the app's provider, if it configured
268 /// one.
269 ///
270 /// `request` names the operation; the reply's shape depends on it:
271 ///
272 /// ```json
273 /// { "op": "checkout", "stripe_price_id": "price_…", "recurring": true,
274 /// "organization_id": "…" } → { "url": "https://checkout.stripe.com/…", … }
275 /// { "op": "portal", "stripe_customer_id": "cus_…" } → { "url": "https://billing.stripe.com/…" }
276 /// { "op": "customer", "organization_id": "…", "email": "…" }
277 /// → { "stripe_customer_id": "cus_…", … }
278 /// { "op": "product", "name": "Pro" } → { "stripe_product_id": "prod_…" }
279 /// { "op": "price", "stripe_product_id": "prod_…", "unit_amount": 1000 }
280 /// → { "stripe_price_id": "price_…", "replaced": false }
281 /// { "op": "subscription", "id": "sub_…" } → { "status": "active", "entitled": true, … }
282 /// { "op": "cancel", "id": "sub_…" } → the subscription's new state
283 /// ```
284 ///
285 /// This is the *provider*, not the app's tables. Reading whether an
286 /// organisation is subscribed is an ordinary query against
287 /// `billing_subscription`, which the webhook keeps current and which costs
288 /// no round trip; come here to make something happen, or when a decision
289 /// is worth asking Stripe directly about.
290 ///
291 /// Errors when the app configured no provider, when the request isn't one
292 /// of the operations above, or when the provider refused it.
293 fn payments(&self, request: RStr<'_>) -> RResult<RString, RString>;
294
295 /// Ask the app's AI assistant something, if it configured one.
296 ///
297 /// `request` is a conversation; the reply is the whole answer:
298 ///
299 /// ```json
300 /// // request
301 /// { "messages": [{ "role": "user", "content": "Summarise this." }],
302 /// "model": null, "system": null, "temperature": null, "max_tokens": null }
303 /// // reply
304 /// { "text": "…", "provider": "openai", "model": "gpt-4o-mini",
305 /// "finish_reason": "stop", "input_tokens": 42, "output_tokens": 96 }
306 /// ```
307 ///
308 /// Everything but `messages` falls back to the app's `[ai]` configuration,
309 /// so a function that only has a question writes only the question.
310 ///
311 /// This call waits for the complete answer, because a function returns one
312 /// value. A function that wants to *stream* an answer to its caller reads
313 /// it here and re-emits it through [`emit`](Self::emit) — see the
314 /// `<base>/functions/<name>/stream` endpoint.
315 ///
316 /// Errors when the app configured no provider, when the conversation is
317 /// empty, or when the provider refused it.
318 fn ai(&self, request: RStr<'_>) -> RResult<RString, RString>;
319
320 /// Push a chunk of the response to the caller *before* this invocation
321 /// returns.
322 ///
323 /// Only meaningful when the function was called through
324 /// `<base>/functions/<name>/stream`, which answers as `text/event-stream`
325 /// and forwards each chunk as it arrives. Everywhere else — an ordinary
326 /// invocation, a lifecycle hook — this is a no-op: nobody is listening, and
327 /// a function should not have to know which way it was called.
328 ///
329 /// `chunk` is arbitrary text. A function streaming JSON objects one per
330 /// chunk, and one streaming plain prose, are both ordinary uses; the host
331 /// does not interpret what it forwards.
332 ///
333 /// Returns whether it is still worth producing more. `false` means the
334 /// caller hung up — a function's cue to stop generating rather than an
335 /// error, since nobody will read the rest of it. An invocation that is not
336 /// being streamed answers `true`: the chunk went nowhere, but the caller
337 /// is still waiting for the return value, so stopping would be wrong.
338 fn emit(&self, chunk: RStr<'_>) -> bool;
339
340 /// The function's resolved configuration as a JSON object (merged defaults +
341 /// per-deployment overrides from the app's `functions/<name>.toml`).
342 fn config(&self) -> RString;
343
344 /// Id of the authenticated principal calling this function, or empty when
345 /// the endpoint is [`Visibility::Public`] and the caller is anonymous.
346 fn principal_id(&self) -> RString;
347
348 /// Lifecycle-hook context as a JSON object when this invocation is a
349 /// resource hook, or empty when the function was called over HTTP.
350 ///
351 /// The object carries the event (`"before_create"`, `"after_list"`, …), the
352 /// resource it fired for, the request URL and method, the caller's auth
353 /// status, and the subject of the operation — the submitted `data`, the
354 /// `row` created/fetched/deleted, or the `rows` a list returned:
355 ///
356 /// ```json
357 /// {
358 /// "event": "after_create", "action": "create", "phase": "after",
359 /// "resource": "post", "url": "/api/post", "method": "POST",
360 /// "query": {}, "authenticated": true,
361 /// "principal_id": "…", "organization_id": "…", "role": "admin",
362 /// "record_id": null,
363 /// "data": null, "row": { "id": "…", "title": "…" }, "rows": null
364 /// }
365 /// ```
366 ///
367 /// A function running as a queue *subscriber* reads its delivery here
368 /// instead, under `"event": "message"` — the topic, the message's id, and
369 /// which attempt this is:
370 ///
371 /// ```json
372 /// { "event": "message", "topic": "order.paid", "message_id": "…",
373 /// "subscriber": "fulfil_order", "attempts": 1, "principal_id": "…" }
374 /// ```
375 ///
376 /// `attempts` is the one worth branching on: delivery is at-least-once, so
377 /// anything above `1` is a message whose side effects may have partly
378 /// happened already.
379 fn hook(&self) -> RString;
380
381 /// Queue a message for whichever functions subscribe to a topic, to be
382 /// handled after this invocation returns.
383 ///
384 /// ```json
385 /// // request
386 /// { "op": "publish", "topic": "order.paid", "message": { "order_id": "…" } }
387 /// // reply
388 /// { "id": "…", "topic": "order.paid", "delivered": 2 }
389 /// ```
390 ///
391 /// `delivered` is how many subscribers it was queued for, and **zero is not
392 /// an error**: the message is recorded either way, so that a topic nobody
393 /// listens to is a row to find rather than a silence to guess at. A
394 /// publisher that would rather know can check it.
395 ///
396 /// This returns once the message is *committed*, not once it has been
397 /// handled — that is the entire point, and what makes it different from
398 /// calling the other function directly. The handler runs on a subscriber,
399 /// possibly in another process, possibly after a retry, and its failure has
400 /// no effect on this invocation.
401 ///
402 /// Errors when the topic isn't a usable name, or when the database refused
403 /// the write. It does *not* error when no subscriber is configured.
404 fn publish(&self, request: RStr<'_>) -> RResult<RString, RString>;
405}
406
407/// Marks an [`Function::invoke`] error as *the function's own fault* — a panic
408/// or an internal fault — rather than a complaint about the caller's input.
409///
410/// The error channel is a bare string, so without a marker the host cannot tell
411/// "you sent me nonsense" (a `400`, and the message is safe to echo back) from
412/// "I broke" (a `500`, and the message may name internals the caller has no
413/// business seeing). A function prefixes the latter with this constant; the host
414/// strips it, logs the detail at `ERROR`, and answers with a generic `500`.
415///
416/// The leading `\x01` cannot occur in a JSON string or a `Display` message
417/// anyone writes deliberately, so an unprefixed error is unambiguous — which is
418/// what keeps this backward compatible with functions that never set it.
419///
420/// Functions built with `apiplant-function` get this for free: its generated
421/// `invoke` catches unwinding panics and prefixes them. Functions written
422/// against the raw ABI (in C, Zig, Go, …) should do the same.
423pub const INTERNAL_ERROR_PREFIX: &str = "\x01apiplant-internal:";
424
425/// A loaded function instance. Constructed once per library via
426/// [`FunctionMod::new_functions`] and reused across requests, so it must be
427/// `Send + Sync`.
428#[sabi_trait]
429pub trait Function: Send + Sync {
430 /// Static metadata; called once right after construction.
431 fn manifest(&self) -> FunctionManifest;
432
433 /// Handle a single request. `input` is the request body as JSON; the return
434 /// value is the JSON response body, or an error message the host turns into
435 /// a `400` — or a `500` when prefixed with [`INTERNAL_ERROR_PREFIX`].
436 ///
437 /// **Must not unwind.** This crosses an `extern "C"` boundary, so a panic
438 /// escaping it aborts the whole host process rather than failing the one
439 /// request; catch panics here and return [`INTERNAL_ERROR_PREFIX`] instead.
440 fn invoke(
441 &self,
442 host: HostApi_TO<'_, abi_stable::std_types::RBox<()>>,
443 input: RStr<'_>,
444 ) -> RResult<RString, RString>;
445}
446
447/// The root module every function library exports.
448///
449/// Use [`FunctionMod_Ref`] together with [`abi_stable::export_root_module`] on
450/// the function side; the host loads it with [`FunctionMod_Ref::load_from_file`].
451///
452/// A library exports **one root module** but may carry any number of functions
453/// through it — each with its own name, manifest and handler. That is what lets
454/// one crate provide a whole set of related endpoints or
455/// [lifecycle hooks](FunctionManifest) without a shared dispatcher.
456#[repr(C)]
457#[derive(StableAbi)]
458#[sabi(kind(Prefix(prefix_ref = FunctionMod_Ref)))]
459#[sabi(missing_field(panic))]
460pub struct FunctionMod {
461 /// Construct every function this library provides. Called exactly once by
462 /// the host, which then reads each function's [`FunctionManifest`].
463 /// Duplicate names within one library are a load error.
464 #[sabi(last_prefix_field)]
465 pub new_functions: extern "C" fn() -> abi_stable::std_types::RVec<BoxedFunction>,
466}
467
468impl RootModule for FunctionMod_Ref {
469 declare_root_module_statics! {FunctionMod_Ref}
470 const BASE_NAME: &'static str = "apiplant_function";
471 const NAME: &'static str = "apiplant_function";
472 const VERSION_STRINGS: abi_stable::sabi_types::VersionStrings = package_version_strings!();
473}
474
475/// Convenience alias for the boxed function trait object the host works with.
476pub type BoxedFunction = Function_TO<'static, abi_stable::std_types::RBox<()>>;
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 fn manifest(visibility: Visibility, role: &str, permission: &str) -> FunctionManifest {
483 FunctionManifest {
484 name: "act".into(),
485 version: "0.0.0".into(),
486 description: RString::new(),
487 visibility,
488 role: role.into(),
489 method: HttpMethod::Post,
490 permission: permission.into(),
491 admin: RString::new(),
492 config_schema: RString::new(),
493 input_schema: RString::new(),
494 output_schema: RString::new(),
495 }
496 }
497
498 #[test]
499 fn access_round_trips_through_its_string_form() {
500 for value in [
501 FunctionAccess::Public,
502 FunctionAccess::Authenticated,
503 FunctionAccess::Member,
504 FunctionAccess::Role("buyer".into()),
505 FunctionAccess::Private,
506 ] {
507 assert_eq!(FunctionAccess::parse(&value.as_string()), Some(value));
508 }
509 assert_eq!(
510 FunctionAccess::parse(" member "),
511 Some(FunctionAccess::Member)
512 );
513 // A bare `role:` names nobody, so it is not a role.
514 assert_eq!(FunctionAccess::parse("role:"), None);
515 assert_eq!(FunctionAccess::parse("owner"), None);
516 assert_eq!(FunctionAccess::parse("wat"), None);
517 }
518
519 #[test]
520 fn permission_wins_over_visibility_and_a_typo_closes_the_door() {
521 // `member` is the level `visibility` cannot express.
522 assert_eq!(
523 manifest(Visibility::Public, "", "member").access(),
524 FunctionAccess::Member
525 );
526 assert_eq!(
527 manifest(Visibility::Private, "", "role:admin").access(),
528 FunctionAccess::Role("admin".into())
529 );
530 // An unreadable permission hides the endpoint rather than exposing it.
531 assert_eq!(
532 manifest(Visibility::Public, "", "membre").access(),
533 FunctionAccess::Private
534 );
535 }
536
537 #[test]
538 fn a_manifest_without_permission_keeps_the_access_visibility_gave_it() {
539 assert_eq!(
540 manifest(Visibility::Public, "", "").access(),
541 FunctionAccess::Public
542 );
543 assert_eq!(
544 manifest(Visibility::Authenticated, "", "").access(),
545 FunctionAccess::Authenticated
546 );
547 assert_eq!(
548 manifest(Visibility::RoleGated, "buyer", "").access(),
549 FunctionAccess::Role("buyer".into())
550 );
551 assert_eq!(
552 manifest(Visibility::Private, "", "").access(),
553 FunctionAccess::Private
554 );
555 }
556}