featherbit/plugins/native/
degraphql.rs1use 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
27pub struct DegraphqlPlugin {
40 query: Template,
41 variables: Vec<Template>,
42 operation_name: Option<Template>,
43}
44
45fn 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 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 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 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 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 }
242
243 new_body.insert("variables".to_string(), serde_json::Value::Object(vars));
244 }
245
246 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 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 #[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!({}), serde_json::json!({ "query": "" }), serde_json::json!({ "query": "no selection set" }), serde_json::json!({ "query": "{ unbalanced" }), serde_json::json!({ "query": "} backwards {" }), serde_json::json!({ "query": "{ x }", "variables": [] }), serde_json::json!({ "query": "{ x }", "variables": [1] }), serde_json::json!({ "query": "{ x }", "operation_name": "" }), serde_json::json!({ "query": format!("{{ {} }}", "a".repeat(2000)) }), ];
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}