Skip to main content

reinhardt_graphql/
schema.rs

1use async_graphql::extensions::Analyzer;
2use async_graphql::{Context, EmptySubscription, ID, Object, Result as GqlResult, Schema};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::sync::Arc;
6use tokio::sync::RwLock;
7
8/// Error types for GraphQL schema operations.
9#[derive(Debug, thiserror::Error)]
10pub enum GraphQLError {
11	/// A schema construction or configuration error.
12	#[error("Schema error: {0}")]
13	Schema(String),
14	/// An error occurred during resolver execution.
15	#[error("Resolver error: {0}")]
16	Resolver(String),
17	/// The requested resource was not found.
18	#[error("Not found: {0}")]
19	NotFound(String),
20}
21
22/// A specialized `Result` type for GraphQL schema operations.
23pub type GraphQLResult<T> = Result<T, GraphQLError>;
24
25/// Default maximum query depth limit.
26///
27/// Limits how deeply nested a query can be to prevent resource exhaustion
28/// from deeply nested selections.
29pub const DEFAULT_MAX_QUERY_DEPTH: usize = 10;
30
31/// Default maximum query complexity limit.
32///
33/// Limits total complexity score for a single query to prevent
34/// resource exhaustion from expensive operations.
35pub const DEFAULT_MAX_QUERY_COMPLEXITY: usize = 100;
36
37/// Default maximum query size in bytes.
38///
39/// Prevents excessively large query strings from consuming parsing resources.
40pub const DEFAULT_MAX_QUERY_SIZE: usize = 32_768; // 32 KB
41
42/// Default maximum number of fields in a single query.
43///
44/// Prevents queries that request an excessive number of fields,
45/// which could lead to resource exhaustion.
46pub const DEFAULT_MAX_FIELD_COUNT: usize = 200;
47
48/// Default maximum page size for paginated queries.
49///
50/// Prevents unbounded result sets that could cause memory exhaustion.
51pub const DEFAULT_MAX_PAGE_SIZE: usize = 100;
52
53/// Default page size for paginated queries.
54pub const DEFAULT_PAGE_SIZE: usize = 20;
55
56/// Maximum allowed length for user name input.
57const MAX_NAME_LENGTH: usize = 100;
58
59/// Maximum allowed length for email input.
60const MAX_EMAIL_LENGTH: usize = 254;
61
62/// Check whether a string exceeds the given character limit.
63///
64/// Uses short-circuit counting: stops as soon as `max + 1` characters
65/// have been scanned, avoiding a full O(n) traversal for large inputs.
66fn exceeds_max_chars(s: &str, max: usize) -> bool {
67	s.chars().nth(max).is_some()
68}
69
70/// Configuration for GraphQL query protection limits.
71///
72/// Controls query depth, complexity, size, and field count limits to prevent
73/// denial-of-service attacks through resource exhaustion.
74///
75/// # Examples
76///
77/// ```
78/// use reinhardt_graphql::schema::QueryLimits;
79///
80/// // Use defaults
81/// let limits = QueryLimits::default();
82/// assert_eq!(limits.max_depth, 10);
83/// assert_eq!(limits.max_complexity, 100);
84/// assert_eq!(limits.max_query_size, 32_768);
85/// assert_eq!(limits.max_field_count, 200);
86///
87/// // Custom limits
88/// let limits = QueryLimits::new(15, 200);
89/// assert_eq!(limits.max_depth, 15);
90/// assert_eq!(limits.max_complexity, 200);
91/// ```
92#[derive(Debug, Clone, Copy)]
93pub struct QueryLimits {
94	/// Maximum allowed query depth
95	pub max_depth: usize,
96	/// Maximum allowed query complexity
97	pub max_complexity: usize,
98	/// Maximum allowed query string size in bytes
99	pub max_query_size: usize,
100	/// Maximum allowed number of fields in a query
101	pub max_field_count: usize,
102}
103
104impl QueryLimits {
105	/// Create a new `QueryLimits` with custom depth and complexity values.
106	///
107	/// Uses default values for query size and field count limits.
108	pub fn new(max_depth: usize, max_complexity: usize) -> Self {
109		Self {
110			max_depth,
111			max_complexity,
112			max_query_size: DEFAULT_MAX_QUERY_SIZE,
113			max_field_count: DEFAULT_MAX_FIELD_COUNT,
114		}
115	}
116
117	/// Create a new `QueryLimits` with all values specified.
118	pub fn full(
119		max_depth: usize,
120		max_complexity: usize,
121		max_query_size: usize,
122		max_field_count: usize,
123	) -> Self {
124		Self {
125			max_depth,
126			max_complexity,
127			max_query_size,
128			max_field_count,
129		}
130	}
131}
132
133impl Default for QueryLimits {
134	fn default() -> Self {
135		Self {
136			max_depth: DEFAULT_MAX_QUERY_DEPTH,
137			max_complexity: DEFAULT_MAX_QUERY_COMPLEXITY,
138			max_query_size: DEFAULT_MAX_QUERY_SIZE,
139			max_field_count: DEFAULT_MAX_FIELD_COUNT,
140		}
141	}
142}
143
144/// Validate a GraphQL query string against size and field count limits.
145///
146/// Returns `Ok(())` if the query passes all checks, or an error message
147/// describing which limit was exceeded.
148pub fn validate_query(query: &str, limits: &QueryLimits) -> Result<(), String> {
149	// Check query size
150	if query.len() > limits.max_query_size {
151		return Err(format!(
152			"Query size {} bytes exceeds maximum of {} bytes",
153			query.len(),
154			limits.max_query_size
155		));
156	}
157
158	// Approximate field count by counting field-like tokens
159	// A more accurate count would require parsing, but this provides
160	// a reasonable heuristic for DoS prevention
161	let field_count = count_query_fields(query);
162	if field_count > limits.max_field_count {
163		return Err(format!(
164			"Query field count {} exceeds maximum of {}",
165			field_count, limits.max_field_count
166		));
167	}
168
169	Ok(())
170}
171
172/// GraphQL keywords that should not be counted as fields.
173const GRAPHQL_KEYWORDS: &[&str] = &[
174	"query",
175	"mutation",
176	"subscription",
177	"fragment",
178	"on",
179	"true",
180	"false",
181	"null",
182];
183
184/// Check whether a token is a field-like identifier.
185///
186/// Returns `true` when the token looks like a GraphQL field name:
187/// an alphanumeric identifier that is not a keyword and does not
188/// start with a fragment spread (`...`).
189fn is_field_identifier(token: &str) -> bool {
190	!token.is_empty()
191		&& !token.starts_with("...")
192		&& !GRAPHQL_KEYWORDS.contains(&token)
193		&& token
194			.chars()
195			.next()
196			.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
197}
198
199/// Count approximate number of fields in a GraphQL query.
200///
201/// Counts field-like identifiers that appear inside selection sets
202/// (brace depth > 0). Each identifier token is evaluated immediately
203/// during character processing, so multiple fields on the same line
204/// are counted correctly.
205///
206/// Handles inline fragment type conditions (`... on Type { }`) by
207/// tracking the `on` keyword and skipping the subsequent type name.
208/// Also handles block strings (`"""..."""`) to avoid miscounting
209/// content inside them as fields.
210fn count_query_fields(query: &str) -> usize {
211	let mut count = 0;
212	let mut in_string = false;
213	let mut in_block_string = false;
214	let mut depth: usize = 0;
215	let mut token = String::new();
216	let mut in_comment = false;
217	let mut escaped = false;
218	// Track whether the last flushed token was the `on` keyword,
219	// so the next identifier (a type condition) is not counted as a field.
220	let mut after_on_keyword = false;
221
222	let chars: Vec<char> = query.chars().collect();
223	let len = chars.len();
224	let mut i = 0;
225
226	while i < len {
227		let ch = chars[i];
228
229		if escaped {
230			escaped = false;
231			i += 1;
232			continue;
233		}
234
235		// Handle block strings: skip everything until closing """
236		if in_block_string {
237			if ch == '"' && i + 2 < len && chars[i + 1] == '"' && chars[i + 2] == '"' {
238				in_block_string = false;
239				i += 3; // skip closing """
240			} else {
241				i += 1;
242			}
243			continue;
244		}
245
246		// Handle line comments: everything after '#' (outside strings) is ignored
247		if ch == '\n' {
248			in_comment = false;
249			// Flush any accumulated token at end of line
250			if depth > 0 && !in_string && is_field_identifier(&token) {
251				if after_on_keyword {
252					after_on_keyword = false;
253				} else {
254					count += 1;
255				}
256			}
257			if !is_field_identifier(&token) {
258				after_on_keyword = false;
259			}
260			token.clear();
261			i += 1;
262			continue;
263		}
264
265		if in_comment {
266			i += 1;
267			continue;
268		}
269
270		if in_string {
271			match ch {
272				'\\' => escaped = true,
273				'"' => in_string = false,
274				_ => {}
275			}
276			i += 1;
277			continue;
278		}
279
280		match ch {
281			'#' => {
282				// Flush token before comment starts
283				if depth > 0 && is_field_identifier(&token) {
284					if after_on_keyword {
285						after_on_keyword = false;
286					} else {
287						count += 1;
288					}
289				}
290				token.clear();
291				in_comment = true;
292			}
293			'"' => {
294				// Check for block string opening: """
295				if i + 2 < len && chars[i + 1] == '"' && chars[i + 2] == '"' {
296					// Flush token before block string starts
297					if depth > 0 && is_field_identifier(&token) {
298						if after_on_keyword {
299							after_on_keyword = false;
300						} else {
301							count += 1;
302						}
303					}
304					token.clear();
305					in_block_string = true;
306					i += 3; // skip opening """
307					continue;
308				}
309				// Flush token before string starts
310				if depth > 0 && is_field_identifier(&token) {
311					if after_on_keyword {
312						after_on_keyword = false;
313					} else {
314						count += 1;
315					}
316				}
317				token.clear();
318				in_string = true;
319			}
320			'{' => {
321				// Flush token — the identifier before '{' is a field with sub-selection
322				if depth > 0 && is_field_identifier(&token) {
323					if after_on_keyword {
324						after_on_keyword = false;
325					} else {
326						count += 1;
327					}
328				}
329				token.clear();
330				depth += 1;
331			}
332			'}' => {
333				// Flush token before closing brace
334				if depth > 0 && is_field_identifier(&token) {
335					if after_on_keyword {
336						after_on_keyword = false;
337					} else {
338						count += 1;
339					}
340				}
341				token.clear();
342				depth = depth.saturating_sub(1);
343			}
344			'(' => {
345				// Flush token — the identifier before '(' is a field with arguments
346				if depth > 0 && is_field_identifier(&token) {
347					if after_on_keyword {
348						after_on_keyword = false;
349					} else {
350						count += 1;
351					}
352				}
353				token.clear();
354			}
355			c if c.is_ascii_whitespace() || c == ',' => {
356				// Token delimiter: evaluate accumulated token
357				if depth > 0 && is_field_identifier(&token) {
358					if after_on_keyword {
359						after_on_keyword = false;
360					} else {
361						// Set after_on_keyword when flushing the `on` keyword itself
362						if token == "on" {
363							after_on_keyword = true;
364						}
365						count += 1;
366					}
367				} else if token == "on" {
368					// `on` is in GRAPHQL_KEYWORDS so is_field_identifier returns false,
369					// but we still need to track it for inline fragment detection
370					after_on_keyword = true;
371				}
372				token.clear();
373			}
374			')' | ':' | '!' | '@' | '$' | '=' | '|' | '&' => {
375				// Punctuation that terminates a token but is not a field delimiter
376				token.clear();
377			}
378			_ => {
379				token.push(ch);
380			}
381		}
382		i += 1;
383	}
384
385	// Flush final token (query may not end with newline)
386	if depth > 0 && !in_string && is_field_identifier(&token) {
387		if after_on_keyword {
388			// Type condition at end of query — do not count
389		} else {
390			count += 1;
391		}
392	}
393
394	count
395}
396
397/// Validate input for creating a user.
398///
399/// Enforces:
400/// - Name is non-empty and within length limits
401/// - Name contains only valid characters
402/// - Email is non-empty and within length limits
403/// - Email has a basic valid format
404fn validate_create_user_input(input: &CreateUserInput) -> GqlResult<()> {
405	// Validate name
406	let name = input.name.trim();
407	if name.is_empty() {
408		return Err(async_graphql::Error::new("Name cannot be empty"));
409	}
410	if exceeds_max_chars(name, MAX_NAME_LENGTH) {
411		return Err(async_graphql::Error::new(format!(
412			"Name exceeds maximum length of {} characters",
413			MAX_NAME_LENGTH
414		)));
415	}
416	if !name
417		.chars()
418		.all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == ' ' || c == '.')
419	{
420		return Err(async_graphql::Error::new(
421			"Name contains invalid characters (allowed: alphanumeric, spaces, underscores, hyphens, dots)",
422		));
423	}
424
425	// Validate email
426	let email = input.email.trim();
427	if email.is_empty() {
428		return Err(async_graphql::Error::new("Email cannot be empty"));
429	}
430	if exceeds_max_chars(email, MAX_EMAIL_LENGTH) {
431		return Err(async_graphql::Error::new(format!(
432			"Email exceeds maximum length of {} characters",
433			MAX_EMAIL_LENGTH
434		)));
435	}
436	// Basic email format validation: must contain exactly one @ with parts on both sides
437	let at_count = email.chars().filter(|c| *c == '@').count();
438	if at_count != 1 {
439		return Err(async_graphql::Error::new("Invalid email format"));
440	}
441	let parts: Vec<&str> = email.splitn(2, '@').collect();
442	if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() || !parts[1].contains('.') {
443		return Err(async_graphql::Error::new("Invalid email format"));
444	}
445
446	Ok(())
447}
448
449/// Example: User type for GraphQL
450#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct User {
452	/// Unique identifier for the user.
453	pub id: ID,
454	/// Display name of the user.
455	pub name: String,
456	/// Email address of the user.
457	pub email: String,
458	/// Whether the user account is active.
459	pub active: bool,
460}
461
462#[Object]
463impl User {
464	async fn id(&self) -> &ID {
465		&self.id
466	}
467
468	async fn name(&self) -> &str {
469		&self.name
470	}
471
472	async fn email(&self) -> &str {
473		&self.email
474	}
475
476	async fn active(&self) -> bool {
477		self.active
478	}
479}
480
481/// User storage (in-memory for example)
482#[derive(Clone)]
483pub struct UserStorage {
484	users: Arc<RwLock<HashMap<String, User>>>,
485}
486
487impl UserStorage {
488	/// Create a new user storage
489	///
490	/// # Examples
491	///
492	/// ```
493	/// use reinhardt_graphql::schema::UserStorage;
494	///
495	/// let storage = UserStorage::new();
496	/// // Creates a new storage instance with defaults
497	/// ```
498	pub fn new() -> Self {
499		Self {
500			users: Arc::new(RwLock::new(HashMap::new())),
501		}
502	}
503	/// Add a user to storage
504	///
505	pub async fn add_user(&self, user: User) {
506		self.users.write().await.insert(user.id.to_string(), user);
507	}
508	/// Get a user by ID
509	///
510	/// # Examples
511	///
512	/// ```no_run
513	/// # fn main() {
514	/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
515	/// use reinhardt_graphql::schema::UserStorage;
516	/// let storage = UserStorage::new();
517	/// // Retrieve user
518	/// let user = storage.get_user("user-1").await;
519	/// # });
520	/// # }
521	/// ```
522	pub async fn get_user(&self, id: &str) -> Option<User> {
523		self.users.read().await.get(id).cloned()
524	}
525	/// List all users
526	///
527	/// # Examples
528	///
529	/// ```no_run
530	/// # fn main() {
531	/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
532	/// use reinhardt_graphql::schema::UserStorage;
533	/// let storage = UserStorage::new();
534	/// // List all users
535	/// let users = storage.list_users().await;
536	/// # });
537	/// # }
538	/// ```
539	pub async fn list_users(&self) -> Vec<User> {
540		self.users.read().await.values().cloned().collect()
541	}
542}
543
544impl Default for UserStorage {
545	fn default() -> Self {
546		Self::new()
547	}
548}
549
550/// GraphQL Query root
551pub struct Query;
552
553#[Object]
554impl Query {
555	async fn user(&self, ctx: &Context<'_>, id: ID) -> GqlResult<Option<User>> {
556		let storage = ctx.data::<UserStorage>()?;
557		Ok(storage.get_user(id.as_ref()).await)
558	}
559
560	/// List users with pagination support.
561	///
562	/// # Arguments
563	///
564	/// * `first` - Maximum number of users to return (default: 20, max: 100)
565	/// * `offset` - Number of users to skip (default: 0)
566	async fn users(
567		&self,
568		ctx: &Context<'_>,
569		first: Option<usize>,
570		offset: Option<usize>,
571	) -> GqlResult<Vec<User>> {
572		let storage = ctx.data::<UserStorage>()?;
573		let limit = first
574			.unwrap_or(DEFAULT_PAGE_SIZE)
575			.min(DEFAULT_MAX_PAGE_SIZE);
576		let skip = offset.unwrap_or(0);
577		let all_users = storage.list_users().await;
578		Ok(all_users.into_iter().skip(skip).take(limit).collect())
579	}
580
581	async fn hello(&self, name: Option<String>) -> String {
582		format!("Hello, {}!", name.unwrap_or_else(|| "World".to_string()))
583	}
584}
585
586/// Input type for creating users
587#[derive(async_graphql::InputObject)]
588pub struct CreateUserInput {
589	/// Name of the user to create.
590	pub name: String,
591	/// Email address of the user to create.
592	pub email: String,
593}
594
595/// GraphQL Mutation root
596pub struct Mutation;
597
598#[Object]
599impl Mutation {
600	async fn create_user(&self, ctx: &Context<'_>, input: CreateUserInput) -> GqlResult<User> {
601		// Validate input before processing
602		validate_create_user_input(&input)?;
603
604		let storage = ctx.data::<UserStorage>()?;
605
606		let user = User {
607			id: ID::from(uuid::Uuid::now_v7().to_string()),
608			name: input.name.trim().to_string(),
609			email: input.email.trim().to_string(),
610			active: true,
611		};
612
613		storage.add_user(user.clone()).await;
614		Ok(user)
615	}
616
617	async fn update_user_status(
618		&self,
619		ctx: &Context<'_>,
620		id: ID,
621		active: bool,
622	) -> GqlResult<Option<User>> {
623		let storage = ctx.data::<UserStorage>()?;
624
625		if let Some(mut user) = storage.get_user(id.as_ref()).await {
626			user.active = active;
627			storage.add_user(user.clone()).await;
628			Ok(Some(user))
629		} else {
630			Ok(None)
631		}
632	}
633}
634
635/// Create GraphQL schema
636pub type AppSchema = Schema<Query, Mutation, EmptySubscription>;
637
638/// Create a GraphQL schema with default query protection limits.
639///
640/// Applies default depth and complexity limits to prevent
641/// resource exhaustion from malicious queries.
642pub fn create_schema(storage: UserStorage) -> AppSchema {
643	create_schema_with_limits(storage, QueryLimits::default())
644}
645
646/// Create a GraphQL schema with custom query protection limits.
647///
648/// Configures depth limit, complexity limit, and the `Analyzer` extension
649/// for query cost analysis.
650///
651/// # Arguments
652///
653/// * `storage` - User data storage
654/// * `limits` - Query protection limits configuration
655pub fn create_schema_with_limits(storage: UserStorage, limits: QueryLimits) -> AppSchema {
656	Schema::build(Query, Mutation, EmptySubscription)
657		.data(storage)
658		.limit_depth(limits.max_depth)
659		.limit_complexity(limits.max_complexity)
660		.extension(Analyzer)
661		.finish()
662}
663
664#[cfg(test)]
665mod tests {
666	use super::*;
667
668	#[tokio::test]
669	async fn test_query_hello() {
670		let storage = UserStorage::new();
671		let schema = create_schema(storage);
672
673		let query = r#"
674            {
675                hello(name: "GraphQL")
676            }
677        "#;
678
679		let result = schema.execute(query).await;
680		let data = result.data.into_json().unwrap();
681		assert_eq!(data["hello"], "Hello, GraphQL!");
682	}
683
684	#[tokio::test]
685	async fn test_mutation_create_user() {
686		let storage = UserStorage::new();
687		let schema = create_schema(storage);
688
689		let query = r#"
690            mutation {
691                createUser(input: { name: "Alice", email: "alice@example.com" }) {
692                    name
693                    email
694                    active
695                }
696            }
697        "#;
698
699		let result = schema.execute(query).await;
700		let data = result.data.into_json().unwrap();
701		assert_eq!(data["createUser"]["name"], "Alice");
702		assert!(data["createUser"]["active"].as_bool().unwrap());
703	}
704
705	#[tokio::test]
706	async fn test_query_user() {
707		let storage = UserStorage::new();
708		let user = User {
709			id: ID::from("test-id-123"),
710			name: "Bob".to_string(),
711			email: "bob@example.com".to_string(),
712			active: true,
713		};
714		storage.add_user(user).await;
715
716		let schema = create_schema(storage);
717
718		let query = r#"
719            {
720                user(id: "test-id-123") {
721                    id
722                    name
723                    email
724                    active
725                }
726            }
727        "#;
728
729		let result = schema.execute(query).await;
730		let data = result.data.into_json().unwrap();
731		assert_eq!(data["user"]["id"], "test-id-123");
732		assert_eq!(data["user"]["name"], "Bob");
733		assert_eq!(data["user"]["email"], "bob@example.com");
734		assert!(data["user"]["active"].as_bool().unwrap());
735	}
736
737	#[tokio::test]
738	async fn test_query_user_not_found() {
739		let storage = UserStorage::new();
740		let schema = create_schema(storage);
741
742		let query = r#"
743            {
744                user(id: "nonexistent-id") {
745                    id
746                    name
747                }
748            }
749        "#;
750
751		let result = schema.execute(query).await;
752		let data = result.data.into_json().unwrap();
753		assert!(data["user"].is_null());
754	}
755
756	#[tokio::test]
757	async fn test_query_users_empty() {
758		let storage = UserStorage::new();
759		let schema = create_schema(storage);
760
761		let query = r#"
762            {
763                users {
764                    id
765                    name
766                }
767            }
768        "#;
769
770		let result = schema.execute(query).await;
771		let data = result.data.into_json().unwrap();
772		assert!(data["users"].is_array());
773		assert_eq!(data["users"].as_array().unwrap().len(), 0);
774	}
775
776	#[tokio::test]
777	async fn test_query_users_multiple() {
778		let storage = UserStorage::new();
779
780		let user1 = User {
781			id: ID::from("1"),
782			name: "Alice".to_string(),
783			email: "alice@example.com".to_string(),
784			active: true,
785		};
786		let user2 = User {
787			id: ID::from("2"),
788			name: "Bob".to_string(),
789			email: "bob@example.com".to_string(),
790			active: false,
791		};
792		let user3 = User {
793			id: ID::from("3"),
794			name: "Charlie".to_string(),
795			email: "charlie@example.com".to_string(),
796			active: true,
797		};
798
799		storage.add_user(user1).await;
800		storage.add_user(user2).await;
801		storage.add_user(user3).await;
802
803		let schema = create_schema(storage);
804
805		let query = r#"
806            {
807                users {
808                    id
809                    name
810                    email
811                    active
812                }
813            }
814        "#;
815
816		let result = schema.execute(query).await;
817		let data = result.data.into_json().unwrap();
818		let users = data["users"].as_array().unwrap();
819		assert_eq!(users.len(), 3);
820
821		// Verify that all users are present
822		let names: Vec<&str> = users.iter().map(|u| u["name"].as_str().unwrap()).collect();
823		assert!(names.contains(&"Alice"));
824		assert!(names.contains(&"Bob"));
825		assert!(names.contains(&"Charlie"));
826	}
827
828	#[tokio::test]
829	async fn test_query_users_pagination_with_first() {
830		// Arrange
831		let storage = UserStorage::new();
832		for i in 0..10 {
833			storage
834				.add_user(User {
835					id: ID::from(format!("user-{}", i)),
836					name: format!("User{}", i),
837					email: format!("user{}@example.com", i),
838					active: true,
839				})
840				.await;
841		}
842		let schema = create_schema(storage);
843
844		// Act: request only 3 users
845		let query = r#"{ users(first: 3) { id } }"#;
846		let result = schema.execute(query).await;
847
848		// Assert
849		assert!(result.errors.is_empty());
850		let data = result.data.into_json().unwrap();
851		let users = data["users"].as_array().unwrap();
852		assert_eq!(users.len(), 3);
853	}
854
855	#[tokio::test]
856	async fn test_query_users_pagination_with_offset() {
857		// Arrange
858		let storage = UserStorage::new();
859		for i in 0..5 {
860			storage
861				.add_user(User {
862					id: ID::from(format!("user-{}", i)),
863					name: format!("User{}", i),
864					email: format!("user{}@example.com", i),
865					active: true,
866				})
867				.await;
868		}
869		let schema = create_schema(storage);
870
871		// Act: skip 3, take 10 -> should get 2
872		let query = r#"{ users(first: 10, offset: 3) { id } }"#;
873		let result = schema.execute(query).await;
874
875		// Assert
876		assert!(result.errors.is_empty());
877		let data = result.data.into_json().unwrap();
878		let users = data["users"].as_array().unwrap();
879		assert_eq!(users.len(), 2);
880	}
881
882	#[tokio::test]
883	async fn test_query_users_enforces_max_page_size() {
884		// Arrange
885		let storage = UserStorage::new();
886		for i in 0..150 {
887			storage
888				.add_user(User {
889					id: ID::from(format!("user-{}", i)),
890					name: format!("User{}", i),
891					email: format!("user{}@example.com", i),
892					active: true,
893				})
894				.await;
895		}
896		let schema = create_schema(storage);
897
898		// Act: request 500 users but max is 100
899		let query = r#"{ users(first: 500) { id } }"#;
900		let result = schema.execute(query).await;
901
902		// Assert: clamped to max page size
903		assert!(result.errors.is_empty());
904		let data = result.data.into_json().unwrap();
905		let users = data["users"].as_array().unwrap();
906		assert_eq!(users.len(), DEFAULT_MAX_PAGE_SIZE);
907	}
908
909	#[tokio::test]
910	async fn test_create_user_validates_empty_name() {
911		// Arrange
912		let storage = UserStorage::new();
913		let schema = create_schema(storage);
914
915		// Act
916		let query = r#"
917			mutation {
918				createUser(input: { name: "   ", email: "test@example.com" }) {
919					id
920				}
921			}
922		"#;
923		let result = schema.execute(query).await;
924
925		// Assert
926		assert!(
927			!result.errors.is_empty(),
928			"expected validation error for empty name"
929		);
930	}
931
932	#[tokio::test]
933	async fn test_create_user_validates_invalid_email() {
934		// Arrange
935		let storage = UserStorage::new();
936		let schema = create_schema(storage);
937
938		// Act
939		let query = r#"
940			mutation {
941				createUser(input: { name: "Alice", email: "not-an-email" }) {
942					id
943				}
944			}
945		"#;
946		let result = schema.execute(query).await;
947
948		// Assert
949		assert!(
950			!result.errors.is_empty(),
951			"expected validation error for invalid email"
952		);
953	}
954
955	#[tokio::test]
956	async fn test_validate_query_rejects_oversized_query() {
957		// Arrange
958		let limits = QueryLimits::full(10, 100, 100, 200); // 100 byte limit
959
960		// Act
961		let long_query = "{ ".to_string() + &"a ".repeat(100) + "}";
962		let result = validate_query(&long_query, &limits);
963
964		// Assert
965		assert!(result.is_err());
966		assert!(result.unwrap_err().contains("exceeds maximum"));
967	}
968
969	#[tokio::test]
970	async fn test_validate_query_accepts_normal_query() {
971		// Arrange
972		let limits = QueryLimits::default();
973
974		// Act
975		let result = validate_query("{ users { id name } }", &limits);
976
977		// Assert
978		assert!(result.is_ok());
979	}
980
981	#[tokio::test]
982	async fn test_mutation_update_user_status() {
983		let storage = UserStorage::new();
984		let user = User {
985			id: ID::from("update-test-id"),
986			name: "David".to_string(),
987			email: "david@example.com".to_string(),
988			active: true,
989		};
990		storage.add_user(user).await;
991
992		let schema = create_schema(storage);
993
994		let query = r#"
995            mutation {
996                updateUserStatus(id: "update-test-id", active: false) {
997                    id
998                    name
999                    active
1000                }
1001            }
1002        "#;
1003
1004		let result = schema.execute(query).await;
1005		let data = result.data.into_json().unwrap();
1006		assert_eq!(data["updateUserStatus"]["id"], "update-test-id");
1007		assert!(!data["updateUserStatus"]["active"].as_bool().unwrap());
1008	}
1009
1010	#[tokio::test]
1011	async fn test_mutation_update_nonexistent_user() {
1012		let storage = UserStorage::new();
1013		let schema = create_schema(storage);
1014
1015		let query = r#"
1016            mutation {
1017                updateUserStatus(id: "does-not-exist", active: false) {
1018                    id
1019                    name
1020                }
1021            }
1022        "#;
1023
1024		let result = schema.execute(query).await;
1025		let data = result.data.into_json().unwrap();
1026		assert!(data["updateUserStatus"].is_null());
1027	}
1028
1029	#[tokio::test]
1030	async fn test_user_object_fields() {
1031		let user = User {
1032			id: ID::from("field-test-id"),
1033			name: "Eve".to_string(),
1034			email: "eve@example.com".to_string(),
1035			active: false,
1036		};
1037
1038		// Test direct field access
1039		assert_eq!(user.id.to_string(), "field-test-id");
1040		assert_eq!(user.name, "Eve");
1041		assert_eq!(user.email, "eve@example.com");
1042		assert!(!user.active);
1043	}
1044
1045	#[tokio::test]
1046	async fn test_user_storage_add_get() {
1047		let storage = UserStorage::new();
1048
1049		let user = User {
1050			id: ID::from("storage-test-1"),
1051			name: "Frank".to_string(),
1052			email: "frank@example.com".to_string(),
1053			active: true,
1054		};
1055
1056		storage.add_user(user.clone()).await;
1057
1058		let retrieved = storage.get_user("storage-test-1").await;
1059		let retrieved = retrieved.unwrap();
1060		assert_eq!(retrieved.id.to_string(), "storage-test-1");
1061		assert_eq!(retrieved.name, "Frank");
1062		assert_eq!(retrieved.email, "frank@example.com");
1063		assert!(retrieved.active);
1064	}
1065
1066	#[tokio::test]
1067	async fn test_user_storage_list() {
1068		let storage = UserStorage::new();
1069
1070		// Initially empty
1071		let users = storage.list_users().await;
1072		assert_eq!(users.len(), 0);
1073
1074		// Add users
1075		storage
1076			.add_user(User {
1077				id: ID::from("list-1"),
1078				name: "User1".to_string(),
1079				email: "user1@example.com".to_string(),
1080				active: true,
1081			})
1082			.await;
1083
1084		storage
1085			.add_user(User {
1086				id: ID::from("list-2"),
1087				name: "User2".to_string(),
1088				email: "user2@example.com".to_string(),
1089				active: false,
1090			})
1091			.await;
1092
1093		let users = storage.list_users().await;
1094		assert_eq!(users.len(), 2);
1095	}
1096
1097	#[tokio::test]
1098	async fn test_create_schema_with_data() {
1099		let storage = UserStorage::new();
1100		storage
1101			.add_user(User {
1102				id: ID::from("pre-existing"),
1103				name: "PreExisting".to_string(),
1104				email: "preexisting@example.com".to_string(),
1105				active: true,
1106			})
1107			.await;
1108
1109		let schema = create_schema(storage);
1110
1111		// Verify schema can query pre-existing data
1112		let query = r#"
1113            {
1114                user(id: "pre-existing") {
1115                    name
1116                }
1117            }
1118        "#;
1119
1120		let result = schema.execute(query).await;
1121		let data = result.data.into_json().unwrap();
1122		assert_eq!(data["user"]["name"], "PreExisting");
1123	}
1124
1125	#[tokio::test]
1126	async fn test_graphql_error_types() {
1127		let err1 = GraphQLError::Schema("test schema error".to_string());
1128		assert!(err1.to_string().contains("Schema error"));
1129
1130		let err2 = GraphQLError::Resolver("test resolver error".to_string());
1131		assert!(err2.to_string().contains("Resolver error"));
1132
1133		let err3 = GraphQLError::NotFound("test item".to_string());
1134		assert!(err3.to_string().contains("Not found"));
1135	}
1136
1137	#[tokio::test]
1138	async fn test_query_depth_limit_rejects_deep_query() {
1139		// Arrange: depth limit of 1 only allows top-level fields
1140		let storage = UserStorage::new();
1141		let limits = QueryLimits::new(1, 1000);
1142		let schema = create_schema_with_limits(storage, limits);
1143
1144		// Act: query with nested selection exceeds depth limit of 1
1145		let query = r#"
1146			{
1147				users {
1148					name
1149				}
1150			}
1151		"#;
1152		let result = schema.execute(query).await;
1153
1154		// Assert: should produce a depth-limit error
1155		assert!(
1156			!result.errors.is_empty(),
1157			"expected depth limit error but query succeeded"
1158		);
1159		let error_message = &result.errors[0].message;
1160		assert!(
1161			error_message.to_lowercase().contains("too deep"),
1162			"expected depth-limit message, got: {error_message}"
1163		);
1164	}
1165
1166	#[tokio::test]
1167	async fn test_query_depth_limit_allows_shallow_query() {
1168		// Arrange
1169		let storage = UserStorage::new();
1170		let limits = QueryLimits::new(10, 1000);
1171		let schema = create_schema_with_limits(storage, limits);
1172
1173		// Act
1174		let query = r#"{ hello(name: "Test") }"#;
1175		let result = schema.execute(query).await;
1176
1177		// Assert
1178		assert!(
1179			result.errors.is_empty(),
1180			"expected no errors for shallow query"
1181		);
1182		let data = result.data.into_json().unwrap();
1183		assert_eq!(data["hello"], "Hello, Test!");
1184	}
1185
1186	#[tokio::test]
1187	async fn test_query_complexity_limit_rejects_complex_query() {
1188		// Arrange: very low complexity limit
1189		let storage = UserStorage::new();
1190		let limits = QueryLimits::new(100, 1);
1191		let schema = create_schema_with_limits(storage, limits);
1192
1193		// Act: query with multiple fields exceeds complexity of 1
1194		let query = r#"
1195			{
1196				users {
1197					id
1198					name
1199					email
1200					active
1201				}
1202			}
1203		"#;
1204		let result = schema.execute(query).await;
1205
1206		// Assert: should produce a complexity-limit error
1207		assert!(
1208			!result.errors.is_empty(),
1209			"expected complexity limit error but query succeeded"
1210		);
1211		let error_message = &result.errors[0].message;
1212		assert!(
1213			error_message.to_lowercase().contains("complex"),
1214			"expected complexity-limit message, got: {error_message}"
1215		);
1216	}
1217
1218	#[tokio::test]
1219	async fn test_query_limits_default_values() {
1220		// Arrange / Act
1221		let limits = QueryLimits::default();
1222
1223		// Assert
1224		assert_eq!(limits.max_depth, DEFAULT_MAX_QUERY_DEPTH);
1225		assert_eq!(limits.max_complexity, DEFAULT_MAX_QUERY_COMPLEXITY);
1226	}
1227
1228	#[tokio::test]
1229	async fn test_create_schema_with_custom_limits() {
1230		// Arrange
1231		let storage = UserStorage::new();
1232		let limits = QueryLimits::new(20, 500);
1233		let schema = create_schema_with_limits(storage, limits);
1234
1235		// Act: simple query within limits
1236		let query = r#"{ hello }"#;
1237		let result = schema.execute(query).await;
1238
1239		// Assert
1240		assert!(result.errors.is_empty());
1241		let data = result.data.into_json().unwrap();
1242		assert_eq!(data["hello"], "Hello, World!");
1243	}
1244
1245	#[tokio::test]
1246	async fn test_analyzer_extension_present() {
1247		// Arrange
1248		let storage = UserStorage::new();
1249		let schema = create_schema(storage);
1250
1251		// Act: execute query and check for complexity/depth in extensions
1252		let query = r#"{ hello(name: "Analyzer") }"#;
1253		let result = schema.execute(query).await;
1254
1255		// Assert: Analyzer extension adds complexity/depth to response extensions
1256		assert!(result.errors.is_empty());
1257		assert!(
1258			!result.extensions.is_empty(),
1259			"expected Analyzer extension data in response"
1260		);
1261	}
1262
1263	#[rstest::rstest]
1264	#[case(
1265		"{\n  user(name: \"hello \\\"world\\\"\") {\n    id\n  }\n}",
1266		2,
1267		"escaped quotes inside string should not affect field count"
1268	)]
1269	#[case(
1270		"{\n  user(name: \"hello \\\\\\\"end\") {\n    id\n    name\n  }\n}",
1271		3,
1272		"escaped backslash before quote should correctly toggle string state"
1273	)]
1274	#[case(
1275		"{\n  user(name: \"no escapes\") {\n    id\n  }\n}",
1276		2,
1277		"string without escapes should count fields normally"
1278	)]
1279	#[case(
1280		"{\n  user(name: \"a\\\"b\\\"c\") {\n    id\n    name\n    email\n  }\n}",
1281		4,
1282		"multiple escaped quotes in a single string literal"
1283	)]
1284	fn test_count_query_fields_with_escaped_strings(
1285		#[case] query: &str,
1286		#[case] expected: usize,
1287		#[case] description: &str,
1288	) {
1289		// Arrange — query and expected count provided by rstest parametrization
1290
1291		// Act
1292		let count = count_query_fields(query);
1293
1294		// Assert
1295		assert_eq!(count, expected, "{}", description);
1296	}
1297
1298	#[rstest::rstest]
1299	#[case(
1300		"{ users { id name email } }",
1301		4,
1302		"parent field plus multiple fields on same line within sub-selection"
1303	)]
1304	#[case(
1305		"{ users { id } }",
1306		2,
1307		"parent field plus single field on same line within sub-selection"
1308	)]
1309	#[case(
1310		"{ users { id name } posts { title body } }",
1311		6,
1312		"two parent fields plus their sub-selection fields on same line"
1313	)]
1314	fn test_count_query_fields_same_line(
1315		#[case] query: &str,
1316		#[case] expected: usize,
1317		#[case] description: &str,
1318	) {
1319		// Arrange — query and expected count provided by rstest parametrization
1320
1321		// Act
1322		let count = count_query_fields(query);
1323
1324		// Assert
1325		assert_eq!(count, expected, "{}", description);
1326	}
1327
1328	#[rstest::rstest]
1329	#[case(
1330		"{ ... on User { id name } }",
1331		2,
1332		"inline fragment type condition should not be counted as a field"
1333	)]
1334	#[case(
1335		"{ users { ... on Admin { role } ... on Member { level } } }",
1336		3,
1337		"multiple inline fragments: users + role + level, type names excluded"
1338	)]
1339	fn test_count_query_fields_inline_fragments(
1340		#[case] query: &str,
1341		#[case] expected: usize,
1342		#[case] description: &str,
1343	) {
1344		// Arrange — query and expected count provided by rstest parametrization
1345
1346		// Act
1347		let count = count_query_fields(query);
1348
1349		// Assert
1350		assert_eq!(count, expected, "{}", description);
1351	}
1352
1353	#[rstest::rstest]
1354	#[case(
1355		"{ user(bio: \"\"\"multi\nline\"\"\") { id } }",
1356		2,
1357		"block string argument content should not be counted as fields"
1358	)]
1359	#[case(
1360		"{ user(desc: \"\"\"has identifier inside\"\"\") { id name } }",
1361		3,
1362		"block string with identifier-like content should not affect field count"
1363	)]
1364	fn test_count_query_fields_block_strings(
1365		#[case] query: &str,
1366		#[case] expected: usize,
1367		#[case] description: &str,
1368	) {
1369		// Arrange — query and expected count provided by rstest parametrization
1370
1371		// Act
1372		let count = count_query_fields(query);
1373
1374		// Assert
1375		assert_eq!(count, expected, "{}", description);
1376	}
1377
1378	#[tokio::test]
1379	async fn test_exceeds_max_chars_short_circuits() {
1380		// Arrange / Act / Assert
1381		assert!(!exceeds_max_chars("hello", 5)); // exactly at limit
1382		assert!(exceeds_max_chars("hello!", 5)); // one over
1383		assert!(!exceeds_max_chars("", 0)); // empty at zero limit
1384		assert!(exceeds_max_chars("a", 0)); // single char over zero limit
1385	}
1386
1387	#[tokio::test]
1388	async fn test_create_user_accepts_multibyte_name_within_limit() {
1389		// Arrange: CJK characters are multi-byte in UTF-8 but each is 1 char
1390		let storage = UserStorage::new();
1391		let schema = create_schema(storage);
1392
1393		// 4 CJK characters = 4 chars (well under MAX_NAME_LENGTH of 100)
1394		let query = r#"
1395			mutation {
1396				createUser(input: { name: "田中太郎", email: "tanaka@example.com" }) {
1397					name
1398				}
1399			}
1400		"#;
1401
1402		// Act
1403		let result = schema.execute(query).await;
1404
1405		// Assert: should succeed because character count is within limit
1406		assert!(
1407			result.errors.is_empty(),
1408			"expected success for multi-byte name within limit, got: {:?}",
1409			result.errors
1410		);
1411		let data = result.data.into_json().unwrap();
1412		assert_eq!(data["createUser"]["name"], "田中太郎");
1413	}
1414
1415	#[tokio::test]
1416	async fn test_create_user_rejects_multibyte_name_over_limit() {
1417		// Arrange: build a name with exactly MAX_NAME_LENGTH + 1 CJK characters
1418		let storage = UserStorage::new();
1419		let schema = create_schema(storage);
1420
1421		let long_name: String = "あ".repeat(MAX_NAME_LENGTH + 1);
1422		let query = format!(
1423			r#"mutation {{ createUser(input: {{ name: "{}", email: "test@example.com" }}) {{ id }} }}"#,
1424			long_name
1425		);
1426
1427		// Act
1428		let result = schema.execute(&query).await;
1429
1430		// Assert: should reject because character count exceeds limit
1431		assert!(
1432			!result.errors.is_empty(),
1433			"expected validation error for name exceeding {} characters",
1434			MAX_NAME_LENGTH
1435		);
1436	}
1437
1438	#[tokio::test]
1439	async fn test_create_user_accepts_emoji_name_at_limit() {
1440		// Arrange: emoji are multi-byte in UTF-8 but each is 1 char count
1441		let storage = UserStorage::new();
1442		let schema = create_schema(storage);
1443
1444		// Exactly MAX_NAME_LENGTH emoji characters
1445		// Note: name validation only allows alphanumeric, spaces, underscores,
1446		// hyphens, and dots, so emoji will be rejected by the character check,
1447		// not the length check. We test length via CJK instead.
1448		// Here we verify that a name at exactly the limit passes length validation.
1449		let name_at_limit: String = "a".repeat(MAX_NAME_LENGTH);
1450		let query = format!(
1451			r#"mutation {{ createUser(input: {{ name: "{}", email: "test@example.com" }}) {{ id }} }}"#,
1452			name_at_limit
1453		);
1454
1455		// Act
1456		let result = schema.execute(&query).await;
1457
1458		// Assert: should succeed (exactly at limit)
1459		assert!(
1460			result.errors.is_empty(),
1461			"expected success for name at exactly the limit, got: {:?}",
1462			result.errors
1463		);
1464	}
1465}