Repository: hh-h/aiohttp-swagger3

Issue:
Definitions with requestBody / required: false can not be used
Schema with `requestBody` where `required` is set to false can not be used as the library fails to handle them.

The library first raises a 400 with a complaint about the content type `application/octet-stream` not being supported. I would already expect this to work, as it is the default for an empty body. When explicitly setting `application/json` the library fails with another 400 `Expecting value: line 1 column 1 (char 0)`.

Below a modification of `test_nullable_ref` test case from `test_docs_request_bodies` to reproduce the issue:

```python
async def test_non_required(swagger_docs_with_components, aiohttp_client):

    routes = web.RouteTableDef()

    @routes.post("/r")
    async def handler(request, body: Dict):
        """
        ---
        requestBody:
          required: false
          content:
            application/json:
              schema:
                type: object
                required:
                  - pet
                properties:
                  pet:
                    nullable: true
                    allOf:
                      - $ref: '#/components/schemas/Pet'

        responses:
          '200':
            description: OK.
        """
        return web.json_response(body)

    swagger = swagger_docs_with_components()
    swagger.add_routes(routes)

    client = await aiohttp_client(swagger._app)

    resp = await client.post("/r", headers={"content-type": "application/json"})
    assert resp.status == 200
    assert await resp.text() == ""

    body = {
        "pet": None,
    }
    resp = await client.post("/r", json=body)
    assert resp.status == 200
    assert await resp.json() == body

    body = {
        "pet": {
            "name": "lizzy",
            "age": 12,
        }
    }
    resp = await client.post("/r", json=body)
    assert resp.status == 200
    assert await resp.json() == body
```

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/hh-h/aiohttp-swagger3/pull/98?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
> Merging [#98](https://codecov.io/gh/hh-h/aiohttp-swagger3/pull/98?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (aed32c6) into [master](https://codecov.io/gh/hh-h/aiohttp-swagger3/commit/4ce20d2eb102bbbe7c95a22060a558fc22003618?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (4ce20d2) will **decrease** coverage by `0.15%`.
> The diff coverage is `92.59%`.

[![Impacted file tree graph](https://codecov.io/gh/hh-h/aiohttp-swagger3/pull/98/graphs/tree.svg?width=650&height=150&src=pr&token=vsUkD2f5wc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)](https://codecov.io/gh/hh-h/aiohttp-swagger3/pull/98?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)

```diff
@@            Coverage Diff             @@
##           master      #98      +/-   ##
==========================================
- Coverage   98.71%   98.56%   -0.16%     
==========================================
  Files          13       13              
  Lines        1249     1257       +8     
==========================================
+ Hits         1233     1239       +6     
- Misses         16       18       +2     
```


| [Impacted Files](https://codecov.io/gh/hh-h/aiohttp-swagger3/pull/98?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [aiohttp\_swagger3/swagger\_route.py](https://codecov.io/gh/hh-h/aiohttp-swagger3/pull/98/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-YWlvaHR0cF9zd2FnZ2VyMy9zd2FnZ2VyX3JvdXRlLnB5) | `98.40% <92.30%> (-1.05%)` | :arrow_down: |
| [aiohttp\_swagger3/\_\_init\_\_.py](https://codecov.io/gh/hh-h/aiohttp-swagger3/pull/98/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-YWlvaHR0cF9zd2FnZ2VyMy9fX2luaXRfXy5weQ==) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/hh-h/aiohttp-swagger3/pull/98?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/hh-h/aiohttp-swagger3/pull/98?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None). Last update [4ce20d2...aed32c6](https://codecov.io/gh/hh-h/aiohttp-swagger3/pull/98?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).


Patch:
diff --git a/.travis.yml b/.travis.yml
index c9bb4cc..4eb9d8d 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -12,7 +12,6 @@ matrix:
   - python: nightly
   fast_finish: true
   allow_failures:
-  - python: 3.10
   - python: 3.10-dev
   - python: 3.11-dev
   - python: nightly
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 562f3d7..890f6f1 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,6 +1,11 @@
 Changelog
 =========
 
+0.7.1 (30-01-2021)
+------------------
+
+- requestBody can be optional (#97)
+
 0.7.0 (04-11-2021)
 ------------------
 
diff --git a/aiohttp_swagger3/__init__.py b/aiohttp_swagger3/__init__.py
index 98d8dcc..ba70487 100644
--- a/aiohttp_swagger3/__init__.py
+++ b/aiohttp_swagger3/__init__.py
@@ -9,7 +9,7 @@ __all__ = (
     "ValidatorError",
     "__version__",
 )
-__version__ = "0.7.0"
+__version__ = "0.7.1"
 __author__ = "Valetov Konstantin"
 
 from .exceptions import ValidatorError
diff --git a/aiohttp_swagger3/swagger_route.py b/aiohttp_swagger3/swagger_route.py
index 1f15226..ef1c732 100644
--- a/aiohttp_swagger3/swagger_route.py
+++ b/aiohttp_swagger3/swagger_route.py
@@ -12,6 +12,8 @@ from .validators import MISSING, Validator, ValidatorError, schema_to_validator,
 
 _SwaggerHandler = Callable[..., Awaitable[web.StreamResponse]]
 
+REQUEST_BODY_NAME: str = "body"
+
 
 class RequestValidationFailed(web.HTTPBadRequest):
     """This exception can be caught in a aiohttp middleware.
@@ -50,6 +52,7 @@ class SwaggerRoute:
         "hp",
         "cp",
         "bp",
+        "is_body_required",
         "auth",
         "params",
     )
@@ -68,6 +71,7 @@ class SwaggerRoute:
         method_section = self._swagger.spec["paths"][path][method]
         parameters = method_section.get("parameters")
         body = method_section.get("requestBody")
+        self.is_body_required = body and body.get("required", False)
         method_security = method_section.get("security")
         security = method_security if method_security is not None else self._swagger.spec.get("security", [])
         components = self._swagger.spec.get("components", {})
@@ -104,14 +108,14 @@ class SwaggerRoute:
                 self._swagger._get_media_type_handler(media_type)
                 value = body["content"][media_type]
                 self.bp[media_type] = Parameter(
-                    "body",
+                    REQUEST_BODY_NAME,
                     schema_to_validator(value["schema"]),
                     body.get("required", False),
                 )
         self.params = set(_get_fn_parameters(self.handler))
 
     async def parse(self, request: web.Request) -> Dict:
-        params = {}
+        params: Dict = {}
         if "request" in self.params:
             params["request"] = request
         request_key = self._swagger.request_key
@@ -157,29 +161,39 @@ class SwaggerRoute:
                         params[param.name] = value
         # body parameters
         if self.bp:
-            if "Content-Type" not in request.headers:
-                if next(iter(self.bp.values())).required:
-                    errors["body"] = "is required"
-            else:
-                media_type, _ = cgi.parse_header(request.headers["Content-Type"])
-                if media_type not in self.bp:
-                    errors["body"] = f"no handler for {media_type}"
+            if request.body_exists:
+                if "Content-Type" not in request.headers:
+                    if next(iter(self.bp.values())).required:
+                        errors[REQUEST_BODY_NAME] = "is required"
                 else:
-                    handler = self._swagger._get_media_type_handler(media_type)
-                    param = self.bp[media_type]
-                    try:
-                        v, has_raw = await handler(request)
-                    except ValidatorError as e:
-                        errors[param.name] = e.error
+                    media_type, _ = cgi.parse_header(request.headers["Content-Type"])
+                    if media_type not in self.bp:
+                        errors[REQUEST_BODY_NAME] = f"no handler for {media_type}"
                     else:
+                        handler = self._swagger._get_media_type_handler(media_type)
+                        param = self.bp[media_type]
                         try:
-                            value = param.validator.validate(v, has_raw)
+                            v, has_raw = await handler(request)
                         except ValidatorError as e:
                             errors[param.name] = e.error
                         else:
-                            request[request_key][param.name] = value
-                            if param.name in self.params:
-                                params[param.name] = value
+                            try:
+                                value = param.validator.validate(v, has_raw)
+                            except ValidatorError as e:
+                                errors[param.name] = e.error
+                            else:
+                                request[request_key][param.name] = value
+                                if param.name in self.params:
+                                    params[param.name] = value
+
+            elif self.is_body_required:
+                errors[REQUEST_BODY_NAME] = "is required"
+
+            else:
+                request[request_key][REQUEST_BODY_NAME] = None
+                if REQUEST_BODY_NAME in self.params:
+                    params[REQUEST_BODY_NAME] = None
+
         # header parameters
         if self.hp:
             for param in self.hp:
diff --git a/docs/conf.py b/docs/conf.py
index ac1b90c..ab151ec 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -18,11 +18,11 @@ sys.path.insert(0, os.path.abspath('..'))
 # -- Project information -----------------------------------------------------
 
 project = 'aiohttp-swagger3'
-copyright = '2021, Konstantin Valetov'
+copyright = '2022, Konstantin Valetov'
 author = 'Konstantin Valetov'
 
 # The full version, including alpha/beta/rc tags
-release = '0.7.0'
+release = '0.7.1'
 
 
 # -- General configuration ---------------------------------------------------


Test patch:
diff --git a/tests/test_docs_request_bodies.py b/tests/test_docs_request_bodies.py
index 5371a31..22e70d5 100644
--- a/tests/test_docs_request_bodies.py
+++ b/tests/test_docs_request_bodies.py
@@ -1211,6 +1211,44 @@ async def test_no_content_type(swagger_docs, aiohttp_client):
     assert await resp.json() is None
 
 
+async def test_no_content_type_body_required(swagger_docs, aiohttp_client):
+    async def handler(request, body: Dict):
+        """
+        ---
+        requestBody:
+          required: true
+          content:
+            application/json:
+              schema:
+                type: object
+                required:
+                  - required
+                properties:
+                  required:
+                    type: integer
+
+        responses:
+          '200':
+            description: OK.
+
+        """
+        return web.json_response(body)
+
+    swagger = swagger_docs()
+    swagger.add_route("POST", "/r", handler)
+
+    client = await aiohttp_client(swagger._app)
+
+    resp = await client.post("/r", skip_auto_headers=("Content-Type",))
+    assert resp.status == 400
+    error = error_to_json(await resp.text())
+    assert error == {"body": "is required"}
+
+    resp = await client.post("/r", skip_auto_headers=("Content-Type",), data="payload")
+    assert resp.status == 400
+    assert error == {"body": "is required"}
+
+
 async def test_required_no_content_type(swagger_docs, aiohttp_client):
     async def handler(request, body: Dict):
         """
@@ -1245,7 +1283,7 @@ async def test_required_no_content_type(swagger_docs, aiohttp_client):
     assert error == {"body": "is required"}
 
 
-async def test_optional_body(swagger_docs, aiohttp_client):
+async def test_no_handler(swagger_docs, aiohttp_client):
     async def handler(request, body: Optional[Dict] = None):
         """
         ---
@@ -1363,3 +1401,78 @@ async def test_nullable_ref(swagger_docs_with_components, aiohttp_client):
     resp = await client.post("/r", json=body)
     assert resp.status == 200
     assert await resp.json() == body
+
+
+async def test_optional_body_implicit(swagger_docs, aiohttp_client):
+    async def handler(request, body: Optional[Dict]):
+        """
+        ---
+        requestBody:
+          content:
+            application/json:
+              schema:
+                type: object
+                required:
+                  - required
+                properties:
+                  required:
+                    type: integer
+
+        responses:
+          '200':
+            description: OK.
+
+        """
+        return web.json_response(body)
+
+    swagger = swagger_docs()
+    swagger.add_route("POST", "/r", handler)
+
+    client = await aiohttp_client(swagger._app)
+
+    resp = await client.post("/r")
+    assert resp.status == 200
+    assert await resp.json() is None
+
+    body = {"required": 10}
+    resp = await client.post("/r", json=body)
+    assert resp.status == 200
+    assert await resp.json() == body
+
+
+async def test_optional_body_explicit(swagger_docs, aiohttp_client):
+    async def handler(request, body: Optional[Dict]):
+        """
+        ---
+        requestBody:
+          required: false
+          content:
+            application/json:
+              schema:
+                type: object
+                required:
+                  - required
+                properties:
+                  required:
+                    type: integer
+
+        responses:
+          '200':
+            description: OK.
+
+        """
+        return web.json_response(body)
+
+    swagger = swagger_docs()
+    swagger.add_route("POST", "/r", handler)
+
+    client = await aiohttp_client(swagger._app)
+
+    resp = await client.post("/r")
+    assert resp.status == 200
+    assert await resp.json() is None
+
+    body = {"required": 10}
+    resp = await client.post("/r", json=body)
+    assert resp.status == 200
+    assert await resp.json() == body