Repository: hh-h/aiohttp-swagger3

Issue:
Combination of $ref with nullable: true: validation fails
Hello hh-h,

Just went into a validation error for some specific case which is supposed to be supported: combinating an existing model and make it nullable by using:

```yaml
nullable: true
allOf:
  - $ref: '#/components/schemas/SomeObject'
```

Working with other frameworks and swagger editor says it's valid so I guess it's a bug, despite being an edge case I admit ;)

Here is a complete Python script reproducing the error:

```python
import tempfile
from aiohttp import web

from aiohttp_swagger3 import SwaggerDocs, SwaggerUiSettings


swagger_spec = """
components:
  schemas:
    SomeObject:
      type: object
      properties:
        prop1:
          type: boolean
        prop2:
          type: string
      required:
      - prop1
      - prop2
"""


async def post_nullable_ref(request):
    """
    Example failing when combinating nullable: true and $ref with allOf

    This is supposed to be supported:
      * https://stackoverflow.com/a/48114924
      * https://github.com/OAI/OpenAPI-Specification/issues/1368

    Confirmed as being supported by connexion framewrok by a colleague
    ---
    summary: Combination of ref and nullable
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            properties:
              someProp:
                nullable: true
                allOf:
                - $ref: '#/components/schemas/SomeObject'
            required:
            - someProp
            example:
              someProp: null
    responses:
      "200":
        description: Worked
    """

    return web.json_response({"worked": True})


def main():
    app = web.Application()

    with tempfile.NamedTemporaryFile() as spec_fh:
        spec_fh.write(bytes(swagger_spec.strip(), "utf-8"))
        spec_fh.seek(0)
        swagger = SwaggerDocs(app, components=spec_fh.name, swagger_ui_settings=SwaggerUiSettings(path="/"))

    swagger.add_routes([web.post("/post_nullable_ref", post_nullable_ref)])

    web.run_app(app)


if __name__ == "__main__":
    main()
```

Best regards, Adam.

Discussion:
deepcode-ci-bot[bot]: ## DeepCode's analysis on [#c2921a](https://www.deepcode.ai/app/gh/hh-h/aiohttp-swagger3/07135b67c9729b9f1a0ee31e86c7c9c971025296/hh-h/aiohttp-swagger3/c2921a9554473bbf57752570168cb277fc4cb27d/pr/_/%2F/code/?utm_source=gh_review&c=0&w=0&i=1&) found:

- :information_source: **1** minor issue. :point_down:
- :heavy_check_mark: **1** issue was fixed.
## Top issues

<table>
  <thead>
    <tr>
      <th align="left">Description</th>
      <th align="left">Example fixes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td width="77%">Access to a protected member _app of a client class Occurrences: <ul><li><a href="https://github.com/hh-h/aiohttp-swagger3/blob/c2921a9554473bbf57752570168cb277fc4cb27d/tests/test_read_only.py#L53">test_read_only.py:53</a></li> <li><a href="https://github.com/hh-h/aiohttp-swagger3/blob/c2921a9554473bbf57752570168cb277fc4cb27d/tests/test_read_only.py#L105">test_read_only.py:105</a></li> <li><a href="https://github.com/hh-h/aiohttp-swagger3/blob/c2921a9554473bbf57752570168cb277fc4cb27d/tests/test_read_only.py#L173">test_read_only.py:173</a></li></ul></td>
      <td width="23%"><a href="https://www.deepcode.ai/app/gh/hh-h/aiohttp-swagger3/07135b67c9729b9f1a0ee31e86c7c9c971025296/hh-h/aiohttp-swagger3/c2921a9554473bbf57752570168cb277fc4cb27d/pr/_/%2Ftests%2Ftest_read_only.py/python%2Fpylint%2FW0212/code/?utm_source=gh_review&">:wrench: Example fixes</a></td>
    </tr>
  </tbody>
</table>


