Skip to content

Commit

Permalink
Add support for the multiple formats of marshmallow.fields.DateTime
Browse files Browse the repository at this point in the history
  • Loading branch information
Sebastien LOVERGNE committed Feb 15, 2023
1 parent 5f356b3 commit c56a7ee
Show file tree
Hide file tree
Showing 4 changed files with 141 additions and 4 deletions.
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,4 @@ Contributors (chronological)
- `<https://github.com/kasium>`_
- Edwin Erdmanis `@vorticity <https://github.com/vorticity>`_
- Mounier Florian `@paradoxxxzero <https://github.com/paradoxxxzero>`_
- Sebastien Lovergne `@TheBigRoomXXL <https://github.com/TheBigRoomXXL>`_
16 changes: 16 additions & 0 deletions docs/using_plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,22 @@ Schema Modifiers

apispec will respect schema modifiers such as ``exclude`` and ``partial`` in the generated schema definition. If a schema is initialized with modifiers, apispec will treat each combination of modifiers as a unique schema definition.

Custom DateTime format
*************

apispec support all four basic formats of `marshmallow.fields.DateTime`: ``"rfc"`` (for RFC822), ``"iso"`` (for ISO8601),
``"timestamp"``, ``"timestamp_ms"`` (for a POSIX timestamp).

If you are using a custom DateTime format you should pass a regex string to the ``pattern`` parametter in your field ``metadata``.

.. code-block:: python
class SchemaWithCustomDate(Schema):
french_date = ma.DateTime(
format="%d-%m%Y %H:%M:%S",
metadata={"pattern": r"^\d{2}-\d{2}-\d{4} \d{2}:\d{2}:\d{2}$"},
)
Custom Fields
*************

Expand Down
43 changes: 43 additions & 0 deletions src/apispec/ext/marshmallow/field_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ def init_attribute_functions(self):
self.list2properties,
self.dict2properties,
self.timedelta2properties,
self.datetime2properties,
]

def map_to_openapi_type(self, field_cls, *args):
Expand Down Expand Up @@ -516,6 +517,48 @@ def enum2properties(self, field, **kwargs: typing.Any) -> dict:
ret["enum"] = [field.field._serialize(v, None, None) for v in choices]
return ret

def datetime2properties(self, field, **kwargs: typing.Any) -> dict:
"""Return a dictionary of properties from :class:`DateTime <marshmallow.fields.DateTime` fields.
:param Field field: A marshmallow field.
:rtype: dict
"""
ret = {}
if isinstance(field, marshmallow.fields.DateTime) and not isinstance(
field, marshmallow.fields.Date
):
if field.format == "iso" or field.format is None:
# Will return { "type": "string", "format": "date-time" }
# as specified inside DEFAULT_FIELD_MAPPING
pass
elif field.format == "rfc":
ret = {
"type": "string",
"format": None,
"example": "Wed, 02 Oct 2002 13:00:00 GMT",
"pattern": r"((Mon|Tue|Wed|Thu|Fri|Sat|Sun), ){0,1}\d{2} "
+ r"(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} "
+ r"(UT|GMT|EST|EDT|CST|CDT|MST|MDT|PST|PDT|(Z|A|M|N)|(\+|-)\d{4})",
}
elif "timestamp" in field.format:
ret = {
"type": "number",
"format": "float",
"example": "1676451245.596",
"min": "0",
}
if "_ms" in field.format:
ret["example"] = "1676451277514.654"
else:
ret = {
"type": "string",
"format": None,
"pattern": field.metadata["pattern"]
if field.metadata.get("pattern")
else None,
}
return ret


