Skip to main content

fraiseql_functions/host/
request_query.rs

1//! The host surface a `request:query` function runs on (#1329).
2//!
3//! [`RequestQueryHost`] is written out the way [`BeforeMutationHost`] is, and for
4//! the same reason: a host whose surface is "whatever the wiring happened to attach"
5//! answers "what may a request-serving function do?" with a call-site audit, and
6//! widens silently the day someone attaches one more backend. Every op is listed,
7//! and the ones this kind does not get refuse **by name**, saying which trigger kind
8//! is asking.
9//!
10//! # The surface, and why it is this one
11//!
12//! | op | `request:query` | why |
13//! |---|---|---|
14//! | `fraiseql_query` | **yes**, read-only, as the caller | it is a read: the field exists to compute an answer, and almost every answer depends on data |
15//! | `fraiseql_http_request` | **yes**, SSRF-allowlisted | #1329's own examples — an LLM-backed answer, a BFF aggregation — are outbound calls. The guard is `outbound_http::perform`, the same code the live host runs, so "the SSRF allowlist, unchanged" is a fact about the binary rather than a promise |
16//! | `fraiseql_auth_context` | yes, the caller's; refused when anonymous | a field keyed on who is asking |
17//! | `fraiseql_log` | yes | diagnostics never leave the process |
18//! | the event payload | the field's resolved **arguments**, under `data` | what the client asked for |
19//! | `fraiseql_sql_query` | no | it has no execution backend anywhere (#871) |
20//! | `fraiseql_storage_*`, `fraiseql_send_email` | **no** | a read that writes a file or sends mail is a side effect on a path that is cached, retried by clients, and reachable by anyone who can issue the query. Those belong in `after:mutation`, which is durable and dead-lettered |
21//! | `fraiseql_env_var` | no | nothing on this path needs one, and an allowlisted read is a widening to make deliberately |
22//! | the idempotency token, the source cursor | no | a request-serving invocation is not a durable dispatch and is not bound to a source |
23//!
24//! # Why this is narrower than `after:mutation` and wider than `before:mutation`
25//!
26//! `before:mutation` runs **inside** a write and its side effects are not rolled
27//! back when a later hook aborts, so it gets no outbound anything. This one decides
28//! nothing and rolls nothing back; its constraint is different — it answers a read,
29//! so it must not *cause* anything. Hence the reads and the outbound call, and no
30//! store, no mail.
31//!
32//! [`BeforeMutationHost`]: crate::host::before_mutation::BeforeMutationHost
33
34use std::sync::Arc;
35
36use fraiseql_core::security::{GuestQueryBridge, SecurityContext};
37use fraiseql_error::{FraiseQLError, Result};
38
39use crate::{
40    HostContext,
41    host::{HttpResponse, live::HostContextConfig},
42    types::{EventPayload, LogLevel},
43};
44
45/// The host a `request:query` function runs on: reads, one outbound call, and
46/// refusals that say why.
47pub struct RequestQueryHost {
48    /// The field's resolved arguments, in the shape the runner hands the guest.
49    event_payload: EventPayload,
50    /// The caller-scoped, read-only GraphQL bridge (#1328) — the same object a
51    /// `before:mutation` hook reads through. `None` leaves `query` refusing loudly
52    /// rather than answering from nowhere; the engine always supplies one.
53    reader:        Option<Arc<dyn GuestQueryBridge>>,
54    /// The principal that issued the request, or `None` when it was anonymous.
55    principal:     Option<SecurityContext>,
56    /// Outbound-HTTP configuration: the SSRF allowlist, the timeouts and the
57    /// response ceiling. Carried rather than defaulted so the allowlist an operator
58    /// configured is the one this surface enforces.
59    http:          HostContextConfig,
60    /// The shared outbound client, when the server built one.
61    http_client:   Option<Arc<reqwest::Client>>,
62}
63
64impl RequestQueryHost {
65    /// Build the host for one field invocation.
66    #[must_use]
67    pub fn new(
68        event_payload: EventPayload,
69        reader: Option<Arc<dyn GuestQueryBridge>>,
70        principal: Option<&SecurityContext>,
71        http: HostContextConfig,
72    ) -> Self {
73        Self {
74            event_payload,
75            reader,
76            principal: principal.cloned(),
77            http,
78            http_client: None,
79        }
80    }
81
82    /// Share an existing outbound HTTP client rather than building one per call.
83    #[must_use]
84    pub fn with_http_client(mut self, client: Arc<reqwest::Client>) -> Self {
85        self.http_client = Some(client);
86        self
87    }
88
89    /// The refusal every op outside the surface answers with.
90    ///
91    /// One constructor so the diagnosis is uniform and every refusal names the op
92    /// and the trigger kind — a guest author reading a stack trace needs to know
93    /// *which* surface said no, not just that something did.
94    fn outside_the_surface(op: &str) -> FraiseQLError {
95        FraiseQLError::Authorization {
96            message:  format!(
97                "`{op}` is not available to a request:query function: it answers a read, on a \
98                 path that is cached and that any caller who can issue the query can reach, so \
99                 it must not cause anything. Move the effect to an after:mutation function, \
100                 which is durable, retried and dead-lettered."
101            ),
102            action:   Some(op.to_string()),
103            resource: Some("request:query".to_string()),
104        }
105    }
106}
107
108// Reason: `HostContext` is async because other implementations perform I/O. The
109// ops this host refuses answer from memory but still have to present the awaited
110// signature the trait defines and every guest call site uses.
111#[allow(unknown_lints, clippy::unused_async_trait_impl)]
112impl HostContext for RequestQueryHost {
113    async fn query(
114        &self,
115        graphql: &str,
116        variables: serde_json::Value,
117    ) -> Result<serde_json::Value> {
118        let reader = self.reader.as_ref().ok_or_else(|| FraiseQLError::Unsupported {
119            message: "no read bridge is wired on this request:query host — the engine supplies \
120                      one for every invocation"
121                .to_string(),
122        })?;
123        // `Null` is what the guest sends when it passes no variables; forwarding it
124        // as `Some(Null)` would make the executor's "were variables supplied?"
125        // question answer yes with nothing in it.
126        let variables = (!variables.is_null()).then_some(variables);
127        reader.query(graphql, variables.as_ref()).await
128    }
129
130    async fn sql_query(
131        &self,
132        _sql: &str,
133        _params: &[serde_json::Value],
134    ) -> Result<Vec<serde_json::Value>> {
135        Err(Self::outside_the_surface("sql_query"))
136    }
137
138    async fn http_request(
139        &self,
140        method: &str,
141        url: &str,
142        headers: &[(String, String)],
143        body: Option<&[u8]>,
144    ) -> Result<HttpResponse> {
145        crate::host::outbound_http::perform(
146            &self.http,
147            self.http_client.as_ref(),
148            method,
149            url,
150            headers,
151            body,
152        )
153        .await
154    }
155
156    async fn storage_get(&self, _bucket: &str, _key: &str) -> Result<Vec<u8>> {
157        Err(Self::outside_the_surface("storage_get"))
158    }
159
160    async fn storage_put(
161        &self,
162        _bucket: &str,
163        _key: &str,
164        _body: &[u8],
165        _content_type: &str,
166    ) -> Result<()> {
167        Err(Self::outside_the_surface("storage_put"))
168    }
169
170    async fn send_email(
171        &self,
172        _request: &crate::outbound::SendEmailRequest,
173    ) -> Result<crate::outbound::SendEmailResponse> {
174        Err(Self::outside_the_surface("send_email"))
175    }
176
177    fn auth_context(&self) -> Result<serde_json::Value> {
178        // An anonymous request has no authenticated identity, and saying so is not
179        // the same as `LiveHostContext`'s "nothing was wired" (#803) — here the
180        // absence is a fact about the request. Refused rather than answered with a
181        // null-filled object, so a function cannot read `sub: null` as a user.
182        let context = self.principal.as_ref().ok_or_else(|| FraiseQLError::Unsupported {
183            message: "this query was issued anonymously: a request:query function has no \
184                      authenticated context to read"
185                .to_string(),
186        })?;
187        Ok(crate::host::auth_context_json(context))
188    }
189
190    fn env_var(&self, _name: &str) -> Result<Option<String>> {
191        Err(Self::outside_the_surface("env_var"))
192    }
193
194    fn event_payload(&self) -> &EventPayload {
195        &self.event_payload
196    }
197
198    fn log(&self, level: LogLevel, message: &str) {
199        match level {
200            LogLevel::Debug => tracing::debug!(target: "fraiseql::functions::guest", "{message}"),
201            LogLevel::Info => tracing::info!(target: "fraiseql::functions::guest", "{message}"),
202            LogLevel::Warn => tracing::warn!(target: "fraiseql::functions::guest", "{message}"),
203            LogLevel::Error => tracing::error!(target: "fraiseql::functions::guest", "{message}"),
204        }
205    }
206}
207
208/// Build the event payload a request-serving invocation is handed (#1329).
209///
210/// Shared so the server and the authoring harness (`fraiseql functions invoke`)
211/// hand a guest the same shape. A harness that built its own would be a second copy
212/// of the convention, free to drift from the one the server uses — which is the
213/// defect `interpret_guest_decision` was extracted to prevent on the write side.
214#[must_use]
215pub fn request_query_payload(field: &str, arguments: serde_json::Value) -> EventPayload {
216    EventPayload {
217        trigger_type: "request:query".to_string(),
218        // The root query field being answered. One function may back several, and
219        // this is how the guest tells which.
220        entity:       field.to_string(),
221        event_kind:   "request".to_string(),
222        data:         arguments,
223        timestamp:    chrono::Utc::now(),
224    }
225}
226
227/// Interpret what a request-serving guest returned as the field's data (#1329).
228///
229/// The convention is deliberately thin — the return value **is** the data — and
230/// extracted anyway, because the one case that needs a decision is the one a second
231/// implementation would get wrong: a guest that returns nothing. `None` is `null`,
232/// which a nullable field renders as `null` and a non-null field is refused for by
233/// the engine, naming the field. The alternative (treating "returned nothing" as an
234/// empty object) would render every selected field as null and say nothing.
235#[must_use]
236pub fn interpret_query_answer(value: Option<serde_json::Value>) -> serde_json::Value {
237    value.unwrap_or(serde_json::Value::Null)
238}
239
240#[cfg(test)]
241#[path = "request_query/tests.rs"]
242mod tests;