Skip to content

Commit

Permalink
Fix schema generation for nested serializers (#1177)
Browse files Browse the repository at this point in the history
* Fix Serializer schema generation when used as a ListField child
* Fix Serializer schema generation when used in another serializer
  • Loading branch information
arttuperala authored Sep 19, 2023
1 parent cd5f179 commit ed5a999
Show file tree
Hide file tree
Showing 13 changed files with 194 additions and 0 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/),
any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change.

## [Unreleased]

### Fixed

* Fixed OpenAPI schema generation for `Serializer` when used inside another `Serializer` or as a child of `ListField`.

## [6.1.0] - 2023-08-25

### Added
Expand Down
22 changes: 22 additions & 0 deletions example/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
Company,
Entry,
ProjectType,
Questionnaire,
ResearchProject,
TaggedItem,
)
Expand Down Expand Up @@ -140,3 +141,24 @@ def future_projects(self, create, extracted, **kwargs):
if extracted:
for project in extracted:
self.future_projects.add(project)


class QuestionnaireFactory(factory.django.DjangoModelFactory):
class Meta:
model = Questionnaire

name = factory.LazyAttribute(lambda x: faker.text())
questions = [
{
"text": "What is your name?",
"required": True,
},
{
"text": "What is your quest?",
"required": False,
},
{
"text": "What is the air-speed velocity of an unladen swallow?",
},
]
metadata = {"author": "Bridgekeeper"}
28 changes: 28 additions & 0 deletions example/migrations/0013_questionnaire.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 4.2.5 on 2023-09-07 02:35

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("example", "0012_author_full_name"),
]

operations = [
migrations.CreateModel(
name="Questionnaire",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100)),
("questions", models.JSONField()),
],
),
]
18 changes: 18 additions & 0 deletions example/migrations/0014_questionnaire_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.5 on 2023-09-12 07:12

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("example", "0013_questionnaire"),
]

operations = [
migrations.AddField(
model_name="questionnaire",
name="metadata",
field=models.JSONField(default={}),
preserve_default=False,
),
]
6 changes: 6 additions & 0 deletions example/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,9 @@ class Company(models.Model):

def __str__(self):
return self.name


class Questionnaire(models.Model):
name = models.CharField(max_length=100)
questions = models.JSONField()
metadata = models.JSONField()
20 changes: 20 additions & 0 deletions example/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
LabResults,
Project,
ProjectType,
Questionnaire,
ResearchProject,
TaggedItem,
)
Expand Down Expand Up @@ -421,3 +422,22 @@ class CompanySerializer(serializers.ModelSerializer):
class Meta:
model = Company
fields = "__all__"


class QuestionSerializer(serializers.Serializer):
text = serializers.CharField()
required = serializers.BooleanField(default=False)


class QuestionnaireMetadataSerializer(serializers.Serializer):
author = serializers.CharField()
producer = serializers.CharField(default=None)


class QuestionnaireSerializer(serializers.ModelSerializer):
questions = serializers.ListField(child=QuestionSerializer())
metadata = QuestionnaireMetadataSerializer()

class Meta:
model = Questionnaire
fields = ("name", "questions", "metadata")
2 changes: 2 additions & 0 deletions example/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
CommentFactory,
CompanyFactory,
EntryFactory,
QuestionnaireFactory,
ResearchProjectFactory,
TaggedItemFactory,
)
Expand All @@ -27,6 +28,7 @@
register(ArtProjectFactory)
register(ResearchProjectFactory)
register(CompanyFactory)
register(QuestionnaireFactory)


@pytest.fixture
Expand Down
40 changes: 40 additions & 0 deletions example/tests/test_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,46 @@ def test_schema_id_field():
assert "id" not in company_properties["attributes"]["properties"]


def test_schema_subserializers():
"""Schema for child Serializers reflects the actual response structure."""
patterns = [
re_path(
"^questionnaires/?$", views.QuestionnaireViewset.as_view({"get": "list"})
),
]
generator = SchemaGenerator(patterns=patterns)

request = create_request("/")
schema = generator.get_schema(request=request)

