1import datetime as dt
2import re
3
4import pytest
5from marshmallow import fields, validate
6
7from .schemas import CategorySchema, CustomList, CustomStringField, CustomIntegerField
8from .utils import build_ref, get_schemas
9
10
11def test_field2choices_preserving_order(openapi):
12    choices = ["a", "b", "c", "aa", "0", "cc"]
13    field = fields.String(validate=validate.OneOf(choices))
14    assert openapi.field2choices(field) == {"enum": choices}
15
16
17@pytest.mark.parametrize(
18    ("FieldClass", "jsontype"),
19    [
20        (fields.Integer, "integer"),
21        (fields.Number, "number"),
22        (fields.Float, "number"),
23        (fields.String, "string"),
24        (fields.Str, "string"),
25        (fields.Boolean, "boolean"),
26        (fields.Bool, "boolean"),
27        (fields.UUID, "string"),
28        (fields.DateTime, "string"),
29        (fields.Date, "string"),
30        (fields.Time, "string"),
31        (fields.TimeDelta, "integer"),
32        (fields.Email, "string"),
33        (fields.URL, "string"),
34        # Custom fields inherit types from their parents
35        (CustomStringField, "string"),
36        (CustomIntegerField, "integer"),
37    ],
38)
39def test_field2property_type(FieldClass, jsontype, spec_fixture):
40    field = FieldClass()
41    res = spec_fixture.openapi.field2property(field)
42    assert res["type"] == jsontype
43
44
45@pytest.mark.parametrize("FieldClass", [fields.Field, fields.Raw])
46def test_field2property_no_type_(FieldClass, spec_fixture):
47    field = FieldClass()
48    res = spec_fixture.openapi.field2property(field)
49    assert "type" not in res
50
51
52@pytest.mark.parametrize("ListClass", [fields.List, CustomList])
53def test_formatted_field_translates_to_array(ListClass, spec_fixture):
54    field = ListClass(fields.String)
55    res = spec_fixture.openapi.field2property(field)
56    assert res["type"] == "array"
57    assert res["items"] == spec_fixture.openapi.field2property(fields.String())
58
59
60@pytest.mark.parametrize(
61    ("FieldClass", "expected_format"),
62    [
63        (fields.UUID, "uuid"),
64        (fields.DateTime, "date-time"),
65        (fields.Date, "date"),
66        (fields.Email, "email"),
67        (fields.URL, "url"),
68    ],
69)
70def test_field2property_formats(FieldClass, expected_format, spec_fixture):
71    field = FieldClass()
72    res = spec_fixture.openapi.field2property(field)
73    assert res["format"] == expected_format
74
75
76def test_field_with_description(spec_fixture):
77    field = fields.Str(metadata={"description": "a username"})
78    res = spec_fixture.openapi.field2property(field)
79    assert res["description"] == "a username"
80
81
82def test_field_with_load_default(spec_fixture):
83    field = fields.Str(dump_default="foo", load_default="bar")
84    res = spec_fixture.openapi.field2property(field)
85    assert res["default"] == "bar"
86
87
88def test_boolean_field_with_false_load_default(spec_fixture):
89    field = fields.Boolean(dump_default=None, load_default=False)
90    res = spec_fixture.openapi.field2property(field)
91    assert res["default"] is False
92
93
94def test_datetime_field_with_load_default(spec_fixture):
95    field = fields.Date(load_default=dt.date(2014, 7, 18))
96    res = spec_fixture.openapi.field2property(field)
97    assert res["default"] == dt.date(2014, 7, 18).isoformat()
98
99
100def test_field_with_load_default_callable(spec_fixture):
101    field = fields.Str(load_default=lambda: "dummy")
102    res = spec_fixture.openapi.field2property(field)
103    assert "default" not in res
104
105
106def test_field_with_default(spec_fixture):
107    field = fields.Str(metadata={"default": "Manual default"})
108    res = spec_fixture.openapi.field2property(field)
109    assert res["default"] == "Manual default"
110
111
112def test_field_with_default_and_load_default(spec_fixture):
113    field = fields.Int(load_default=12, metadata={"default": 42})
114    res = spec_fixture.openapi.field2property(field)
115    assert res["default"] == 42
116
117
118def test_field_with_choices(spec_fixture):
119    field = fields.Str(validate=validate.OneOf(["freddie", "brian", "john"]))
120    res = spec_fixture.openapi.field2property(field)
121    assert set(res["enum"]) == {"freddie", "brian", "john"}
122
123
124def test_field_with_equal(spec_fixture):
125    field = fields.Str(validate=validate.Equal("only choice"))
126    res = spec_fixture.openapi.field2property(field)
127    assert res["enum"] == ["only choice"]
128
129
130def test_only_allows_valid_properties_in_metadata(spec_fixture):
131    field = fields.Str(
132        load_default="foo",
133        metadata={
134            "description": "foo",
135            "not_valid": "lol",
136            "allOf": ["bar"],
137            "enum": ["red", "blue"],
138        },
139    )
140    res = spec_fixture.openapi.field2property(field)
141    assert res["default"] == field.load_default
142    assert "description" in res
143    assert "enum" in res
144    assert "allOf" in res
145    assert "not_valid" not in res
146
147
148def test_field_with_choices_multiple(spec_fixture):
149    field = fields.Str(
150        validate=[
151            validate.OneOf(["freddie", "brian", "john"]),
152            validate.OneOf(["brian", "john", "roger"]),
153        ]
154    )
155    res = spec_fixture.openapi.field2property(field)
156    assert set(res["enum"]) == {"brian", "john"}
157
158
159def test_field_with_additional_metadata(spec_fixture):
160    field = fields.Str(metadata={"minLength": 6, "maxLength": 100})
161    res = spec_fixture.openapi.field2property(field)
162    assert res["maxLength"] == 100
163    assert res["minLength"] == 6
164
165
166@pytest.mark.parametrize("spec_fixture", ("2.0", "3.0.0", "3.1.0"), indirect=True)
167def test_field_with_allow_none(spec_fixture):
168    field = fields.Str(allow_none=True)
169    res = spec_fixture.openapi.field2property(field)
170    if spec_fixture.openapi.openapi_version.major < 3:
171        assert res["x-nullable"] is True
172    elif spec_fixture.openapi.openapi_version.minor < 1:
173        assert res["nullable"] is True
174    else:
175        assert "nullable" not in res
176        assert res["type"] == ["string", "null"]
177
178
179def test_field_with_dump_only(spec_fixture):
180    field = fields.Str(dump_only=True)
181    res = spec_fixture.openapi.field2property(field)
182    assert res["readOnly"] is True
183
184
185@pytest.mark.parametrize("spec_fixture", ("2.0", "3.0.0", "3.1.0"), indirect=True)
186def test_field_with_load_only(spec_fixture):
187    field = fields.Str(load_only=True)
188    res = spec_fixture.openapi.field2property(field)
189    if spec_fixture.openapi.openapi_version.major < 3:
190        assert "writeOnly" not in res
191    else:
192        assert res["writeOnly"] is True
193
194
195def test_field_with_range_no_type(spec_fixture):
196    field = fields.Field(validate=validate.Range(min=1, max=10))
197    res = spec_fixture.openapi.field2property(field)
198    assert res["x-minimum"] == 1
199    assert res["x-maximum"] == 10
200    assert "type" not in res
201
202
203@pytest.mark.parametrize("field", (fields.Number, fields.Integer))
204def test_field_with_range_string_type(spec_fixture, field):
205    field = field(validate=validate.Range(min=1, max=10))
206    res = spec_fixture.openapi.field2property(field)
207    assert res["minimum"] == 1
208    assert res["maximum"] == 10
209    assert isinstance(res["type"], str)
210
211
212@pytest.mark.parametrize("spec_fixture", ("3.1.0",), indirect=True)
213def test_field_with_range_type_list_with_number(spec_fixture):
214    @spec_fixture.openapi.map_to_openapi_type(["integer", "null"], None)
215    class NullableInteger(fields.Field):
216        """Nullable integer"""
217
218    field = NullableInteger(validate=validate.Range(min=1, max=10))
219    res = spec_fixture.openapi.field2property(field)
220    assert res["minimum"] == 1
221    assert res["maximum"] == 10
222    assert res["type"] == ["integer", "null"]
223
224
225@pytest.mark.parametrize("spec_fixture", ("3.1.0",), indirect=True)
226def test_field_with_range_type_list_without_number(spec_fixture):
227    @spec_fixture.openapi.map_to_openapi_type(["string", "null"], None)
228    class NullableInteger(fields.Field):
229        """Nullable integer"""
230
231    field = NullableInteger(validate=validate.Range(min=1, max=10))
232    res = spec_fixture.openapi.field2property(field)
233    assert res["x-minimum"] == 1
234    assert res["x-maximum"] == 10
235    assert res["type"] == ["string", "null"]
236
237
238def test_field_with_str_regex(spec_fixture):
239    regex_str = "^[a-zA-Z0-9]$"
240    field = fields.Str(validate=validate.Regexp(regex_str))
241    ret = spec_fixture.openapi.field2property(field)
242    assert ret["pattern"] == regex_str
243
244
245def test_field_with_pattern_obj_regex(spec_fixture):
246    regex_str = "^[a-zA-Z0-9]$"
247    field = fields.Str(validate=validate.Regexp(re.compile(regex_str)))
248    ret = spec_fixture.openapi.field2property(field)
249    assert ret["pattern"] == regex_str
250
251
252def test_field_with_no_pattern(spec_fixture):
253    field = fields.Str()
254    ret = spec_fixture.openapi.field2property(field)
255    assert "pattern" not in ret
256
257
258def test_field_with_multiple_patterns(recwarn, spec_fixture):
259    regex_validators = [validate.Regexp("winner"), validate.Regexp("loser")]
260    field = fields.Str(validate=regex_validators)
261    with pytest.warns(UserWarning, match="More than one regex validator"):
262        ret = spec_fixture.openapi.field2property(field)
263    assert ret["pattern"] == "winner"
264
265
266def test_field2property_nested_spec_metadatas(spec_fixture):
267    spec_fixture.spec.components.schema("Category", schema=CategorySchema)
268    category = fields.Nested(
269        CategorySchema,
270        metadata={
271            "description": "A category",
272            "invalid_property": "not in the result",
273            "x_extension": "A great extension",
274        },
275    )
276    result = spec_fixture.openapi.field2property(category)
277    assert result == {
278        "allOf": [build_ref(spec_fixture.spec, "schema", "Category")],
279        "description": "A category",
280        "x-extension": "A great extension",
281    }
282
283
284def test_field2property_nested_spec(spec_fixture):
285    spec_fixture.spec.components.schema("Category", schema=CategorySchema)
286    category = fields.Nested(CategorySchema)
287    assert spec_fixture.openapi.field2property(category) == build_ref(
288        spec_fixture.spec, "schema", "Category"
289    )
290
291
292def test_field2property_nested_many_spec(spec_fixture):
293    spec_fixture.spec.components.schema("Category", schema=CategorySchema)
294    category = fields.Nested(CategorySchema, many=True)
295    ret = spec_fixture.openapi.field2property(category)
296    assert ret["type"] == "array"
297    assert ret["items"] == build_ref(spec_fixture.spec, "schema", "Category")
298
299
300def test_field2property_nested_ref(spec_fixture):
301    category = fields.Nested(CategorySchema)
302    ref = spec_fixture.openapi.field2property(category)
303    assert ref == build_ref(spec_fixture.spec, "schema", "Category")
304
305
306def test_field2property_nested_many(spec_fixture):
307    categories = fields.Nested(CategorySchema, many=True)
308    res = spec_fixture.openapi.field2property(categories)
309    assert res["type"] == "array"
310    assert res["items"] == build_ref(spec_fixture.spec, "schema", "Category")
311
312
313def test_nested_field_with_property(spec_fixture):
314    category_1 = fields.Nested(CategorySchema)
315    category_2 = fields.Nested(CategorySchema, dump_only=True)
316    category_3 = fields.Nested(CategorySchema, many=True)
317    category_4 = fields.Nested(CategorySchema, many=True, dump_only=True)
318    spec_fixture.spec.components.schema("Category", schema=CategorySchema)
319
320    assert spec_fixture.openapi.field2property(category_1) == build_ref(
321        spec_fixture.spec, "schema", "Category"
322    )
323    assert spec_fixture.openapi.field2property(category_2) == {
324        "allOf": [build_ref(spec_fixture.spec, "schema", "Category")],
325        "readOnly": True,
326    }
327    assert spec_fixture.openapi.field2property(category_3) == {
328        "items": build_ref(spec_fixture.spec, "schema", "Category"),
329        "type": "array",
330    }
331    assert spec_fixture.openapi.field2property(category_4) == {
332        "items": build_ref(spec_fixture.spec, "schema", "Category"),
333        "readOnly": True,
334        "type": "array",
335    }
336
337
338class TestField2PropertyPluck:
339    @pytest.fixture(autouse=True)
340    def _setup(self, spec_fixture):
341        self.field2property = spec_fixture.openapi.field2property
342
343        self.spec = spec_fixture.spec
344        self.spec.components.schema("Category", schema=CategorySchema)
345        self.unplucked = get_schemas(self.spec)["Category"]["properties"]["breed"]
346
347    def test_spec(self, spec_fixture):
348        breed = fields.Pluck(CategorySchema, "breed")
349        assert self.field2property(breed) == self.unplucked
350
351    def test_with_property(self):
352        breed = fields.Pluck(CategorySchema, "breed", dump_only=True)
353        assert self.field2property(breed) == {**self.unplucked, "readOnly": True}
354
355    def test_metadata(self):
356        breed = fields.Pluck(
357            CategorySchema,
358            "breed",
359            metadata={
360                "description": "Category breed",
361                "invalid_property": "not in the result",
362                "x_extension": "A great extension",
363            },
364        )
365        assert self.field2property(breed) == {
366            **self.unplucked,
367            "description": "Category breed",
368            "x-extension": "A great extension",
369        }
370
371    def test_many(self):
372        breed = fields.Pluck(CategorySchema, "breed", many=True)
373        assert self.field2property(breed) == {"type": "array", "items": self.unplucked}
374
375    def test_many_with_property(self):
376        breed = fields.Pluck(CategorySchema, "breed", many=True, dump_only=True)
377        assert self.field2property(breed) == {
378            "items": self.unplucked,
379            "type": "array",
380            "readOnly": True,
381        }
382
383
384def test_custom_properties_for_custom_fields(spec_fixture):
385    def custom_string2properties(self, field, **kwargs):
386        ret = {}
387        if isinstance(field, CustomStringField):
388            if self.openapi_version.major == 2:
389                ret["x-customString"] = True
390            else:
391                ret["x-customString"] = False
392        return ret
393
394    spec_fixture.marshmallow_plugin.converter.add_attribute_function(
395        custom_string2properties
396    )
397    properties = spec_fixture.marshmallow_plugin.converter.field2property(
398        CustomStringField()
399    )
400    assert properties["x-customString"] == (
401        spec_fixture.openapi.openapi_version == "2.0"
402    )
403
404
405def test_field2property_with_non_string_metadata_keys(spec_fixture):
406    class _DesertSentinel:
407        pass
408
409    field = fields.Boolean(metadata={"description": "A description"})
410    field.metadata[_DesertSentinel()] = "to be ignored"
411    result = spec_fixture.openapi.field2property(field)
412    assert result == {"description": "A description", "type": "boolean"}
413