tfranzel / drf-spectacular

Sane and flexible OpenAPI 3 schema generation for Django REST framework.
https://drf-spectacular.readthedocs.io
BSD 3-Clause "New" or "Revised" License
2.32k stars 259 forks source link

Support callable min/max integer values and string lengths #913

Open Lucidiot opened 1 year ago

Lucidiot commented 1 year ago

Describe the bug

I tried to use a function as the maximum value for an IntegerField in a Django model, since the validator docs mention that a callable is supported. I found that Spectacular crashes when generating a schema from that:

λ python3 manage.py spectacular
Traceback (most recent call last):
  File "/tmp/example/manage.py", line 22, in <module>
    main()
  File "/tmp/example/manage.py", line 18, in main
    execute_from_command_line(sys.argv)
  File "/home/lucidiot/.virtualenvs/example/lib/python3.10/site-packages/django/core/management/__init__.py", line 446, in execute_from_command_line
    utility.execute()
  File "/home/lucidiot/.virtualenvs/example/lib/python3.10/site-packages/django/core/management/__init__.py", line 440, in execute
    self.fetch_command(subcommand).run_from_argv(self.argv)
  File "/home/lucidiot/.virtualenvs/example/lib/python3.10/site-packages/django/core/management/base.py", line 402, in run_from_argv
    self.execute(*args, **cmd_options)
  File "/home/lucidiot/.virtualenvs/example/lib/python3.10/site-packages/django/core/management/base.py", line 448, in execute
    output = self.handle(*args, **options)
  File "/home/lucidiot/.virtualenvs/example/lib/python3.10/site-packages/drf_spectacular/management/commands/spectacular.py", line 72, in handle
    schema = generator.get_schema(request=None, public=True)
  File "/home/lucidiot/.virtualenvs/example/lib/python3.10/site-packages/drf_spectacular/generators.py", line 268, in get_schema
    paths=self.parse(request, public),
  File "/home/lucidiot/.virtualenvs/example/lib/python3.10/site-packages/drf_spectacular/generators.py", line 239, in parse
    operation = view.schema.get_operation(
  File "/home/lucidiot/.virtualenvs/example/lib/python3.10/site-packages/drf_spectacular/openapi.py", line 82, in get_operation
    request_body = self._get_request_body()
  File "/home/lucidiot/.virtualenvs/example/lib/python3.10/site-packages/drf_spectacular/openapi.py", line 1216, in _get_request_body
    schema, request_body_required = self._get_request_for_media_type(request_serializer, direction)
  File "/home/lucidiot/.virtualenvs/example/lib/python3.10/site-packages/drf_spectacular/openapi.py", line 1247, in _get_request_for_media_type
    component = self.resolve_serializer(serializer, direction)
  File "/home/lucidiot/.virtualenvs/example/lib/python3.10/site-packages/drf_spectacular/openapi.py", line 1514, in resolve_serializer
    component.schema = self._map_serializer(serializer, direction, bypass_extensions)
  File "/home/lucidiot/.virtualenvs/example/lib/python3.10/site-packages/drf_spectacular/openapi.py", line 896, in _map_serializer
    schema = self._map_basic_serializer(serializer, direction)
  File "/home/lucidiot/.virtualenvs/example/lib/python3.10/site-packages/drf_spectacular/openapi.py", line 983, in _map_basic_serializer
    schema = self._map_serializer_field(field, direction)
  File "/home/lucidiot/.virtualenvs/example/lib/python3.10/site-packages/drf_spectacular/openapi.py", line 799, in _map_serializer_field
    if not all(-2147483648 <= int(content.get(key, 0)) <= 2147483647 for key in ('maximum', 'minimum')):
  File "/home/lucidiot/.virtualenvs/example/lib/python3.10/site-packages/drf_spectacular/openapi.py", line 799, in <genexpr>
    if not all(-2147483648 <= int(content.get(key, 0)) <= 2147483647 for key in ('maximum', 'minimum')):
TypeError: int() argument must be a string, a bytes-like object or a real number, not 'function'

Note that I also found some small related issues in DRF itself which I reported here, but they are independent from this issue.

To Reproduce

Pick your poison! The Django model way:

from django.db import models
from django.core.validators import MaxValueValidator, MinValueValidator
from rest_framework import serializers

def small_funny_number():
    return 42

def large_funny_number():
    return 1337

class Thingy(models.Model):
    number = models.IntegerField(validators=[
        MinValueValidator(small_funny_number),
        MaxValueValidator(large_funny_number),
    ])

class ThingySerializer(serializers.ModelSerializer):
    class Meta:
        model = Thingy
        fields = ['number']

Or the min/max_value way:

class ThingySerializer(serializers.ModelSerializer):
    number = serializers.IntegerField(min_value=small_funny_number, max_value=large_funny_number)

    class Meta:
        model = Thingy
        fields = ['number']

Or the DRF validators way:

class ThingySerializer(serializers.ModelSerializer):
    number = serializers.IntegerField(validators=[
        MinValueValidator(small_funny_number),
        MaxValueValidator(large_funny_number),
    ])

    class Meta:
        model = Thingy
        fields = ['number']

Then add the serializer to an API view and generate a schema from it in any way.

Expected behavior

The functions should have been called to get the minimum and maximum values right as the schema gets generated, so there would have been this field in the resulting schema:

number:
  type: integer
  minimum: 42
  maximum: 1337
tfranzel commented 1 year ago

excellent point @Lucidiot

Am I correct in understanding that this is supported by Django, but not "yet" by DRF? I think we can fix the exception, but to make it correct, DRF would also need to be changed.

Lucidiot commented 1 year ago

DRF does need some fixes, and I mentioned them in this discussion on its repo, but since DRF still relies on Django's validators to perform the actual validation, it already almost works. You could use them as long as you don't look at the error messages 🙈

BTW, I ended up using a workaround that tricks DRF and Spectacular into not seeing the callable at all:

class HiddenCallableValidatorMixin(object):
    def __init__(self, limit_value, message=None):
        self._limit_value = limit_value
        if message:
            self.message = message

    @property
    def limit_value(self):
        return self._limit_value() if callable(self._limit_value) else self._limit_value

class MaxValueValidator(HiddenCallableValidatorMixin, validators.MaxValueValidator):
    pass