assert {
"type": "object",
"properties": {
"metadata": {
"type": "object",
"properties": {
"author": {"type": "string"},
"producer": {"type": "string"},
},
"required": ["author"],
},
"questions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"text": {"type": "string"},
"required": {"type": "boolean", "default": False},
},
"required": ["text"],
},
},
"name": {"type": "string", "maxLength": 100},
},
"required": ["name", "questions", "metadata"],
} == schema["components"]["schemas"]["Questionnaire"]["properties"]["attributes"]


def test_schema_parameters_include():
"""Include paramater is only used when serializer defines included_serializers."""
patterns = [
Expand Down
33 changes: 33 additions & 0 deletions example/tests/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,39 @@ def test_model_serializer_with_implicit_fields(self, comment, client):
assert response.status_code == 200
assert expected == response.json()

def test_model_serializer_with_subserializers(self, questionnaire, client):
expected = {
"data": {
"type": "questionnaires",
"id": str(questionnaire.pk),
"attributes": {
"name": questionnaire.name,
"questions": [
{
"text": "What is your name?",
"required": True,
},
{
"text": "What is your quest?",
"required": False,
},
{
"text": "What is the air-speed velocity of an unladen swallow?",
"required": False,
},
],
"metadata": {"author": "Bridgekeeper", "producer": None},
},
},
}

response = client.get(
reverse("questionnaire-detail", kwargs={"pk": questionnaire.pk})
)

assert response.status_code == 200
assert expected == response.json()


class TestPolymorphicModelSerializer(TestCase):
def setUp(self):
Expand Down
2 changes: 2 additions & 0 deletions example/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
NonPaginatedEntryViewSet,
ProjectTypeViewset,
ProjectViewset,
QuestionnaireViewset,
)

router = routers.DefaultRouter(trailing_slash=False)
Expand All @@ -32,6 +33,7 @@
router.register(r"projects", ProjectViewset)
router.register(r"project-types", ProjectTypeViewset)
router.register(r"lab-results", LabResultViewSet)
router.register(r"questionnaires", QuestionnaireViewset)

urlpatterns = [
path("", include(router.urls)),
Expand Down
2 changes: 2 additions & 0 deletions example/urls_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
NonPaginatedEntryViewSet,
ProjectTypeViewset,
ProjectViewset,
QuestionnaireViewset,
)

router = routers.DefaultRouter(trailing_slash=False)
Expand All @@ -38,6 +39,7 @@
router.register(r"projects", ProjectViewset)
router.register(r"project-types", ProjectTypeViewset)
router.register(r"lab-results", LabResultViewSet)
router.register(r"questionnaires", QuestionnaireViewset)

# for the old tests
router.register(r"identities", Identity)
Expand Down
7 changes: 7 additions & 0 deletions example/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
LabResults,
Project,
ProjectType,
Questionnaire,
)
from example.serializers import (
AuthorDetailSerializer,
Expand All @@ -43,6 +44,7 @@
LabResultsSerializer,
ProjectSerializer,
ProjectTypeSerializer,
QuestionnaireSerializer,
)

HTTP_422_UNPROCESSABLE_ENTITY = 422
Expand Down Expand Up @@ -292,3 +294,8 @@ class LabResultViewSet(ReadOnlyModelViewSet):
"__all__": [],
"author": ["author__bio", "author__entries"],
}


class QuestionnaireViewset(ModelViewSet):
queryset = Questionnaire.objects.all()
serializer_class = QuestionnaireSerializer
8 changes: 8 additions & 0 deletions rest_framework_json_api/schemas/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,14 @@ def map_serializer(self, serializer):
and 'links'.
"""
# TODO: remove attributes, etc. for relationshipView??
if isinstance(
serializer.parent, (serializers.ListField, serializers.BaseSerializer)
):
# Return plain non-JSON:API serializer schema for serializers nested inside
# a Serializer or a ListField, as those don't use the full JSON:API
# serializer schemas.
return super().map_serializer(serializer)

required = []
attributes = {}
relationships_required = []
Expand Down

0 comments on commit ed5a999

Please sign in to comment.