graphql-http 2.0.3__tar.gz → 2.0.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {graphql_http-2.0.3 → graphql_http-2.0.5}/PKG-INFO +1 -1
  2. graphql_http-2.0.5/VERSION +1 -0
  3. {graphql_http-2.0.3 → graphql_http-2.0.5}/examples/advanced_server.py +31 -33
  4. {graphql_http-2.0.3 → graphql_http-2.0.5}/examples/auth_server.py +4 -4
  5. {graphql_http-2.0.3 → graphql_http-2.0.5}/examples/graphql_api_server.py +55 -56
  6. {graphql_http-2.0.3 → graphql_http-2.0.5}/graphql_http/server.py +253 -19
  7. {graphql_http-2.0.3 → graphql_http-2.0.5}/graphql_http.egg-info/PKG-INFO +1 -1
  8. {graphql_http-2.0.3 → graphql_http-2.0.5}/tests/test_auth.py +26 -4
  9. graphql_http-2.0.3/VERSION +0 -1
  10. {graphql_http-2.0.3 → graphql_http-2.0.5}/.coverage +0 -0
  11. {graphql_http-2.0.3 → graphql_http-2.0.5}/.github/workflows/python-package.yml +0 -0
  12. {graphql_http-2.0.3 → graphql_http-2.0.5}/.gitignore +0 -0
  13. {graphql_http-2.0.3 → graphql_http-2.0.5}/LICENSE +0 -0
  14. {graphql_http-2.0.3 → graphql_http-2.0.5}/README.md +0 -0
  15. {graphql_http-2.0.3 → graphql_http-2.0.5}/examples/README.md +0 -0
  16. {graphql_http-2.0.3 → graphql_http-2.0.5}/examples/basic_server.py +0 -0
  17. {graphql_http-2.0.3 → graphql_http-2.0.5}/graphql_http/__init__.py +0 -0
  18. {graphql_http-2.0.3 → graphql_http-2.0.5}/graphql_http/error.py +0 -0
  19. {graphql_http-2.0.3 → graphql_http-2.0.5}/graphql_http/graphiql/index.html +0 -0
  20. {graphql_http-2.0.3 → graphql_http-2.0.5}/graphql_http/helpers.py +0 -0
  21. {graphql_http-2.0.3 → graphql_http-2.0.5}/graphql_http.egg-info/SOURCES.txt +0 -0
  22. {graphql_http-2.0.3 → graphql_http-2.0.5}/graphql_http.egg-info/dependency_links.txt +0 -0
  23. {graphql_http-2.0.3 → graphql_http-2.0.5}/graphql_http.egg-info/requires.txt +0 -0
  24. {graphql_http-2.0.3 → graphql_http-2.0.5}/graphql_http.egg-info/top_level.txt +0 -0
  25. {graphql_http-2.0.3 → graphql_http-2.0.5}/pyproject.toml +0 -0
  26. {graphql_http-2.0.3 → graphql_http-2.0.5}/setup.cfg +0 -0
  27. {graphql_http-2.0.3 → graphql_http-2.0.5}/test_server.py +0 -0
  28. {graphql_http-2.0.3 → graphql_http-2.0.5}/tests/__init__.py +0 -0
  29. {graphql_http-2.0.3 → graphql_http-2.0.5}/tests/app.py +0 -0
  30. {graphql_http-2.0.3 → graphql_http-2.0.5}/tests/app_federation.py +0 -0
  31. {graphql_http-2.0.3 → graphql_http-2.0.5}/tests/conftest.py +0 -0
  32. {graphql_http-2.0.3 → graphql_http-2.0.5}/tests/test_cors.py +0 -0
  33. {graphql_http-2.0.3 → graphql_http-2.0.5}/tests/test_federation.py +0 -0
  34. {graphql_http-2.0.3 → graphql_http-2.0.5}/tests/test_graphql_api.py +0 -0
  35. {graphql_http-2.0.3 → graphql_http-2.0.5}/tests/test_server.py +0 -0
  36. {graphql_http-2.0.3 → graphql_http-2.0.5}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphql_http
3
- Version: 2.0.3
3
+ Version: 2.0.5
4
4
  Summary: HTTP Server for GraphQL.
5
5
  Author-email: Robert Parker <rob@parob.com>
6
6
  License: MIT
