Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Let WS and HTTP use the same JSON encoder #3708

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Release type: minor

Starting with this release, the same JSON encoder is used to encode HTTP
responses and WebSocket messages.

This enables developers to override the `encode_json` method on their views to
customize the JSON encoder used by all web protocols.
25 changes: 25 additions & 0 deletions docs/breaking-changes/0.251.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
title: 0.251.0 Breaking Changes
slug: breaking-changes/0.251.0
---

# v0.251.0 Breaking Changes

We slightly changed the signature of the `encode_json` method used to customize
the JSON encoder used by our HTTP views.

Originally, the method was only meant to encode HTTP response data. Starting
with this release, it's also used to encode WebSocket messages.

Previously, the method signature was:

```python
def encode_json(self, response_data: GraphQLHTTPResponse) -> str: ...
```

To upgrade your code, change the method signature to the following and make sure
your method can handle the same inputs as the built-in `json.dumps` method:

```python
def encode_json(self, data: object) -> str: ...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we type this better? is it worth it?

Copy link
Member Author

@DoctorJohn DoctorJohn Nov 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

json.loads expects Any so I went with object. Technically it's anything that can be serialized to JSON (list, dict, int, float, str, etc.). But the json library doesn't provide a type for it which we could reuse. We would have to create our own, pretty sure it's not worth it for this method.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DoctorJohn mmh, is it because the ws integration can send pretty much anything, right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it because the ws integration can send pretty much anything

Not necessarily, the WS messages are all quite well defined. If we wanted to limited the input to the encode_json method we could go with Union[GraphQLHTTPResponse, Message, OperationMessage].

However, this signature would signal to users that they should implement a method that can specifically/only encode these messages. If someone does that, they would have to update their encode_json method every time we add a new protocol or any of the specs changes.

IMHO we should ask the user to write encode_json methods that can encode any JSON payload and not just a subset, so that they don't worry about specific payloads and so that we can use that method for any JSON that needs to be encoded from within views.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, sounds good to me 😊

```
9 changes: 5 additions & 4 deletions docs/integrations/aiohttp.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ methods:
- `async get_context(self, request: aiohttp.web.Request, response: aiohttp.web.StreamResponse) -> object`
- `async get_root_value(self, request: aiohttp.web.Request) -> object`
- `async process_result(self, request: aiohttp.web.Request, result: ExecutionResult) -> GraphQLHTTPResponse`
- `def encode_json(self, data: GraphQLHTTPResponse) -> str`
- `def encode_json(self, data: object) -> str`
- `async def render_graphql_ide(self, request: aiohttp.web.Request) -> aiohttp.web.Response`

### get_context
Expand Down Expand Up @@ -144,12 +144,13 @@ tweaked based on your needs.

### encode_json

`encode_json` allows to customize the encoding of the JSON response. By default
we use `json.dumps` but you can override this method to use a different encoder.
`encode_json` allows to customize the encoding of HTTP and WebSocket JSON
responses. By default we use `json.dumps` but you can override this method to
use a different encoder.

```python
class MyGraphQLView(GraphQLView):
def encode_json(self, data: GraphQLHTTPResponse) -> str:
def encode_json(self, data: object) -> str:
return json.dumps(data, indent=2)
```

Expand Down
9 changes: 5 additions & 4 deletions docs/integrations/asgi.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ We allow to extend the base `GraphQL` app, by overriding the following methods:
- `async get_context(self, request: Union[Request, WebSocket], response: Optional[Response] = None) -> Any`
- `async get_root_value(self, request: Request) -> Any`
- `async process_result(self, request: Request, result: ExecutionResult) -> GraphQLHTTPResponse`
- `def encode_json(self, response_data: GraphQLHTTPResponse) -> str`
- `def encode_json(self, response_data: object) -> str`
- `async def render_graphql_ide(self, request: Request) -> Response`

### get_context
Expand Down Expand Up @@ -169,12 +169,13 @@ tweaked based on your needs.

### encode_json

`encode_json` allows to customize the encoding of the JSON response. By default
we use `json.dumps` but you can override this method to use a different encoder.
`encode_json` allows to customize the encoding of HTTP and WebSocket JSON
responses. By default we use `json.dumps` but you can override this method to
use a different encoder.

