Skip to main content

sz_orm_graphql/
lib.rs

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