@@ -0,0 +1 @@
1
+ 2.0.5
@@ -12,8 +12,6 @@ This example demonstrates advanced features including:
12
12
  """
13
13
 
14
14
  import time
15
- import asyncio
16
- from typing import Any, Dict, List, Optional
17
15
  from graphql import (
18
16
  GraphQLSchema,
19
17
  GraphQLObjectType,
@@ -33,15 +31,15 @@ from graphql_http import GraphQLHTTP
33
31
  # Custom execution context for performance monitoring
34
32
  class PerformanceExecutionContext(ExecutionContext):
35
33
  """Custom execution context that tracks performance metrics."""
36
-
34
+
37
35
  def __init__(self, *args, **kwargs):
38
36
  super().__init__(*args, **kwargs)
39
37
  self.start_time = time.time()
40
38
  self.field_count = 0
41
-
39
+
42
40
  def __enter__(self):
43
41
  return super().__enter__()
44
-
42
+
45
43
  def __exit__(self, exc_type, exc_val, exc_tb):
46
44
  execution_time = time.time() - self.start_time
47
45
  print(f"Query executed in {execution_time:.3f}s with {self.field_count} fields")
@@ -51,20 +49,20 @@ class PerformanceExecutionContext(ExecutionContext):
51
49
  # Custom context class
52
50
  class RequestContext:
53
51
  """Custom context that provides request-specific information."""
54
-
52
+
55
53
  def __init__(self, request=None):
56
54
  self.request = request
57
55
  self.start_time = time.time()
58
56
  self.user_id = None
59
57
  self.cache = {}
60
-
58
+
61
59
  def get_user_id(self):
62
60
  """Extract user ID from request headers (simplified)."""
63
61
  if self.request and not self.user_id:
64
62
  # In real apps, this would decode JWT or session
65
63
  self.user_id = self.request.headers.get("X-User-ID", "anonymous")
66
64
  return self.user_id
67
-
65
+
68
66
  def get_elapsed_time(self):
69
67
  """Get elapsed time since request started."""
70
68
  return time.time() - self.start_time
@@ -88,19 +86,19 @@ posts_data = [
88
86
  def performance_middleware(next_fn, root, info, **args):
89
87
  """Middleware that measures field resolution time."""
90
88
  field_start = time.time()
91
-
89
+
92
90
  # Track field count in execution context
93
91
  if hasattr(info.context, 'field_count'):
94
92
  info.context.field_count += 1
95
-
93
+
96
94
  try:
97
95
  result = next_fn(root, info, **args)
98
96
  field_time = time.time() - field_start
99
-
97
+
100
98
  # Log slow fields
101
99
  if field_time > 0.1: # 100ms threshold
102
100
  print(f"Slow field detected: {info.field_name} took {field_time:.3f}s")
103
-
101
+
104
102
  return result
105
103
  except Exception as e:
106
104
  field_time = time.time() - field_start
@@ -112,40 +110,40 @@ def caching_middleware(next_fn, root, info, **args):
112
110
  """Simple in-memory caching middleware."""
113
111
  # Create cache key
114
112
  cache_key = f"{info.field_name}:{hash(str(args))}"
115
-
113
+
116
114
  # Check if we have cached result
117
115
  if hasattr(info.context, 'cache') and cache_key in info.context.cache:
118
116
  print(f"Cache hit for {info.field_name}")
119
117
  return info.context.cache[cache_key]
120
-
118
+
121
119
  # Execute and cache result
122
120
  result = next_fn(root, info, **args)
123
-
121
+
124
122
  if hasattr(info.context, 'cache'):
125
123
  info.context.cache[cache_key] = result
126
124
  print(f"Cached result for {info.field_name}")
127
-
125
+
128
126
  return result
129
127
 
130
128
 
131
129
  def auth_middleware(next_fn, root, info, **args):
132
130
  """Authentication middleware for protected fields."""
133
131
  field_name = info.field_name
134
-
132
+
135
133
  # Check if field requires authentication
136
134
  protected_fields = ["adminData", "deletePost", "promoteUser"]
137
135
  if field_name in protected_fields:
138
136
  user_id = info.context.get_user_id() if hasattr(info.context, 'get_user_id') else None
139
137
  if not user_id or user_id == "anonymous":
140
138
  raise GraphQLError(f"Authentication required for field: {field_name}")
141
-
139
+
142
140
  # Check admin role for admin fields
143
141
  admin_fields = ["adminData", "promoteUser"]
144
142
  if field_name in admin_fields:
145
143
  # In real apps, check user role from database/JWT
146
144
  if not user_id.startswith("admin"):
147
145
  raise GraphQLError(f"Admin access required for field: {field_name}")
148
-
146
+
149
147
  return next_fn(root, info, **args)
150
148
 
151
149
 
@@ -162,11 +160,11 @@ def get_user_by_id(obj, info, user_id):
162
160
  """Get user by ID with validation."""
163
161
  if user_id <= 0:
164
162
  raise GraphQLError("User ID must be positive")
165
-
163
+
166
164
  user = next((u for u in users_data if u["id"] == user_id), None)
167
165
  if not user:
168
166
  raise GraphQLError(f"User with ID {user_id} not found")
169
-
167
+
170
168
  return user
171
169
 
172
170
 
@@ -325,27 +323,27 @@ def create_custom_context(request):
325
323
  def main():
326
324
  """Run the advanced GraphQL server."""
327
325
  print("Starting advanced GraphQL server...")
328
-
326
+
329
327
  # Create server with advanced configuration
330
328
  server = GraphQLHTTP(
331
329
  schema=schema,
332
330
  serve_graphiql=True,
333
331
  allow_cors=True,
334
332
  health_path="/health",
335
-
333
+
336
334
  # Add middleware stack
337
335
  middleware=[
338
336
  auth_middleware, # Authentication first
339
337
  performance_middleware, # Then performance monitoring
340
338
  caching_middleware, # Finally caching
341
339
  ],
342
-
340
+
343
341
  # Custom execution context
344
342
  execution_context_class=PerformanceExecutionContext,
345
-
343
+
346
344
  # Custom context factory
347
345
  context_value=lambda: create_custom_context(None),
348
-
346
+
349
347
  graphiql_default_query="""
