diff --git a/CHANGELOG.md b/CHANGELOG.md index 243f13b3..77c9e990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` children of `ListField`. + ## [6.1.0] - 2023-08-25 ### Added diff --git a/example/factories.py b/example/factories.py index 4ca1e0b1..80535935 100644 --- a/example/factories.py +++ b/example/factories.py @@ -12,6 +12,7 @@ Company, Entry, ProjectType, + Questionnaire, ResearchProject, TaggedItem, ) @@ -140,3 +141,23 @@ 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?", + }, + ] diff --git a/example/migrations/0013_questionnaire.py b/example/migrations/0013_questionnaire.py new file mode 100644 index 00000000..0a3b7cd2 --- /dev/null +++ b/example/migrations/0013_questionnaire.py @@ -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()), + ], + ), + ] diff --git a/example/models.py b/example/models.py index 8fc86c22..a8b3f4f1 100644 --- a/example/models.py +++ b/example/models.py @@ -180,3 +180,8 @@ class Company(models.Model): def __str__(self): return self.name + + +class Questionnaire(models.Model): + name = models.CharField(max_length=100) + questions = models.JSONField() diff --git a/example/serializers.py b/example/serializers.py index 3d94e6cc..f667a59b 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -18,6 +18,7 @@ LabResults, Project, ProjectType, + Questionnaire, ResearchProject, TaggedItem, ) @@ -421,3 +422,16 @@ class CompanySerializer(serializers.ModelSerializer): class Meta: model = Company fields = "__all__" + + +class QuestionSerializer(serializers.Serializer): + text = serializers.CharField() + required = serializers.BooleanField(default=False) + + +class QuestionnaireSerializer(serializers.ModelSerializer): + questions = serializers.ListField(child=QuestionSerializer()) + + class Meta: + model = Questionnaire + fields = "__all__" diff --git a/example/tests/conftest.py b/example/tests/conftest.py index 22ab6bd1..6e4b05ba 100644 --- a/example/tests/conftest.py +++ b/example/tests/conftest.py @@ -12,6 +12,7 @@ CommentFactory, CompanyFactory, EntryFactory, + QuestionnaireFactory, ResearchProjectFactory, TaggedItemFactory, ) @@ -27,6 +28,7 @@ register(ArtProjectFactory) register(ResearchProjectFactory) register(CompanyFactory) +register(QuestionnaireFactory) @pytest.fixture diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index 5710da2a..09598187 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -125,6 +125,38 @@ def test_schema_id_field(): assert "id" not in company_properties["attributes"]["properties"] +def test_schema_list_of_objects(): + """Schema for ListField child Serializer 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": { + "questions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": {"type": "string"}, + "required": {"type": "boolean", "default": False}, + }, + "required": ["text"], + }, + }, + "name": {"type": "string", "maxLength": 100}, + }, + "required": ["questions", "name"], + } == schema["components"]["schemas"]["Questionnaire"]["properties"]["attributes"] + + def test_schema_parameters_include(): """Include paramater is only used when serializer defines included_serializers.""" patterns = [ diff --git a/example/tests/test_serializers.py b/example/tests/test_serializers.py index 37f50b53..be188423 100644 --- a/example/tests/test_serializers.py +++ b/example/tests/test_serializers.py @@ -224,6 +224,38 @@ def test_model_serializer_with_implicit_fields(self, comment, client): assert response.status_code == 200 assert expected == response.json() + def test_model_serializer_with_list_of_objects(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, + }, + ], + }, + }, + } + + 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): diff --git a/example/urls.py b/example/urls.py index 3d1cf2fa..413d058d 100644 --- a/example/urls.py +++ b/example/urls.py @@ -19,6 +19,7 @@ NonPaginatedEntryViewSet, ProjectTypeViewset, ProjectViewset, + QuestionnaireViewset, ) router = routers.DefaultRouter(trailing_slash=False) @@ -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)), diff --git a/example/urls_test.py b/example/urls_test.py index 92802a81..bb8fbecf 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -20,6 +20,7 @@ NonPaginatedEntryViewSet, ProjectTypeViewset, ProjectViewset, + QuestionnaireViewset, ) router = routers.DefaultRouter(trailing_slash=False) @@ -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) diff --git a/example/views.py b/example/views.py index b0d92811..9c949684 100644 --- a/example/views.py +++ b/example/views.py @@ -29,6 +29,7 @@ LabResults, Project, ProjectType, + Questionnaire, ) from example.serializers import ( AuthorDetailSerializer, @@ -43,6 +44,7 @@ LabResultsSerializer, ProjectSerializer, ProjectTypeSerializer, + QuestionnaireSerializer, ) HTTP_422_UNPROCESSABLE_ENTITY = 422 @@ -292,3 +294,8 @@ class LabResultViewSet(ReadOnlyModelViewSet): "__all__": [], "author": ["author__bio", "author__entries"], } + + +class QuestionnaireViewset(ModelViewSet): + queryset = Questionnaire.objects.all() + serializer_class = QuestionnaireSerializer diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 52f08da6..9cb203ca 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -681,6 +681,11 @@ def map_serializer(self, serializer): and 'links'. """ # TODO: remove attributes, etc. for relationshipView?? + if isinstance(serializer.parent, serializers.ListField): + # Return plain non-JSON:API serializer schema for serializers nested inside + # a ListField, as those don't use the full JSON:API serializer schemas. + return super().map_serializer(serializer) + required = [] attributes = {} relationships_required = []