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;