From 28c0bc6e4678acd5e0fa4bf511192f8fa1ae2a51 Mon Sep 17 00:00:00 2001 From: "T. Franzel" Date: Sat, 23 Sep 2023 16:42:48 +0200 Subject: [PATCH] OAS 3.1 --- drf_spectacular/hooks.py | 9 +- drf_spectacular/openapi.py | 1 + drf_spectacular/plumbing.py | 20 +- drf_spectacular/settings.py | 5 + drf_spectacular/validation/__init__.py | 17 +- ...i3_schema.json => openapi_3_0_schema.json} | 36 +- .../validation/openapi_3_1_schema.json | 1449 +++++++++++++++++ tests/test_basic.py | 9 + tests/test_basic_oas_3_1.yml | 270 +++ tests/test_fields.py | 10 + tests/test_fields_oas_3_1.yml | 407 +++++ tests/test_oas31.py | 70 + 12 files changed, 2285 insertions(+), 18 deletions(-) rename drf_spectacular/validation/{openapi3_schema.json => openapi_3_0_schema.json} (97%) create mode 100644 drf_spectacular/validation/openapi_3_1_schema.json create mode 100644 tests/test_basic_oas_3_1.yml create mode 100644 tests/test_fields_oas_3_1.yml create mode 100644 tests/test_oas31.py diff --git a/drf_spectacular/hooks.py b/drf_spectacular/hooks.py index e10e48ba..3b52e427 100644 --- a/drf_spectacular/hooks.py +++ b/drf_spectacular/hooks.py @@ -144,7 +144,14 @@ def extract_hash(schema): if '' in prop_enum_original_list: components.append(create_enum_component('BlankEnum', schema={'enum': ['']})) if None in prop_enum_original_list: - components.append(create_enum_component('NullEnum', schema={'enum': [None]})) + if spectacular_settings.OAS_VERSION.startswith('3.1'): + components.append(create_enum_component('NullEnum', schema={'type': 'null'})) + else: + components.append(create_enum_component('NullEnum', schema={'enum': [None]})) + + # undo OAS 3.1 type list NULL construction as we cover this in a separate component already + if spectacular_settings.OAS_VERSION.startswith('3.1') and isinstance(enum_schema['type'], list): + enum_schema['type'] = [t for t in enum_schema['type'] if t != 'null'][0] if len(components) == 1: prop_schema.update(components[0].ref) diff --git a/drf_spectacular/openapi.py b/drf_spectacular/openapi.py index 952e45d5..497fc606 100644 --- a/drf_spectacular/openapi.py +++ b/drf_spectacular/openapi.py @@ -957,6 +957,7 @@ def _get_serializer_field_meta(self, field, direction): if field.write_only: meta['writeOnly'] = True if field.allow_null: + # this will be converted later in case of OAS 3.1 meta['nullable'] = True if isinstance(field, serializers.CharField) and not field.allow_blank: # blank check only applies to inbound requests diff --git a/drf_spectacular/plumbing.py b/drf_spectacular/plumbing.py index ce40eaba..5fbbe677 100644 --- a/drf_spectacular/plumbing.py +++ b/drf_spectacular/plumbing.py @@ -471,7 +471,7 @@ def build_root_object(paths, components, version): else: version = settings.VERSION or version or '' root = { - 'openapi': '3.0.3', + 'openapi': settings.OAS_VERSION, 'info': { 'title': settings.TITLE, 'version': version, @@ -511,6 +511,24 @@ def safe_ref(schema): def append_meta(schema, meta): + if spectacular_settings.OAS_VERSION.startswith('3.1'): + schema_nullable = meta.pop('nullable', None) + meta_nullable = schema.pop('nullable', None) + + if schema_nullable or meta_nullable: + if 'type' in schema: + schema['type'] = [schema['type'], 'null'] + elif '$ref' in schema: + schema = {'oneOf': [schema, {'type': 'null'}]} + else: + assert False, 'Invalid nullable case' # pragma: no cover + + # these two aspects were merged in OpenAPI 3.1 + if "exclusiveMinimum" in schema and "minimum" in schema: + schema["exclusiveMinimum"] = schema.pop("minimum") + if "exclusiveMaximum" in schema and "maximum" in schema: + schema["exclusiveMaximum"] = schema.pop("maximum") + return safe_ref({**schema, **meta}) diff --git a/drf_spectacular/settings.py b/drf_spectacular/settings.py index 69c4d9d8..fc29b95f 100644 --- a/drf_spectacular/settings.py +++ b/drf_spectacular/settings.py @@ -44,6 +44,11 @@ # accurately modeled when request and response components are separated. 'ENFORCE_NON_BLANK_FIELDS': False, + # This version string will end up the in schema header. The default OpenAPI + # version is 3.0.3, which is heavily tested. We now also support 3.1.0, + # which contains the same features and a few mandatory, but minor changes. + 'OAS_VERSION': '3.0.3', + # Configuration for serving a schema subset with SpectacularAPIView 'SERVE_URLCONF': None, # complete public schema or a subset based on the requesting user diff --git a/drf_spectacular/validation/__init__.py b/drf_spectacular/validation/__init__.py index f891cc91..284e0f1a 100644 --- a/drf_spectacular/validation/__init__.py +++ b/drf_spectacular/validation/__init__.py @@ -3,8 +3,6 @@ import jsonschema -JSON_SCHEMA_SPEC_PATH = os.path.join(os.path.dirname(__file__), 'openapi3_schema.json') - def validate_schema(api_schema): """ @@ -12,10 +10,21 @@ def validate_schema(api_schema): Note: On conflict, the written specification always wins over the json schema. OpenApi3 schema specification taken from: + https://github.com/OAI/OpenAPI-Specification/blob/master/schemas/v3.0/schema.json - https://github.com/OAI/OpenAPI-Specification/blob/6d17b631fff35186c495b9e7d340222e19d60a71/schemas/v3.0/schema.json + https://github.com/OAI/OpenAPI-Specification/blob/9dff244e5708fbe16e768738f4f17cf3fddf4066/schemas/v3.0/schema.json + + https://github.com/OAI/OpenAPI-Specification/blob/main/schemas/v3.1/schema.json + https://github.com/OAI/OpenAPI-Specification/blob/9dff244e5708fbe16e768738f4f17cf3fddf4066/schemas/v3.1/schema.json """ - with open(JSON_SCHEMA_SPEC_PATH) as fh: + if api_schema['openapi'].startswith("3.0"): + schema_spec_path = os.path.join(os.path.dirname(__file__), 'openapi_3_0_schema.json') + elif api_schema['openapi'].startswith("3.1"): + schema_spec_path = os.path.join(os.path.dirname(__file__), 'openapi_3_1_schema.json') + else: + raise RuntimeError('No validation specification available') # pragma: no cover + + with open(schema_spec_path) as fh: openapi3_schema_spec = json.load(fh) # coerce any remnants of objects to basic types diff --git a/drf_spectacular/validation/openapi3_schema.json b/drf_spectacular/validation/openapi_3_0_schema.json similarity index 97% rename from drf_spectacular/validation/openapi3_schema.json rename to drf_spectacular/validation/openapi_3_0_schema.json index 71808402..6e8eab89 100644 --- a/drf_spectacular/validation/openapi3_schema.json +++ b/drf_spectacular/validation/openapi_3_0_schema.json @@ -1,7 +1,7 @@ { - "id": "https://spec.openapis.org/oas/3.0/schema/2019-04-02", + "id": "https://spec.openapis.org/oas/3.0/schema/2021-09-28", "$schema": "http://json-schema.org/draft-04/schema#", - "description": "Validation schema for OpenAPI Specification 3.0.X.", + "description": "The description of OpenAPI v3.0.x documents, as defined by https://spec.openapis.org/oas/v3.0.3", "type": "object", "required": [ "openapi", @@ -1358,9 +1358,8 @@ "description": "Bearer", "properties": { "scheme": { - "enum": [ - "bearer" - ] + "type": "string", + "pattern": "^[Bb][Ee][Aa][Rr][Ee][Rr]$" } } }, @@ -1374,9 +1373,8 @@ "properties": { "scheme": { "not": { - "enum": [ - "bearer" - ] + "type": "string", + "pattern": "^[Bb][Ee][Aa][Rr][Ee][Rr]$" } } } @@ -1489,7 +1487,8 @@ "PasswordOAuthFlow": { "type": "object", "required": [ - "tokenUrl" + "tokenUrl", + "scopes" ], "properties": { "tokenUrl": { @@ -1516,7 +1515,8 @@ "ClientCredentialsFlow": { "type": "object", "required": [ - "tokenUrl" + "tokenUrl", + "scopes" ], "properties": { "tokenUrl": { @@ -1544,7 +1544,8 @@ "type": "object", "required": [ "authorizationUrl", - "tokenUrl" + "tokenUrl", + "scopes" ], "properties": { "authorizationUrl": { @@ -1628,7 +1629,14 @@ "headers": { "type": "object", "additionalProperties": { - "$ref": "#/definitions/Header" + "oneOf": [ + { + "$ref": "#/definitions/Header" + }, + { + "$ref": "#/definitions/Reference" + } + ] } }, "style": { @@ -1648,6 +1656,10 @@ "default": false } }, + "patternProperties": { + "^x-": { + } + }, "additionalProperties": false } } diff --git a/drf_spectacular/validation/openapi_3_1_schema.json b/drf_spectacular/validation/openapi_3_1_schema.json new file mode 100644 index 00000000..5c5165f7 --- /dev/null +++ b/drf_spectacular/validation/openapi_3_1_schema.json @@ -0,0 +1,1449 @@ +{ + "$id": "https://spec.openapis.org/oas/3.1/schema/2022-10-07", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "The description of OpenAPI v3.1.x documents without schema validation, as defined by https://spec.openapis.org/oas/v3.1.0", + "type": "object", + "properties": { + "openapi": { + "type": "string", + "pattern": "^3\\.1\\.\\d+(-.+)?$" + }, + "info": { + "$ref": "#/$defs/info" + }, + "jsonSchemaDialect": { + "type": "string", + "format": "uri", + "default": "https://spec.openapis.org/oas/3.1/dialect/base" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/server" + }, + "default": [ + { + "url": "/" + } + ] + }, + "paths": { + "$ref": "#/$defs/paths" + }, + "webhooks": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/path-item-or-reference" + } + }, + "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.1.0#info-object", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "termsOfService": { + "type": "string", + "format": "uri" + }, + "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.1.0#contact-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "email": { + "type": "string", + "format": "email" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "license": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#license-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name" + ], + "dependentSchemas": { + "identifier": { + "not": { + "required": [ + "url" + ] + } + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "server": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#server-object", + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri-reference" + }, + "description": { + "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.1.0#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.1.0#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-or-reference" + } + } + }, + "patternProperties": { + "^(schemas|responses|parameters|examples|requestBodies|headers|securitySchemes|links|callbacks|pathItems)$": { + "$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.1.0#paths-object", + "type": "object", + "patternProperties": { + "^/": { + "$ref": "#/$defs/path-item" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "path-item": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#path-item-object", + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/server" + } + }, + "parameters": { + "type": "array", + "items": { + "$ref": "#/$defs/parameter-or-reference" + } + }, + "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" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "path-item-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/path-item" + } + }, + "operation": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#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": { + "type": "array", + "items": { + "$ref": "#/$defs/parameter-or-reference" + } + }, + "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.1.0#external-documentation-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "parameter": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#parameter-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "in": { + "enum": [ + "query", + "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" + ] + } + ], + "if": { + "properties": { + "in": { + "const": "query" + } + }, + "required": [ + "in" + ] + }, + "then": { + "properties": { + "allowEmptyValue": { + "default": false, + "type": "boolean" + } + } + }, + "dependentSchemas": { + "schema": { + "properties": { + "style": { + "type": "string" + }, + "explode": { + "type": "boolean" + } + }, + "allOf": [ + { + "$ref": "#/$defs/examples" + }, + { + "$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" + }, + { + "$ref": "#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-form" + } + ], + "$defs": { + "styles-for-path": { + "if": { + "properties": { + "in": { + "const": "path" + } + }, + "required": [ + "in" + ] + }, + "then": { + "properties": { + "name": { + "pattern": "[^/#?]+$" + }, + "style": { + "default": "simple", + "enum": [ + "matrix", + "label", + "simple" + ] + }, + "required": { + "const": true + } + }, + "required": [ + "required" + ] + } + }, + "styles-for-header": { + "if": { + "properties": { + "in": { + "const": "header" + } + }, + "required": [ + "in" + ] + }, + "then": { + "properties": { + "style": { + "default": "simple", + "const": "simple" + } + } + } + }, + "styles-for-query": { + "if": { + "properties": { + "in": { + "const": "query" + } + }, + "required": [ + "in" + ] + }, + "then": { + "properties": { + "style": { + "default": "form", + "enum": [ + "form", + "spaceDelimited", + "pipeDelimited", + "deepObject" + ] + }, + "allowReserved": { + "default": false, + "type": "boolean" + } + } + } + }, + "styles-for-cookie": { + "if": { + "properties": { + "in": { + "const": "cookie" + } + }, + "required": [ + "in" + ] + }, + "then": { + "properties": { + "style": { + "default": "form", + "const": "form" + } + } + } + }, + "styles-for-form": { + "if": { + "properties": { + "style": { + "const": "form" + } + }, + "required": [ + "style" + ] + }, + "then": { + "properties": { + "explode": { + "default": true + } + } + }, + "else": { + "properties": { + "explode": { + "default": false + } + } + } + } + } + } + }, + "$ref": "#/$defs/specification-extensions", + "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.1.0#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.1.0#fixed-fields-10", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/media-type" + }, + "propertyNames": { + "format": "media-range" + } + }, + "media-type": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#media-type-object", + "type": "object", + "properties": { + "schema": { + "$dynamicRef": "#meta" + }, + "encoding": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/encoding" + } + } + }, + "allOf": [ + { + "$ref": "#/$defs/specification-extensions" + }, + { + "$ref": "#/$defs/examples" + } + ], + "unevaluatedProperties": false + }, + "encoding": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#encoding-object", + "type": "object", + "properties": { + "contentType": { + "type": "string", + "format": "media-range" + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/header-or-reference" + } + }, + "style": { + "default": "form", + "enum": [ + "form", + "spaceDelimited", + "pipeDelimited", + "deepObject" + ] + }, + "explode": { + "type": "boolean" + }, + "allowReserved": { + "default": false, + "type": "boolean" + } + }, + "allOf": [ + { + "$ref": "#/$defs/specification-extensions" + }, + { + "$ref": "#/$defs/encoding/$defs/explode-default" + } + ], + "unevaluatedProperties": false, + "$defs": { + "explode-default": { + "if": { + "properties": { + "style": { + "const": "form" + } + }, + "required": [ + "style" + ] + }, + "then": { + "properties": { + "explode": { + "default": true + } + } + }, + "else": { + "properties": { + "explode": { + "default": false + } + } + } + } + } + }, + "responses": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#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.1.0#response-object", + "type": "object", + "properties": { + "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" + } + } + }, + "required": [ + "description" + ], + "$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.1.0#callback-object", + "type": "object", + "$ref": "#/$defs/specification-extensions", + "additionalProperties": { + "$ref": "#/$defs/path-item-or-reference" + } + }, + "callbacks-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/callbacks" + } + }, + "example": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#example-object", + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": true, + "externalValue": { + "type": "string", + "format": "uri" + } + }, + "not": { + "required": [ + "value", + "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.1.0#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" + }, + "body": { + "$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.1.0#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" + } + }, + "$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.1.0#tag-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/$defs/external-documentation" + } + }, + "required": [ + "name" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "reference": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#reference-object", + "type": "object", + "properties": { + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "unevaluatedProperties": false + }, + "schema": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#schema-object", + "$dynamicAnchor": "meta", + "type": [ + "object", + "boolean" + ] + }, + "security-scheme": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#security-scheme-object", + "type": "object", + "properties": { + "type": { + "enum": [ + "apiKey", + "http", + "mutualTLS", + "oauth2", + "openIdConnect" + ] + }, + "description": { + "type": "string" + } + }, + "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" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "name": { + "type": "string" + }, + "in": { + "enum": [ + "query", + "header", + "cookie" + ] + } + }, + "required": [ + "name", + "in" + ] + } + }, + "type-http": { + "if": { + "properties": { + "type": { + "const": "http" + } + }, + "required": [ + "type" + ] + }, + "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" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "flows": { + "$ref": "#/$defs/oauth-flows" + } + }, + "required": [ + "flows" + ] + } + }, + "type-oidc": { + "if": { + "properties": { + "type": { + "const": "openIdConnect" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "openIdConnectUrl": { + "type": "string", + "format": "uri" + } + }, + "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" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false, + "$defs": { + "implicit": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri" + }, + "refreshUrl": { + "type": "string", + "format": "uri" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "authorizationUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "password": { + "type": "object", + "properties": { + "tokenUrl": { + "type": "string", + "format": "uri" + }, + "refreshUrl": { + "type": "string", + "format": "uri" + }, + "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" + }, + "refreshUrl": { + "type": "string", + "format": "uri" + }, + "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" + }, + "tokenUrl": { + "type": "string", + "format": "uri" + }, + "refreshUrl": { + "type": "string", + "format": "uri" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "authorizationUrl", + "tokenUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + } + } + }, + "security-requirement": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#security-requirement-object", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "specification-extensions": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#specification-extensions", + "patternProperties": { + "^x-": true + } + }, + "examples": { + "properties": { + "example": true, + "examples": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/example-or-reference" + } + } + } + }, + "map-of-strings": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } +} \ No newline at end of file diff --git a/tests/test_basic.py b/tests/test_basic.py index 6270e979..ce07fc33 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,5 +1,6 @@ import uuid from typing import Optional +from unittest import mock from django.db import models from rest_framework import serializers, viewsets @@ -79,3 +80,11 @@ def test_basic(no_warnings): generate_schema('albums', AlbumModelViewset), 'tests/test_basic.yml' ) + + +@mock.patch('drf_spectacular.settings.spectacular_settings.OAS_VERSION', '3.1.0') +def test_basic_oas_3_1(no_warnings): + assert_schema( + generate_schema('albums', AlbumModelViewset), + 'tests/test_basic_oas_3_1.yml' + ) diff --git a/tests/test_basic_oas_3_1.yml b/tests/test_basic_oas_3_1.yml new file mode 100644 index 00000000..c1c8c2df --- /dev/null +++ b/tests/test_basic_oas_3_1.yml @@ -0,0 +1,270 @@ +openapi: 3.1.0 +info: + title: '' + version: 0.0.0 +paths: + /albums/: + get: + operationId: albums_list + tags: + - albums + security: + - tokenAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Album' + description: '' + post: + operationId: albums_create + description: |- + Special documentation about creating albums + + There is even more info here + tags: + - albums + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Album' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Album' + multipart/form-data: + schema: + $ref: '#/components/schemas/Album' + required: true + security: + - tokenAuth: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/Album' + description: '' + /albums/{id}/: + get: + operationId: albums_retrieve + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this album. + required: true + tags: + - albums + security: + - tokenAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Album' + description: '' + put: + operationId: albums_update + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this album. + required: true + tags: + - albums + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Album' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Album' + multipart/form-data: + schema: + $ref: '#/components/schemas/Album' + required: true + security: + - tokenAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Album' + description: '' + patch: + operationId: albums_partial_update + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this album. + required: true + tags: + - albums + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedAlbum' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedAlbum' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedAlbum' + security: + - tokenAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Album' + description: '' + delete: + operationId: albums_destroy + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this album. + required: true + tags: + - albums + security: + - tokenAuth: [] + responses: + '204': + description: No response body + /albums/{id}/like/: + post: + operationId: albums_like_create + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this album. + required: true + tags: + - albums + security: + - tokenAuth: [] + responses: + '200': + description: No response body +components: + schemas: + Album: + type: object + properties: + id: + type: string + format: uuid + readOnly: true + songs: + type: array + items: + $ref: '#/components/schemas/Song' + readOnly: true + single: + allOf: + - $ref: '#/components/schemas/Song' + readOnly: true + title: + type: string + maxLength: 100 + genre: + $ref: '#/components/schemas/GenreEnum' + year: + type: integer + released: + type: boolean + required: + - genre + - id + - released + - single + - songs + - title + - year + GenreEnum: + enum: + - POP + - ROCK + type: string + description: |- + * `POP` - Pop + * `ROCK` - Rock + PatchedAlbum: + type: object + properties: + id: + type: string + format: uuid + readOnly: true + songs: + type: array + items: + $ref: '#/components/schemas/Song' + readOnly: true + single: + allOf: + - $ref: '#/components/schemas/Song' + readOnly: true + title: + type: string + maxLength: 100 + genre: + $ref: '#/components/schemas/GenreEnum' + year: + type: integer + released: + type: boolean + Song: + type: object + properties: + id: + type: string + format: uuid + readOnly: true + title: + type: string + maxLength: 100 + length: + type: integer + top10: + type: + - boolean + - 'null' + readOnly: true + required: + - id + - length + - title + - top10 + securitySchemes: + tokenAuth: + type: apiKey + in: header + name: Authorization + description: Token-based authentication with required prefix "Token" diff --git a/tests/test_fields.py b/tests/test_fields.py index 969af9aa..dd6bac8f 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -6,6 +6,7 @@ from datetime import timedelta from decimal import Decimal from typing import Optional +from unittest import mock import pytest from django import __version__ as DJANGO_VERSION @@ -302,6 +303,15 @@ def test_fields(no_warnings): ) +@pytest.mark.urls(__name__) +@mock.patch('drf_spectacular.settings.spectacular_settings.OAS_VERSION', '3.1.0') +def test_fields_oas_3_1(no_warnings): + assert_schema( + SchemaGenerator().get_schema(request=None, public=True), + 'tests/test_fields_oas_3_1.yml', + ) + + @pytest.mark.urls(__name__) @pytest.mark.django_db def test_model_setup_is_valid(): diff --git a/tests/test_fields_oas_3_1.yml b/tests/test_fields_oas_3_1.yml new file mode 100644 index 00000000..ec52e28b --- /dev/null +++ b/tests/test_fields_oas_3_1.yml @@ -0,0 +1,407 @@ +openapi: 3.1.0 +info: + title: '' + version: 0.0.0 +paths: + /allfields/: + get: + operationId: allfields_list + tags: + - allfields + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AllFields' + description: '' + /allfields/{id}/: + get: + operationId: allfields_retrieve + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this all fields. + required: true + tags: + - allfields + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/AllFields' + description: '' + /aux/: + get: + operationId: aux_list + tags: + - aux + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Aux' + description: '' + /aux/{id}/: + get: + operationId: aux_retrieve + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this aux. + required: true + tags: + - aux + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Aux' + description: '' +components: + schemas: + AllFields: + type: object + properties: + id: + type: integer + readOnly: true + field_decimal_uncoerced: + type: number + format: double + exclusiveMaximum: 1000 + exclusiveMinimum: -1000 + field_method_float: + type: number + format: double + readOnly: true + field_method_object: + type: object + additionalProperties: {} + readOnly: true + field_regex: + type: string + title: A regex field + pattern: ^[a-zA-z0-9]{10}\-[a-z] + field_list: + type: array + items: + type: number + format: double + maxItems: 100 + minItems: 3 + field_list_serializer: + type: array + items: + $ref: '#/components/schemas/Aux' + field_related_slug: + type: string + format: uri + description: URL identifier for Aux + readOnly: true + field_related_slug_queryset: + type: string + format: uri + description: URL identifier for Aux + field_related_slug_many: + type: array + items: + type: string + format: uri + description: URL identifier for Aux + readOnly: true + field_related_string: + type: string + readOnly: true + field_related_hyperlink: + type: string + format: uri + readOnly: true + field_identity_hyperlink: + type: string + format: uri + readOnly: true + field_read_only_nav_uuid: + type: string + format: uuid + readOnly: true + field_read_only_nav_uuid_3steps: + type: + - string + - 'null' + format: uuid + readOnly: true + field_read_only_model_function_basic: + type: boolean + readOnly: true + field_read_only_model_function_model: + type: string + format: uuid + readOnly: true + field_read_only_model_property_model: + type: string + format: uuid + readOnly: true + field_bool_override: + type: boolean + readOnly: true + field_model_property_float: + type: number + format: double + readOnly: true + field_model_cached_property_float: + type: number + format: double + readOnly: true + field_model_py_cached_property_float: + type: number + format: double + readOnly: true + field_dict_int: + type: object + additionalProperties: + type: integer + field_json: {} + field_sub_object_calculated: + type: integer + description: My calculated property + readOnly: true + field_sub_object_nested_calculated: + type: integer + description: My calculated property + readOnly: true + field_sub_object_model_int: + type: integer + readOnly: true + field_sub_object_cached_calculated: + type: integer + description: My calculated property + readOnly: true + field_sub_object_cached_nested_calculated: + type: integer + description: My calculated property + readOnly: true + field_sub_object_cached_model_int: + type: integer + readOnly: true + field_sub_object_py_cached_calculated: + type: integer + description: My calculated property + readOnly: true + field_sub_object_py_cached_nested_calculated: + type: integer + description: My calculated property + readOnly: true + field_sub_object_py_cached_model_int: + type: integer + readOnly: true + field_optional_sub_object_calculated: + type: + - integer + - 'null' + description: My calculated property + readOnly: true + field_sub_object_optional_int: + type: + - integer + - 'null' + readOnly: true + field_int: + type: integer + field_float: + type: number + format: double + field_bool: + type: boolean + field_char: + type: string + maxLength: 100 + field_text: + type: string + title: A text field + field_slug: + type: string + maxLength: 50 + pattern: ^[-a-zA-Z0-9_]+$ + field_email: + type: string + format: email + maxLength: 254 + field_uuid: + type: string + format: uuid + field_url: + type: string + format: uri + maxLength: 200 + field_ip_generic: + type: string + field_decimal: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,3})?$ + field_file: + type: string + format: uri + field_img: + type: string + format: uri + field_date: + type: string + format: date + field_datetime: + type: string + format: date-time + field_bigint: + type: integer + field_smallint: + type: integer + field_posint: + type: integer + field_possmallint: + type: integer + field_nullbool: + type: + - boolean + - 'null' + field_time: + type: string + format: time + field_duration: + type: string + field_binary: + type: string + format: byte + readOnly: true + field_foreign: + type: string + format: uuid + description: main aux object + field_o2o: + type: string + format: uuid + description: bound aux object + field_m2m: + type: array + items: + type: string + format: uuid + description: set of related aux objects + required: + - field_bigint + - field_binary + - field_bool + - field_bool_override + - field_char + - field_date + - field_datetime + - field_decimal + - field_decimal_uncoerced + - field_dict_int + - field_duration + - field_email + - field_file + - field_float + - field_foreign + - field_identity_hyperlink + - field_img + - field_int + - field_ip_generic + - field_json + - field_list + - field_list_serializer + - field_m2m + - field_method_float + - field_method_object + - field_model_cached_property_float + - field_model_property_float + - field_model_py_cached_property_float + - field_o2o + - field_optional_sub_object_calculated + - field_posint + - field_possmallint + - field_read_only_model_function_basic + - field_read_only_model_function_model + - field_read_only_model_property_model + - field_read_only_nav_uuid + - field_read_only_nav_uuid_3steps + - field_regex + - field_related_hyperlink + - field_related_slug + - field_related_slug_many + - field_related_slug_queryset + - field_related_string + - field_slug + - field_smallint + - field_sub_object_cached_calculated + - field_sub_object_cached_model_int + - field_sub_object_cached_nested_calculated + - field_sub_object_calculated + - field_sub_object_model_int + - field_sub_object_nested_calculated + - field_sub_object_optional_int + - field_sub_object_py_cached_calculated + - field_sub_object_py_cached_model_int + - field_sub_object_py_cached_nested_calculated + - field_text + - field_time + - field_url + - field_uuid + - id + Aux: + type: object + description: description for aux object + properties: + id: + type: string + format: uuid + readOnly: true + url: + type: string + format: uri + description: URL identifier for Aux + maxLength: 200 + field_foreign: + type: + - string + - 'null' + format: uuid + required: + - id + - url + securitySchemes: + basicAuth: + type: http + scheme: basic + cookieAuth: + type: apiKey + in: cookie + name: sessionid diff --git a/tests/test_oas31.py b/tests/test_oas31.py new file mode 100644 index 00000000..9cdaa4cd --- /dev/null +++ b/tests/test_oas31.py @@ -0,0 +1,70 @@ +from unittest import mock + +from rest_framework import serializers +from rest_framework.views import APIView + +from drf_spectacular.utils import extend_schema +from tests import generate_schema + + +@mock.patch('drf_spectacular.settings.spectacular_settings.OAS_VERSION', '3.1.0') +def test_nullable_sub_serializer(no_warnings): + class XSerializer(serializers.Serializer): + f = serializers.FloatField(allow_null=True) + + class YSerializer(serializers.Serializer): + x = XSerializer(allow_null=True) + + class XAPIView(APIView): + @extend_schema(responses=YSerializer) + def get(self, request): + pass # pragma: no cover + + schema = generate_schema('x', view=XAPIView) + + assert schema['components']['schemas'] == { + 'X': { + 'properties': {'f': {'format': 'double', 'type': ['number', 'null']}}, + 'required': ['f'], + 'type': 'object' + }, + 'Y': { + 'properties': {'x': {'oneOf': [{'$ref': '#/components/schemas/X'}, {'type': 'null'}]}}, + 'required': ['x'], + 'type': 'object' + } + } + + +@mock.patch('drf_spectacular.settings.spectacular_settings.OAS_VERSION', '3.1.0') +def test_nullable_enum_resolution(no_warnings): + class XSerializer(serializers.Serializer): + foo = serializers.ChoiceField( + choices=[('A', 'A'), ('B', 'B')], + allow_null=True + ) + + class XAPIView(APIView): + @extend_schema(responses=XSerializer) + def get(self, request): + pass # pragma: no cover + + schema = generate_schema('x', view=XAPIView) + + assert schema['components']['schemas']['FooEnum'] == { + 'description': '* `A` - A\n* `B` - B', + 'enum': ['A', 'B'], + 'type': 'string', + } + assert schema['components']['schemas']['X'] == { + 'properties': { + 'foo': { + 'oneOf': [ + {'$ref': '#/components/schemas/FooEnum'}, + {'$ref': '#/components/schemas/NullEnum'} + ] + } + }, + 'required': ['foo'], + 'type': 'object' + }