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	#[cfg(feature = "graphql-grpc")]
657	let builder = crate::GraphQLGrpcService::schema_builder(Query, Mutation, EmptySubscription);
658	#[cfg(not(feature = "graphql-grpc"))]
659	let builder = Schema::build(Query, Mutation, EmptySubscription);
660	builder
661		.data(storage)
662		.limit_depth(limits.max_depth)
663		.limit_complexity(limits.max_complexity)
664		.extension(Analyzer)
665		.finish()
666}
667
668#[cfg(test)]
669mod tests {
670	use super::*;
671
672	#[tokio::test]
673	async fn test_query_hello() {
674		let storage = UserStorage::new();
675		let schema = create_schema(storage);
676
677		let query = r#"
678            {
679                hello(name: "GraphQL")
680            }
681        "#;
682
683		let result = schema.execute(query).await;
684		let data = result.data.into_json().unwrap();
685		assert_eq!(data["hello"], "Hello, GraphQL!");
686	}
687
688	#[tokio::test]
689	async fn test_mutation_create_user() {
690		let storage = UserStorage::new();
691		let schema = create_schema(storage);
692
693		let query = r#"
694            mutation {
695                createUser(input: { name: "Alice", email: "alice@example.com" }) {
696                    name
697                    email
698                    active
699                }
700            }
701        "#;
702
703		let result = schema.execute(query).await;
704		let data = result.data.into_json().unwrap();
705		assert_eq!(data["createUser"]["name"], "Alice");
706		assert!(data["createUser"]["active"].as_bool().unwrap());
707	}
708
709	#[tokio::test]
710	async fn test_query_user() {
711		let storage = UserStorage::new();
712		let user = User {
713			id: ID::from("test-id-123"),
714			name: "Bob".to_string(),
715			email: "bob@example.com".to_string(),
716			active: true,
717		};
718		storage.add_user(user).await;
719
720		let schema = create_schema(storage);
721
722		let query = r#"
723            {
724                user(id: "test-id-123") {
725                    id
726                    name
727                    email
728                    active
729                }
730            }
731        "#;
732
733		let result = schema.execute(query).await;
734		let data = result.data.into_json().unwrap();
735		assert_eq!(data["user"]["id"], "test-id-123");
736		assert_eq!(data["user"]["name"], "Bob");
737		assert_eq!(data["user"]["email"], "bob@example.com");
738		assert!(data["user"]["active"].as_bool().unwrap());
739	}
740
741	#[tokio::test]
742	async fn test_query_user_not_found() {
743		let storage = UserStorage::new();
744		let schema = create_schema(storage);
745
746		let query = r#"
747            {
748                user(id: "nonexistent-id") {
749                    id
750                    name
751                }
752            }
753        "#;
754
755		let result = schema.execute(query).await;
756		let data = result.data.into_json().unwrap();
757		assert!(data["user"].is_null());
758	}
759
760	#[tokio::test]
761	async fn test_query_users_empty() {
762		let storage = UserStorage::new();
763		let schema = create_schema(storage);
764
765		let query = r#"
766            {
767                users {
768                    id
769                    name
770                }
771            }
772        "#;
773
774		let result = schema.execute(query).await;
775		let data = result.data.into_json().unwrap();
776		assert!(data["users"].is_array());
777		assert_eq!(data["users"].as_array().unwrap().len(), 0);
778	}
779
780	#[tokio::test]
781	async fn test_query_users_multiple() {
782		let storage = UserStorage::new();
783
784		let user1 = User {
785			id: ID::from("1"),
786			name: "Alice".to_string(),
787			email: "alice@example.com".to_string(),
788			active: true,
789		};
790		let user2 = User {
791			id: ID::from("2"),
792			name: "Bob".to_string(),
793			email: "bob@example.com".to_string(),
794			active: false,
795		};
796		let user3 = User {
797			id: ID::from("3"),
798			name: "Charlie".to_string(),
799			email: "charlie@example.com".to_string(),
800			active: true,
801		};
802
803		storage.add_user(user1).await;
804		storage.add_user(user2).await;
805		storage.add_user(user3).await;
806
807		let schema = create_schema(storage);
808
809		let query = r#"
810            {
811                users {
812                    id
813                    name
814                    email
815                    active
816                }
817            }
818        "#;
819
820		let result = schema.execute(query).await;
821		let data = result.data.into_json().unwrap();
822		let users = data["users"].as_array().unwrap();
823		assert_eq!(users.len(), 3);
824
825		// Verify that all users are present
826		let names: Vec<&str> = users.iter().map(|u| u["name"].as_str().unwrap()).collect();
827		assert!(names.contains(&"Alice"));
828		assert!(names.contains(&"Bob"));
829		assert!(names.contains(&"Charlie"));
830	}
831
832	#[tokio::test]
833	async fn test_query_users_pagination_with_first() {
834		// Arrange
835		let storage = UserStorage::new();
836		for i in 0..10 {
837			storage
838				.add_user(User {
839					id: ID::from(format!("user-{}", i)),
840					name: format!("User{}", i),
841					email: format!("user{}@example.com", i),
842					active: true,
843				})
844				.await;
845		}
846		let schema = create_schema(storage);
847
848		// Act: request only 3 users
849		let query = r#"{ users(first: 3) { id } }"#;
850		let result = schema.execute(query).await;
851
852		// Assert
853		assert!(result.errors.is_empty());
854		let data = result.data.into_json().unwrap();
855		let users = data["users"].as_array().unwrap();
856		assert_eq!(users.len(), 3);
857	}
858
859	#[tokio::test]
860	async fn test_query_users_pagination_with_offset() {
861		// Arrange
862		let storage = UserStorage::new();
863		for i in 0..5 {
864			storage
865				.add_user(User {
866					id: ID::from(format!("user-{}", i)),
867					name: format!("User{}", i),
868					email: format!("user{}@example.com", i),
869					active: true,
870				})
871				.await;
872		}
873		let schema = create_schema(storage);
874
875		// Act: skip 3, take 10 -> should get 2
876		let query = r#"{ users(first: 10, offset: 3) { id } }"#;
877		let result = schema.execute(query).await;
878
879		// Assert
880		assert!(result.errors.is_empty());
881		let data = result.data.into_json().unwrap();
882		let users = data["users"].as_array().unwrap();
883		assert_eq!(users.len(), 2);
884	}
885
886	#[tokio::test]
887	async fn test_query_users_enforces_max_page_size() {
888		// Arrange
889		let storage = UserStorage::new();
890		for i in 0..150 {
891			storage
892				.add_user(User {
893					id: ID::from(format!("user-{}", i)),
894					name: format!("User{}", i),
895					email: format!("user{}@example.com", i),
896					active: true,
897				})
898				.await;
899		}
900		let schema = create_schema(storage);
901
902		// Act: request 500 users but max is 100
903		let query = r#"{ users(first: 500) { id } }"#;
904		let result = schema.execute(query).await;
905
906		// Assert: clamped to max page size
907		assert!(result.errors.is_empty());
908		let data = result.data.into_json().unwrap();
909		let users = data["users"].as_array().unwrap();
910		assert_eq!(users.len(), DEFAULT_MAX_PAGE_SIZE);
911	}
912
913	#[tokio::test]
914	async fn test_create_user_validates_empty_name() {
915		// Arrange
916		let storage = UserStorage::new();
917		let schema = create_schema(storage);
918
919		// Act
920		let query = r#"
921			mutation {
922				createUser(input: { name: "   ", email: "test@example.com" }) {
923					id
924				}
925			}
926		"#;
927		let result = schema.execute(query).await;
928
929		// Assert
930		assert!(
931			!result.errors.is_empty(),
932			"expected validation error for empty name"
933		);
934	}
935
936	#[tokio::test]
937	async fn test_create_user_validates_invalid_email() {
938		// Arrange
939		let storage = UserStorage::new();
940		let schema = create_schema(storage);
941
942		// Act
943		let query = r#"
944			mutation {
945				createUser(input: { name: "Alice", email: "not-an-email" }) {
946					id
947				}
948			}
949		"#;
950		let result = schema.execute(query).await;
951
952		// Assert
953		assert!(
954			!result.errors.is_empty(),
955			"expected validation error for invalid email"
956		);
957	}
958
959	#[tokio::test]
960	async fn test_validate_query_rejects_oversized_query() {
961		// Arrange
962		let limits = QueryLimits::full(10, 100, 100, 200); // 100 byte limit
963
964		// Act
965		let long_query = "{ ".to_string() + &"a ".repeat(100) + "}";
966		let result = validate_query(&long_query, &limits);
967
968		// Assert
969		assert!(result.is_err());
970		assert!(result.unwrap_err().contains("exceeds maximum"));
971	}
972
973	#[tokio::test]
974	async fn test_validate_query_accepts_normal_query() {
975		// Arrange
976		let limits = QueryLimits::default();
977
978		// Act
979		let result = validate_query("{ users { id name } }", &limits);
980
981		// Assert
982		assert!(result.is_ok());
983	}
984
985	#[tokio::test]
986	async fn test_mutation_update_user_status() {
987		let storage = UserStorage::new();
988		let user = User {
989			id: ID::from("update-test-id"),
990			name: "David".to_string(),
991			email: "david@example.com".to_string(),
992			active: true,
993		};
994		storage.add_user(user).await;
995
996		let schema = create_schema(storage);
997
998		let query = r#"
999            mutation {
1000                updateUserStatus(id: "update-test-id", active: false) {
1001                    id
1002                    name
1003                    active
1004                }
1005            }
1006        "#;
1007
1008		let result = schema.execute(query).await;
1009		let data = result.data.into_json().unwrap();
1010		assert_eq!(data["updateUserStatus"]["id"], "update-test-id");
1011		assert!(!data["updateUserStatus"]["active"].as_bool().unwrap());
1012	}
1013
1014	#[tokio::test]
1015	async fn test_mutation_update_nonexistent_user() {
1016		let storage = UserStorage::new();
1017		let schema = create_schema(storage);
1018
1019		let query = r#"
1020            mutation {
1021                updateUserStatus(id: "does-not-exist", active: false) {
1022                    id
1023                    name
1024                }
1025            }
1026        "#;
1027
1028		let result = schema.execute(query).await;
1029		let data = result.data.into_json().unwrap();
1030		assert!(data["updateUserStatus"].is_null());
1031	}
1032
1033	#[tokio::test]
1034	async fn test_user_object_fields() {
1035		let user = User {
1036			id: ID::from("field-test-id"),
1037			name: "Eve".to_string(),
1038			email: "eve@example.com".to_string(),
1039			active: false,
1040		};
1041
1042		// Test direct field access
1043		assert_eq!(user.id.to_string(), "field-test-id");
1044		assert_eq!(user.name, "Eve");
1045		assert_eq!(user.email, "eve@example.com");
1046		assert!(!user.active);
1047	}
1048
1049	#[tokio::test]
1050	async fn test_user_storage_add_get() {
1051		let storage = UserStorage::new();
1052
1053		let user = User {
1054			id: ID::from("storage-test-1"),
1055			name: "Frank".to_string(),
1056			email: "frank@example.com".to_string(),
1057			active: true,
1058		};
1059
1060		storage.add_user(user.clone()).await;
1061
1062		let retrieved = storage.get_user("storage-test-1").await;
1063		let retrieved = retrieved.unwrap();
1064		assert_eq!(retrieved.id.to_string(), "storage-test-1");
1065		assert_eq!(retrieved.name, "Frank");
1066		assert_eq!(retrieved.email, "frank@example.com");
1067		assert!(retrieved.active);
1068	}
1069
1070	#[tokio::test]
1071	async fn test_user_storage_list() {
1072		let storage = UserStorage::new();
1073
1074		// Initially empty
1075		let users = storage.list_users().await;
1076		assert_eq!(users.len(), 0);
1077
1078		// Add users
1079		storage
1080			.add_user(User {
1081				id: ID::from("list-1"),
1082				name: "User1".to_string(),
1083				email: "user1@example.com".to_string(),
1084				active: true,
1085			})
1086			.await;
1087
1088		storage
1089			.add_user(User {
1090				id: ID::from("list-2"),
1091				name: "User2".to_string(),
1092				email: "user2@example.com".to_string(),
1093				active: false,
1094			})
1095			.await;
1096
1097		let users = storage.list_users().await;
1098		assert_eq!(users.len(), 2);
1099	}
1100
1101	#[tokio::test]
1102	async fn test_create_schema_with_data() {
1103		let storage = UserStorage::new();
1104		storage
1105			.add_user(User {
1106				id: ID::from("pre-existing"),
1107				name: "PreExisting".to_string(),
1108				email: "preexisting@example.com".to_string(),
1109				active: true,
1110			})
1111			.await;
1112
1113		let schema = create_schema(storage);
1114
1115		// Verify schema can query pre-existing data
1116		let query = r#"
1117            {
1118                user(id: "pre-existing") {
1119                    name
1120                }
1121            }
1122        "#;
1123
1124		let result = schema.execute(query).await;
1125		let data = result.data.into_json().unwrap();
1126		assert_eq!(data["user"]["name"], "PreExisting");
1127	}
1128
1129	#[tokio::test]
1130	async fn test_graphql_error_types() {
1131		let err1 = GraphQLError::Schema("test schema error".to_string());
1132		assert!(err1.to_string().contains("Schema error"));
1133
1134		let err2 = GraphQLError::Resolver("test resolver error".to_string());
1135		assert!(err2.to_string().contains("Resolver error"));
1136
1137		let err3 = GraphQLError::NotFound("test item".to_string());
1138		assert!(err3.to_string().contains("Not found"));
1139	}
1140
1141	#[tokio::test]
1142	async fn test_query_depth_limit_rejects_deep_query() {
1143		// Arrange: depth limit of 1 only allows top-level fields
1144		let storage = UserStorage::new();
1145		let limits = QueryLimits::new(1, 1000);
1146		let schema = create_schema_with_limits(storage, limits);
1147
1148		// Act: query with nested selection exceeds depth limit of 1
1149		let query = r#"
1150			{
1151				users {
1152					name
1153				}
1154			}
1155		"#;
1156		let result = schema.execute(query).await;
1157
1158		// Assert: should produce a depth-limit error
1159		assert!(
1160			!result.errors.is_empty(),
1161			"expected depth limit error but query succeeded"
1162		);
1163		let error_message = &result.errors[0].message;
1164		assert!(
1165			error_message.to_lowercase().contains("too deep"),
1166			"expected depth-limit message, got: {error_message}"
1167		);
1168	}
1169
1170	#[tokio::test]
1171	async fn test_query_depth_limit_allows_shallow_query() {
1172		// Arrange
1173		let storage = UserStorage::new();
1174		let limits = QueryLimits::new(10, 1000);
1175		let schema = create_schema_with_limits(storage, limits);
1176
1177		// Act
1178		let query = r#"{ hello(name: "Test") }"#;
1179		let result = schema.execute(query).await;
1180
1181		// Assert
1182		assert!(
1183			result.errors.is_empty(),
1184			"expected no errors for shallow query"
1185		);
1186		let data = result.data.into_json().unwrap();
1187		assert_eq!(data["hello"], "Hello, Test!");
1188	}
1189
1190	#[tokio::test]
1191	async fn test_query_complexity_limit_rejects_complex_query() {
1192		// Arrange: very low complexity limit
1193		let storage = UserStorage::new();
1194		let limits = QueryLimits::new(100, 1);
1195		let schema = create_schema_with_limits(storage, limits);
1196
1197		// Act: query with multiple fields exceeds complexity of 1
1198		let query = r#"
1199			{
1200				users {
1201					id
1202					name
1203					email
1204					active
1205				}
1206			}
1207		"#;
1208		let result = schema.execute(query).await;
1209
1210		// Assert: should produce a complexity-limit error
1211		assert!(
1212			!result.errors.is_empty(),
1213			"expected complexity limit error but query succeeded"
1214		);
1215		let error_message = &result.errors[0].message;
1216		assert!(
1217			error_message.to_lowercase().contains("complex"),
1218			"expected complexity-limit message, got: {error_message}"
1219		);
1220	}
1221
1222	#[tokio::test]
1223	async fn test_query_limits_default_values() {
1224		// Arrange / Act
1225		let limits = QueryLimits::default();
1226
1227		// Assert
1228		assert_eq!(limits.max_depth, DEFAULT_MAX_QUERY_DEPTH);
1229		assert_eq!(limits.max_complexity, DEFAULT_MAX_QUERY_COMPLEXITY);
1230	}
1231
1232	#[tokio::test]
1233	async fn test_create_schema_with_custom_limits() {
1234		// Arrange
1235		let storage = UserStorage::new();
1236		let limits = QueryLimits::new(20, 500);
1237		let schema = create_schema_with_limits(storage, limits);
1238
1239		// Act: simple query within limits
1240		let query = r#"{ hello }"#;
1241		let result = schema.execute(query).await;
1242
1243		// Assert
1244		assert!(result.errors.is_empty());
1245		let data = result.data.into_json().unwrap();
1246		assert_eq!(data["hello"], "Hello, World!");
1247	}
1248
1249	#[tokio::test]
1250	async fn test_analyzer_extension_present() {
1251		// Arrange
1252		let storage = UserStorage::new();
1253		let schema = create_schema(storage);
1254
1255		// Act: execute query and check for complexity/depth in extensions
1256		let query = r#"{ hello(name: "Analyzer") }"#;
1257		let result = schema.execute(query).await;
1258
1259		// Assert: Analyzer extension adds complexity/depth to response extensions
1260		assert!(result.errors.is_empty());
1261		assert!(
1262			!result.extensions.is_empty(),
1263			"expected Analyzer extension data in response"
1264		);
1265	}
1266
1267	#[rstest::rstest]
1268	#[case(
1269		"{\n  user(name: \"hello \\\"world\\\"\") {\n    id\n  }\n}",
1270		2,
1271		"escaped quotes inside string should not affect field count"
1272	)]
1273	#[case(
1274		"{\n  user(name: \"hello \\\\\\\"end\") {\n    id\n    name\n  }\n}",
1275		3,
1276		"escaped backslash before quote should correctly toggle string state"
1277	)]
1278	#[case(
1279		"{\n  user(name: \"no escapes\") {\n    id\n  }\n}",
1280		2,
1281		"string without escapes should count fields normally"
1282	)]
1283	#[case(
1284		"{\n  user(name: \"a\\\"b\\\"c\") {\n    id\n    name\n    email\n  }\n}",
1285		4,
1286		"multiple escaped quotes in a single string literal"
1287	)]
1288	fn test_count_query_fields_with_escaped_strings(
1289		#[case] query: &str,
1290		#[case] expected: usize,
1291		#[case] description: &str,
1292	) {
1293		// Arrange — query and expected count provided by rstest parametrization
1294
1295		// Act
1296		let count = count_query_fields(query);
1297
1298		// Assert
1299		assert_eq!(count, expected, "{}", description);
1300	}
1301
1302	#[rstest::rstest]
1303	#[case(
1304		"{ users { id name email } }",
1305		4,
1306		"parent field plus multiple fields on same line within sub-selection"
1307	)]
1308	#[case(
1309		"{ users { id } }",
1310		2,
1311		"parent field plus single field on same line within sub-selection"
1312	)]
1313	#[case(
1314		"{ users { id name } posts { title body } }",
1315		6,
1316		"two parent fields plus their sub-selection fields on same line"
1317	)]
1318	fn test_count_query_fields_same_line(
1319		#[case] query: &str,
1320		#[case] expected: usize,
1321		#[case] description: &str,
1322	) {
1323		// Arrange — query and expected count provided by rstest parametrization
1324
1325		// Act
1326		let count = count_query_fields(query);
1327
1328		// Assert
1329		assert_eq!(count, expected, "{}", description);
1330	}
1331
1332	#[rstest::rstest]
1333	#[case(
1334		"{ ... on User { id name } }",
1335		2,
1336		"inline fragment type condition should not be counted as a field"
1337	)]
1338	#[case(
1339		"{ users { ... on Admin { role } ... on Member { level } } }",
1340		3,
1341		"multiple inline fragments: users + role + level, type names excluded"
1342	)]
1343	fn test_count_query_fields_inline_fragments(
1344		#[case] query: &str,
1345		#[case] expected: usize,
1346		#[case] description: &str,
1347	) {
1348		// Arrange — query and expected count provided by rstest parametrization
1349
1350		// Act
1351		let count = count_query_fields(query);
1352
1353		// Assert
1354		assert_eq!(count, expected, "{}", description);
1355	}
1356
1357	#[rstest::rstest]
1358	#[case(
1359		"{ user(bio: \"\"\"multi\nline\"\"\") { id } }",
1360		2,
1361		"block string argument content should not be counted as fields"
1362	)]
1363	#[case(
1364		"{ user(desc: \"\"\"has identifier inside\"\"\") { id name } }",
1365		3,
1366		"block string with identifier-like content should not affect field count"
1367	)]
1368	fn test_count_query_fields_block_strings(
1369		#[case] query: &str,
1370		#[case] expected: usize,
1371		#[case] description: &str,
1372	) {
1373		// Arrange — query and expected count provided by rstest parametrization
1374
1375		// Act
1376		let count = count_query_fields(query);
1377
1378		// Assert
1379		assert_eq!(count, expected, "{}", description);
1380	}
1381
1382	#[tokio::test]
1383	async fn test_exceeds_max_chars_short_circuits() {
1384		// Arrange / Act / Assert
1385		assert!(!exceeds_max_chars("hello", 5)); // exactly at limit
1386		assert!(exceeds_max_chars("hello!", 5)); // one over
1387		assert!(!exceeds_max_chars("", 0)); // empty at zero limit
1388		assert!(exceeds_max_chars("a", 0)); // single char over zero limit
1389	}
1390
1391	#[tokio::test]
1392	async fn test_create_user_accepts_multibyte_name_within_limit() {
1393		// Arrange: CJK characters are multi-byte in UTF-8 but each is 1 char
1394		let storage = UserStorage::new();
1395		let schema = create_schema(storage);
1396
1397		// 4 CJK characters = 4 chars (well under MAX_NAME_LENGTH of 100)
1398		let query = r#"
1399			mutation {
1400				createUser(input: { name: "田中太郎", email: "tanaka@example.com" }) {
1401					name
1402				}
1403			}
1404		"#;
1405
1406		// Act
1407		let result = schema.execute(query).await;
1408
1409		// Assert: should succeed because character count is within limit
1410		assert!(
1411			result.errors.is_empty(),
1412			"expected success for multi-byte name within limit, got: {:?}",
1413			result.errors
1414		);
1415		let data = result.data.into_json().unwrap();
1416		assert_eq!(data["createUser"]["name"], "田中太郎");
1417	}
1418
1419	#[tokio::test]
1420	async fn test_create_user_rejects_multibyte_name_over_limit() {
1421		// Arrange: build a name with exactly MAX_NAME_LENGTH + 1 CJK characters
1422		let storage = UserStorage::new();
1423		let schema = create_schema(storage);
1424
1425		let long_name: String = "あ".repeat(MAX_NAME_LENGTH + 1);
1426		let query = format!(
1427			r#"mutation {{ createUser(input: {{ name: "{}", email: "test@example.com" }}) {{ id }} }}"#,
1428			long_name
1429		);
1430
1431		// Act
1432		let result = schema.execute(&query).await;
1433
1434		// Assert: should reject because character count exceeds limit
1435		assert!(
1436			!result.errors.is_empty(),
1437			"expected validation error for name exceeding {} characters",
1438			MAX_NAME_LENGTH
1439		);
1440	}
1441
1442	#[tokio::test]
1443	async fn test_create_user_accepts_emoji_name_at_limit() {
1444		// Arrange: emoji are multi-byte in UTF-8 but each is 1 char count
1445		let storage = UserStorage::new();
1446		let schema = create_schema(storage);
1447
1448		// Exactly MAX_NAME_LENGTH emoji characters
1449		// Note: name validation only allows alphanumeric, spaces, underscores,
1450		// hyphens, and dots, so emoji will be rejected by the character check,
1451		// not the length check. We test length via CJK instead.
1452		// Here we verify that a name at exactly the limit passes length validation.
1453		let name_at_limit: String = "a".repeat(MAX_NAME_LENGTH);
1454		let query = format!(
1455			r#"mutation {{ createUser(input: {{ name: "{}", email: "test@example.com" }}) {{ id }} }}"#,
1456			name_at_limit
1457		);
1458
1459		// Act
1460		let result = schema.execute(&query).await;
1461
1462		// Assert: should succeed (exactly at limit)
1463		assert!(
1464			result.errors.is_empty(),
1465			"expected success for name at exactly the limit, got: {:?}",
1466			result.errors
1467		);
1468	}
1469}