Skip to content

Commit ed5a999

Browse files
authored
Fix schema generation for nested serializers (#1177)
* Fix Serializer schema generation when used as a ListField child * Fix Serializer schema generation when used in another serializer
1 parent cd5f179 commit ed5a999

13 files changed

+194
-0
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/),
99
any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change.
1010

11+
## [Unreleased]
12+
13+
### Fixed
14+
15+
* Fixed OpenAPI schema generation for `Serializer` when used inside another `Serializer` or as a child of `ListField`.
16+
1117
## [6.1.0] - 2023-08-25
1218

1319
### Added

example/factories.py

+22
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
Company,
1313
Entry,
1414
ProjectType,
15+
Questionnaire,
1516
ResearchProject,
1617
TaggedItem,
1718
)
@@ -140,3 +141,24 @@ def future_projects(self, create, extracted, **kwargs):
140141
if extracted:
141142
for project in extracted:
142143
self.future_projects.add(project)
144+
145+
146+
class QuestionnaireFactory(factory.django.DjangoModelFactory):
147+
class Meta:
148+
model = Questionnaire
149+
150+
name = factory.LazyAttribute(lambda x: faker.text())
151+
questions = [
152+
{
153+
"text": "What is your name?",
154+
"required": True,
155+
},
156+
{
157+
"text": "What is your quest?",
158+
"required": False,
159+
},
160+
{
161+
"text": "What is the air-speed velocity of an unladen swallow?",
162+
},
163+
]
164+
metadata = {"author": "Bridgekeeper"}
+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 4.2.5 on 2023-09-07 02:35
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("example", "0012_author_full_name"),
9+
]
10+
11+
operations = [
12+
migrations.CreateModel(
13+
name="Questionnaire",
14+
fields=[
15+
(
16+
"id",
17+
models.AutoField(
18+
auto_created=True,
19+
primary_key=True,
20+
serialize=False,
21+
verbose_name="ID",
22+
),
23+
),
24+
("name", models.CharField(max_length=100)),
25+
("questions", models.JSONField()),
26+
],
27+
),
28+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.2.5 on 2023-09-12 07:12
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("example", "0013_questionnaire"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="questionnaire",
14+
name="metadata",
15+
field=models.JSONField(default={}),
16+
preserve_default=False,
17+
),
18+
]

example/models.py

+6
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,9 @@ class Company(models.Model):
180180

181181
def __str__(self):
182182
return self.name
183+
184+
185+
class Questionnaire(models.Model):
186+
name = models.CharField(max_length=100)
187+
questions = models.JSONField()
188+
metadata = models.JSONField()

example/serializers.py

+20
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
LabResults,
1919
Project,
2020
ProjectType,
21+
Questionnaire,
2122
ResearchProject,
2223
TaggedItem,
2324
)
@@ -421,3 +422,22 @@ class CompanySerializer(serializers.ModelSerializer):
421422
class Meta:
422423
model = Company
423424
fields = "__all__"
425+
426+
427+
class QuestionSerializer(serializers.Serializer):
428+
text = serializers.CharField()
429+
required = serializers.BooleanField(default=False)
430+
431+
432+
class QuestionnaireMetadataSerializer(serializers.Serializer):
433+
author = serializers.CharField()
434+
producer = serializers.CharField(default=None)
435+
436+
437+
class QuestionnaireSerializer(serializers.ModelSerializer):
438+
questions = serializers.ListField(child=QuestionSerializer())
439+
metadata = QuestionnaireMetadataSerializer()
440+
441+
class Meta:
442+
model = Questionnaire
443+
fields = ("name", "questions", "metadata")

example/tests/conftest.py

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
CommentFactory,
1313
CompanyFactory,
1414
EntryFactory,
15+
QuestionnaireFactory,
1516
ResearchProjectFactory,
1617
TaggedItemFactory,
1718
)
@@ -27,6 +28,7 @@
2728
register(ArtProjectFactory)
2829
register(ResearchProjectFactory)
2930
register(CompanyFactory)
31+
register(QuestionnaireFactory)
3032

3133

3234
@pytest.fixture

example/tests/test_openapi.py

+40
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,46 @@ def test_schema_id_field():
125125
assert "id" not in company_properties["attributes"]["properties"]
126126

127127

