Repository: marshmallow-code/apispec

Issue:
Minimum/maximum date is not jsonschema compliant
I'm using flask-apispec (but I think that the issue is related to apispec) to generate a swagger 2.0 schema from  webargs/marshmallow models that include datetimes with min/max boundaries like this:

```
mydate = fields.DateTime(
    required=True,
    validate=validate.Range(
        max=datetime.now(pytz.utc).replace(hour=23, minute=59, second=59),
        min=datetime(1900, 1, 1, tzinfo=pytz.utc),
        max_inclusive=True
    )
)
```

The generated json shema is:

```
"mydate": {
    "format": "date-time",
    "maximum": "Tue, 08 Dec 2020 23:59:59 GMT",
    "minimum": "Mon, 01 Jan 1900 00:00:00 GMT",
    "type": "string"
},
```

But when I run schemathesis on the generated schema I obtain a validation error

```
E   jsonschema.exceptions.ValidationError: 'Tue, 08 Dec 2020 23:59:59 GMT' is not of type 'number'
E  
E   Failed validating 'type' in schema['properties']['definitions']['additionalProperties']['properties']['properties']['additionalProperties']['properties']['maximum']:
E       {'type': 'number'}
E  
E   On instance['definitions']['Input']['properties']['mydate']['maximum']:
E       'Tue, 08 Dec 2020 23:59:59 GMT'
```

Schemathesis is right, because minimum and maximum values are expected to be "JSON numbers" (https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-5.1.3) and not datetimes

On the other side having min and max date validation is a very appreciated feature, so the option to avoid to set these boundaries on the models is not really feasible for my use cases. Furthermore the use of schemathesis is very helpful for my test suite and I would like to continue to use it.

Is there something that I can modify / configure to obtain jsonschema compliant models from webargs/marshmallow schema with datetimes min/max values?

Or maybe is there something that apispec could fix to obtain the result? (for example convert min/max datetimes into timestamps? Having a number should be adherent to the schema specification)