def make_type_list(types):
"""Return a list of types from a type attribute
Expand Down
85 changes: 81 additions & 4 deletions tests/test_ext_marshmallow_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def test_field2choices_preserving_order(openapi):
(fields.Boolean, "boolean"),
(fields.Bool, "boolean"),
(fields.UUID, "string"),
(fields.DateTime, "string"),
(fields.DateTime, ["string", "integer"]),
(fields.Date, "string"),
(fields.Time, "string"),
(fields.TimeDelta, "integer"),
Expand All @@ -40,7 +40,10 @@ def test_field2choices_preserving_order(openapi):
def test_field2property_type(FieldClass, jsontype, spec_fixture):
field = FieldClass()
res = spec_fixture.openapi.field2property(field)
assert res["type"] == jsontype
if isinstance(jsontype, list):
assert res["type"] in jsontype
else:
assert res["type"] == jsontype


@pytest.mark.parametrize("FieldClass", [fields.Field, fields.Raw])
Expand All @@ -62,7 +65,7 @@ def test_formatted_field_translates_to_array(ListClass, spec_fixture):
("FieldClass", "expected_format"),
[
(fields.UUID, "uuid"),
(fields.DateTime, "date-time"),
(fields.DateTime, ["date-time", "timestamp", "timestamp_ms"]),
(fields.Date, "date"),
(fields.Email, "email"),
(fields.URL, "url"),
Expand All @@ -71,7 +74,11 @@ def test_formatted_field_translates_to_array(ListClass, spec_fixture):
def test_field2property_formats(FieldClass, expected_format, spec_fixture):
field = FieldClass()
res = spec_fixture.openapi.field2property(field)
assert res["format"] == expected_format

if isinstance(expected_format, list):
assert res["format"] in expected_format
else:
assert res["format"] == expected_format


def test_field_with_description(spec_fixture):
Expand Down Expand Up @@ -364,6 +371,76 @@ def test_nested_field_with_property(spec_fixture):
}


def test_datetime2property_iso(spec_fixture):
field = fields.DateTime(format="iso")
res = spec_fixture.openapi.field2property(field)
print(test_datetime2property_iso)
assert res == {
"type": "string",
"format": "date-time",
}


def test_datetime2property_rfc(spec_fixture):
field = fields.DateTime(format="rfc")
res = spec_fixture.openapi.field2property(field)
assert res == {
"type": "string",
"format": None,
"example": "Wed, 02 Oct 2002 13:00:00 GMT",
"pattern": r"((Mon|Tue|Wed|Thu|Fri|Sat|Sun), ){0,1}\d{2} "
+ r"(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} "
+ r"(UT|GMT|EST|EDT|CST|CDT|MST|MDT|PST|PDT|(Z|A|M|N)|(\+|-)\d{4})",
}


def test_datetime2property_timestamp(spec_fixture):
field = fields.DateTime(format="timestamp")
res = spec_fixture.openapi.field2property(field)
assert res == {
"type": "number",
"format": "float",
"min": "0",
"example": "1676451245.596",
}


def test_datetime2property_timestamp_ms(spec_fixture):
field = fields.DateTime(format="timestamp_ms")
res = spec_fixture.openapi.field2property(field)
assert res == {
"type": "number",
"format": "float",
"min": "0",
"example": "1676451277514.654",
}


def test_datetime2property_custom_format(spec_fixture):
field = fields.DateTime(
format="%d-%m%Y %H:%M:%S",
metadata={
"pattern": r"^((?:(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2}(?:\.\d+)?))(Z|[\+-]\d{2}:\d{2})?)$"
},
)
res = spec_fixture.openapi.field2property(field)
assert res == {
"type": "string",
"format": None,
"pattern": r"^((?:(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2}(?:\.\d+)?))(Z|[\+-]\d{2}:\d{2})?)$",
}


def test_datetime2property_custom_format_missing_regex(spec_fixture):
field = fields.DateTime(format="%d-%m%Y %H:%M:%S")
res = spec_fixture.openapi.field2property(field)
assert res == {
"type": "string",
"format": None,
"pattern": None,
}


class TestField2PropertyPluck:
@pytest.fixture(autouse=True)
def _setup(self, spec_fixture):
Expand Down

0 comments on commit c56a7ee

Please sign in to comment.