```python
class MyGraphQLView(GraphQL):
def encode_json(self, data: GraphQLHTTPResponse) -> str:
def encode_json(self, data: object) -> str:
return json.dumps(data, indent=2)
```

Expand Down
9 changes: 5 additions & 4 deletions docs/integrations/chalice.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ We allow to extend the base `GraphQLView`, by overriding the following methods:
- `get_context(self, request: Request, response: TemporalResponse) -> Any`
- `get_root_value(self, request: Request) -> Any`
- `process_result(self, request: Request, result: ExecutionResult) -> GraphQLHTTPResponse`
- `encode_json(self, response_data: GraphQLHTTPResponse) -> str`
- `encode_json(self, response_data: object) -> str`
- `def render_graphql_ide(self, request: Request) -> Response`

### get_context
Expand Down Expand Up @@ -154,12 +154,13 @@ tweaked based on your needs.

### encode_json

`encode_json` allows to customize the encoding of the JSON response. By default
we use `json.dumps` but you can override this method to use a different encoder.
`encode_json` allows to customize the encoding of HTTP and WebSocket JSON
responses. By default we use `json.dumps` but you can override this method to
use a different encoder.

```python
class MyGraphQLView(GraphQLView):
def encode_json(self, data: GraphQLHTTPResponse) -> str:
def encode_json(self, data: object) -> str:
return json.dumps(data, indent=2)
```

Expand Down
9 changes: 5 additions & 4 deletions docs/integrations/django.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ methods:
- `async get_context(self, request: HttpRequest) -> Any`
- `async get_root_value(self, request: HttpRequest) -> Any`
- `async process_result(self, request: HttpRequest, result: ExecutionResult) -> GraphQLHTTPResponse`
- `def encode_json(self, data: GraphQLHTTPResponse) -> str`
- `def encode_json(self, data: object) -> str`
- `async def render_graphql_ide(self, request: HttpRequest) -> HttpResponse`

### get_context
Expand Down Expand Up @@ -288,12 +288,13 @@ tweaked based on your needs.

### encode_json

`encode_json` allows to customize the encoding of the JSON response. By default
we use `json.dumps` but you can override this method to use a different encoder.
`encode_json` allows to customize the encoding of HTTP and WebSocket JSON
responses. By default we use `json.dumps` but you can override this method to
use a different encoder.

```python
class MyGraphQLView(AsyncGraphQLView):
def encode_json(self, data: GraphQLHTTPResponse) -> str:
def encode_json(self, data: object) -> str:
return json.dumps(data, indent=2)
```

Expand Down
10 changes: 5 additions & 5 deletions docs/integrations/fastapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,14 +292,14 @@ tweaked based on your needs.

### encode_json

`encode_json` allows to customize the encoding of the JSON response. By default
we use `json.dumps` but you can override this method to use a different encoder.
For example, the `orjson` library from pypi has blazing fast speeds.
`encode_json` allows to customize the encoding of HTTP and WebSocket JSON
responses. By default we use `json.dumps` but you can override this method to
use a different encoder.

```python
class MyGraphQLRouter(GraphQLRouter):
def encode_json(self, data: GraphQLHTTPResponse) -> bytes:
return orjson.dumps(data)
def encode_json(self, data: object) -> bytes:
return json.dumps(data, indent=2)
```

### render_graphql_ide
Expand Down
9 changes: 5 additions & 4 deletions docs/integrations/flask.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ We allow to extend the base `GraphQLView`, by overriding the following methods:
- `def get_context(self, request: Request, response: Response) -> Any`
- `def get_root_value(self, request: Request) -> Any`
- `def process_result(self, result: ExecutionResult) -> GraphQLHTTPResponse`
- `def encode_json(self, response_data: GraphQLHTTPResponse) -> str`
- `def encode_json(self, response_data: object) -> str`
- `def render_graphql_ide(self, request: Request) -> Response`

<Note>
Expand Down Expand Up @@ -141,12 +141,13 @@ tweaked based on your needs.

### encode_json

