Skip to main content

apiplant_function/
lib.rs

1//! # apiplant-function
2//!
3//! Write an apiplant function without the ABI boilerplate.
4//!
5//! Instead of hand-implementing the [`apiplant_abi`] traits, exporting a root
6//! module, and shuttling JSON in and out by hand, you write one ordinary typed
7//! function and call [`function!`]:
8//!
9//! ```no_run
10//! use apiplant_function::prelude::*;
11//!
12//! #[derive(serde::Deserialize, Default)]
13//! struct Config { #[serde(default)] greeting: String }
14//!
15//! #[derive(serde::Deserialize, JsonSchema)]
16//! struct Input { name: String }
17//!
18//! #[derive(serde::Serialize, JsonSchema)]
19//! struct Output { message: String }
20//!
21//! fn greet(ctx: &Context<Config>, input: Input) -> Result<Output, String> {
22//!     Ok(Output { message: format!("{}, {}!", ctx.config().greeting, input.name) })
23//! }
24//!
25//! apiplant_function::function! {
26//!     name: "greet",
27//!     description: "Greets a person",
28//!     method: Post,
29//!     visibility: Public,
30//!     handler: greet,
31//! }
32//! # fn main() {}
33//! ```
34//!
35//! The macro generates the root module, reads/writes JSON, resolves typed config
36//! and input, and turns your `Err(_)` into a `400`. Types are inferred from the
37//! handler's signature — you never name them twice. With the default `schema`
38//! feature the input and output types must also derive [`JsonSchema`](prelude::JsonSchema)
39//! so the endpoint shows up typed in the OpenAPI docs.
40//!
41//! Use [`functions!`] to export several from one library — each with its own
42//! name, manifest and handler.
43//!
44//! ## Functions as lifecycle hooks
45//!
46//! A function can also be attached to a resource's lifecycle from
47//! `models/<name>.toml`, in which case [`Context::hook`] carries the operation's
48//! context — the row created or fetched, the rows a list returned, the request
49//! URL, the caller's auth status — and the [`reply`] helpers say what should
50//! happen next. One function per event, so a handler never has to work out why
51//! it was called:
52//!
53//! ```no_run
54//! # use apiplant_function::prelude::*;
55//! fn post_after_create(ctx: &Context<()>, row: serde_json::Value) -> Result<serde_json::Value, String> {
56//!     let actor = ctx.hook().and_then(|hook| hook.principal_id.clone());
57//!     ctx.info(&format!("post {} created by {actor:?}", row["id"]));
58//!     Ok(reply::proceed())
59//! }
60//!
61//! apiplant_function::functions! {
62//!     {
63//!         name: "post_after_create",
64//!         description: "Records a newly created post",
65//!         method: Post,
66//!         visibility: Private,
67//!         handler: post_after_create,
68//!     },
69//! }
70//! # fn main() {}
71//! ```
72
73use abi_stable::std_types::{RBox, RResult, RStr};
74use apiplant_abi::{HostApi_TO, LogLevel};
75
76/// The handle a function receives for one invocation.
77///
78/// It carries the function's typed, already-deserialized [config](Self::config),
79/// the [caller's id](Self::principal_id), and a borrow of the host so you can
80/// [query the database](Self::query). Construct it via the [`function!`] macro —
81/// you won't build one yourself.
82pub struct Context<'a, 'h, C> {
83    host: &'a HostApi_TO<'h, RBox<()>>,
84    config: C,
85    principal_id: String,
86    hook: Option<Hook>,
87}
88
89impl<'a, 'h, C> Context<'a, 'h, C> {
90    /// Internal constructor used by generated code.
91    #[doc(hidden)]
92    pub fn __new(
93        host: &'a HostApi_TO<'h, RBox<()>>,
94        config: C,
95        principal_id: String,
96        hook: Option<Hook>,
97    ) -> Self {
98        Context {
99            host,
100            config,
101            principal_id,
102            hook,
103        }
104    }
105
106    /// The lifecycle-hook context when this call came from a resource hook, or
107    /// `None` when the function was invoked directly over HTTP.
108    ///
109    /// This is where the data *around* the operation lives: the row that was
110    /// created, fetched or deleted, the rows a list returned, the request URL,
111    /// and the caller's auth status.
112    ///
113    /// ```no_run
114    /// # use apiplant_function::prelude::*;
115    /// # fn validate(_data: &serde_json::Value) {}
116    /// # fn audit(_event: &str, _row: &serde_json::Value) {}
117    /// # fn example(ctx: &Context<()>) {
118    /// match ctx.hook() {
119    ///     Some(h) if h.is_before() => validate(h.data()),
120    ///     Some(h) => audit(&h.event, h.row()),
121    ///     None => {} // plain HTTP call
122    /// }
123    /// # }
124    /// ```
125    pub fn hook(&self) -> Option<&Hook> {
126        self.hook.as_ref()
127    }
128
129    /// The function's resolved, typed configuration (`functions/<name>.toml`).
130    pub fn config(&self) -> &C {
131        &self.config
132    }
133
134    /// The authenticated caller's user id, or `""` when the endpoint is public
135    /// and the caller is anonymous.
136    pub fn principal_id(&self) -> &str {
137        &self.principal_id
138    }
139
140    /// Run a `SELECT` (or `WITH`) and get the rows as JSON objects.
141    pub fn query(
142        &self,
143        sql: &str,
144        params: &[serde_json::Value],
145    ) -> Result<Vec<serde_json::Value>, String> {
146        match self.raw(sql, params)? {
147            serde_json::Value::Array(rows) => Ok(rows),
148            other => Err(format!("expected rows, got {other}")),
149        }
150    }
151
152    /// Run a query expected to return at most one row.
153    pub fn query_one(
154        &self,
155        sql: &str,
156        params: &[serde_json::Value],
157    ) -> Result<Option<serde_json::Value>, String> {
158        Ok(self.query(sql, params)?.into_iter().next())
159    }
160
161    /// Run an `INSERT`/`UPDATE`/`DELETE` and get the number of affected rows.
162    pub fn execute(&self, sql: &str, params: &[serde_json::Value]) -> Result<u64, String> {
163        match self.raw(sql, params)? {
164            serde_json::Value::Object(map) => Ok(map
165                .get("rows_affected")
166                .and_then(|v| v.as_u64())
167                .unwrap_or(0)),
168            serde_json::Value::Array(rows) => Ok(rows.len() as u64),
169            _ => Ok(0),
170        }
171    }
172
173    fn raw(&self, sql: &str, params: &[serde_json::Value]) -> Result<serde_json::Value, String> {
174        let request = serde_json::json!({ "sql": sql, "params": params }).to_string();
175        match self.host.query(RStr::from_str(request.as_str())) {
176            RResult::ROk(s) => serde_json::from_str(s.as_str()).map_err(|e| e.to_string()),
177            RResult::RErr(e) => Err(e.into_string()),
178        }
179    }
180
181    /// Send an email through whichever provider the app configured in
182    /// `[email]`, and get the provider's receipt back.
183    ///
184    /// The function doesn't know or care which provider that is: the same call
185    /// goes out through SES, SendGrid, Brevo, Mailjet or a plain SMTP relay
186    /// depending on one line of `main.toml`.
187    ///
188    /// ```no_run
189    /// # use apiplant_function::prelude::*;
190    /// # fn example(ctx: &Context<()>) -> Result<(), String> {
191    /// ctx.send_email(
192    ///     Email::to("ann@example.com")
193    ///         .subject("Welcome")
194    ///         .text("Glad you're here.")
195    ///         .html("<p>Glad you're here.</p>"),
196    /// )?;
197    /// # Ok(()) }
198    /// ```
199    ///
200    /// Errors when no provider is configured, when the message has no
201    /// recipient, or when the provider refuses it. Whether that should fail the
202    /// request is the caller's decision — a failed welcome email usually
203    /// shouldn't undo the signup that triggered it.
204    pub fn send_email(&self, email: Email) -> Result<Sent, String> {
205        let request = serde_json::to_string(&email).map_err(|e| e.to_string())?;
206        match self.host.send_email(RStr::from_str(&request)) {
207            RResult::ROk(receipt) => serde_json::from_str(receipt.as_str())
208                .map_err(|e| format!("unreadable email receipt: {e}")),
209            RResult::RErr(e) => Err(e.into_string()),
210        }
211    }
212
213    /// Read a value from the app's Redis cache. `None` for a miss.
214    ///
215    /// ```no_run
216    /// # use apiplant_function::prelude::*;
217    /// # fn fetch() -> serde_json::Value { serde_json::Value::Null }
218    /// # fn example(ctx: &Context<()>) -> Result<serde_json::Value, String> {
219    /// if let Some(hit) = ctx.cache_get("rates:eur")? {
220    ///     return Ok(hit);
221    /// }
222    /// let rates = fetch();
223    /// ctx.cache_set("rates:eur", &rates, Some(900))?;
224    /// # Ok(rates) }
225    /// ```
226    ///
227    /// Errors when the app configured no cache, or when Redis is unreachable.
228    /// Since a cache holds only what can be recomputed, treating an error like
229    /// a miss (`ctx.cache_get(k).ok().flatten()`) is a reasonable choice — and
230    /// the one that keeps the endpoint working while Redis restarts.
231    pub fn cache_get(&self, key: &str) -> Result<Option<serde_json::Value>, String> {
232        let reply = self.cache(serde_json::json!({ "op": "get", "key": key }))?;
233        match reply.get("value") {
234            None | Some(serde_json::Value::Null) => Ok(None),
235            Some(value) => Ok(Some(value.clone())),
236        }
237    }
238
239    /// Read a value and deserialize it. A miss and a value of the wrong shape
240    /// both come back as `None`, because a cache entry written by an older
241    /// version of the function is a miss in every way that matters.
242    pub fn cache_get_as<T: serde::de::DeserializeOwned>(
243        &self,
244        key: &str,
245    ) -> Result<Option<T>, String> {
246        Ok(self
247            .cache_get(key)?
248            .and_then(|value| serde_json::from_value(value).ok()))
249    }
250
251    /// Write a value, expiring after `ttl_secs`. `None` uses the app's
252    /// `[cache] default_ttl_secs`; `Some(0)` means "keep it until deleted".
253    pub fn cache_set<T: serde::Serialize>(
254        &self,
255        key: &str,
256        value: &T,
257        ttl_secs: Option<u64>,
258    ) -> Result<(), String> {
259        let value = serde_json::to_value(value).map_err(|e| e.to_string())?;
260        self.cache(serde_json::json!({
261            "op": "set", "key": key, "value": value, "ttl": ttl_secs
262        }))?;
263        Ok(())
264    }
265
266    /// Drop a key. `true` when it was there.
267    pub fn cache_delete(&self, key: &str) -> Result<bool, String> {
268        let reply = self.cache(serde_json::json!({ "op": "delete", "key": key }))?;
269        Ok(reply
270            .get("deleted")
271            .and_then(|v| v.as_bool())
272            .unwrap_or(false))
273    }
274
275    /// Add `by` to a counter and return its new value, starting from zero.
276    ///
277    /// The increment happens on the server, so this counts correctly across
278    /// every worker and every host — which is what makes it usable for rate
279    /// limiting, and what a `get` + `set` pair could not do. `ttl_secs` is
280    /// applied only when the counter is created, so a window doesn't extend
281    /// itself on every hit.
282    pub fn cache_incr(&self, key: &str, by: i64, ttl_secs: Option<u64>) -> Result<i64, String> {
283        let reply = self.cache(serde_json::json!({
284            "op": "incr", "key": key, "by": by, "ttl": ttl_secs
285        }))?;
286        Ok(reply.get("value").and_then(|v| v.as_i64()).unwrap_or(0))
287    }
288
289    /// Seconds until `key` expires; `None` when it is absent or set to persist.
290    pub fn cache_ttl(&self, key: &str) -> Result<Option<i64>, String> {
291        let reply = self.cache(serde_json::json!({ "op": "ttl", "key": key }))?;
292        Ok(reply.get("ttl").and_then(|v| v.as_i64()))
293    }
294
295    /// Send one operation to the host's cache and parse its reply.
296    fn cache(&self, request: serde_json::Value) -> Result<serde_json::Value, String> {
297        match self.host.cache(RStr::from_str(&request.to_string())) {
298            RResult::ROk(reply) => serde_json::from_str(reply.as_str())
299                .map_err(|e| format!("unreadable cache reply: {e}")),
300            RResult::RErr(e) => Err(e.into_string()),
301        }
302    }
303
304    /// Log through the host's `tracing` subscriber.
305    pub fn log(&self, level: LogLevel, message: &str) {
306        self.host.log(level, RStr::from_str(message));
307    }
308
309    /// Log at INFO.
310    pub fn info(&self, message: &str) {
311        self.log(LogLevel::Info, message);
312    }
313
314    /// Log at WARN.
315    pub fn warn(&self, message: &str) {
316        self.log(LogLevel::Warn, message);
317    }
318
319    /// Log at ERROR.
320    pub fn error(&self, message: &str) {
321        self.log(LogLevel::Error, message);
322    }
323
324    /// Log at DEBUG.
325    pub fn debug(&self, message: &str) {
326        self.log(LogLevel::Debug, message);
327    }
328}
329
330/// A message to send with [`Context::send_email`].
331///
332/// Addresses are written the way you'd write them in a mail client — either
333/// `"ann@example.com"` or `"Ann Lee <ann@example.com>"`. `from` and `reply_to`
334/// are left unset unless this particular message needs to differ from the app's
335/// `[email]` defaults.
336///
337/// Deliberately not the host's own message type: this crate is compiled into
338/// every function library, and a function has no business linking an HTTP
339/// client, an SMTP stack and a request signer to describe an email. What
340/// crosses the ABI is the JSON below.
341#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
342pub struct Email {
343    pub to: Vec<String>,
344    #[serde(skip_serializing_if = "Vec::is_empty", default)]
345    pub cc: Vec<String>,
346    #[serde(skip_serializing_if = "Vec::is_empty", default)]
347    pub bcc: Vec<String>,
348    pub subject: String,
349    #[serde(skip_serializing_if = "String::is_empty", default)]
350    pub text: String,
351    #[serde(skip_serializing_if = "String::is_empty", default)]
352    pub html: String,
353    #[serde(skip_serializing_if = "Option::is_none", default)]
354    pub from: Option<String>,
355    #[serde(skip_serializing_if = "Option::is_none", default)]
356    pub reply_to: Option<String>,
357}
358
359impl Email {
360    /// Start a message to one recipient.
361    pub fn to(recipient: impl Into<String>) -> Email {
362        Email {
363            to: vec![recipient.into()],
364            ..Email::default()
365        }
366    }
367
368    /// Start a message to several recipients.
369    pub fn to_all<S: Into<String>>(recipients: impl IntoIterator<Item = S>) -> Email {
370        Email {
371            to: recipients.into_iter().map(Into::into).collect(),
372            ..Email::default()
373        }
374    }
375
376    pub fn cc(mut self, address: impl Into<String>) -> Email {
377        self.cc.push(address.into());
378        self
379    }
380
381    pub fn bcc(mut self, address: impl Into<String>) -> Email {
382        self.bcc.push(address.into());
383        self
384    }
385
386    pub fn subject(mut self, subject: impl Into<String>) -> Email {
387        self.subject = subject.into();
388        self
389    }
390
391    /// The plain-text body. Send at least one of this and [`html`](Self::html);
392    /// sending both produces a `multipart/alternative`, which is what a mail
393    /// client expects.
394    pub fn text(mut self, body: impl Into<String>) -> Email {
395        self.text = body.into();
396        self
397    }
398
399    pub fn html(mut self, body: impl Into<String>) -> Email {
400        self.html = body.into();
401        self
402    }
403
404    /// Override the app's configured sender for this message.
405    pub fn from(mut self, address: impl Into<String>) -> Email {
406        self.from = Some(address.into());
407        self
408    }
409
410    pub fn reply_to(mut self, address: impl Into<String>) -> Email {
411        self.reply_to = Some(address.into());
412        self
413    }
414}
415
416/// The receipt [`Context::send_email`] returns: which provider took the
417/// message, and what it called it.
418#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
419#[serde(default)]
420pub struct Sent {
421    /// The provider that accepted it, e.g. `"ses"`.
422    pub provider: String,
423    /// The provider's identifier for the message; empty when it returns none.
424    pub id: String,
425    /// How many addresses it went to, across `to`, `cc` and `bcc`.
426    pub recipients: usize,
427}
428
429/// Everything the host knows about the operation a hook fired for.
430///
431/// Reachable through [`Context::hook`]. Every field is optional on the wire, so
432/// a function written against an older host still loads; unknown fields are
433/// ignored, so a newer host can add more.
434#[derive(Debug, Clone, Default, serde::Deserialize)]
435#[serde(default)]
436pub struct Hook {
437    /// The lifecycle event, e.g. `"before_create"` or `"after_list"`.
438    pub event: String,
439    /// The operation: `"list"`, `"read"`, `"create"`, `"update"` or `"delete"`.
440    pub action: String,
441    /// `"before"` or `"after"`.
442    pub phase: String,
443    /// The resource the hook is attached to, e.g. `"post"`.
444    pub resource: String,
445    /// Path and query string of the request that triggered the hook.
446    pub url: String,
447    /// HTTP method of that request.
448    pub method: String,
449    /// Parsed query parameters.
450    pub query: std::collections::BTreeMap<String, String>,
451    /// Whether the caller is authenticated.
452    pub authenticated: bool,
453    /// The caller's user id, when authenticated.
454    pub principal_id: Option<String>,
455    /// The caller's active organisation, when one is resolved.
456    pub organization_id: Option<String>,
457    /// The caller's *primary* role in that organisation, when they have one.
458    pub role: Option<String>,
459    /// Every role they hold there. A member can hold several, and this is what
460    /// a `role:` permission is checked against — so prefer it to [`role`] when
461    /// deciding what somebody may do.
462    ///
463    /// [`role`]: Hook::role
464    #[serde(default)]
465    pub roles: Vec<String>,
466    /// The id in the URL for single-record operations (read/update/delete).
467    pub record_id: Option<String>,
468    /// The submitted body on `before_create` / `before_update`.
469    pub data: Option<serde_json::Value>,
470    /// The row created, fetched, updated or about to be deleted.
471    pub row: Option<serde_json::Value>,
472    /// The rows a list returned, on `after_list`.
473    pub rows: Option<Vec<serde_json::Value>>,
474}
475
476impl Hook {
477    /// Parse a hook context, or `None` when the string is empty or malformed
478    /// (i.e. this was a plain HTTP invocation).
479    pub fn parse(json: &str) -> Option<Hook> {
480        if json.trim().is_empty() {
481            return None;
482        }
483        serde_json::from_str(json).ok()
484    }
485
486    /// Whether this hook runs before the database operation (and so can still
487    /// rewrite the payload or abort).
488    pub fn is_before(&self) -> bool {
489        self.phase == "before"
490    }
491
492    /// Whether this hook runs after the operation succeeded.
493    pub fn is_after(&self) -> bool {
494        self.phase == "after"
495    }
496
497    /// The submitted body, or `null` when the event carries none.
498    pub fn data(&self) -> &serde_json::Value {
499        self.data.as_ref().unwrap_or(&serde_json::Value::Null)
500    }
501
502    /// The row in play, or `null` when the event carries none.
503    pub fn row(&self) -> &serde_json::Value {
504        self.row.as_ref().unwrap_or(&serde_json::Value::Null)
505    }
506
507    /// The rows a list returned; empty for every other event.
508    pub fn rows(&self) -> &[serde_json::Value] {
509        self.rows.as_deref().unwrap_or(&[])
510    }
511
512    /// Read a field from whichever subject the event carries — the submitted
513    /// `data` for `before_create`/`before_update`, else the `row`.
514    pub fn field(&self, name: &str) -> Option<&serde_json::Value> {
515        let subject = if self.data.is_some() {
516            self.data()
517        } else {
518            self.row()
519        };
520        subject.get(name)
521    }
522}
523
524/// What a hook handler returns to the host.
525///
526/// A hook's `Ok` value is a JSON object the host reads as an instruction. These
527/// helpers build it; anything else (including `{}` or `null`) means "carry on
528/// unchanged", so an observational hook can simply return
529/// `Ok(serde_json::Value::Null)`.
530///
531/// ```no_run
532/// # use apiplant_function::prelude::*;
533/// fn guard(ctx: &Context<()>, _input: serde_json::Value) -> Result<serde_json::Value, String> {
534///     let Some(h) = ctx.hook() else { return Ok(reply::proceed()) };
535///     if h.field("title").and_then(|t| t.as_str()).unwrap_or("").is_empty() {
536///         return Ok(reply::abort(422, "title is required"));
537///     }
538///     Ok(reply::proceed())
539/// }
540/// ```
541pub mod reply {
542    use serde_json::{json, Value};
543
544    /// Continue with the payload unchanged.
545    pub fn proceed() -> Value {
546        json!({})
547    }
548
549    /// Replace the payload (`before_create`/`before_update`) or the response
550    /// body (any `after_*` hook) with `data`.
551    pub fn replace(data: Value) -> Value {
552        json!({ "data": data })
553    }
554
555    /// Abort the request with an HTTP status and message. Statuses outside
556    /// `400..=599` are clamped to `400` by the host.
557    pub fn abort(status: u16, message: impl Into<String>) -> Value {
558        json!({ "error": { "status": status, "message": message.into() } })
559    }
560}
561
562/// The glue every generated `invoke` calls: parse config + input, run the
563/// handler, serialize the result. Type parameters are inferred from `handler`.
564///
565/// Also the crate's panic firewall. [`apiplant_abi::Function::invoke`] is
566/// reached through an `extern "C"` function pointer, and a panic that escapes
567/// one of those does not unwind into the host — `abi_stable` detects it and
568/// aborts the process. A `panic!`, `unwrap()` or index-out-of-bounds anywhere in
569/// a handler would therefore take the whole server down with it, dropping every
570/// other in-flight request. So the handler runs inside [`catch_unwind`] here,
571/// while it is still on the function's side of the boundary, and a panic becomes
572/// an [`INTERNAL_ERROR_PREFIX`](apiplant_abi::INTERNAL_ERROR_PREFIX) error that
573/// the host reports as a `500`.
574#[doc(hidden)]
575pub fn invoke_handler<C, I, O, E, F>(
576    host: &HostApi_TO<'_, RBox<()>>,
577    input: RStr<'_>,
578    handler: F,
579) -> RResult<abi_stable::std_types::RString, abi_stable::std_types::RString>
580where
581    C: serde::de::DeserializeOwned + Default,
582    I: serde::de::DeserializeOwned,
583    O: serde::Serialize,
584    E: core::fmt::Display,
585    F: FnOnce(&Context<'_, '_, C>, I) -> Result<O, E>,
586{
587    use abi_stable::std_types::RString;
588    use std::panic::{catch_unwind, AssertUnwindSafe};
589
590    // `AssertUnwindSafe`: nothing observable is shared across the boundary that a
591    // half-finished handler could leave inconsistent. `host` is borrowed and its
592    // methods are the host's own business, the config and input are moved in and
593    // dropped on unwind, and on a panic we return immediately without touching
594    // anything the handler may have left mid-update.
595    let outcome = catch_unwind(AssertUnwindSafe(|| {
596        run_handler::<C, I, O, E, F>(host, input, handler)
597    }));
598
599    match outcome {
600        Ok(result) => result,
601        // The default panic hook has already printed the message and backtrace to
602        // stderr, so the detail is in the operator's log either way; this carries
603        // enough for the host to log a useful line without echoing it to the caller.
604        // `&*payload`, not `&payload`: `Box<dyn Any + Send>` is itself `Any`, so
605        // `&payload` would coerce by erasing the *box* and every downcast below
606        // would miss, turning every panic message into "panicked".
607        Err(payload) => RResult::RErr(RString::from(format!(
608            "{}{}",
609            apiplant_abi::INTERNAL_ERROR_PREFIX,
610            panic_message(&*payload)
611        ))),
612    }
613}
614
615/// [`invoke_handler`] minus the panic firewall — everything here may unwind, and
616/// [`invoke_handler`] is what stops it from reaching the ABI boundary.
617fn run_handler<C, I, O, E, F>(
618    host: &HostApi_TO<'_, RBox<()>>,
619    input: RStr<'_>,
620    handler: F,
621) -> RResult<abi_stable::std_types::RString, abi_stable::std_types::RString>
622where
623    C: serde::de::DeserializeOwned + Default,
624    I: serde::de::DeserializeOwned,
625    O: serde::Serialize,
626    E: core::fmt::Display,
627    F: FnOnce(&Context<'_, '_, C>, I) -> Result<O, E>,
628{
629    use abi_stable::std_types::RString;
630
631    let config: C = serde_json::from_str(host.config().as_str()).unwrap_or_default();
632    let principal_id = host.principal_id().into_string();
633    let hook = Hook::parse(host.hook().as_str());
634
635    let input: I = match serde_json::from_str(input.as_str()) {
636        Ok(v) => v,
637        Err(e) => return RResult::RErr(RString::from(format!("invalid input: {e}"))),
638    };
639
640    let ctx = Context::__new(host, config, principal_id, hook);
641    match handler(&ctx, input) {
642        Ok(output) => match serde_json::to_string(&output) {
643            Ok(s) => RResult::ROk(RString::from(s)),
644            Err(e) => RResult::RErr(RString::from(format!("failed to serialize output: {e}"))),
645        },
646        Err(e) => RResult::RErr(RString::from(e.to_string())),
647    }
648}
649
650/// Recover the text from a caught panic payload. `panic!` with a literal yields
651/// a `&str` and the formatting forms yield a `String`; anything else (a
652/// `panic_any` with a custom type) has no text to show.
653fn panic_message(payload: &(dyn core::any::Any + Send)) -> &str {
654    if let Some(s) = payload.downcast_ref::<&'static str>() {
655        s
656    } else if let Some(s) = payload.downcast_ref::<String>() {
657        s.as_str()
658    } else {
659        "panicked"
660    }
661}
662
663/// One exported function: a manifest plus the handler that serves it.
664///
665/// Generated code builds one of these per entry in [`functions!`], which is what
666/// lets a single library export several independently-named functions without
667/// declaring a type for each. The `C`/`I`/`O`/`E` parameters are inferred from
668/// the handler's signature, exactly as they are for a lone [`function!`].
669/// The handler shape an [`Exported`] stands for, held only as a marker so the
670/// inferred type parameters stay pinned to the struct.
671type Signature<C, I, O, E> = fn(C, I) -> Result<O, E>;
672
673#[doc(hidden)]
674pub struct Exported<C, I, O, E, F> {
675    manifest: apiplant_abi::FunctionManifest,
676    handler: F,
677    _signature: core::marker::PhantomData<Signature<C, I, O, E>>,
678}
679
680impl<C, I, O, E, F> Exported<C, I, O, E, F> {
681    pub fn new(manifest: apiplant_abi::FunctionManifest, handler: F) -> Self {
682        Exported {
683            manifest,
684            handler,
685            _signature: core::marker::PhantomData,
686        }
687    }
688}
689
690impl<C, I, O, E, F> apiplant_abi::Function for Exported<C, I, O, E, F>
691where
692    C: serde::de::DeserializeOwned + Default,
693    I: serde::de::DeserializeOwned,
694    O: serde::Serialize,
695    E: core::fmt::Display,
696    F: Fn(&Context<'_, '_, C>, I) -> Result<O, E> + Send + Sync,
697{
698    fn manifest(&self) -> apiplant_abi::FunctionManifest {
699        self.manifest.clone()
700    }
701
702    fn invoke(
703        &self,
704        host: HostApi_TO<'_, RBox<()>>,
705        input: RStr<'_>,
706    ) -> RResult<abi_stable::std_types::RString, abi_stable::std_types::RString> {
707        invoke_handler(&host, input, &self.handler)
708    }
709}
710
711/// Produce the JSON Schema for a handler's `Input` type, inferred from the
712/// handler's signature. Used by [`function!`] to type the request body in the
713/// OpenAPI docs. Returns `""` when the `schema` feature is off.
714#[doc(hidden)]
715#[cfg(feature = "schema")]
716pub fn input_schema_json<C, I, O, E, F>(_handler: &F) -> String
717where
718    F: Fn(&Context<'_, '_, C>, I) -> Result<O, E>,
719    I: schemars::JsonSchema,
720{
721    serde_json::to_string(&schemars::schema_for!(I)).unwrap_or_default()
722}
723
724/// Produce the JSON Schema for a handler's `Output` (the `Ok` type).
725#[doc(hidden)]
726#[cfg(feature = "schema")]
727pub fn output_schema_json<C, I, O, E, F>(_handler: &F) -> String
728where
729    F: Fn(&Context<'_, '_, C>, I) -> Result<O, E>,
730    O: schemars::JsonSchema,
731{
732    serde_json::to_string(&schemars::schema_for!(O)).unwrap_or_default()
733}
734
735#[doc(hidden)]
736#[cfg(not(feature = "schema"))]
737pub fn input_schema_json<C, I, O, E, F>(_handler: &F) -> String
738where
739    F: Fn(&Context<'_, '_, C>, I) -> Result<O, E>,
740{
741    String::new()
742}
743
744#[doc(hidden)]
745#[cfg(not(feature = "schema"))]
746pub fn output_schema_json<C, I, O, E, F>(_handler: &F) -> String
747where
748    F: Fn(&Context<'_, '_, C>, I) -> Result<O, E>,
749{
750    String::new()
751}
752
753/// Turn a `permission` string into the nearest legacy [`Visibility`] + role.
754///
755/// The manifest carries both because [`Visibility`] is the older, coarser field
756/// that generated docs and pre-`permission` tooling still read. `member` has no
757/// `Visibility` of its own, so it degrades to `Authenticated` — the closest
758/// truthful statement, and never a *wider* one.
759#[doc(hidden)]
760pub fn derive_visibility(permission: &str) -> (apiplant_abi::Visibility, String) {
761    use apiplant_abi::{FunctionAccess, Visibility};
762    match FunctionAccess::parse(permission) {
763        Some(FunctionAccess::Public) => (Visibility::Public, String::new()),
764        Some(FunctionAccess::Authenticated) | Some(FunctionAccess::Member) => {
765            (Visibility::Authenticated, String::new())
766        }
767        Some(FunctionAccess::Role(role)) => (Visibility::RoleGated, role),
768        // Unparseable or absent: closed, like every other access default here.
769        Some(FunctionAccess::Private) | None => (Visibility::Private, String::new()),
770    }
771}
772
773/// Collects the optional `admin { … }` block of a [`functions!`] entry and
774/// serialises it into [`apiplant_abi::FunctionManifest::admin`].
775///
776/// An entry that declares nothing produces the empty string rather than `{}`,
777/// so "said nothing" stays distinguishable from "said the defaults out loud".
778#[doc(hidden)]
779#[derive(Default)]
780pub struct AdminBuilder {
781    pub visible: Option<bool>,
782    pub roles: Vec<String>,
783    pub label: Option<String>,
784    pub group: Option<String>,
785    pub description: Option<String>,
786    pub confirm: Option<String>,
787    pub run_label: Option<String>,
788    pub order: Option<i64>,
789}
790
791impl AdminBuilder {
792    pub fn finish(self) -> String {
793        let mut object = serde_json::Map::new();
794        let mut put = |key: &str, value: Option<serde_json::Value>| {
795            if let Some(value) = value {
796                object.insert(key.to_string(), value);
797            }
798        };
799        put("visible", self.visible.map(serde_json::Value::from));
800        put("label", self.label.map(serde_json::Value::from));
801        put("group", self.group.map(serde_json::Value::from));
802        put("description", self.description.map(serde_json::Value::from));
803        put("confirm", self.confirm.map(serde_json::Value::from));
804        put("run_label", self.run_label.map(serde_json::Value::from));
805        put("order", self.order.map(serde_json::Value::from));
806        if !self.roles.is_empty() {
807            object.insert("roles".to_string(), serde_json::Value::from(self.roles));
808        }
809        if object.is_empty() {
810            return String::new();
811        }
812        serde_json::to_string(&object).unwrap_or_default()
813    }
814}
815
816/// A curated set of imports for function authors: `use apiplant_function::prelude::*;`.
817pub mod prelude {
818    pub use crate::{reply, Context, Email, Hook, Sent};
819    pub use apiplant_abi::{HttpMethod, LogLevel, Visibility};
820    /// `#[derive(JsonSchema)]` for typed OpenAPI (with the `schema` feature).
821    #[cfg(feature = "schema")]
822    pub use schemars::JsonSchema;
823}
824
825/// Re-exports the generated code depends on. Not a stable public API.
826#[doc(hidden)]
827pub mod __rt {
828    pub use crate::{
829        derive_visibility, input_schema_json, invoke_handler, output_schema_json, AdminBuilder,
830        Context, Exported, Hook,
831    };
832    pub use abi_stable::export_root_module;
833    pub use abi_stable::prefix_type::PrefixTypeTrait;
834    pub use abi_stable::sabi_extern_fn;
835    pub use abi_stable::sabi_trait::TD_Opaque;
836    pub use abi_stable::std_types::{RBox, RResult, RStr, RString, RVec};
837    pub use apiplant_abi::{
838        BoxedFunction, Function, FunctionManifest, FunctionMod, FunctionMod_Ref, Function_TO,
839        HostApi_TO, HttpMethod, Visibility,
840    };
841}
842
843/// Define and export **one** apiplant function from a plain handler.
844///
845/// Only `name`, `description`, `method` and `handler` are required:
846///
847/// ```no_run
848/// # use apiplant_function::prelude::*;
849/// # type Json = serde_json::Value;
850/// # fn greet(_ctx: &Context<()>, input: Json) -> Result<Json, String> { Ok(input) }
851/// apiplant_function::function! {
852///     name: "greet",               // URL segment → /functions/greet
853///     version: "1.2.0",            // optional; defaults to CARGO_PKG_VERSION
854///     description: "Greets people",
855///     method: Post,                // Get | Post | Put | Delete
856///     permission: "role:admin",    // public | authenticated | member | role:<name> | private
857///     handler: greet,              // fn(&Context<C>, I) -> Result<O, E>
858/// }
859/// # fn main() {}
860/// ```
861///
862/// # Access
863///
864/// `permission` uses the same grammar as a resource's `[permissions]`, so an
865/// app has one access vocabulary rather than two. The older
866/// `visibility: RoleGated` + `role: "admin"` pair still works and means exactly
867/// what it always did; give one or the other, not both.
868///
869/// `member` — any member of the caller's active organisation — is the level
870/// most operator-facing actions want and the reason `permission` exists;
871/// `visibility` cannot express it.
872///
873/// # Appearing in the dashboard
874///
875/// The optional `admin` block controls how `apiplant admin` presents the
876/// function. Every key is optional:
877///
878/// ```no_run
879/// # use apiplant_function::prelude::*;
880/// # type Json = serde_json::Value;
881/// # fn reindex(_ctx: &Context<()>, input: Json) -> Result<Json, String> { Ok(input) }
882/// apiplant_function::function! {
883///     name: "reindex_catalogue",
884///     description: "Rebuilds the product search index.",
885///     method: Post,
886///     permission: "role:admin",
887///     admin: {
888///         visible: true,                        // default: true unless private
889///         roles: ["admin", "manager"],          // who sees it; default: anyone who may call it
890///         label: "Rebuild search index",
891///         group: "Maintenance",
892///         description: "Run this after a bulk import.",
893///         confirm: "Rebuild the index for every product?",
894///         run_label: "Rebuild index",
895///         order: 10,
896///     },
897///     handler: reindex,
898/// }
899/// # fn main() {}
900/// ```
901///
902/// This is presentation only — hiding a function from the dashboard does not
903/// close its endpoint. `permission` is what does that.
904///
905/// To export several functions from one library, use [`functions!`] — this is
906/// exactly that macro with a single entry.
907#[macro_export]
908macro_rules! function {
909    ( $($definition:tt)* ) => {
910        $crate::functions! { { $($definition)* } }
911    };
912}
913
914/// Define and export **several** apiplant functions from one library.
915///
916/// Each entry is an independent function with its own name, manifest and
917/// handler — there is no shared dispatcher and no matching inside a handler.
918/// This is how one crate provides a set of related endpoints, or a resource's
919/// whole set of lifecycle hooks:
920///
921/// ```no_run
922/// # use apiplant_function::prelude::*;
923/// # type Json = serde_json::Value;
924/// # fn post_before_create(_ctx: &Context<()>, input: Json) -> Result<Json, String> { Ok(input) }
925/// # fn post_after_create(_ctx: &Context<()>, input: Json) -> Result<Json, String> { Ok(input) }
926/// apiplant_function::functions! {
927///     {
928///         name: "post_before_create",
929///         description: "Validates a post before it is stored.",
930///         method: Post,
931///         visibility: Private,
932///         handler: post_before_create,
933///     },
934///     {
935///         name: "post_after_create",
936///         description: "Records a newly created post.",
937///         method: Post,
938///         visibility: Private,
939///         handler: post_after_create,
940///     },
941/// }
942/// # fn main() {}
943/// ```
944///
945/// Then, in `models/post.toml`:
946///
947/// ```toml
948/// [hooks]
949/// before_create = "post_before_create"
950/// after_create  = "post_after_create"
951/// ```
952///
953/// Every entry takes the same fields as [`function!`], and each handler keeps
954/// its own inferred `Config`/`Input`/`Output` types. Names must be unique within
955/// a library; the host rejects duplicates at load time.
956#[macro_export]
957macro_rules! functions {
958    (
959        $(
960            {
961                name: $name:expr,
962                $(version: $version:expr,)?
963                description: $description:expr,
964                method: $method:ident,
965                $(visibility: $visibility:ident,)?
966                $(permission: $permission:expr,)?
967                $(role: $role:expr,)?
968                $(admin: {
969                    $(visible: $admin_visible:expr,)?
970                    $(roles: $admin_roles:expr,)?
971                    $(label: $admin_label:expr,)?
972                    $(group: $admin_group:expr,)?
973                    $(description: $admin_description:expr,)?
974                    $(confirm: $admin_confirm:expr,)?
975                    $(run_label: $admin_run_label:expr,)?
976                    $(order: $admin_order:expr,)?
977                },)?
978                handler: $handler:path
979                $(,)?
980            }
981        ),+
982        $(,)?
983    ) => {
984        #[doc(hidden)]
985        pub mod __apiplant_generated_functions {
986            use super::*;
987
988            #[$crate::__rt::export_root_module]
989            fn __apiplant_root_module() -> $crate::__rt::FunctionMod_Ref {
990                use $crate::__rt::PrefixTypeTrait as _;
991                $crate::__rt::FunctionMod {
992                    new_functions: __apiplant_new_functions,
993                }
994                .leak_into_prefix()
995            }
996
997            #[$crate::__rt::sabi_extern_fn]
998            fn __apiplant_new_functions() -> $crate::__rt::RVec<$crate::__rt::BoxedFunction> {
999                let mut exported = $crate::__rt::RVec::new();
1000                $(
1001                    exported.push({
1002                        #[allow(unused_mut)]
1003                        let mut version =
1004                            $crate::__rt::RString::from(::core::env!("CARGO_PKG_VERSION"));
1005                        $( version = $crate::__rt::RString::from($version); )?
1006
1007                        #[allow(unused_mut)]
1008                        let mut role = ::std::string::String::new();
1009                        $( role = ::std::string::String::from($role); )?
1010
1011                        // `permission` is the current spelling and `visibility`
1012                        // + `role` the original one. Whichever the author used,
1013                        // both fields end up populated and agreeing.
1014                        #[allow(unused_mut)]
1015                        let mut permission = ::std::string::String::new();
1016                        $( permission = ::std::string::String::from($permission); )?
1017
1018                        #[allow(unused_mut)]
1019                        let mut declared_visibility:
1020                            ::core::option::Option<$crate::__rt::Visibility> =
1021                            ::core::option::Option::None;
1022                        $(
1023                            declared_visibility = ::core::option::Option::Some(
1024                                $crate::__rt::Visibility::$visibility,
1025                            );
1026                        )?
1027
1028                        let (visibility, role) = match declared_visibility {
1029                            ::core::option::Option::Some(visibility) => {
1030                                if permission.is_empty() {
1031                                    permission = match visibility {
1032                                        $crate::__rt::Visibility::Public =>
1033                                            "public".to_string(),
1034                                        $crate::__rt::Visibility::Authenticated =>
1035                                            "authenticated".to_string(),
1036                                        $crate::__rt::Visibility::Private =>
1037                                            "private".to_string(),
1038                                        $crate::__rt::Visibility::RoleGated =>
1039                                            ::std::format!("role:{}", role),
1040                                    };
1041                                }
1042                                (visibility, role)
1043                            }
1044                            ::core::option::Option::None => {
1045                                let (visibility, derived_role) =
1046                                    $crate::__rt::derive_visibility(&permission);
1047                                let role = if role.is_empty() { derived_role } else { role };
1048                                (visibility, role)
1049                            }
1050                        };
1051
1052                        #[allow(unused_mut)]
1053                        let mut admin = $crate::__rt::AdminBuilder::default();
1054                        $(
1055                            $( admin.visible = ::core::option::Option::Some($admin_visible); )?
1056                            $(
1057                                admin.roles = $admin_roles
1058                                    .iter()
1059                                    .map(|role| ::std::string::ToString::to_string(role))
1060                                    .collect();
1061                            )?
1062                            $( admin.label =
1063                                ::core::option::Option::Some($admin_label.to_string()); )?
1064                            $( admin.group =
1065                                ::core::option::Option::Some($admin_group.to_string()); )?
1066                            $( admin.description =
1067                                ::core::option::Option::Some($admin_description.to_string()); )?
1068                            $( admin.confirm =
1069                                ::core::option::Option::Some($admin_confirm.to_string()); )?
1070                            $( admin.run_label =
1071                                ::core::option::Option::Some($admin_run_label.to_string()); )?
1072                            $( admin.order = ::core::option::Option::Some($admin_order); )?
1073                        )?
1074
1075                        let manifest = $crate::__rt::FunctionManifest {
1076                            name: $crate::__rt::RString::from($name),
1077                            version,
1078                            description: $crate::__rt::RString::from($description),
1079                            visibility,
1080                            role: $crate::__rt::RString::from(role),
1081                            method: $crate::__rt::HttpMethod::$method,
1082                            permission: $crate::__rt::RString::from(permission),
1083                            admin: $crate::__rt::RString::from(admin.finish()),
1084                            config_schema: $crate::__rt::RString::new(),
1085                            input_schema: $crate::__rt::RString::from(
1086                                $crate::__rt::input_schema_json(&$handler),
1087                            ),
1088                            output_schema: $crate::__rt::RString::from(
1089                                $crate::__rt::output_schema_json(&$handler),
1090                            ),
1091                        };
1092                        $crate::__rt::Function_TO::from_value(
1093                            $crate::__rt::Exported::new(manifest, $handler),
1094                            $crate::__rt::TD_Opaque,
1095                        )
1096                    });
1097                )+
1098                exported
1099            }
1100        }
1101    };
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106    use super::*;
1107    use abi_stable::sabi_trait::TD_Opaque;
1108    use abi_stable::std_types::{RResult, RStr, RString};
1109    use apiplant_abi::{HostApi, HostApi_TO, LogLevel};
1110    use serde::{Deserialize, Serialize};
1111    use std::sync::Mutex;
1112
1113    struct MockHost {
1114        config_json: String,
1115        principal_id: String,
1116        hook_json: String,
1117        query_result: Result<String, String>,
1118        /// Reply to `send_email`/`cache`, or an error to hand back instead.
1119        service_result: Result<String, String>,
1120        requests: Mutex<Vec<String>>,
1121        /// Every `send_email` and `cache` request, as sent.
1122        service_requests: Mutex<Vec<String>>,
1123        logs: Mutex<Vec<(LogLevel, String)>>,
1124    }
1125
1126    impl MockHost {
1127        fn success(config_json: &str, principal_id: &str, response: serde_json::Value) -> Self {
1128            Self {
1129                config_json: config_json.into(),
1130                principal_id: principal_id.into(),
1131                hook_json: String::new(),
1132                query_result: Ok(response.to_string()),
1133                service_result: Ok("{}".to_string()),
1134                requests: Mutex::new(Vec::new()),
1135                service_requests: Mutex::new(Vec::new()),
1136                logs: Mutex::new(Vec::new()),
1137            }
1138        }
1139
1140        fn with_hook(mut self, hook: serde_json::Value) -> Self {
1141            self.hook_json = hook.to_string();
1142            self
1143        }
1144
1145        /// What `send_email`/`cache` should answer.
1146        fn replying(mut self, reply: serde_json::Value) -> Self {
1147            self.service_result = Ok(reply.to_string());
1148            self
1149        }
1150
1151        fn failing(mut self, error: &str) -> Self {
1152            self.service_result = Err(error.to_string());
1153            self
1154        }
1155
1156        /// The last request made through `send_email`/`cache`.
1157        fn last_service_request(&self) -> serde_json::Value {
1158            let requests = self.service_requests.lock().unwrap();
1159            serde_json::from_str(requests.last().expect("no service request was made")).unwrap()
1160        }
1161
1162        /// `send_email` and `cache` are the same shape — record the request,
1163        /// hand back the canned answer.
1164        fn service(&self, request: RStr<'_>) -> RResult<RString, RString> {
1165            self.service_requests
1166                .lock()
1167                .unwrap()
1168                .push(request.as_str().to_string());
1169            match &self.service_result {
1170                Ok(reply) => RResult::ROk(RString::from(reply.as_str())),
1171                Err(error) => RResult::RErr(RString::from(error.as_str())),
1172            }
1173        }
1174    }
1175
1176    impl HostApi for MockHost {
1177        fn query(&self, request: RStr<'_>) -> RResult<RString, RString> {
1178            self.requests
1179                .lock()
1180                .unwrap()
1181                .push(request.as_str().to_string());
1182            match &self.query_result {
1183                Ok(json) => RResult::ROk(RString::from(json.as_str())),
1184                Err(err) => RResult::RErr(RString::from(err.as_str())),
1185            }
1186        }
1187
1188        fn log(&self, level: LogLevel, message: RStr<'_>) {
1189            self.logs
1190                .lock()
1191                .unwrap()
1192                .push((level, message.as_str().to_string()));
1193        }
1194
1195        fn send_email(&self, request: RStr<'_>) -> RResult<RString, RString> {
1196            self.service(request)
1197        }
1198
1199        fn cache(&self, request: RStr<'_>) -> RResult<RString, RString> {
1200            self.service(request)
1201        }
1202
1203        fn config(&self) -> RString {
1204            self.config_json.clone().into()
1205        }
1206
1207        fn principal_id(&self) -> RString {
1208            self.principal_id.clone().into()
1209        }
1210
1211        fn hook(&self) -> RString {
1212            self.hook_json.clone().into()
1213        }
1214    }
1215
1216    /// A mock the test still holds a handle to after the ABI has taken it —
1217    /// the only way to assert on what a `Context` method actually sent.
1218    struct Shared(std::sync::Arc<MockHost>);
1219
1220    impl HostApi for Shared {
1221        fn query(&self, request: RStr<'_>) -> RResult<RString, RString> {
1222            self.0.query(request)
1223        }
1224
1225        fn log(&self, level: LogLevel, message: RStr<'_>) {
1226            self.0.log(level, message)
1227        }
1228
1229        fn send_email(&self, request: RStr<'_>) -> RResult<RString, RString> {
1230            self.0.send_email(request)
1231        }
1232
1233        fn cache(&self, request: RStr<'_>) -> RResult<RString, RString> {
1234            self.0.cache(request)
1235        }
1236
1237        fn config(&self) -> RString {
1238            self.0.config()
1239        }
1240
1241        fn principal_id(&self) -> RString {
1242            self.0.principal_id()
1243        }
1244
1245        fn hook(&self) -> RString {
1246            self.0.hook()
1247        }
1248    }
1249
1250    /// A mock kept alive alongside the trait object built from it.
1251    fn shared(mock: MockHost) -> (std::sync::Arc<MockHost>, HostApi_TO<'static, RBox<()>>) {
1252        let mock = std::sync::Arc::new(mock);
1253        let host = HostApi_TO::from_value(Shared(mock.clone()), TD_Opaque);
1254        (mock, host)
1255    }
1256
1257    #[derive(Deserialize)]
1258    struct Config {
1259        greeting: String,
1260    }
1261
1262    impl Default for Config {
1263        fn default() -> Self {
1264            Self {
1265                greeting: "Hello".into(),
1266            }
1267        }
1268    }
1269
1270    #[derive(Deserialize)]
1271    struct Input {
1272        name: String,
1273    }
1274
1275    #[derive(Serialize, serde::Deserialize, schemars::JsonSchema)]
1276    struct Output {
1277        message: String,
1278    }
1279
1280    #[test]
1281    fn context_bridges_queries_execution_and_principal_id() {
1282        let host = MockHost::success("{}", "user-123", serde_json::json!([{ "n": 1 }]));
1283        let host = HostApi_TO::from_value(host, TD_Opaque);
1284        let ctx = Context::__new(&host, (), "user-123".into(), None);
1285
1286        let rows = ctx
1287            .query("SELECT count(*) AS n", &[serde_json::json!(true)])
1288            .unwrap();
1289        assert_eq!(rows.len(), 1);
1290        assert_eq!(ctx.principal_id(), "user-123");
1291
1292        let request = &host.config().into_string();
1293        assert_eq!(request, "{}");
1294    }
1295
1296    #[test]
1297    fn context_execute_and_logging_use_host_bridge() {
1298        let host = MockHost::success("{}", "user-123", serde_json::json!({ "rows_affected": 3 }));
1299        let host = HostApi_TO::from_value(host, TD_Opaque);
1300        let ctx = Context::__new(&host, (), "user-123".into(), None);
1301
1302        assert_eq!(ctx.execute("DELETE FROM apiplant_post", &[]).unwrap(), 3);
1303        ctx.warn("careful");
1304    }
1305
1306    #[test]
1307    fn send_email_hands_the_host_the_message_and_reads_the_receipt() {
1308        let host = MockHost::success("{}", "u1", serde_json::json!([]))
1309            .replying(serde_json::json!({ "provider": "ses", "id": "abc", "recipients": 2 }));
1310        let host = HostApi_TO::from_value(host, TD_Opaque);
1311        let ctx = Context::__new(&host, (), "u1".into(), None);
1312
1313        let sent = ctx
1314            .send_email(
1315                Email::to("Ann <ann@example.com>")
1316                    .cc("bo@example.com")
1317                    .subject("Welcome")
1318                    .text("Hello")
1319                    .reply_to("help@example.com"),
1320            )
1321            .unwrap();
1322
1323        assert_eq!(sent.provider, "ses");
1324        assert_eq!(sent.id, "abc");
1325        assert_eq!(sent.recipients, 2);
1326    }
1327
1328    /// Empty parts must not appear on the wire at all: a provider that sees
1329    /// `"html": ""` may send a blank body instead of the text one.
1330    #[test]
1331    fn an_email_only_carries_the_fields_it_was_given() {
1332        let (mock, host) = shared(
1333            MockHost::success("{}", "u1", serde_json::json!([]))
1334                .replying(serde_json::json!({ "provider": "smtp", "id": "", "recipients": 1 })),
1335        );
1336        let ctx = Context::__new(&host, (), "u1".into(), None);
1337
1338        ctx.send_email(Email::to("ann@example.com").subject("Hi").text("Hello"))
1339            .unwrap();
1340
1341        let request = mock.last_service_request();
1342        assert_eq!(request["to"][0], "ann@example.com");
1343        assert_eq!(request["subject"], "Hi");
1344        assert_eq!(request["text"], "Hello");
1345        assert!(request.get("html").is_none());
1346        assert!(request.get("cc").is_none());
1347        assert!(request.get("from").is_none());
1348    }
1349
1350    #[test]
1351    fn a_provider_failure_surfaces_as_an_error() {
1352        let host = MockHost::success("{}", "u1", serde_json::json!([]))
1353            .failing("sendgrid rejected the message (401): unauthorized");
1354        let host = HostApi_TO::from_value(host, TD_Opaque);
1355        let ctx = Context::__new(&host, (), "u1".into(), None);
1356
1357        let err = ctx
1358            .send_email(Email::to("ann@example.com").subject("Hi").text("Hello"))
1359            .unwrap_err();
1360        assert!(err.contains("401"), "{err}");
1361    }
1362
1363    #[test]
1364    fn cache_get_distinguishes_a_hit_from_a_miss() {
1365        let hit = MockHost::success("{}", "u1", serde_json::json!([]))
1366            .replying(serde_json::json!({ "hit": true, "value": { "eur": 1.1 } }));
1367        let hit = HostApi_TO::from_value(hit, TD_Opaque);
1368        let ctx = Context::__new(&hit, (), "u1".into(), None);
1369        assert_eq!(
1370            ctx.cache_get("rates").unwrap(),
1371            Some(serde_json::json!({ "eur": 1.1 }))
1372        );
1373
1374        let miss = MockHost::success("{}", "u1", serde_json::json!([]))
1375            .replying(serde_json::json!({ "hit": false, "value": null }));
1376        let miss = HostApi_TO::from_value(miss, TD_Opaque);
1377        let ctx = Context::__new(&miss, (), "u1".into(), None);
1378        assert_eq!(ctx.cache_get("rates").unwrap(), None);
1379    }
1380
1381    /// A cached value written by an older version of a function is a miss, not
1382    /// an error — otherwise every deployment breaks its own endpoint.
1383    #[test]
1384    fn cache_get_as_treats_an_unreadable_value_as_a_miss() {
1385        #[derive(serde::Deserialize)]
1386        struct Rates {
1387            #[allow(dead_code)]
1388            eur: f64,
1389        }
1390
1391        let host = MockHost::success("{}", "u1", serde_json::json!([]))
1392            .replying(serde_json::json!({ "hit": true, "value": { "old_shape": true } }));
1393        let host = HostApi_TO::from_value(host, TD_Opaque);
1394        let ctx = Context::__new(&host, (), "u1".into(), None);
1395
1396        assert!(ctx.cache_get_as::<Rates>("rates").unwrap().is_none());
1397    }
1398
1399    #[test]
1400    fn cache_writes_name_their_operation_key_and_ttl() {
1401        let (mock, host) = shared(
1402            MockHost::success("{}", "u1", serde_json::json!([])).replying(
1403                serde_json::json!({ "ok": true, "deleted": true, "value": 3, "ttl": 42 }),
1404            ),
1405        );
1406        let ctx = Context::__new(&host, (), "u1".into(), None);
1407
1408        ctx.cache_set("rates", &serde_json::json!({ "eur": 1.1 }), Some(900))
1409            .unwrap();
1410        let request = mock.last_service_request();
1411        assert_eq!(request["op"], "set");
1412        assert_eq!(request["key"], "rates");
1413        assert_eq!(request["value"]["eur"], 1.1);
1414        assert_eq!(request["ttl"], 900);
1415
1416        // No TTL is `null`, meaning "use the app's default" — not zero, which
1417        // would mean "never expire".
1418        ctx.cache_set("rates", &1, None).unwrap();
1419        assert!(mock.last_service_request()["ttl"].is_null());
1420
1421        assert_eq!(ctx.cache_incr("hits", 1, Some(60)).unwrap(), 3);
1422        assert_eq!(mock.last_service_request()["op"], "incr");
1423
1424        assert!(ctx.cache_delete("rates").unwrap());
1425        assert_eq!(mock.last_service_request()["op"], "delete");
1426
1427        assert_eq!(ctx.cache_ttl("rates").unwrap(), Some(42));
1428    }
1429
1430    #[test]
1431    fn invoke_handler_uses_default_config_when_host_config_is_invalid() {
1432        let host = MockHost::success("{not-json", "u1", serde_json::json!([]));
1433        let host = HostApi_TO::from_value(host, TD_Opaque);
1434
1435        let result = invoke_handler::<Config, Input, Output, String, _>(
1436            &host,
1437            RStr::from_str(r#"{"name":"Ann"}"#),
1438            |ctx, input| {
1439                Ok(Output {
1440                    message: format!("{}, {}!", ctx.config().greeting, input.name),
1441                })
1442            },
1443        );
1444
1445        let json = match result {
1446            RResult::ROk(v) => v.into_string(),
1447            RResult::RErr(e) => panic!("unexpected error: {}", e.into_string()),
1448        };
1449        assert!(json.contains("Hello, Ann!"));
1450    }
1451
1452    #[test]
1453    fn invoke_handler_rejects_invalid_input_json() {
1454        let host = MockHost::success("{}", "u1", serde_json::json!([]));
1455        let host = HostApi_TO::from_value(host, TD_Opaque);
1456
1457        let result = invoke_handler::<Config, Input, Output, String, _>(
1458            &host,
1459            RStr::from_str("{"),
1460            |_ctx, _input| {
1461                Ok(Output {
1462                    message: "never".into(),
1463                })
1464            },
1465        );
1466
1467        match result {
1468            RResult::ROk(v) => panic!("unexpected success: {}", v.into_string()),
1469            RResult::RErr(e) => assert!(e.into_string().contains("invalid input")),
1470        }
1471    }
1472
1473    /// A panic must not escape as a panic: `Function::invoke` is reached through
1474    /// an `extern "C"` pointer, and `abi_stable` aborts the process rather than
1475    /// letting one unwind into the host.
1476    #[test]
1477    fn invoke_handler_turns_a_panicking_handler_into_an_internal_error() {
1478        let host = MockHost::success("{}", "u1", serde_json::json!([]));
1479        let host = HostApi_TO::from_value(host, TD_Opaque);
1480
1481        let result = invoke_handler::<Config, Input, Output, String, _>(
1482            &host,
1483            RStr::from_str(r#"{"name":"Ann"}"#),
1484            |_ctx, _input| panic!("handler exploded"),
1485        );
1486
1487        match result {
1488            RResult::ROk(v) => panic!("unexpected success: {}", v.into_string()),
1489            RResult::RErr(e) => {
1490                let msg = e.into_string();
1491                let detail = msg
1492                    .strip_prefix(apiplant_abi::INTERNAL_ERROR_PREFIX)
1493                    .expect("a panic must be marked internal so the host answers 500, not 400");
1494                // The real message has to survive, or the operator's log says nothing.
1495                assert_eq!(detail, "handler exploded");
1496            }
1497        }
1498    }
1499
1500    /// The same for the implicit panics people actually hit.
1501    #[test]
1502    fn invoke_handler_catches_panics_from_unwrap_and_indexing() {
1503        for (label, handler) in [
1504            (
1505                "unwrap",
1506                Box::new(
1507                    |_: &Context<'_, '_, Config>, input: Input| -> Result<Output, String> {
1508                        // Derived from the input so clippy sees a real `Option`
1509                        // rather than a literal `None` it can flag at the call site.
1510                        let missing = input.name.strip_prefix("nonexistent-prefix");
1511                        Ok(Output {
1512                            message: missing.unwrap().to_string(),
1513                        })
1514                    },
1515                )
1516                    as Box<dyn Fn(&Context<'_, '_, Config>, Input) -> Result<Output, String>>,
1517            ),
1518            (
1519                "index",
1520                Box::new(
1521                    |_: &Context<'_, '_, Config>, input: Input| -> Result<Output, String> {
1522                        // Indexed by input length so the compiler can't prove it's
1523                        // out of bounds and reject the test with `unconditional_panic`.
1524                        let empty: Vec<u8> = Vec::new();
1525                        let _ = empty[input.name.len()];
1526                        unreachable!()
1527                    },
1528                ),
1529            ),
1530        ] {
1531            let host = MockHost::success("{}", "u1", serde_json::json!([]));
1532            let host = HostApi_TO::from_value(host, TD_Opaque);
1533            let result = invoke_handler::<Config, Input, Output, String, _>(
1534                &host,
1535                RStr::from_str(r#"{"name":"Ann"}"#),
1536                handler,
1537            );
1538            match result {
1539                RResult::ROk(_) => panic!("{label}: expected an error"),
1540                RResult::RErr(e) => {
1541                    let msg = e.into_string();
1542                    assert!(
1543                        msg.starts_with(apiplant_abi::INTERNAL_ERROR_PREFIX),
1544                        "{label}: not marked internal: {msg}"
1545                    );
1546                    // "panicked" is the fallback for payloads with no text; these
1547                    // both carry a real message, so seeing it means the downcast
1548                    // erased the Box instead of its contents.
1549                    assert_ne!(
1550                        msg,
1551                        format!("{}panicked", apiplant_abi::INTERNAL_ERROR_PREFIX),
1552                        "{label}: panic message was lost"
1553                    );
1554                }
1555            }
1556        }
1557    }
1558
1559    /// A handler that merely *returns* an error keeps the plain (400) channel —
1560    /// only faults get the internal marker.
1561    #[test]
1562    fn a_returned_error_is_not_marked_internal() {
1563        let host = MockHost::success("{}", "u1", serde_json::json!([]));
1564        let host = HostApi_TO::from_value(host, TD_Opaque);
1565
1566        let result = invoke_handler::<Config, Input, Output, String, _>(
1567            &host,
1568            RStr::from_str(r#"{"name":"Ann"}"#),
1569            |_ctx, _input| Err("name is taken".to_string()),
1570        );
1571
1572        match result {
1573            RResult::ROk(_) => panic!("expected an error"),
1574            RResult::RErr(e) => assert_eq!(e.into_string(), "name is taken"),
1575        }
1576    }
1577
1578    /// The whole point: the same panic driven through the `extern "C"` vtable
1579    /// `abi_stable` builds. Before the firewall this aborted the test process.
1580    #[test]
1581    fn a_panic_does_not_cross_the_abi_boundary() {
1582        let manifest = apiplant_abi::FunctionManifest {
1583            name: "boom".into(),
1584            version: "0.0.0".into(),
1585            description: RString::new(),
1586            visibility: apiplant_abi::Visibility::Public,
1587            role: RString::new(),
1588            method: apiplant_abi::HttpMethod::Post,
1589            permission: RString::new(),
1590            admin: RString::new(),
1591            config_schema: RString::new(),
1592            input_schema: RString::new(),
1593            output_schema: RString::new(),
1594        };
1595        let exported = Exported::<Config, Input, Output, String, _>::new(
1596            manifest,
1597            |_ctx: &Context<'_, '_, Config>, _input: Input| -> Result<Output, String> {
1598                panic!("handler exploded")
1599            },
1600        );
1601
1602        // Erase it exactly as a real library does, so `invoke` below travels
1603        // through the generated `extern "C"` function pointer.
1604        let boxed: apiplant_abi::BoxedFunction =
1605            apiplant_abi::Function_TO::from_value(exported, TD_Opaque);
1606        assert_eq!(boxed.manifest().name.as_str(), "boom");
1607
1608        let host = HostApi_TO::from_value(
1609            MockHost::success("{}", "u1", serde_json::json!([])),
1610            TD_Opaque,
1611        );
1612        match boxed.invoke(host, RStr::from_str(r#"{"name":"Ann"}"#)) {
1613            RResult::ROk(v) => panic!("unexpected success: {}", v.into_string()),
1614            RResult::RErr(e) => assert!(e
1615                .into_string()
1616                .starts_with(apiplant_abi::INTERNAL_ERROR_PREFIX)),
1617        }
1618    }
1619
1620    fn hook_context() -> serde_json::Value {
1621        serde_json::json!({
1622            "event": "after_create",
1623            "action": "create",
1624            "phase": "after",
1625            "resource": "post",
1626            "url": "/api/post?draft=true",
1627            "method": "POST",
1628            "query": { "draft": "true" },
1629            "authenticated": true,
1630            "principal_id": "11111111-1111-1111-1111-111111111111",
1631            "organization_id": "22222222-2222-2222-2222-222222222222",
1632            "role": "admin",
1633            "record_id": null,
1634            "data": null,
1635            "row": { "id": "33333333-3333-3333-3333-333333333333", "title": "Hi" },
1636            "rows": null,
1637        })
1638    }
1639
1640    #[test]
1641    fn context_exposes_hook_data_when_invoked_as_a_hook() {
1642        let host = MockHost::success("{}", "u1", serde_json::json!([])).with_hook(hook_context());
1643        let host = HostApi_TO::from_value(host, TD_Opaque);
1644        let hook = Hook::parse(host.hook().as_str());
1645        let ctx = Context::__new(&host, (), "u1".into(), hook);
1646
1647        let hook = ctx.hook().expect("hook context should be present");
1648        assert_eq!(hook.event, "after_create");
1649        assert_eq!(hook.action, "create");
1650        assert!(hook.is_after());
1651        assert!(!hook.is_before());
1652        assert_eq!(hook.resource, "post");
1653        assert_eq!(hook.url, "/api/post?draft=true");
1654        assert_eq!(hook.method, "POST");
1655        assert_eq!(hook.query.get("draft").map(String::as_str), Some("true"));
1656        assert!(hook.authenticated);
1657        assert_eq!(hook.role.as_deref(), Some("admin"));
1658        // A context from an older server carries no `roles`; that is a hook
1659        // with nothing to say about them, not a parse failure.
1660        assert!(hook.roles.is_empty());
1661        assert!(hook.organization_id.is_some());
1662        assert_eq!(hook.record_id, None);
1663        assert_eq!(hook.row()["title"], "Hi");
1664        assert!(hook.data().is_null());
1665        assert!(hook.rows().is_empty());
1666        // `field` reads the row when no submitted data is present.
1667        assert_eq!(hook.field("title").and_then(|v| v.as_str()), Some("Hi"));
1668    }
1669
1670    #[test]
1671    fn hook_is_absent_for_plain_http_invocations() {
1672        let host = MockHost::success("{}", "u1", serde_json::json!([]));
1673        let host = HostApi_TO::from_value(host, TD_Opaque);
1674        let ctx = Context::__new(&host, (), "u1".into(), Hook::parse(host.hook().as_str()));
1675
1676        assert!(ctx.hook().is_none());
1677        assert!(Hook::parse("").is_none());
1678        assert!(Hook::parse("   ").is_none());
1679        assert!(Hook::parse("{not json").is_none());
1680    }
1681
1682    #[test]
1683    fn hook_reads_submitted_data_on_before_events_and_lists_on_after_list() {
1684        let before = Hook::parse(
1685            &serde_json::json!({
1686                "event": "before_create",
1687                "phase": "before",
1688                "data": { "title": "Draft" },
1689            })
1690            .to_string(),
1691        )
1692        .unwrap();
1693        assert!(before.is_before());
1694        assert_eq!(
1695            before.field("title").and_then(|v| v.as_str()),
1696            Some("Draft")
1697        );
1698        assert!(before.row().is_null());
1699
1700        let listed = Hook::parse(
1701            &serde_json::json!({
1702                "event": "after_list",
1703                "phase": "after",
1704                "rows": [{ "id": "a" }, { "id": "b" }],
1705            })
1706            .to_string(),
1707        )
1708        .unwrap();
1709        assert_eq!(listed.rows().len(), 2);
1710        assert_eq!(listed.rows()[1]["id"], "b");
1711    }
1712
1713    #[test]
1714    fn hook_tolerates_missing_and_unknown_fields() {
1715        let sparse = Hook::parse(r#"{"event":"before_delete","surprise":42}"#).unwrap();
1716        assert_eq!(sparse.event, "before_delete");
1717        assert_eq!(sparse.resource, "");
1718        assert!(!sparse.authenticated);
1719        assert!(sparse.principal_id.is_none());
1720    }
1721
1722    #[test]
1723    fn reply_helpers_build_the_host_protocol() {
1724        assert_eq!(reply::proceed(), serde_json::json!({}));
1725        assert_eq!(
1726            reply::replace(serde_json::json!({ "title": "clean" })),
1727            serde_json::json!({ "data": { "title": "clean" } })
1728        );
1729        assert_eq!(
1730            reply::abort(422, "title is required"),
1731            serde_json::json!({ "error": { "status": 422, "message": "title is required" } })
1732        );
1733    }
1734
1735    #[test]
1736    fn invoke_handler_passes_hook_context_through_to_the_handler() {
1737        let host = MockHost::success("{}", "u1", serde_json::json!([])).with_hook(hook_context());
1738        let host = HostApi_TO::from_value(host, TD_Opaque);
1739
1740        let result = invoke_handler::<(), serde_json::Value, serde_json::Value, String, _>(
1741            &host,
1742            RStr::from_str(r#"{"id":"33333333-3333-3333-3333-333333333333","title":"Hi"}"#),
1743            |ctx, input| {
1744                let hook = ctx.hook().ok_or("expected a hook context")?;
1745                assert_eq!(input["title"], "Hi");
1746                Ok(reply::replace(serde_json::json!({
1747                    "event": hook.event,
1748                    "title": hook.row()["title"],
1749                })))
1750            },
1751        );
1752
1753        let json = match result {
1754            RResult::ROk(v) => v.into_string(),
1755            RResult::RErr(e) => panic!("unexpected error: {}", e.into_string()),
1756        };
1757        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1758        assert_eq!(value["data"]["event"], "after_create");
1759        assert_eq!(value["data"]["title"], "Hi");
1760    }
1761
1762    #[test]
1763    fn exported_functions_carry_their_own_manifest_and_handler() {
1764        use apiplant_abi::{Function, FunctionManifest, HttpMethod, Visibility};
1765
1766        fn manifest(name: &str) -> FunctionManifest {
1767            FunctionManifest {
1768                name: RString::from(name),
1769                version: RString::from("1.0.0"),
1770                description: RString::from("test"),
1771                visibility: Visibility::Private,
1772                role: RString::new(),
1773                method: HttpMethod::Post,
1774                permission: RString::new(),
1775                admin: RString::new(),
1776                config_schema: RString::new(),
1777                input_schema: RString::new(),
1778                output_schema: RString::new(),
1779            }
1780        }
1781
1782        // Two functions with different handlers — and different inferred input
1783        // types — as `functions!` builds them.
1784        let before: Exported<(), Input, Output, String, _> = Exported::new(
1785            manifest("post_before_create"),
1786            |_ctx: &Context<'_, '_, ()>, input: Input| {
1787                Ok(Output {
1788                    message: format!("before {}", input.name),
1789                })
1790            },
1791        );
1792        let after: Exported<(), Vec<i64>, Output, String, _> = Exported::new(
1793            manifest("post_after_list"),
1794            |_ctx: &Context<'_, '_, ()>, rows: Vec<i64>| {
1795                Ok(Output {
1796                    message: format!("after {}", rows.len()),
1797                })
1798            },
1799        );
1800
1801        assert_eq!(before.manifest().name.as_str(), "post_before_create");
1802        assert_eq!(after.manifest().name.as_str(), "post_after_list");
1803        assert_eq!(before.manifest().version.as_str(), "1.0.0");
1804
1805        let new_host = || {
1806            HostApi_TO::from_value(
1807                MockHost::success("{}", "u1", serde_json::json!([])),
1808                TD_Opaque,
1809            )
1810        };
1811
1812        let first = match before.invoke(new_host(), RStr::from_str(r#"{"name":"Ann"}"#)) {
1813            RResult::ROk(v) => v.into_string(),
1814            RResult::RErr(e) => panic!("unexpected error: {}", e.into_string()),
1815        };
1816        assert!(first.contains("before Ann"));
1817
1818        let second = match after.invoke(new_host(), RStr::from_str("[1,2,3]")) {
1819            RResult::ROk(v) => v.into_string(),
1820            RResult::RErr(e) => panic!("unexpected error: {}", e.into_string()),
1821        };
1822        assert!(second.contains("after 3"));
1823    }
1824
1825    #[test]
1826    fn exported_functions_are_abi_trait_objects() {
1827        use apiplant_abi::{FunctionManifest, Function_TO, HttpMethod, Visibility};
1828
1829        let exported: Exported<Config, Input, Output, String, _> = Exported::new(
1830            FunctionManifest {
1831                name: RString::from("greet"),
1832                version: RString::from("0.1.0"),
1833                description: RString::from("test"),
1834                visibility: Visibility::Public,
1835                role: RString::new(),
1836                method: HttpMethod::Post,
1837                permission: RString::new(),
1838                admin: RString::new(),
1839                config_schema: RString::new(),
1840                input_schema: RString::new(),
1841                output_schema: RString::new(),
1842            },
1843            |ctx: &Context<'_, '_, Config>, input: Input| {
1844                Ok(Output {
1845                    message: format!("{}, {}!", ctx.config().greeting, input.name),
1846                })
1847            },
1848        );
1849
1850        // This is the exact conversion the `functions!` macro performs per entry.
1851        let boxed = Function_TO::from_value(exported, TD_Opaque);
1852        assert_eq!(boxed.manifest().name.as_str(), "greet");
1853
1854        let host = MockHost::success(r#"{"greeting":"Hi"}"#, "u1", serde_json::json!([]));
1855        let host = HostApi_TO::from_value(host, TD_Opaque);
1856        let reply = match boxed.invoke(host, RStr::from_str(r#"{"name":"Ann"}"#)) {
1857            RResult::ROk(v) => v.into_string(),
1858            RResult::RErr(e) => panic!("unexpected error: {}", e.into_string()),
1859        };
1860        assert!(reply.contains("Hi, Ann!"));
1861    }
1862
1863    #[derive(Deserialize, schemars::JsonSchema)]
1864    struct SchemaInput {
1865        name: String,
1866    }
1867
1868    #[derive(Serialize, schemars::JsonSchema)]
1869    struct SchemaOutput {
1870        ok: bool,
1871    }
1872
1873    #[test]
1874    fn schema_generation_is_typed() {
1875        let handler =
1876            |_ctx: &Context<'_, '_, ()>, input: SchemaInput| -> Result<SchemaOutput, String> {
1877                Ok(SchemaOutput {
1878                    ok: !input.name.is_empty(),
1879                })
1880            };
1881
1882        let input_schema = input_schema_json::<(), SchemaInput, SchemaOutput, String, _>(&handler);
1883        let output_schema =
1884            output_schema_json::<(), SchemaInput, SchemaOutput, String, _>(&handler);
1885
1886        assert!(input_schema.contains("\"name\""));
1887        assert!(output_schema.contains("\"ok\""));
1888    }
1889}