Repository: mirumee/ariadne

Issue:
Handle safe method HTTP OPTIONS
GraphQL clients may need to know whether Ariadne is processing QUERY / MUTATION using either of HTTP GET or HTTP POST. But any HTTP request that is **not** using the POST method currently throws an HTTP 405 Method Not Allowed (unless _introspection_ is turned on).

Can you please update Ariadne to safely respond to HTTP OPTIONS such that it informs any inquiring client that only HTTP POST methods are to be used when processing either of QUERY or MUTATION?




Discussion:
Without any deep knowledge of Ariadne, could it be possible to resolve this issue with only [the following code](https://github.com/mirumee/ariadne/blob/f18afbefd6b6aec7f3b56b775ea906dcca246802/ariadne/asgi.py#L150) change?

```python
async def handle_http(self, scope: Scope, receive: Receive, send: Send):
    request = Request(scope=scope, receive=receive)
    if request.method == "GET" and self.introspection:
        # only render playground when introspection is enabled
        response = await self.render_playground(request)
    elif request.method == "POST":
        response = await self.graphql_http_server(request)
    elif request.method == "OPTIONS":
        allowed_methods = ["OPTIONS", "POST"]
        if self.introspection: allowed_methods.append("GET")
        response = JSONResponse(headers={"Allow": allowed_methods})
    else:
        response = Response(status_code=405)
    await response(scope, receive, send)
```
We don't currently support query execution over `GET`, only via `POST`.

We can fix `OPTIONS` to report that `GET` is supported if introspection is enabled, but this will only mean that you can run HTTP GET request to retrieve the GraphQL Playground. GraphQL clients will still fail for `GET` queries because response will fail either the response type (HTTP vs JSON) or because they attempted to parse HTML as JSON, which is what I've already seen some clients in the wild do, producing the "`< is not expected...`" error message.

We also have issue for executing queries over `GET`, but that one didn't gain much support since it was opened: #123
I might be missing something in the Ariadne codebase. I cannot find handling of the `OPTIONS` method at all. Your point is well taken though- responding to that particular method should only include `OPTIONS` and `POST` in the `Allow` header.

I'm currently working around this problem by mounting Ariadne onto FastAPI, and implementing a response to `OPTIONS` in the latter. Other than this work around, Ariadne currently suits all my back-end needs; and I'd rather not pay the various costs associated with mounting.

I also think it is general good practice to keep to the [`HTTP` ref spec](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Allow), which states _"The `Allow` header must be sent if the server responds with a 405 Method Not Allowed status code to indicate which request methods can be used."._

Resolving patch:
diff --git a/ariadne/asgi.py b/ariadne/asgi.py
--- a/ariadne/asgi.py
+++ b/ariadne/asgi.py
@@ -20,7 +20,11 @@
 from starlette.types import Receive, Scope, Send
 from starlette.websockets import WebSocket, WebSocketState, WebSocketDisconnect
 
-from .constants import DATA_TYPE_JSON, DATA_TYPE_MULTIPART, PLAYGROUND_HTML
+from .constants import (
+    DATA_TYPE_JSON,
+    DATA_TYPE_MULTIPART,
+    PLAYGROUND_HTML,
+)
 from .exceptions import HttpBadRequestError, HttpError
 from .file_uploads import combine_multipart_data
 from .format_error import format_error
@@ -155,7 +159,7 @@ async def handle_http(self, scope: Scope, receive: Receive, send: Send):
         elif request.method == "POST":
             response = await self.graphql_http_server(request)
         else:
-            response = Response(status_code=405)
+            response = self.handle_not_allowed_method(request)
         await response(scope, receive, send)
 
     async def handle_websocket(self, scope: Scope, receive: Receive, send: Send):
@@ -243,6 +247,17 @@ async def extract_data_from_multipart_request(self, request: Request):
 
         return combine_multipart_data(operations, files_map, request_files)
 
+    def handle_not_allowed_method(self, request: Request):
+        allowed_methods = ["OPTIONS", "POST"]
+        if self.introspection:
+            allowed_methods.append("GET")
+        allow_header = {"Allow": ", ".join(allowed_methods)}
+
+        if request.method == "OPTIONS":
+            return Response(headers=allow_header)
+
+        return Response(status_code=405, headers=allow_header)
+
     async def websocket_server(self, websocket: WebSocket) -> None:
         subscriptions: Dict[str, AsyncGenerator] = {}
         await websocket.accept("graphql-ws")
diff --git a/ariadne/exceptions.py b/ariadne/exceptions.py
--- a/ariadne/exceptions.py
+++ b/ariadne/exceptions.py
@@ -1,4 +1,4 @@
-from .constants import HTTP_STATUS_400_BAD_REQUEST, HTTP_STATUS_405_METHOD_NOT_ALLOWED
+from .constants import HTTP_STATUS_400_BAD_REQUEST
 
 
 class HttpError(Exception):
@@ -13,10 +13,6 @@ class HttpBadRequestError(HttpError):
     status = HTTP_STATUS_400_BAD_REQUEST
 
 
-class HttpMethodNotAllowedError(HttpError):
-    status = HTTP_STATUS_405_METHOD_NOT_ALLOWED
-
-
 class GraphQLFileSyntaxError(Exception):
     def __init__(self, schema_file, message) -> None:
         super().__init__()
diff --git a/ariadne/wsgi.py b/ariadne/wsgi.py
--- a/ariadne/wsgi.py
+++ b/ariadne/wsgi.py
@@ -13,9 +13,10 @@
     DATA_TYPE_MULTIPART,
     HTTP_STATUS_200_OK,
     HTTP_STATUS_400_BAD_REQUEST,
+    HTTP_STATUS_405_METHOD_NOT_ALLOWED,
     PLAYGROUND_HTML,
 )