`encode_json` allows to customize the encoding of the JSON response. By default
we use `json.dumps` but you can override this method to use a different encoder.
`encode_json` allows to customize the encoding of HTTP and WebSocket JSON
responses. By default we use `json.dumps` but you can override this method to
use a different encoder.

```python
class MyGraphQLView(GraphQLView):
def encode_json(self, data: GraphQLHTTPResponse) -> str:
def encode_json(self, data: object) -> str:
return json.dumps(data, indent=2)
```

Expand Down
9 changes: 5 additions & 4 deletions docs/integrations/quart.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ We allow to extend the base `GraphQLView`, by overriding the following methods:
- `async def get_context(self, request: Request, response: Response) -> Any`
- `async def get_root_value(self, request: Request) -> Any`
- `async def process_result(self, result: ExecutionResult) -> GraphQLHTTPResponse`
- `def encode_json(self, response_data: GraphQLHTTPResponse) -> str`
- `def encode_json(self, response_data: object) -> str`
- `async def render_graphql_ide(self, request: Request) -> Response`

### get_context
Expand Down Expand Up @@ -125,12 +125,13 @@ tweaked based on your needs.

### encode_json

`encode_json` allows to customize the encoding of the JSON response. By default
we use `json.dumps` but you can override this method to use a different encoder.
`encode_json` allows to customize the encoding of HTTP and WebSocket JSON
responses. By default we use `json.dumps` but you can override this method to
use a different encoder.

```python
class MyGraphQLView(GraphQLView):
def encode_json(self, data: GraphQLHTTPResponse) -> str:
def encode_json(self, data: object) -> str:
return json.dumps(data, indent=2)
```

Expand Down
7 changes: 4 additions & 3 deletions docs/integrations/sanic.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,13 @@ tweaked based on your needs.

### encode_json

`encode_json` allows to customize the encoding of the JSON response. By default
we use `json.dumps` but you can override this method to use a different encoder.
`encode_json` allows to customize the encoding of HTTP and WebSocket JSON
responses. By default we use `json.dumps` but you can override this method to
use a different encoder.

```python
class MyGraphQLView(GraphQLView):
def encode_json(self, data: GraphQLHTTPResponse) -> str:
def encode_json(self, data: object) -> str:
return json.dumps(data, indent=2)
```

Expand Down
7 changes: 5 additions & 2 deletions strawberry/aiohttp/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,10 @@ def content_type(self) -> Optional[str]:


class AioHTTPWebSocketAdapter(AsyncWebSocketAdapter):
def __init__(self, request: web.Request, ws: web.WebSocketResponse) -> None:
def __init__(
self, view: AsyncBaseHTTPView, request: web.Request, ws: web.WebSocketResponse
) -> None:
super().__init__(view)
self.request = request
self.ws = ws