128+
def test_schema_subserializers():
129+
"""Schema for child Serializers reflects the actual response structure."""
130+
patterns = [
131+
re_path(
132+
"^questionnaires/?$", views.QuestionnaireViewset.as_view({"get": "list"})
133+
),
134+
]
135+
generator = SchemaGenerator(patterns=patterns)
136+
137+
request = create_request("/")
138+
schema = generator.get_schema(request=request)
139+
140+
assert {
141+
"type": "object",
142+
"properties": {
143+
"metadata": {
144+
"type": "object",
145+
"properties": {
146+
"author": {"type": "string"},
147+
"producer": {"type": "string"},
148+
},
149+
"required": ["author"],
150+
},
151+
"questions": {
152+
"type": "array",
153+
"items": {
154+
"type": "object",
155+
"properties": {
156+
"text": {"type": "string"},
157+
"required": {"type": "boolean", "default": False},
158+
},
159+
"required": ["text"],
160+
},
161+
},
162+
"name": {"type": "string", "maxLength": 100},
163+
},
164+
"required": ["name", "questions", "metadata"],
165+
} == schema["components"]["schemas"]["Questionnaire"]["properties"]["attributes"]
166+
167+
128168
def test_schema_parameters_include():
129169
"""Include paramater is only used when serializer defines included_serializers."""
130170
patterns = [

example/tests/test_serializers.py

+33
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,39 @@ def test_model_serializer_with_implicit_fields(self, comment, client):
224224
assert response.status_code == 200
225225
assert expected == response.json()
226226

227+
def test_model_serializer_with_subserializers(self, questionnaire, client):
228+
expected = {
229+
"data": {
230+
"type": "questionnaires",
231+
"id": str(questionnaire.pk),
232+
"attributes": {
233+
"name": questionnaire.name,
234+
"questions": [
235+
{
236+
"text": "What is your name?",
237+
"required": True,
238+
},
239+
{
240+
"text": "What is your quest?",
241+
"required": False,
242+
},
243+
{
244+
"text": "What is the air-speed velocity of an unladen swallow?",
245+
"required": False,
246+
},
247+
],
248+
"metadata": {"author": "Bridgekeeper", "producer": None},
249+
},
250+
},
251+
}
252+
253+
response = client.get(
254+
reverse("questionnaire-detail", kwargs={"pk": questionnaire.pk})
255+
)
256+
257+
assert response.status_code == 200
258+
assert expected == response.json()
259+
227260

228261
class TestPolymorphicModelSerializer(TestCase):
229262
def setUp(self):

example/urls.py

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
NonPaginatedEntryViewSet,
2020
ProjectTypeViewset,
2121
ProjectViewset,
22+
QuestionnaireViewset,
2223
)
2324

2425
router = routers.DefaultRouter(trailing_slash=False)
@@ -32,6 +33,7 @@
3233
router.register(r"projects", ProjectViewset)
3334
router.register(r"project-types", ProjectTypeViewset)
3435
router.register(r"lab-results", LabResultViewSet)
36+
router.register(r"questionnaires", QuestionnaireViewset)
3537

3638
urlpatterns = [
3739
path("", include(router.urls)),

example/urls_test.py

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
NonPaginatedEntryViewSet,
2121
ProjectTypeViewset,
2222
ProjectViewset,
23+
QuestionnaireViewset,
2324
)
2425

2526
router = routers.DefaultRouter(trailing_slash=False)
@@ -38,6 +39,7 @@
3839
router.register(r"projects", ProjectViewset)
3940
router.register(r"project-types", ProjectTypeViewset)
4041
router.register(r"lab-results", LabResultViewSet)
42+
router.register(r"questionnaires", QuestionnaireViewset)
4143

4244
# for the old tests
4345
router.register(r"identities", Identity)

example/views.py

+7
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
LabResults,
3030
Project,
3131
ProjectType,
32+
Questionnaire,
3233
)
3334
from example.serializers import (
3435
AuthorDetailSerializer,
@@ -43,6 +44,7 @@
4344
LabResultsSerializer,
4445
ProjectSerializer,
4546
ProjectTypeSerializer,
47+
QuestionnaireSerializer,
4648
)
4749

4850
HTTP_422_UNPROCESSABLE_ENTITY = 422
@@ -292,3 +294,8 @@ class LabResultViewSet(ReadOnlyModelViewSet):
292294
"__all__": [],
293295
"author": ["author__bio", "author__entries"],
294296
}
297+
298+
299+
class QuestionnaireViewset(ModelViewSet):
300+
queryset = Questionnaire.objects.all()
301+
serializer_class = QuestionnaireSerializer

rest_framework_json_api/schemas/openapi.py

+8
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,14 @@ def map_serializer(self, serializer):
681681
and 'links'.
682682
"""
683683
# TODO: remove attributes, etc. for relationshipView??
684+
if isinstance(
685+
serializer.parent, (serializers.ListField, serializers.BaseSerializer)
686+
):
687+
# Return plain non-JSON:API serializer schema for serializers nested inside
688+
# a Serializer or a ListField, as those don't use the full JSON:API
689+
# serializer schemas.
690+
return super().map_serializer(serializer)
691+
684692
required = []
685693
attributes = {}
686694
relationships_required = []

0 commit comments

Comments
 (0)