From bc567a264a0b1274eb3ed25ab988b6f59abdb18c Mon Sep 17 00:00:00 2001 From: p1c2u <1679024+p1c2u@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:08:53 +0000 Subject: [PATCH] Check OpenAPI 3.0 schemas against the OAS 3.0 Schema Object schema OpenAPI 3.0 schemas were checked against the JSON Schema Draft 4 metaschema, which accepts keywords OpenAPI 3.0 does not support (type arrays, definitions, id, $schema, patternProperties, const, unknown keywords, ...). Vendor the official OAS 3.0 schema (2024-10-18) unmodified and check OAS 3.0 schemas against its Schema, Reference, Discriminator, XML and ExternalDocumentation definitions. Schema or Reference is chosen by the presence of $ref instead of the official oneOf, so errors point at the offending keyword and keys next to $ref are ignored as the specification requires. Apply the check to OAS30Validator and the Strict, Read and Write variants; the variants previously used jsonschema's default Draft 4 check. This is a breaking change: schemas using unsupported keywords, and roots carrying non-Schema Object keys such as components or a whole OpenAPI document, now raise SchemaError. Referenced schemas can be provided via registry, or the check skipped with check_schema=False. --- openapi_schema_validator/_dialects.py | 2 + .../schemas/oas3.0/schema/2024-10-18.json | 1651 +++++++++++++++++ openapi_schema_validator/validators.py | 91 +- .../test_oas30_schema_object_metaschema.py | 270 +++ 4 files changed, 2013 insertions(+), 1 deletion(-) create mode 100644 openapi_schema_validator/schemas/oas3.0/schema/2024-10-18.json create mode 100644 tests/unit/test_oas30_schema_object_metaschema.py diff --git a/openapi_schema_validator/_dialects.py b/openapi_schema_validator/_dialects.py index f301ed0..f597e56 100644 --- a/openapi_schema_validator/_dialects.py +++ b/openapi_schema_validator/_dialects.py @@ -7,6 +7,7 @@ ) __all__ = [ + "OAS30_SCHEMA_ID", "OAS31_BASE_DIALECT_ID", "OAS31_BASE_DIALECT_METASCHEMA", "OAS32_BASE_DIALECT_ID", @@ -14,6 +15,7 @@ "register_openapi_dialect", ] +OAS30_SCHEMA_ID = "https://spec.openapis.org/oas/3.0/schema/2024-10-18" OAS31_BASE_DIALECT_ID = "https://spec.openapis.org/oas/3.1/dialect/base" OAS31_BASE_DIALECT_METASCHEMA = OPENAPI_SPECIFICATIONS.contents( OAS31_BASE_DIALECT_ID, diff --git a/openapi_schema_validator/schemas/oas3.0/schema/2024-10-18.json b/openapi_schema_validator/schemas/oas3.0/schema/2024-10-18.json new file mode 100644 index 0000000..a9cd941 --- /dev/null +++ b/openapi_schema_validator/schemas/oas3.0/schema/2024-10-18.json @@ -0,0 +1,1651 @@ +{ + "id": "https://spec.openapis.org/oas/3.0/schema/2024-10-18", + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "The description of OpenAPI v3.0.x Documents", + "type": "object", + "required": [ + "openapi", + "info", + "paths" + ], + "properties": { + "openapi": { + "type": "string", + "pattern": "^3\\.0\\.\\d(-.+)?$" + }, + "info": { + "$ref": "#/definitions/Info" + }, + "externalDocs": { + "$ref": "#/definitions/ExternalDocumentation" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/definitions/Server" + } + }, + "security": { + "type": "array", + "items": { + "$ref": "#/definitions/SecurityRequirement" + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/Tag" + }, + "uniqueItems": true + }, + "paths": { + "$ref": "#/definitions/Paths" + }, + "components": { + "$ref": "#/definitions/Components" + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false, + "definitions": { + "Reference": { + "type": "object", + "required": [ + "$ref" + ], + "patternProperties": { + "^\\$ref$": { + "type": "string", + "format": "uri-reference" + } + } + }, + "Info": { + "type": "object", + "required": [ + "title", + "version" + ], + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "termsOfService": { + "type": "string", + "format": "uri-reference" + }, + "contact": { + "$ref": "#/definitions/Contact" + }, + "license": { + "$ref": "#/definitions/License" + }, + "version": { + "type": "string" + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "Contact": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + }, + "email": { + "type": "string", + "format": "email" + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "License": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "Server": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string" + }, + "description": { + "type": "string" + }, + "variables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/ServerVariable" + } + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "ServerVariable": { + "type": "object", + "required": [ + "default" + ], + "properties": { + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "default": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "Components": { + "type": "object", + "properties": { + "schemas": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + } + }, + "responses": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/Response" + } + ] + } + } + }, + "parameters": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/Parameter" + } + ] + } + } + }, + "examples": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/Example" + } + ] + } + } + }, + "requestBodies": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/RequestBody" + } + ] + } + } + }, + "headers": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/Header" + } + ] + } + } + }, + "securitySchemes": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/SecurityScheme" + } + ] + } + } + }, + "links": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/Link" + } + ] + } + } + }, + "callbacks": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/Callback" + } + ] + } + } + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "Schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "multipleOf": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "boolean", + "default": false + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "boolean", + "default": false + }, + "maxLength": { + "type": "integer", + "minimum": 0 + }, + "minLength": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "pattern": { + "type": "string", + "format": "regex" + }, + "maxItems": { + "type": "integer", + "minimum": 0 + }, + "minItems": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "maxProperties": { + "type": "integer", + "minimum": 0 + }, + "minProperties": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "required": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + }, + "enum": { + "type": "array", + "items": {}, + "minItems": 1, + "uniqueItems": false + }, + "type": { + "type": "string", + "enum": [ + "array", + "boolean", + "integer", + "number", + "object", + "string" + ] + }, + "not": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "allOf": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "oneOf": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "anyOf": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + }, + { + "type": "boolean" + } + ], + "default": true + }, + "description": { + "type": "string" + }, + "format": { + "type": "string" + }, + "default": {}, + "nullable": { + "type": "boolean", + "default": false + }, + "discriminator": { + "$ref": "#/definitions/Discriminator" + }, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "example": {}, + "externalDocs": { + "$ref": "#/definitions/ExternalDocumentation" + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "xml": { + "$ref": "#/definitions/XML" + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "Discriminator": { + "type": "object", + "required": [ + "propertyName" + ], + "properties": { + "propertyName": { + "type": "string" + }, + "mapping": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "XML": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string", + "format": "uri" + }, + "prefix": { + "type": "string" + }, + "attribute": { + "type": "boolean", + "default": false + }, + "wrapped": { + "type": "boolean", + "default": false + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "Response": { + "type": "object", + "required": [ + "description" + ], + "properties": { + "description": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Header" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "content": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/MediaType" + } + }, + "links": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Link" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "MediaType": { + "type": "object", + "properties": { + "schema": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "example": {}, + "examples": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Example" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "encoding": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Encoding" + } + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false, + "allOf": [ + { + "$ref": "#/definitions/ExampleXORExamples" + } + ] + }, + "Example": { + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": {}, + "externalValue": { + "type": "string", + "format": "uri-reference" + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "Header": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "allowEmptyValue": { + "type": "boolean", + "default": false + }, + "style": { + "type": "string", + "enum": [ + "simple" + ], + "default": "simple" + }, + "explode": { + "type": "boolean" + }, + "allowReserved": { + "type": "boolean", + "default": false + }, + "schema": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "content": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/MediaType" + }, + "minProperties": 1, + "maxProperties": 1 + }, + "example": {}, + "examples": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Example" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false, + "allOf": [ + { + "$ref": "#/definitions/ExampleXORExamples" + }, + { + "$ref": "#/definitions/SchemaXORContent" + } + ] + }, + "Paths": { + "type": "object", + "patternProperties": { + "^\\/": { + "$ref": "#/definitions/PathItem" + }, + "^x-": {} + }, + "additionalProperties": false + }, + "PathItem": { + "type": "object", + "properties": { + "$ref": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "get": { + "$ref": "#/definitions/Operation" + }, + "put": { + "$ref": "#/definitions/Operation" + }, + "post": { + "$ref": "#/definitions/Operation" + }, + "delete": { + "$ref": "#/definitions/Operation" + }, + "options": { + "$ref": "#/definitions/Operation" + }, + "head": { + "$ref": "#/definitions/Operation" + }, + "patch": { + "$ref": "#/definitions/Operation" + }, + "trace": { + "$ref": "#/definitions/Operation" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/definitions/Server" + } + }, + "parameters": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Parameter" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "uniqueItems": true + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "Operation": { + "type": "object", + "required": [ + "responses" + ], + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/definitions/ExternalDocumentation" + }, + "operationId": { + "type": "string" + }, + "parameters": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Parameter" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "uniqueItems": true + }, + "requestBody": { + "oneOf": [ + { + "$ref": "#/definitions/RequestBody" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "responses": { + "$ref": "#/definitions/Responses" + }, + "callbacks": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Callback" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "security": { + "type": "array", + "items": { + "$ref": "#/definitions/SecurityRequirement" + } + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/definitions/Server" + } + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "Responses": { + "type": "object", + "properties": { + "default": { + "oneOf": [ + { + "$ref": "#/definitions/Response" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "patternProperties": { + "^[1-5](?:\\d{2}|XX)$": { + "oneOf": [ + { + "$ref": "#/definitions/Response" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "^x-": {} + }, + "minProperties": 1, + "additionalProperties": false + }, + "SecurityRequirement": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "Tag": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/definitions/ExternalDocumentation" + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "ExternalDocumentation": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "description": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "ExampleXORExamples": { + "description": "Example and examples are mutually exclusive", + "not": { + "required": [ + "example", + "examples" + ] + } + }, + "SchemaXORContent": { + "description": "Schema and content are mutually exclusive, at least one is required", + "not": { + "required": [ + "schema", + "content" + ] + }, + "oneOf": [ + { + "required": [ + "schema" + ] + }, + { + "required": [ + "content" + ], + "description": "Some properties are not allowed if content is present", + "allOf": [ + { + "not": { + "required": [ + "style" + ] + } + }, + { + "not": { + "required": [ + "explode" + ] + } + }, + { + "not": { + "required": [ + "allowReserved" + ] + } + }, + { + "not": { + "required": [ + "example" + ] + } + }, + { + "not": { + "required": [ + "examples" + ] + } + } + ] + } + ] + }, + "Parameter": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "in": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "allowEmptyValue": { + "type": "boolean", + "default": false + }, + "style": { + "type": "string" + }, + "explode": { + "type": "boolean" + }, + "allowReserved": { + "type": "boolean", + "default": false + }, + "schema": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "content": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/MediaType" + }, + "minProperties": 1, + "maxProperties": 1 + }, + "example": {}, + "examples": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Example" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false, + "required": [ + "name", + "in" + ], + "allOf": [ + { + "$ref": "#/definitions/ExampleXORExamples" + }, + { + "$ref": "#/definitions/SchemaXORContent" + } + ], + "oneOf": [ + { + "$ref": "#/definitions/PathParameter" + }, + { + "$ref": "#/definitions/QueryParameter" + }, + { + "$ref": "#/definitions/HeaderParameter" + }, + { + "$ref": "#/definitions/CookieParameter" + } + ] + }, + "PathParameter": { + "description": "Parameter in path", + "required": [ + "required" + ], + "properties": { + "in": { + "enum": [ + "path" + ] + }, + "style": { + "enum": [ + "matrix", + "label", + "simple" + ], + "default": "simple" + }, + "required": { + "enum": [ + true + ] + } + } + }, + "QueryParameter": { + "description": "Parameter in query", + "properties": { + "in": { + "enum": [ + "query" + ] + }, + "style": { + "enum": [ + "form", + "spaceDelimited", + "pipeDelimited", + "deepObject" + ], + "default": "form" + } + } + }, + "HeaderParameter": { + "description": "Parameter in header", + "properties": { + "in": { + "enum": [ + "header" + ] + }, + "style": { + "enum": [ + "simple" + ], + "default": "simple" + } + } + }, + "CookieParameter": { + "description": "Parameter in cookie", + "properties": { + "in": { + "enum": [ + "cookie" + ] + }, + "style": { + "enum": [ + "form" + ], + "default": "form" + } + } + }, + "RequestBody": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "description": { + "type": "string" + }, + "content": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/MediaType" + } + }, + "required": { + "type": "boolean", + "default": false + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "SecurityScheme": { + "oneOf": [ + { + "$ref": "#/definitions/APIKeySecurityScheme" + }, + { + "$ref": "#/definitions/HTTPSecurityScheme" + }, + { + "$ref": "#/definitions/OAuth2SecurityScheme" + }, + { + "$ref": "#/definitions/OpenIdConnectSecurityScheme" + } + ] + }, + "APIKeySecurityScheme": { + "type": "object", + "required": [ + "type", + "name", + "in" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "apiKey" + ] + }, + "name": { + "type": "string" + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "cookie" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "HTTPSecurityScheme": { + "type": "object", + "required": [ + "scheme", + "type" + ], + "properties": { + "scheme": { + "type": "string" + }, + "bearerFormat": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "http" + ] + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false, + "oneOf": [ + { + "description": "Bearer", + "properties": { + "scheme": { + "type": "string", + "pattern": "^[Bb][Ee][Aa][Rr][Ee][Rr]$" + } + } + }, + { + "description": "Non Bearer", + "not": { + "required": [ + "bearerFormat" + ] + }, + "properties": { + "scheme": { + "not": { + "type": "string", + "pattern": "^[Bb][Ee][Aa][Rr][Ee][Rr]$" + } + } + } + } + ] + }, + "OAuth2SecurityScheme": { + "type": "object", + "required": [ + "type", + "flows" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "oauth2" + ] + }, + "flows": { + "$ref": "#/definitions/OAuthFlows" + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "OpenIdConnectSecurityScheme": { + "type": "object", + "required": [ + "type", + "openIdConnectUrl" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "openIdConnect" + ] + }, + "openIdConnectUrl": { + "type": "string", + "format": "uri-reference" + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "OAuthFlows": { + "type": "object", + "properties": { + "implicit": { + "$ref": "#/definitions/ImplicitOAuthFlow" + }, + "password": { + "$ref": "#/definitions/PasswordOAuthFlow" + }, + "clientCredentials": { + "$ref": "#/definitions/ClientCredentialsFlow" + }, + "authorizationCode": { + "$ref": "#/definitions/AuthorizationCodeOAuthFlow" + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "ImplicitOAuthFlow": { + "type": "object", + "required": [ + "authorizationUrl", + "scopes" + ], + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "PasswordOAuthFlow": { + "type": "object", + "required": [ + "tokenUrl", + "scopes" + ], + "properties": { + "tokenUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "ClientCredentialsFlow": { + "type": "object", + "required": [ + "tokenUrl", + "scopes" + ], + "properties": { + "tokenUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "AuthorizationCodeOAuthFlow": { + "type": "object", + "required": [ + "authorizationUrl", + "tokenUrl", + "scopes" + ], + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri-reference" + }, + "tokenUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + }, + "Link": { + "type": "object", + "properties": { + "operationId": { + "type": "string" + }, + "operationRef": { + "type": "string", + "format": "uri-reference" + }, + "parameters": { + "type": "object", + "additionalProperties": {} + }, + "requestBody": {}, + "description": { + "type": "string" + }, + "server": { + "$ref": "#/definitions/Server" + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false, + "not": { + "description": "Operation Id and Operation Ref are mutually exclusive", + "required": [ + "operationId", + "operationRef" + ] + } + }, + "Callback": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/PathItem" + }, + "patternProperties": { + "^x-": {} + } + }, + "Encoding": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Header" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "style": { + "type": "string", + "enum": [ + "form", + "spaceDelimited", + "pipeDelimited", + "deepObject" + ] + }, + "explode": { + "type": "boolean" + }, + "allowReserved": { + "type": "boolean", + "default": false + } + }, + "patternProperties": { + "^x-": {} + }, + "additionalProperties": false + } + } +} \ No newline at end of file diff --git a/openapi_schema_validator/validators.py b/openapi_schema_validator/validators.py index 9edee4b..5983dec 100644 --- a/openapi_schema_validator/validators.py +++ b/openapi_schema_validator/validators.py @@ -9,6 +9,8 @@ from jsonschema.exceptions import SchemaError from jsonschema.exceptions import ValidationError from jsonschema.protocols import Validator +from jsonschema.validators import Draft4Validator +from jsonschema.validators import Draft7Validator from jsonschema.validators import Draft202012Validator from jsonschema.validators import create from jsonschema.validators import extend @@ -17,6 +19,7 @@ from openapi_schema_validator import _format as oas_format from openapi_schema_validator import _keywords as oas_keywords from openapi_schema_validator import _types as oas_types +from openapi_schema_validator._dialects import OAS30_SCHEMA_ID from openapi_schema_validator._dialects import OAS31_BASE_DIALECT_ID from openapi_schema_validator._dialects import OAS31_BASE_DIALECT_METASCHEMA from openapi_schema_validator._dialects import OAS32_BASE_DIALECT_ID @@ -50,6 +53,89 @@ def check_openapi_schema( raise SchemaError.create_from(error) +_OAS30_SCHEMA_OBJECT_DEFINITIONS = ( + "Schema", + "Reference", + "Discriminator", + "XML", + "ExternalDocumentation", +) +_OAS30_SCHEMA_OR_REFERENCE = [ + {"$ref": "#/definitions/Schema"}, + {"$ref": "#/definitions/Reference"}, +] +# Choose the alternative from ``$ref`` the way the specification does, so +# errors point at the offending keyword instead of a generic oneOf failure. +# Keys next to ``$ref`` are allowed by the Reference definition and ignored. +_OAS30_SCHEMA_OR_REFERENCE_CHECK = { + "if": {"required": ["$ref"]}, + "then": {"$ref": "#/definitions/Reference"}, + "else": {"$ref": "#/definitions/Schema"}, +} + + +def _dispatch_schema_or_reference(value: Any) -> Any: + if isinstance(value, list): + return [_dispatch_schema_or_reference(item) for item in value] + if not isinstance(value, Mapping): + return value + alternatives = value.get("oneOf") + if alternatives == _OAS30_SCHEMA_OR_REFERENCE: + rest = {k: v for k, v in value.items() if k != "oneOf"} + return {**rest, **_OAS30_SCHEMA_OR_REFERENCE_CHECK} + if alternatives == [*_OAS30_SCHEMA_OR_REFERENCE, {"type": "boolean"}]: + rest = {k: v for k, v in value.items() if k != "oneOf"} + return { + **rest, + "if": {"type": "object"}, + "then": _OAS30_SCHEMA_OR_REFERENCE_CHECK, + "else": {"type": "boolean"}, + } + return { + key: _dispatch_schema_or_reference(item) for key, item in value.items() + } + + +def _build_oas30_check_schema() -> dict[str, Any]: + # The OpenAPI 3.0 Schema Object as published by the OpenAPI Initiative: + # only the fields the specification lists, plus ``x-`` extensions. The + # vendored document stays unmodified; this anonymous copy keeps its + # definitions and only rewrites the Schema/Reference alternatives. + definitions = OPENAPI_SPECIFICATIONS.contents(OAS30_SCHEMA_ID)[ + "definitions" + ] + return { + "definitions": { + name: _dispatch_schema_or_reference(definitions[name]) + for name in _OAS30_SCHEMA_OBJECT_DEFINITIONS + }, + **_OAS30_SCHEMA_OR_REFERENCE_CHECK, + } + + +_OAS30_CHECK_SCHEMA = _build_oas30_check_schema() +_OAS30_SCHEMA_CHECKER = extend( + Draft4Validator, {"if": Draft7Validator.VALIDATORS["if"]} +) + + +def check_oas30_schema( + cls: Any, + schema: Any, + format_checker: Any = _CHECK_SCHEMA_UNSET, +) -> None: + if format_checker is _CHECK_SCHEMA_UNSET: + format_checker = cls.FORMAT_CHECKER + + validator = _OAS30_SCHEMA_CHECKER( + _OAS30_CHECK_SCHEMA, + format_checker=format_checker, + ) + + for error in validator.iter_errors(schema): + raise SchemaError.create_from(error) + + def _oas30_id_of(schema: Any) -> str: if isinstance(schema, dict): return schema.get("id", "") # type: ignore[no-any-return] @@ -189,7 +275,10 @@ def _build_oas32_validator() -> Any: # each class's FORMAT_CHECKER. Override check_schema on each class to keep # OpenAPI metaschema resolution local and to apply optional ecma-regex # behavior consistently across OAS 3.0/3.1/3.2. -OAS30Validator.check_schema = classmethod(check_openapi_schema) +OAS30Validator.check_schema = classmethod(check_oas30_schema) +OAS30StrictValidator.check_schema = classmethod(check_oas30_schema) +OAS30ReadValidator.check_schema = classmethod(check_oas30_schema) +OAS30WriteValidator.check_schema = classmethod(check_oas30_schema) OAS31Validator.check_schema = classmethod(check_openapi_schema) OAS32Validator.check_schema = classmethod(check_openapi_schema) diff --git a/tests/unit/test_oas30_schema_object_metaschema.py b/tests/unit/test_oas30_schema_object_metaschema.py new file mode 100644 index 0000000..26db931 --- /dev/null +++ b/tests/unit/test_oas30_schema_object_metaschema.py @@ -0,0 +1,270 @@ +import json +from typing import Any + +import pytest +from jsonschema.exceptions import SchemaError +from jsonschema.exceptions import ValidationError +from jsonschema.validators import Draft4Validator +from referencing import Registry +from referencing.jsonschema import DRAFT4 + +from openapi_schema_validator import OAS31Validator +from openapi_schema_validator import OAS32Validator +from openapi_schema_validator import validate +from openapi_schema_validator._dialects import OAS30_SCHEMA_ID +from openapi_schema_validator._specifications import ( + REGISTRY as OPENAPI_SPECIFICATIONS, +) +from openapi_schema_validator.validators import _OAS30_CHECK_SCHEMA +from openapi_schema_validator.validators import OAS30ReadValidator +from openapi_schema_validator.validators import OAS30StrictValidator +from openapi_schema_validator.validators import OAS30Validator +from openapi_schema_validator.validators import OAS30WriteValidator + +OAS30_VALIDATORS = [ + OAS30Validator, + OAS30StrictValidator, + OAS30ReadValidator, + OAS30WriteValidator, +] + +FULL_SCHEMA: dict[str, Any] = { + "title": "Pet", + "description": "A pet", + "type": "object", + "required": ["name", "kind"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z]+$", + "nullable": True, + "default": "rex", + "example": "rex", + }, + "age": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": True, + "maximum": 30, + "exclusiveMaximum": False, + "multipleOf": 1, + "readOnly": True, + }, + "tags": { + "type": "array", + "items": {"type": "string", "enum": ["a", "b"]}, + "minItems": 0, + "maxItems": 3, + "uniqueItems": True, + "xml": {"name": "tag", "wrapped": True}, + }, + "kind": {"$ref": "#/properties/name", "description": "ignored"}, + "owner": { + "allOf": [{"$ref": "#/properties/name"}], + "anyOf": [{"type": "string"}], + "oneOf": [{"type": "string"}], + "not": {"type": "integer"}, + "writeOnly": True, + "deprecated": True, + }, + }, + "additionalProperties": {"type": "string", "format": "email"}, + "minProperties": 1, + "maxProperties": 10, + "discriminator": {"propertyName": "kind", "mapping": {"dog": "#/x"}}, + "externalDocs": {"url": "https://example.com/docs"}, + "xml": {"name": "pet", "x-extra": True}, + "x-internal": {"anything": ["goes"]}, +} + + +def test_vendored_document_is_the_official_oas30_schema() -> None: + document = OPENAPI_SPECIFICATIONS.contents(OAS30_SCHEMA_ID) + + assert document["id"] == OAS30_SCHEMA_ID + assert document["$schema"] == "http://json-schema.org/draft-04/schema#" + assert document["definitions"]["Schema"]["additionalProperties"] is False + + +def test_check_schema_rewrites_only_schema_or_reference_alternatives() -> None: + serialized = json.dumps(_OAS30_CHECK_SCHEMA) + + assert "#/definitions/Schema" in serialized + assert '"oneOf": [' not in serialized + + +@pytest.mark.parametrize("cls", OAS30_VALIDATORS) +def test_accepts_all_schema_object_fields(cls: Any) -> None: + cls.check_schema(FULL_SCHEMA) + + +@pytest.mark.parametrize( + "schema, message", + [ + ( + {"type": ["string", "null"]}, + "['string', 'null'] is not of type 'string'", + ), + ({"type": "null"}, "'null' is not one of"), + ({"items": [{"type": "string"}]}, "is not of type 'object'"), + ({"definitions": {"Name": {}}}, "'definitions' does not match"), + ({"id": "https://example.com/pet"}, "'id' does not match"), + ( + {"$schema": "http://json-schema.org/draft-04/schema#"}, + "'$schema' does not match", + ), + ({"patternProperties": {"^a": {}}}, "'patternProperties' does not"), + ({"additionalItems": False}, "'additionalItems' does not match"), + ({"dependencies": {"a": ["b"]}}, "'dependencies' does not match"), + ({"const": 1}, "'const' does not match"), + ({"examples": [1]}, "'examples' does not match"), + ({"unknownKeyword": 1}, "'unknownKeyword' does not match"), + ], +) +def test_rejects_keywords_oas30_does_not_support( + schema: dict[str, Any], message: str +) -> None: + # The Draft 4 metaschema used previously accepts all of these. + Draft4Validator.check_schema(schema) + + with pytest.raises(SchemaError) as excinfo: + OAS30Validator.check_schema(schema) + assert message in excinfo.value.message + + +@pytest.mark.parametrize( + "wrap", + [ + lambda s: {"properties": {"a": s}}, + lambda s: {"items": s}, + lambda s: {"allOf": [s]}, + lambda s: {"anyOf": [{"type": "string"}, s]}, + lambda s: {"oneOf": [s]}, + lambda s: {"not": s}, + lambda s: {"additionalProperties": s}, + lambda s: {"properties": {"a": {"items": {"not": s}}}}, + ], +) +def test_checks_every_schema_position(wrap: Any) -> None: + OAS30Validator.check_schema(wrap({"type": "string"})) + OAS30Validator.check_schema(wrap({"$ref": "#/x"})) + + with pytest.raises(SchemaError, match="'definitions' does not match"): + OAS30Validator.check_schema(wrap({"definitions": {}})) + + +@pytest.mark.parametrize("value", [True, False]) +def test_accepts_boolean_additional_properties(value: bool) -> None: + OAS30Validator.check_schema({"additionalProperties": value}) + + +@pytest.mark.parametrize( + "schema, message", + [ + ({"discriminator": {"mapping": {}}}, "'propertyName' is a required"), + ({"xml": {"unknown": 1}}, "'unknown' does not match"), + ({"externalDocs": {}}, "'url' is a required property"), + ({"nullable": "yes"}, "'yes' is not of type 'boolean'"), + ({"readOnly": 1}, "1 is not of type 'boolean'"), + ({"exclusiveMinimum": 1}, "1 is not of type 'boolean'"), + ({"minLength": -1}, "-1 is less than the minimum of 0"), + ({"required": []}, "should be non-empty"), + (True, "True is not of type 'object'"), + ], +) +def test_rejects_malformed_fixed_fields(schema: Any, message: str) -> None: + with pytest.raises(SchemaError) as excinfo: + OAS30Validator.check_schema(schema) + assert message in excinfo.value.message + + +def test_ignores_reference_siblings() -> None: + schema = { + "properties": { + "a": { + "$ref": "#/properties/b", + "definitions": {}, + "type": ["string"], + }, + "b": {"type": "string"}, + }, + } + + OAS30Validator.check_schema(schema) + + +def test_rejects_non_string_reference() -> None: + with pytest.raises(SchemaError, match="is not of type 'string'"): + OAS30Validator.check_schema({"$ref": 5}) + + +def test_ignores_schema_next_to_reference() -> None: + OAS30Validator.check_schema( + { + "$ref": "#/x", + "$schema": "https://json-schema.org/draft/2020-12/schema", + } + ) + + +def test_keeps_format_checks() -> None: + with pytest.raises(SchemaError, match="is not a 'regex'"): + OAS30Validator.check_schema({"pattern": "("}) + + +@pytest.mark.parametrize("cls", OAS30_VALIDATORS) +@pytest.mark.parametrize("enforce", [False, True]) +def test_shortcut_checks_schema_object(cls: Any, enforce: bool) -> None: + with pytest.raises(SchemaError, match="'definitions' does not match"): + validate( + "abc", + {"type": "string", "definitions": {}}, + cls=cls, + enforce_properties_required=enforce, + ) + + +def test_shortcut_skips_schema_object_check_when_disabled() -> None: + validate( + "abc", + {"type": "string", "const": "abc"}, + cls=OAS30Validator, + check_schema=False, + ) + + +@pytest.mark.parametrize("cls", [OAS31Validator, OAS32Validator]) +def test_other_versions_keep_their_metaschema(cls: Any) -> None: + validate("abc", {"type": ["string", "null"], "$defs": {}}, cls=cls) + + +@pytest.mark.parametrize( + "schema", + [ + { + "oneOf": [{"$ref": "#/components/schemas/A"}], + "components": {"schemas": {"A": {"type": "string"}}}, + }, + { + "openapi": "3.0.3", + "info": {"title": "API", "version": "1"}, + "paths": {}, + }, + ], +) +def test_rejects_non_schema_object_root(schema: dict[str, Any]) -> None: + with pytest.raises(SchemaError, match="match any of the regexes"): + validate("abc", schema, cls=OAS30Validator) + + +def test_shortcut_resolves_references_from_registry() -> None: + registry = Registry().with_resource( + "urn:name", DRAFT4.create_resource({"type": "string"}) + ) + schema = {"oneOf": [{"$ref": "urn:name"}]} + + validate("abc", schema, cls=OAS30Validator, registry=registry) + with pytest.raises(ValidationError): + validate(1, schema, cls=OAS30Validator, registry=registry)