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 documentation
openapi3
Cristi Vîjdea 2018-02-22 03:46:16 +02:00 committed by GitHub
parent 10c7e22940
commit d5073081d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 326 additions and 11 deletions

View File

@ -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**
********* *********

View File

@ -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.

View File

@ -234,7 +234,8 @@ class FieldInspector(BaseInspector):
if default is not None: if default is not None:
instance_kwargs['default'] = default instance_kwargs['default'] = default
instance_kwargs.setdefault('title', title) if instance_kwargs.get('type', None) != openapi.TYPE_ARRAY:
instance_kwargs.setdefault('title', title)
instance_kwargs.setdefault('description', description) instance_kwargs.setdefault('description', description)
instance_kwargs.update(kwargs) instance_kwargs.update(kwargs)

View File

@ -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()

View File

@ -26,6 +26,7 @@ INSTALLED_APPS = [
'snippets', 'snippets',
'users', 'users',
'articles', 'articles',
'todo',
] ]
MIDDLEWARE = [ MIDDLEWARE = [

View File

@ -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),
] ]

View File

View File

@ -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')),
],
),
]

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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']