diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index cb0bc81..02ad2a9 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: [3.7, 3.8, 3.9] fail-fast: false steps: - uses: actions/checkout@v2 diff --git a/README.rst b/README.rst index 8feff79..410ec9c 100644 --- a/README.rst +++ b/README.rst @@ -18,7 +18,10 @@ openapi-schema-validator About ##### -Openapi-schema-validator is a Python library that validates schema against the `OpenAPI Schema Specification v3.0 `__ which is an extended subset of the `JSON Schema Specification Wright Draft 00 `__. +Openapi-schema-validator is a Python library that validates schema against: + +* `OpenAPI Schema Specification v3.0 `__ which is an extended subset of the `JSON Schema Specification Wright Draft 00 `__. +* `OpenAPI Schema Specification v3.1 `__ which is an extended superset of the `JSON Schema Specification Draft 2020-12 `__. Installation ############ @@ -47,7 +50,7 @@ Simple usage # A sample schema schema = { - "type" : "object", + "type": "object", "required": [ "name" ], @@ -82,9 +85,9 @@ You can also check format for primitive types .. code-block:: python - from openapi_schema_validator import oas30_format_checker + from openapi_schema_validator import oas31_format_checker - validate({"name": "John", "birth-date": "-12"}, schema, format_checker=oas30_format_checker) + validate({"name": "John", "birth-date": "-12"}, schema, format_checker=oas31_format_checker) Traceback (most recent call last): ... diff --git a/openapi_schema_validator/__init__.py b/openapi_schema_validator/__init__.py index 9de2346..d79eb9b 100644 --- a/openapi_schema_validator/__init__.py +++ b/openapi_schema_validator/__init__.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- -from openapi_schema_validator._format import oas30_format_checker +from openapi_schema_validator._format import oas30_format_checker, \ + oas31_format_checker from openapi_schema_validator.shortcuts import validate -from openapi_schema_validator.validators import OAS30Validator +from openapi_schema_validator.validators import OAS30Validator, OAS31Validator __author__ = 'Artur Maciag' __email__ = 'maciag.artur@gmail.com' @@ -9,4 +10,10 @@ __url__ = 'https://github.com/p1c2u/openapi-schema-validator' __license__ = '3-clause BSD License' -__all__ = ['validate', 'OAS30Validator', 'oas30_format_checker'] +__all__ = [ + 'validate', + 'OAS30Validator', + 'oas30_format_checker', + 'OAS31Validator', + 'oas31_format_checker', +] diff --git a/openapi_schema_validator/_format.py b/openapi_schema_validator/_format.py index 2d96a91..2762f34 100644 --- a/openapi_schema_validator/_format.py +++ b/openapi_schema_validator/_format.py @@ -144,3 +144,4 @@ def check(self, instance, format): oas30_format_checker = OASFormatChecker() +oas31_format_checker = oas30_format_checker diff --git a/openapi_schema_validator/_types.py b/openapi_schema_validator/_types.py index c18b62d..2bfa962 100644 --- a/openapi_schema_validator/_types.py +++ b/openapi_schema_validator/_types.py @@ -1,6 +1,6 @@ from jsonschema._types import ( TypeChecker, is_array, is_bool, is_integer, - is_object, is_number, + is_object, is_number, draft202012_type_checker, ) @@ -18,3 +18,4 @@ def is_string(checker, instance): u"object": is_object, }, ) +oas31_type_checker = draft202012_type_checker diff --git a/openapi_schema_validator/shortcuts.py b/openapi_schema_validator/shortcuts.py index ad494a3..0a8685e 100644 --- a/openapi_schema_validator/shortcuts.py +++ b/openapi_schema_validator/shortcuts.py @@ -1,9 +1,9 @@ from jsonschema.exceptions import best_match -from openapi_schema_validator.validators import OAS30Validator +from openapi_schema_validator.validators import OAS31Validator -def validate(instance, schema, cls=OAS30Validator, *args, **kwargs): +def validate(instance, schema, cls=OAS31Validator, *args, **kwargs): cls.check_schema(schema) validator = cls(schema, *args, **kwargs) error = best_match(validator.iter_errors(instance)) diff --git a/openapi_schema_validator/validators.py b/openapi_schema_validator/validators.py index 8f191e4..f15effd 100644 --- a/openapi_schema_validator/validators.py +++ b/openapi_schema_validator/validators.py @@ -2,11 +2,11 @@ from copy import deepcopy from jsonschema import _legacy_validators, _utils, _validators -from jsonschema.validators import create +from jsonschema.validators import create, Draft202012Validator, extend from openapi_schema_validator import _types as oas_types from openapi_schema_validator import _validators as oas_validators - +from openapi_schema_validator._types import oas31_type_checker BaseOAS30Validator = create( meta_schema=_utils.load_schema("draft4"), @@ -56,6 +56,21 @@ id_of=lambda schema: schema.get(u"id", ""), ) +BaseOAS31Validator = extend( + Draft202012Validator, + { + # adjusted to OAS + u"description": oas_validators.not_implemented, + u"format": oas_validators.format, + # fixed OAS fields + u"discriminator": oas_validators.not_implemented, + u"xml": oas_validators.not_implemented, + u"externalDocs": oas_validators.not_implemented, + u"example": oas_validators.not_implemented, + }, + type_checker=oas31_type_checker, +) + @attrs class OAS30Validator(BaseOAS30Validator): @@ -76,3 +91,7 @@ def iter_errors(self, instance, _schema=None): validator = self.evolve(schema=_schema) return super(OAS30Validator, validator).iter_errors(instance) + + +class OAS31Validator(BaseOAS31Validator): + pass diff --git a/tests/integration/test_validators.py b/tests/integration/test_validators.py index 9b5e458..858edc1 100644 --- a/tests/integration/test_validators.py +++ b/tests/integration/test_validators.py @@ -1,7 +1,8 @@ from jsonschema import ValidationError import pytest -from openapi_schema_validator import OAS30Validator, oas30_format_checker +from openapi_schema_validator import OAS30Validator, oas30_format_checker, \ + OAS31Validator, oas31_format_checker try: from unittest import mock @@ -237,3 +238,232 @@ def test_oneof_required(self): validator = OAS30Validator(schema, format_checker=oas30_format_checker) result = validator.validate(instance) assert result is None + + +class TestOAS31ValidatorValidate(object): + @pytest.mark.parametrize('schema_type', [ + 'boolean', 'array', 'integer', 'number', 'string', + ]) + def test_null(self, schema_type): + schema = {"type": schema_type} + validator = OAS31Validator(schema) + value = None + + with pytest.raises(ValidationError): + validator.validate(value) + + @pytest.mark.parametrize('schema_type', [ + 'boolean', 'array', 'integer', 'number', 'string', + ]) + def test_nullable(self, schema_type): + schema = {"type": [schema_type, 'null']} + validator = OAS31Validator(schema) + value = None + + result = validator.validate(value) + + assert result is None + + @pytest.mark.parametrize('value', [ + '1989-01-02T00:00:00Z', + '2018-01-02T23:59:59Z', + ]) + @mock.patch( + 'openapi_schema_validator._format.' + 'DATETIME_HAS_RFC3339_VALIDATOR', False + ) + @mock.patch( + 'openapi_schema_validator._format.' + 'DATETIME_HAS_STRICT_RFC3339', False + ) + @mock.patch( + 'openapi_schema_validator._format.' + 'DATETIME_HAS_ISODATE', False + ) + def test_string_format_no_datetime_validator(self, value): + schema = {"type": 'string', "format": 'date-time'} + validator = OAS31Validator( + schema, + format_checker=oas31_format_checker, + ) + + result = validator.validate(value) + + assert result is None + + @pytest.mark.parametrize('value', [ + '1989-01-02T00:00:00Z', + '2018-01-02T23:59:59Z', + ]) + @mock.patch( + 'openapi_schema_validator._format.' + 'DATETIME_HAS_RFC3339_VALIDATOR', True + ) + @mock.patch( + 'openapi_schema_validator._format.' + 'DATETIME_HAS_STRICT_RFC3339', False + ) + @mock.patch( + 'openapi_schema_validator._format.' + 'DATETIME_HAS_ISODATE', False + ) + def test_string_format_datetime_rfc3339_validator(self, value): + schema = {"type": 'string', "format": 'date-time'} + validator = OAS31Validator( + schema, + format_checker=oas31_format_checker, + ) + + result = validator.validate(value) + + assert result is None + + @pytest.mark.parametrize('value', [ + '1989-01-02T00:00:00Z', + '2018-01-02T23:59:59Z', + ]) + @mock.patch( + 'openapi_schema_validator._format.' + 'DATETIME_HAS_RFC3339_VALIDATOR', False + ) + @mock.patch( + 'openapi_schema_validator._format.' + 'DATETIME_HAS_STRICT_RFC3339', True + ) + @mock.patch( + 'openapi_schema_validator._format.' + 'DATETIME_HAS_ISODATE', False + ) + def test_string_format_datetime_strict_rfc3339(self, value): + schema = {"type": 'string', "format": 'date-time'} + validator = OAS31Validator( + schema, + format_checker=oas31_format_checker, + ) + + result = validator.validate(value) + + assert result is None + + @pytest.mark.parametrize('value', [ + '1989-01-02T00:00:00Z', + '2018-01-02T23:59:59Z', + ]) + @mock.patch( + 'openapi_schema_validator._format.' + 'DATETIME_HAS_RFC3339_VALIDATOR', False + ) + @mock.patch( + 'openapi_schema_validator._format.' + 'DATETIME_HAS_STRICT_RFC3339', False + ) + @mock.patch( + 'openapi_schema_validator._format.' + 'DATETIME_HAS_ISODATE', True + ) + def test_string_format_datetime_isodate(self, value): + schema = {"type": 'string', "format": 'date-time'} + validator = OAS31Validator( + schema, + format_checker=oas31_format_checker, + ) + + result = validator.validate(value) + + assert result is None + + @pytest.mark.parametrize('value', [ + 'f50ec0b7-f960-400d-91f0-c42a6d44e3d0', + 'F50EC0B7-F960-400D-91F0-C42A6D44E3D0', + ]) + def test_string_uuid(self, value): + schema = {"type": 'string', "format": 'uuid'} + validator = OAS31Validator( + schema, + format_checker=oas31_format_checker, + ) + + result = validator.validate(value) + + assert result is None + + def test_schema_validation(self): + schema = { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "age": { + "type": "integer", + "format": "int32", + "minimum": 0, + "nullable": True, + }, + "birth-date": { + "type": "string", + "format": "date", + } + }, + "additionalProperties": False, + } + validator = OAS31Validator( + schema, + format_checker=oas31_format_checker, + ) + + result = validator.validate({"name": "John", "age": 23}, schema) + assert result is None + + with pytest.raises(ValidationError) as excinfo: + validator.validate({"name": "John", "city": "London"}, schema) + + error = "Additional properties are not allowed ('city' was unexpected)" + assert error in str(excinfo.value) + + with pytest.raises(ValidationError) as excinfo: + validator.validate({"name": "John", "birth-date": "-12"}) + + error = "'-12' is not a 'date'" + assert error in str(excinfo.value) + + def test_schema_ref(self): + schema = { + "$ref": "#/$defs/Pet", + "$defs": { + "Pet": { + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + } + } + } + validator = OAS31Validator( + schema, + format_checker=oas31_format_checker, + ) + + result = validator.validate({"id": 1, "name": "John"}, schema) + assert result is None + + with pytest.raises(ValidationError) as excinfo: + validator.validate({"name": "John"}, schema) + + error = "'id' is a required property" + assert error in str(excinfo.value)