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