diff --git a/README.rst b/README.rst index 03829a8..8ff20f4 100644 --- a/README.rst +++ b/README.rst @@ -696,25 +696,35 @@ Implemented ``{'type': 'object', 'properties': {'test': {'type': 'string'}}, 'required': [], 'additionalProperties': False}`` - additionalProperties is set to true when at least one of the conditions is met: - - ignore_extra_keys is True - - at least one key is `str` or `object` + additionalProperties is set to True when ignore_extra_keys is True. + + additionalProperties is set to a schema when the following conditions are met: + - ignore_extra_keys is False + - only one key is `str`, `Optional(str)` or `Literal(str)` For example: - ``Schema({str: str})`` and ``Schema({}, ignore_extra_keys=True)`` + ``Schema({}, ignore_extra_keys=True)`` - both becomes + becomes - ``{'type': 'object', 'properties' : {}, 'required': [], 'additionalProperties': True}`` + ``{'type': 'object', 'properties': {}, 'required': [], 'additionalProperties': True}`` and + ``Schema({str: int})`` + + becomes + + ``{'type': 'object', 'properties': {}, 'required': [], 'additionalProperties': {'type': 'integer'}}`` + + while + ``Schema({})`` becomes - ``{'type': 'object', 'properties' : {}, 'required': [], 'additionalProperties': False}`` + ``{'type': 'object', 'properties': {}, 'required': [], 'additionalProperties': False}`` Types Use the Python type name directly. It will be converted to the JSON name: @@ -834,7 +844,6 @@ The following JSON schema validations cannot be generated from this library. - `Combining schemas with oneOf `_ - `Not `_ - `Object size `_ -- `additionalProperties having a different schema (true and false is supported)` JSON: Minimizing output size diff --git a/schema/__init__.py b/schema/__init__.py index 0b8134b..7687e9d 100644 --- a/schema/__init__.py +++ b/schema/__init__.py @@ -679,6 +679,19 @@ def _to_schema(s: Any, ignore_extra_keys: bool) -> Schema: ) return_schema["$ref"] = "#/definitions/" + cast(str, schema.name) + + # NOTE: Any application parsing the draft-07 dialect must ignore any other properties when $ref is present. + # See: https://json-schema.org/draft-07/draft-handrews-json-schema-01#rfc.section.8.3 + # Starting with draft-2019, applications *may* allow overriding the referenced properties in these cases. + # See: https://json-schema.org/draft/2019-09/draft-handrews-json-schema-02#rfc.section.7.7.1.1 + + # Remove description key when the referenced description is the same + if ( + return_description + and definitions_by_name[schema.name].get("description") + == return_description + ): + del return_schema["description"] else: if schema.name and not title: return_schema["title"] = schema.name @@ -768,6 +781,7 @@ def _to_schema(s: Any, ignore_extra_keys: bool) -> Schema: required_keys = [] expanded_schema = {} additional_properties = i + pattern_properties = {} for key in s: if isinstance(key, Hook): continue @@ -777,7 +791,10 @@ def _key_allows_additional_properties(key: Any) -> bool: if isinstance(key, Optional): return _key_allows_additional_properties(key.schema) - return key == str or key == object + if isinstance(key, Literal): + return _key_allows_additional_properties(key.schema) + + return key == str def _get_key_title(key: Any) -> Union[str, None]: """Get the title associated to a key (as specified in a Literal object). Return None if not a Literal""" @@ -836,11 +853,45 @@ def _get_key_name(key: Any) -> Any: # This is less strict because we cannot enforce that one or the other is required for or_key in key_name.args: + if isinstance(or_key, Regex): + or_key_name = re.sub( + r"\(\?P<[a-z\d_]+>", "(", or_key.pattern_str + ).replace("/", r"\/") + pattern_properties[or_key_name] = _json_schema( + sub_schema, + is_main_schema=False, + description=_get_key_description(or_key), + ) + continue expanded_schema[_get_key_name(or_key)] = _json_schema( sub_schema, is_main_schema=False, description=_get_key_description(or_key), ) + elif isinstance(key_name, Regex): + key_name = re.sub( + r"\(\?P<[a-z\d_]+>", "(", key_name.pattern_str + ).replace("/", r"\/") + pattern_properties[key_name] = _json_schema( + sub_schema, + is_main_schema=False, + description=_get_key_description(key), + ) + elif _key_allows_additional_properties(key): + if i: + # Don't generate sub-schema when extra keys are already ignored + continue + if isinstance(additional_properties, dict): + raise TypeError( + "For JSON schema generation only one key can be str." + ) + + additional_properties = _json_schema( + sub_schema, + is_main_schema=False, + title=_get_key_title(key), + description=_get_key_description(key), + ) return_schema.update( { @@ -851,6 +902,9 @@ def _get_key_name(key: Any) -> Any: } ) + if len(pattern_properties) > 0: + return_schema["patternProperties"] = pattern_properties + if is_main_schema: return_schema.update( { diff --git a/test_schema.py b/test_schema.py index 8936e53..e4a85a2 100644 --- a/test_schema.py +++ b/test_schema.py @@ -1317,9 +1317,9 @@ def test_json_schema_forbidden_key_ignored(): "input_schema, ignore_extra_keys, additional_properties", [ ({}, False, False), - ({str: str}, False, True), - ({Optional(str): str}, False, True), - ({object: int}, False, True), + ({str: str}, False, {"type": "string"}), + ({Optional(str): str}, False, {"type": "string"}), + ({object: int}, False, False), ({}, True, True), ], ) @@ -1344,7 +1344,7 @@ def test_json_schema_additional_properties_multiple(): "$id": "my-id", "required": ["named_property"], "properties": {"named_property": {"type": "boolean"}}, - "additionalProperties": True, + "additionalProperties": False, "type": "object", } @@ -1603,6 +1603,280 @@ def test_json_schema_description_and_nested(): } +def test_json_schema_regex_properties(): + s = Schema( + { + Regex(r"^[A-Z]+$"): { + "test1": int, + Optional("test2"): str, + }, + Regex(r"^[0-9]+$"): int, + } + ) + assert s.json_schema("my-id") == { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": False, + "patternProperties": { + "^[A-Z]+$": { + "type": "object", + "properties": { + "test1": { + "type": "integer", + }, + "test2": { + "type": "string", + }, + }, + "required": [ + "test1", + ], + "additionalProperties": False, + }, + "^[0-9]+$": { + "type": "integer", + }, + }, + "$id": "my-id", + "$schema": "http://json-schema.org/draft-07/schema#", + } + + +def test_json_schema_regex_properties_with_or_keys(): + s = Schema( + { + Or(Regex(r"^[A-Z]+$"), "/"): { + "test1": int, + Optional("test2"): str, + }, + Or(Regex(r"^[0-9]+$"), Regex(r"^abc[0-9]+$")): int, + } + ) + assert s.json_schema("my-id") == { + "type": "object", + "properties": { + "/": { + "type": "object", + "properties": { + "test1": { + "type": "integer", + }, + "test2": { + "type": "string", + }, + }, + "required": [ + "test1", + ], + "additionalProperties": False, + }, + }, + "required": [], + "additionalProperties": False, + "patternProperties": { + "^[A-Z]+$": { + "type": "object", + "properties": { + "test1": { + "type": "integer", + }, + "test2": { + "type": "string", + }, + }, + "required": [ + "test1", + ], + "additionalProperties": False, + }, + "^[0-9]+$": { + "type": "integer", + }, + "^abc[0-9]+$": { + "type": "integer", + }, + }, + "$id": "my-id", + "$schema": "http://json-schema.org/draft-07/schema#", + } + + +def test_json_schema_properties_and_additional_properties(): + s = Schema( + { + "test": int, + "abc": bool, + str: { + "abc": bool, + Optional("test1"): bool, + "test2": int, + "test3": { + "test": bool, + }, + }, + } + ) + assert s.json_schema("my-id") == { + "type": "object", + "properties": { + "test": { + "type": "integer", + }, + "abc": { + "type": "boolean", + }, + }, + "required": [ + "test", + "abc", + ], + "additionalProperties": { + "type": "object", + "properties": { + "abc": { + "type": "boolean", + }, + "test1": { + "type": "boolean", + }, + "test2": { + "type": "integer", + }, + "test3": { + "type": "object", + "required": ["test"], + "properties": { + "test": { + "type": "boolean", + } + }, + "additionalProperties": False, + }, + }, + "required": [ + "abc", + "test2", + "test3", + ], + "additionalProperties": False, + }, + "$id": "my-id", + "$schema": "http://json-schema.org/draft-07/schema#", + } + + +@mark.parametrize( + "sub_schema_key, expected_additional_pairs", + [ + # Any string key + (str, {}), + # Any Literal key + ( + Literal(str, title="My title", description="My description"), + {"title": "My title", "description": "My description"}, + ), + # Any Optional key + (Optional(str), {}), + # Any nested Literal & Optional key + ( + Literal( + Optional(str), title="My second title", description="New description" + ), + {"title": "My second title", "description": "New description"}, + ), + # Any nested Optional & Literal key + ( + Optional( + Literal(str, title="My third title", description="Test description") + ), + {"title": "My third title", "description": "Test description"}, + ), + ], +) +def test_json_schema_additional_properties_with_refs( + sub_schema_key, expected_additional_pairs +): + sub_schema = Schema( + { + "abc": bool, + Optional("test1"): bool, + "test2": int, + "test3": { + "test": bool, + }, + }, + name="sub-schema", + description="A sub schema description", + as_reference=True, + ) + s = Schema( + { + "test": int, + "abc": bool, + sub_schema_key: sub_schema, + } + ) + assert s.json_schema("my-id") == { + "type": "object", + "properties": { + "test": { + "type": "integer", + }, + "abc": { + "type": "boolean", + }, + }, + "required": [ + "test", + "abc", + ], + "additionalProperties": { + "$ref": "#/definitions/sub-schema", + # NOTE: Although application parsing the draft-07 dialect + # must ignore any other properties when $ref is present, + # we are still allowed to add them. + **expected_additional_pairs, + }, + "$id": "my-id", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "sub-schema": { + "type": "object", + "properties": { + "abc": { + "type": "boolean", + }, + "test1": { + "type": "boolean", + }, + "test2": { + "type": "integer", + }, + "test3": { + "type": "object", + "required": ["test"], + "properties": { + "test": { + "type": "boolean", + } + }, + "additionalProperties": False, + }, + }, + "required": [ + "abc", + "test2", + "test3", + ], + "additionalProperties": False, + "title": "sub-schema", + "description": "A sub schema description", + }, + }, + } + + def test_description(): s = Schema( {Optional(Literal("test1", description="A description here"), default={}): dict}