Skip to content

Commit

Permalink
feat(schema): set minItems and maxItems in JSON schema for tuples (
Browse files Browse the repository at this point in the history
…#2497)

* feat(schema): enforce length in generated JSON schema for tuple type

* docs: add change file

* docs: update documentation

* simplify a bit

* always set array
  • Loading branch information
PrettyWood committed Dec 5, 2021
1 parent cc1cb48 commit a35cde9
Show file tree
Hide file tree
Showing 5 changed files with 66 additions and 26 deletions.
1 change: 1 addition & 0 deletions changes/2497-PrettyWood.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Set `minItems` and `maxItems` in generated JSON schema for fixed-length tuples
9 changes: 8 additions & 1 deletion docs/build/schema_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,17 @@
'JSON Schema Validation',
'And equivalently for any other sub type, e.g. `List[int]`.'
],
[
'Tuple[str, ...]',
'array',
{'items': {'type': 'string'}},
'JSON Schema Validation',
'And equivalently for any other sub type, e.g. `Tuple[int, ...]`.'
],
[
'Tuple[str, int]',
'array',
{'items': [{'type': 'string'}, {'type': 'integer'}]},
{'items': [{'type': 'string'}, {'type': 'integer'}], 'minItems': 2, 'maxItems': 2},
'JSON Schema Validation',
(
'And equivalently for any other set of subtypes. Note: If using schemas for OpenAPI, '
Expand Down
30 changes: 21 additions & 9 deletions pydantic/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -499,15 +499,19 @@ def field_type_schema(
definitions.update(sf_definitions)
nested_models.update(sf_nested_models)
sub_schema.append(sf_schema)
if len(sub_schema) == 1:
if field.shape == SHAPE_GENERIC:
f_schema = sub_schema[0]
else:
f_schema = {'type': 'array', 'items': sub_schema[0]}
else:
f_schema = {'type': 'array', 'items': sub_schema}

sub_fields_len = len(sub_fields)
if field.shape == SHAPE_GENERIC:
f_schema = {'allOf': [f_schema]}
all_of_schemas = sub_schema[0] if sub_fields_len == 1 else {'type': 'array', 'items': sub_schema}
f_schema = {'allOf': [all_of_schemas]}
else:
f_schema = {
'type': 'array',
'minItems': sub_fields_len,
'maxItems': sub_fields_len,
}
if sub_fields_len >= 1:
f_schema['items'] = sub_schema
else:
assert field.shape in {SHAPE_SINGLETON, SHAPE_GENERIC}, field.shape
f_schema, f_definitions, f_nested_models = field_singleton_schema(
Expand Down Expand Up @@ -844,7 +848,15 @@ def field_singleton_schema( # noqa: C901 (ignore complexity)
ref_template=ref_template,
known_models=known_models,
)
f_schema.update({'type': 'array', 'items': list(sub_schema['properties'].values())})
items_schemas = list(sub_schema['properties'].values())
f_schema.update(
{
'type': 'array',
'items': items_schemas,
'minItems': len(items_schemas),
'maxItems': len(items_schemas),
}
)
elif not hasattr(field_type, '__pydantic_model__'):
add_field_type_to_schema(field_type, f_schema)

Expand Down
6 changes: 6 additions & 0 deletions tests/test_annotated_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ class Model(BaseModel):
{'title': 'X', 'type': 'integer'},
{'title': 'Y', 'type': 'integer'},
],
'minItems': 2,
'maxItems': 2,
},
'pos2': {
'title': 'Pos2',
Expand All @@ -87,6 +89,8 @@ class Model(BaseModel):
{'title': 'X'},
{'title': 'Y'},
],
'minItems': 2,
'maxItems': 2,
},
'pos3': {
'title': 'Pos3',
Expand All @@ -95,6 +99,8 @@ class Model(BaseModel):
{'type': 'integer'},
{'type': 'integer'},
],
'minItems': 2,
'maxItems': 2,
},
},
'required': ['pos1', 'pos2', 'pos3'],
Expand Down
46 changes: 30 additions & 16 deletions tests/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -535,34 +535,36 @@ class Model(BaseModel):


@pytest.mark.parametrize(
'field_type,expected_schema',
'field_type,extra_props',
[
(tuple, {}),
(tuple, {'items': {}}),
(
Tuple[str, int, Union[str, int, float], float],
[
{'type': 'string'},
{'type': 'integer'},
{'anyOf': [{'type': 'string'}, {'type': 'integer'}, {'type': 'number'}]},
{'type': 'number'},
],
{
'items': [
{'type': 'string'},
{'type': 'integer'},
{'anyOf': [{'type': 'string'}, {'type': 'integer'}, {'type': 'number'}]},
{'type': 'number'},
],
'minItems': 4,
'maxItems': 4,
},
),
(Tuple[str], {'type': 'string'}),
(Tuple[str], {'items': [{'type': 'string'}], 'minItems': 1, 'maxItems': 1}),
(Tuple[()], {'maxItems': 0, 'minItems': 0}),
],
)
def test_tuple(field_type, expected_schema):
def test_tuple(field_type, extra_props):
class Model(BaseModel):
a: field_type

base_schema = {
assert Model.schema() == {
'title': 'Model',
'type': 'object',
'properties': {'a': {'title': 'A', 'type': 'array'}},
'properties': {'a': {'title': 'A', 'type': 'array', **extra_props}},
'required': ['a'],
}
base_schema['properties']['a']['items'] = expected_schema

assert Model.schema() == base_schema


def test_deque():
Expand Down Expand Up @@ -1944,6 +1946,8 @@ class Config:
{'exclusiveMinimum': 0, 'type': 'integer'},
{'exclusiveMinimum': 0, 'type': 'integer'},
],
'minItems': 3,
'maxItems': 3,
},
),
(
Expand Down Expand Up @@ -2333,6 +2337,8 @@ class LocationBase(BaseModel):
'default': Coordinates(x=0, y=0),
'type': 'array',
'items': [{'title': 'X', 'type': 'number'}, {'title': 'Y', 'type': 'number'}],
'minItems': 2,
'maxItems': 2,
}
},
}
Expand Down Expand Up @@ -2424,11 +2430,19 @@ class Model(BaseModel):
'examples': 'examples',
},
'data3': {'title': 'Data3', 'type': 'array', 'items': {}},
'data4': {'title': 'Data4', 'type': 'array', 'items': {'$ref': '#/definitions/CustomType'}},
'data4': {
'title': 'Data4',
'type': 'array',
'items': [{'$ref': '#/definitions/CustomType'}],
'minItems': 1,
'maxItems': 1,
},
'data5': {
'title': 'Data5',
'type': 'array',
'items': [{'$ref': '#/definitions/CustomType'}, {'type': 'string'}],
'minItems': 2,
'maxItems': 2,
},
},
'required': ['data0', 'data1', 'data2', 'data3', 'data4', 'data5'],
Expand Down

0 comments on commit a35cde9

Please sign in to comment.