Skip to main content

fraiseql_functions/triggers/http/
mod.rs

1//! HTTP triggers: Custom HTTP endpoints backed by functions.
2//!
3//! **NOT MOUNTED (#871):** no server code consumes this matcher today, so a
4//! declared `http:` trigger is **rejected at registry load** (a declared route
5//! that would silently never serve is a misconfiguration).
6//!
7//! **And the alternative changed (#1329).** A function that answers a request now
8//! does it as a `request:query` function behind a typed root query field, where it is
9//! introspectable, has checked arguments, participates in the existing auth, RLS and
10//! cache machinery, and cannot invent a route. The name-dispatched
11//! `POST /functions/v1/{name}` route this module used to point at was library-only —
12//! the stock binary never mounted it — and was retired with that decision. The types
13//! below are the building blocks for a future mounted surface, should evidence appear
14//! of a case a typed root field cannot serve; it would still need an auth story per
15//! ADR-0018 and ambiguous-route rejection.
16//!
17//! ## Routing (design, not served)
18//!
19//! Routes would mount under a `/functions/v1/` prefix:
20//! - `GET /functions/v1/users/:id` → `http:GET:/users/:id`
21//! - `POST /functions/v1/process` → `http:POST:/process`
22//!
23//! Path parameters are extracted and passed to the function in the event payload.
24//!
25//! ## Request Handling
26//!
27//! The function receives an `HttpTriggerPayload` containing:
28//! - HTTP method and path
29//! - Query parameters
30//! - Path parameters (from `:id` patterns)
31//! - Request headers and body
32//! - Authentication context (if required)
33//!
34//! ## Response Format
35//!
36//! Functions return `HttpTriggerResponse` with:
37//! - Optional status code (default 200)
38//! - Optional custom headers
39//! - Response body (serialized as JSON)
40//! # Trigger Format
41//!
42//! ```text
43//! http:<METHOD>:<path>
44//! http:GET:/hello
45//! http:POST:/users/:id/avatar
46//! http:DELETE:/cache/:key
47//! ```
48//!
49//! # Request Mapping
50//!
51//! HTTP requests are mapped to `EventPayload` with:
52//! - `trigger_type`: `"http:GET:/hello"`
53//! - `entity`: `"HttpRequest"`
54//! - `event_kind`: `"request"`
55//! - `data`: Contains method, path, headers, query params, path params, body
56//!
57//! # Response Mapping
58//!
59//! Functions return `HttpTriggerResponse` JSON with:
60//! ```json
61//! {
62//!   "status": 201,
63//!   "headers": {"x-custom": "value"},
64//!   "body": {...}
65//! }
66//! ```
67
68use std::collections::HashMap;
69
70use serde::{Deserialize, Serialize};
71
72/// HTTP method for trigger routes.
73#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
74pub struct HttpMethod(pub String);
75
76impl HttpMethod {
77    /// Create a new HTTP method.
78    #[must_use]
79    pub fn new(method: &str) -> Self {
80        Self(method.to_uppercase())
81    }
82
83    /// Check if this method matches another.
84    #[must_use]
85    pub fn matches(&self, other: &str) -> bool {
86        self.0.eq_ignore_ascii_case(other)
87    }
88
89    /// Get the method as a string.
90    #[must_use]
91    pub fn as_str(&self) -> &str {
92        &self.0
93    }
94}
95
96/// Route for an HTTP trigger.
97///
98/// Defines a function that handles requests for a specific HTTP method and path.
99///
100/// # Execution
101///
102/// When a request matches this route, the function is invoked with an `EventPayload`
103/// containing the request data (method, path, headers, body, params, query).
104/// The function returns an HTTP response (status, headers, body).
105#[derive(Debug, Clone)]
106pub struct HttpTriggerRoute {
107    /// Name of the function to invoke.
108    pub function_name: String,
109    /// HTTP method (GET, POST, etc.).
110    pub method:        String,
111    /// Path pattern (e.g., "/users/:id").
112    pub path:          String,
113    /// Whether authentication is required.
114    pub requires_auth: bool,
115}
116
117impl HttpTriggerRoute {
118    /// Create a new HTTP trigger route.
119    #[must_use]
120    pub fn new(function_name: &str, method: &str, path: &str) -> Self {
121        Self {
122            function_name: function_name.to_string(),
123            method:        method.to_string(),
124            path:          path.to_string(),
125            requires_auth: false,
126        }
127    }
128
129    /// Builder method to require authentication.
130    #[must_use = "builder method returns modified builder"]
131    pub const fn with_auth(mut self) -> Self {
132        self.requires_auth = true;
133        self
134    }
135
136    /// Builder method to not require authentication.
137    #[must_use = "builder method returns modified builder"]
138    pub const fn without_auth(mut self) -> Self {
139        self.requires_auth = false;
140        self
141    }
142
143    /// Check if this route matches the given method and path.
144    #[must_use]
145    pub fn matches(&self, method: &str, path: &str) -> bool {
146        self.method.eq_ignore_ascii_case(method) && self.path == path
147    }
148
149    /// Check if this route's path pattern matches a request path.
150    ///
151    /// Simple pattern matching: exact match or `*` for variable segments.
152    #[must_use]
153    pub fn pattern_matches(&self, request_path: &str) -> bool {
154        let route_parts: Vec<&str> = self.path.split('/').collect();
155        let request_parts: Vec<&str> = request_path.split('/').collect();
156
157        if route_parts.len() != request_parts.len() {
158            return false;
159        }
160
161        route_parts.iter().zip(request_parts.iter()).all(|(route_part, request_part)| {
162            // Exact match or parameter (e.g., ":id")
163            route_part == request_part || route_part.starts_with(':')
164        })
165    }
166
167    /// Extract path parameters from a request path.
168    ///
169    /// Returns a map of parameter names to values.
170    #[must_use]
171    pub fn extract_params(&self, request_path: &str) -> HashMap<String, String> {
172        let mut params = HashMap::new();
173
174        let route_parts: Vec<&str> = self.path.split('/').collect();
175        let request_parts: Vec<&str> = request_path.split('/').collect();
176
177        for (route_part, request_part) in route_parts.iter().zip(request_parts.iter()) {
178            if let Some(param_name) = route_part.strip_prefix(':') {
179                params.insert(param_name.to_string(), request_part.to_string());
180            }
181        }
182
183        params
184    }
185}
186
187/// Request payload for HTTP trigger functions.
188///
189/// Passed to function as `EventPayload.data`.
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct HttpTriggerPayload {
192    /// HTTP method (GET, POST, etc.).
193    pub method:  String,
194    /// Request path.
195    pub path:    String,
196    /// Request headers.
197    pub headers: serde_json::Value,
198    /// Query parameters.
199    pub query:   serde_json::Value,
200    /// Path parameters (extracted from route pattern).
201    pub params:  serde_json::Value,
202    /// Request body (if any).
203    pub body:    Option<serde_json::Value>,
204}
205
206impl HttpTriggerPayload {
207    /// Create a new HTTP trigger payload.
208    #[must_use]
209    pub fn new(
210        method: &str,
211        path: &str,
212        headers: serde_json::Value,
213        query: serde_json::Value,
214        body: Option<serde_json::Value>,
215    ) -> Self {
216        Self {
217            method: method.to_string(),
218            path: path.to_string(),
219            headers,
220            query,
221            params: serde_json::json!({}),
222            body,
223        }
224    }
225
226    /// Get a header value by name (case-insensitive).
227    #[must_use]
228    pub fn header(&self, name: &str) -> Option<String> {
229        let name_lower = name.to_lowercase();
230        if let serde_json::Value::Object(ref obj) = self.headers {
231            for (key, value) in obj {
232                if key.to_lowercase() == name_lower {
233                    return value.as_str().map(|s| s.to_string());
234                }
235            }
236        }
237        None
238    }
239
240    /// Get a query parameter value.
241    #[must_use]
242    pub fn query_param(&self, name: &str) -> Option<String> {
243        self.query.get(name).and_then(|v| v.as_str().map(|s| s.to_string()))
244    }
245
246    /// Get a path parameter value.
247    #[must_use]
248    pub fn path_param(&self, name: &str) -> Option<String> {
249        self.params.get(name).and_then(|v| v.as_str().map(|s| s.to_string()))
250    }
251
252    /// Get the request body as JSON.
253    #[must_use]
254    pub const fn json_body(&self) -> Option<&serde_json::Value> {
255        self.body.as_ref()
256    }
257
258    /// Check if this is a GET request.
259    #[must_use]
260    pub fn is_get(&self) -> bool {
261        self.method.eq_ignore_ascii_case("GET")
262    }
263
264    /// Check if this is a POST request.
265    #[must_use]
266    pub fn is_post(&self) -> bool {
267        self.method.eq_ignore_ascii_case("POST")
268    }
269
270    /// Check if this is a PUT request.
271    #[must_use]
272    pub fn is_put(&self) -> bool {
273        self.method.eq_ignore_ascii_case("PUT")
274    }
275
276    /// Check if this is a DELETE request.
277    #[must_use]
278    pub fn is_delete(&self) -> bool {
279        self.method.eq_ignore_ascii_case("DELETE")
280    }
281
282    /// Check if this is a PATCH request.
283    #[must_use]
284    pub fn is_patch(&self) -> bool {
285        self.method.eq_ignore_ascii_case("PATCH")
286    }
287}
288
289/// Response from an HTTP trigger function.
290///
291/// Functions should return this format as JSON.
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct HttpTriggerResponse {
294    /// HTTP status code (default 200).
295    pub status:  u16,
296    /// Response headers.
297    pub headers: serde_json::Value,
298    /// Response body.
299    pub body:    serde_json::Value,
300}
301
302impl HttpTriggerResponse {
303    /// Create a successful response with the given body.
304    #[must_use]
305    pub fn ok(body: serde_json::Value) -> Self {
306        Self {
307            status: 200,
308            headers: serde_json::json!({}),
309            body,
310        }
311    }
312
313    /// Create a response with custom status and body.
314    #[must_use]
315    pub fn with_status(status: u16, body: serde_json::Value) -> Self {
316        Self {
317            status,
318            headers: serde_json::json!({}),
319            body,
320        }
321    }
322
323    /// Create a 201 Created response.
324    #[must_use]
325    pub fn created(body: serde_json::Value) -> Self {
326        Self::with_status(201, body)
327    }
328
329    /// Create a 204 No Content response.
330    #[must_use]
331    pub fn no_content() -> Self {
332        Self {
333            status:  204,
334            headers: serde_json::json!({}),
335            body:    serde_json::json!({}),
336        }
337    }
338
339    /// Create a 400 Bad Request response.
340    #[must_use]
341    pub fn bad_request(message: &str) -> Self {
342        Self::with_status(400, serde_json::json!({"error": message}))
343    }
344
345    /// Create a 401 Unauthorized response.
346    #[must_use]
347    pub fn unauthorized() -> Self {
348        Self::with_status(401, serde_json::json!({"error": "Unauthorized"}))
349    }
350
351    /// Create a 403 Forbidden response.
352    #[must_use]
353    pub fn forbidden() -> Self {
354        Self::with_status(403, serde_json::json!({"error": "Forbidden"}))
355    }
356
357    /// Create a 404 Not Found response.
358    #[must_use]
359    pub fn not_found() -> Self {
360        Self::with_status(404, serde_json::json!({"error": "Not found"}))
361    }
362
363    /// Create a 500 Internal Server Error response.
364    #[must_use]
365    pub fn internal_error(message: &str) -> Self {
366        Self::with_status(500, serde_json::json!({"error": message}))
367    }
368
369    /// Add a header to the response.
370    #[must_use = "builder method returns modified builder"]
371    pub fn with_header(mut self, key: String, value: String) -> Self {
372        if let serde_json::Value::Object(ref mut map) = self.headers {
373            map.insert(key, serde_json::Value::String(value));
374        }
375        self
376    }
377}
378
379/// Matcher for efficiently finding HTTP trigger routes.
380///
381/// Supports path parameter extraction and pattern matching.
382#[derive(Debug, Clone, Default)]
383pub struct HttpTriggerMatcher {
384    /// Routes indexed by (method, path).
385    routes: Vec<HttpTriggerRoute>,
386}
387
388impl HttpTriggerMatcher {
389    /// Create a new empty HTTP trigger matcher.
390    #[must_use]
391    pub const fn new() -> Self {
392        Self { routes: Vec::new() }
393    }
394
395    /// Add a route to the matcher.
396    pub fn add(&mut self, route: HttpTriggerRoute) {
397        self.routes.push(route);
398    }
399
400    /// Find a matching route for the given method and path.
401    #[must_use]
402    pub fn find(&self, method: &str, path: &str) -> Option<HttpTriggerRoute> {
403        self.routes
404            .iter()
405            .find(|route| route.method.eq_ignore_ascii_case(method) && route.pattern_matches(path))
406            .cloned()
407    }
408
409    /// Get all routes.
410    #[must_use]
411    pub fn routes(&self) -> &[HttpTriggerRoute] {
412        &self.routes
413    }
414}
415
416#[cfg(test)]
417mod tests;