From 00fc3f55a21cd6cd123fe6bdccaad67a2eea3598 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Thu, 23 Apr 2026 15:25:16 -0500 Subject: [PATCH 01/12] Add extensions support to GraphQLRequest Support the `extensions` field on GraphQL requests, as defined in the GraphQL over HTTP spec. This allows passing protocol extensions (e.g. persisted query IDs) as a top-level key in the request payload. Closes #590 Co-Authored-By: Claude Opus 4.6 --- gql/graphql_request.py | 12 ++++++++ tests/test_graphql_request.py | 54 +++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/gql/graphql_request.py b/gql/graphql_request.py index 5e6f3ee4..f84813b6 100644 --- a/gql/graphql_request.py +++ b/gql/graphql_request.py @@ -13,6 +13,7 @@ def __init__( *, variable_values: Optional[Dict[str, Any]] = None, operation_name: Optional[str] = None, + extensions: Optional[Dict[str, Any]] = None, ): """Initialize a GraphQL request. @@ -21,6 +22,10 @@ def __init__( :param variable_values: Dictionary of input parameters (Default: None). :param operation_name: Name of the operation that shall be executed. Only required in multi-operation documents (Default: None). + :param extensions: Dictionary of protocol extensions (Default: None). + This is passed as the top-level ``extensions`` key in the request + payload, as defined in the `GraphQL over HTTP spec + `_. :return: a :class:`GraphQLRequest ` which can be later executed or subscribed by a :class:`Client `, by an @@ -42,9 +47,12 @@ def __init__( variable_values = request.variable_values if operation_name is None: operation_name = request.operation_name + if extensions is None: + extensions = request.extensions self.variable_values: Optional[Dict[str, Any]] = variable_values self.operation_name: Optional[str] = operation_name + self.extensions: Optional[Dict[str, Any]] = extensions def serialize_variable_values(self, schema: GraphQLSchema) -> "GraphQLRequest": @@ -61,6 +69,7 @@ def serialize_variable_values(self, schema: GraphQLSchema) -> "GraphQLRequest": operation_name=self.operation_name, ), operation_name=self.operation_name, + extensions=self.extensions, ) @property @@ -74,6 +83,9 @@ def payload(self) -> Dict[str, Any]: if self.variable_values: payload["variables"] = self.variable_values + if self.extensions: + payload["extensions"] = self.extensions + return payload def __str__(self): diff --git a/tests/test_graphql_request.py b/tests/test_graphql_request.py index ea255c7d..c8a571f9 100644 --- a/tests/test_graphql_request.py +++ b/tests/test_graphql_request.py @@ -236,3 +236,57 @@ def test_graphql_request_init_with_graphql_request(): assert request_1.variable_values["money"] == money_value_1 assert request_2.variable_values["money"] == money_value_1 assert request_3.variable_values["money"] == money_value_2 + + +def test_graphql_request_extensions_in_payload(): + extensions = {"persistedQuery": {"version": 1, "sha256Hash": "abc123"}} + request = GraphQLRequest("{balance}", extensions=extensions) + + payload = request.payload + assert payload["extensions"] == extensions + + +def test_graphql_request_extensions_not_in_payload_when_none(): + request = GraphQLRequest("{balance}") + assert "extensions" not in request.payload + + +def test_graphql_request_extensions_copied_from_graphql_request(): + extensions = {"persistedQuery": {"version": 1, "sha256Hash": "abc123"}} + request_1 = GraphQLRequest("{balance}", extensions=extensions) + request_2 = GraphQLRequest(request_1) + + assert request_2.extensions == extensions + assert request_2.payload["extensions"] == extensions + + +def test_graphql_request_extensions_override_from_graphql_request(): + extensions_1 = {"persistedQuery": {"version": 1, "sha256Hash": "abc123"}} + extensions_2 = {"custom": "value"} + request_1 = GraphQLRequest("{balance}", extensions=extensions_1) + request_2 = GraphQLRequest(request_1, extensions=extensions_2) + + assert request_2.extensions == extensions_2 + + +def test_graphql_request_extensions_preserved_by_serialize_variable_values(): + extensions = {"persistedQuery": {"version": 1, "sha256Hash": "abc123"}} + money_value = Money(10, "DM") + + request = GraphQLRequest( + "query myquery($money: Money) {toEuros(money: $money)}", + variable_values={"money": money_value}, + extensions=extensions, + ) + + serialized = request.serialize_variable_values(schema) + assert serialized.extensions == extensions + assert serialized.payload["extensions"] == extensions + + +def test_graphql_request_str_includes_extensions(): + extensions = {"key": "value"} + request = GraphQLRequest("{balance}", extensions=extensions) + result = str(request) + assert "extensions" in result + assert "'key': 'value'" in result From c72bd7164893533943641928067d8b5a03201ce5 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Thu, 23 Apr 2026 16:21:14 -0500 Subject: [PATCH 02/12] Add transport integration tests for request extensions Verify that extensions set on GraphQLRequest are sent in the HTTP request body for aiohttp, httpx, and requests transports. Co-Authored-By: Claude Opus 4.6 --- tests/test_aiohttp.py | 42 +++++++++++++++++++++++++++++++++++++- tests/test_httpx.py | 46 +++++++++++++++++++++++++++++++++++++++++- tests/test_requests.py | 46 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 131 insertions(+), 3 deletions(-) diff --git a/tests/test_aiohttp.py b/tests/test_aiohttp.py index 102fe3f2..0be98348 100644 --- a/tests/test_aiohttp.py +++ b/tests/test_aiohttp.py @@ -6,7 +6,7 @@ import pytest -from gql import Client, FileVar, gql +from gql import Client, FileVar, GraphQLRequest, gql from gql.cli import get_parser, main from gql.transport.exceptions import ( TransportAlreadyConnected, @@ -87,6 +87,46 @@ async def handler(request): assert transport.response_headers["dummy"] == "test1234" +@pytest.mark.asyncio +async def test_aiohttp_query_with_extensions(aiohttp_server): + from aiohttp import web + + from gql.transport.aiohttp import AIOHTTPTransport + + async def handler(request): + body = await request.json() + assert "extensions" in body + assert body["extensions"] == { + "persistedQuery": {"version": 1, "sha256Hash": "abc123"} + } + return web.Response( + text=query1_server_answer, + content_type="application/json", + ) + + app = web.Application() + app.router.add_route("POST", "/", handler) + server = await aiohttp_server(app) + + url = server.make_url("/") + + transport = AIOHTTPTransport(url=url, timeout=10) + + async with Client(transport=transport) as session: + + request = GraphQLRequest( + query1_str, + extensions={ + "persistedQuery": {"version": 1, "sha256Hash": "abc123"} + }, + ) + + result = await session.execute(request) + + continents = result["continents"] + assert continents[0]["code"] == "AF" + + @pytest.mark.asyncio async def test_aiohttp_ignore_backend_content_type(aiohttp_server): from aiohttp import web diff --git a/tests/test_httpx.py b/tests/test_httpx.py index 0411294b..4979bca8 100644 --- a/tests/test_httpx.py +++ b/tests/test_httpx.py @@ -3,7 +3,7 @@ import pytest -from gql import Client, FileVar, gql +from gql import Client, FileVar, GraphQLRequest, gql from gql.transport.exceptions import ( TransportAlreadyConnected, TransportClosed, @@ -84,6 +84,50 @@ def test_code(): await run_sync_test(server, test_code) +@pytest.mark.aiohttp +@pytest.mark.asyncio +async def test_httpx_query_with_extensions(aiohttp_server, run_sync_test): + from aiohttp import web + + from gql.transport.httpx import HTTPXTransport + + async def handler(request): + body = await request.json() + assert "extensions" in body + assert body["extensions"] == { + "persistedQuery": {"version": 1, "sha256Hash": "abc123"} + } + return web.Response( + text=query1_server_answer, + content_type="application/json", + ) + + app = web.Application() + app.router.add_route("POST", "/", handler) + server = await aiohttp_server(app) + + url = str(server.make_url("/")) + + def test_code(): + transport = HTTPXTransport(url=url) + + with Client(transport=transport) as session: + + request = GraphQLRequest( + query1_str, + extensions={ + "persistedQuery": {"version": 1, "sha256Hash": "abc123"} + }, + ) + + result = session.execute(request) + + continents = result["continents"] + assert continents[0]["code"] == "AF" + + await run_sync_test(server, test_code) + + @pytest.mark.aiohttp @pytest.mark.asyncio @pytest.mark.parametrize("verify_https", ["disabled", "cert_provided"]) diff --git a/tests/test_requests.py b/tests/test_requests.py index fe57f5e3..fce864ac 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -4,7 +4,7 @@ import pytest -from gql import Client, FileVar, gql +from gql import Client, FileVar, GraphQLRequest, gql from gql.transport.exceptions import ( TransportAlreadyConnected, TransportClosed, @@ -85,6 +85,50 @@ def test_code(): await run_sync_test(server, test_code) +@pytest.mark.aiohttp +@pytest.mark.asyncio +async def test_requests_query_with_extensions(aiohttp_server, run_sync_test): + from aiohttp import web + + from gql.transport.requests import RequestsHTTPTransport + + async def handler(request): + body = await request.json() + assert "extensions" in body + assert body["extensions"] == { + "persistedQuery": {"version": 1, "sha256Hash": "abc123"} + } + return web.Response( + text=query1_server_answer, + content_type="application/json", + ) + + app = web.Application() + app.router.add_route("POST", "/", handler) + server = await aiohttp_server(app) + + url = server.make_url("/") + + def test_code(): + transport = RequestsHTTPTransport(url=url) + + with Client(transport=transport) as session: + + request = GraphQLRequest( + query1_str, + extensions={ + "persistedQuery": {"version": 1, "sha256Hash": "abc123"} + }, + ) + + result = session.execute(request) + + continents = result["continents"] + assert continents[0]["code"] == "AF" + + await run_sync_test(server, test_code) + + @pytest.mark.aiohttp @pytest.mark.asyncio @pytest.mark.parametrize("verify_https", ["disabled", "cert_provided"]) From c0291175f87da23d07792148dfe1ba02e3e6e8a8 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Thu, 23 Apr 2026 16:23:01 -0500 Subject: [PATCH 03/12] Drop rst syntax from extensions docstring Co-Authored-By: Claude Opus 4.6 --- gql/graphql_request.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/gql/graphql_request.py b/gql/graphql_request.py index f84813b6..e8e4fb7d 100644 --- a/gql/graphql_request.py +++ b/gql/graphql_request.py @@ -23,9 +23,8 @@ def __init__( :param operation_name: Name of the operation that shall be executed. Only required in multi-operation documents (Default: None). :param extensions: Dictionary of protocol extensions (Default: None). - This is passed as the top-level ``extensions`` key in the request - payload, as defined in the `GraphQL over HTTP spec - `_. + This is passed as the top-level "extensions" key in the request + payload, as defined in the GraphQL over HTTP spec. :return: a :class:`GraphQLRequest ` which can be later executed or subscribed by a :class:`Client `, by an From 3b26a26d463df78dd2982f2154561178df4c0e1c Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Thu, 23 Apr 2026 16:27:21 -0500 Subject: [PATCH 04/12] Add subscribe and batch tests for request extensions Cover extensions across all transports and operation types: - aiohttp subscribe (HTTP + websocket) - aiohttp, httpx, and requests execute_batch Co-Authored-By: Claude Opus 4.6 --- tests/test_aiohttp.py | 40 +++++++++ tests/test_aiohttp_batch.py | 43 +++++++++ tests/test_aiohttp_websocket_subscription.py | 32 ++++++- tests/test_httpx_batch.py | 91 ++++++++++++++++++++ tests/test_requests_batch.py | 47 ++++++++++ 5 files changed, 252 insertions(+), 1 deletion(-) diff --git a/tests/test_aiohttp.py b/tests/test_aiohttp.py index 0be98348..4dbfbdb5 100644 --- a/tests/test_aiohttp.py +++ b/tests/test_aiohttp.py @@ -619,6 +619,46 @@ def test_code(): await run_sync_test(server, test_code) +@pytest.mark.asyncio +async def test_aiohttp_subscribe_with_extensions(aiohttp_server): + from aiohttp import web + + from gql.transport.aiohttp import AIOHTTPTransport + + async def handler(request): + body = await request.json() + assert "extensions" in body + assert body["extensions"] == { + "persistedQuery": {"version": 1, "sha256Hash": "abc123"} + } + return web.Response( + text=query1_server_answer, + content_type="application/json", + ) + + app = web.Application() + app.router.add_route("POST", "/", handler) + server = await aiohttp_server(app) + + url = server.make_url("/") + + transport = AIOHTTPTransport(url=url, timeout=10) + + request = GraphQLRequest( + query1_str, + extensions={"persistedQuery": {"version": 1, "sha256Hash": "abc123"}}, + ) + + async with Client(transport=transport) as session: + + results = [] + async for result in session.subscribe(request): + results.append(result) + + assert len(results) == 1 + assert results[0]["continents"][0]["code"] == "AF" + + file_upload_mutation_1 = """ mutation($file: Upload!) { uploadFile(input:{other_var:$other_var, file:$file}) { diff --git a/tests/test_aiohttp_batch.py b/tests/test_aiohttp_batch.py index ad9924a0..35972553 100644 --- a/tests/test_aiohttp_batch.py +++ b/tests/test_aiohttp_batch.py @@ -89,6 +89,49 @@ async def handler(request): assert transport.response_headers["dummy"] == "test1234" +@pytest.mark.asyncio +async def test_aiohttp_batch_query_with_extensions(aiohttp_server): + from aiohttp import web + + from gql.transport.aiohttp import AIOHTTPTransport + + async def handler(request): + body = await request.json() + assert isinstance(body, list) + assert "extensions" in body[0] + assert body[0]["extensions"] == { + "persistedQuery": {"version": 1, "sha256Hash": "abc123"} + } + return web.Response( + text=query1_server_answer_list, + content_type="application/json", + ) + + app = web.Application() + app.router.add_route("POST", "/", handler) + server = await aiohttp_server(app) + + url = server.make_url("/") + + transport = AIOHTTPTransport(url=url, timeout=10) + + async with Client(transport=transport) as session: + + query = [ + GraphQLRequest( + query1_str, + extensions={ + "persistedQuery": {"version": 1, "sha256Hash": "abc123"} + }, + ) + ] + + results = await session.execute_batch(query) + + continents = results[0]["continents"] + assert continents[0]["code"] == "AF" + + @pytest.mark.asyncio async def test_aiohttp_batch_query_auto_batch_enabled(aiohttp_server, run_sync_test): from aiohttp import web diff --git a/tests/test_aiohttp_websocket_subscription.py b/tests/test_aiohttp_websocket_subscription.py index f06046df..42ed7df1 100644 --- a/tests/test_aiohttp_websocket_subscription.py +++ b/tests/test_aiohttp_websocket_subscription.py @@ -8,7 +8,7 @@ from graphql import ExecutionResult from parse import search -from gql import Client, gql +from gql import Client, GraphQLRequest, gql from gql.client import AsyncClientSession from gql.transport.exceptions import TransportConnectionFailed, TransportServerError @@ -460,6 +460,36 @@ async def test_aiohttp_websocket_subscription_with_operation_name( assert '"operationName": "CountdownSubscription"' in logged_messages[0] +@pytest.mark.asyncio +@pytest.mark.parametrize("server", [server_countdown], indirect=True) +@pytest.mark.parametrize("subscription_str", [countdown_subscription_str]) +async def test_aiohttp_websocket_subscription_with_extensions( + aiohttp_client_and_server, subscription_str +): + + session, server = aiohttp_client_and_server + + count = 10 + request = GraphQLRequest( + subscription_str.format(count=count), + extensions={"persistedQuery": {"version": 1, "sha256Hash": "abc123"}}, + ) + + async for result in session.subscribe(request): + + number = result["number"] + print(f"Number received: {number}") + + assert number == count + count -= 1 + + assert count == -1 + + # Check that the query contains the extensions + assert '"persistedQuery"' in logged_messages[0] + assert '"sha256Hash": "abc123"' in logged_messages[0] + + WITH_KEEPALIVE = True diff --git a/tests/test_httpx_batch.py b/tests/test_httpx_batch.py index 63472dab..ece5220a 100644 --- a/tests/test_httpx_batch.py +++ b/tests/test_httpx_batch.py @@ -118,6 +118,97 @@ def test_code(): await run_sync_test(server, test_code) +@pytest.mark.aiohttp +@pytest.mark.asyncio +async def test_httpx_async_batch_query_with_extensions(aiohttp_server): + from aiohttp import web + + from gql.transport.httpx import HTTPXAsyncTransport + + async def handler(request): + body = await request.json() + assert isinstance(body, list) + assert "extensions" in body[0] + assert body[0]["extensions"] == { + "persistedQuery": {"version": 1, "sha256Hash": "abc123"} + } + return web.Response( + text=query1_server_answer_list, + content_type="application/json", + ) + + app = web.Application() + app.router.add_route("POST", "/", handler) + server = await aiohttp_server(app) + + url = str(server.make_url("/")) + + transport = HTTPXAsyncTransport(url=url, timeout=10) + + async with Client(transport=transport) as session: + + query = [ + GraphQLRequest( + query1_str, + extensions={ + "persistedQuery": {"version": 1, "sha256Hash": "abc123"} + }, + ) + ] + + results = await session.execute_batch(query) + + continents = results[0]["continents"] + assert continents[0]["code"] == "AF" + + +@pytest.mark.aiohttp +@pytest.mark.asyncio +async def test_httpx_sync_batch_query_with_extensions(aiohttp_server, run_sync_test): + from aiohttp import web + + from gql.transport.httpx import HTTPXTransport + + async def handler(request): + body = await request.json() + assert isinstance(body, list) + assert "extensions" in body[0] + assert body[0]["extensions"] == { + "persistedQuery": {"version": 1, "sha256Hash": "abc123"} + } + return web.Response( + text=query1_server_answer_list, + content_type="application/json", + ) + + app = web.Application() + app.router.add_route("POST", "/", handler) + server = await aiohttp_server(app) + + url = str(server.make_url("/")) + + transport = HTTPXTransport(url=url, timeout=10) + + def test_code(): + with Client(transport=transport) as session: + + query = [ + GraphQLRequest( + query1_str, + extensions={ + "persistedQuery": {"version": 1, "sha256Hash": "abc123"} + }, + ) + ] + + results = session.execute_batch(query) + + continents = results[0]["continents"] + assert continents[0]["code"] == "AF" + + await run_sync_test(server, test_code) + + @pytest.mark.aiohttp @pytest.mark.asyncio async def test_httpx_async_batch_query_without_session(aiohttp_server, run_sync_test): diff --git a/tests/test_requests_batch.py b/tests/test_requests_batch.py index 7131c2da..8fe3ef3d 100644 --- a/tests/test_requests_batch.py +++ b/tests/test_requests_batch.py @@ -90,6 +90,53 @@ def test_code(): await run_sync_test(server, test_code) +@pytest.mark.aiohttp +@pytest.mark.asyncio +async def test_requests_batch_query_with_extensions(aiohttp_server, run_sync_test): + from aiohttp import web + + from gql.transport.requests import RequestsHTTPTransport + + async def handler(request): + body = await request.json() + assert isinstance(body, list) + assert "extensions" in body[0] + assert body[0]["extensions"] == { + "persistedQuery": {"version": 1, "sha256Hash": "abc123"} + } + return web.Response( + text=query1_server_answer_list, + content_type="application/json", + ) + + app = web.Application() + app.router.add_route("POST", "/", handler) + server = await aiohttp_server(app) + + url = server.make_url("/") + + def test_code(): + transport = RequestsHTTPTransport(url=url) + + with Client(transport=transport) as session: + + query = [ + GraphQLRequest( + query1_str, + extensions={ + "persistedQuery": {"version": 1, "sha256Hash": "abc123"} + }, + ) + ] + + results = session.execute_batch(query) + + continents = results[0]["continents"] + assert continents[0]["code"] == "AF" + + await run_sync_test(server, test_code) + + @pytest.mark.aiohttp @pytest.mark.asyncio async def test_requests_query_auto_batch_enabled(aiohttp_server, run_sync_test): From c7952231e966d7852070712723313f408366df57 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Thu, 23 Apr 2026 16:33:06 -0500 Subject: [PATCH 05/12] Consolidate and clean up extensions tests - Merge 6 unit tests into one test_graphql_request_extensions - Merge aiohttp execute + subscribe into one test - Drop redundant httpx sync batch test (same code path as async) - Tighten assertions and remove unnecessary comments - Parse WS logged message as JSON instead of fragile string matching Co-Authored-By: Claude Opus 4.6 --- tests/test_aiohttp.py | 65 +++--------------- tests/test_aiohttp_batch.py | 23 ++----- tests/test_aiohttp_websocket_subscription.py | 7 +- tests/test_graphql_request.py | 57 ++++------------ tests/test_httpx.py | 22 ++---- tests/test_httpx_batch.py | 70 ++------------------ tests/test_requests.py | 22 ++---- tests/test_requests_batch.py | 24 ++----- 8 files changed, 59 insertions(+), 231 deletions(-) diff --git a/tests/test_aiohttp.py b/tests/test_aiohttp.py index 4dbfbdb5..00bd8a0f 100644 --- a/tests/test_aiohttp.py +++ b/tests/test_aiohttp.py @@ -88,17 +88,16 @@ async def handler(request): @pytest.mark.asyncio -async def test_aiohttp_query_with_extensions(aiohttp_server): +async def test_aiohttp_request_extensions(aiohttp_server): from aiohttp import web from gql.transport.aiohttp import AIOHTTPTransport + extensions = {"persistedQuery": {"version": 1, "sha256Hash": "abc123"}} + async def handler(request): body = await request.json() - assert "extensions" in body - assert body["extensions"] == { - "persistedQuery": {"version": 1, "sha256Hash": "abc123"} - } + assert body["extensions"] == extensions return web.Response( text=query1_server_answer, content_type="application/json", @@ -112,19 +111,17 @@ async def handler(request): transport = AIOHTTPTransport(url=url, timeout=10) - async with Client(transport=transport) as session: + request = GraphQLRequest(query1_str, extensions=extensions) - request = GraphQLRequest( - query1_str, - extensions={ - "persistedQuery": {"version": 1, "sha256Hash": "abc123"} - }, - ) + async with Client(transport=transport) as session: + # execute result = await session.execute(request) + assert result["continents"][0]["code"] == "AF" - continents = result["continents"] - assert continents[0]["code"] == "AF" + # subscribe + async for result in session.subscribe(request): + assert result["continents"][0]["code"] == "AF" @pytest.mark.asyncio @@ -619,46 +616,6 @@ def test_code(): await run_sync_test(server, test_code) -@pytest.mark.asyncio -async def test_aiohttp_subscribe_with_extensions(aiohttp_server): - from aiohttp import web - - from gql.transport.aiohttp import AIOHTTPTransport - - async def handler(request): - body = await request.json() - assert "extensions" in body - assert body["extensions"] == { - "persistedQuery": {"version": 1, "sha256Hash": "abc123"} - } - return web.Response( - text=query1_server_answer, - content_type="application/json", - ) - - app = web.Application() - app.router.add_route("POST", "/", handler) - server = await aiohttp_server(app) - - url = server.make_url("/") - - transport = AIOHTTPTransport(url=url, timeout=10) - - request = GraphQLRequest( - query1_str, - extensions={"persistedQuery": {"version": 1, "sha256Hash": "abc123"}}, - ) - - async with Client(transport=transport) as session: - - results = [] - async for result in session.subscribe(request): - results.append(result) - - assert len(results) == 1 - assert results[0]["continents"][0]["code"] == "AF" - - file_upload_mutation_1 = """ mutation($file: Upload!) { uploadFile(input:{other_var:$other_var, file:$file}) { diff --git a/tests/test_aiohttp_batch.py b/tests/test_aiohttp_batch.py index 35972553..37d8f64e 100644 --- a/tests/test_aiohttp_batch.py +++ b/tests/test_aiohttp_batch.py @@ -90,18 +90,17 @@ async def handler(request): @pytest.mark.asyncio -async def test_aiohttp_batch_query_with_extensions(aiohttp_server): +async def test_aiohttp_batch_request_extensions(aiohttp_server): from aiohttp import web from gql.transport.aiohttp import AIOHTTPTransport + extensions = {"persistedQuery": {"version": 1, "sha256Hash": "abc123"}} + async def handler(request): body = await request.json() assert isinstance(body, list) - assert "extensions" in body[0] - assert body[0]["extensions"] == { - "persistedQuery": {"version": 1, "sha256Hash": "abc123"} - } + assert body[0]["extensions"] == extensions return web.Response( text=query1_server_answer_list, content_type="application/json", @@ -117,19 +116,9 @@ async def handler(request): async with Client(transport=transport) as session: - query = [ - GraphQLRequest( - query1_str, - extensions={ - "persistedQuery": {"version": 1, "sha256Hash": "abc123"} - }, - ) - ] - + query = [GraphQLRequest(query1_str, extensions=extensions)] results = await session.execute_batch(query) - - continents = results[0]["continents"] - assert continents[0]["code"] == "AF" + assert results[0]["continents"][0]["code"] == "AF" @pytest.mark.asyncio diff --git a/tests/test_aiohttp_websocket_subscription.py b/tests/test_aiohttp_websocket_subscription.py index 42ed7df1..b099a4a9 100644 --- a/tests/test_aiohttp_websocket_subscription.py +++ b/tests/test_aiohttp_websocket_subscription.py @@ -485,9 +485,10 @@ async def test_aiohttp_websocket_subscription_with_extensions( assert count == -1 - # Check that the query contains the extensions - assert '"persistedQuery"' in logged_messages[0] - assert '"sha256Hash": "abc123"' in logged_messages[0] + message = json.loads(logged_messages[0]) + assert message["payload"]["extensions"] == { + "persistedQuery": {"version": 1, "sha256Hash": "abc123"} + } WITH_KEEPALIVE = True diff --git a/tests/test_graphql_request.py b/tests/test_graphql_request.py index c8a571f9..ea26355e 100644 --- a/tests/test_graphql_request.py +++ b/tests/test_graphql_request.py @@ -238,55 +238,26 @@ def test_graphql_request_init_with_graphql_request(): assert request_3.variable_values["money"] == money_value_2 -def test_graphql_request_extensions_in_payload(): - extensions = {"persistedQuery": {"version": 1, "sha256Hash": "abc123"}} - request = GraphQLRequest("{balance}", extensions=extensions) - - payload = request.payload - assert payload["extensions"] == extensions - - -def test_graphql_request_extensions_not_in_payload_when_none(): - request = GraphQLRequest("{balance}") - assert "extensions" not in request.payload - - -def test_graphql_request_extensions_copied_from_graphql_request(): - extensions = {"persistedQuery": {"version": 1, "sha256Hash": "abc123"}} - request_1 = GraphQLRequest("{balance}", extensions=extensions) - request_2 = GraphQLRequest(request_1) - - assert request_2.extensions == extensions - assert request_2.payload["extensions"] == extensions - - -def test_graphql_request_extensions_override_from_graphql_request(): +def test_graphql_request_extensions(): extensions_1 = {"persistedQuery": {"version": 1, "sha256Hash": "abc123"}} extensions_2 = {"custom": "value"} - request_1 = GraphQLRequest("{balance}", extensions=extensions_1) - request_2 = GraphQLRequest(request_1, extensions=extensions_2) + money_value = Money(10, "DM") - assert request_2.extensions == extensions_2 + assert "extensions" not in GraphQLRequest("{balance}").payload + request_1 = GraphQLRequest("{balance}", extensions=extensions_1) + assert request_1.payload["extensions"] == extensions_1 -def test_graphql_request_extensions_preserved_by_serialize_variable_values(): - extensions = {"persistedQuery": {"version": 1, "sha256Hash": "abc123"}} - money_value = Money(10, "DM") + request_2 = GraphQLRequest(request_1) + assert request_2.extensions == extensions_1 - request = GraphQLRequest( + request_3 = GraphQLRequest(request_1, extensions=extensions_2) + assert request_3.extensions == extensions_2 + + request_4 = GraphQLRequest( "query myquery($money: Money) {toEuros(money: $money)}", variable_values={"money": money_value}, - extensions=extensions, + extensions=extensions_1, ) - - serialized = request.serialize_variable_values(schema) - assert serialized.extensions == extensions - assert serialized.payload["extensions"] == extensions - - -def test_graphql_request_str_includes_extensions(): - extensions = {"key": "value"} - request = GraphQLRequest("{balance}", extensions=extensions) - result = str(request) - assert "extensions" in result - assert "'key': 'value'" in result + serialized = request_4.serialize_variable_values(schema) + assert serialized.extensions == extensions_1 diff --git a/tests/test_httpx.py b/tests/test_httpx.py index 4979bca8..aa25edc2 100644 --- a/tests/test_httpx.py +++ b/tests/test_httpx.py @@ -86,17 +86,16 @@ def test_code(): @pytest.mark.aiohttp @pytest.mark.asyncio -async def test_httpx_query_with_extensions(aiohttp_server, run_sync_test): +async def test_httpx_request_extensions(aiohttp_server, run_sync_test): from aiohttp import web from gql.transport.httpx import HTTPXTransport + extensions = {"persistedQuery": {"version": 1, "sha256Hash": "abc123"}} + async def handler(request): body = await request.json() - assert "extensions" in body - assert body["extensions"] == { - "persistedQuery": {"version": 1, "sha256Hash": "abc123"} - } + assert body["extensions"] == extensions return web.Response( text=query1_server_answer, content_type="application/json", @@ -112,18 +111,9 @@ def test_code(): transport = HTTPXTransport(url=url) with Client(transport=transport) as session: - - request = GraphQLRequest( - query1_str, - extensions={ - "persistedQuery": {"version": 1, "sha256Hash": "abc123"} - }, - ) - + request = GraphQLRequest(query1_str, extensions=extensions) result = session.execute(request) - - continents = result["continents"] - assert continents[0]["code"] == "AF" + assert result["continents"][0]["code"] == "AF" await run_sync_test(server, test_code) diff --git a/tests/test_httpx_batch.py b/tests/test_httpx_batch.py index ece5220a..2f05df99 100644 --- a/tests/test_httpx_batch.py +++ b/tests/test_httpx_batch.py @@ -120,18 +120,17 @@ def test_code(): @pytest.mark.aiohttp @pytest.mark.asyncio -async def test_httpx_async_batch_query_with_extensions(aiohttp_server): +async def test_httpx_batch_request_extensions(aiohttp_server): from aiohttp import web from gql.transport.httpx import HTTPXAsyncTransport + extensions = {"persistedQuery": {"version": 1, "sha256Hash": "abc123"}} + async def handler(request): body = await request.json() assert isinstance(body, list) - assert "extensions" in body[0] - assert body[0]["extensions"] == { - "persistedQuery": {"version": 1, "sha256Hash": "abc123"} - } + assert body[0]["extensions"] == extensions return web.Response( text=query1_server_answer_list, content_type="application/json", @@ -147,66 +146,9 @@ async def handler(request): async with Client(transport=transport) as session: - query = [ - GraphQLRequest( - query1_str, - extensions={ - "persistedQuery": {"version": 1, "sha256Hash": "abc123"} - }, - ) - ] - + query = [GraphQLRequest(query1_str, extensions=extensions)] results = await session.execute_batch(query) - - continents = results[0]["continents"] - assert continents[0]["code"] == "AF" - - -@pytest.mark.aiohttp -@pytest.mark.asyncio -async def test_httpx_sync_batch_query_with_extensions(aiohttp_server, run_sync_test): - from aiohttp import web - - from gql.transport.httpx import HTTPXTransport - - async def handler(request): - body = await request.json() - assert isinstance(body, list) - assert "extensions" in body[0] - assert body[0]["extensions"] == { - "persistedQuery": {"version": 1, "sha256Hash": "abc123"} - } - return web.Response( - text=query1_server_answer_list, - content_type="application/json", - ) - - app = web.Application() - app.router.add_route("POST", "/", handler) - server = await aiohttp_server(app) - - url = str(server.make_url("/")) - - transport = HTTPXTransport(url=url, timeout=10) - - def test_code(): - with Client(transport=transport) as session: - - query = [ - GraphQLRequest( - query1_str, - extensions={ - "persistedQuery": {"version": 1, "sha256Hash": "abc123"} - }, - ) - ] - - results = session.execute_batch(query) - - continents = results[0]["continents"] - assert continents[0]["code"] == "AF" - - await run_sync_test(server, test_code) + assert results[0]["continents"][0]["code"] == "AF" @pytest.mark.aiohttp diff --git a/tests/test_requests.py b/tests/test_requests.py index fce864ac..31399f70 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -87,17 +87,16 @@ def test_code(): @pytest.mark.aiohttp @pytest.mark.asyncio -async def test_requests_query_with_extensions(aiohttp_server, run_sync_test): +async def test_requests_request_extensions(aiohttp_server, run_sync_test): from aiohttp import web from gql.transport.requests import RequestsHTTPTransport + extensions = {"persistedQuery": {"version": 1, "sha256Hash": "abc123"}} + async def handler(request): body = await request.json() - assert "extensions" in body - assert body["extensions"] == { - "persistedQuery": {"version": 1, "sha256Hash": "abc123"} - } + assert body["extensions"] == extensions return web.Response( text=query1_server_answer, content_type="application/json", @@ -113,18 +112,9 @@ def test_code(): transport = RequestsHTTPTransport(url=url) with Client(transport=transport) as session: - - request = GraphQLRequest( - query1_str, - extensions={ - "persistedQuery": {"version": 1, "sha256Hash": "abc123"} - }, - ) - + request = GraphQLRequest(query1_str, extensions=extensions) result = session.execute(request) - - continents = result["continents"] - assert continents[0]["code"] == "AF" + assert result["continents"][0]["code"] == "AF" await run_sync_test(server, test_code) diff --git a/tests/test_requests_batch.py b/tests/test_requests_batch.py index 8fe3ef3d..b9732639 100644 --- a/tests/test_requests_batch.py +++ b/tests/test_requests_batch.py @@ -92,18 +92,17 @@ def test_code(): @pytest.mark.aiohttp @pytest.mark.asyncio -async def test_requests_batch_query_with_extensions(aiohttp_server, run_sync_test): +async def test_requests_batch_request_extensions(aiohttp_server, run_sync_test): from aiohttp import web from gql.transport.requests import RequestsHTTPTransport + extensions = {"persistedQuery": {"version": 1, "sha256Hash": "abc123"}} + async def handler(request): body = await request.json() assert isinstance(body, list) - assert "extensions" in body[0] - assert body[0]["extensions"] == { - "persistedQuery": {"version": 1, "sha256Hash": "abc123"} - } + assert body[0]["extensions"] == extensions return web.Response( text=query1_server_answer_list, content_type="application/json", @@ -119,20 +118,9 @@ def test_code(): transport = RequestsHTTPTransport(url=url) with Client(transport=transport) as session: - - query = [ - GraphQLRequest( - query1_str, - extensions={ - "persistedQuery": {"version": 1, "sha256Hash": "abc123"} - }, - ) - ] - + query = [GraphQLRequest(query1_str, extensions=extensions)] results = session.execute_batch(query) - - continents = results[0]["continents"] - assert continents[0]["code"] == "AF" + assert results[0]["continents"][0]["code"] == "AF" await run_sync_test(server, test_code) From 5d1f42dd5a6c715cb780d0f5f98dd7a269c97beb Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Thu, 23 Apr 2026 16:39:16 -0500 Subject: [PATCH 06/12] Remove debug print from websocket extensions test Co-Authored-By: Claude Opus 4.6 --- tests/test_aiohttp_websocket_subscription.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_aiohttp_websocket_subscription.py b/tests/test_aiohttp_websocket_subscription.py index b099a4a9..898ae332 100644 --- a/tests/test_aiohttp_websocket_subscription.py +++ b/tests/test_aiohttp_websocket_subscription.py @@ -478,8 +478,6 @@ async def test_aiohttp_websocket_subscription_with_extensions( async for result in session.subscribe(request): number = result["number"] - print(f"Number received: {number}") - assert number == count count -= 1 From 1e6e1343651fc24ee75a58746e61168aefce8c56 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Thu, 23 Apr 2026 16:40:31 -0500 Subject: [PATCH 07/12] Add comments to extensions test explaining each scenario Co-Authored-By: Claude Opus 4.6 --- tests/test_graphql_request.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_graphql_request.py b/tests/test_graphql_request.py index ea26355e..d6ba30d2 100644 --- a/tests/test_graphql_request.py +++ b/tests/test_graphql_request.py @@ -248,12 +248,15 @@ def test_graphql_request_extensions(): request_1 = GraphQLRequest("{balance}", extensions=extensions_1) assert request_1.payload["extensions"] == extensions_1 + # Copied from another GraphQLRequest request_2 = GraphQLRequest(request_1) assert request_2.extensions == extensions_1 + # Explicit extensions override the copied value request_3 = GraphQLRequest(request_1, extensions=extensions_2) assert request_3.extensions == extensions_2 + # Preserved through serialize_variable_values request_4 = GraphQLRequest( "query myquery($money: Money) {toEuros(money: $money)}", variable_values={"money": money_value}, From 4532d1409d547ac89661268a621f99af5bf734f6 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Thu, 23 Apr 2026 16:41:06 -0500 Subject: [PATCH 08/12] Restore debug print in websocket extensions test for consistency Co-Authored-By: Claude Opus 4.6 --- tests/test_aiohttp_websocket_subscription.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_aiohttp_websocket_subscription.py b/tests/test_aiohttp_websocket_subscription.py index 898ae332..b099a4a9 100644 --- a/tests/test_aiohttp_websocket_subscription.py +++ b/tests/test_aiohttp_websocket_subscription.py @@ -478,6 +478,8 @@ async def test_aiohttp_websocket_subscription_with_extensions( async for result in session.subscribe(request): number = result["number"] + print(f"Number received: {number}") + assert number == count count -= 1 From 2d20946db4c6e087bea1b69566ee33f2d06c123d Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Thu, 23 Apr 2026 16:55:05 -0500 Subject: [PATCH 09/12] Add request extensions to docs Co-Authored-By: Claude Opus 4.6 --- docs/usage/extensions.rst | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/usage/extensions.rst b/docs/usage/extensions.rst index ec413656..974ee9fb 100644 --- a/docs/usage/extensions.rst +++ b/docs/usage/extensions.rst @@ -3,6 +3,41 @@ Extensions ---------- +Request extensions +^^^^^^^^^^^^^^^^^^ + +The `GraphQL over HTTP spec `_ +defines an optional :code:`extensions` field on requests. This is sent as a +top-level key in the request payload alongside :code:`query`, :code:`variables`, +and :code:`operationName`. + +You can use this to pass protocol extensions such as +`persisted queries `_: + +.. code-block:: python + + from gql import Client, GraphQLRequest + from gql.transport.aiohttp import AIOHTTPTransport + + transport = AIOHTTPTransport(url="https://example.com/graphql") + + async with Client(transport=transport) as session: + + request = GraphQLRequest( + "query { viewer { name } }", + extensions={ + "persistedQuery": { + "version": 1, + "sha256Hash": "abc123...", + } + }, + ) + + result = await session.execute(request) + +Response extensions +^^^^^^^^^^^^^^^^^^^ + When you execute (or subscribe) GraphQL requests, the server will send responses which may have 3 fields: From cedf54a839b07359062207b159d012ae2798f9f2 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Thu, 23 Apr 2026 16:59:56 -0500 Subject: [PATCH 10/12] Use trusted documents example in extensions docs Co-Authored-By: Claude Opus 4.6 --- docs/usage/extensions.rst | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/usage/extensions.rst b/docs/usage/extensions.rst index 974ee9fb..ca8ba55b 100644 --- a/docs/usage/extensions.rst +++ b/docs/usage/extensions.rst @@ -12,7 +12,7 @@ top-level key in the request payload alongside :code:`query`, :code:`variables`, and :code:`operationName`. You can use this to pass protocol extensions such as -`persisted queries `_: +`trusted documents `_: .. code-block:: python @@ -26,10 +26,7 @@ You can use this to pass protocol extensions such as request = GraphQLRequest( "query { viewer { name } }", extensions={ - "persistedQuery": { - "version": 1, - "sha256Hash": "abc123...", - } + "x-trusted-document-id": "foo", }, ) From bfa5620f939673678b3a51ee70915f4af92ffdce Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Thu, 23 Apr 2026 17:00:25 -0500 Subject: [PATCH 11/12] Fix trusted document extension key name Co-Authored-By: Claude Opus 4.6 --- docs/usage/extensions.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/usage/extensions.rst b/docs/usage/extensions.rst index ca8ba55b..ecb7e188 100644 --- a/docs/usage/extensions.rst +++ b/docs/usage/extensions.rst @@ -26,7 +26,7 @@ You can use this to pass protocol extensions such as request = GraphQLRequest( "query { viewer { name } }", extensions={ - "x-trusted-document-id": "foo", + "document-id": "foo", }, ) From 562a4c2546709ee2521a8fec789cffa694207bbd Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Thu, 23 Apr 2026 17:02:04 -0500 Subject: [PATCH 12/12] Use realistic document-id value in docs example Co-Authored-By: Claude Opus 4.6 --- docs/usage/extensions.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/usage/extensions.rst b/docs/usage/extensions.rst index ecb7e188..72924711 100644 --- a/docs/usage/extensions.rst +++ b/docs/usage/extensions.rst @@ -26,7 +26,7 @@ You can use this to pass protocol extensions such as request = GraphQLRequest( "query { viewer { name } }", extensions={ - "document-id": "foo", + "document-id": "155d6e8f5545...", }, )