Repository: marshmallow-code/apispec

Issue:
Incorrect handling of marshmallow nested schema with allow_none=True in OpenAPI 3.0
This is a spinoff of #833, but for OpenAPI 3.0.

The following schemas:

```
from apispec import APISpec
from apispec.ext.marshmallow import MarshmallowPlugin
from marshmallow import Schema, fields

spec = APISpec(
    title="My API Spec",
    version="1.0.0",
    openapi_version="3.0.3",
    plugins=[MarshmallowPlugin()],
)


class FooSchema(Schema):
    bar = fields.Integer(required=True)


class MySchema(Schema):
    foo = fields.Nested(FooSchema, allow_none=True)
```

Generates the following OpenAPI spec:

```
{
  "Foo": {
    "type": "object",
    "properties": {
      "bar": {
        "type": "integer"
      }
    },
    "required": [
      "bar"
    ]
  },
  "MySchema": {
    "type": "object",
    "properties": {
      "foo": {
        "nullable": true,
        "allOf": [
          {
            "$ref": "#/components/schemas/Foo"
          }
        ]
      }
    }
  }
}
```

This looks correct at first glance, but isn't compliant with the `3.0.3` revision of OpenAPI, which clarified how `nullable` should work. See [Clarify Semantics of nullable in OpenAPI 3.0](https://github.com/OAI/OpenAPI-Specification/blob/main/proposals/2019-10-31-Clarify-Nullable.md#detailed-design) for details.

> nullable: true is only meaningful in combination with a type assertion specified in the same Schema Object. nullable acts as a type modifier, allowing null in addition to the specified type.
>
> nullable: true operates within a single Schema Object. It does not "override" or otherwise compete with supertype or subtype schemas defined with allOf or other applicators. It cannot be directly "inherited" through those applicators, and it cannot be applied to an inherited type constraint.

The above spec causes issues when used with data validators such as [openapi-core](https://github.com/python-openapi/openapi-core).

I think that the correct version of the above spec would look like:

```
{
  "Foo": {
    "type": "object",
    "properties": {
      "bar": {
        "type": "integer"
      }
    },
    "required": [
      "bar"
    ]
  },
  "MySchema": {
    "type": "object",
    "properties": {
      "foo": {
        "anyOf": [
          {
            "type": "object",
            "nullable": true
          },
          {
            "$ref": "#/components/schemas/Foo"
          }
        ]
      }
    }
  }
}
```

I've tested this with apispec 6.7.0 and marshmallow 3.23.1. I also have investigated a fix for this and am happy to open a PR if the above result is acceptable.

Discussion:

Patch:
diff --git a/src/apispec/ext/marshmallow/field_converter.py b/src/apispec/ext/marshmallow/field_converter.py
index a284f9b..63fbe96 100644
--- a/src/apispec/ext/marshmallow/field_converter.py
+++ b/src/apispec/ext/marshmallow/field_converter.py
@@ -301,9 +301,13 @@ class FieldConverterMixin:
             if self.openapi_version.major < 3:
                 attributes["x-nullable"] = True
             elif self.openapi_version.minor < 1:
-                attributes["nullable"] = True
                 if "$ref" in ret:
-                    attributes["allOf"] = [{"$ref": ret.pop("$ref")}]
+                    attributes["anyOf"] = [
+                        {"type": "object", "nullable": True},
+                        {"$ref": ret.pop("$ref")},
+                    ]
+                else:
+                    attributes["nullable"] = True
             else:
                 if "$ref" in ret:
                     attributes["anyOf"] = [{"$ref": ret.pop("$ref")}, {"type": "null"}]
@@ -580,9 +584,11 @@ class FieldConverterMixin:
                 ret = {
                     "type": "string",
                     "format": None,
-                    "pattern": field.metadata["pattern"]
-                    if field.metadata.get("pattern")
-                    else None,
+                    "pattern": (
+                        field.metadata["pattern"]
+                        if field.metadata.get("pattern")
+                        else None
+                    ),
                 }
         return ret
 


Test patch:
diff --git a/tests/test_ext_marshmallow_field.py b/tests/test_ext_marshmallow_field.py
index 20e079d..5e5250b 100644
--- a/tests/test_ext_marshmallow_field.py
+++ b/tests/test_ext_marshmallow_field.py
@@ -211,8 +211,10 @@ def test_nested_nullable(spec_fixture):
         assert res == {"$ref": "#/definitions/Child", "x-nullable": True}
     elif version.major == 3 and version.minor < 1:
         assert res == {
-            "nullable": True,
-            "allOf": [{"$ref": "#/components/schemas/Child"}],
+            "anyOf": [
+                {"type": "object", "nullable": True},
+                {"$ref": "#/components/schemas/Child"},
+            ]
         }
     else:
         assert res == {