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