350
348
  # Try these queries to see advanced features:
351
349
 
@@ -360,17 +358,17 @@ def main():
360
358
  likes
361
359
  }
362
360
  }
363
-
361
+
364
362
  # Context information
365
363
  contextInfo {
366
364
  elapsedTime
367
365
  userId
368
366
  cacheSize
369
367
  }
370
-
368
+
371
369
  # This will be slow and trigger performance warning
372
370
  # slowField
373
-
371
+
374
372
  # This will demonstrate error handling
375
373
  # errorField
376
374
  }
@@ -384,7 +382,7 @@ def main():
384
382
  # }
385
383
  """.strip(),
386
384
  )
387
-
385
+
388
386
  print("Advanced server features enabled:")
389
387
  print(" ✓ Performance monitoring middleware")
390
388
  print(" ✓ Simple in-memory caching")
@@ -392,17 +390,17 @@ def main():
392
390
  print(" ✓ Custom execution context")
393
391
  print(" ✓ Custom request context")
394
392
  print(" ✓ Error handling and validation")
395
-
393
+
396
394
  print(f"\nTesting tips:")
397
395
  print(f" • Set 'X-User-ID: admin123' header for admin access")
398
396
  print(f" • Watch console for performance metrics")
399
397
  print(f" • Try the slowField and errorField for testing")
400
398
  print(f" • Run same query twice to see caching in action")
401
-
399
+
402
400
  print(f"\nEndpoints:")
403
401
  print(f" GraphiQL: http://localhost:8000/graphql")
404
402
  print(f" Health: http://localhost:8000/health")
405
-
403
+
406
404
  # Run the server
407
405
  server.run(host="0.0.0.0", port=8000)
408
406
 
@@ -221,10 +221,10 @@ def main():
221
221
  print("\nAuthentication disabled for development!")
222
222
  print("Set ENABLE_AUTH=true to enable authentication")
223
223
 
224
- print(f"\nEndpoints:")
225
- print(f" GraphiQL: http://localhost:8000/graphql")
226
- print(f" Health: http://localhost:8000/health")
227
- print(f" API: http://localhost:8000/graphql")
224
+ print("\nEndpoints:")
225
+ print(" GraphiQL: http://localhost:8000/graphql")
226
+ print(" Health: http://localhost:8000/health")
227
+ print(" API: http://localhost:8000/graphql")
228
228
 
229
229
  # Run the server
230
230
  server.run(host="0.0.0.0", port=8000)
@@ -11,9 +11,8 @@ Install with: pip install graphql-api
11
11
  """
12
12
 
13
13
  try:
14
- from graphql_api import GraphQLAPI, field, type
14
+ from graphql_api import GraphQLAPI, field
15
15
  from graphql_api.context import GraphQLContext
16
- from graphql_api.mapper import GraphQLMetaKey
17
16
  except ImportError:
18
17
  print("This example requires graphql-api to be installed.")
19
18
  print("Install with: pip install graphql-api")
@@ -75,52 +74,52 @@ api = GraphQLAPI()
75
74
  @api.type
76
75
  class AuthorType:
77
76
  """Author type with automatic field resolution."""
78
-
77
+
79
78
  @field
80
79
  def id(self) -> int:
81
- return self.id
82
-
80
+ return self._id
81
+
83
82
  @field
84
83
  def name(self) -> str:
85
- return self.name
86
-
84
+ return self._name
85
+
87
86
  @field
88
87
  def email(self) -> str:
