From 268e42731348304c7ac0da781a0eff3abf59661d Mon Sep 17 00:00:00 2001 From: p1c2u Date: Wed, 25 Feb 2026 20:19:08 +0000 Subject: [PATCH 1/2] OpenAPI 3.2 support --- README.rst | 7 +- docs/cli.rst | 4 +- docs/index.rst | 7 +- docs/python.rst | 9 +- openapi_spec_validator/__init__.py | 4 + openapi_spec_validator/__main__.py | 20 +- .../resources/schemas/v3.2/schema.json | 1684 +++++++++++++++++ openapi_spec_validator/schemas/__init__.py | 8 +- openapi_spec_validator/shortcuts.py | 2 + openapi_spec_validator/validation/__init__.py | 17 +- openapi_spec_validator/validation/keywords.py | 79 +- .../validation/validators.py | 26 +- openapi_spec_validator/versions/__init__.py | 2 + openapi_spec_validator/versions/consts.py | 8 +- poetry.lock | 11 +- pyproject.toml | 2 +- tests/integration/data/v3.2/petstore.yaml | 115 ++ tests/integration/test_main.py | 22 + tests/integration/test_shortcuts.py | 26 + tests/integration/test_versions.py | 7 + tests/integration/validation/test_dialect.py | 41 +- .../integration/validation/test_validators.py | 130 ++ 22 files changed, 2187 insertions(+), 44 deletions(-) create mode 100644 openapi_spec_validator/resources/schemas/v3.2/schema.json create mode 100644 tests/integration/data/v3.2/petstore.yaml diff --git a/README.rst b/README.rst index 8b19251..149a472 100644 --- a/README.rst +++ b/README.rst @@ -22,7 +22,8 @@ OpenAPI Spec Validator is a CLI, pre-commit hook and python package that validat against the `OpenAPI 2.0 (aka Swagger) `__, `OpenAPI 3.0 `__ -and `OpenAPI 3.1 `__ +`OpenAPI 3.1 `__ +and `OpenAPI 3.2 `__ specification. The validator aims to check for full compliance with the Specification. @@ -119,9 +120,9 @@ Related projects ################ * `openapi-core `__ - Python library that adds client-side and server-side support for the OpenAPI v3.0 and OpenAPI v3.1 specification. + Python library that adds client-side and server-side support for the OpenAPI v3.0, OpenAPI v3.1 and OpenAPI v3.2 specification. * `openapi-schema-validator `__ - Python library that validates schema against the OpenAPI Schema Specification v3.0 and OpenAPI Schema Specification v3.1. + Python library that validates schema against the OpenAPI Schema Specification v3.0, OpenAPI Schema Specification v3.1 and OpenAPI Schema Specification v3.2. License ####### diff --git a/docs/cli.rst b/docs/cli.rst index ee80ab2..88a4e89 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -45,7 +45,7 @@ CLI (Command Line Interface) usage: openapi-spec-validator [-h] [--subschema-errors {best-match,all}] [--validation-errors {first,all}] - [--errors {best-match,all}] [--schema {detect,2.0,3.0,3.1}] + [--errors {best-match,all}] [--schema {detect,2.0,3.0,3.1,3.2}] [--version] file [file ...] positional arguments: @@ -61,7 +61,7 @@ CLI (Command Line Interface) use "all" to get all validation errors. --errors {best-match,all}, --error {best-match,all} Deprecated alias for --subschema-errors. - --schema {detect,2.0,3.0,3.1} + --schema {detect,2.0,3.0,3.1,3.2} OpenAPI schema version (default: detect). --version show program's version number and exit diff --git a/docs/index.rst b/docs/index.rst index 889f4ec..adce589 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,7 +14,8 @@ OpenAPI Spec Validator is a CLI, pre-commit hook and python package that validat against the `OpenAPI 2.0 (aka Swagger) `__, `OpenAPI 3.0 `__ -and `OpenAPI 3.1 `__ +`OpenAPI 3.1 `__ +and `OpenAPI 3.2 `__ specification. The validator aims to check for full compliance with the Specification. Installation @@ -111,9 +112,9 @@ Related projects ---------------- * `openapi-core `__ - Python library that adds client-side and server-side support for the OpenAPI v3.0 and OpenAPI v3.1 specification. + Python library that adds client-side and server-side support for the OpenAPI v3.0, OpenAPI v3.1 and OpenAPI v3.2 specification. * `openapi-schema-validator `__ - Python library that validates schema against the OpenAPI Schema Specification v3.0 and OpenAPI Schema Specification v3.1. + Python library that validates schema against the OpenAPI Schema Specification v3.0, OpenAPI Schema Specification v3.1 and OpenAPI Schema Specification v3.2. License ------- diff --git a/docs/python.rst b/docs/python.rst index 7bb05ae..88eaf97 100644 --- a/docs/python.rst +++ b/docs/python.rst @@ -41,13 +41,14 @@ In order to explicitly validate a: * Swagger / OpenAPI 2.0 spec, import ``OpenAPIV2SpecValidator`` * OpenAPI 3.0 spec, import ``OpenAPIV30SpecValidator`` -* OpenAPI 3.1 spec, import ``OpenAPIV31SpecValidator`` +* OpenAPI 3.1 spec, import ``OpenAPIV31SpecValidator`` +* OpenAPI 3.2 spec, import ``OpenAPIV32SpecValidator`` and pass the validator class to ``validate`` or ``validate_url`` function: .. code:: python - validate(spec_dict, cls=OpenAPIV31SpecValidator) + validate(spec_dict, cls=OpenAPIV32SpecValidator) You can also explicitly import ``OpenAPIV3SpecValidator`` which is a shortcut to the latest v3 release. @@ -55,6 +56,6 @@ If you want to iterate through validation errors: .. code:: python - from openapi_spec_validator import OpenAPIV31SpecValidator + from openapi_spec_validator import OpenAPIV32SpecValidator - errors_iterator = OpenAPIV31SpecValidator(spec).iter_errors() + errors_iterator = OpenAPIV32SpecValidator(spec).iter_errors() diff --git a/openapi_spec_validator/__init__.py b/openapi_spec_validator/__init__.py index 6a0b1dc..af7620a 100644 --- a/openapi_spec_validator/__init__.py +++ b/openapi_spec_validator/__init__.py @@ -8,10 +8,12 @@ from openapi_spec_validator.validation import OpenAPIV3SpecValidator from openapi_spec_validator.validation import OpenAPIV30SpecValidator from openapi_spec_validator.validation import OpenAPIV31SpecValidator +from openapi_spec_validator.validation import OpenAPIV32SpecValidator from openapi_spec_validator.validation import openapi_v2_spec_validator from openapi_spec_validator.validation import openapi_v3_spec_validator from openapi_spec_validator.validation import openapi_v30_spec_validator from openapi_spec_validator.validation import openapi_v31_spec_validator +from openapi_spec_validator.validation import openapi_v32_spec_validator __author__ = "Artur Maciag" __email__ = "maciag.artur@gmail.com" @@ -24,10 +26,12 @@ "openapi_v3_spec_validator", "openapi_v30_spec_validator", "openapi_v31_spec_validator", + "openapi_v32_spec_validator", "OpenAPIV2SpecValidator", "OpenAPIV3SpecValidator", "OpenAPIV30SpecValidator", "OpenAPIV31SpecValidator", + "OpenAPIV32SpecValidator", "validate", "validate_url", "validate_spec", diff --git a/openapi_spec_validator/__main__.py b/openapi_spec_validator/__main__.py index 9b7bdeb..f5ff1f2 100644 --- a/openapi_spec_validator/__main__.py +++ b/openapi_spec_validator/__main__.py @@ -15,6 +15,7 @@ from openapi_spec_validator.validation import OpenAPIV2SpecValidator from openapi_spec_validator.validation import OpenAPIV30SpecValidator from openapi_spec_validator.validation import OpenAPIV31SpecValidator +from openapi_spec_validator.validation import OpenAPIV32SpecValidator from openapi_spec_validator.validation import SpecValidator logger = logging.getLogger(__name__) @@ -55,8 +56,8 @@ def print_validationerror( print("## " + str(best_match(exc.context))) if len(exc.context) > 1: print( - f"\n({len(exc.context) - 1} more subschemas errors,", - "use --subschema-errors=all to see them.)", + f"\n({len(exc.context) - 1} more subschemas errors, " + "use --subschema-errors=all to see them.)" ) @@ -101,9 +102,18 @@ def main(args: Sequence[str] | None = None) -> None: parser.add_argument( "--schema", type=str, - choices=["detect", "2.0", "3.0", "3.1", "3.0.0", "3.1.0"], + choices=[ + "detect", + "2.0", + "3.0", + "3.1", + "3.2", + "3.0.0", + "3.1.0", + "3.2.0", + ], default="detect", - metavar="{detect,2.0,3.0,3.1}", + metavar="{detect,2.0,3.0,3.1,3.2}", help="OpenAPI schema version (default: detect).", ) parser.add_argument( @@ -149,9 +159,11 @@ def main(args: Sequence[str] | None = None) -> None: "2.0": OpenAPIV2SpecValidator, "3.0": OpenAPIV30SpecValidator, "3.1": OpenAPIV31SpecValidator, + "3.2": OpenAPIV32SpecValidator, # backward compatibility "3.0.0": OpenAPIV30SpecValidator, "3.1.0": OpenAPIV31SpecValidator, + "3.2.0": OpenAPIV32SpecValidator, } validator_cls = validators[args_parsed.schema] diff --git a/openapi_spec_validator/resources/schemas/v3.2/schema.json b/openapi_spec_validator/resources/schemas/v3.2/schema.json new file mode 100644 index 0000000..95ab03f --- /dev/null +++ b/openapi_spec_validator/resources/schemas/v3.2/schema.json @@ -0,0 +1,1684 @@ +{ + "$id": "https://spec.openapis.org/oas/3.2/schema/2025-11-23", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "The description of OpenAPI v3.2.x Documents without Schema Object validation", + "type": "object", + "properties": { + "openapi": { + "type": "string", + "pattern": "^3\\.2\\.\\d+(-.+)?$" + }, + "$self": { + "type": "string", + "format": "uri-reference", + "$comment": "MUST NOT contain a fragment", + "pattern": "^[^#]*$" + }, + "info": { + "$ref": "#/$defs/info" + }, + "jsonSchemaDialect": { + "type": "string", + "format": "uri-reference", + "default": "https://spec.openapis.org/oas/3.2/dialect/2025-09-17" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/server" + }, + "default": [ + { + "url": "/" + } + ] + }, + "paths": { + "$ref": "#/$defs/paths" + }, + "webhooks": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/path-item" + } + }, + "components": { + "$ref": "#/$defs/components" + }, + "security": { + "type": "array", + "items": { + "$ref": "#/$defs/security-requirement" + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/$defs/tag" + } + }, + "externalDocs": { + "$ref": "#/$defs/external-documentation" + } + }, + "required": [ + "openapi", + "info" + ], + "anyOf": [ + { + "required": [ + "paths" + ] + }, + { + "required": [ + "components" + ] + }, + { + "required": [ + "webhooks" + ] + } + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false, + "$defs": { + "info": { + "$comment": "https://spec.openapis.org/oas/v3.2#info-object", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "termsOfService": { + "type": "string", + "format": "uri-reference" + }, + "contact": { + "$ref": "#/$defs/contact" + }, + "license": { + "$ref": "#/$defs/license" + }, + "version": { + "type": "string" + } + }, + "required": [ + "title", + "version" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "contact": { + "$comment": "https://spec.openapis.org/oas/v3.2#contact-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + }, + "email": { + "type": "string", + "format": "email" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "license": { + "$comment": "https://spec.openapis.org/oas/v3.2#license-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + } + }, + "required": [ + "name" + ], + "dependentSchemas": { + "identifier": { + "not": { + "required": [ + "url" + ] + } + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "server": { + "$comment": "https://spec.openapis.org/oas/v3.2#server-object", + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "variables": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/server-variable" + } + } + }, + "required": [ + "url" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "server-variable": { + "$comment": "https://spec.openapis.org/oas/v3.2#server-variable-object", + "type": "object", + "properties": { + "enum": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "default": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "default" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "components": { + "$comment": "https://spec.openapis.org/oas/v3.2#components-object", + "type": "object", + "properties": { + "schemas": { + "type": "object", + "additionalProperties": { + "$dynamicRef": "#meta" + } + }, + "responses": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/response-or-reference" + } + }, + "parameters": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/parameter-or-reference" + } + }, + "examples": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/example-or-reference" + } + }, + "requestBodies": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/request-body-or-reference" + } + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/header-or-reference" + } + }, + "securitySchemes": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/security-scheme-or-reference" + } + }, + "links": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/link-or-reference" + } + }, + "callbacks": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/callbacks-or-reference" + } + }, + "pathItems": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/path-item" + } + }, + "mediaTypes": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/media-type-or-reference" + } + } + }, + "patternProperties": { + "^(?:schemas|responses|parameters|examples|requestBodies|headers|securitySchemes|links|callbacks|pathItems|mediaTypes)$": { + "$comment": "Enumerating all of the property names in the regex above is necessary for unevaluatedProperties to work as expected", + "propertyNames": { + "pattern": "^[a-zA-Z0-9._-]+$" + } + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "paths": { + "$comment": "https://spec.openapis.org/oas/v3.2#paths-object", + "type": "object", + "patternProperties": { + "^/": { + "$ref": "#/$defs/path-item" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "path-item": { + "$comment": "https://spec.openapis.org/oas/v3.2#path-item-object", + "type": "object", + "properties": { + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/server" + } + }, + "parameters": { + "$ref": "#/$defs/parameters" + }, + "additionalOperations": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/operation" + }, + "propertyNames": { + "$comment": "RFC9110 restricts methods to \"1*tchar\" in ABNF", + "pattern": "^[a-zA-Z0-9!#$%&'*+.^_`|~-]+$", + "not": { + "enum": [ + "GET", + "PUT", + "POST", + "DELETE", + "OPTIONS", + "HEAD", + "PATCH", + "TRACE", + "QUERY" + ] + } + } + }, + "get": { + "$ref": "#/$defs/operation" + }, + "put": { + "$ref": "#/$defs/operation" + }, + "post": { + "$ref": "#/$defs/operation" + }, + "delete": { + "$ref": "#/$defs/operation" + }, + "options": { + "$ref": "#/$defs/operation" + }, + "head": { + "$ref": "#/$defs/operation" + }, + "patch": { + "$ref": "#/$defs/operation" + }, + "trace": { + "$ref": "#/$defs/operation" + }, + "query": { + "$ref": "#/$defs/operation" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "operation": { + "$comment": "https://spec.openapis.org/oas/v3.2#operation-object", + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/$defs/external-documentation" + }, + "operationId": { + "type": "string" + }, + "parameters": { + "$ref": "#/$defs/parameters" + }, + "requestBody": { + "$ref": "#/$defs/request-body-or-reference" + }, + "responses": { + "$ref": "#/$defs/responses" + }, + "callbacks": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/callbacks-or-reference" + } + }, + "deprecated": { + "default": false, + "type": "boolean" + }, + "security": { + "type": "array", + "items": { + "$ref": "#/$defs/security-requirement" + } + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/server" + } + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "external-documentation": { + "$comment": "https://spec.openapis.org/oas/v3.2#external-documentation-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + } + }, + "required": [ + "url" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "parameters": { + "type": "array", + "items": { + "$ref": "#/$defs/parameter-or-reference" + }, + "not": { + "allOf": [ + { + "contains": { + "type": "object", + "properties": { + "in": { + "const": "query" + } + }, + "required": [ + "in" + ] + } + }, + { + "contains": { + "type": "object", + "properties": { + "in": { + "const": "querystring" + } + }, + "required": [ + "in" + ] + } + } + ] + }, + "contains": { + "type": "object", + "properties": { + "in": { + "const": "querystring" + } + }, + "required": [ + "in" + ] + }, + "minContains": 0, + "maxContains": 1 + }, + "parameter": { + "$comment": "https://spec.openapis.org/oas/v3.2#parameter-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "in": { + "enum": [ + "query", + "querystring", + "header", + "path", + "cookie" + ] + }, + "description": { + "type": "string" + }, + "required": { + "default": false, + "type": "boolean" + }, + "deprecated": { + "default": false, + "type": "boolean" + }, + "schema": { + "$dynamicRef": "#meta" + }, + "content": { + "$ref": "#/$defs/content", + "minProperties": 1, + "maxProperties": 1 + } + }, + "required": [ + "name", + "in" + ], + "oneOf": [ + { + "required": [ + "schema" + ] + }, + { + "required": [ + "content" + ] + } + ], + "allOf": [ + { + "$ref": "#/$defs/examples" + }, + { + "$ref": "#/$defs/specification-extensions" + }, + { + "if": { + "properties": { + "in": { + "const": "query" + } + } + }, + "then": { + "properties": { + "allowEmptyValue": { + "default": false, + "type": "boolean" + } + } + } + }, + { + "if": { + "properties": { + "in": { + "const": "querystring" + } + } + }, + "then": { + "required": [ + "content" + ] + } + } + ], + "dependentSchemas": { + "schema": { + "properties": { + "style": { + "type": "string" + }, + "explode": { + "type": "boolean" + } + }, + "allOf": [ + { + "$ref": "#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-path" + }, + { + "$ref": "#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-header" + }, + { + "$ref": "#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-query" + }, + { + "$ref": "#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-cookie" + } + ], + "$defs": { + "styles-for-path": { + "if": { + "properties": { + "in": { + "const": "path" + } + } + }, + "then": { + "properties": { + "name": { + "pattern": "^[^{}]+$" + }, + "style": { + "default": "simple", + "enum": [ + "matrix", + "label", + "simple" + ] + }, + "required": { + "const": true + }, + "explode": { + "default": false + }, + "allowReserved": { + "type": "boolean", + "default": false + } + }, + "required": [ + "required" + ] + } + }, + "styles-for-header": { + "if": { + "properties": { + "in": { + "const": "header" + } + } + }, + "then": { + "properties": { + "style": { + "default": "simple", + "const": "simple" + }, + "explode": { + "default": false + } + } + } + }, + "styles-for-query": { + "if": { + "properties": { + "in": { + "const": "query" + } + } + }, + "then": { + "properties": { + "style": { + "default": "form", + "enum": [ + "form", + "spaceDelimited", + "pipeDelimited", + "deepObject" + ] + }, + "allowReserved": { + "type": "boolean", + "default": false + } + }, + "$ref": "#/$defs/explode-for-form" + } + }, + "styles-for-cookie": { + "if": { + "properties": { + "in": { + "const": "cookie" + } + } + }, + "then": { + "properties": { + "style": { + "default": "form", + "enum": [ + "form", + "cookie" + ] + }, + "explode": { + "default": true + } + }, + "if": { + "properties": { + "style": { + "const": "form" + } + } + }, + "then": { + "properties": { + "allowReserved": { + "type": "boolean", + "default": false + } + } + } + } + } + } + } + }, + "unevaluatedProperties": false + }, + "parameter-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/parameter" + } + }, + "request-body": { + "$comment": "https://spec.openapis.org/oas/v3.2#request-body-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "content": { + "$ref": "#/$defs/content" + }, + "required": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "content" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "request-body-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/request-body" + } + }, + "content": { + "$comment": "https://spec.openapis.org/oas/v3.2#fixed-fields-10", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/media-type-or-reference" + }, + "propertyNames": { + "format": "media-range" + } + }, + "media-type": { + "$comment": "https://spec.openapis.org/oas/v3.2#media-type-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "schema": { + "$dynamicRef": "#meta" + }, + "itemSchema": { + "$dynamicRef": "#meta" + }, + "encoding": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/encoding" + } + }, + "prefixEncoding": { + "type": "array", + "items": { + "$ref": "#/$defs/encoding" + } + }, + "itemEncoding": { + "$ref": "#/$defs/encoding" + } + }, + "dependentSchemas": { + "encoding": { + "properties": { + "prefixEncoding": false, + "itemEncoding": false + } + } + }, + "allOf": [ + { + "$ref": "#/$defs/examples" + }, + { + "$ref": "#/$defs/specification-extensions" + } + ], + "unevaluatedProperties": false + }, + "media-type-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/media-type" + } + }, + "encoding": { + "$comment": "https://spec.openapis.org/oas/v3.2#encoding-object", + "type": "object", + "properties": { + "contentType": { + "type": "string", + "format": "media-range" + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/header-or-reference" + } + }, + "style": { + "enum": [ + "form", + "spaceDelimited", + "pipeDelimited", + "deepObject" + ] + }, + "explode": { + "type": "boolean" + }, + "allowReserved": { + "type": "boolean" + }, + "encoding": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/encoding" + } + }, + "prefixEncoding": { + "type": "array", + "items": { + "$ref": "#/$defs/encoding" + } + }, + "itemEncoding": { + "$ref": "#/$defs/encoding" + } + }, + "dependentSchemas": { + "encoding": { + "properties": { + "prefixEncoding": false, + "itemEncoding": false + } + }, + "style": { + "properties": { + "allowReserved": { + "default": false + } + }, + "$ref": "#/$defs/explode-for-form" + }, + "explode": { + "properties": { + "style": { + "default": "form" + }, + "allowReserved": { + "default": false + } + } + }, + "allowReserved": { + "properties": { + "style": { + "default": "form" + } + }, + "$ref": "#/$defs/explode-for-form" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "responses": { + "$comment": "https://spec.openapis.org/oas/v3.2#responses-object", + "type": "object", + "properties": { + "default": { + "$ref": "#/$defs/response-or-reference" + } + }, + "patternProperties": { + "^[1-5](?:[0-9]{2}|XX)$": { + "$ref": "#/$defs/response-or-reference" + } + }, + "minProperties": 1, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false, + "if": { + "$comment": "either default, or at least one response code property must exist", + "patternProperties": { + "^[1-5](?:[0-9]{2}|XX)$": false + } + }, + "then": { + "required": [ + "default" + ] + } + }, + "response": { + "$comment": "https://spec.openapis.org/oas/v3.2#response-object", + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/header-or-reference" + } + }, + "content": { + "$ref": "#/$defs/content" + }, + "links": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/link-or-reference" + } + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "response-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/response" + } + }, + "callbacks": { + "$comment": "https://spec.openapis.org/oas/v3.2#callback-object", + "type": "object", + "$ref": "#/$defs/specification-extensions", + "additionalProperties": { + "$ref": "#/$defs/path-item" + } + }, + "callbacks-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/callbacks" + } + }, + "example": { + "$comment": "https://spec.openapis.org/oas/v3.2#example-object", + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "dataValue": true, + "serializedValue": { + "type": "string" + }, + "value": true, + "externalValue": { + "type": "string", + "format": "uri-reference" + } + }, + "allOf": [ + { + "not": { + "required": [ + "value", + "externalValue" + ] + } + }, + { + "not": { + "required": [ + "value", + "dataValue" + ] + } + }, + { + "not": { + "required": [ + "value", + "serializedValue" + ] + } + }, + { + "not": { + "required": [ + "serializedValue", + "externalValue" + ] + } + } + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "example-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/example" + } + }, + "link": { + "$comment": "https://spec.openapis.org/oas/v3.2#link-object", + "type": "object", + "properties": { + "operationRef": { + "type": "string", + "format": "uri-reference" + }, + "operationId": { + "type": "string" + }, + "parameters": { + "$ref": "#/$defs/map-of-strings" + }, + "requestBody": true, + "description": { + "type": "string" + }, + "server": { + "$ref": "#/$defs/server" + } + }, + "oneOf": [ + { + "required": [ + "operationRef" + ] + }, + { + "required": [ + "operationId" + ] + } + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "link-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/link" + } + }, + "header": { + "$comment": "https://spec.openapis.org/oas/v3.2#header-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "required": { + "default": false, + "type": "boolean" + }, + "deprecated": { + "default": false, + "type": "boolean" + }, + "schema": { + "$dynamicRef": "#meta" + }, + "content": { + "$ref": "#/$defs/content", + "minProperties": 1, + "maxProperties": 1 + } + }, + "oneOf": [ + { + "required": [ + "schema" + ] + }, + { + "required": [ + "content" + ] + } + ], + "dependentSchemas": { + "schema": { + "properties": { + "style": { + "default": "simple", + "const": "simple" + }, + "explode": { + "default": false, + "type": "boolean" + } + } + } + }, + "allOf": [ + { + "$ref": "#/$defs/examples" + }, + { + "$ref": "#/$defs/specification-extensions" + } + ], + "unevaluatedProperties": false + }, + "header-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/header" + } + }, + "tag": { + "$comment": "https://spec.openapis.org/oas/v3.2#tag-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/$defs/external-documentation" + }, + "parent": { + "type": "string" + }, + "kind": { + "type": "string" + } + }, + "required": [ + "name" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "reference": { + "$comment": "https://spec.openapis.org/oas/v3.2#reference-object", + "type": "object", + "properties": { + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "schema": { + "$comment": "https://spec.openapis.org/oas/v3.2#schema-object", + "$dynamicAnchor": "meta", + "type": [ + "object", + "boolean" + ] + }, + "security-scheme": { + "$comment": "https://spec.openapis.org/oas/v3.2#security-scheme-object", + "type": "object", + "properties": { + "type": { + "enum": [ + "apiKey", + "http", + "mutualTLS", + "oauth2", + "openIdConnect" + ] + }, + "description": { + "type": "string" + }, + "deprecated": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "type" + ], + "allOf": [ + { + "$ref": "#/$defs/specification-extensions" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-apikey" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-http" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-http-bearer" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-oauth2" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-oidc" + } + ], + "unevaluatedProperties": false, + "$defs": { + "type-apikey": { + "if": { + "properties": { + "type": { + "const": "apiKey" + } + } + }, + "then": { + "properties": { + "name": { + "type": "string" + }, + "in": { + "enum": [ + "query", + "header", + "cookie" + ] + } + }, + "required": [ + "name", + "in" + ] + } + }, + "type-http": { + "if": { + "properties": { + "type": { + "const": "http" + } + } + }, + "then": { + "properties": { + "scheme": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + } + }, + "type-http-bearer": { + "if": { + "properties": { + "type": { + "const": "http" + }, + "scheme": { + "type": "string", + "pattern": "^[Bb][Ee][Aa][Rr][Ee][Rr]$" + } + }, + "required": [ + "type", + "scheme" + ] + }, + "then": { + "properties": { + "bearerFormat": { + "type": "string" + } + } + } + }, + "type-oauth2": { + "if": { + "properties": { + "type": { + "const": "oauth2" + } + } + }, + "then": { + "properties": { + "flows": { + "$ref": "#/$defs/oauth-flows" + }, + "oauth2MetadataUrl": { + "type": "string", + "format": "uri-reference" + } + }, + "required": [ + "flows" + ] + } + }, + "type-oidc": { + "if": { + "properties": { + "type": { + "const": "openIdConnect" + } + } + }, + "then": { + "properties": { + "openIdConnectUrl": { + "type": "string", + "format": "uri-reference" + } + }, + "required": [ + "openIdConnectUrl" + ] + } + } + } + }, + "security-scheme-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/security-scheme" + } + }, + "oauth-flows": { + "type": "object", + "properties": { + "implicit": { + "$ref": "#/$defs/oauth-flows/$defs/implicit" + }, + "password": { + "$ref": "#/$defs/oauth-flows/$defs/password" + }, + "clientCredentials": { + "$ref": "#/$defs/oauth-flows/$defs/client-credentials" + }, + "authorizationCode": { + "$ref": "#/$defs/oauth-flows/$defs/authorization-code" + }, + "deviceAuthorization": { + "$ref": "#/$defs/oauth-flows/$defs/device-authorization" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false, + "$defs": { + "implicit": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "authorizationUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "password": { + "type": "object", + "properties": { + "tokenUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "tokenUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "client-credentials": { + "type": "object", + "properties": { + "tokenUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "tokenUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "authorization-code": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri-reference" + }, + "tokenUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "authorizationUrl", + "tokenUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "device-authorization": { + "type": "object", + "properties": { + "deviceAuthorizationUrl": { + "type": "string", + "format": "uri-reference" + }, + "tokenUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "deviceAuthorizationUrl", + "tokenUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + } + } + }, + "security-requirement": { + "$comment": "https://spec.openapis.org/oas/v3.2#security-requirement-object", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "specification-extensions": { + "$comment": "https://spec.openapis.org/oas/v3.2#specification-extensions", + "patternProperties": { + "^x-": true + } + }, + "examples": { + "properties": { + "example": true, + "examples": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/example-or-reference" + } + } + }, + "not": { + "required": [ + "example", + "examples" + ] + } + }, + "map-of-strings": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "explode-for-form": { + "$comment": "for encoding objects, and query and cookie parameters, style=form is the default", + "if": { + "properties": { + "style": { + "const": "form" + } + } + }, + "then": { + "properties": { + "explode": { + "default": true + } + } + }, + "else": { + "properties": { + "explode": { + "default": false + } + } + } + } + } +} diff --git a/openapi_spec_validator/schemas/__init__.py b/openapi_spec_validator/schemas/__init__.py index e1147bc..ef4cc4d 100644 --- a/openapi_spec_validator/schemas/__init__.py +++ b/openapi_spec_validator/schemas/__init__.py @@ -8,23 +8,27 @@ from openapi_spec_validator.schemas.utils import get_schema_content -__all__ = ["schema_v2", "schema_v3", "schema_v30", "schema_v31"] +__all__ = ["schema_v2", "schema_v3", "schema_v30", "schema_v31", "schema_v32"] get_schema_content_v2 = partial(get_schema_content, "2.0") get_schema_content_v30 = partial(get_schema_content, "3.0") get_schema_content_v31 = partial(get_schema_content, "3.1") +get_schema_content_v32 = partial(get_schema_content, "3.2") schema_v2 = Proxy(get_schema_content_v2) schema_v30 = Proxy(get_schema_content_v30) schema_v31 = Proxy(get_schema_content_v31) +schema_v32 = Proxy(get_schema_content_v32) # alias to the latest v3 version -schema_v3 = schema_v31 +schema_v3 = schema_v32 get_openapi_v2_schema_validator = partial(Draft4Validator, schema_v2) get_openapi_v30_schema_validator = partial(Draft4Validator, schema_v30) get_openapi_v31_schema_validator = partial(Draft202012Validator, schema_v31) +get_openapi_v32_schema_validator = partial(Draft202012Validator, schema_v32) openapi_v2_schema_validator = Proxy(get_openapi_v2_schema_validator) openapi_v30_schema_validator = Proxy(get_openapi_v30_schema_validator) openapi_v31_schema_validator = Proxy(get_openapi_v31_schema_validator) +openapi_v32_schema_validator = Proxy(get_openapi_v32_schema_validator) diff --git a/openapi_spec_validator/shortcuts.py b/openapi_spec_validator/shortcuts.py index 884d079..3b32ee1 100644 --- a/openapi_spec_validator/shortcuts.py +++ b/openapi_spec_validator/shortcuts.py @@ -10,6 +10,7 @@ from openapi_spec_validator.validation import OpenAPIV2SpecValidator from openapi_spec_validator.validation import OpenAPIV30SpecValidator from openapi_spec_validator.validation import OpenAPIV31SpecValidator +from openapi_spec_validator.validation import OpenAPIV32SpecValidator from openapi_spec_validator.validation.exceptions import ValidatorDetectError from openapi_spec_validator.validation.protocols import SupportsValidation from openapi_spec_validator.validation.types import SpecValidatorType @@ -23,6 +24,7 @@ versions.OPENAPIV2: OpenAPIV2SpecValidator, versions.OPENAPIV30: OpenAPIV30SpecValidator, versions.OPENAPIV31: OpenAPIV31SpecValidator, + versions.OPENAPIV32: OpenAPIV32SpecValidator, } diff --git a/openapi_spec_validator/validation/__init__.py b/openapi_spec_validator/validation/__init__.py index d30e991..ad15027 100644 --- a/openapi_spec_validator/validation/__init__.py +++ b/openapi_spec_validator/validation/__init__.py @@ -7,6 +7,9 @@ from openapi_spec_validator.validation.validators import ( OpenAPIV31SpecValidator, ) +from openapi_spec_validator.validation.validators import ( + OpenAPIV32SpecValidator, +) from openapi_spec_validator.validation.validators import SpecValidator __all__ = [ @@ -14,11 +17,13 @@ "openapi_v3_spec_validator", "openapi_v30_spec_validator", "openapi_v31_spec_validator", + "openapi_v32_spec_validator", "openapi_spec_validator_proxy", "OpenAPIV2SpecValidator", "OpenAPIV3SpecValidator", "OpenAPIV30SpecValidator", "OpenAPIV31SpecValidator", + "OpenAPIV32SpecValidator", "SpecValidator", ] @@ -43,9 +48,16 @@ use="OpenAPIV31SpecValidator", ) +# v3.2 spec +openapi_v32_spec_validator = SpecValidatorProxy( + OpenAPIV32SpecValidator, + deprecated="openapi_v32_spec_validator", + use="OpenAPIV32SpecValidator", +) + # alias to the latest v3 version -openapi_v3_spec_validator = openapi_v31_spec_validator -OpenAPIV3SpecValidator = OpenAPIV31SpecValidator +openapi_v3_spec_validator = openapi_v32_spec_validator +OpenAPIV3SpecValidator = OpenAPIV32SpecValidator # detect version spec openapi_spec_validator_proxy = DetectValidatorProxy( @@ -53,5 +65,6 @@ ("swagger", "2.0"): openapi_v2_spec_validator, ("openapi", "3.0"): openapi_v30_spec_validator, ("openapi", "3.1"): openapi_v31_spec_validator, + ("openapi", "3.2"): openapi_v32_spec_validator, }, ) diff --git a/openapi_spec_validator/validation/keywords.py b/openapi_spec_validator/validation/keywords.py index 4678f62..8eccb9d 100644 --- a/openapi_spec_validator/validation/keywords.py +++ b/openapi_spec_validator/validation/keywords.py @@ -14,11 +14,14 @@ from jsonschema.protocols import Validator from jsonschema.validators import validator_for from jsonschema_path.paths import SchemaPath +from openapi_schema_validator import OAS31_BASE_DIALECT_ID +from openapi_schema_validator import OAS32_BASE_DIALECT_ID from openapi_schema_validator import oas30_format_checker from openapi_schema_validator import oas31_format_checker +from openapi_schema_validator import oas32_format_checker from openapi_schema_validator.validators import OAS30Validator from openapi_schema_validator.validators import OAS31Validator -from openapi_schema_validator.validators import check_openapi_schema +from openapi_schema_validator.validators import OAS32Validator from openapi_spec_validator.validation.exceptions import ( DuplicateOperationIDError, @@ -37,8 +40,6 @@ KeywordValidatorRegistry, ) -OAS31_BASE_DIALECT_URI = "https://spec.openapis.org/oas/3.1/dialect/base" - class KeywordValidator: def __init__(self, registry: "KeywordValidatorRegistry"): @@ -71,6 +72,11 @@ class OpenAPIV31ValueValidator(ValueValidator): value_validator_format_checker = oas31_format_checker +class OpenAPIV32ValueValidator(ValueValidator): + value_validator_cls = OAS32Validator + value_validator_format_checker = oas32_format_checker + + class SchemaValidator(KeywordValidator): def __init__(self, registry: "KeywordValidatorRegistry"): super().__init__(registry) @@ -109,13 +115,7 @@ def _collect_properties(self, schema: SchemaPath) -> set[str]: def _get_schema_checker( self, schema: SchemaPath, schema_value: Any ) -> Callable[[Any], None]: - return cast( - Callable[[Any], None], - getattr( - self.default_validator.value_validator_cls, - "check_schema", - ), - ) + raise NotImplementedError def _validate_schema_meta( self, schema: SchemaPath, schema_value: Any @@ -243,7 +243,22 @@ def __call__( yield from self.default_validator(schema, default_value) +class OpenAPIV30SchemaValidator(SchemaValidator): + schema_validator_cls = OAS30Validator + + def _get_schema_checker( + self, schema: SchemaPath, schema_value: Any + ) -> Callable[[Any], None]: + return cast( + Callable[[Any], None], + self.schema_validator_cls.check_schema, + ) + + class OpenAPIV31SchemaValidator(SchemaValidator): + default_jsonschema_dialect_id = OAS31_BASE_DIALECT_ID + schema_validator_format_checker = oas31_format_checker + def __init__(self, registry: "KeywordValidatorRegistry"): super().__init__(registry) self._default_jsonschema_dialect_id: str | None = None @@ -264,9 +279,8 @@ def _get_schema_checker( raise ValueError(f"Unknown JSON Schema dialect: {dialect_id!r}") return partial( - check_openapi_schema, - validator_cls, - format_checker=oas31_format_checker, + validator_cls.check_schema, + format_checker=self.schema_validator_format_checker, ) def _get_schema_dialect_id( @@ -321,7 +335,7 @@ def _get_default_jsonschema_dialect_id(self, schema: SchemaPath) -> str: spec_root = self._get_spec_root(schema) dialect_id = (spec_root / "jsonSchemaDialect").read_str( - default=OAS31_BASE_DIALECT_URI + default=self.default_jsonschema_dialect_id ) self._default_jsonschema_dialect_id = dialect_id @@ -332,6 +346,11 @@ def _get_spec_root(self, schema: SchemaPath) -> SchemaPath: return schema._clone_with_parts(()) +class OpenAPIV32SchemaValidator(OpenAPIV31SchemaValidator): + default_jsonschema_dialect_id = OAS32_BASE_DIALECT_ID + schema_validator_format_checker = oas32_format_checker + + class SchemasValidator(KeywordValidator): @property def schema_validator(self) -> SchemaValidator: @@ -381,7 +400,7 @@ def __call__(self, parameters: SchemaPath) -> Iterator[ValidationError]: key = (parameter["name"], parameter["in"]) if key in seen: yield ParameterDuplicateError( - f"Duplicate parameter `{parameter['name']}`" + f"Duplicate parameter '{parameter['name']}'" ) seen.add(key) @@ -571,6 +590,36 @@ def __call__( ) +class OpenAPIV32PathValidator(PathValidator): + OPERATIONS = [*PathValidator.OPERATIONS, "query"] + + def __call__( + self, url: str, path_item: SchemaPath + ) -> Iterator[ValidationError]: + parameters = None + if "parameters" in path_item: + parameters = path_item / "parameters" + yield from self.parameters_validator(parameters) + + for field_name, operation in path_item.items(): + assert isinstance(field_name, str) + if field_name in self.OPERATIONS: + yield from self.operation_validator( + url, field_name, operation, parameters + ) + continue + + if field_name == "additionalOperations": + for operation_name, additional_operation in operation.items(): + assert isinstance(operation_name, str) + yield from self.operation_validator( + url, + operation_name, + additional_operation, + parameters, + ) + + class PathsValidator(KeywordValidator): @property def path_validator(self) -> PathValidator: diff --git a/openapi_spec_validator/validation/validators.py b/openapi_spec_validator/validation/validators.py index 80a76ae..bf02eb1 100644 --- a/openapi_spec_validator/validation/validators.py +++ b/openapi_spec_validator/validation/validators.py @@ -15,6 +15,7 @@ from openapi_spec_validator.schemas import openapi_v2_schema_validator from openapi_spec_validator.schemas import openapi_v30_schema_validator from openapi_spec_validator.schemas import openapi_v31_schema_validator +from openapi_spec_validator.schemas import openapi_v32_schema_validator from openapi_spec_validator.schemas.types import AnySchema from openapi_spec_validator.validation import keywords from openapi_spec_validator.validation.decorators import unwraps_iter @@ -102,7 +103,7 @@ class OpenAPIV2SpecValidator(SpecValidator): "path": keywords.PathValidator, "response": keywords.OpenAPIV2ResponseValidator, "responses": keywords.ResponsesValidator, - "schema": keywords.SchemaValidator, + "schema": keywords.OpenAPIV30SchemaValidator, "schemas": keywords.SchemasValidator, } root_keywords = ["paths", "components"] @@ -123,7 +124,7 @@ class OpenAPIV30SpecValidator(SpecValidator): "path": keywords.PathValidator, "response": keywords.OpenAPIV3ResponseValidator, "responses": keywords.ResponsesValidator, - "schema": keywords.SchemaValidator, + "schema": keywords.OpenAPIV30SchemaValidator, "schemas": keywords.SchemasValidator, } root_keywords = ["paths", "components"] @@ -148,3 +149,24 @@ class OpenAPIV31SpecValidator(SpecValidator): "schemas": keywords.SchemasValidator, } root_keywords = ["paths", "components"] + + +class OpenAPIV32SpecValidator(SpecValidator): + schema_validator = openapi_v32_schema_validator + keyword_validators = { + "__root__": keywords.RootValidator, + "components": keywords.ComponentsValidator, + "content": keywords.ContentValidator, + "default": keywords.OpenAPIV32ValueValidator, + "mediaType": keywords.MediaTypeValidator, + "operation": keywords.OperationValidator, + "parameter": keywords.ParameterValidator, + "parameters": keywords.ParametersValidator, + "paths": keywords.PathsValidator, + "path": keywords.OpenAPIV32PathValidator, + "response": keywords.OpenAPIV3ResponseValidator, + "responses": keywords.ResponsesValidator, + "schema": keywords.OpenAPIV32SchemaValidator, + "schemas": keywords.SchemasValidator, + } + root_keywords = ["paths", "components"] diff --git a/openapi_spec_validator/versions/__init__.py b/openapi_spec_validator/versions/__init__.py index 2203413..2784405 100644 --- a/openapi_spec_validator/versions/__init__.py +++ b/openapi_spec_validator/versions/__init__.py @@ -1,6 +1,7 @@ from openapi_spec_validator.versions.consts import OPENAPIV2 from openapi_spec_validator.versions.consts import OPENAPIV30 from openapi_spec_validator.versions.consts import OPENAPIV31 +from openapi_spec_validator.versions.consts import OPENAPIV32 from openapi_spec_validator.versions.datatypes import SpecVersion from openapi_spec_validator.versions.shortcuts import get_spec_version @@ -8,6 +9,7 @@ "OPENAPIV2", "OPENAPIV30", "OPENAPIV31", + "OPENAPIV32", "SpecVersion", "get_spec_version", ] diff --git a/openapi_spec_validator/versions/consts.py b/openapi_spec_validator/versions/consts.py index e864595..8734958 100644 --- a/openapi_spec_validator/versions/consts.py +++ b/openapi_spec_validator/versions/consts.py @@ -18,4 +18,10 @@ minor="1", ) -VERSIONS: list[SpecVersion] = [OPENAPIV2, OPENAPIV30, OPENAPIV31] +OPENAPIV32 = SpecVersion( + keyword="openapi", + major="3", + minor="2", +) + +VERSIONS: list[SpecVersion] = [OPENAPIV2, OPENAPIV30, OPENAPIV31, OPENAPIV32] diff --git a/poetry.lock b/poetry.lock index 6f04526..bce671e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1045,14 +1045,14 @@ files = [ [[package]] name = "openapi-schema-validator" -version = "0.7.2" +version = "0.8.0" description = "OpenAPI schema validation for Python" optional = false python-versions = "<4.0.0,>=3.10.0" groups = ["main"] files = [ - {file = "openapi_schema_validator-0.7.2-py3-none-any.whl", hash = "sha256:8f92a1442000f8e15beffdd33b59620523237d56a2f2e783c07e4f4c20d88fbd"}, - {file = "openapi_schema_validator-0.7.2.tar.gz", hash = "sha256:8515cafc62c3f6374c3d0517f1b0ea69650f07fd81d759238199eac2d26eef0c"}, + {file = "openapi_schema_validator-0.8.0-py3-none-any.whl", hash = "sha256:2769ccc58ce6e99a469cafdd7c6b660624a2b05c7052b85e56e6a6f0d7d58742"}, + {file = "openapi_schema_validator-0.8.0.tar.gz", hash = "sha256:5828e9f11bddf8efbbfa50f178783572219fbf0d92f85561bda04ebf5bcefe17"}, ] [package.dependencies] @@ -1061,6 +1061,9 @@ jsonschema-specifications = ">=2024.10.1" referencing = ">=0.37.0,<0.38.0" rfc3339-validator = "*" +[package.extras] +ecma-regex = ["regress (>=2025.10.1)"] + [[package]] name = "packaging" version = "26.0" @@ -2222,4 +2225,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "2351557df9ba38427c372cdc99fd542b2eb4233946c787ebed01f1d5cf3dffff" +content-hash = "ccdc2656740ecd911fc8e7bb645557688fd257f251dd21e5a13305c5aa8730a4" diff --git a/pyproject.toml b/pyproject.toml index d4edc80..80d6d8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ ] dependencies = [ "jsonschema >=4.24.0,<4.25.0", - "openapi-schema-validator >=0.7.2,<0.8.0", + "openapi-schema-validator >=0.8.0,<0.9.0", "jsonschema-path >=0.4.2,<0.5.0", "lazy-object-proxy >=1.7.1,<2.0", ] diff --git a/tests/integration/data/v3.2/petstore.yaml b/tests/integration/data/v3.2/petstore.yaml new file mode 100644 index 0000000..f67f5e7 --- /dev/null +++ b/tests/integration/data/v3.2/petstore.yaml @@ -0,0 +1,115 @@ +openapi: "3.2.0" +info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT License + identifier: MIT +servers: + - url: http://petstore.swagger.io/v1 +paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + responses: + 200: + description: An paged array of pets + headers: + x-next: + description: A link to the next page of responses + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + post: + summary: Create a pet + operationId: createPets + tags: + - pets + responses: + "201": + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /pets/{petId}: + $ref: "#/components/pathItems/PetPath" +components: + schemas: + Pet: + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + $ref: + type: string + Pets: + type: array + items: + $ref: "#/components/schemas/Pet" + Error: + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string + pathItems: + PetPath: + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py index 1ce4e41..e95e4c0 100644 --- a/tests/integration/test_main.py +++ b/tests/integration/test_main.py @@ -38,6 +38,28 @@ def test_schema_v31(capsys): assert "./tests/integration/data/v3.1/petstore.yaml: OK\n" in out +def test_schema_v32_detect(capsys): + """Test schema v3.2 is detected""" + testargs = ["./tests/integration/data/v3.2/petstore.yaml"] + main(testargs) + out, err = capsys.readouterr() + assert not err + assert "./tests/integration/data/v3.2/petstore.yaml: OK\n" in out + + +def test_schema_v32(capsys): + """No errors when calling proper v3.2 file.""" + testargs = [ + "--schema", + "3.2.0", + "./tests/integration/data/v3.2/petstore.yaml", + ] + main(testargs) + out, err = capsys.readouterr() + assert not err + assert "./tests/integration/data/v3.2/petstore.yaml: OK\n" in out + + def test_schema_v30(capsys): """No errors when calling proper v3.0 file.""" testargs = [ diff --git a/tests/integration/test_shortcuts.py b/tests/integration/test_shortcuts.py index f6939c6..cb79007 100644 --- a/tests/integration/test_shortcuts.py +++ b/tests/integration/test_shortcuts.py @@ -1,9 +1,12 @@ import pytest from openapi_spec_validator import OpenAPIV2SpecValidator +from openapi_spec_validator import OpenAPIV3SpecValidator from openapi_spec_validator import OpenAPIV30SpecValidator +from openapi_spec_validator import OpenAPIV32SpecValidator from openapi_spec_validator import openapi_v2_spec_validator from openapi_spec_validator import openapi_v30_spec_validator +from openapi_spec_validator import openapi_v32_spec_validator from openapi_spec_validator import validate from openapi_spec_validator import validate_spec from openapi_spec_validator import validate_spec_url @@ -108,6 +111,29 @@ def test_failed(self, factory, spec_file): validate_spec(spec, validator=openapi_v30_spec_validator) +class TestLocalValidatev32Spec: + LOCAL_SOURCE_DIRECTORY = "data/v3.2/" + + def local_test_suite_file_path(self, test_file): + return f"{self.LOCAL_SOURCE_DIRECTORY}{test_file}" + + @pytest.mark.parametrize( + "spec_file", + [ + "petstore.yaml", + ], + ) + def test_valid(self, factory, spec_file): + spec_path = self.local_test_suite_file_path(spec_file) + spec = factory.spec_from_file(spec_path) + + validate(spec) + validate(spec, cls=OpenAPIV3SpecValidator) + validate(spec, cls=OpenAPIV32SpecValidator) + with pytest.warns(DeprecationWarning): + validate_spec(spec, validator=openapi_v32_spec_validator) + + @pytest.mark.network class TestRemoteValidatev2SpecUrl: REMOTE_SOURCE_URL = ( diff --git a/tests/integration/test_versions.py b/tests/integration/test_versions.py index 891dd50..5af0f9a 100644 --- a/tests/integration/test_versions.py +++ b/tests/integration/test_versions.py @@ -1,10 +1,15 @@ import pytest +import openapi_spec_validator.versions as versions_module from openapi_spec_validator.versions import consts as versions from openapi_spec_validator.versions.exceptions import OpenAPIVersionNotFound from openapi_spec_validator.versions.shortcuts import get_spec_version +def test_versions_module_exports_openapiv32(): + assert versions_module.OPENAPIV32 == versions.OPENAPIV32 + + class TestGetSpecVersion: def test_no_keyword(self): spec = {} @@ -31,6 +36,8 @@ def test_invalid(self, keyword, version): ("openapi", "3.0.2", versions.OPENAPIV30), ("openapi", "3.0.3", versions.OPENAPIV30), ("openapi", "3.1.0", versions.OPENAPIV31), + ("openapi", "3.2.0", versions.OPENAPIV32), + ("openapi", "3.2.1", versions.OPENAPIV32), ], ) def test_valid(self, keyword, version, expected): diff --git a/tests/integration/validation/test_dialect.py b/tests/integration/validation/test_dialect.py index 64dee33..73e81d6 100644 --- a/tests/integration/validation/test_dialect.py +++ b/tests/integration/validation/test_dialect.py @@ -1,4 +1,5 @@ from openapi_spec_validator import OpenAPIV31SpecValidator +from openapi_spec_validator import OpenAPIV32SpecValidator from openapi_spec_validator.validation import keywords as validation_keywords from openapi_spec_validator.validation.exceptions import OpenAPIValidationError @@ -6,9 +7,10 @@ def make_spec( component_schema: dict[str, object] | bool, json_schema_dialect: str | None = None, + openapi_version: str = "3.1.0", ) -> dict[str, object]: spec: dict[str, object] = { - "openapi": "3.1.0", + "openapi": openapi_version, "info": { "title": "Test API", "version": "0.0.1", @@ -161,3 +163,40 @@ def counting_validator_for(*args, **kwargs): assert len(errors) == 2 assert all("Unknown JSON Schema dialect" in err.message for err in errors) assert calls["count"] == 1 + + +def test_oas32_default_root_json_schema_dialect_is_honored(): + spec = make_spec( + {"type": "object"}, + json_schema_dialect="https://json-schema.org/draft/2020-12/schema", + openapi_version="3.2.0", + ) + + errors = list(OpenAPIV32SpecValidator(spec).iter_errors()) + + assert errors == [] + + +def test_oas32_uses_default_dialect_when_jsonschema_dialect_is_missing(): + spec = make_spec( + {"type": "object"}, + openapi_version="3.2.0", + ) + + errors = list(OpenAPIV32SpecValidator(spec).iter_errors()) + + assert errors == [] + + +def test_oas32_unknown_dialect_raises_error(): + spec = make_spec( + {"type": "object"}, + json_schema_dialect="https://example.com/custom", + openapi_version="3.2.0", + ) + + errors = list(OpenAPIV32SpecValidator(spec).iter_errors()) + + assert len(errors) == 1 + assert isinstance(errors[0], OpenAPIValidationError) + assert "Unknown JSON Schema dialect" in errors[0].message diff --git a/tests/integration/validation/test_validators.py b/tests/integration/validation/test_validators.py index d5d64b6..2c95d97 100644 --- a/tests/integration/validation/test_validators.py +++ b/tests/integration/validation/test_validators.py @@ -5,6 +5,7 @@ from openapi_spec_validator import OpenAPIV2SpecValidator from openapi_spec_validator import OpenAPIV30SpecValidator from openapi_spec_validator import OpenAPIV31SpecValidator +from openapi_spec_validator import OpenAPIV32SpecValidator from openapi_spec_validator.validation.exceptions import OpenAPIValidationError @@ -124,6 +125,135 @@ def test_ref_failed(self, factory, spec_file): OpenAPIV30SpecValidator(spec, base_uri=spec_url).validate() +class TestLocalOpenAPIv32Validator: + LOCAL_SOURCE_DIRECTORY = "data/v3.2/" + + def local_test_suite_file_path(self, test_file): + return f"{self.LOCAL_SOURCE_DIRECTORY}{test_file}" + + @pytest.mark.parametrize( + "spec_file", + [ + "petstore.yaml", + ], + ) + def test_valid(self, factory, spec_file): + spec_path = self.local_test_suite_file_path(spec_file) + spec = factory.spec_from_file(spec_path) + spec_url = factory.spec_file_url(spec_path) + validator = OpenAPIV32SpecValidator(spec, base_uri=spec_url) + + validator.validate() + + assert validator.is_valid() + + def test_query_operation_is_semantically_validated(self): + spec = { + "openapi": "3.2.0", + "info": { + "title": "Query API", + "version": "1.0.0", + }, + "paths": { + "/items/{item_id}": { + "query": { + "responses": { + "200": { + "description": "ok", + }, + }, + }, + }, + }, + } + + errors = list(OpenAPIV32SpecValidator(spec).iter_errors()) + + assert len(errors) == 1 + assert "Path parameter 'item_id'" in errors[0].message + + def test_additional_operations_are_semantically_validated(self): + spec = { + "openapi": "3.2.0", + "info": { + "title": "Additional API", + "version": "1.0.0", + }, + "paths": { + "/items/{item_id}": { + "additionalOperations": { + "CUSTOM": { + "responses": { + "200": { + "description": "ok", + }, + }, + }, + }, + }, + }, + } + + errors = list(OpenAPIV32SpecValidator(spec).iter_errors()) + + assert len(errors) == 1 + assert "Path parameter 'item_id'" in errors[0].message + + +def test_oas31_query_operation_is_not_semantically_traversed(): + spec = { + "openapi": "3.1.0", + "info": { + "title": "Query API", + "version": "1.0.0", + }, + "paths": { + "/items/{item_id}": { + "query": { + "responses": { + "200": { + "description": "ok", + }, + }, + }, + }, + }, + } + + errors = list(OpenAPIV31SpecValidator(spec).iter_errors()) + + assert errors + assert all("Path parameter 'item_id'" not in err.message for err in errors) + + +def test_oas31_additional_operations_are_not_semantically_traversed(): + spec = { + "openapi": "3.1.0", + "info": { + "title": "Additional API", + "version": "1.0.0", + }, + "paths": { + "/items/{item_id}": { + "additionalOperations": { + "CUSTOM": { + "responses": { + "200": { + "description": "ok", + }, + }, + }, + }, + }, + }, + } + + errors = list(OpenAPIV31SpecValidator(spec).iter_errors()) + + assert errors + assert all("Path parameter 'item_id'" not in err.message for err in errors) + + @pytest.mark.network class TestRemoteOpenAPIv30Validator: REMOTE_SOURCE_URL = ( From 383d17aaf10f603aeb29d5c85ce8fa0dc683b639 Mon Sep 17 00:00:00 2001 From: p1c2u Date: Fri, 27 Feb 2026 11:28:35 +0000 Subject: [PATCH 2/2] Add OpenAPI 3.2 tags --- openapi_spec_validator/validation/keywords.py | 64 +++++++++++++++++++ .../validation/validators.py | 4 ++ .../integration/validation/test_validators.py | 58 +++++++++++++++++ 3 files changed, 126 insertions(+) diff --git a/openapi_spec_validator/validation/keywords.py b/openapi_spec_validator/validation/keywords.py index 8eccb9d..2371067 100644 --- a/openapi_spec_validator/validation/keywords.py +++ b/openapi_spec_validator/validation/keywords.py @@ -642,6 +642,65 @@ def __call__(self, components: SchemaPath) -> Iterator[ValidationError]: yield from self.schemas_validator(schemas) +class TagsValidator(KeywordValidator): + def __call__(self, tags: SchemaPath) -> Iterator[ValidationError]: + seen: set[str] = set() + for tag in tags: + tag_name = (tag / "name").read_str() + if tag_name in seen: + yield OpenAPIValidationError( + f"Duplicate tag name '{tag_name}'" + ) + seen.add(tag_name) + + +class OpenAPIV32TagsValidator(TagsValidator): + def __call__(self, tags: SchemaPath) -> Iterator[ValidationError]: + yield from super().__call__(tags) + + seen: set[str] = set() + parent_by_tag_name: dict[str, str | None] = {} + for tag in tags: + tag_name = (tag / "name").read_str() + seen.add(tag_name) + + if "parent" in tag: + parent_by_tag_name[tag_name] = (tag / "parent").read_str() + else: + parent_by_tag_name[tag_name] = None + + for tag_name, parent in parent_by_tag_name.items(): + if parent is not None and parent not in seen: + yield OpenAPIValidationError( + f"Tag '{tag_name}' references unknown parent tag '{parent}'" + ) + + reported_cycles: set[str] = set() + for start_tag_name in parent_by_tag_name: + tag_name = start_tag_name + trail: list[str] = [] + trail_pos: dict[str, int] = {} + + while True: + if tag_name in trail_pos: + cycle = trail[trail_pos[tag_name] :] + [tag_name] + cycle_str = " -> ".join(cycle) + if cycle_str not in reported_cycles: + reported_cycles.add(cycle_str) + yield OpenAPIValidationError( + f"Circular tag hierarchy detected: {cycle_str}" + ) + break + + trail_pos[tag_name] = len(trail) + trail.append(tag_name) + + parent = parent_by_tag_name.get(tag_name) + if parent is None or parent not in seen: + break + tag_name = parent + + class RootValidator(KeywordValidator): @property def paths_validator(self) -> PathsValidator: @@ -652,6 +711,11 @@ def components_validator(self) -> ComponentsValidator: return cast(ComponentsValidator, self.registry["components"]) def __call__(self, spec: SchemaPath) -> Iterator[ValidationError]: + if "tags" in spec and "tags" in self.registry.keyword_validators: + tags = spec / "tags" + tags_validator = cast(Any, self.registry["tags"]) + yield from tags_validator(tags) + if "paths" in spec: paths = spec / "paths" yield from self.paths_validator(paths) diff --git a/openapi_spec_validator/validation/validators.py b/openapi_spec_validator/validation/validators.py index bf02eb1..3eca7a4 100644 --- a/openapi_spec_validator/validation/validators.py +++ b/openapi_spec_validator/validation/validators.py @@ -105,6 +105,7 @@ class OpenAPIV2SpecValidator(SpecValidator): "responses": keywords.ResponsesValidator, "schema": keywords.OpenAPIV30SchemaValidator, "schemas": keywords.SchemasValidator, + "tags": keywords.TagsValidator, } root_keywords = ["paths", "components"] @@ -126,6 +127,7 @@ class OpenAPIV30SpecValidator(SpecValidator): "responses": keywords.ResponsesValidator, "schema": keywords.OpenAPIV30SchemaValidator, "schemas": keywords.SchemasValidator, + "tags": keywords.TagsValidator, } root_keywords = ["paths", "components"] @@ -147,6 +149,7 @@ class OpenAPIV31SpecValidator(SpecValidator): "responses": keywords.ResponsesValidator, "schema": keywords.OpenAPIV31SchemaValidator, "schemas": keywords.SchemasValidator, + "tags": keywords.TagsValidator, } root_keywords = ["paths", "components"] @@ -168,5 +171,6 @@ class OpenAPIV32SpecValidator(SpecValidator): "responses": keywords.ResponsesValidator, "schema": keywords.OpenAPIV32SchemaValidator, "schemas": keywords.SchemasValidator, + "tags": keywords.OpenAPIV32TagsValidator, } root_keywords = ["paths", "components"] diff --git a/tests/integration/validation/test_validators.py b/tests/integration/validation/test_validators.py index 2c95d97..38c7b61 100644 --- a/tests/integration/validation/test_validators.py +++ b/tests/integration/validation/test_validators.py @@ -199,6 +199,64 @@ def test_additional_operations_are_semantically_validated(self): assert len(errors) == 1 assert "Path parameter 'item_id'" in errors[0].message + def test_top_level_duplicate_tags_are_invalid(self): + spec = { + "openapi": "3.2.0", + "info": { + "title": "Tag API", + "version": "1.0.0", + }, + "tags": [ + { + "name": "pets", + }, + { + "name": "pets", + }, + ], + "paths": { + "/pets": { + "get": { + "responses": { + "200": { + "description": "ok", + }, + }, + }, + }, + }, + } + + errors = list(OpenAPIV32SpecValidator(spec).iter_errors()) + + assert len(errors) == 1 + assert errors[0].message == "Duplicate tag name 'pets'" + + def test_operation_tags_without_root_declaration_are_valid(self): + spec = { + "openapi": "3.2.0", + "info": { + "title": "Tag API", + "version": "1.0.0", + }, + "paths": { + "/pets": { + "get": { + "tags": ["pets", "animals"], + "responses": { + "200": { + "description": "ok", + }, + }, + }, + }, + }, + } + + errors = list(OpenAPIV32SpecValidator(spec).iter_errors()) + + assert not errors + def test_oas31_query_operation_is_not_semantically_traversed(): spec = {