Fix ModelViewSet Nested bug (#65)
* Add todo repro app * Add explicit test against model named "Nested" * Force serializers named NestedSerializer to be output as inline models * Allow ref_name to rescue a NestedSerializer * Add tests and documentationopenapi3
parent
10c7e22940
commit
d5073081d9
|
|
@ -2,6 +2,15 @@
|
||||||
Changelog
|
Changelog
|
||||||
#########
|
#########
|
||||||
|
|
||||||
|
*********
|
||||||
|
**1.4.2**
|
||||||
|
*********
|
||||||
|
|
||||||
|
- **FIXED:** fixed a bug that causes a ``ModelViewSet`` generated from models with nested ``ForeignKey`` to output
|
||||||
|
models named ``Nested`` into the ``definitions`` section (:issue:`59`, :pr:`65`)
|
||||||
|
- **FIXED:** ``Response`` objects without a ``schema`` are now properly handled when passed through
|
||||||
|
``swagger_auto_schema`` (:issue:`66`)
|
||||||
|
|
||||||
*********
|
*********
|
||||||
**1.4.1**
|
**1.4.1**
|
||||||
*********
|
*********
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,11 @@ You can define some per-serializer options by adding a ``Meta`` class to your se
|
||||||
Currently, the only option you can add here is
|
Currently, the only option you can add here is
|
||||||
|
|
||||||
* ``ref_name`` - a string which will be used as the model definition name for this serializer class; setting it to
|
* ``ref_name`` - a string which will be used as the model definition name for this serializer class; setting it to
|
||||||
``None`` will force the serializer to be generated as an inline model everywhere it is used
|
``None`` will force the serializer to be generated as an inline model everywhere it is used. If two serializers
|
||||||
|
have the same ``ref_name``, both their usages will be replaced with a reference to the same definition.
|
||||||
|
If this option is not specified, all serializers have an implicit name derived from their class name, minus any
|
||||||
|
``Serializer`` suffix (e.g. ``UserSerializer`` -> ``User``, ``SerializerWithSuffix`` -> ``SerializerWithSuffix``)
|
||||||
|
|
||||||
|
|
||||||
*************************
|
*************************
|
||||||
Subclassing and extending
|
Subclassing and extending
|
||||||
|
|
@ -301,3 +305,25 @@ A second example, of a :class:`~.inspectors.FieldInspector` that removes the ``t
|
||||||
This means that you should generally avoid view or method-specific ``FieldInspector``\ s if you are dealing with
|
This means that you should generally avoid view or method-specific ``FieldInspector``\ s if you are dealing with
|
||||||
references (a.k.a named models), because you can never know which view will be the first to generate the schema
|
references (a.k.a named models), because you can never know which view will be the first to generate the schema
|
||||||
for a given serializer.
|
for a given serializer.
|
||||||
|
|
||||||
|
**IMPORTANT:** nested fields on ``ModelSerializer``\ s that are generated from model ``ForeignKeys`` will always be
|
||||||
|
output by value. If you want the by-reference behaviour you have to explictly set the serializer class of nested
|
||||||
|
fields instead of letting ``ModelSerializer`` generate one automatically; for example:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
class OneSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = SomeModel
|
||||||
|
fields = ('id',)
|
||||||
|
|
||||||
|
|
||||||
|
class AnotherSerializer(serializers.ModelSerializer):
|
||||||
|
chilf = OneSerializer()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = SomeParentModel
|
||||||
|
fields = ('id', 'child')
|
||||||
|
|
||||||
|
Another caveat that stems from this is that any serializer named "``NestedSerializer``" will be forced inline
|
||||||
|
unless it has a ``ref_name`` set explicitly.
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,7 @@ class FieldInspector(BaseInspector):
|
||||||
if default is not None:
|
if default is not None:
|
||||||
instance_kwargs['default'] = default
|
instance_kwargs['default'] = default
|
||||||
|
|
||||||
|
if instance_kwargs.get('type', None) != openapi.TYPE_ARRAY:
|
||||||
instance_kwargs.setdefault('title', title)
|
instance_kwargs.setdefault('title', title)
|
||||||
instance_kwargs.setdefault('description', description)
|
instance_kwargs.setdefault('description', description)
|
||||||
instance_kwargs.update(kwargs)
|
instance_kwargs.update(kwargs)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import logging
|
||||||
import operator
|
import operator
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
@ -12,6 +13,8 @@ from ..errors import SwaggerGenerationError
|
||||||
from ..utils import decimal_as_float, filter_none
|
from ..utils import decimal_as_float, filter_none
|
||||||
from .base import FieldInspector, NotHandled, SerializerInspector
|
from .base import FieldInspector, NotHandled, SerializerInspector
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class InlineSerializerInspector(SerializerInspector):
|
class InlineSerializerInspector(SerializerInspector):
|
||||||
"""Provides serializer conversions using :meth:`.FieldInspector.field_to_swagger_object`."""
|
"""Provides serializer conversions using :meth:`.FieldInspector.field_to_swagger_object`."""
|
||||||
|
|
@ -54,10 +57,14 @@ class InlineSerializerInspector(SerializerInspector):
|
||||||
|
|
||||||
serializer = field
|
serializer = field
|
||||||
serializer_meta = getattr(serializer, 'Meta', None)
|
serializer_meta = getattr(serializer, 'Meta', None)
|
||||||
|
serializer_name = type(serializer).__name__
|
||||||
if hasattr(serializer_meta, 'ref_name'):
|
if hasattr(serializer_meta, 'ref_name'):
|
||||||
ref_name = serializer_meta.ref_name
|
ref_name = serializer_meta.ref_name
|
||||||
|
elif serializer_name == 'NestedSerializer' and isinstance(serializer, serializers.ModelSerializer):
|
||||||
|
logger.debug("Forcing inline output for ModelSerializer named 'NestedSerializer': " + str(serializer))
|
||||||
|
ref_name = None
|
||||||
else:
|
else:
|
||||||
ref_name = type(serializer).__name__
|
ref_name = serializer_name
|
||||||
if ref_name.endswith('Serializer'):
|
if ref_name.endswith('Serializer'):
|
||||||
ref_name = ref_name[:-len('Serializer')]
|
ref_name = ref_name[:-len('Serializer')]
|
||||||
|
|
||||||
|
|
@ -77,11 +84,17 @@ class InlineSerializerInspector(SerializerInspector):
|
||||||
if child.required:
|
if child.required:
|
||||||
required.append(property_name)
|
required.append(property_name)
|
||||||
|
|
||||||
return SwaggerType(
|
result = SwaggerType(
|
||||||
type=openapi.TYPE_OBJECT,
|
type=openapi.TYPE_OBJECT,
|
||||||
properties=properties,
|
properties=properties,
|
||||||
required=required or None,
|
required=required or None,
|
||||||
)
|
)
|
||||||
|
if not ref_name:
|
||||||
|
# on an inline model, the title is derived from the field name
|
||||||
|
# but is visually displayed like the model named, which is confusing
|
||||||
|
# it is better to just remove title from inline models
|
||||||
|
del result.title
|
||||||
|
return result
|
||||||
|
|
||||||
if not ref_name or not use_references:
|
if not ref_name or not use_references:
|
||||||
return make_schema_definition()
|
return make_schema_definition()
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ INSTALLED_APPS = [
|
||||||
'snippets',
|
'snippets',
|
||||||
'users',
|
'users',
|
||||||
'articles',
|
'articles',
|
||||||
|
'todo',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|
|
||||||
|
|
@ -62,5 +62,6 @@ urlpatterns = [
|
||||||
url(r'^snippets/', include('snippets.urls')),
|
url(r'^snippets/', include('snippets.urls')),
|
||||||
url(r'^articles/', include('articles.urls')),
|
url(r'^articles/', include('articles.urls')),
|
||||||
url(r'^users/', include('users.urls')),
|
url(r'^users/', include('users.urls')),
|
||||||
|
url(r'^todo/', include('todo.urls')),
|
||||||
url(r'^plain/', plain_view),
|
url(r'^plain/', plain_view),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11 on 2018-02-21 23:26
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Todo',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=50)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TodoAnother',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=50)),
|
||||||
|
('todo', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='todo.Todo')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TodoYetAnother',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=50)),
|
||||||
|
('todo', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='todo.TodoAnother')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Todo(models.Model):
|
||||||
|
title = models.CharField(max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
class TodoAnother(models.Model):
|
||||||
|
todo = models.ForeignKey(Todo, on_delete=models.CASCADE)
|
||||||
|
title = models.CharField(max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
class TodoYetAnother(models.Model):
|
||||||
|
todo = models.ForeignKey(TodoAnother, on_delete=models.CASCADE)
|
||||||
|
title = models.CharField(max_length=50)
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from .models import Todo, TodoAnother, TodoYetAnother
|
||||||
|
|
||||||
|
|
||||||
|
class TodoSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Todo
|
||||||
|
fields = ('title',)
|
||||||
|
|
||||||
|
|
||||||
|
class TodoAnotherSerializer(serializers.ModelSerializer):
|
||||||
|
todo = TodoSerializer()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TodoAnother
|
||||||
|
fields = ('title', 'todo')
|
||||||
|
|
||||||
|
|
||||||
|
class TodoYetAnotherSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = TodoYetAnother
|
||||||
|
fields = ('title', 'todo')
|
||||||
|
depth = 2
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
from rest_framework import routers
|
||||||
|
|
||||||
|
from todo import views
|
||||||
|
|
||||||
|
router = routers.DefaultRouter()
|
||||||
|
router.register(r'', views.TodoViewSet)
|
||||||
|
router.register(r'another', views.TodoAnotherViewSet)
|
||||||
|
router.register(r'yetanother', views.TodoYetAnotherViewSet)
|
||||||
|
|
||||||
|
urlpatterns = router.urls
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
from rest_framework import viewsets
|
||||||
|
|
||||||
|
from .models import Todo, TodoAnother, TodoYetAnother
|
||||||
|
from .serializer import TodoAnotherSerializer, TodoSerializer, TodoYetAnotherSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class TodoViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
queryset = Todo.objects.all()
|
||||||
|
serializer_class = TodoSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class TodoAnotherViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
queryset = TodoAnother.objects.all()
|
||||||
|
serializer_class = TodoAnotherSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class TodoYetAnotherViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
queryset = TodoYetAnother.objects.all()
|
||||||
|
serializer_class = TodoYetAnotherSerializer
|
||||||
|
|
@ -339,6 +339,105 @@ paths:
|
||||||
description: A unique integer value identifying this snippet.
|
description: A unique integer value identifying this snippet.
|
||||||
required: true
|
required: true
|
||||||
type: integer
|
type: integer
|
||||||
|
/todo/:
|
||||||
|
get:
|
||||||
|
operationId: todo_list
|
||||||
|
description: ''
|
||||||
|
parameters: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: ''
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/Todo'
|
||||||
|
tags:
|
||||||
|
- todo
|
||||||
|
parameters: []
|
||||||
|
/todo/another/:
|
||||||
|
get:
|
||||||
|
operationId: todo_another_list
|
||||||
|
description: ''
|
||||||
|
parameters: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: ''
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/TodoAnother'
|
||||||
|
tags:
|
||||||
|
- todo
|
||||||
|
parameters: []
|
||||||
|
/todo/another/{id}/:
|
||||||
|
get:
|
||||||
|
operationId: todo_another_read
|
||||||
|
description: ''
|
||||||
|
parameters: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: ''
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/TodoAnother'
|
||||||
|
tags:
|
||||||
|
- todo
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
description: A unique integer value identifying this todo another.
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
/todo/yetanother/:
|
||||||
|
get:
|
||||||
|
operationId: todo_yetanother_list
|
||||||
|
description: ''
|
||||||
|
parameters: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: ''
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/TodoYetAnother'
|
||||||
|
tags:
|
||||||
|
- todo
|
||||||
|
parameters: []
|
||||||
|
/todo/yetanother/{id}/:
|
||||||
|
get:
|
||||||
|
operationId: todo_yetanother_read
|
||||||
|
description: ''
|
||||||
|
parameters: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: ''
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/TodoYetAnother'
|
||||||
|
tags:
|
||||||
|
- todo
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
description: A unique integer value identifying this todo yet another.
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
/todo/{id}/:
|
||||||
|
get:
|
||||||
|
operationId: todo_read
|
||||||
|
description: ''
|
||||||
|
parameters: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: ''
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/Todo'
|
||||||
|
tags:
|
||||||
|
- todo
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
description: A unique integer value identifying this todo.
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
/users/:
|
/users/:
|
||||||
get:
|
get:
|
||||||
operationId: users_list
|
operationId: users_list
|
||||||
|
|
@ -538,7 +637,6 @@ definitions:
|
||||||
title: Linenos
|
title: Linenos
|
||||||
type: boolean
|
type: boolean
|
||||||
language:
|
language:
|
||||||
title: Language
|
|
||||||
description: Sample help text for language
|
description: Sample help text for language
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|
@ -983,7 +1081,6 @@ definitions:
|
||||||
- zephir
|
- zephir
|
||||||
default: python
|
default: python
|
||||||
styles:
|
styles:
|
||||||
title: Styles
|
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
|
@ -1020,12 +1117,10 @@ definitions:
|
||||||
default:
|
default:
|
||||||
- friendly
|
- friendly
|
||||||
lines:
|
lines:
|
||||||
title: Lines
|
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: integer
|
type: integer
|
||||||
exampleProjects:
|
exampleProjects:
|
||||||
title: Example projects
|
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/definitions/Project'
|
$ref: '#/definitions/Project'
|
||||||
|
|
@ -1047,6 +1142,64 @@ definitions:
|
||||||
format: decimal
|
format: decimal
|
||||||
default: 0.0
|
default: 0.0
|
||||||
minimum: 0.0
|
minimum: 0.0
|
||||||
|
Todo:
|
||||||
|
required:
|
||||||
|
- title
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
title: Title
|
||||||
|
type: string
|
||||||
|
maxLength: 50
|
||||||
|
TodoAnother:
|
||||||
|
required:
|
||||||
|
- title
|
||||||
|
- todo
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
title: Title
|
||||||
|
type: string
|
||||||
|
maxLength: 50
|
||||||
|
todo:
|
||||||
|
$ref: '#/definitions/Todo'
|
||||||
|
TodoYetAnother:
|
||||||
|
required:
|
||||||
|
- title
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
title: Title
|
||||||
|
type: string
|
||||||
|
maxLength: 50
|
||||||
|
todo:
|
||||||
|
required:
|
||||||
|
- title
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
title: ID
|
||||||
|
type: integer
|
||||||
|
readOnly: true
|
||||||
|
title:
|
||||||
|
title: Title
|
||||||
|
type: string
|
||||||
|
maxLength: 50
|
||||||
|
todo:
|
||||||
|
required:
|
||||||
|
- title
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
title: ID
|
||||||
|
type: integer
|
||||||
|
readOnly: true
|
||||||
|
title:
|
||||||
|
title: Title
|
||||||
|
type: string
|
||||||
|
maxLength: 50
|
||||||
|
readOnly: true
|
||||||
|
readOnly: true
|
||||||
UserSerializerrr:
|
UserSerializerrr:
|
||||||
required:
|
required:
|
||||||
- username
|
- username
|
||||||
|
|
@ -1071,13 +1224,11 @@ definitions:
|
||||||
format: email
|
format: email
|
||||||
maxLength: 254
|
maxLength: 254
|
||||||
articles:
|
articles:
|
||||||
title: Articles
|
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: integer
|
type: integer
|
||||||
uniqueItems: true
|
uniqueItems: true
|
||||||
snippets:
|
snippets:
|
||||||
title: Snippets
|
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: integer
|
type: integer
|
||||||
|
|
@ -1095,7 +1246,6 @@ definitions:
|
||||||
format: date
|
format: date
|
||||||
readOnly: true
|
readOnly: true
|
||||||
article_slugs:
|
article_slugs:
|
||||||
title: Article slugs
|
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
|
|
||||||
|
|
@ -46,3 +46,9 @@ def test_noop_inspectors(swagger_settings, mock_schema_request, codec_json, refe
|
||||||
json_bytes = codec_json.encode(swagger)
|
json_bytes = codec_json.encode(swagger)
|
||||||
swagger_dict = json.loads(json_bytes.decode('utf-8'), object_pairs_hook=OrderedDict)
|
swagger_dict = json.loads(json_bytes.decode('utf-8'), object_pairs_hook=OrderedDict)
|
||||||
compare_schemas(swagger_dict, reference_schema)
|
compare_schemas(swagger_dict, reference_schema)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_nested_model(swagger_dict):
|
||||||
|
# ForeignKey models in deep ModelViewSets might wrongly be labeled as 'Nested' in the definitions section
|
||||||
|
# see https://github.com/axnsan12/drf-yasg/issues/59
|
||||||
|
assert 'Nested' not in swagger_dict['definitions']
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue