Skip to main content

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#[cfg(feature = "manifest-json")]
37mod manifest_json;
38#[cfg(feature = "manifest-json")]
39pub use manifest_json::manifest_from_json;
40
41use abi_stable::{
42    declare_root_module_statics,
43    library::RootModule,
44    package_version_strings, sabi_trait,
45    std_types::{RResult, RStr, RString},
46    StableAbi,
47};
48
49/// Who is allowed to call a function's (or resource's) endpoint.
50#[repr(u8)]
51#[derive(Debug, Clone, Copy, PartialEq, Eq, StableAbi)]
52pub enum Visibility {
53    /// Anyone, no authentication required.
54    Public,
55    /// Any authenticated principal (session, api-key or oauth).
56    Authenticated,
57    /// Authenticated *and* holding a specific role (see [`FunctionManifest::role`]).
58    RoleGated,
59    /// Never exposed over HTTP; only callable internally by other functions.
60    Private,
61}
62
63/// HTTP verb a function endpoint responds to.
64#[repr(u8)]
65#[derive(Debug, Clone, Copy, PartialEq, Eq, StableAbi)]
66pub enum HttpMethod {
67    Get,
68    Post,
69    Put,
70    Delete,
71}
72
73/// Severity for [`HostApi::log`].
74#[repr(u8)]
75#[derive(Debug, Clone, Copy, PartialEq, Eq, StableAbi)]
76pub enum LogLevel {
77    Trace,
78    Debug,
79    Info,
80    Warn,
81    Error,
82}
83
84/// Static description of a function, read once at load time.
85#[repr(C)]
86#[derive(Debug, Clone, StableAbi)]
87pub struct FunctionManifest {
88    /// URL-safe identifier, e.g. `"greet"` → mounted at `/functions/greet`.
89    pub name: RString,
90    /// Semver of the function itself (independent of the ABI version).
91    pub version: RString,
92    /// Human description, surfaced in generated API docs.
93    pub description: RString,
94    /// Access-control policy for the generated endpoint.
95    pub visibility: Visibility,
96    /// Required role name when `visibility == RoleGated`, else empty.
97    pub role: RString,
98    /// HTTP method the endpoint answers.
99    pub method: HttpMethod,
100    /// Access policy in the same string grammar a resource's `[permissions]`
101    /// uses — `"public"`, `"authenticated"`, `"member"`, `"role:admin"`,
102    /// `"private"`. Empty means "derive it from `visibility` and `role`", which
103    /// is what a function that predates this field gets.
104    ///
105    /// This exists because [`Visibility`] cannot express `member` — "anyone in
106    /// the active organisation" — which is the level most operator-facing
107    /// actions actually want, and because sharing one grammar with resources
108    /// means an app has exactly one thing to learn about access.
109    pub permission: RString,
110    /// Dashboard presentation as a JSON object, or empty for the defaults:
111    ///
112    /// ```json
113    /// { "visible": true, "roles": ["admin"], "label": "Reindex catalogue",
114    ///   "group": "Maintenance", "confirm": "Reindex every product?",
115    ///   "run_label": "Reindex", "order": 10 }
116    /// ```
117    ///
118    /// Carried as JSON rather than as struct fields so that adding a
119    /// presentation knob later never changes this struct's layout — and so
120    /// never invalidates an already-compiled function library.
121    pub admin: RString,
122    /// Optional JSON-Schema describing the function's config object. Empty = none.
123    pub config_schema: RString,
124    /// Optional JSON-Schema for the request body. Empty = untyped. Surfaced in
125    /// the generated OpenAPI document so function I/O is typed in the docs.
126    pub input_schema: RString,
127    /// Optional JSON-Schema for the response body. Empty = untyped.
128    pub output_schema: RString,
129}
130
131/// A function's effective access policy — the resolved form of
132/// [`FunctionManifest::permission`], falling back to [`Visibility`].
133///
134/// Deliberately *not* `StableAbi`: it is derived on the host from fields that
135/// are, so it can grow a variant without touching the wire contract.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum FunctionAccess {
138    /// Anyone, authenticated or not.
139    Public,
140    /// Any authenticated principal.
141    Authenticated,
142    /// Any member of the caller's active organisation.
143    Member,
144    /// A member holding this role in the active organisation.
145    Role(String),
146    /// Not exposed over HTTP at all.
147    Private,
148}
149
150impl FunctionAccess {
151    /// Parse the string grammar shared with a resource's `[permissions]`.
152    /// Returns `None` for anything unrecognised, so a caller can decide whether
153    /// a typo is an error (at build time) or should close the door (at load).
154    pub fn parse(value: &str) -> Option<FunctionAccess> {
155        match value.trim() {
156            "public" => Some(FunctionAccess::Public),
157            "authenticated" => Some(FunctionAccess::Authenticated),
158            "member" => Some(FunctionAccess::Member),
159            "private" => Some(FunctionAccess::Private),
160            other => other
161                .strip_prefix("role:")
162                .filter(|role| !role.is_empty())
163                .map(|role| FunctionAccess::Role(role.to_string())),
164        }
165    }
166
167    /// The canonical string form, round-tripping [`FunctionAccess::parse`].
168    pub fn as_string(&self) -> String {
169        match self {
170            FunctionAccess::Public => "public".to_string(),
171            FunctionAccess::Authenticated => "authenticated".to_string(),
172            FunctionAccess::Member => "member".to_string(),
173            FunctionAccess::Role(role) => format!("role:{role}"),
174            FunctionAccess::Private => "private".to_string(),
175        }
176    }
177
178    /// Whether the endpoint is reachable without credentials.
179    pub fn is_public(&self) -> bool {
180        matches!(self, FunctionAccess::Public)
181    }
182}
183
184impl FunctionManifest {
185    /// The policy the host enforces for this function.
186    ///
187    /// An explicit, parseable `permission` wins. Otherwise the legacy
188    /// `visibility` + `role` pair is used, so libraries compiled before
189    /// `permission` existed keep exactly the access they always had. An
190    /// *unparseable* `permission` collapses to [`FunctionAccess::Private`] —
191    /// the safe direction, matching how the rest of apiplant treats a typo in
192    /// an access string.
193    pub fn access(&self) -> FunctionAccess {
194        if !self.permission.is_empty() {
195            return FunctionAccess::parse(self.permission.as_str())
196                .unwrap_or(FunctionAccess::Private);
197        }
198        match self.visibility {
199            Visibility::Public => FunctionAccess::Public,
200            Visibility::Authenticated => FunctionAccess::Authenticated,
201            Visibility::Private => FunctionAccess::Private,
202            Visibility::RoleGated => FunctionAccess::Role(self.role.to_string()),
203        }
204    }
205}
206
207/// Services the host lends to a function for the duration of one invocation.
208///
209/// Implemented on the host side and handed across the boundary as an
210/// [`abi_stable`] trait object ([`HostApi_TO`]). Every method is synchronous
211/// from the function's point of view — the host is responsible for bridging to
212/// its async database internally (functions run on a blocking worker), so
213/// function authors never touch `async`.
214#[sabi_trait]
215pub trait HostApi: Send + Sync {
216    /// Run a query. `request` is a JSON object of the form
217    /// `{ "sql": "...", "params": [ ... ] }` and the reply is a JSON array of
218    /// row objects (for `SELECT`) or `{ "rows_affected": n }`.
219    fn query(&self, request: RStr<'_>) -> RResult<RString, RString>;
220
221    /// Emit a structured log line through the host's `tracing` subscriber.
222    fn log(&self, level: LogLevel, message: RStr<'_>);
223
224    /// Send an email through whichever provider the app configured.
225    ///
226    /// `request` is the message as a JSON object; the reply is a receipt:
227    ///
228    /// ```json
229    /// // request
230    /// { "to": "ann@example.com", "cc": [], "subject": "Hi", "text": "Hello",
231    ///   "html": "<p>Hello</p>", "from": null, "reply_to": null }
232    /// // reply
233    /// { "provider": "sendgrid", "id": "…", "recipients": 1 }
234    /// ```
235    ///
236    /// `to`/`cc`/`bcc` each accept a bare string, `"Ann <ann@example.com>"`, an
237    /// `{ "email": …, "name": … }` object, or a list of any of those. `from`
238    /// and `reply_to` default to the app's `[email]` configuration.
239    ///
240    /// Errors when the app configured no provider, when the message can't be
241    /// sent (no recipient, no sender), or when the provider refused it.
242    fn send_email(&self, request: RStr<'_>) -> RResult<RString, RString>;
243
244    /// Run one cache operation against the app's Redis, if it configured one.
245    ///
246    /// `request` names the operation; the reply's shape depends on it:
247    ///
248    /// ```json
249    /// { "op": "get",    "key": "k" }                        → { "hit": true, "value": … }
250    /// { "op": "set",    "key": "k", "value": …, "ttl": 60 } → { "ok": true }
251    /// { "op": "delete", "key": "k" }                        → { "deleted": true }
252    /// { "op": "exists", "key": "k" }                        → { "exists": true }
253    /// { "op": "incr",   "key": "k", "by": 1, "ttl": 60 }    → { "value": 3 }
254    /// { "op": "ttl",    "key": "k" }                        → { "ttl": 42 }
255    /// ```
256    ///
257    /// Errors when no cache is configured, when the request isn't one of the
258    /// operations above, or when Redis is unreachable. Cached data is by
259    /// definition reconstructible, so treating an error as a miss is a valid
260    /// and usually correct thing for a function to do.
261    fn cache(&self, request: RStr<'_>) -> RResult<RString, RString>;
262
263    /// The function's resolved configuration as a JSON object (merged defaults +
264    /// per-deployment overrides from the app's `functions/<name>.toml`).
265    fn config(&self) -> RString;
266
267    /// Id of the authenticated principal calling this function, or empty when
268    /// the endpoint is [`Visibility::Public`] and the caller is anonymous.
269    fn principal_id(&self) -> RString;
270
271    /// Lifecycle-hook context as a JSON object when this invocation is a
272    /// resource hook, or empty when the function was called over HTTP.
273    ///
274    /// The object carries the event (`"before_create"`, `"after_list"`, …), the
275    /// resource it fired for, the request URL and method, the caller's auth
276    /// status, and the subject of the operation — the submitted `data`, the
277    /// `row` created/fetched/deleted, or the `rows` a list returned:
278    ///
279    /// ```json
280    /// {
281    ///   "event": "after_create", "action": "create", "phase": "after",
282    ///   "resource": "post", "url": "/api/post", "method": "POST",
283    ///   "query": {}, "authenticated": true,
284    ///   "principal_id": "…", "organization_id": "…", "role": "admin",
285    ///   "record_id": null,
286    ///   "data": null, "row": { "id": "…", "title": "…" }, "rows": null
287    /// }
288    /// ```
289    fn hook(&self) -> RString;
290}
291
292/// Marks an [`Function::invoke`] error as *the function's own fault* — a panic
293/// or an internal fault — rather than a complaint about the caller's input.
294///
295/// The error channel is a bare string, so without a marker the host cannot tell
296/// "you sent me nonsense" (a `400`, and the message is safe to echo back) from
297/// "I broke" (a `500`, and the message may name internals the caller has no
298/// business seeing). A function prefixes the latter with this constant; the host
299/// strips it, logs the detail at `ERROR`, and answers with a generic `500`.
300///
301/// The leading `\x01` cannot occur in a JSON string or a `Display` message
302/// anyone writes deliberately, so an unprefixed error is unambiguous — which is
303/// what keeps this backward compatible with functions that never set it.
304///
305/// Functions built with `apiplant-function` get this for free: its generated
306/// `invoke` catches unwinding panics and prefixes them. Functions written
307/// against the raw ABI (in C, Zig, Go, …) should do the same.
308pub const INTERNAL_ERROR_PREFIX: &str = "\x01apiplant-internal:";
309
310/// A loaded function instance. Constructed once per library via
311/// [`FunctionMod::new_functions`] and reused across requests, so it must be
312/// `Send + Sync`.
313#[sabi_trait]
314pub trait Function: Send + Sync {
315    /// Static metadata; called once right after construction.
316    fn manifest(&self) -> FunctionManifest;
317
318    /// Handle a single request. `input` is the request body as JSON; the return
319    /// value is the JSON response body, or an error message the host turns into
320    /// a `400` — or a `500` when prefixed with [`INTERNAL_ERROR_PREFIX`].
321    ///
322    /// **Must not unwind.** This crosses an `extern "C"` boundary, so a panic
323    /// escaping it aborts the whole host process rather than failing the one
324    /// request; catch panics here and return [`INTERNAL_ERROR_PREFIX`] instead.
325    fn invoke(
326        &self,
327        host: HostApi_TO<'_, abi_stable::std_types::RBox<()>>,
328        input: RStr<'_>,
329    ) -> RResult<RString, RString>;
330}
331
332/// The root module every function library exports.
333///
334/// Use [`FunctionMod_Ref`] together with [`abi_stable::export_root_module`] on
335/// the function side; the host loads it with [`FunctionMod_Ref::load_from_file`].
336///
337/// A library exports **one root module** but may carry any number of functions
338/// through it — each with its own name, manifest and handler. That is what lets
339/// one crate provide a whole set of related endpoints or
340/// [lifecycle hooks](FunctionManifest) without a shared dispatcher.
341#[repr(C)]
342#[derive(StableAbi)]
343#[sabi(kind(Prefix(prefix_ref = FunctionMod_Ref)))]
344#[sabi(missing_field(panic))]
345pub struct FunctionMod {
346    /// Construct every function this library provides. Called exactly once by
347    /// the host, which then reads each function's [`FunctionManifest`].
348    /// Duplicate names within one library are a load error.
349    #[sabi(last_prefix_field)]
350    pub new_functions: extern "C" fn() -> abi_stable::std_types::RVec<BoxedFunction>,
351}
352
353impl RootModule for FunctionMod_Ref {
354    declare_root_module_statics! {FunctionMod_Ref}
355    const BASE_NAME: &'static str = "apiplant_function";
356    const NAME: &'static str = "apiplant_function";
357    const VERSION_STRINGS: abi_stable::sabi_types::VersionStrings = package_version_strings!();
358}
359
360/// Convenience alias for the boxed function trait object the host works with.
361pub type BoxedFunction = Function_TO<'static, abi_stable::std_types::RBox<()>>;
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    fn manifest(visibility: Visibility, role: &str, permission: &str) -> FunctionManifest {
368        FunctionManifest {
369            name: "act".into(),
370            version: "0.0.0".into(),
371            description: RString::new(),
372            visibility,
373            role: role.into(),
374            method: HttpMethod::Post,
375            permission: permission.into(),
376            admin: RString::new(),
377            config_schema: RString::new(),
378            input_schema: RString::new(),
379            output_schema: RString::new(),
380        }
381    }
382
383    #[test]
384    fn access_round_trips_through_its_string_form() {
385        for value in [
386            FunctionAccess::Public,
387            FunctionAccess::Authenticated,
388            FunctionAccess::Member,
389            FunctionAccess::Role("buyer".into()),
390            FunctionAccess::Private,
391        ] {
392            assert_eq!(FunctionAccess::parse(&value.as_string()), Some(value));
393        }
394        assert_eq!(
395            FunctionAccess::parse("  member "),
396            Some(FunctionAccess::Member)
397        );
398        // A bare `role:` names nobody, so it is not a role.
399        assert_eq!(FunctionAccess::parse("role:"), None);
400        assert_eq!(FunctionAccess::parse("owner"), None);
401        assert_eq!(FunctionAccess::parse("wat"), None);
402    }
403
404    #[test]
405    fn permission_wins_over_visibility_and_a_typo_closes_the_door() {
406        // `member` is the level `visibility` cannot express.
407        assert_eq!(
408            manifest(Visibility::Public, "", "member").access(),
409            FunctionAccess::Member
410        );
411        assert_eq!(
412            manifest(Visibility::Private, "", "role:admin").access(),
413            FunctionAccess::Role("admin".into())
414        );
415        // An unreadable permission hides the endpoint rather than exposing it.
416        assert_eq!(
417            manifest(Visibility::Public, "", "membre").access(),
418            FunctionAccess::Private
419        );
420    }
421
422    #[test]
423    fn a_manifest_without_permission_keeps_the_access_visibility_gave_it() {
424        assert_eq!(
425            manifest(Visibility::Public, "", "").access(),
426            FunctionAccess::Public
427        );
428        assert_eq!(
429            manifest(Visibility::Authenticated, "", "").access(),
430            FunctionAccess::Authenticated
431        );
432        assert_eq!(
433            manifest(Visibility::RoleGated, "buyer", "").access(),
434            FunctionAccess::Role("buyer".into())
435        );
436        assert_eq!(
437            manifest(Visibility::Private, "", "").access(),
438            FunctionAccess::Private
439        );
440    }
441}