Skip to main content

ecat_graphql/
lib.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2use axum::Router;
3use axum::http::StatusCode;
4use axum::response::{IntoResponse, Response};
5use axum::routing::post;
6use serde::Deserialize;
7use serde_json::{Map, Value};
8use std::collections::HashMap;
9use std::sync::Arc;
10
11mod parser;
12mod validation;
13
14pub use parser::{FieldNode, Operation, SelectionSet};
15pub use validation::QueryLimits;
16
17type Resolver = Arc<
18    dyn Fn(
19            serde_json::Value,
20        ) -> std::pin::Pin<
21            Box<dyn std::future::Future<Output = Result<serde_json::Value, String>> + Send>,
22        > + Send
23        + Sync,
24>;
25
26/// 富 resolver 请求上下文:字段参数、原始 variables 与嵌套 selection 树。
27#[derive(Debug, Clone)]
28pub struct FieldRequest {
29    pub args: Map<String, Value>,
30    pub variables: Value,
31    pub selection: Option<SelectionSet>,
32}
33
34/// 富 resolver trait:可访问字段参数与嵌套 selection(经
35/// [`GraphQLSchema::query_field`] / [`GraphQLSchema::mutation_field`] 注册)。
36/// async fn in trait 非 dyn-compatible,故用 #[async_trait] 换取对象安全
37/// (可存入 `FieldHandler::Rich` 的 `Arc<dyn GraphQLField>`)。
38#[async_trait::async_trait]
39pub trait GraphQLField: Send + Sync {
40    async fn resolve(&self, req: FieldRequest) -> Result<Value, String>;
41}
42
43enum FieldHandler {
44    Legacy(Resolver),
45    Rich(Arc<dyn GraphQLField>),
46}
47
48pub struct GraphQLSchema {
49    query_resolvers: HashMap<String, FieldHandler>,
50    mutation_resolvers: HashMap<String, FieldHandler>,
51    limits: QueryLimits,
52}
53
54impl GraphQLSchema {
55    pub fn new() -> Self {
56        Self {
57            query_resolvers: HashMap::new(),
58            mutation_resolvers: HashMap::new(),
59            limits: QueryLimits::default(),
60        }
61    }
62
63    /// 配置执行前查询预算(防查询放大 DoS),默认已启用。
64    pub fn with_limits(mut self, limits: QueryLimits) -> Self {
65        self.limits = limits;
66        self
67    }
68
69    pub fn query(mut self, name: impl Into<String>, r: Resolver) -> Self {
70        self.query_resolvers
71            .insert(name.into(), FieldHandler::Legacy(r));
72        self
73    }
74
75    pub fn mutation(mut self, name: impl Into<String>, r: Resolver) -> Self {
76        self.mutation_resolvers
77            .insert(name.into(), FieldHandler::Legacy(r));
78        self
79    }
80
81    pub fn query_fn(
82        self,
83        name: impl Into<String>,
84        f: impl Fn(
85            serde_json::Value,
86        ) -> std::pin::Pin<
87            Box<dyn std::future::Future<Output = Result<serde_json::Value, String>> + Send>,
88        > + Send
89        + Sync
90        + 'static,
91    ) -> Self {
92        self.query(name, Arc::new(f))
93    }
94
95    /// 注册富 query resolver:接收 `FieldRequest`(参数 + 嵌套 selection)。
96    pub fn query_field(mut self, name: impl Into<String>, f: impl GraphQLField + 'static) -> Self {
97        self.query_resolvers
98            .insert(name.into(), FieldHandler::Rich(Arc::new(f)));
99        self
100    }
101
102    /// 注册富 mutation resolver:接收 `FieldRequest`(参数 + 嵌套 selection)。
103    pub fn mutation_field(
104        mut self,
105        name: impl Into<String>,
106        f: impl GraphQLField + 'static,
107    ) -> Self {
108        self.mutation_resolvers
109            .insert(name.into(), FieldHandler::Rich(Arc::new(f)));
110        self
111    }
112}
113
114impl Default for GraphQLSchema {
115    fn default() -> Self {
116        Self::new()
117    }
118}
119
120#[derive(Debug, Deserialize)]
121struct GqlReq {
122    query: String,
123    #[serde(default)]
124    variables: serde_json::Value,
125}
126
127pub fn graphql_router(schema: GraphQLSchema) -> Router {
128    let schema = Arc::new(schema);
129
130    async fn handler(axum::Json(req): axum::Json<GqlReq>, schema: Arc<GraphQLSchema>) -> Response {
131        match execute(&schema, &req.query, &req.variables).await {
132            Ok(data) => axum::Json(serde_json::json!({"data": data})).into_response(),
133            Err(errors) => (
134                StatusCode::BAD_REQUEST,
135                axum::Json(serde_json::json!({"errors": errors})),
136            )
137                .into_response(),
138        }
139    }
140
141    let s = Arc::clone(&schema);
142    Router::new().route("/graphql", post(move |body| handler(body, Arc::clone(&s))))
143}
144
145async fn execute(
146    schema: &GraphQLSchema,
147    query: &str,
148    variables: &serde_json::Value,
149) -> Result<serde_json::Value, Vec<String>> {
150    let trimmed = query.trim();
151    let mut errors = Vec::new();
152
153    let field = match parser::parse_query(trimmed, variables) {
154        Ok(f) => f,
155        Err(e) => {
156            errors.push(e);
157            return Err(errors);
158        }
159    };
160
161    if let Err(e) = validation::validate(&field, &schema.limits) {
162        errors.push(e);
163        return Err(errors);
164    }
165
166    let (resolvers, field_name) = if field.operation == Operation::Mutation {
167        (&schema.mutation_resolvers, &field.name)
168    } else {
169        (&schema.query_resolvers, &field.name)
170    };
171
172    match resolvers.get(field_name) {
173        Some(FieldHandler::Legacy(resolver)) => {
174            let vars = match merge_args(variables, &field.args) {
175                Ok(v) => v,
176                Err(e) => {
177                    errors.push(e);
178                    return Err(errors);
179                }
180            };
181            match resolver(vars).await {
182                Ok(data) => {
183                    let mut result = serde_json::Map::new();
184                    result.insert(field.name.clone(), data);
185                    return Ok(serde_json::Value::Object(result));
186                }
187                Err(e) => errors.push(e),
188            }
189        }
190        Some(FieldHandler::Rich(r)) => {
191            let req = FieldRequest {
192                args: field.args,
193                variables: variables.clone(),
194                selection: field.selection,
195            };
196            match r.resolve(req).await {
197                Ok(data) => {
198                    let mut result = serde_json::Map::new();
199                    result.insert(field.name.clone(), data);
200                    return Ok(serde_json::Value::Object(result));
201                }
202                Err(e) => errors.push(e),
203            }
204        }
205        None => errors.push(format!("unknown field: {field_name}")),
206    }
207
208    Err(errors)
209}
210
211/// Legacy resolver 的参数合并:字段参数并入 variables(同名时参数胜出)。
212/// 无参数时与旧行为逐字节一致(直接克隆 variables)。
213fn merge_args(variables: &Value, args: &Map<String, Value>) -> Result<Value, String> {
214    if args.is_empty() {
215        return Ok(variables.clone());
216    }
217    match variables {
218        Value::Object(m) => {
219            let mut merged = m.clone();
220            for (k, v) in args {
221                merged.insert(k.clone(), v.clone());
222            }
223            Ok(Value::Object(merged))
224        }
225        Value::Null => Ok(Value::Object(args.clone())),
226        _ => Err("variables must be a JSON object when field arguments are present".into()),
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use axum::body::Body;
234    use axum::http::Request;
235    use tower::ServiceExt;
236
237    struct EchoField;
238
239    #[async_trait::async_trait]
240    impl GraphQLField for EchoField {
241        async fn resolve(&self, req: FieldRequest) -> Result<Value, String> {
242            Ok(serde_json::json!({
243                "args": req.args,
244                "variables": req.variables,
245                "has_selection": req.selection.is_some(),
246            }))
247        }
248    }
249
250    #[test]
251    fn legacy_resolver_receives_merged_args() {
252        let schema =
253            GraphQLSchema::new().query_fn("echo", |vars| Box::pin(async move { Ok(vars) }));
254        let rt = tokio::runtime::Runtime::new().unwrap();
255        let vars = serde_json::json!({"id": 1});
256        let data = rt
257            .block_on(execute(&schema, "{ echo(id: 2, name: \"x\") }", &vars))
258            .unwrap();
259        assert_eq!(data["echo"], serde_json::json!({"id": 2, "name": "x"}));
260    }
261
262    #[test]
263    fn legacy_resolver_without_args_is_unchanged() {
264        let schema =
265            GraphQLSchema::new().query_fn("echo", |vars| Box::pin(async move { Ok(vars) }));
266        let rt = tokio::runtime::Runtime::new().unwrap();
267        let vars = serde_json::json!({"a": 1});
268        let data = rt.block_on(execute(&schema, "{ echo }", &vars)).unwrap();
269        assert_eq!(data["echo"], serde_json::json!({"a": 1}));
270    }
271
272    #[test]
273    fn legacy_resolver_args_override_same_named_variables() {
274        let schema =
275            GraphQLSchema::new().query_fn("echo", |vars| Box::pin(async move { Ok(vars) }));
276        let rt = tokio::runtime::Runtime::new().unwrap();
277        let vars = serde_json::json!({"id": 1});
278        let data = rt
279            .block_on(execute(&schema, "{ echo(id: 9) }", &vars))
280            .unwrap();
281        assert_eq!(data["echo"]["id"], 9);
282    }
283
284    #[test]
285    fn legacy_resolver_args_with_null_variables() {
286        let schema =
287            GraphQLSchema::new().query_fn("echo", |vars| Box::pin(async move { Ok(vars) }));
288        let rt = tokio::runtime::Runtime::new().unwrap();
289        let data = rt
290            .block_on(execute(&schema, "{ echo(id: 1) }", &Value::Null))
291            .unwrap();
292        assert_eq!(data["echo"], serde_json::json!({"id": 1}));
293    }
294
295    #[test]
296    fn legacy_resolver_errors_on_non_object_variables_with_args() {
297        let schema =
298            GraphQLSchema::new().query_fn("echo", |vars| Box::pin(async move { Ok(vars) }));
299        let rt = tokio::runtime::Runtime::new().unwrap();
300        let err = rt
301            .block_on(execute(&schema, "{ echo(id: 1) }", &serde_json::json!(5)))
302            .unwrap_err();
303        assert!(
304            err.iter()
305                .any(|e| e.contains("variables must be a JSON object")),
306            "got: {err:?}"
307        );
308    }
309
310    #[test]
311    fn rich_resolver_receives_full_request() {
312        let schema = GraphQLSchema::new().query_field("user", EchoField);
313        let rt = tokio::runtime::Runtime::new().unwrap();
314        let vars = serde_json::json!({"env": "prod"});
315        let data = rt
316            .block_on(execute(&schema, "{ user(id: 7) { name } }", &vars))
317            .unwrap();
318        // Rich resolver 收到原样 variables 与解析后的 args,二者不合并
319        assert_eq!(data["user"]["args"]["id"], 7);
320        assert_eq!(
321            data["user"]["variables"],
322            serde_json::json!({"env": "prod"})
323        );
324        assert_eq!(data["user"]["has_selection"], true);
325    }
326
327    #[test]
328    fn rich_resolver_without_selection() {
329        let schema = GraphQLSchema::new().query_field("ping", EchoField);
330        let rt = tokio::runtime::Runtime::new().unwrap();
331        let data = rt
332            .block_on(execute(&schema, "{ ping }", &Value::Null))
333            .unwrap();
334        assert_eq!(data["ping"]["has_selection"], false);
335        assert!(data["ping"]["args"].as_object().unwrap().is_empty());
336    }
337
338    #[test]
339    fn unknown_field_and_resolver_error_go_to_errors() {
340        let schema = GraphQLSchema::new().query_field("boom", ResolveErr);
341        let rt = tokio::runtime::Runtime::new().unwrap();
342        let err = rt
343            .block_on(execute(&schema, "{ nope }", &Value::Null))
344            .unwrap_err();
345        assert!(err.iter().any(|e| e.contains("unknown field: nope")));
346
347        let err = rt
348            .block_on(execute(&schema, "{ boom }", &Value::Null))
349            .unwrap_err();
350        assert!(err.iter().any(|e| e == "resolver exploded"));
351    }
352
353    struct ResolveErr;
354
355    #[async_trait::async_trait]
356    impl GraphQLField for ResolveErr {
357        async fn resolve(&self, _req: FieldRequest) -> Result<Value, String> {
358            Err("resolver exploded".into())
359        }
360    }
361
362    #[test]
363    fn mutation_dispatches_to_mutation_resolvers() {
364        let schema = GraphQLSchema::new().query_fn("write", |_v| {
365            Box::pin(async { Ok(serde_json::json!("query")) })
366        });
367        let schema = schema.mutation_field("write", MutWrite);
368        let rt = tokio::runtime::Runtime::new().unwrap();
369        let data = rt
370            .block_on(execute(&schema, "mutation { write(id: 3) }", &Value::Null))
371            .unwrap();
372        assert_eq!(data["write"]["id"], 3);
373    }
374
375    struct MutWrite;
376
377    #[async_trait::async_trait]
378    impl GraphQLField for MutWrite {
379        async fn resolve(&self, req: FieldRequest) -> Result<Value, String> {
380            Ok(serde_json::json!({"id": req.args["id"]}))
381        }
382    }
383
384    #[test]
385    fn subscription_dispatches_to_query_resolvers() {
386        let schema = GraphQLSchema::new().query_fn("sub", |_v| {
387            Box::pin(async { Ok(serde_json::json!("query")) })
388        });
389        let schema = schema.mutation_field("sub", MutWrite);
390        let rt = tokio::runtime::Runtime::new().unwrap();
391        let data = rt
392            .block_on(execute(&schema, "subscription { sub }", &Value::Null))
393            .unwrap();
394        assert_eq!(data["sub"], "query");
395    }
396
397    fn router() -> Router {
398        graphql_router(
399            GraphQLSchema::new()
400                .query_fn("hello", |_v| {
401                    Box::pin(async { Ok(serde_json::json!("world")) })
402                })
403                .query_field("user", EchoField),
404        )
405    }
406
407    #[tokio::test]
408    async fn router_serves_simple_query() {
409        let res = router()
410            .oneshot(
411                Request::builder()
412                    .method("POST")
413                    .uri("/graphql")
414                    .header("content-type", "application/json")
415                    .body(Body::from(r#"{"query":"{ hello }"}"#))
416                    .unwrap(),
417            )
418            .await
419            .unwrap();
420        assert_eq!(res.status(), StatusCode::OK);
421        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
422            .await
423            .unwrap();
424        let v: Value = serde_json::from_slice(&body).unwrap();
425        assert_eq!(v["data"]["hello"], "world");
426    }
427
428    #[tokio::test]
429    async fn router_serves_query_with_args_and_nested_selection() {
430        let res = router()
431            .oneshot(
432                Request::builder()
433                    .method("POST")
434                    .uri("/graphql")
435                    .header("content-type", "application/json")
436                    .body(Body::from(
437                        r#"{"query":"{ user(id: 7, env: $e) { name } }","variables":{"e":"prod"}}"#,
438                    ))
439                    .unwrap(),
440            )
441            .await
442            .unwrap();
443        assert_eq!(res.status(), StatusCode::OK);
444        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
445            .await
446            .unwrap();
447        let v: Value = serde_json::from_slice(&body).unwrap();
448        assert_eq!(v["data"]["user"]["args"]["id"], 7);
449        assert_eq!(v["data"]["user"]["args"]["env"], "prod");
450        assert_eq!(
451            v["data"]["user"]["variables"],
452            serde_json::json!({"e": "prod"})
453        );
454        assert_eq!(v["data"]["user"]["has_selection"], true);
455    }
456
457    #[tokio::test]
458    async fn router_returns_400_with_errors() {
459        let res = router()
460            .oneshot(
461                Request::builder()
462                    .method("POST")
463                    .uri("/graphql")
464                    .header("content-type", "application/json")
465                    .body(Body::from(r#"{"query":"{ nope }"}"#))
466                    .unwrap(),
467            )
468            .await
469            .unwrap();
470        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
471        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
472            .await
473            .unwrap();
474        let v: Value = serde_json::from_slice(&body).unwrap();
475        assert!(v["errors"][0].as_str().unwrap().contains("unknown field"));
476    }
477
478    #[tokio::test]
479    async fn router_returns_400_on_parse_error() {
480        let res = router()
481            .oneshot(
482                Request::builder()
483                    .method("POST")
484                    .uri("/graphql")
485                    .header("content-type", "application/json")
486                    .body(Body::from(r#"{"query":"{ a b }"}"#))
487                    .unwrap(),
488            )
489            .await
490            .unwrap();
491        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
492        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
493            .await
494            .unwrap();
495        let v: Value = serde_json::from_slice(&body).unwrap();
496        assert!(
497            v["errors"][0]
498                .as_str()
499                .unwrap()
500                .contains("multiple top-level fields")
501        );
502    }
503
504    #[test]
505    fn schema_field_name_conflict_latest_wins() {
506        let schema = GraphQLSchema::new()
507            .query_fn("f", |_v| {
508                Box::pin(async { Ok(serde_json::json!("legacy")) })
509            })
510            .query_field("f", EchoField);
511        let rt = tokio::runtime::Runtime::new().unwrap();
512        let data = rt
513            .block_on(execute(&schema, "{ f(a: 1) }", &Value::Null))
514            .unwrap();
515        assert_eq!(data["f"]["args"]["a"], 1);
516    }
517}