-from .exceptions import HttpBadRequestError, HttpError, HttpMethodNotAllowedError
+from .exceptions import HttpBadRequestError, HttpError
 from .file_uploads import combine_multipart_data
 from .format_error import format_error
 from .graphql import graphql_sync
@@ -92,7 +93,8 @@ def handle_request(self, environ: dict, start_response: Callable) -> List[bytes]
             return self.handle_get(start_response)
         if environ["REQUEST_METHOD"] == "POST":
             return self.handle_post(environ, start_response)
-        raise HttpMethodNotAllowedError()
+
+        return self.handle_not_allowed_method(environ, start_response)
 
     def handle_get(self, start_response) -> List[bytes]:
         start_response(HTTP_STATUS_200_OK, [("Content-Type", CONTENT_TYPE_TEXT_HTML)])
@@ -220,6 +222,27 @@ def return_response_from_result(
         start_response(status_str, [("Content-Type", CONTENT_TYPE_JSON)])
         return [json.dumps(response).encode("utf-8")]
 
+    def handle_not_allowed_method(
+        self, environ: dict, start_response: Callable
+    ) -> List[bytes]:
+        allowed_methods = ["OPTIONS", "POST"]
+        if self.introspection:
+            allowed_methods.append("GET")
+
+        if environ["REQUEST_METHOD"] == "OPTIONS":
+            status_str = HTTP_STATUS_200_OK
+        else:
+            status_str = HTTP_STATUS_405_METHOD_NOT_ALLOWED
+
+        headers = [
+            ("Content-Type", CONTENT_TYPE_TEXT_PLAIN),
+            ("Content-Length", 0),
+            ("Allow", ", ".join(allowed_methods)),
+        ]
+
+        start_response(status_str, headers)
+        return []
+
 
 class GraphQLMiddleware:
     def __init__(


Test patch:
diff --git a/tests/asgi/test_http_methods.py b/tests/asgi/test_http_methods.py
--- a/tests/asgi/test_http_methods.py
+++ b/tests/asgi/test_http_methods.py
@@ -1,8 +1,45 @@
-def test_unsupported_patch(client):
+from starlette.testclient import TestClient
+
+from ariadne.asgi import GraphQL
+
+
+def test_options_method_is_supported(client):
+    response = client.options("/")
+    assert response.status_code == 200
+    assert response.headers["Allow"] == "OPTIONS, POST, GET"
+
+
+def test_options_response_excludes_get_if_introspection_is_disabled(schema):
+    app = GraphQL(schema, introspection=False)
+    client = TestClient(app)
+
+    response = client.options("/")
+    assert response.status_code == 200
+    assert response.headers["Allow"] == "OPTIONS, POST"
+
+
+def test_patch_is_not_supported(client):
     response = client.patch("/", json={})
     assert response.status_code == 405
+    assert response.headers["Allow"] == "OPTIONS, POST, GET"
 
 
-def test_unsupported_put(client):
-    response = client.patch("/", json={})
+def test_put_is_not_supported(client):
+    response = client.put("/", json={})
+    assert response.status_code == 405
+    assert response.headers["Allow"] == "OPTIONS, POST, GET"
+
+
+def test_delete_is_not_supported(client):
+    response = client.delete("/", json={})
+    assert response.status_code == 405
+    assert response.headers["Allow"] == "OPTIONS, POST, GET"
+
+
+def test_unsupported_method_response_excludes_get_if_introspection_is_disabled(schema):
+    app = GraphQL(schema, introspection=False)
+    client = TestClient(app)
+
+    response = client.patch("/")
     assert response.status_code == 405
+    assert response.headers["Allow"] == "OPTIONS, POST"
diff --git a/tests/wsgi/test_configuration.py b/tests/wsgi/test_configuration.py
--- a/tests/wsgi/test_configuration.py
+++ b/tests/wsgi/test_configuration.py
@@ -3,20 +3,13 @@
 from unittest.mock import ANY, Mock
 
 from werkzeug.test import Client
-from werkzeug.wrappers import BaseResponse
+from werkzeug.wrappers import Response
 
 from ariadne.constants import DATA_TYPE_JSON
 from ariadne.types import ExtensionSync
 from ariadne.wsgi import GraphQL
 
 
-# Add json method to keep test similar to ASGI
-class Response(BaseResponse):
-    @property
-    def json(self):
-        return json.loads(self.data)
-
-
 # Add TestClient to keep test similar to ASGI
 class TestClient(Client):
     __test__ = False
diff --git a/tests/wsgi/test_http_error_handling.py b/tests/wsgi/test_http_error_handling.py
--- a/tests/wsgi/test_http_error_handling.py
+++ b/tests/wsgi/test_http_error_handling.py
@@ -1,6 +1,6 @@
 from unittest.mock import Mock
 
-from ariadne.exceptions import HttpError, HttpBadRequestError, HttpMethodNotAllowedError
+from ariadne.exceptions import HttpError, HttpBadRequestError
 
 
 def test_http_errors_raised_in_handle_request_are_passed_to_http_error_handler(
@@ -35,26 +35,3 @@ def test_http_error_400_with_message_is_converted_to_http_response_in_http_error
     response = middleware(middleware_request, start_response)
     start_response.assert_called_once_with(exception.status, error_response_headers)
     assert response == [message.encode("utf-8")]
-
-
-def test_http_error_405_is_converted_to_http_response_in_http_error_handler(
-    middleware, middleware_request, start_response, error_response_headers
-):
-    exception = HttpMethodNotAllowedError()
-    middleware.graphql_app.handle_request = Mock(side_effect=exception)
-
-    response = middleware(middleware_request, start_response)
-    start_response.assert_called_once_with(exception.status, error_response_headers)
-    assert response == [exception.status.encode("utf-8")]
-
-
-def test_http_error_405_with_message_is_converted_to_http_response_in_http_error_handler(
-    middleware, middleware_request, start_response, error_response_headers
-):
-    message = "This is method not allowed error."
-    exception = HttpMethodNotAllowedError(message)
-    middleware.graphql_app.handle_request = Mock(side_effect=exception)
-
-    response = middleware(middleware_request, start_response)
-    start_response.assert_called_once_with(exception.status, error_response_headers)
-    assert response == [message.encode("utf-8")]
diff --git a/tests/wsgi/test_query_execution.py b/tests/wsgi/test_query_execution.py
--- a/tests/wsgi/test_query_execution.py
+++ b/tests/wsgi/test_query_execution.py
@@ -1,7 +1,5 @@
-import json
-
 from werkzeug.test import Client
-from werkzeug.wrappers import BaseResponse
+from werkzeug.wrappers import Response
 
 from ariadne.wsgi import GraphQL
 from ariadne.constants import HTTP_STATUS_200_OK, HTTP_STATUS_400_BAD_REQUEST
@@ -10,13 +8,6 @@
 from .factories import create_multipart_request
 
 
-# Add json method to keep test similar to ASGI
-class Response(BaseResponse):
-    @property
-    def json(self):
-        return json.loads(self.data)
-
-
 # Add TestClient to keep test similar to ASGI
 class TestClient(Client):
     __test__ = False
diff --git a/tests/wsgi/test_request_dispatching.py b/tests/wsgi/test_request_dispatching.py
--- a/tests/wsgi/test_request_dispatching.py
+++ b/tests/wsgi/test_request_dispatching.py
@@ -2,8 +2,12 @@
 
 import pytest
 
-from ariadne.exceptions import HttpMethodNotAllowedError
-from ariadne.wsgi import GraphQLMiddleware
+from ariadne.constants import (
+    CONTENT_TYPE_TEXT_PLAIN,
+    HTTP_STATUS_200_OK,
+    HTTP_STATUS_405_METHOD_NOT_ALLOWED,
+)
+from ariadne.wsgi import GraphQL, GraphQLMiddleware
 
 
 def test_request_to_app_root_path_is_forwarded(app_mock, middleware):
@@ -67,66 +71,80 @@ def test_post_handler_is_called_for_post_request(
     handle_post.assert_called_once_with(middleware_request, start_response)
 
 
-class InstanceOfHttpMethodNotAllowedError:
-    def __eq__(self, other):
-        return isinstance(other, HttpMethodNotAllowedError)
-
-
-def test_http_not_allowed_error_is_thrown_for_delete_request(
+def test_allowed_methods_list_is_returned_for_options_request(
     middleware, middleware_request, start_response
 ):
-    middleware_request["REQUEST_METHOD"] = "DELETE"
-    handle_error = middleware.graphql_app.handle_http_error = Mock()
+    middleware_request["REQUEST_METHOD"] = "OPTIONS"
+    middleware(middleware_request, start_response)
+    start_response.assert_called_once_with(
+        HTTP_STATUS_200_OK,
+        [
+            ("Content-Type", CONTENT_TYPE_TEXT_PLAIN),
+            ("Content-Length", 0),
+            ("Allow", "OPTIONS, POST, GET"),
+        ],
+    )
 
+
+def test_allowed_methods_list_returned_for_options_request_excludes_get(
+    app_mock, middleware_request, start_response, schema
+):
+    middleware_request["REQUEST_METHOD"] = "OPTIONS"
+    server = GraphQL(schema, introspection=False)
+    middleware = GraphQLMiddleware(app_mock, server)
     middleware(middleware_request, start_response)
-    handle_error.assert_called_once_with(
-        InstanceOfHttpMethodNotAllowedError(), start_response
+    start_response.assert_called_once_with(
+        HTTP_STATUS_200_OK,
+        [
+            ("Content-Type", CONTENT_TYPE_TEXT_PLAIN),
+            ("Content-Length", 0),
+            ("Allow", "OPTIONS, POST"),
+        ],
     )
 
 
-def test_http_not_allowed_error_is_thrown_for_head_request(
+METHOD_NOT_ALLOWED_HEADERS = [
+    ("Content-Type", CONTENT_TYPE_TEXT_PLAIN),
+    ("Content-Length", 0),
+    ("Allow", "OPTIONS, POST, GET"),
+]
+
+
+def test_http_not_allowed_response_is_returned_for_delete_request(
     middleware, middleware_request, start_response
 ):
-    middleware_request["REQUEST_METHOD"] = "HEAD"
-    handle_error = middleware.graphql_app.handle_http_error = Mock()
-
+    middleware_request["REQUEST_METHOD"] = "DELETE"
     middleware(middleware_request, start_response)
-    handle_error.assert_called_once_with(
-        InstanceOfHttpMethodNotAllowedError(), start_response
+    start_response.assert_called_once_with(
+        HTTP_STATUS_405_METHOD_NOT_ALLOWED, METHOD_NOT_ALLOWED_HEADERS
     )
 
 
-def test_http_not_allowed_error_is_thrown_for_patch_request(
+def test_http_not_allowed_response_is_returned_for_head_request(
     middleware, middleware_request, start_response
 ):
-    middleware_request["REQUEST_METHOD"] = "PATCH"
-    handle_error = middleware.graphql_app.handle_http_error = Mock()
-
+    middleware_request["REQUEST_METHOD"] = "HEAD"
     middleware(middleware_request, start_response)
-    handle_error.assert_called_once_with(
-        InstanceOfHttpMethodNotAllowedError(), start_response
+    start_response.assert_called_once_with(
+        HTTP_STATUS_405_METHOD_NOT_ALLOWED, METHOD_NOT_ALLOWED_HEADERS
     )
 
 
-def test_http_not_allowed_error_is_thrown_for_put_request(
+def test_http_not_allowed_response_is_returned_for_patch_request(
     middleware, middleware_request, start_response
 ):
-    middleware_request["REQUEST_METHOD"] = "PUT"
-    handle_error = middleware.graphql_app.handle_http_error = Mock()
-
+    middleware_request["REQUEST_METHOD"] = "PATCH"
     middleware(middleware_request, start_response)
-    handle_error.assert_called_once_with(
-        InstanceOfHttpMethodNotAllowedError(), start_response
+    start_response.assert_called_once_with(
+        HTTP_STATUS_405_METHOD_NOT_ALLOWED, METHOD_NOT_ALLOWED_HEADERS
     )
 
 
-def test_http_not_allowed_error_is_thrown_for_options_request(
+def test_http_not_allowed_response_is_returned_for_put_request(
     middleware, middleware_request, start_response
 ):
-    middleware_request["REQUEST_METHOD"] = "OPTIONS"
-    handle_error = middleware.graphql_app.handle_http_error = Mock()
-
+    middleware_request["REQUEST_METHOD"] = "PUT"
     middleware(middleware_request, start_response)
-    handle_error.assert_called_once_with(
-        InstanceOfHttpMethodNotAllowedError(), start_response
+    start_response.assert_called_once_with(
+        HTTP_STATUS_405_METHOD_NOT_ALLOWED, METHOD_NOT_ALLOWED_HEADERS
     )