Skip to main content

fraiseql_functions/host/
mod.rs

1//! Host context trait for function runtime access to FraiseQL services.
2
3use std::future::Future;
4
5use fraiseql_error::Result;
6
7use crate::types::{EventPayload, LogEntry, LogLevel};
8
9#[cfg(feature = "host-live")]
10pub mod live;
11
12#[cfg(feature = "host-live")]
13pub mod factory;
14
15#[cfg(any(feature = "runtime-wasm", feature = "runtime-deno"))]
16pub mod dyn_context;
17
18/// Response from an HTTP request.
19#[derive(Debug, Clone)]
20pub struct HttpResponse {
21    /// HTTP status code.
22    pub status:  u16,
23    /// Response headers.
24    pub headers: Vec<(String, String)>,
25    /// Response body.
26    pub body:    Vec<u8>,
27}
28
29/// Trait for providing host services to functions (queries, storage, HTTP, etc.).
30///
31/// This trait is implemented by the FraiseQL server to allow functions to call
32/// back into the server's services during execution.
33///
34/// The `#[trait_variant::make]` macro generates `SendHostContext` which is
35/// object-safe for `Box<dyn SendHostContext>` dynamic dispatch.
36#[allow(clippy::trait_duplication_in_bounds)] // Reason: trait_variant::make expands to bounds that look duplicated to clippy
37#[trait_variant::make(SendHostContext: Send)]
38pub trait HostContext: Send + Sync {
39    /// Execute a GraphQL query.
40    ///
41    /// # Errors
42    ///
43    /// Returns `Err` if the query fails to execute.
44    fn query(
45        &self,
46        graphql: &str,
47        variables: serde_json::Value,
48    ) -> impl Future<Output = Result<serde_json::Value>> + Send;
49
50    /// Execute a raw SQL query.
51    ///
52    /// # Errors
53    ///
54    /// Returns `Err` if the query fails to execute or is classified as insecure.
55    fn sql_query(
56        &self,
57        sql: &str,
58        params: &[serde_json::Value],
59    ) -> impl Future<Output = Result<Vec<serde_json::Value>>> + Send;
60
61    /// Make an HTTP request.
62    ///
63    /// # Errors
64    ///
65    /// Returns `Err` if the request fails or is blocked (e.g., SSRF check).
66    fn http_request(
67        &self,
68        method: &str,
69        url: &str,
70        headers: &[(String, String)],
71        body: Option<&[u8]>,
72    ) -> impl Future<Output = Result<HttpResponse>> + Send;
73
74    /// Retrieve an object from storage.
75    ///
76    /// # Errors
77    ///
78    /// Returns `Err` if the object does not exist or access is denied.
79    fn storage_get(&self, bucket: &str, key: &str) -> impl Future<Output = Result<Vec<u8>>> + Send;
80
81    /// Store an object to storage.
82    ///
83    /// # Errors
84    ///
85    /// Returns `Err` if the write fails or access is denied.
86    fn storage_put(
87        &self,
88        bucket: &str,
89        key: &str,
90        body: &[u8],
91        content_type: &str,
92    ) -> impl Future<Output = Result<()>> + Send;
93
94    /// Send an email.
95    ///
96    /// The `from` is **host-owned**: it is resolved by the host from the
97    /// authenticated identity, never supplied by the guest (a `from` field in the
98    /// guest request is ignored). The request carries only `to`/`subject`/body.
99    ///
100    /// # Errors
101    ///
102    /// Returns `Err` if no sender-identity resolver or transport is configured, the
103    /// sender identity cannot be resolved (fail-closed), or the relay rejects the
104    /// message.
105    fn send_email(
106        &self,
107        request: &crate::outbound::SendEmailRequest,
108    ) -> impl Future<Output = Result<crate::outbound::SendEmailResponse>> + Send;
109
110    /// Get the current authenticated user's context.
111    ///
112    /// # Errors
113    ///
114    /// Returns `Err` if authentication information is unavailable.
115    fn auth_context(&self) -> Result<serde_json::Value>;
116
117    /// Get an environment variable.
118    ///
119    /// Returns `Ok(None)` if the variable is not set.
120    ///
121    /// # Errors
122    ///
123    /// Returns `Err` if the variable is blocked from access.
124    fn env_var(&self, name: &str) -> Result<Option<String>>;
125
126    /// Get the current event payload (for reference).
127    fn event_payload(&self) -> &EventPayload;
128
129    /// Log a message to the tracing subscriber.
130    fn log(&self, level: LogLevel, message: &str);
131
132    /// A per-dispatch idempotency token, stable across retries of the same
133    /// dispatch and distinct per dispatch.
134    ///
135    /// `None` on invocation paths that are not durably dispatched (the bare sync
136    /// `invoke` path, or a mock host that does not set one). The durable
137    /// dispatcher derives the token from the dispatch's stable identity and
138    /// injects it so a guest can pass it straight to a downstream money/mail
139    /// idempotency header, keeping an at-least-once dispatch at-most-once end to
140    /// end. Defaults to `None` so only hosts that carry a token override it.
141    fn idempotency_token(&self) -> Option<String> {
142        None
143    }
144
145    /// Read this source's durable opaque cursor (Model B pull sources, #573).
146    ///
147    /// Returns the JSON value the source last advanced to via
148    /// [`advance_cursor`](Self::advance_cursor), or `None` if it has never advanced.
149    /// Defaults to `None` on every host not bound to a source — a non-source
150    /// invocation has no cursor — so only source-bound hosts override it.
151    ///
152    /// # Errors
153    ///
154    /// Returns `Err` if the cursor load fails on a source-bound host.
155    fn cursor(&self) -> impl Future<Output = Result<Option<serde_json::Value>>> + Send {
156        async { Ok(None) }
157    }
158
159    /// Advance this source's durable cursor to `value` (Model B pull sources, #573).
160    ///
161    /// The value is opaque and owned by the source. Defaults to failing on a host
162    /// not bound to a source, so a guest that is not a scheduled source cannot move
163    /// a cursor.
164    ///
165    /// # Errors
166    ///
167    /// Returns `Err` on a host not bound to a source (the default), or if the write
168    /// fails / lost a concurrent race on a source-bound host.
169    fn advance_cursor(&self, value: serde_json::Value) -> impl Future<Output = Result<()>> + Send {
170        async move {
171            let _ = value;
172            Err(fraiseql_error::FraiseQLError::validation(
173                "advance_cursor: this function is not a scheduled source (no cursor binding)",
174            ))
175        }
176    }
177}
178
179/// A no-op host context for testing WASM execution without real backends.
180///
181/// All I/O methods return `Unsupported` errors. Logs are captured in-memory for test verification.
182pub struct NoopHostContext {
183    event_payload: EventPayload,
184    logs:          std::sync::Arc<std::sync::Mutex<Vec<LogEntry>>>,
185}
186
187impl NoopHostContext {
188    /// Create a new no-op host context for testing.
189    #[must_use]
190    pub fn new(event_payload: EventPayload) -> Self {
191        Self {
192            event_payload,
193            logs: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
194        }
195    }
196
197    /// Get a copy of all captured logs (for test verification).
198    ///
199    /// # Panics
200    ///
201    /// Panics if the Mutex is poisoned (should never happen in normal operation).
202    #[must_use]
203    pub fn captured_logs(&self) -> Vec<LogEntry> {
204        self.logs.lock().expect("log mutex poisoned").clone()
205    }
206}
207
208impl HostContext for NoopHostContext {
209    async fn query(
210        &self,
211        _graphql: &str,
212        _variables: serde_json::Value,
213    ) -> Result<serde_json::Value> {
214        Err(fraiseql_error::FraiseQLError::Unsupported {
215            message: "HostContext::query not implemented".to_string(),
216        })
217    }
218
219    async fn sql_query(
220        &self,
221        _sql: &str,
222        _params: &[serde_json::Value],
223    ) -> Result<Vec<serde_json::Value>> {
224        Err(fraiseql_error::FraiseQLError::Unsupported {
225            message: "HostContext::sql_query not implemented".to_string(),
226        })
227    }
228
229    async fn http_request(
230        &self,
231        _method: &str,
232        _url: &str,
233        _headers: &[(String, String)],
234        _body: Option<&[u8]>,
235    ) -> Result<HttpResponse> {
236        Err(fraiseql_error::FraiseQLError::Unsupported {
237            message: "HostContext::http_request not implemented".to_string(),
238        })
239    }
240
241    async fn storage_get(&self, _bucket: &str, _key: &str) -> Result<Vec<u8>> {
242        Err(fraiseql_error::FraiseQLError::Unsupported {
243            message: "HostContext::storage_get not implemented".to_string(),
244        })
245    }
246
247    async fn storage_put(
248        &self,
249        _bucket: &str,
250        _key: &str,
251        _body: &[u8],
252        _content_type: &str,
253    ) -> Result<()> {
254        Err(fraiseql_error::FraiseQLError::Unsupported {
255            message: "HostContext::storage_put not implemented".to_string(),
256        })
257    }
258
259    async fn send_email(
260        &self,
261        _request: &crate::outbound::SendEmailRequest,
262    ) -> Result<crate::outbound::SendEmailResponse> {
263        Err(fraiseql_error::FraiseQLError::Unsupported {
264            message: "HostContext::send_email not implemented".to_string(),
265        })
266    }
267
268    fn auth_context(&self) -> Result<serde_json::Value> {
269        Err(fraiseql_error::FraiseQLError::Unsupported {
270            message: "HostContext::auth_context not implemented".to_string(),
271        })
272    }
273
274    fn env_var(&self, _name: &str) -> Result<Option<String>> {
275        Ok(None)
276    }
277
278    fn event_payload(&self) -> &EventPayload {
279        &self.event_payload
280    }
281
282    fn log(&self, level: LogLevel, message: &str) {
283        let entry = LogEntry {
284            level,
285            message: message.to_string(),
286            timestamp: chrono::Utc::now(),
287        };
288        self.logs.lock().expect("log mutex poisoned").push(entry);
289    }
290}
291
292#[cfg(test)]
293mod tests;