Repository: sanic-org/sanic

Issue:
RFC 7231violations
https://tools.ietf.org/html/rfc7231#section-4.1 specifies that
> All general-purpose servers MUST support the methods GET and HEAD.
>    All other methods are OPTIONAL.
While it's not defined what 'general purpose' means imo sanic should by default also accept HEAD requests performing the same action as a GET except returning a body.

Testcase:
```python
@app.route('/sanic/')
async def test(request):
    return response.json({'hello': 'world'})
```
Result:
```
curl -I -v -o /dev/null http://127.0.0.1:5071/sanic/ 
*   Trying 127.0.0.1...
* TCP_NODELAY set
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0* Connected to 127.0.0.1 (127.0.0.1) port 5071 (#0)
> HEAD /sanic/ HTTP/1.1
> Host: 127.0.0.1:5071
> User-Agent: curl/7.52.1
> Accept: */*
> 
< HTTP/1.1 405 Method Not Allowed
< Connection: keep-alive
< Keep-Alive: 5
< Content-Length: 46
< Content-Type: text/plain; charset=utf-8
< 
* Excess found in a non pipelined read: excess = 46 url = /sanic/ (zero-length body)
* Curl_http_done: called premature == 0
  0    46    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
* Connection #0 to host 127.0.0.1 left intact
```

This result has multiple issues:
- Incorrect Content-Length
- > The origin server MUST generate an Allow header field in a 405 response containing a list of the target resource's currently supported methods.

There's 3 options to fix the response code:
1. Have a HEAD performing the same action as a GET except returning a body (this only fixes the issue with HEAD requests).
2. Return [405 Method Not Allowed](https://tools.ietf.org/html/rfc7231#section-6.5.5) and Allow header containing allowed methods (this also fixes the response for POST/PUT/other requests).
3. Return [501 Not Implemented](https://tools.ietf.org/html/rfc7231#section-6.6.2).

For comparison with Flask regarding HEAD/GET: http://flask.pocoo.org/docs/0.12/quickstart/#http-methods (this refers to [RFC 2068](https://tools.ietf.org/html/rfc2068), however, the relevant parts for this issue are the same)
> If GET is present, HEAD will be added automatically for you. You don’t have to deal with that. It will also make sure that HEAD requests are handled as the [HTTP RFC](http://www.ietf.org/rfc/rfc2068.txt) (the document describing the HTTP protocol) demands, so you can completely ignore that part of the HTTP specification. Likewise, as of Flask 0.6, OPTIONS is implemented for you automatically as well.


Discussion:

Resolving patch:
diff --git a/sanic/app.py b/sanic/app.py
--- a/sanic/app.py
+++ b/sanic/app.py
@@ -543,6 +543,7 @@ async def handle_request(self, request, write_callback, stream_callback):
 
                 # Fetch handler from router
                 handler, args, kwargs, uri = self.router.get(request)
+
                 request.uri_template = uri
                 if handler is None:
                     raise ServerError(
diff --git a/sanic/exceptions.py b/sanic/exceptions.py
--- a/sanic/exceptions.py
+++ b/sanic/exceptions.py
@@ -150,6 +150,16 @@ class InvalidUsage(SanicException):
     pass
 
 
+@add_status_code(405)
+class MethodNotSupported(SanicException):
+    def __init__(self, message, method, allowed_methods):
+        super().__init__(message)
+        self.headers = dict()
+        self.headers["Allow"] = ", ".join(allowed_methods)
+        if method in ['HEAD', 'PATCH', 'PUT', 'DELETE']:
+            self.headers['Content-Length'] = 0
+
+
 @add_status_code(500)
 class ServerError(SanicException):
     pass
@@ -167,8 +177,6 @@ class URLBuildError(ServerError):
 
 
 class FileNotFound(NotFound):
-    pass
-
     def __init__(self, message, path, relative_url):
         super().__init__(message)
         self.path = path
@@ -198,8 +206,6 @@ class HeaderNotFound(InvalidUsage):
 
 @add_status_code(416)
 class ContentRangeError(SanicException):
-    pass
-
     def __init__(self, message, content_range):
         super().__init__(message)
         self.headers = {
diff --git a/sanic/router.py b/sanic/router.py
--- a/sanic/router.py
+++ b/sanic/router.py
@@ -3,7 +3,7 @@
 from collections.abc import Iterable
 from functools import lru_cache
 
-from sanic.exceptions import NotFound, InvalidUsage
+from sanic.exceptions import NotFound, MethodNotSupported
 from sanic.views import CompositionView
 
 Route = namedtuple(
@@ -352,6 +352,16 @@ def get(self, request):
         except NotFound:
             return self._get(request.path, request.method, '')
 
+    def get_supported_methods(self, url):
+        """Get a list of supported methods for a url and optional host.
+
+        :param url: URL string (including host)
+        :return: frozenset of supported methods
+        """
+        route = self.routes_all.get(url)
+        # if methods are None then this logic will prevent an error
+        return getattr(route, 'methods', None) or frozenset()
+
     @lru_cache(maxsize=ROUTER_CACHE_SIZE)
     def _get(self, url, method, host):
         """Get a request handler based on the URL of the request, or raises an
@@ -364,9 +374,10 @@ def _get(self, url, method, host):
         url = host + url
         # Check against known static routes
         route = self.routes_static.get(url)
-        method_not_supported = InvalidUsage(
-            'Method {} not allowed for URL {}'.format(
-                method, url), status_code=405)
+        method_not_supported = MethodNotSupported(
+            'Method {} not allowed for URL {}'.format(method, url),
+            method=method,
+            allowed_methods=self.get_supported_methods(url))
         if route:
             if route.methods and method not in route.methods:
                 raise method_not_supported
@@ -409,7 +420,7 @@ def is_stream_handler(self, request):
         """
         try:
             handler = self.get(request)[0]
-        except (NotFound, InvalidUsage):
+        except (NotFound, MethodNotSupported):
             return False
         if (hasattr(handler, 'view_class') and
                 hasattr(handler.view_class, request.method.lower())):


Test patch:
diff --git a/tests/test_response.py b/tests/test_response.py
--- a/tests/test_response.py
+++ b/tests/test_response.py
@@ -35,6 +35,25 @@ async def sample_streaming_fn(response):
     await asyncio.sleep(.001)
     response.write('bar')
 
+def test_method_not_allowed():
+    app = Sanic('method_not_allowed')
+
+    @app.get('/')
+    async def test(request):
+        return response.json({'hello': 'world'})
+
+    request, response = app.test_client.head('/')
+    assert response.headers['Allow']== 'GET'
+
+    @app.post('/')
+    async def test(request):
+        return response.json({'hello': 'world'})
+
+    request, response = app.test_client.head('/')
+    assert response.status == 405
+    assert set(response.headers['Allow'].split(', ')) == set(['GET', 'POST'])
+    assert response.headers['Content-Length'] == '0'
+
 
 @pytest.fixture
 def json_app():
@@ -254,4 +273,4 @@ async def file_route(request, filename):
     assert 'Content-Length' in response.headers
     assert int(response.headers[
                'Content-Length']) == len(
-                   get_file_content(static_file_directory, file_name))
\ No newline at end of file
+                   get_file_content(static_file_directory, file_name))