89
- return self.email
90
-
88
+ return self._email
89
+
91
90
  @field
92
- def posts(self) -> List["PostType"]:
91
+ def posts(self) -> List[Post]:
93
92
  """Get all posts by this author."""
94
- return [post for post in posts if post.author_id == self.id]
93
+ return [post for post in posts if post.author_id == self._id]
95
94
 
96
95
 
97
96
  @api.type
98
97
  class PostType:
99
98
  """Post type with relationships."""
100
-
99
+
101
100
  @field
102
101
  def id(self) -> int:
103
- return self.id
104
-
102
+ return self._id
103
+
105
104
  @field
106
105
  def title(self) -> str:
107
- return self.title
108
-
106
+ return self._title
107
+
109
108
  @field
110
109
  def content(self) -> str:
111
- return self.content
112
-
110
+ return self._content
111
+
113
112
  @field
114
113
  def published(self) -> bool:
115
- return self.published
116
-
114
+ return self._published
115
+
117
116
  @field
118
117
  def author(self) -> Optional[AuthorType]:
119
118
  """Get the author of this post."""
120
- return next((author for author in authors if author.id == self.author_id), None)
121
-
119
+ return next((author for author in authors if author.id == self._author_id), None)
120
+
122
121
  @field
123
- def comments(self) -> List["CommentType"]:
122
+ def comments(self) -> List[Comment]:
124
123
  """Get all comments for this post."""
125
124
  return [comment for comment in comments if comment.post_id == self.id]
126
125
 
@@ -128,56 +127,56 @@ class PostType:
128
127
  @api.type
129
128
  class CommentType:
130
129
  """Comment type."""
131
-
130
+
132
131
  @field
133
132
  def id(self) -> int:
134
- return self.id
135
-
133
+ return self._id
134
+
136
135
  @field
137
136
  def author_name(self) -> str:
138
- return self.author_name
139
-
137
+ return self._author_name
138
+
140
139
  @field
141
140
  def content(self) -> str:
142
- return self.content
143
-
141
+ return self._content
142
+
144
143
  @field
145
144
  def post(self) -> Optional[PostType]:
146
145
  """Get the post this comment belongs to."""
147
- return next((post for post in posts if post.id == self.post_id), None)
146
+ return next((post for post in posts if post.id == self._post_id), None)
148
147
 
149
148
 
150
149
  @api.type(is_root_type=True)
151
150
  class Query:
152
151
  """Root Query type."""
153
-
152
+
154
153
  @field
155
154
  def hello(self, name: str = "World") -> str:
156
155
  """Simple hello field for testing."""
157
156
  return f"Hello, {name}!"
158
-
157
+
159
158
  @field
160
159
  def authors(self) -> List[AuthorType]:
161
160
  """Get all authors."""
162
161
  return authors
163
-
162
+
164
163
  @field
165
164
  def author(self, author_id: int) -> Optional[AuthorType]:
166
165
  """Get an author by ID."""
167
166
  return next((author for author in authors if author.id == author_id), None)
168
-
167
+
169
168
  @field
170
169
  def posts(self, published_only: bool = False) -> List[PostType]:
171
170
  """Get all posts, optionally filter by published status."""
172
171
  if published_only:
173
172
  return [post for post in posts if post.published]
174
173
  return posts
175
-
174
+
176
175
  @field
177
176
  def post(self, post_id: int) -> Optional[PostType]:
178
177
  """Get a post by ID."""
179
178
  return next((post for post in posts if post.id == post_id), None)
180
-
179
+
181
180
  @field
182
181
  def search_posts(self, query: str) -> List[PostType]:
183
182
  """Search posts by title or content."""
@@ -191,7 +190,7 @@ class Query:
191
190
  @api.type
192
191
  class Mutation:
193
192
  """Root Mutation type."""
194
-
193
+
195
194
  @field
196
195
  def create_post(self, title: str, content: str, author_id: int, published: bool = False) -> PostType:
197
196
  """Create a new post."""
@@ -204,30 +203,30 @@ class Mutation:
204
203
  )
205
204
  posts.append(new_post)
206
205
  return new_post
207
-
206
+
208
207
  @field
209
208
  def update_post(self, post_id: int, title: Optional[str] = None, content: Optional[str] = None, published: Optional[bool] = None) -> Optional[PostType]:
210
209
  """Update an existing post."""
211
210
  post = next((p for p in posts if p.id == post_id), None)
212
211
  if not post:
213
212
  return None
214
-
213
+
215
214
  if title is not None:
216
215
  post.title = title
217
216
  if content is not None:
218
217
  post.content = content
219
218
  if published is not None:
220
219
  post.published = published
221
-
220
+
222
221
  return post
