Skip to main content

featherbit/plugins/native/
degraphql.rs

1//! The `degraphql` node — exposes a GraphQL upstream through a plain REST
2//! route: the incoming request is rewritten into a standard GraphQL POST
3//! (`{"query": ..., "variables": ..., "operationName": ...}`) with variables
4//! harvested from the client's query parameters and JSON body.
5//!
6//! Port of APISIX's `degraphql` plugin. Deviations:
7//! - The `query` string is checked only structurally (non-empty, balanced
8//!   braces) at config load — APISIX parses it with a real GraphQL parser and
9//!   can enforce `operation_name` when the document holds several operations.
10//! - APISIX keeps GET requests as GETs, packing `query`/`variables` into the
11//!   URI arguments; featherbit always rewrites to a JSON **POST** body, the
12//!   canonical GraphQL transport, and converts the method accordingly.
13//! - Variables resolve from query parameters first, then from JSON body
14//!   fields (APISIX reads only one source depending on the method).
15//!
16//! Place this node **before** the `upstream` node: it rewrites
17//! `context.request` (method, body, headers) that the upstream forwards.
18
19use async_trait::async_trait;
20use bytes::Bytes;
21use std::collections::HashMap;
22
23use crate::context::{Context, GatewayError};
24use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
25use crate::vars::template::Template;
26
27/// Rewrites the request into a GraphQL POST for the configured `query`.
28///
29/// Only `GET` and `POST` requests are accepted (anything else exits through
30/// the `error` port with a 405 and code `METHOD_NOT_ALLOWED`, mirroring
31/// APISIX). Each configured variable name is looked up in the request's
32/// query parameters (first value, as a string) and then in the JSON request
33/// body (any JSON type); names found in neither are omitted from
34/// `variables`. A non-empty request body that is not valid JSON fails with
35/// a 400 and code `INVALID_REQUEST_BODY` when body lookups are needed.
36///
37/// `query`, `operation_name`, and each `variables` entry support
38/// `{{namespace.path}}` template references, rendered per request.
39pub struct DegraphqlPlugin {
40    query: Template,
41    variables: Vec<Template>,
42    operation_name: Option<Template>,
43}
44
45/// Structural sanity check for a GraphQL document: non-blank, contains a
46/// selection set, and curly braces are balanced. Not a full parser — see the
47/// module docs.
48fn check_query(query: &str) -> Result<(), String> {
49    if query.trim().is_empty() {
50        return Err("degraphql: 'query' must not be blank".to_string());
51    }
52    let mut depth: i64 = 0;
53    for c in query.chars() {
54        match c {
55            '{' => depth += 1,
56            '}' => {
57                depth -= 1;
58                if depth < 0 {
59                    return Err("degraphql: 'query' has unbalanced braces".to_string());
60                }
61            }
62            _ => {}
63        }
64    }
65    if depth != 0 {
66        return Err("degraphql: 'query' has unbalanced braces".to_string());
67    }
68    if !query.contains('{') {
69        return Err("degraphql: 'query' has no selection set".to_string());
70    }
71    Ok(())
72}
73
74impl DegraphqlPlugin {
75    /// Builds the plugin from node config.
76    ///
77    /// Accepted keys:
78    /// - `query` (string, required, 1–1024 chars): the GraphQL document sent
79    ///   upstream. Checked structurally at config load (balanced braces, a
80    ///   selection set present).
81    /// - `variables` (array of strings, optional): variable names to collect
82    ///   from the request. When present it must be non-empty. When absent,
83    ///   no `variables` key is sent upstream.
84    /// - `operation_name` (string, optional, 1–1024 chars): sent as
85    ///   `operationName` for multi-operation documents.
86    ///
87    /// ```yaml
88    /// type: degraphql
89    /// config:
90    ///   query: |
91    ///     query ($name: String!) {
92    ///       persons(filter: { name: $name }) { id name }
93    ///     }
94    ///   variables: [name]
95    /// ```
96    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
97        let query = config
98            .get("query")
99            .and_then(|v| v.as_str())
100            .ok_or("degraphql: 'query' is required")?;
101        if query.len() > 1024 {
102            return Err("degraphql: 'query' must be at most 1024 characters".to_string());
103        }
104        check_query(query)?;
105
106        let variables = match config.get("variables") {
107            None => Vec::new(),
108            Some(raw) => {
109                let items = raw
110                    .as_array()
111                    .ok_or("degraphql: 'variables' must be an array of strings")?;
112                if items.is_empty() {
113                    return Err("degraphql: 'variables' must not be empty when present".to_string());
114                }
115                // Discard warnings here — the compile-time walk (a later
116                // task) reports well-formed-but-unknown references;
117                // execution must not.
118                items
119                    .iter()
120                    .map(|v| {
121                        v.as_str()
122                            .filter(|s| !s.is_empty())
123                            .map(|s| Template::parse(s).0)
124                            .ok_or(
125                                "degraphql: 'variables' items must be non-empty strings"
126                                    .to_string(),
127                            )
128                    })
129                    .collect::<Result<Vec<_>, _>>()?
130            }
131        };
132
133        let operation_name = match config.get("operation_name") {
134            None => None,
135            Some(raw) => {
136                let s = raw
137                    .as_str()
138                    .filter(|s| !s.is_empty() && s.len() <= 1024)
139                    .ok_or("degraphql: 'operation_name' must be a string of 1–1024 characters")?;
140                Some(Template::parse(s).0)
141            }
142        };
143
144        Ok(Self {
145            query: Template::parse(query).0,
146            variables,
147            operation_name,
148        })
149    }
150
151    /// Builds the `error`-port rejection with a JSON response body.
152    fn fail(
153        &self,
154        mut ctx: Context,
155        status: u16,
156        code: &str,
157        error: &str,
158        message: String,
159    ) -> PluginExecutionError {
160        ctx.response.status_code = status;
161        ctx.response.body =
162            Bytes::from(serde_json::json!({ "error": error, "message": message }).to_string());
163        ctx.response.headers.insert(
164            "content-type".to_string(),
165            vec!["application/json".to_string()],
166        );
167        PluginExecutionError {
168            context: ctx,
169            error: GatewayError {
170                node_id: String::new(),
171                code: code.to_string(),
172                message,
173                metadata: HashMap::new(),
174            },
175        }
176    }
177}
178
179#[async_trait]
180impl Plugin for DegraphqlPlugin {
181    fn plugin_type(&self) -> &str {
182        "degraphql"
183    }
184
185    async fn execute(&self, mut ctx: Context) -> PluginResult {
186        if ctx.request.method != "GET" && ctx.request.method != "POST" {
187            let method = ctx.request.method.clone();
188            return Err(self.fail(
189                ctx,
190                405,
191                "METHOD_NOT_ALLOWED",
192                "method_not_allowed",
193                format!("degraphql accepts GET and POST, got {}", method),
194            ));
195        }
196
197        let mut new_body = serde_json::Map::new();
198        new_body.insert(
199            "query".to_string(),
200            serde_json::Value::String(self.query.render(&ctx).into_owned()),
201        );
202        if let Some(op) = &self.operation_name {
203            new_body.insert(
204                "operationName".to_string(),
205                serde_json::Value::String(op.render(&ctx).into_owned()),
206            );
207        }
208
209        if !self.variables.is_empty() {
210            // Query parameters win; JSON body fields fill the rest. The body
211            // is parsed lazily — only when a variable is not in the args.
212            let mut json_body: Option<serde_json::Value> = None;
213            let mut vars = serde_json::Map::new();
214
215            for name_tpl in &self.variables {
216                let name = name_tpl.render(&ctx).into_owned();
217                if let Some(v) = ctx.request.query_params.get(&name).and_then(|v| v.first()) {
218                    vars.insert(name.clone(), serde_json::Value::String(v.clone()));
219                    continue;
220                }
221                if !ctx.request.body.is_empty() {
222                    if json_body.is_none() {
223                        match serde_json::from_slice::<serde_json::Value>(&ctx.request.body) {
224                            Ok(parsed) => json_body = Some(parsed),
225                            Err(e) => {
226                                return Err(self.fail(
227                                    ctx,
228                                    400,
229                                    "INVALID_REQUEST_BODY",
230                                    "invalid_request_body",
231                                    format!("request body can't be decoded as JSON: {}", e),
232                                ));
233                            }
234                        }
235                    }
236                    if let Some(v) = json_body.as_ref().and_then(|b| b.get(&name)) {
237                        vars.insert(name.clone(), v.clone());
238                    }
239                }
240                // found nowhere -> omitted, like APISIX
241            }
242
243            new_body.insert("variables".to_string(), serde_json::Value::Object(vars));
244        }
245
246        // Rewrite into the canonical GraphQL POST. Body-mutation convention:
247        // the new body is plain JSON, so stale framing headers must go.
248        ctx.request.method = "POST".to_string();
249        ctx.request.body = Bytes::from(
250            serde_json::to_vec(&serde_json::Value::Object(new_body)).unwrap_or_default(),
251        );
252        ctx.request.headers.insert(
253            "content-type".to_string(),
254            vec!["application/json".to_string()],
255        );
256        ctx.request.headers.remove("content-length");
257        ctx.request.headers.remove("content-encoding");
258
259        Ok(PluginOutput::success(ctx))
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
267
268    const QUERY: &str = "query ($name: String!) { persons(filter: { name: $name }) { id name } }";
269
270    fn test_context(method: &str, body: &str) -> Context {
271        let mut headers = HashMap::new();
272        headers.insert("content-type".to_string(), vec!["text/plain".to_string()]);
273        headers.insert("content-length".to_string(), vec![body.len().to_string()]);
274
275        Context {
276            request: GatewayRequest {
277                method: method.to_string(),
278                path: "/persons".to_string(),
279                host: "localhost".to_string(),
280                scheme: "http".to_string(),
281                headers,
282                query_params: HashMap::new(),
283                body: Bytes::from(body.to_string()),
284                remote_addr: "127.0.0.1:12345".to_string(),
285                protocol: Protocol::Http1,
286            },
287            response: GatewayResponse {
288                status_code: 0,
289                headers: HashMap::new(),
290                body: Bytes::new(),
291                stream: None,
292            },
293            message: HashMap::new(),
294            errors: Vec::new(),
295        }
296    }
297
298    fn plugin(vars: Option<serde_json::Value>) -> DegraphqlPlugin {
299        let mut config = HashMap::new();
300        config.insert("query".to_string(), serde_json::json!(QUERY));
301        if let Some(v) = vars {
302            config.insert("variables".to_string(), v);
303        }
304        DegraphqlPlugin::from_config(&config).unwrap()
305    }
306
307    #[tokio::test]
308    async fn test_degraphql_body_from_query_params() {
309        let p = plugin(Some(serde_json::json!(["name"])));
310        let mut ctx = test_context("GET", "");
311        ctx.request
312            .query_params
313            .insert("name".to_string(), vec!["jack".to_string()]);
314
315        let out = p.execute(ctx).await.unwrap();
316        let req = &out.context.request;
317        assert_eq!(req.method, "POST");
318        assert_eq!(
319            req.headers.get("content-type"),
320            Some(&vec!["application/json".to_string()])
321        );
322        assert!(!req.headers.contains_key("content-length"));
323        let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
324        assert_eq!(body["query"], QUERY);
325        assert_eq!(body["variables"], serde_json::json!({ "name": "jack" }));
326        assert!(body.get("operationName").is_none());
327    }
328
329    #[tokio::test]
330    async fn test_degraphql_body_from_json_body_preserves_types() {
331        let p = plugin(Some(serde_json::json!(["name", "limit"])));
332        let ctx = test_context("POST", r#"{"name":"jill","limit":10,"noise":true}"#);
333        let out = p.execute(ctx).await.unwrap();
334        let body: serde_json::Value = serde_json::from_slice(&out.context.request.body).unwrap();
335        // JSON body values keep their JSON types
336        assert_eq!(
337            body["variables"],
338            serde_json::json!({ "name": "jill", "limit": 10 })
339        );
340    }
341
342    #[tokio::test]
343    async fn test_degraphql_query_params_win_over_body() {
344        let p = plugin(Some(serde_json::json!(["name"])));
345        let mut ctx = test_context("POST", r#"{"name":"from-body"}"#);
346        ctx.request
347            .query_params
348            .insert("name".to_string(), vec!["from-args".to_string()]);
349        let out = p.execute(ctx).await.unwrap();
350        let body: serde_json::Value = serde_json::from_slice(&out.context.request.body).unwrap();
351        assert_eq!(body["variables"]["name"], "from-args");
352    }
353
354    /// TDD (Task 3): `operation_name` and each `variables` entry render
355    /// `{{...}}` template references per request.
356    #[tokio::test]
357    async fn test_degraphql_operation_name_and_variable_name_render_template() {
358        let mut config = HashMap::new();
359        config.insert("query".to_string(), serde_json::json!(QUERY));
360        config.insert(
361            "operation_name".to_string(),
362            serde_json::json!("Get{{request.headers.x-op-suffix}}"),
363        );
364        config.insert(
365            "variables".to_string(),
366            serde_json::json!(["{{request.headers.x-var-name}}"]),
367        );
368        let p = DegraphqlPlugin::from_config(&config).unwrap();
369
370        let mut ctx = test_context("GET", "");
371        ctx.request
372            .headers
373            .insert("x-op-suffix".to_string(), vec!["Person".to_string()]);
374        ctx.request
375            .headers
376            .insert("x-var-name".to_string(), vec!["name".to_string()]);
377        ctx.request
378            .query_params
379            .insert("name".to_string(), vec!["jack".to_string()]);
380
381        let out = p.execute(ctx).await.unwrap();
382        let body: serde_json::Value = serde_json::from_slice(&out.context.request.body).unwrap();
383        assert_eq!(body["operationName"], "GetPerson");
384        assert_eq!(body["variables"], serde_json::json!({ "name": "jack" }));
385    }
386
387    #[tokio::test]
388    async fn test_degraphql_missing_variable_omitted() {
389        let p = plugin(Some(serde_json::json!(["name", "ghost"])));
390        let ctx = test_context("POST", r#"{"name":"jack"}"#);
391        let out = p.execute(ctx).await.unwrap();
392        let body: serde_json::Value = serde_json::from_slice(&out.context.request.body).unwrap();
393        assert_eq!(body["variables"], serde_json::json!({ "name": "jack" }));
394    }
395
396    #[tokio::test]
397    async fn test_degraphql_no_variables_config() {
398        let mut config = HashMap::new();
399        config.insert("query".to_string(), serde_json::json!("{ persons { id } }"));
400        config.insert("operation_name".to_string(), serde_json::json!("List"));
401        let p = DegraphqlPlugin::from_config(&config).unwrap();
402
403        let out = p.execute(test_context("GET", "")).await.unwrap();
404        let body: serde_json::Value = serde_json::from_slice(&out.context.request.body).unwrap();
405        assert_eq!(body["query"], "{ persons { id } }");
406        assert_eq!(body["operationName"], "List");
407        assert!(body.get("variables").is_none());
408    }
409
410    #[tokio::test]
411    async fn test_degraphql_rejects_other_methods() {
412        let p = plugin(None);
413        let err = p.execute(test_context("DELETE", "")).await.unwrap_err();
414        assert_eq!(err.error.code, "METHOD_NOT_ALLOWED");
415        assert_eq!(err.context.response.status_code, 405);
416    }
417
418    #[tokio::test]
419    async fn test_degraphql_invalid_body_when_variable_needed() {
420        let p = plugin(Some(serde_json::json!(["name"])));
421        let err = p
422            .execute(test_context("POST", "not json"))
423            .await
424            .unwrap_err();
425        assert_eq!(err.error.code, "INVALID_REQUEST_BODY");
426        assert_eq!(err.context.response.status_code, 400);
427    }
428
429    #[test]
430    fn test_degraphql_config_rejections() {
431        let bad = [
432            serde_json::json!({}),                                         // query required
433            serde_json::json!({ "query": "" }),                            // blank
434            serde_json::json!({ "query": "no selection set" }),            // no braces
435            serde_json::json!({ "query": "{ unbalanced" }),                // unbalanced
436            serde_json::json!({ "query": "} backwards {" }),               // closes first
437            serde_json::json!({ "query": "{ x }", "variables": [] }),      // empty variables
438            serde_json::json!({ "query": "{ x }", "variables": [1] }),     // non-string variable
439            serde_json::json!({ "query": "{ x }", "operation_name": "" }), // blank op name
440            serde_json::json!({ "query": format!("{{ {} }}", "a".repeat(2000)) }), // too long
441        ];
442        for case in bad {
443            let config: HashMap<String, serde_json::Value> =
444                serde_json::from_value(case.clone()).unwrap();
445            assert!(
446                DegraphqlPlugin::from_config(&config).is_err(),
447                "should reject: {case}"
448            );
449        }
450    }
451}