Skip to main content

fraiseql_functions/triggers/http/
mod.rs

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