223
-
222
+
224
223
  @field
225
- def add_comment(self, post_id: int, author_name: str, content: str) -> Optional["CommentType"]:
224
+ def add_comment(self, post_id: int, author_name: str, content: str) -> Optional[Comment]:
226
225
  """Add a comment to a post."""
227
226
  post = next((p for p in posts if p.id == post_id), None)
228
227
  if not post:
229
228
  return None
230
-
229
+
231
230
  new_comment = Comment(
232
231
  id=max(comment.id for comment in comments) + 1,
233
232
  post_id=post_id,
@@ -247,12 +246,12 @@ def custom_middleware(next_fn, root, info, **args):
247
246
  # Log the field being accessed
248
247
  field_name = info.field_name
249
248
  print(f"Accessing field: {field_name}")
250
-
249
+
251
250
  # You could add authentication logic here
252
251
  # if field_name in ["sensitive_field"]:
253
252
  # # Check authentication
254
253
  # pass
255
-
254
+
256
255
  # Call the next resolver
257
256
  return next_fn(root, info, **args)
258
257
 
@@ -279,7 +278,7 @@ class ContextQuery:
279
278
  def main():
280
279
  """Run the GraphQL server with GraphQL-API integration."""
281
280
  print("Starting GraphQL server with GraphQL-API integration...")
282
-
281
+
283
282
  # Create server from the GraphQL API
284
283
  server = GraphQLHTTP.from_api(
285
284
  api=api,
@@ -294,7 +293,7 @@ def main():
294
293
  {
295
294
  # Simple greeting
296
295
  hello(name: "GraphQL API User")
297
-
296
+
298
297
  # Get all authors with their posts
299
298
  authors {
300
299
  id
@@ -311,7 +310,7 @@ def main():
311
310
  }
312
311
  }
313
312
  }
314
-
313
+
315
314
  # Get only published posts
316
315
  posts(publishedOnly: true) {
317
316
  id
@@ -321,7 +320,7 @@ def main():
321
320
  email
322
321
  }
323
322
  }
324
-
323
+
325
324
  # Search posts