If not possible, I could try to open an issue on schemathesis to ask for some compatibility fixes. They already include [compatibility fixes for fastapi](https://schemathesis.readthedocs.io/en/stable/compatibility.html#using-fastapi) (that by using pydantic produces Draft 7 compatible schemas, while schemathesis validates against Draft 4 and Wright Draft 00) so maybe they would be interested in adding some compatibility fixes also for apispec. But in this case I would like to ask for your help to support the request by providing your motivations for not meeting the specifications or other information that they could ask to me.

I really thank you for your help

Comments:

Patch:
diff --git a/src/apispec/ext/marshmallow/field_converter.py b/src/apispec/ext/marshmallow/field_converter.py
index 0b3798a..8583ec0 100644
--- a/src/apispec/ext/marshmallow/field_converter.py
+++ b/src/apispec/ext/marshmallow/field_converter.py
@@ -83,6 +83,8 @@ class FieldConverterMixin:
 
     def init_attribute_functions(self):
         self.attribute_functions = [
+            # self.field2type_and_format should run first
+            # as other functions may rely on its output
             self.field2type_and_format,
             self.field2default,
             self.field2choices,
@@ -272,7 +274,7 @@ class FieldConverterMixin:
             ] = True
         return attributes
 
-    def field2range(self, field, **kwargs):
+    def field2range(self, field, ret):
         """Return the dictionary of OpenAPI field attributes for a set of
         :class:`Range <marshmallow.validators.Range>` validators.
 
@@ -289,19 +291,12 @@ class FieldConverterMixin:
             )
         ]
 
-        attributes = {}
-        for validator in validators:
-            if validator.min is not None:
-                if hasattr(attributes, "minimum"):
-                    attributes["minimum"] = max(attributes["minimum"], validator.min)
-                else:
-                    attributes["minimum"] = validator.min
-            if validator.max is not None:
-                if hasattr(attributes, "maximum"):
-                    attributes["maximum"] = min(attributes["maximum"], validator.max)
-                else:
-                    attributes["maximum"] = validator.max
-        return attributes
+        min_attr, max_attr = (
+            ("minimum", "maximum")
+            if ret.get("type") in {"number", "integer"}
+            else ("x-minimum", "x-maximum")
+        )
+        return make_min_max_attributes(validators, min_attr, max_attr)
 
     def field2length(self, field, **kwargs):
         """Return the dictionary of OpenAPI field attributes for a set of
@@ -310,8 +305,6 @@ class FieldConverterMixin:
         :param Field field: A marshmallow field.
         :rtype: dict
         """
-        attributes = {}
-
         validators = [
             validator
             for validator in field.validators
@@ -328,23 +321,13 @@ class FieldConverterMixin:
         min_attr = "minItems" if is_array else "minLength"
         max_attr = "maxItems" if is_array else "maxLength"
 
-        for validator in validators:
-            if validator.min is not None:
-                if hasattr(attributes, min_attr):
-                    attributes[min_attr] = max(attributes[min_attr], validator.min)
-                else:
-                    attributes[min_attr] = validator.min
-            if validator.max is not None:
-                if hasattr(attributes, max_attr):
-                    attributes[max_attr] = min(attributes[max_attr], validator.max)
-                else:
-                    attributes[max_attr] = validator.max
-
-        for validator in validators:
-            if validator.equal is not None:
-                attributes[min_attr] = validator.equal
-                attributes[max_attr] = validator.equal
-        return attributes
+        equal_list = [
+            validator.equal for validator in validators if validator.equal is not None
+        ]
+        if equal_list:
+            return {min_attr: equal_list[0], max_attr: equal_list[0]}
+
+        return make_min_max_attributes(validators, min_attr, max_attr)
 
     def field2pattern(self, field, **kwargs):
         """Return the dictionary of OpenAPI field attributes for a set of
@@ -449,3 +432,23 @@ class FieldConverterMixin:
             if value_field:
                 ret["additionalProperties"] = self.field2property(value_field)
         return ret
+
+
+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
+    of the validator objects that attribute will be omitted.
+
+    :param validators list: A list of `Marshmallow` validator objects. Each
+        objct is inspected for a minimum and maximum values
+    :param min_attr string: The OpenAPI attribute for the minimum value
+    :param max_attr string: The OpenAPI attribute for the maximum value
+    """
+    attributes = {}
+    min_list = [validator.min for validator in validators if validator.min is not None]
+    max_list = [validator.max for validator in validators if validator.max is not None]
+    if min_list:
+        attributes[min_attr] = max(min_list)
+    if max_list:
+        attributes[max_attr] = min(max_list)
+    return attributes


Test patch:
diff --git a/tests/test_ext_marshmallow_openapi.py b/tests/test_ext_marshmallow_openapi.py
index 5879073..36c6ad0 100644
--- a/tests/test_ext_marshmallow_openapi.py
+++ b/tests/test_ext_marshmallow_openapi.py
@@ -1,4 +1,5 @@
 import pytest
+from datetime import datetime
 
 from marshmallow import fields, Schema, validate
 
@@ -498,6 +499,7 @@ class TestFieldValidation:
     class ValidationSchema(Schema):
         id = fields.Int(dump_only=True)
         range = fields.Int(validate=validate.Range(min=1, max=10))
+        range_no_upper = fields.Float(validate=validate.Range(min=1))
         multiple_ranges = fields.Int(
             validate=[
                 validate.Range(min=1),
@@ -523,11 +525,13 @@ class TestFieldValidation:
         equal_length = fields.Str(
             validate=[validate.Length(equal=5), validate.Length(min=1, max=10)]
         )
+        date_range = fields.DateTime(validate=validate.Range(min=datetime(1900, 1, 1),))
 
     @pytest.mark.parametrize(
         ("field", "properties"),
         [
             ("range", {"minimum": 1, "maximum": 10}),
+            ("range_no_upper", {"minimum": 1}),
             ("multiple_ranges", {"minimum": 3, "maximum": 7}),
             ("list_length", {"minItems": 1, "maxItems": 10}),
             ("custom_list_length", {"minItems": 1, "maxItems": 10}),
@@ -535,6 +539,7 @@ class TestFieldValidation:
             ("custom_field_length", {"minLength": 1, "maxLength": 10}),
             ("multiple_lengths", {"minLength": 3, "maxLength": 7}),
             ("equal_length", {"minLength": 5, "maxLength": 5}),
+            ("date_range", {"x-minimum": datetime(1900, 1, 1)}),
         ],
     )
     def test_properties(self, field, properties, spec):