|
| 1 | +""" |
| 2 | +Test runner for the openapi-schema-test-suite. |
| 3 | +
|
| 4 | +This module integrates the external test suite from |
| 5 | +https://github.com/python-openapi/openapi-schema-test-suite |
| 6 | +to validate OAS 3.1 and OAS 3.2 schema validators against |
| 7 | +the canonical test cases. |
| 8 | +""" |
| 9 | +import json |
| 10 | +from pathlib import Path |
| 11 | +from typing import Any |
| 12 | + |
| 13 | +import pytest |
| 14 | +from jsonschema.exceptions import ValidationError |
| 15 | + |
| 16 | +from openapi_schema_validator import OAS31Validator |
| 17 | +from openapi_schema_validator import OAS32Validator |
| 18 | +from openapi_schema_validator import oas31_format_checker |
| 19 | +from openapi_schema_validator import oas32_format_checker |
| 20 | + |
| 21 | +SUITE_ROOT = ( |
| 22 | + Path(__file__).parent.parent |
| 23 | + / "vendor" |
| 24 | + / "openapi-schema-test-suite" |
| 25 | + / "tests" |
| 26 | +) |
| 27 | + |
| 28 | +# Known failures due to limitations in the underlying jsonschema library. |
| 29 | +# Each entry is (dialect, relative_path, case_description, test_description). |
| 30 | +_KNOWN_FAILURES: dict[tuple[str, str, str, str], str] = { |
| 31 | + ( |
| 32 | + "oas31", |
| 33 | + "unevaluated.json", |
| 34 | + "unevaluatedProperties with if/then/else", |
| 35 | + "non-premium type with name is valid", |
| 36 | + ): ( |
| 37 | + "jsonschema does not collect annotations from failing 'if' subschemas, " |
| 38 | + "causing properties evaluated by 'if' to be reported as unevaluated" |
| 39 | + ), |
| 40 | + ( |
| 41 | + "oas32", |
| 42 | + "unevaluated.json", |
| 43 | + "unevaluatedProperties with if/then/else", |
| 44 | + "non-premium type with name is valid", |
| 45 | + ): ( |
| 46 | + "jsonschema does not collect annotations from failing 'if' subschemas, " |
| 47 | + "causing properties evaluated by 'if' to be reported as unevaluated" |
| 48 | + ), |
| 49 | + ( |
| 50 | + "oas31", |
| 51 | + "optional/format/format-assertion.json", |
| 52 | + "format uri with assertion", |
| 53 | + "a relative URI is not a valid URI", |
| 54 | + ): "uri format checker does not validate RFC 3986 absolute-URI requirement", |
| 55 | + ( |
| 56 | + "oas31", |
| 57 | + "optional/format/format-assertion.json", |
| 58 | + "format uri with assertion", |
| 59 | + "an invalid URI is not valid", |
| 60 | + ): "uri format checker does not validate RFC 3986 absolute-URI requirement", |
| 61 | + ( |
| 62 | + "oas32", |
| 63 | + "optional/format/format-assertion.json", |
| 64 | + "format uri with assertion", |
| 65 | + "a relative URI is not a valid URI", |
| 66 | + ): "uri format checker does not validate RFC 3986 absolute-URI requirement", |
| 67 | + ( |
| 68 | + "oas32", |
| 69 | + "optional/format/format-assertion.json", |
| 70 | + "format uri with assertion", |
| 71 | + "an invalid URI is not valid", |
| 72 | + ): "uri format checker does not validate RFC 3986 absolute-URI requirement", |
| 73 | +} |
| 74 | + |
| 75 | +_DIALECT_CONFIG: dict[str, dict[str, Any]] = { |
| 76 | + "oas31": { |
| 77 | + "validator_class": OAS31Validator, |
| 78 | + "format_checker": oas31_format_checker, |
| 79 | + }, |
| 80 | + "oas32": { |
| 81 | + "validator_class": OAS32Validator, |
| 82 | + "format_checker": oas32_format_checker, |
| 83 | + }, |
| 84 | +} |
| 85 | + |
| 86 | + |
| 87 | +def _collect_params() -> list[pytest.param]: |
| 88 | + params: list[pytest.param] = [] |
| 89 | + |
| 90 | + for dialect, config in _DIALECT_CONFIG.items(): |
| 91 | + dialect_dir = SUITE_ROOT / dialect |
| 92 | + if not dialect_dir.is_dir(): |
| 93 | + continue |
| 94 | + |
| 95 | + for json_path in sorted(dialect_dir.rglob("*.json")): |
| 96 | + rel_path = json_path.relative_to(dialect_dir) |
| 97 | + is_in_optional_dir = rel_path.parts[0] == "optional" |
| 98 | + format_checker = config["format_checker"] if is_in_optional_dir else None |
| 99 | + |
| 100 | + test_cases: list[dict[str, Any]] = json.loads( |
| 101 | + json_path.read_text(encoding="utf-8") |
| 102 | + ) |
| 103 | + for case in test_cases: |
| 104 | + case_desc: str = case["description"] |
| 105 | + schema: dict[str, Any] = case["schema"] |
| 106 | + for test in case["tests"]: |
| 107 | + test_desc: str = test["description"] |
| 108 | + data: Any = test["data"] |
| 109 | + expected_valid: bool = test["valid"] |
| 110 | + |
| 111 | + param_id = ( |
| 112 | + f"{dialect}/{rel_path}/{case_desc}/{test_desc}" |
| 113 | + ) |
| 114 | + failure_key = ( |
| 115 | + dialect, |
| 116 | + str(rel_path), |
| 117 | + case_desc, |
| 118 | + test_desc, |
| 119 | + ) |
| 120 | + marks: list[pytest.MarkDecorator] = [] |
| 121 | + if failure_key in _KNOWN_FAILURES: |
| 122 | + marks.append( |
| 123 | + pytest.mark.xfail( |
| 124 | + reason=_KNOWN_FAILURES[failure_key], |
| 125 | + strict=True, |
| 126 | + ) |
| 127 | + ) |
| 128 | + params.append( |
| 129 | + pytest.param( |
| 130 | + config["validator_class"], |
| 131 | + schema, |
| 132 | + format_checker, |
| 133 | + data, |
| 134 | + expected_valid, |
| 135 | + id=param_id, |
| 136 | + marks=marks, |
| 137 | + ) |
| 138 | + ) |
| 139 | + |
| 140 | + return params |
| 141 | + |
| 142 | + |
| 143 | +@pytest.mark.parametrize( |
| 144 | + "validator_class,schema,format_checker,data,expected_valid", |
| 145 | + _collect_params(), |
| 146 | +) |
| 147 | +def test_suite( |
| 148 | + validator_class: Any, |
| 149 | + schema: dict[str, Any], |
| 150 | + format_checker: Any, |
| 151 | + data: Any, |
| 152 | + expected_valid: bool, |
| 153 | +) -> None: |
| 154 | + validator = validator_class(schema, format_checker=format_checker) |
| 155 | + errors = list(validator.iter_errors(data)) |
| 156 | + is_valid = len(errors) == 0 |
| 157 | + assert is_valid == expected_valid, ( |
| 158 | + f"Expected valid={expected_valid}, got valid={is_valid}. " |
| 159 | + f"Errors: {[e.message for e in errors]}" |
| 160 | + ) |
0 commit comments