Skip to main content

sz_orm_graphql/
lib.rs

1//! # SZ-ORM GraphQL — GraphQL Schema 解析与查询
2//!
3//! 提供 GraphQL Schema 定义、类型/字段/查询/变更构建与查询执行,
4//! 启用 `real` feature 后接入真实 GraphQL 引擎。
5//!
6//! ## 主要类型
7//!
8//! - [`GraphQLSchema`] — Schema 容器
9//! - [`GraphQLType`] / [`GraphQLField`] — 类型与字段定义
10//! - [`resolver::DbResolver`] — 真实 DB resolver trait(P2-1 修复 C-3:可注入真实数据源)
11//!
12//! ## P2-1 修复 C-3:DB resolver 注入
13//!
14//! `GraphQLServer::with_db_resolver` 允许调用方注入真实 DB resolver,
15//! 启用后 GraphQL root field 通过 resolver 异步查询真实数据库,
16//! 未注入时回退到 mock 数据(向后兼容)。
17
18pub mod extensions;
19pub mod resolver;
20
21use serde::{Deserialize, Serialize};
22
23#[cfg(feature = "real")]
24mod real_graphql;
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct GraphQLSchema {
28    pub types: Vec<GraphQLType>,
29    pub queries: Vec<GraphQLField>,
30    pub mutations: Vec<GraphQLField>,
31}
32
33impl GraphQLSchema {
34    pub fn new() -> Self {
35        Self {
36            types: vec![],
37            queries: vec![],
38            mutations: vec![],
39        }
40    }
41
42    pub fn add_type(mut self, t: GraphQLType) -> Self {
43        self.types.push(t);
44        self
45    }
46
47    pub fn add_query(mut self, f: GraphQLField) -> Self {
48        self.queries.push(f);
49        self
50    }
51
52    pub fn add_mutation(mut self, f: GraphQLField) -> Self {
53        self.mutations.push(f);
54        self
55    }
56
57    /// Render the schema to a GraphQL SDL string.
58    pub fn to_sdl(&self) -> String {
59        let mut out = String::new();
60        for t in &self.types {
61            out.push_str(&format!("type {} {{\n", t.name));
62            for f in &t.fields {
63                out.push_str(&format!("    {}: {}\n", f.name, f.type_name));
64            }
65            out.push_str("}\n\n");
66        }
67        out.push_str("type Query {\n");
68        for q in &self.queries {
69            out.push_str(&format!("    {}: {}\n", q.name, q.type_name));
70        }
71        out.push_str("}\n");
72        if !self.mutations.is_empty() {
73            out.push_str("\ntype Mutation {\n");
74            for m in &self.mutations {
75                out.push_str(&format!("    {}: {}\n", m.name, m.type_name));
76            }
77            out.push_str("}\n");
78        }
79        out
80    }
81}
82
83impl Default for GraphQLSchema {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct GraphQLType {
91    pub name: String,
92    pub fields: Vec<GraphQLField>,
93}
94
95impl GraphQLType {
96    pub fn new(name: &str) -> Self {
97        Self {
98            name: name.to_string(),
99            fields: vec![],
100        }
101    }
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct GraphQLField {
106    pub name: String,
107    pub type_name: String,
108}
109
110/// Convert a model name like "users" or "order_items" to a PascalCase singular
111/// GraphQL type name like "User" / "OrderItem".
112fn to_pascal_singular(input: &str) -> String {
113    let mut out = String::new();
114    let mut cap_next = true;
115    for ch in input.chars() {
116        if ch == '_' || ch == '-' || ch == ' ' {
117            cap_next = true;
118        } else if cap_next {
119            out.extend(ch.to_uppercase());
120            cap_next = false;
121        } else {
122            out.push(ch);
123        }
124    }
125    let len = out.len();
126    if len > 1 && out.ends_with('s') && !out.ends_with("ss") {
127        out.truncate(len - 1);
128    }
129    out
130}
131
132pub struct GraphQLSchemaGenerator;
133
134impl GraphQLSchemaGenerator {
135    /// Generate a real GraphQL schema from model names: for each model, emit a
136    /// `type` definition with id/name/createdAt/updatedAt fields plus a
137    /// `getX` query and a `listXs` query.
138    pub fn generate_schema(models: &[&str]) -> GraphQLSchema {
139        let mut schema = GraphQLSchema::new();
140        for m in models {
141            let type_name = to_pascal_singular(m);
142            let mut t = GraphQLType::new(&type_name);
143            t.fields.push(GraphQLField {
144                name: "id".to_string(),
145                type_name: "ID!".to_string(),
146            });
147            t.fields.push(GraphQLField {
148                name: "name".to_string(),
149                type_name: "String!".to_string(),
150            });
151            t.fields.push(GraphQLField {
152                name: "createdAt".to_string(),
153                type_name: "String!".to_string(),
154            });
155            t.fields.push(GraphQLField {
156                name: "updatedAt".to_string(),
157                type_name: "String!".to_string(),
158            });
159            schema = schema.add_type(t);
160            schema = schema.add_query(GraphQLField {
161                name: format!("get{}", type_name),
162                type_name: type_name.clone(),
163            });
164            schema = schema.add_query(GraphQLField {
165                name: format!("list{}s", type_name),
166                type_name: format!("[{}!]!", type_name),
167            });
168        }
169        schema
170    }
171}
172
173pub struct GraphQLServer {
174    port: u16,
175    schema: Option<GraphQLSchema>,
176    #[cfg(feature = "real")]
177    dynamic_schema: std::sync::OnceLock<Result<async_graphql::dynamic::Schema, String>>,
178    #[cfg(feature = "real")]
179    resolver: Option<resolver::SharedDbResolver>,
180}
181
182impl GraphQLServer {
183    pub fn new(port: u16) -> Self {
184        Self {
185            port,
186            schema: None,
187            #[cfg(feature = "real")]
188            dynamic_schema: std::sync::OnceLock::new(),
189            #[cfg(feature = "real")]
190            resolver: None,
191        }
192    }
193
194    pub fn with_schema(mut self, s: GraphQLSchema) -> Self {
195        self.schema = Some(s);
196        self
197    }
198
199    /// 注入真实 DB resolver — P2-1 修复 C-3
200    ///
201    /// 启用后,GraphQL root field 将通过 resolver 查询真实数据库,
202    /// 而非返回 mock 数据。
203    #[cfg(feature = "real")]
204    pub fn with_db_resolver(mut self, resolver: resolver::SharedDbResolver) -> Self {
205        self.resolver = Some(resolver);
206        self
207    }
208
209    /// Start a background tokio task that binds a TCP listener on the port.
210    /// Returns the URL the server is listening on.
211    /// Must be called from within a tokio runtime context.
212    #[cfg(not(feature = "real"))]
213    pub fn start(&self) -> Result<String, String> {
214        if self.schema.is_none() {
215            return Err("No schema".to_string());
216        }
217        let port = self.port;
218        let url = format!("http://localhost:{}", port);
219        // Spawn a background task that occupies the port with a TCP listener.
220        tokio::spawn(async move {
221            let addr = format!("127.0.0.1:{}", port);
222            match tokio::net::TcpListener::bind(&addr).await {
223                Ok(listener) => {
224                    while listener.accept().await.is_ok() {
225                        // Accept and drop; this is a placeholder server.
226                    }
227                }
228                Err(_) => {
229                    // Port may already be in use; the spawn task just exits.
230                }
231            }
232        });
233        Ok(url)
234    }
235
236    /// Lazily build (and cache) the executable async-graphql schema.
237    #[cfg(feature = "real")]
238    fn executable_schema(&self) -> Result<&async_graphql::dynamic::Schema, String> {
239        let schema = self.schema.as_ref().ok_or("No schema")?;
240        self.dynamic_schema
241            .get_or_init(|| real_graphql::build_dynamic_schema(schema, self.resolver.as_ref()))
242            .as_ref()
243            .map_err(Clone::clone)
244    }
245
246    /// Start a background tokio task that serves real GraphQL over HTTP via
247    /// axum + async-graphql (`POST /graphql`). Returns the URL the server is
248    /// listening on. Must be called from within a tokio runtime context.
249    #[cfg(feature = "real")]
250    pub fn start(&self) -> Result<String, String> {
251        let schema = self.executable_schema()?.clone();
252        let port = self.port;
253        let url = format!("http://localhost:{}", port);
254        tokio::spawn(async move {
255            let addr = format!("127.0.0.1:{}", port);
256            match tokio::net::TcpListener::bind(&addr).await {
257                Ok(listener) => {
258                    let _ = axum::serve(listener, real_graphql::router(schema)).await;
259                }
260                Err(_) => {
261                    // Port may already be in use; the spawn task just exits.
262                }
263            }
264        });
265        Ok(url)
266    }
267
268    /// Execute a simple GraphQL query of the form `{ getX(id: 1) { id name } }`
269    /// or `{ listXs { id name } }`. Returns mock JSON data based on the schema.
270    #[cfg(not(feature = "real"))]
271    pub fn execute_query(&self, query: &str) -> Result<serde_json::Value, String> {
272        let schema = self.schema.as_ref().ok_or("No schema")?;
273        let trimmed = query.trim();
274        let brace_start = trimmed.find('{').ok_or("Missing '{' in query")?;
275        let after_brace = trimmed[brace_start + 1..].trim_start();
276        // The query name ends at the first whitespace or '('.
277        let end = after_brace
278            .find(|c: char| c.is_whitespace() || c == '(' || c == '{')
279            .unwrap_or(after_brace.len());
280        let query_name = after_brace[..end].trim();
281        if query_name.is_empty() {
282            return Err("Empty query name".to_string());
283        }
284        let field = schema
285            .queries
286            .iter()
287            .find(|q| q.name == query_name)
288            .ok_or_else(|| format!("Query '{}' not found in schema", query_name))?;
289        if field.type_name.starts_with('[') {
290            // List query: return an array of mock objects.
291            Ok(serde_json::json!([
292                {
293                    "id": "1",
294                    "name": format!("{}_1", field.name),
295                    "createdAt": "2024-01-01T00:00:00Z",
296                    "updatedAt": "2024-01-01T00:00:00Z"
297                },
298                {
299                    "id": "2",
300                    "name": format!("{}_2", field.name),
301                    "createdAt": "2024-01-01T00:00:00Z",
302                    "updatedAt": "2024-01-01T00:00:00Z"
303                }
304            ]))
305        } else {
306            // Single query: return one mock object.
307            Ok(serde_json::json!({
308                "id": "1",
309                "name": format!("{}_1", field.name),
310                "createdAt": "2024-01-01T00:00:00Z",
311                "updatedAt": "2024-01-01T00:00:00Z"
312            }))
313        }
314    }
315
316    /// Execute a GraphQL query with the real async-graphql engine and return
317    /// the resolved value of the first root field as JSON.
318    #[cfg(feature = "real")]
319    pub fn execute_query(&self, query: &str) -> Result<serde_json::Value, String> {
320        let schema = self.executable_schema()?;
321        real_graphql::execute(schema, query)
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn test_schema_new() {
331        let s = GraphQLSchema::new();
332        assert!(s.types.is_empty());
333    }
334
335    #[test]
336    fn test_schema_add_type() {
337        let s = GraphQLSchema::new().add_type(GraphQLType::new("User"));
338        assert_eq!(s.types.len(), 1);
339    }
340
341    #[test]
342    fn test_generator_creates_types_and_queries() {
343        let s = GraphQLSchemaGenerator::generate_schema(&["users", "orders"]);
344        // 2 model types
345        assert_eq!(s.types.len(), 2);
346        // 2 queries per model (getX + listXs) = 4 queries
347        assert_eq!(s.queries.len(), 4);
348        // Verify each type has the required fields
349        for t in &s.types {
350            assert!(t
351                .fields
352                .iter()
353                .any(|f| f.name == "id" && f.type_name == "ID!"));
354            assert!(t.fields.iter().any(|f| f.name == "name"));
355            assert!(t.fields.iter().any(|f| f.name == "createdAt"));
356            assert!(t.fields.iter().any(|f| f.name == "updatedAt"));
357        }
358        // Verify both getX and listXs queries exist for "users" -> "User"
359        assert!(s
360            .queries
361            .iter()
362            .any(|q| q.name == "getUser" && q.type_name == "User"));
363        assert!(s
364            .queries
365            .iter()
366            .any(|q| q.name == "listUsers" && q.type_name == "[User!]!"));
367        assert!(s
368            .queries
369            .iter()
370            .any(|q| q.name == "getOrder" && q.type_name == "Order"));
371        assert!(s
372            .queries
373            .iter()
374            .any(|q| q.name == "listOrders" && q.type_name == "[Order!]!"));
375    }
376
377    #[test]
378    fn test_schema_sdl_contains_all_models() {
379        let s = GraphQLSchemaGenerator::generate_schema(&["users", "orders"]);
380        let sdl = s.to_sdl();
381        assert!(sdl.contains("type User {"));
382        assert!(sdl.contains("type Order {"));
383        assert!(sdl.contains("type Query {"));
384        assert!(sdl.contains("getUser: User"));
385        assert!(sdl.contains("listUsers: [User!]!"));
386    }
387
388    #[test]
389    fn test_server_new() {
390        let srv = GraphQLServer::new(4000);
391        assert_eq!(srv.port, 4000);
392    }
393
394    #[test]
395    fn test_server_start_without_schema_fails() {
396        let srv = GraphQLServer::new(4000);
397        assert!(srv.start().is_err());
398    }
399
400    #[tokio::test]
401    async fn test_server_start_returns_url_and_binds_port() {
402        let srv = GraphQLServer::new(4123)
403            .with_schema(GraphQLSchemaGenerator::generate_schema(&["users"]));
404        let url = srv.start().expect("start should succeed");
405        assert!(url.contains("4123"));
406        // Give the spawned task a moment to bind the port.
407        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
408        // Verify the port is now bound by trying to bind to it again (should fail).
409        let second = tokio::net::TcpListener::bind("127.0.0.1:4123").await;
410        assert!(
411            second.is_err(),
412            "Port 4123 should already be bound by the spawned server task"
413        );
414    }
415
416    #[test]
417    fn test_execute_query_single() {
418        let srv = GraphQLServer::new(4001)
419            .with_schema(GraphQLSchemaGenerator::generate_schema(&["users"]));
420        let result = srv.execute_query("{ getUser(id: 1) { id name } }");
421        assert!(result.is_ok(), "expected ok, got {:?}", result);
422        let v = result.unwrap();
423        assert_eq!(v["id"], "1");
424        assert!(v["name"].as_str().unwrap().contains("getUser"));
425    }
426
427    #[test]
428    fn test_execute_query_list() {
429        let srv = GraphQLServer::new(4002)
430            .with_schema(GraphQLSchemaGenerator::generate_schema(&["users"]));
431        let result = srv.execute_query("{ listUsers { id name } }");
432        assert!(result.is_ok(), "expected ok, got {:?}", result);
433        let v = result.unwrap();
434        assert!(v.is_array());
435        assert_eq!(v.as_array().unwrap().len(), 2);
436    }
437
438    #[test]
439    fn test_execute_query_unknown_returns_error() {
440        let srv = GraphQLServer::new(4003)
441            .with_schema(GraphQLSchemaGenerator::generate_schema(&["users"]));
442        let result = srv.execute_query("{ unknownQuery { id } }");
443        assert!(result.is_err());
444        assert!(result.unwrap_err().contains("unknownQuery"));
445    }
446
447    #[test]
448    fn test_execute_query_without_schema_fails() {
449        let srv = GraphQLServer::new(4004);
450        let result = srv.execute_query("{ getUser { id } }");
451        assert!(result.is_err());
452    }
453
454    #[test]
455    fn test_execute_query_malformed_no_brace() {
456        let srv = GraphQLServer::new(4005)
457            .with_schema(GraphQLSchemaGenerator::generate_schema(&["users"]));
458        let result = srv.execute_query("getUser");
459        assert!(result.is_err());
460    }
461
462    /// Send a real GraphQL POST request over HTTP/1.0 and return the status
463    /// code together with the decoded JSON body. HTTP/1.0 keeps the response
464    /// free of chunked transfer encoding, so the body ends at connection
465    /// close.
466    #[cfg(feature = "real")]
467    async fn post_graphql(url: &str, body: &str) -> (u16, serde_json::Value) {
468        use tokio::io::{AsyncReadExt, AsyncWriteExt};
469        let without_scheme = url
470            .strip_prefix("http://")
471            .expect("url must start with http://");
472        let (addr, path) = without_scheme
473            .split_once('/')
474            .unwrap_or((without_scheme, ""));
475        let mut stream = tokio::net::TcpStream::connect(addr)
476            .await
477            .expect("connect should succeed");
478        let request = format!(
479            "POST /{path} HTTP/1.0\r\nHost: {addr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
480            body.len()
481        );
482        stream
483            .write_all(request.as_bytes())
484            .await
485            .expect("write should succeed");
486        let mut raw = Vec::new();
487        stream
488            .read_to_end(&mut raw)
489            .await
490            .expect("read should succeed");
491        let text = String::from_utf8(raw).expect("response must be valid UTF-8");
492        let (head, body) = text
493            .split_once("\r\n\r\n")
494            .expect("response must contain a header/body separator");
495        let status = head
496            .split_whitespace()
497            .nth(1)
498            .and_then(|code| code.parse::<u16>().ok())
499            .expect("status line must contain a numeric status code");
500        let json = serde_json::from_str(body).expect("response body must be valid JSON");
501        (status, json)
502    }
503
504    #[cfg(feature = "real")]
505    #[tokio::test]
506    #[ignore = "requires the real GraphQL server (feature `real`)"]
507    async fn test_real_http_post_single_query() {
508        let srv = GraphQLServer::new(4331)
509            .with_schema(GraphQLSchemaGenerator::generate_schema(&["users"]));
510        let url = srv.start().expect("start should succeed");
511        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
512        let (status, body) = post_graphql(
513            &format!("{url}/graphql"),
514            &serde_json::json!({"query": "{ getUser(id: 1) { id name } }"}).to_string(),
515        )
516        .await;
517        assert_eq!(status, 200);
518        assert_eq!(body["data"]["getUser"]["id"], "1");
519        assert!(body["data"]["getUser"]["name"]
520            .as_str()
521            .expect("name must be a string")
522            .contains("getUser"));
523    }
524
525    #[cfg(feature = "real")]
526    #[tokio::test]
527    #[ignore = "requires the real GraphQL server (feature `real`)"]
528    async fn test_real_http_post_list_query() {
529        let srv = GraphQLServer::new(4332)
530            .with_schema(GraphQLSchemaGenerator::generate_schema(&["users"]));
531        let url = srv.start().expect("start should succeed");
532        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
533        let (status, body) = post_graphql(
534            &format!("{url}/graphql"),
535            &serde_json::json!({"query": "{ listUsers { id name } }"}).to_string(),
536        )
537        .await;
538        assert_eq!(status, 200);
539        let users = body["data"]["listUsers"]
540            .as_array()
541            .expect("listUsers must be an array");
542        assert_eq!(users.len(), 2);
543        assert_eq!(users[0]["id"], "1");
544        assert_eq!(users[1]["id"], "2");
545    }
546
547    #[cfg(feature = "real")]
548    #[tokio::test]
549    #[ignore = "requires the real GraphQL server (feature `real`)"]
550    async fn test_real_http_post_unknown_query_returns_errors() {
551        let srv = GraphQLServer::new(4333)
552            .with_schema(GraphQLSchemaGenerator::generate_schema(&["users"]));
553        let url = srv.start().expect("start should succeed");
554        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
555        let (status, body) = post_graphql(
556            &format!("{url}/graphql"),
557            &serde_json::json!({"query": "{ unknownQuery { id } }"}).to_string(),
558        )
559        .await;
560        assert_eq!(status, 200);
561        let errors = body["errors"].as_array().expect("errors must be an array");
562        assert!(!errors.is_empty());
563        assert!(errors[0]["message"]
564            .as_str()
565            .expect("message must be a string")
566            .contains("unknownQuery"));
567    }
568
569    #[cfg(feature = "real")]
570    #[test]
571    #[ignore = "requires the real GraphQL engine (feature `real`)"]
572    fn test_real_execute_query_matches_mock_shape() {
573        let srv = GraphQLServer::new(4334)
574            .with_schema(GraphQLSchemaGenerator::generate_schema(&["users"]));
575        let single = srv
576            .execute_query("{ getUser(id: 1) { id name } }")
577            .expect("single query should succeed");
578        assert_eq!(single["id"], "1");
579        assert!(single["name"]
580            .as_str()
581            .expect("name must be a string")
582            .contains("getUser"));
583        let list = srv
584            .execute_query("{ listUsers { id name } }")
585            .expect("list query should succeed");
586        assert_eq!(list.as_array().expect("result must be an array").len(), 2);
587        let err = srv
588            .execute_query("{ unknownQuery { id } }")
589            .expect_err("unknown query must fail");
590        assert!(err.contains("unknownQuery"));
591    }
592}