Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix OpenAPI Schema yaml rendering for timedelta #9007

Merged
merged 13 commits into from
Jun 17, 2023
Merged
6 changes: 5 additions & 1 deletion rest_framework/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
__version__ = '3.14.0'
__author__ = 'Tom Christie'
__license__ = 'BSD 3-Clause'
__copyright__ = 'Copyright 2011-2019 Encode OSS Ltd'
__copyright__ = 'Copyright 2011-2023 Encode OSS Ltd'

# Version synonym
VERSION = __version__
Expand All @@ -31,3 +31,7 @@

class RemovedInDRF315Warning(DeprecationWarning):
pass


class RemovedInDRF317Warning(PendingDeprecationWarning):
pass
8 changes: 8 additions & 0 deletions rest_framework/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
returned by list views.
"""
import operator
import warnings
from functools import reduce

from django.core.exceptions import ImproperlyConfigured
Expand All @@ -12,6 +13,7 @@
from django.utils.encoding import force_str
from django.utils.translation import gettext_lazy as _

from rest_framework import RemovedInDRF317Warning
from rest_framework.compat import coreapi, coreschema, distinct
from rest_framework.settings import api_settings

Expand All @@ -29,6 +31,8 @@ def filter_queryset(self, request, queryset, view):

def get_schema_fields(self, view):
assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
if coreapi is not None:
warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning)
assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`'
return []

Expand Down Expand Up @@ -146,6 +150,8 @@ def to_html(self, request, queryset, view):

def get_schema_fields(self, view):
assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
if coreapi is not None:
warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning)
assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`'
return [
coreapi.Field(
Expand Down Expand Up @@ -306,6 +312,8 @@ def to_html(self, request, queryset, view):

def get_schema_fields(self, view):
assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
if coreapi is not None:
warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning)
assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`'
return [
coreapi.Field(
Expand Down
11 changes: 11 additions & 0 deletions rest_framework/pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
"""

import contextlib
import warnings

from base64 import b64decode, b64encode
from collections import namedtuple
from urllib import parse
Expand All @@ -15,6 +17,7 @@
from django.utils.encoding import force_str
from django.utils.translation import gettext_lazy as _

from rest_framework import RemovedInDRF317Warning
from rest_framework.compat import coreapi, coreschema
from rest_framework.exceptions import NotFound
from rest_framework.response import Response
Expand Down Expand Up @@ -152,6 +155,8 @@ def get_results(self, data):

def get_schema_fields(self, view):
assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
if coreapi is not None:
warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning)
return []

def get_schema_operation_parameters(self, view):
Expand Down Expand Up @@ -311,6 +316,8 @@ def to_html(self):

