Repository: marshmallow-code/apispec

Issue:
OpenAPI 3.1 support
There's been a 3.1.0 RC release and they decided to make breaking changes.

https://github.com/OAI/OpenAPI-Specification/releases

It didn't go through all the changes yet to see if and how we are impacted.

At least, since they don't follow semver anymore and things can change in minor versions, we may have to modify some functions to pass the version object or string rather than just the major version as int.

Also I did a quick search to find an EOL for OAS v2 and I didn't find anything. Maybe there is no need for an EOL on a spec, as opposed to a code library.

Discussion:

Patch:
diff --git a/src/apispec/ext/marshmallow/field_converter.py b/src/apispec/ext/marshmallow/field_converter.py
index b5e6ba2..620ffc7 100644
--- a/src/apispec/ext/marshmallow/field_converter.py
+++ b/src/apispec/ext/marshmallow/field_converter.py
@@ -261,7 +261,7 @@ class FieldConverterMixin:
             attributes["writeOnly"] = True
         return attributes
 
-    def field2nullable(self, field, **kwargs):
+    def field2nullable(self, field, ret):
         """Return the dictionary of OpenAPI field attributes for a nullable field.
 
         :param Field field: A marshmallow field.
@@ -269,9 +269,12 @@ class FieldConverterMixin:
         """
         attributes = {}
         if field.allow_none:
-            attributes[
-                "x-nullable" if self.openapi_version.major < 3 else "nullable"
-            ] = True
+            if self.openapi_version.major < 3:
+                attributes["x-nullable"] = True
+            elif self.openapi_version.minor < 1:
+                attributes["nullable"] = True
+            else:
+                attributes["type"] = make_type_list(ret.get("type")) + ["'null'"]
         return attributes
 
     def field2range(self, field, ret):
@@ -293,7 +296,7 @@ class FieldConverterMixin:
 
         min_attr, max_attr = (
             ("minimum", "maximum")
-            if ret.get("type") in {"number", "integer"}
+            if set(make_type_list(ret.get("type"))) & {"number", "integer"}
             else ("x-minimum", "x-maximum")
         )
         return make_min_max_attributes(validators, min_attr, max_attr)
@@ -437,6 +440,21 @@ class FieldConverterMixin:
         return ret
 
 
+def make_type_list(types):
+    """Return a list of types from a type attribute
+
+    Since OpenAPI 3.1.0, "type" can be a single type as string or a list of
+    types, including 'null'. This function takes a "type" attribute as input
+    and returns it as a list, be it an empty or single-element list.
+    This is useful to factorize type-conditional code or code adding a type.
+    """
+    if types is None:
+        return []
+    if isinstance(types, str):
+        return [types]
+    return types
+
+
 def make_min_max_attributes(validators, min_attr, max_attr):
     """Return a dictionary of minimum and maximum attributes based on a list
     of validators. If either minimum or maximum values are not present in any


Test patch:
diff --git a/tests/test_ext_marshmallow_field.py b/tests/test_ext_marshmallow_field.py
index 5f26c55..cd5b201 100644
--- a/tests/test_ext_marshmallow_field.py
+++ b/tests/test_ext_marshmallow_field.py
@@ -162,13 +162,60 @@ def test_field_with_additional_metadata(spec_fixture):
     assert res["minLength"] == 6
 
 
+@pytest.mark.parametrize("spec_fixture", ("2.0", "3.0.0", "3.1.0"), indirect=True)
 def test_field_with_allow_none(spec_fixture):
     field = fields.Str(allow_none=True)
     res = spec_fixture.openapi.field2property(field)
     if spec_fixture.openapi.openapi_version.major < 3:
         assert res["x-nullable"] is True
-    else:
+    elif spec_fixture.openapi.openapi_version.minor < 1:
         assert res["nullable"] is True
+    else:
+        assert "nullable" not in res
+        assert res["type"] == ["string", "'null'"]
+
+
+def test_field_with_range_no_type(spec_fixture):
+    field = fields.Field(validate=validate.Range(min=1, max=10))
+    res = spec_fixture.openapi.field2property(field)
+    assert res["x-minimum"] == 1
+    assert res["x-maximum"] == 10
+    assert "type" not in res
+
+
+@pytest.mark.parametrize("field", (fields.Number, fields.Integer))
+def test_field_with_range_string_type(spec_fixture, field):
+    field = field(validate=validate.Range(min=1, max=10))
+    res = spec_fixture.openapi.field2property(field)
+    assert res["minimum"] == 1
+    assert res["maximum"] == 10
+    assert isinstance(res["type"], str)
+
+
+@pytest.mark.parametrize("spec_fixture", ("3.1.0",), indirect=True)
+def test_field_with_range_type_list_with_number(spec_fixture):
+    @spec_fixture.openapi.map_to_openapi_type(["integer", "'null'"], None)
+    class NullableInteger(fields.Field):
+        """Nullable integer"""
+
+    field = NullableInteger(validate=validate.Range(min=1, max=10))
+    res = spec_fixture.openapi.field2property(field)
+    assert res["minimum"] == 1
+    assert res["maximum"] == 10
+    assert res["type"] == ["integer", "'null'"]
+
+
+@pytest.mark.parametrize("spec_fixture", ("3.1.0",), indirect=True)
+def test_field_with_range_type_list_without_number(spec_fixture):
+    @spec_fixture.openapi.map_to_openapi_type(["string", "'null'"], None)
+    class NullableInteger(fields.Field):
+        """Nullable integer"""
+
+    field = NullableInteger(validate=validate.Range(min=1, max=10))
+    res = spec_fixture.openapi.field2property(field)
+    assert res["x-minimum"] == 1
+    assert res["x-maximum"] == 10
+    assert res["type"] == ["string", "'null'"]
 
 
 def test_field_with_str_regex(spec_fixture):