Expand All @@ -107,7 +110,7 @@ async def iter_json(

async def send_json(self, message: Mapping[str, object]) -> None:
try:
await self.ws.send_json(message)
await self.ws.send_str(self.view.encode_json(message))
except RuntimeError as exc:
raise WebSocketDisconnected from exc

Expand Down
7 changes: 5 additions & 2 deletions strawberry/asgi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,10 @@ async def get_form_data(self) -> FormData:


class ASGIWebSocketAdapter(AsyncWebSocketAdapter):
def __init__(self, request: WebSocket, response: WebSocket) -> None:
def __init__(
self, view: AsyncBaseHTTPView, request: WebSocket, response: WebSocket
) -> None:
super().__init__(view)
self.ws = response

async def iter_json(
Expand All @@ -107,7 +110,7 @@ async def iter_json(

async def send_json(self, message: Mapping[str, object]) -> None:
try:
await self.ws.send_json(message)
await self.ws.send_text(self.view.encode_json(message))
except WebSocketDisconnect as exc:
raise WebSocketDisconnected from exc

Expand Down
10 changes: 8 additions & 2 deletions strawberry/channels/handlers/ws_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@


class ChannelsWebSocketAdapter(AsyncWebSocketAdapter):
def __init__(self, request: GraphQLWSConsumer, response: GraphQLWSConsumer) -> None:
def __init__(
self,
view: AsyncBaseHTTPView,
request: GraphQLWSConsumer,
response: GraphQLWSConsumer,
) -> None:
super().__init__(view)
self.ws_consumer = response

async def iter_json(
Expand All @@ -50,7 +56,7 @@ async def iter_json(
raise NonJsonMessageReceived()

async def send_json(self, message: Mapping[str, object]) -> None:
serialized_message = json.dumps(message)
serialized_message = self.view.encode_json(message)
await self.ws_consumer.send(serialized_message)

async def close(self, code: int, reason: str) -> None:
Expand Down
4 changes: 2 additions & 2 deletions strawberry/django/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,8 @@ async def create_streaming_response(
},
)

def encode_json(self, response_data: GraphQLHTTPResponse) -> str:
return json.dumps(response_data, cls=DjangoJSONEncoder)
def encode_json(self, data: object) -> str:
return json.dumps(data, cls=DjangoJSONEncoder)


class GraphQLView(
Expand Down
8 changes: 6 additions & 2 deletions strawberry/http/async_base_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ async def get_form_data(self) -> FormData: ...


class AsyncWebSocketAdapter(abc.ABC):
def __init__(self, view: "AsyncBaseHTTPView") -> None:
self.view = view

@abc.abstractmethod
def iter_json(
self, *, ignore_parsing_errors: bool = False
Expand Down Expand Up @@ -113,7 +116,8 @@ class AsyncBaseHTTPView(
connection_init_wait_timeout: timedelta = timedelta(minutes=1)
request_adapter_class: Callable[[Request], AsyncHTTPRequestAdapter]
websocket_adapter_class: Callable[
[WebSocketRequest, WebSocketResponse], AsyncWebSocketAdapter
["AsyncBaseHTTPView", WebSocketRequest, WebSocketResponse],
AsyncWebSocketAdapter,
]
graphql_transport_ws_handler_class = BaseGraphQLTransportWSHandler
graphql_ws_handler_class = BaseGraphQLWSHandler
Expand Down Expand Up @@ -265,7 +269,7 @@ async def run(
websocket_response = await self.create_websocket_response(
request, websocket_subprotocol
)
websocket = self.websocket_adapter_class(request, websocket_response)
websocket = self.websocket_adapter_class(self, request, websocket_response)

context = (
await self.get_context(request, response=websocket_response)
Expand Down
5 changes: 2 additions & 3 deletions strawberry/http/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
from typing import Any, Dict, Generic, List, Mapping, Optional, Union
from typing_extensions import Protocol

from strawberry.http import GraphQLHTTPResponse
from strawberry.http.ides import GraphQL_IDE, get_graphql_ide_html
from strawberry.http.types import HTTPMethod, QueryParams

Expand Down Expand Up @@ -44,8 +43,8 @@ def parse_json(self, data: Union[str, bytes]) -> Any:
except json.JSONDecodeError as e:
raise HTTPException(400, "Unable to parse request body as JSON") from e

def encode_json(self, response_data: GraphQLHTTPResponse) -> str:
return json.dumps(response_data)
def encode_json(self, data: object) -> str:
DoctorJohn marked this conversation as resolved.
Show resolved Hide resolved
return json.dumps(data)

def parse_query_params(self, params: QueryParams) -> Dict[str, Any]:
params = dict(params)
Expand Down
7 changes: 5 additions & 2 deletions strawberry/litestar/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,10 @@ async def get_form_data(self) -> FormData:


class LitestarWebSocketAdapter(AsyncWebSocketAdapter):
def __init__(self, request: WebSocket, response: WebSocket) -> None:
def __init__(
self, view: AsyncBaseHTTPView, request: WebSocket, response: WebSocket
) -> None:
super().__init__(view)
self.ws = response

async def iter_json(
Expand All @@ -218,7 +221,7 @@ async def iter_json(

async def send_json(self, message: Mapping[str, object]) -> None:
try:
await self.ws.send_json(message)
await self.ws.send_data(data=self.view.encode_json(message))
except WebSocketDisconnect as exc:
raise WebSocketDisconnected from exc

Expand Down
Loading
Loading