def get_schema_fields(self, view):
assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
if coreapi is not None:
warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning)
assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`'
fields = [
coreapi.Field(
Expand Down Expand Up @@ -525,6 +532,8 @@ def get_count(self, queryset):

def get_schema_fields(self, view):
assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
if coreapi is not None:
warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning)
assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`'
return [
coreapi.Field(
Expand Down Expand Up @@ -930,6 +939,8 @@ def to_html(self):

def get_schema_fields(self, view):
assert coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`'
if coreapi is not None:
warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning)
assert coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`'
fields = [
coreapi.Field(
Expand Down
2 changes: 2 additions & 0 deletions rest_framework/renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import base64
import contextlib
import datetime
from urllib import parse

from django import forms
Expand Down Expand Up @@ -1062,6 +1063,7 @@ class Dumper(yaml.Dumper):
def ignore_aliases(self, data):
return True
Dumper.add_representer(SafeString, Dumper.represent_str)
Dumper.add_representer(datetime.timedelta, encoders.CustomScalar.represent_timedelta)
return yaml.dump(data, default_flow_style=False, sort_keys=False, Dumper=Dumper).encode('utf-8')


Expand Down
12 changes: 11 additions & 1 deletion rest_framework/schemas/coreapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from django.db import models
from django.utils.encoding import force_str

from rest_framework import exceptions, serializers
from rest_framework import RemovedInDRF317Warning, exceptions, serializers
from rest_framework.compat import coreapi, coreschema, uritemplate
from rest_framework.settings import api_settings

Expand Down Expand Up @@ -118,6 +118,8 @@ class SchemaGenerator(BaseSchemaGenerator):

def __init__(self, title=None, url=None, description=None, patterns=None, urlconf=None, version=None):
assert coreapi, '`coreapi` must be installed for schema support.'
if coreapi is not None:
warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning)
assert coreschema, '`coreschema` must be installed for schema support.'

super().__init__(title, url, description, patterns, urlconf)
Expand Down Expand Up @@ -351,6 +353,9 @@ def __init__(self, manual_fields=None):
will be added to auto-generated fields, overwriting on `Field.name`
"""
super().__init__()
if coreapi is not None:
warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning)

if manual_fields is None:
manual_fields = []
self._manual_fields = manual_fields
Expand Down Expand Up @@ -592,6 +597,9 @@ def __init__(self, fields, description='', encoding=None):
* `description`: String description for view. Optional.
"""
super().__init__()
if coreapi is not None:
warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning)

assert all(isinstance(f, coreapi.Field) for f in fields), "`fields` must be a list of coreapi.Field instances"
self._fields = fields
self._description = description
Expand All @@ -613,4 +621,6 @@ def get_link(self, path, method, base_url):

def is_enabled():
"""Is CoreAPI Mode enabled?"""
if coreapi is not None:
warnings.warn('CoreAPI compatibility is deprecated and will be removed in DRF 3.17', RemovedInDRF317Warning)
return issubclass(api_settings.DEFAULT_SCHEMA_CLASS, AutoSchema)
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
{% endif %}

<div class="col-sm-10">
<select multiple {{ field.choices|yesno:",disabled" }} class="form-control" name="{{ field.name }}">
<select multiple class="form-control" name="{{ field.name }}">
{% for select in field.iter_options %}
{% if select.start_option_group %}
<optgroup label="{{ select.label }}">
Expand Down
11 changes: 11 additions & 0 deletions rest_framework/utils/encoders.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,14 @@ def default(self, obj):
elif hasattr(obj, '__iter__'):
return tuple(item for item in obj)
return super().default(obj)


class CustomScalar:
"""
CustomScalar that knows how to encode timedelta that renderer
can understand.
"""
@classmethod
def represent_timedelta(cls, dumper, data):
value = str(data.total_seconds())
return dumper.represent_scalar('tag:yaml.org,2002:str', value)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are you using represent_scalar from existing code?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is actually the function of yaml Dumper class for overriding the tag for different datatypes or python object.
so this function gives custom representation for timedelta and first argument is dumper obj where we are adding this function

19 changes: 10 additions & 9 deletions rest_framework/versioning.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,15 +119,16 @@ class NamespaceVersioning(BaseVersioning):

def determine_version(self, request, *args, **kwargs):
resolver_match = getattr(request, 'resolver_match', None)
if resolver_match is None or not resolver_match.namespace:
return self.default_version

# Allow for possibly nested namespaces.
possible_versions = resolver_match.namespace.split(':')
for version in possible_versions:
if self.is_allowed_version(version):
return version
raise exceptions.NotFound(self.invalid_version_message)
if resolver_match is not None and resolver_match.namespace:
# Allow for possibly nested namespaces.
possible_versions = resolver_match.namespace.split(':')
for version in possible_versions:
if self.is_allowed_version(version):
return version

if not self.is_allowed_version(self.default_version):
raise exceptions.NotFound(self.invalid_version_message)
return self.default_version

def reverse(self, viewname, args=None, kwargs=None, request=None, format=None, **extra):
if request.version is not None:
Expand Down
2 changes: 2 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ license_files = LICENSE.md

[tool:pytest]
addopts=--tb=short --strict-markers -ra
testspath = tests
filterwarnings = ignore:CoreAPI compatibility is deprecated*:rest_framework.RemovedInDRF317Warning

[flake8]
ignore = E501,W503,W504
Expand Down
55 changes: 53 additions & 2 deletions tests/schemas/test_coreapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,24 @@
from django.urls import include, path

from rest_framework import (
filters, generics, pagination, permissions, serializers
RemovedInDRF317Warning, filters, generics, pagination, permissions,
serializers
)
from rest_framework.compat import coreapi, coreschema
from rest_framework.decorators import action, api_view, schema
from rest_framework.filters import (
BaseFilterBackend, OrderingFilter, SearchFilter
)
from rest_framework.pagination import (
BasePagination, CursorPagination, LimitOffsetPagination,
PageNumberPagination
)
from rest_framework.request import Request
from rest_framework.routers import DefaultRouter, SimpleRouter
from rest_framework.schemas import (
AutoSchema, ManualSchema, SchemaGenerator, get_schema_view
)
from rest_framework.schemas.coreapi import field_to_schema
from rest_framework.schemas.coreapi import field_to_schema, is_enabled
from rest_framework.schemas.generators import EndpointEnumerator
from rest_framework.schemas.utils import is_list_view
from rest_framework.test import APIClient, APIRequestFactory
Expand Down Expand Up @@ -1433,3 +1441,46 @@ def test_schema_handles_exception():
response.render()
assert response.status_code == 403
assert b"You do not have permission to perform this action." in response.content


@pytest.mark.skipif(not coreapi, reason='coreapi is not installed')
def test_coreapi_deprecation():
with pytest.warns(RemovedInDRF317Warning):
SchemaGenerator()

with pytest.warns(RemovedInDRF317Warning):
AutoSchema()

with pytest.warns(RemovedInDRF317Warning):
ManualSchema({})

with pytest.warns(RemovedInDRF317Warning):
deprecated_filter = OrderingFilter()
deprecated_filter.get_schema_fields({})

with pytest.warns(RemovedInDRF317Warning):
deprecated_filter = BaseFilterBackend()
deprecated_filter.get_schema_fields({})

with pytest.warns(RemovedInDRF317Warning):
deprecated_filter = SearchFilter()
deprecated_filter.get_schema_fields({})

with pytest.warns(RemovedInDRF317Warning):
paginator = BasePagination()
paginator.get_schema_fields({})

with pytest.warns(RemovedInDRF317Warning):
paginator = PageNumberPagination()
paginator.get_schema_fields({})

with pytest.warns(RemovedInDRF317Warning):
paginator = LimitOffsetPagination()
paginator.get_schema_fields({})

with pytest.warns(RemovedInDRF317Warning):
paginator = CursorPagination()
paginator.get_schema_fields({})

with pytest.warns(RemovedInDRF317Warning):
is_enabled()
25 changes: 25 additions & 0 deletions tests/schemas/test_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -1162,6 +1162,31 @@ def test_schema_rendering_to_json(self):
assert b'"openapi": "' in ret
assert b'"default": "0.0"' in ret

def test_schema_rendering_to_yaml(self):
patterns = [
path('example/', views.ExampleGenericAPIView.as_view()),
]
generator = SchemaGenerator(patterns=patterns)

request = create_request('/')
schema = generator.get_schema(request=request)
ret = OpenAPIRenderer().render(schema)
assert b"openapi: " in ret
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you explain the asserts please? also it would be nice to see some additional tests if possible

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure. this test renders openapi yaml definition from an example schema which has a duration field with a default value timedelta(0) and asserts is checking for "openapi" and the duration fields default value "0.0".

I think we can add checking the" !!python/object/apply:datetime.timedelta" tag is not in response.
please let me know if you have any suggestions for cases it would be a great.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you can extra tests it would be great

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have added test case for rendering yaml with durationfield having minvalidator. Please check.

assert b"default: '0.0'" in ret

def test_schema_rendering_timedelta_to_yaml_with_validator(self):

patterns = [
path('example/', views.ExampleValidatedAPIView.as_view()),
]
generator = SchemaGenerator(patterns=patterns)

request = create_request('/')
schema = generator.get_schema(request=request)
ret = OpenAPIRenderer().render(schema)
assert b"openapi: " in ret
assert b"duration:\n type: string\n minimum: \'10.0\'\n" in ret

def test_schema_with_no_paths(self):
patterns = []
generator = SchemaGenerator(patterns=patterns)
Expand Down
5 changes: 5 additions & 0 deletions tests/schemas/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ class ExampleValidatedSerializer(serializers.Serializer):
ip4 = serializers.IPAddressField(protocol='ipv4')
ip6 = serializers.IPAddressField(protocol='ipv6')
ip = serializers.IPAddressField()
duration = serializers.DurationField(
validators=(
MinValueValidator(timedelta(seconds=10)),
)
)


class ExampleValidatedAPIView(generics.GenericAPIView):
Expand Down
Loading