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};
25
26pub struct DegraphqlPlugin {
36 query: String,
37 variables: Vec<String>,
38 operation_name: Option<String>,
39}
40
41fn 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 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 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 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 }
238
239 new_body.insert("variables".to_string(), serde_json::Value::Object(vars));
240 }
241
242 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 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!({}), 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)) }), ];
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}