#### 👉 View analysis in [**DeepCode’s Dashboard**](https://www.deepcode.ai/app/gh/hh-h/aiohttp-swagger3/07135b67c9729b9f1a0ee31e86c7c9c971025296/hh-h/aiohttp-swagger3/c2921a9554473bbf57752570168cb277fc4cb27d/pr/_/%2F/code/?utm_source=gh_review&c=0&w=0&i=1&) | [_Configure the bot_](https://www.deepcode.ai/app/gh/?ownerconfig=hh-h)

codecov[bot]: # [Codecov](https://codecov.io/gh/hh-h/aiohttp-swagger3/pull/75?src=pr&el=h1) Report
> Merging [#75](https://codecov.io/gh/hh-h/aiohttp-swagger3/pull/75?src=pr&el=desc) (c2921a9) into [master](https://codecov.io/gh/hh-h/aiohttp-swagger3/commit/07135b67c9729b9f1a0ee31e86c7c9c971025296?el=desc) (07135b6) will **decrease** coverage by `0.14%`.
> The diff coverage is `94.73%`.

[![Impacted file tree graph](https://codecov.io/gh/hh-h/aiohttp-swagger3/pull/75/graphs/tree.svg?width=650&height=150&src=pr&token=vsUkD2f5wc)](https://codecov.io/gh/hh-h/aiohttp-swagger3/pull/75?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master      #75      +/-   ##
==========================================
- Coverage   98.85%   98.70%   -0.15%     
==========================================
  Files          13       13              
  Lines        1218     1233      +15     
==========================================
+ Hits         1204     1217      +13     
- Misses         14       16       +2     
```


| [Impacted Files](https://codecov.io/gh/hh-h/aiohttp-swagger3/pull/75?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [aiohttp\_swagger3/validators.py](https://codecov.io/gh/hh-h/aiohttp-swagger3/pull/75/diff?src=pr&el=tree#diff-YWlvaHR0cF9zd2FnZ2VyMy92YWxpZGF0b3JzLnB5) | `97.91% <94.64%> (-0.31%)` | :arrow_down: |
| [aiohttp\_swagger3/\_\_init\_\_.py](https://codecov.io/gh/hh-h/aiohttp-swagger3/pull/75/diff?src=pr&el=tree#diff-YWlvaHR0cF9zd2FnZ2VyMy9fX2luaXRfXy5weQ==) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/hh-h/aiohttp-swagger3/pull/75?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/hh-h/aiohttp-swagger3/pull/75?src=pr&el=footer). Last update [07135b6...c2921a9](https://codecov.io/gh/hh-h/aiohttp-swagger3/pull/75?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index bc99362..01d6daf 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,6 +1,12 @@
 Changelog
 =========
 
+0.5.3 (14-01-2021)
+------------------
+
+- allOf, oneOf, anyOf can be nullable
+- readOnly should affect only properties
+
 0.5.2 (31-12-2020)
 ------------------
 
diff --git a/aiohttp_swagger3/__init__.py b/aiohttp_swagger3/__init__.py
index 2cee630..ac3f7c8 100644
--- a/aiohttp_swagger3/__init__.py
+++ b/aiohttp_swagger3/__init__.py
@@ -8,7 +8,7 @@ __all__ = (
     "ValidatorError",
     "__version__",
 )
-__version__ = "0.5.2"
+__version__ = "0.5.3"
 __author__ = "Valetov Konstantin"
 
 from .exceptions import ValidatorError
diff --git a/aiohttp_swagger3/validators.py b/aiohttp_swagger3/validators.py
index 376ef68..db4e728 100644
--- a/aiohttp_swagger3/validators.py
+++ b/aiohttp_swagger3/validators.py
@@ -368,7 +368,7 @@ class Object(Validator):
         return value
 
 
-@attr.attrs(slots=True, frozen=True, eq=False, hash=False, auto_attribs=True)
+@attr.attrs(slots=True, frozen=True, eq=False, hash=False, auto_attribs=True, kw_only=True)
 class Discriminator(Validator):
     validators: List[Validator]
     discriminator: Optional[DiscriminatorObject] = attr.attrib(converter=to_discriminator)
@@ -395,9 +395,14 @@ class Discriminator(Validator):
             raise DiscriminatorValidationError
 
 
-@attr.attrs(slots=True, frozen=True, eq=False, hash=False, auto_attribs=True)
+@attr.attrs(slots=True, frozen=True, eq=False, hash=False, auto_attribs=True, kw_only=True)
 class OneOf(Discriminator):
+    nullable: bool = False
+
     def validate(self, raw_value: Any, raw: bool) -> Any:
+        if raw_value is None and self.nullable:
+            return raw_value
+
         if self.discriminator is not None:
             try:
                 return super().validate(raw_value, raw)
@@ -419,9 +424,14 @@ class OneOf(Discriminator):
         return value
 
 
-@attr.attrs(slots=True, frozen=True, eq=False, hash=False, auto_attribs=True)
+@attr.attrs(slots=True, frozen=True, eq=False, hash=False, auto_attribs=True, kw_only=True)
 class AnyOf(Discriminator):
+    nullable: bool = False
+
     def validate(self, raw_value: Any, raw: bool) -> Any:
+        if raw_value is None and self.nullable:
+            return raw_value
+
         if self.discriminator is not None:
             try:
                 return super().validate(raw_value, raw)
@@ -436,11 +446,15 @@ class AnyOf(Discriminator):
         raise ValidatorError("fail to validate anyOf")
 
 
-@attr.attrs(slots=True, frozen=True, eq=False, hash=False, auto_attribs=True)
+@attr.attrs(slots=True, frozen=True, eq=False, hash=False, auto_attribs=True, kw_only=True)
 class AllOf(Validator):
+    nullable: bool = False
     validators: List[Validator]
 
     def validate(self, raw_value: Any, raw: bool) -> Any:
+        if raw_value is None and self.nullable:
+            return raw_value
+
         value: Dict = {}
         for validator in self.validators:
             try:
@@ -562,10 +576,11 @@ class AllOfAuth(Validator):
         return values
 
 
-def to_integer(schema: Dict) -> Integer:
+def to_integer(schema: Dict, is_property: bool) -> Integer:
+    read_only = schema.get("readOnly", False) if is_property else False
     return Integer(
         nullable=schema.get("nullable", False),
-        readOnly=schema.get("readOnly", False),
+        readOnly=read_only,
         minimum=schema.get("minimum"),
         maximum=schema.get("maximum"),
         exclusiveMinimum=schema.get("exclusiveMinimum", False),
@@ -576,10 +591,11 @@ def to_integer(schema: Dict) -> Integer:
     )
 
 
-def to_number(schema: Dict) -> Number:
+def to_number(schema: Dict, is_property: bool) -> Number:
+    read_only = schema.get("readOnly", False) if is_property else False
     return Number(
         nullable=schema.get("nullable", False),
-        readOnly=schema.get("readOnly", False),
+        readOnly=read_only,
         minimum=schema.get("minimum"),
         maximum=schema.get("maximum"),
         exclusiveMinimum=schema.get("exclusiveMinimum", False),
@@ -590,11 +606,12 @@ def to_number(schema: Dict) -> Number:
     )
 
 
-def to_string(schema: Dict) -> String:
+def to_string(schema: Dict, is_property: bool) -> String:
+    read_only = schema.get("readOnly", False) if is_property else False
     return String(
         format=schema.get("format"),
         nullable=schema.get("nullable", False),
-        readOnly=schema.get("readOnly", False),
+        readOnly=read_only,
         minLength=schema.get("minLength"),
         maxLength=schema.get("maxLength"),
         enum=schema.get("enum"),
@@ -603,18 +620,20 @@ def to_string(schema: Dict) -> String:
     )
 
 
-def to_boolean(schema: Dict) -> Boolean:
+def to_boolean(schema: Dict, is_property: bool) -> Boolean:
+    read_only = schema.get("readOnly", False) if is_property else False
     return Boolean(
         nullable=schema.get("nullable", False),
-        readOnly=schema.get("readOnly", False),
+        readOnly=read_only,
         default=schema.get("default"),
     )
 
 
-def to_array(schema: Dict) -> Array:
+def to_array(schema: Dict, is_property: bool) -> Array:
+    read_only = schema.get("readOnly", False) if is_property else False
     return Array(
         nullable=schema.get("nullable", False),
-        readOnly=schema.get("readOnly", False),
+        readOnly=read_only,
         validator=schema_to_validator(schema["items"]),
         uniqueItems=schema.get("uniqueItems", False),
         minItems=schema.get("minItems"),
@@ -622,8 +641,8 @@ def to_array(schema: Dict) -> Array:
     )
 
 
-def to_object(schema: Dict) -> Object:
-    properties = {k: schema_to_validator(v) for k, v in schema.get("properties", {}).items()}
+def to_object(schema: Dict, is_property: bool) -> Object:
+    properties = {k: schema_to_validator(v, is_property=True) for k, v in schema.get("properties", {}).items()}
     raw_additional_properties = schema.get("additionalProperties", True)
     if isinstance(raw_additional_properties, dict):
         additional_properties = schema_to_validator(raw_additional_properties)
@@ -635,9 +654,10 @@ def to_object(schema: Dict) -> Object:
         if getattr(validator, "readOnly", False):
             required.discard(name)
 
+    read_only = schema.get("readOnly", False) if is_property else False
     return Object(
         nullable=schema.get("nullable", False),
-        readOnly=schema.get("readOnly", False),
+        readOnly=read_only,
         properties=properties,
         required=required,
         minProperties=schema.get("minProperties"),
@@ -656,15 +676,15 @@ _TYPE_TO_FACTORY = {
 }
 
 
-def _type_to_validator(schema: Dict) -> Validator:
+def _type_to_validator(schema: Dict, *, is_property: bool) -> Validator:
     if "type" not in schema:
         raise KeyError("type is required")
     if schema["type"] not in _TYPE_TO_FACTORY:
         raise Exception(f"Unknown type '{schema['type']}'")
-    return _TYPE_TO_FACTORY[schema["type"]](schema)
+    return _TYPE_TO_FACTORY[schema["type"]](schema, is_property)
 
 
-def schema_to_validator(schema: Dict) -> Validator:
+def schema_to_validator(schema: Dict, *, is_property: bool = False) -> Validator:
     if "$ref" in schema:
         components = COMPONENTS.get()
         if not components:
@@ -672,42 +692,46 @@ def schema_to_validator(schema: Dict) -> Validator:
         # #/components/schemas/Pet
         *_, section, obj = schema["$ref"].split("/")
         schema = components[section][obj]
-    if "oneOf" in schema or "anyOf" in schema or "allOf" in schema:
-        if "oneOf" in schema:
-            cls: Type[Discriminator] = OneOf
-            type_ = "oneOf"
-        elif "anyOf" in schema:
-            cls = AnyOf
-            type_ = "anyOf"
-        else:
-            return AllOf(
-                validators=[schema_to_validator(sch) for sch in schema["allOf"]],
-            )
 
-        validators = []
-        discriminator = schema.get("discriminator")
-        mapping: Dict[str, int] = {}
-        schema_names: Set[str] = set()
-        for i, sch in enumerate(schema[type_]):
-            validators.append(schema_to_validator(sch))
-            if discriminator is not None and "$ref" in sch:
-                # #/components/schemas/Pet
-                *_, obj = sch["$ref"].split("/")
-                mapping[obj] = i
-                schema_names.add(obj)
-        if discriminator is not None and "mapping" in discriminator:
-            for key, value in discriminator["mapping"].items():
-                if value.startswith("#"):
-                    value = value.split("/")[-1]
-                    discriminator["mapping"][key] = value
-                if value not in schema_names:
-                    raise Exception(f"schema '{value}' must be defined in components")
-        return cls(
-            validators=[schema_to_validator(sch) for sch in schema[type_]],
-            discriminator=schema.get("discriminator"),
-            mapping=mapping,
+    if not any(field in schema for field in ("oneOf", "anyOf", "allOf")):
+        return _type_to_validator(schema, is_property=is_property)
+
+    if "oneOf" in schema:
+        cls: Type[Union[OneOf, AnyOf]] = OneOf
+        type_ = "oneOf"
+    elif "anyOf" in schema:
+        cls = AnyOf
+        type_ = "anyOf"
+    else:
+        return AllOf(
+            nullable=schema.get("nullable", False),
+            validators=[schema_to_validator(sch) for sch in schema["allOf"]],
         )
-    return _type_to_validator(schema)
+
+    validators = []
+    discriminator = schema.get("discriminator")
+    mapping: Dict[str, int] = {}
+    schema_names: Set[str] = set()
+    for i, sch in enumerate(schema[type_]):
+        validators.append(schema_to_validator(sch))
+        if discriminator is not None and "$ref" in sch:
+            # #/components/schemas/Pet
+            *_, obj = sch["$ref"].split("/")
+            mapping[obj] = i
+            schema_names.add(obj)
+    if discriminator is not None and "mapping" in discriminator:
+        for key, value in discriminator["mapping"].items():
+            if value.startswith("#"):
+                value = value.split("/")[-1]
+                discriminator["mapping"][key] = value
+            if value not in schema_names:
+                raise Exception(f"schema '{value}' must be defined in components")
+    return cls(
+        nullable=schema.get("nullable", False),
+        validators=[schema_to_validator(sch) for sch in schema[type_]],
+        discriminator=schema.get("discriminator"),
+        mapping=mapping,
+    )
 
 
 def _security_to_validator(sec_name: str, components: Dict) -> Validator:
diff --git a/docs/conf.py b/docs/conf.py
index 771f395..30b5ae0 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -22,7 +22,7 @@ copyright = '2020, Konstantin Valetov'
 author = 'Konstantin Valetov'
 
 # The full version, including alpha/beta/rc tags
-release = '0.5.2'
+release = '0.5.3'
 
 
 # -- General configuration ---------------------------------------------------


Test patch:
diff --git a/tests/test_docs_request_bodies.py b/tests/test_docs_request_bodies.py
index ecba70a..81c4306 100644
--- a/tests/test_docs_request_bodies.py
+++ b/tests/test_docs_request_bodies.py
@@ -1314,132 +1314,7 @@ async def test_wrong_body(swagger_docs, aiohttp_client):
     assert error == {"body": "no handler for application/json"}
 
 
-async def test_object_read_only_properties_skipped(swagger_docs, aiohttp_client):
-
-    routes = web.RouteTableDef()
-
-    @routes.post("/r")
-    async def handler(request, body: Dict):
-        """
-        ---
-        requestBody:
-          required: true
-          content:
-            application/json:
-              schema:
-                type: object
-                properties:
-                  boolean:
-                    type: boolean
-                    readOnly: true
-                  integer:
-                    type: integer
-                    readOnly: true
-                  number:
-                    type: number
-                    readOnly: true
-                  string:
-                    type: string
-                    readOnly: true
-                  array:
-                    type: array
-                    items:
-                      type: string
-                    readOnly: true
-                  object:
-                    type: object
-                    readOnly: true
-
-        responses:
-          '200':
-            description: OK.
-        """
-        return web.json_response(body)
-
-    swagger = swagger_docs()
-    swagger.add_routes(routes)
-
-    client = await aiohttp_client(swagger._app)
-
-    resp = await client.post("/r", json={})
-    assert resp.status == 200
-    assert await resp.json() == {}
-
-
-async def test_object_read_only_properties_passed(swagger_docs, aiohttp_client):
-
-    routes = web.RouteTableDef()
-
-    @routes.post("/r")
-    async def handler(request, body: Dict):
-        """
-        ---
-        requestBody:
-          required: true
-          content:
-            application/json:
-              schema:
-                type: object
-                properties:
-                  boolean:
-                    type: boolean
-                    readOnly: true
-                  integer:
-                    type: integer
-                    readOnly: true
-                  number:
-                    type: number
-                    readOnly: true
-                  string:
-                    type: string
-                    readOnly: true
-                  array:
-                    type: array
-                    items:
-                      type: string
-                    readOnly: true
-                  object:
-                    type: object
-                    readOnly: true
-
-        responses:
-          '200':
-            description: OK.
-        """
-        return web.json_response(body)
-
-    swagger = swagger_docs()
-    swagger.add_routes(routes)
-
-    client = await aiohttp_client(swagger._app)
-
-    resp = await client.post(
-        "/r",
-        json={
-            "boolean": True,
-            "integer": 10,
-            "number": 1.1,
-            "string": "str",
-            "array": [1, 2, 3],
-            "object": {"some": "thing"},
-        },
-    )
-    assert resp.status == 400
-    error = error_to_json(await resp.text())
-    msg = "property is read-only"
-    assert error == {
-        "body": {
-            "boolean": msg,
-            "integer": msg,
-            "number": msg,
-            "string": msg,
-            "array": msg,
-            "object": msg,
-        }
-    }
-
-
-async def test_object_required_read_only_properties(swagger_docs, aiohttp_client):
+async def test_nullable_ref(swagger_docs_with_components, aiohttp_client):
 
     routes = web.RouteTableDef()
 
@@ -1454,21 +1329,12 @@ async def test_object_required_read_only_properties(swagger_docs, aiohttp_client
               schema:
                 type: object
                 required:
-                  - id
-                  - name
-                  - description
-                  - updated_at
+                  - pet
                 properties:
-                  id:
-                    type: integer
-                    readOnly: true
-                  name:
-                    type: string
-                  description:
-                    type: string
-                  updated_at:
-                    type: integer
-                    readOnly: true
+                  pet:
+                    nullable: true
+                    allOf:
+                      - $ref: '#/components/schemas/Pet'
 
         responses:
           '200':
@@ -1476,33 +1342,24 @@ async def test_object_required_read_only_properties(swagger_docs, aiohttp_client
         """
         return web.json_response(body)
 
-    swagger = swagger_docs()
+    swagger = swagger_docs_with_components()
     swagger.add_routes(routes)
 
     client = await aiohttp_client(swagger._app)
 
     body = {
-        "name": "name1",
-        "description": "description",
+        "pet": None,
     }
     resp = await client.post("/r", json=body)
     assert resp.status == 200
     assert await resp.json() == body
 
-    resp = await client.post(
-        "/r",
-        json={
-            "id": 1,
-            "name": "name1",
-            "description": "description",
-            "updated_at": 1609407277,
-        },
-    )
-    assert resp.status == 400
-    error = error_to_json(await resp.text())
-    assert error == {
-        "body": {
-            "id": "property is read-only",
-            "updated_at": "property is read-only",
+    body = {
+        "pet": {
+            "name": "lizzy",
+            "age": 12,
         }
     }
+    resp = await client.post("/r", json=body)
+    assert resp.status == 200
+    assert await resp.json() == body
diff --git a/tests/test_read_only.py b/tests/test_read_only.py
new file mode 100644
index 0000000..fd5be25
--- /dev/null
+++ b/tests/test_read_only.py
@@ -0,0 +1,239 @@
+from typing import Dict
+
+from aiohttp import web
+
+from .helpers import error_to_json
+
+
+async def test_object_read_only_properties_skipped(swagger_docs, aiohttp_client):
+
+    routes = web.RouteTableDef()
+
+    @routes.post("/r")
+    async def handler(request, body: Dict):
+        """
+        ---
+        requestBody:
+          required: true
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  boolean:
+                    type: boolean
+                    readOnly: true
+                  integer:
+                    type: integer
+                    readOnly: true
+                  number:
+                    type: number
+                    readOnly: true
+                  string:
+                    type: string
+                    readOnly: true
+                  array:
+                    type: array
+                    items:
+                      type: string
+                    readOnly: true
+                  object:
+                    type: object
+                    readOnly: true
+
+        responses:
+          '200':
+            description: OK.
+        """
+        return web.json_response(body)
+
+    swagger = swagger_docs()
+    swagger.add_routes(routes)
+
+    client = await aiohttp_client(swagger._app)
+
+    resp = await client.post("/r", json={})
+    assert resp.status == 200
+    assert await resp.json() == {}
+
+
+async def test_object_read_only_properties_passed(swagger_docs, aiohttp_client):
+
+    routes = web.RouteTableDef()
+
+    @routes.post("/r")
+    async def handler(request, body: Dict):
+        """
+        ---
+        requestBody:
+          required: true
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  boolean:
+                    type: boolean
+                    readOnly: true
+                  integer:
+                    type: integer
+                    readOnly: true
+                  number:
+                    type: number
+                    readOnly: true
+                  string:
+                    type: string
+                    readOnly: true
+                  array:
+                    type: array
+                    items:
+                      type: string
+                    readOnly: true
+                  object:
+                    type: object
+                    readOnly: true
+
+        responses:
+          '200':
+            description: OK.
+        """
+        return web.json_response(body)
+
+    swagger = swagger_docs()
+    swagger.add_routes(routes)
+
+    client = await aiohttp_client(swagger._app)
+
+    resp = await client.post(
+        "/r",
+        json={
+            "boolean": True,
+            "integer": 10,
+            "number": 1.1,
+            "string": "str",
+            "array": [1, 2, 3],
+            "object": {"some": "thing"},
+        },
+    )
+    assert resp.status == 400
+    error = error_to_json(await resp.text())
+    msg = "property is read-only"
+    assert error == {
+        "body": {
+            "boolean": msg,
+            "integer": msg,
+            "number": msg,
+            "string": msg,
+            "array": msg,
+            "object": msg,
+        }
+    }
+
+
+async def test_object_required_read_only_properties(swagger_docs, aiohttp_client):
+
+    routes = web.RouteTableDef()
+
+    @routes.post("/r")
+    async def handler(request, body: Dict):
+        """
+        ---
+        requestBody:
+          required: true
+          content:
+            application/json:
+              schema:
+                type: object
+                required:
+                  - id
+                  - name
+                  - description
+                  - updated_at
+                properties:
+                  id:
+                    type: integer
+                    readOnly: true
+                  name:
+                    type: string
+                  description:
+                    type: string
+                  updated_at:
+                    type: integer
+                    readOnly: true
+
+        responses:
+          '200':
+            description: OK.
+        """
+        return web.json_response(body)
+
+    swagger = swagger_docs()
+    swagger.add_routes(routes)
+
+    client = await aiohttp_client(swagger._app)
+
+    body = {
+        "name": "name1",
+        "description": "description",
+    }
+    resp = await client.post("/r", json=body)
+    assert resp.status == 200
+    assert await resp.json() == body
+
+    resp = await client.post(
+        "/r",
+        json={
+            "id": 1,
+            "name": "name1",
+            "description": "description",
+            "updated_at": 1609407277,
+        },
+    )
+    assert resp.status == 400
+    error = error_to_json(await resp.text())
+    assert error == {
+        "body": {
+            "id": "property is read-only",
+            "updated_at": "property is read-only",
+        }
+    }
+
+
+async def test_ignore_read_only(swagger_docs, aiohttp_client):
+    async def handler(request, param_id: int, q: int):
+        """
+        ---
+        parameters:
+
+          - name: param_id
+            in: path
+            required: true
+            schema:
+              type: integer
+              readOnly: true
+
+          - name: q
+            in: query
+            required: true
+            schema:
+              type: integer
+              readOnly: true
+
+        responses:
+          '200':
+            description: OK.
+
+        """
+        return web.json_response({"param_id": param_id, "q": q})
+
+    swagger = swagger_docs()
+    swagger.add_route("GET", "/r/{param_id}", handler)
+
+    client = await aiohttp_client(swagger._app)
+
+    params = {
+        "q": 15,
+    }
+    resp = await client.get("/r/10", params=params)
+    assert resp.status == 200
+    assert await resp.json() == {"param_id": 10, "q": 15}