326
325
  searchPosts(query: "GraphQL") {
327
326
  id
@@ -347,18 +346,18 @@ def main():
347
346
  # }
348
347
  """.strip(),
349
348
  )
350
-
349
+
351
350
  print("GraphQL-API server features:")
352
351
  print(" ✓ Automatic type inference from Python types")
353
352
  print(" ✓ Dataclass integration")
354
353
  print(" ✓ Relationship resolution")
355
354
  print(" ✓ Context injection")
356
355
  print(" ✓ Custom middleware support")
357
-
358
- print(f"\nEndpoints:")
359
- print(f" GraphiQL: http://localhost:8000/graphql")
360
- print(f" Health: http://localhost:8000/health")
361
-
356
+
357
+ print("\nEndpoints:")
358
+ print(" GraphiQL: http://localhost:8000/graphql")
359
+ print(" Health: http://localhost:8000/health")
360
+
362
361
  # Run the server
363
362
  server.run(host="0.0.0.0", port=8000)
364
363
 
@@ -3,9 +3,11 @@ import json
3
3
  import os
4
4
  from json import JSONDecodeError
5
5
  from typing import Any, Awaitable, Callable, Dict, List, Optional, Type, Union
6
+ from logging import getLogger
6
7
 
7
8
  import jwt
8
9
  import uvicorn
10
+
9
11
  from graphql import GraphQLError, ExecutionResult
10
12
  from graphql.execution.execute import ExecutionContext
11
13
  from graphql.type.schema import GraphQLSchema
@@ -33,6 +35,7 @@ try:
33
35
  except ImportError:
34
36
  GraphQLContext = None # type: ignore
35
37
 
38
+ logger = getLogger(__name__)
36
39
  # Constants
37
40
  GRAPHIQL_DIR = os.path.join(os.path.dirname(__file__), "graphiql")
38
41
  DEFAULT_HOST = "127.0.0.1"
@@ -317,36 +320,265 @@ class GraphQLHTTP:
317
320
  return PlainTextResponse("OK", headers=response_headers)
318
321
 
319
322
  def _check_introspection_only(self, data: Union[Dict, List]) -> bool:
320
- """Check if request contains only introspection queries.
323
+ """Check if request contains only introspection queries using GraphQL's built-in validation.
324
+
325
+ This approach uses GraphQL's execution preparation to determine if a query
326
+ is introspection-only by checking what fields would actually be resolved.
327
+
328
+ Args:
329
+ data: Request data (dict for single query, list for batched queries)
330
+
331
+ Returns:
332
+ True if all queries are introspection-only
333
+ """
334
+ try:
335
+ from graphql import parse, validate
336
+ from graphql.type.introspection import is_introspection_type
337
+ # Try to import execution context functions, fall back gracefully if not available
338
+ try:
339
+ from graphql.execution import build_execution_context
340
+ from graphql.execution.collect_fields import collect_fields
341
+ has_execution_context = True
342
+ except ImportError:
343
+ has_execution_context = False
344
+ except ImportError:
345
+ # Fallback to AST-based check if advanced GraphQL features not available
346
+ return self._check_introspection_only_ast(data)
347
+
348
+ # Handle batched queries
349
+ if isinstance(data, list):
350
+ return all(self._check_introspection_only(item) for item in data)
351
+
352
+ if not isinstance(data, dict) or 'query' not in data:
353
+ return False
354
+
355
+ query_str = data.get('query', '')
356
+ if not query_str or not isinstance(query_str, str):
357
+ return False
358
+
359
+ # Pre-validation security checks
360
+ query_stripped = query_str.strip()
361
+ if not query_stripped:
362
+ return False
363
+
364
+ # Basic syntax validation - ensure query looks structurally sound
365
+ if query_stripped.count('{') != query_stripped.count('}'):
366
+ return False
367
+ if query_stripped.count('(') != query_stripped.count(')'):
368
+ return False
369
+
370
+ try:
371
+ # Parse and validate the query - this will catch syntax errors
372
+ document = parse(query_str)
373
+ validation_errors = validate(self.schema, document)
374
+
375
+ # If query has validation errors, it's not a valid introspection query
376
+ if validation_errors:
377
+ return False
378
+
379
+ # If execution context is not available, fall back to AST method
380
+ if not has_execution_context:
381
+ return self._check_introspection_only_ast(data)
382
+
383
+ # Additional security checks before execution analysis
384
+ # Check if document contains only query operations (no mutations/subscriptions)
385
+ for definition in document.definitions:
386
+ if hasattr(definition, 'operation'):
387
+ # Only allow query operations for introspection bypass
388
+ if definition.operation != 'query':
389
+ return False
390
+
391
+ # Build execution context to analyze what fields would be resolved
392
+ try:
393
+ context_value = {}
394
+ variable_values = data.get('variables') or {}
395
+ operation_name = data.get('operationName')
396
+
397
+ exe_context = build_execution_context(
398
+ self.schema,
399
+ document,
400
+ context_value=context_value,
401
+ variable_values=variable_values,
402
+ operation_name=operation_name
403
+ )
404
+
405
+ # If execution context building fails, be conservative
406
+ if isinstance(exe_context, list): # List of GraphQLError
407
+ return False
408
+
409
+ # Get the operation
410
+ operation = exe_context.operation
411
+ if not operation or not operation.selection_set:
412
+ return False
413
+
414
+ # Collect the fields that would be executed
415
+ fields = collect_fields(
416
+ exe_context,
417
+ exe_context.schema.query_type,
418
+ operation.selection_set,
419
+ set(),
420
+ set()
421
+ )
422
+
423
+ # Strict validation: only allow official introspection fields
424
+ official_introspection_fields = {'__schema', '__type', '__typename'}
425
+
426
+ for field_name in fields.keys():
427
+ # Must start with __ (introspection convention)
428
+ if not field_name.startswith('__'):
429
+ return False
430
+
431
+ # Must be an official introspection field
432
+ if field_name not in official_introspection_fields:
433
+ return False
434
+
435
+ # Double-check by examining the field types being accessed
436
+ query_type = self.schema.query_type
437
+ for field_name in fields.keys():
438
+ field_def = query_type.fields.get(field_name)
439
+ if field_def:
440
+ field_type = field_def.type
441
+ # Unwrap non-null and list types to get the base type
442
+ while hasattr(field_type, 'of_type'):
443
+ field_type = field_type.of_type
444
+
445
+ # Check if the field type is an introspection type
446
+ if not is_introspection_type(field_type):
447
+ return False
448
+ else:
449
+ # Field doesn't exist in schema - should not happen with validation
450
+ return False
451
+
452
+ return True
453
+
454
+ except Exception:
455
+ # If execution context fails, fall back to AST method
456
+ return self._check_introspection_only_ast(data)
457
+
458
+ except Exception:
459
+ # If parsing fails, it's not a valid query - BLOCK IT
460
+ # Parse errors indicate malformed GraphQL which should never bypass auth
461
+ return False
462
+
463
+ def _check_introspection_only_ast(self, data: Union[Dict, List]) -> bool:
464
+ """AST-based fallback for introspection detection.
465
+
466
+ This is the previous implementation as a fallback when execution context
467
+ analysis is not available.
468
+ """
469
+ try:
470
+ from graphql import parse, FieldNode, visit, Visitor
471
+ except ImportError:
472
+ # Final fallback to string-based check
473
+ return self._check_introspection_only_fallback(data)
474
+
475
+ # Handle batched queries
476
+ if isinstance(data, list):
477
+ return all(self._check_introspection_only_ast(item) for item in data)
478
+
479
+ if not isinstance(data, dict) or 'query' not in data:
480
+ return False
481
+
482
+ query_str = data.get('query', '')
483
+ if not query_str or not isinstance(query_str, str):
484
+ return False
485
+
486
+ try:
487
+ # Parse the GraphQL query into an AST
488
+ document = parse(query_str)
489
+
490
+ # Extract only root-level field names from the query
491
+ root_field_names = set()
492
+
493
+ class RootFieldCollector(Visitor):
494
+ def __init__(self):
495
+ super().__init__()
496
+ self.depth = 0
497
+
498
+ def enter_selection_set(self, node, *_):
499
+ self.depth += 1
500
+
501
+ def leave_selection_set(self, node, *_):
502
+ self.depth -= 1
503
+
504
+ def enter_field(self, node: FieldNode, *_):
505
+ # Only collect fields at depth 1 (root level fields)
506
+ if self.depth == 1 and hasattr(node, 'name') and hasattr(node.name, 'value'):
507
+ root_field_names.add(node.name.value)
508
+
509
+ visit(document, RootFieldCollector())
510
+
511
+ # Check if all root fields are introspection fields
512
+ introspection_fields = {'__schema', '__type', '__typename'}
513
+
514
+ # If no fields found, it's not a valid query
515
+ if not root_field_names:
516
+ return False
517
+
518
+ # All root fields must be introspection fields
519
+ return root_field_names.issubset(introspection_fields)
520
+
521
+ except Exception:
522
+ # If AST parsing fails, it's not a valid query - BLOCK IT
523
+ return False
524
+
525
+ def _check_introspection_only_fallback(self, data: Union[Dict, List]) -> bool:
526
+ """Fallback string-based introspection check for when GraphQL parsing fails.
321
527
 
322
528
  Args:
323
529
  data: Request data
324
530
 
325
531
  Returns:
326
- True if request is introspection-only
532
+ True if request appears to be introspection-only (conservative check)
327
533
  """
534
+ # Handle batched queries
535
+ if isinstance(data, list):
536
+ return all(self._check_introspection_only_fallback(item) for item in data)
328
537
 
329
- # Simple heuristic: if it contains introspection fields AND no regular fields
330
- query_data_lower = str(data).lower()
538
+ if not isinstance(data, dict) or 'query' not in data:
539
+ return False
540
+
541
+ query_str = data.get('query', '')
542
+ if not query_str or not isinstance(query_str, str):
543
+ return False
544
+
545
+ query_lower = query_str.lower()
331
546
 
332
547
  # Check for introspection fields
333
- introspection_fields = ["__schema", "__type", "__typename"]
334
- has_introspection = any(
335
- f in query_data_lower for f in introspection_fields)
548
+ introspection_fields = ['__schema', '__type', '__typename']
549
+ has_introspection = any(field in query_lower for field in introspection_fields)
336
550
 
337
551
  if not has_introspection:
338
552
  return False
339
553
 
340
- # Check for regular fields (non-introspection)
341
- # This is a simple heuristic - in a mixed query, we require auth
342
- regular_field_patterns = [
343
- "hello", "users", "posts", "data", "mutation"]
344
- has_regular_fields = any(
345
- pattern in query_data_lower for pattern in regular_field_patterns
346
- )
554
+ # Conservative approach: check for common non-introspection patterns
555
+ # Remove comments and strings to avoid false positives
556
+ import re
557
+ clean_query = re.sub(r'#.*', '', query_str) # Remove comments
558
+ clean_query = re.sub(r'"[^"]*"', '', clean_query) # Remove string literals
559
+ clean_query = re.sub(r"'[^']*'", '', clean_query) # Remove string literals
560
+ clean_query = clean_query.lower()
561
+
562
+ # Look for non-introspection field patterns in clean query
563
+ # Use word boundaries to avoid matching substrings
564
+ suspicious_patterns = [
565
+ r'\b(query|mutation|subscription)\s+\w+', # Named operations
566
+ r'\{[^}]*[a-z_][a-z0-9_]*\s*[({]', # Regular field selections
567
+ ]
568
+
569
+ # If we find suspicious patterns, be conservative and require auth
570
+ for pattern in suspicious_patterns:
571
+ if re.search(pattern, clean_query):
572
+ # Double-check it's not just introspection
573
+ remaining = clean_query
574
+ for intro_field in introspection_fields:
575
+ remaining = remaining.replace(intro_field, '')
576
+
577
+ # If there's still substantial content, likely has regular fields
578
+ if len(re.findall(r'\w+', remaining)) > 3: # Threshold for field names
579
+ return False
347
580
 
348
- # Only allow introspection-only queries to bypass auth
349
- return has_introspection and not has_regular_fields
581
+ return True
350
582
 
351
583
  def _authenticate_request(self, request: Request) -> Optional[Response]:
352
584
  """Authenticate JWT token from request.
@@ -442,9 +674,11 @@ class GraphQLHTTP:
442
674
  # Handle authentication
443
675
  allow_only_introspection = False
444
676
  if self.auth_enabled:
445
- if not self.auth_bypass_during_introspection or not self._check_introspection_only(data):
446
- auth_error = self._authenticate_request(request)
447
- if auth_error:
677
+ auth_error = self._authenticate_request(request)
678
+ if auth_error:
679
+ if self.auth_bypass_during_introspection and self._check_introspection_only(data):
680
+ logger.info("Authentication bypassed as introspection only query.")
681
+ else:
448
682
  return auth_error
449
683
 
450
684
  # Prepare context for GraphQL execution
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphql_http
3
- Version: 2.0.3
3
+ Version: 2.0.5
4
4
  Summary: HTTP Server for GraphQL.
5
5
  Author-email: Robert Parker <rob@parob.com>
6
6
  License: MIT
@@ -162,7 +162,7 @@ class TestGraphQLHTTPAuthentication:
162
162
  assert "errors" in result
163
163
  assert "Invalid token" in result["errors"][0]["message"]
164
164
 
165
- def test_introspection_without_auth_when_disabled(self, schema):
165
+ def test_introspection_without_auth_bypass(self, schema):
166
166
  """Test introspection queries work without auth when auth_introspection_bypass=False."""
167
167
  server = GraphQLHTTP(
168
168
  schema=schema,
@@ -184,7 +184,7 @@ class TestGraphQLHTTPAuthentication:
184
184
  assert "errors" in result
185
185
  assert "Authorization header is missing" in result["errors"][0]["message"]
186
186
 
187
- def test_introspection_with_auth_when_enabled(self, schema):
187
+ def test_introspection_with_auth_bypass(self, schema):
188
188
  """Test introspection queries require auth when auth_introspection_bypass=True."""
189
189
 
190
190
  server = GraphQLHTTP(
@@ -197,7 +197,7 @@ class TestGraphQLHTTPAuthentication:
197
197
  )
198
198
  client = server.client()
199
199
 
200
- # Introspection query should require auth
200
+ # Introspection query not require auth
201
201
  response = client.post(
202
202
  "/graphql",
203
203
  json={"query": "{ __schema { queryType { name } } }"}
@@ -205,7 +205,7 @@ class TestGraphQLHTTPAuthentication:
205
205
  assert response.status_code == 200
206
206
  # Should get a valid introspection response, not an auth error
207
207
 
208
- def test_regular_query_requires_auth(self, schema):
208
+ def test_regular_query_without_auth_bypass(self, schema):
209
209
  """Test that regular queries always require auth when auth is enabled."""
210
210
  server = GraphQLHTTP(
211
211
  schema=schema,
@@ -227,6 +227,28 @@ class TestGraphQLHTTPAuthentication:
227
227
  assert "errors" in result
228
228
  assert "Authorization header is missing" in result["errors"][0]["message"]
229
229
 
230
+ def test_regular_query_with_auth_bypass(self, schema):
231
+ """Test that regular queries always require auth when auth is enabled."""
232
+ server = GraphQLHTTP(
233
+ schema=schema,
234
+ auth_enabled=True,
235
+ auth_bypass_during_introspection=True, # Only introspection is exempt
236
+ auth_jwks_uri="https://example.com/.well-known/jwks.json",
237
+ auth_issuer="https://example.com/",
238
+ auth_audience="test-audience"
239
+ )
240
+ client = server.client()
241
+
242
+ # Regular query should require auth
243
+ response = client.post(
244
+ "/graphql",
245
+ json={"query": "{ hello }"}
246
+ )
247
+ assert response.status_code == 401
248
+ result = response.json()
249
+ assert "errors" in result
250
+ assert "Authorization header is missing" in result["errors"][0]["message"]
251
+
230
252
  def test_jwks_client_initialization_failure(self, schema):
231
253
  """Test server initialization when JWKS URI is not provided."""
232
254
  # This should raise a ValueError during initialization now due to validation
@@ -1 +0,0 @@
1
- 2.0.3
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes