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;