From 9efea6ab75251f676f7f3a3cdc19e0b0bc822121 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Mon, 12 Aug 2019 16:57:47 -0400 Subject: [PATCH 01/15] initial implementation of OAS 3.0 generateschema --- AUTHORS | 1 + CHANGELOG.md | 7 + README.rst | 1 + docs/getting-started.md | 1 + docs/usage.md | 117 ++- example/serializers.py | 13 +- example/settings/dev.py | 2 + example/templates/swagger-ui.html | 28 + example/tests/snapshots/snap_test_openapi.py | 647 +++++++++++++ example/tests/test_format_keys.py | 3 +- example/tests/test_openapi.py | 168 ++++ .../tests/unit/test_filter_schema_params.py | 77 ++ example/urls.py | 17 +- requirements/requirements-optionals.txt | 2 + .../django_filters/backends.py | 14 + rest_framework_json_api/schemas/__init__.py | 0 rest_framework_json_api/schemas/openapi.py | 878 ++++++++++++++++++ setup.cfg | 1 + setup.py | 3 +- 19 files changed, 1975 insertions(+), 5 deletions(-) create mode 100644 example/templates/swagger-ui.html create mode 100644 example/tests/snapshots/snap_test_openapi.py create mode 100644 example/tests/test_openapi.py create mode 100644 example/tests/unit/test_filter_schema_params.py create mode 100644 rest_framework_json_api/schemas/__init__.py create mode 100644 rest_framework_json_api/schemas/openapi.py diff --git a/AUTHORS b/AUTHORS index 17d3de18..b876d4ae 100644 --- a/AUTHORS +++ b/AUTHORS @@ -14,6 +14,7 @@ Jason Housley Jerel Unruh Jonathan Senecal Joseba Mendivil +Kieran Evans Léo S. Luc Cary Matt Layman diff --git a/CHANGELOG.md b/CHANGELOG.md index d008982c..388d1d35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ This release is not backwards compatible. For easy migration best upgrade first * Added support for Django REST framework 3.12 * Added support for Django 3.1 +* Added initial optional support for [openapi](https://www.openapis.org/) schema generation. Enable with: + ``` + pip install djangorestframework-jsonapi['openapi'] + ``` + This first release is a start at implementing OAS schema generation. To use the generated schema you may + still need to manually add some schema attributes but can expect future improvements here and as + upstream DRF's OAS schema generation continues to mature. ### Removed diff --git a/README.rst b/README.rst index d85431af..8edc421e 100644 --- a/README.rst +++ b/README.rst @@ -108,6 +108,7 @@ From PyPI $ # for optional package integrations $ pip install djangorestframework-jsonapi['django-filter'] $ pip install djangorestframework-jsonapi['django-polymorphic'] + $ pip install djangorestframework-jsonapi['openapi'] From Source diff --git a/docs/getting-started.md b/docs/getting-started.md index 046a9b5e..bd7b460f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -67,6 +67,7 @@ From PyPI # for optional package integrations pip install djangorestframework-jsonapi['django-filter'] pip install djangorestframework-jsonapi['django-polymorphic'] + pip install djangorestframework-jsonapi['openapi'] From Source diff --git a/docs/usage.md b/docs/usage.md index 9fd46e6b..fc50fcaf 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,4 +1,3 @@ - # Usage The DJA package implements a custom renderer, parser, exception handler, query filter backends, and @@ -32,6 +31,7 @@ REST_FRAMEWORK = { 'rest_framework.renderers.BrowsableAPIRenderer' ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', + 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema', 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework_json_api.filters.QueryParameterValidationFilter', 'rest_framework_json_api.filters.OrderingFilter', @@ -944,3 +944,118 @@ The `prefetch_related` case will issue 4 queries, but they will be small and fas ### Relationships ### Errors --> + +## Generating an OpenAPI Specification (OAS) 3.0 schema document + +DRF >= 3.12 has a [new OAS schema functionality](https://www.django-rest-framework.org/api-guide/schemas/) to generate an +[OAS 3.0 schema](https://www.openapis.org/) as a YAML or JSON file. + +DJA extends DRF's schema support to generate an OAS schema in the JSON:API format. + +### AutoSchema Settings + +In order to produce an OAS schema that properly represents the JSON:API structure +you have to either add a `schema` attribute to each view class or set the `REST_FRAMEWORK['DEFAULT_SCHEMA_CLASS']` +to DJA's version of AutoSchema. + +You can also extend the OAS schema with additional static content (a feature not available in DRF at this time). + +#### View-based + +```python +from rest_framework_json_api.schemas.openapi import AutoSchema + +class MyViewset(ModelViewSet): + schema = AutoSchema + ... +``` + +#### Default schema class + +```python +REST_FRAMEWORK = { + # ... + 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema', +} +``` + +### Adding static OAS schema content + +You can optionally include an OAS schema document initialization by subclassing `SchemaGenerator` +and setting `schema_init`. + +Here's an example that fills out OAS `info` and `servers` objects. + +```python +# views.py + +from rest_framework_json_api.schemas.openapi import SchemaGenerator as JSONAPISchemaGenerator + + +class MySchemaGenerator(JSONAPISchemaGenerator): + """ + Describe my OAS schema info in detail (overriding what DRF put in) and list the servers where it can be found. + """ + def get_schema(self, request, public): + schema = super().get_schema(request, public) + schema['info'] = { + 'version': '1.0', + 'title': 'my demo API', + 'description': 'A demonstration of [OAS 3.0](https://www.openapis.org)', + 'contact': { + 'name': 'my name' + }, + 'license': { + 'name': 'BSD 2 clause', + 'url': 'https://github.com/django-json-api/django-rest-framework-json-api/blob/master/LICENSE', + } + } + schema['servers'] = [ + {'url': 'https://localhost/v1', 'description': 'local docker'}, + {'url': 'http://localhost:8000/v1', 'description': 'local dev'}, + {'url': 'https://api.example.com/v1', 'description': 'demo server'}, + {'url': '{serverURL}', 'description': 'provide your server URL', + 'variables': {'serverURL': {'default': 'http://localhost:8000/v1'}}} + ] + return schema +``` + +### Generate a Static Schema on Command Line + +See [DRF documentation for generateschema](https://www.django-rest-framework.org/api-guide/schemas/#generating-a-static-schema-with-the-generateschema-management-command) +To generate an OAS schema document, use something like: + +```text +$ django-admin generateschema --settings=example.settings \ + --generator_class myapp.views.MySchemaGenerator >myschema.yaml +``` + +You can then use any number of OAS tools such as +[swagger-ui-watcher](https://www.npmjs.com/package/swagger-ui-watcher) +to render the schema: +```text +$ swagger-ui-watcher myschema.yaml +``` + +Note: Swagger-ui-watcher will complain that "DELETE operations cannot have a requestBody" +but it will still work. This [error](https://github.com/OAI/OpenAPI-Specification/pull/2117) +in the OAS specification will be fixed when [OAS 3.1.0](https://www.openapis.org/blog/2020/06/18/openapi-3-1-0-rc0-its-here) +is published. + +([swagger-ui](https://www.npmjs.com/package/swagger-ui) will work silently.) + +### Generate a Dynamic Schema in a View + +See [DRF documentation for a Dynamic Schema](https://www.django-rest-framework.org/api-guide/schemas/#generating-a-dynamic-schema-with-schemaview). +You will need to pass in your custom SchemaGenerator if you've created one. + +```python +from rest_framework.schemas import get_schema_view +from views import MySchemaGenerator + +urlpatterns = [ + path('openapi', get_schema_view(generator_class=MySchemaGenerator), name='openapi-schema'), + ... +] +``` + diff --git a/example/serializers.py b/example/serializers.py index 1728b742..9dc84a4a 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -230,6 +230,16 @@ class AuthorSerializer(serializers.ModelSerializer): queryset=Comment.objects, many=True ) + secrets = serializers.HiddenField( + default='Shhhh!' + ) + defaults = serializers.CharField( + default='default', + max_length=20, + min_length=3, + write_only=True, + help_text='help for defaults', + ) included_serializers = { 'bio': AuthorBioSerializer, 'type': AuthorTypeSerializer @@ -244,7 +254,8 @@ class AuthorSerializer(serializers.ModelSerializer): class Meta: model = Author - fields = ('name', 'email', 'bio', 'entries', 'comments', 'first_entry', 'type') + fields = ('name', 'email', 'bio', 'entries', 'comments', 'first_entry', 'type', + 'secrets', 'defaults') def get_first_entry(self, obj): return obj.entries.first() diff --git a/example/settings/dev.py b/example/settings/dev.py index ade24139..961807e3 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -21,6 +21,7 @@ 'django.contrib.sites', 'django.contrib.sessions', 'django.contrib.auth', + 'rest_framework_json_api', 'rest_framework', 'polymorphic', 'example', @@ -88,6 +89,7 @@ 'rest_framework.renderers.BrowsableAPIRenderer', ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', + 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema', 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework_json_api.filters.OrderingFilter', 'rest_framework_json_api.django_filters.DjangoFilterBackend', diff --git a/example/templates/swagger-ui.html b/example/templates/swagger-ui.html new file mode 100644 index 00000000..29776491 --- /dev/null +++ b/example/templates/swagger-ui.html @@ -0,0 +1,28 @@ + + + + Swagger + + + + + +
+ + + + \ No newline at end of file diff --git a/example/tests/snapshots/snap_test_openapi.py b/example/tests/snapshots/snap_test_openapi.py new file mode 100644 index 00000000..ec8da388 --- /dev/null +++ b/example/tests/snapshots/snap_test_openapi.py @@ -0,0 +1,647 @@ +# -*- coding: utf-8 -*- +# snapshottest: v1 - https://goo.gl/zC4yUc +from __future__ import unicode_literals + +from snapshottest import Snapshot + + +snapshots = Snapshot() + +snapshots['test_path_without_parameters 1'] = '''{ + "description": "", + "operationId": "List/authors/", + "parameters": [ + { + "$ref": "#/components/parameters/include" + }, + { + "$ref": "#/components/parameters/fields" + }, + { + "$ref": "#/components/parameters/sort" + }, + { + "description": "A page number within the paginated result set.", + "in": "query", + "name": "page[number]", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Number of results to return per page.", + "in": "query", + "name": "page[size]", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Which field to use when ordering the results.", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "A search term.", + "in": "query", + "name": "filter[search]", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/Author" + }, + "type": "array" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "List/authors/" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not found" + } + } +}''' + +snapshots['test_path_with_id_parameter 1'] = '''{ + "description": "", + "operationId": "retrieve/authors/{id}/", + "parameters": [ + { + "description": "A unique integer value identifying this author.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/include" + }, + { + "$ref": "#/components/parameters/fields" + }, + { + "$ref": "#/components/parameters/sort" + }, + { + "description": "Which field to use when ordering the results.", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "A search term.", + "in": "query", + "name": "filter[search]", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Author" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "retrieve/authors/{id}/" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not found" + } + } +}''' + +snapshots['test_post_request 1'] = '''{ + "description": "", + "operationId": "create/authors/", + "parameters": [], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "attributes": { + "properties": { + "defaults": { + "default": "default", + "description": "help for defaults", + "maxLength": 20, + "minLength": 3, + "type": "string", + "writeOnly": true + }, + "email": { + "format": "email", + "maxLength": 254, + "type": "string" + }, + "name": { + "maxLength": 50, + "type": "string" + } + }, + "required": [ + "name", + "email" + ], + "type": "object" + }, + "id": { + "$ref": "#/components/schemas/id" + }, + "links": { + "properties": { + "self": { + "$ref": "#/components/schemas/link" + } + }, + "type": "object" + }, + "relationships": { + "properties": { + "bio": { + "$ref": "#/components/schemas/reltoone" + }, + "comments": { + "$ref": "#/components/schemas/reltomany" + }, + "entries": { + "$ref": "#/components/schemas/reltomany" + }, + "first_entry": { + "$ref": "#/components/schemas/reltoone" + }, + "type": { + "$ref": "#/components/schemas/reltoone" + } + }, + "type": "object" + }, + "type": { + "$ref": "#/components/schemas/type" + } + }, + "required": [ + "type" + ], + "type": "object" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "201": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Author" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-201). Assigned `id` and/or any other changes are in this response." + }, + "202": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/datum" + } + } + }, + "description": "Accepted for [asynchronous processing](https://jsonapi.org/recommendations/#asynchronous-processing)" + }, + "204": { + "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-204) with the supplied `id`. No other changes from what was POSTed." + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "403": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Forbidden](https://jsonapi.org/format/#crud-creating-responses-403)" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Related resource does not exist](https://jsonapi.org/format/#crud-creating-responses-404)" + }, + "409": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)" + } + } +}''' + +snapshots['test_patch_request 1'] = '''{ + "description": "", + "operationId": "update/authors/{id}", + "parameters": [ + { + "description": "A unique integer value identifying this author.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "attributes": { + "properties": { + "defaults": { + "default": "default", + "description": "help for defaults", + "maxLength": 20, + "minLength": 3, + "type": "string", + "writeOnly": true + }, + "email": { + "format": "email", + "maxLength": 254, + "type": "string" + }, + "name": { + "maxLength": 50, + "type": "string" + } + }, + "type": "object" + }, + "id": { + "$ref": "#/components/schemas/id" + }, + "links": { + "properties": { + "self": { + "$ref": "#/components/schemas/link" + } + }, + "type": "object" + }, + "relationships": { + "properties": { + "bio": { + "$ref": "#/components/schemas/reltoone" + }, + "comments": { + "$ref": "#/components/schemas/reltomany" + }, + "entries": { + "$ref": "#/components/schemas/reltomany" + }, + "first_entry": { + "$ref": "#/components/schemas/reltoone" + }, + "type": { + "$ref": "#/components/schemas/reltoone" + } + }, + "type": "object" + }, + "type": { + "$ref": "#/components/schemas/type" + } + }, + "required": [ + "type", + "id" + ], + "type": "object" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Author" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "update/authors/{id}" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "403": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Forbidden](https://jsonapi.org/format/#crud-updating-responses-403)" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Related resource does not exist](https://jsonapi.org/format/#crud-updating-responses-404)" + }, + "409": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Conflict]([Conflict](https://jsonapi.org/format/#crud-updating-responses-409)" + } + } +}''' + +snapshots['test_delete_request 1'] = '''{ + "description": "", + "operationId": "destroy/authors/{id}", + "parameters": [ + { + "description": "A unique integer value identifying this author.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/onlymeta" + } + } + }, + "description": "[OK](https://jsonapi.org/format/#crud-deleting-responses-200)" + }, + "202": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/datum" + } + } + }, + "description": "Accepted for [asynchronous processing](https://jsonapi.org/recommendations/#asynchronous-processing)" + }, + "204": { + "description": "[no content](https://jsonapi.org/format/#crud-deleting-responses-204)" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Resource does not exist](https://jsonapi.org/format/#crud-deleting-responses-404)" + } + } +}''' diff --git a/example/tests/test_format_keys.py b/example/tests/test_format_keys.py index ba3f4920..0fd76c67 100644 --- a/example/tests/test_format_keys.py +++ b/example/tests/test_format_keys.py @@ -58,5 +58,6 @@ def test_options_format_field_names(db, client): response = client.options(reverse('author-list')) assert response.status_code == status.HTTP_200_OK data = response.json()['data'] - expected_keys = {'name', 'email', 'bio', 'entries', 'firstEntry', 'type', 'comments'} + expected_keys = {'name', 'email', 'bio', 'entries', 'firstEntry', 'type', + 'comments', 'secrets', 'defaults'} assert expected_keys == data['actions']['POST'].keys() diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py new file mode 100644 index 00000000..85fb4458 --- /dev/null +++ b/example/tests/test_openapi.py @@ -0,0 +1,168 @@ +# largely based on DRF's test_openapi +import json + +from django.test import RequestFactory, override_settings +from django.urls import re_path +from rest_framework.request import Request + +from rest_framework_json_api.schemas.openapi import AutoSchema, SchemaGenerator + +from example import views +from example.tests import TestBase + + +def create_request(path): + factory = RequestFactory() + request = Request(factory.get(path)) + return request + + +def create_view_with_kw(view_cls, method, request, initkwargs): + generator = SchemaGenerator() + view = generator.create_view(view_cls.as_view(initkwargs), method, request) + return view + + +def test_path_without_parameters(snapshot): + path = '/authors/' + method = 'GET' + + view = create_view_with_kw( + views.AuthorViewSet, + method, + create_request(path), + {'get': 'list'} + ) + inspector = AutoSchema() + inspector.view = view + + operation = inspector.get_operation(path, method) + snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + + +def test_path_with_id_parameter(snapshot): + path = '/authors/{id}/' + method = 'GET' + + view = create_view_with_kw( + views.AuthorViewSet, + method, + create_request(path), + {'get': 'retrieve'} + ) + inspector = AutoSchema() + inspector.view = view + + operation = inspector.get_operation(path, method) + snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + + +def test_post_request(snapshot): + method = 'POST' + path = '/authors/' + + view = create_view_with_kw( + views.AuthorViewSet, + method, + create_request(path), + {'post': 'create'} + ) + inspector = AutoSchema() + inspector.view = view + + operation = inspector.get_operation(path, method) + snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + + +def test_patch_request(snapshot): + method = 'PATCH' + path = '/authors/{id}' + + view = create_view_with_kw( + views.AuthorViewSet, + method, + create_request(path), + {'patch': 'update'} + ) + inspector = AutoSchema() + inspector.view = view + + operation = inspector.get_operation(path, method) + snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + + +def test_delete_request(snapshot): + method = 'DELETE' + path = '/authors/{id}' + + view = create_view_with_kw( + views.AuthorViewSet, + method, + create_request(path), + {'delete': 'delete'} + ) + inspector = AutoSchema() + # DRF >=3.12 changes the capitalization of these method mappings which breaks the snapshot, + # so just override them to be consistent with >=3.12 + inspector.method_mapping = { + 'get': 'retrieve', + 'post': 'create', + 'put': 'update', + 'patch': 'partialUpdate', + 'delete': 'destroy', + } + inspector.view = view + + operation = inspector.get_operation(path, method) + snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + + +@override_settings(REST_FRAMEWORK={ + 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema'}) +def test_schema_construction(): + """Construction of the top level dictionary.""" + patterns = [ + re_path('^authors/?$', views.AuthorViewSet.as_view({'get': 'list'})), + ] + generator = SchemaGenerator(patterns=patterns) + + request = create_request('/') + schema = generator.get_schema(request=request) + + assert 'openapi' in schema + assert 'info' in schema + assert 'paths' in schema + assert 'components' in schema + + +class TestSchemaRelatedField(TestBase): + def test_schema_related_serializers(self): + """ + Confirm that paths are generated for related fields. For example: + url path '/authors/{pk}/{related_field>}/' generates: + /authors/{id}/relationships/comments/ + /authors/{id}/relationships/entries/ + /authors/{id}/relationships/first_entry/ -- Maybe? + /authors/{id}/comments/ + /authors/{id}/entries/ + /authors/{id}/first_entry/ + and confirm that the schema for the related field is properly rendered + """ + generator = SchemaGenerator() + request = create_request('/') + schema = generator.get_schema(request=request) + # make sure the path's relationship and related {related_field}'s got expanded + assert '/authors/{id}/relationships/entries' in schema['paths'] + assert '/authors/{id}/relationships/comments' in schema['paths'] + # first_entry is a special case (SerializerMethodRelatedField) + # TODO: '/authors/{id}/relationships/first_entry' supposed to be there? + # It fails when doing the actual GET, so this schema excluding it is OK. + # assert '/authors/{id}/relationships/first_entry/' in schema['paths'] + assert '/authors/{id}/comments/' in schema['paths'] + assert '/authors/{id}/entries/' in schema['paths'] + assert '/authors/{id}/first_entry/' in schema['paths'] + first_get = schema['paths']['/authors/{id}/first_entry/']['get']['responses']['200'] + first_schema = first_get['content']['application/vnd.api+json']['schema'] + first_props = first_schema['properties']['data'] + assert '$ref' in first_props + assert first_props['$ref'] == '#/components/schemas/Entry' diff --git a/example/tests/unit/test_filter_schema_params.py b/example/tests/unit/test_filter_schema_params.py new file mode 100644 index 00000000..2044c467 --- /dev/null +++ b/example/tests/unit/test_filter_schema_params.py @@ -0,0 +1,77 @@ +from rest_framework import filters as drf_filters + +from rest_framework_json_api import filters as dja_filters +from rest_framework_json_api.django_filters import backends + +from example.views import EntryViewSet + + +class DummyEntryViewSet(EntryViewSet): + filter_backends = (dja_filters.QueryParameterValidationFilter, dja_filters.OrderingFilter, + backends.DjangoFilterBackend, drf_filters.SearchFilter) + filterset_fields = { + 'id': ('exact',), + 'headline': ('exact', 'contains'), + 'blog__name': ('contains', ), + } + + def __init__(self, **kwargs): + # dummy up self.request since PreloadIncludesMixin expects it to be defined + self.request = None + super(DummyEntryViewSet, self).__init__(**kwargs) + + +def test_filters_get_schema_params(): + """ + test all my filters for `get_schema_operation_parameters()` + """ + # list of tuples: (filter, expected result) + filters = [ + (dja_filters.QueryParameterValidationFilter, []), + (backends.DjangoFilterBackend, [ + { + 'name': 'filter[id]', 'required': False, 'in': 'query', + 'description': 'id', 'schema': {'type': 'string'} + }, + { + 'name': 'filter[headline]', 'required': False, 'in': 'query', + 'description': 'headline', 'schema': {'type': 'string'} + }, + { + 'name': 'filter[headline.contains]', 'required': False, 'in': 'query', + 'description': 'headline__contains', 'schema': {'type': 'string'} + }, + { + 'name': 'filter[blog.name.contains]', 'required': False, 'in': 'query', + 'description': 'blog__name__contains', 'schema': {'type': 'string'} + }, + ]), + (dja_filters.OrderingFilter, [ + { + 'name': 'sort', 'required': False, 'in': 'query', + 'description': 'Which field to use when ordering the results.', + 'schema': {'type': 'string'} + } + ]), + (drf_filters.SearchFilter, [ + { + 'name': 'filter[search]', 'required': False, 'in': 'query', + 'description': 'A search term.', + 'schema': {'type': 'string'} + } + ]), + ] + view = DummyEntryViewSet() + + for c, expected in filters: + f = c() + result = f.get_schema_operation_parameters(view) + assert len(result) == len(expected) + if len(result) == 0: + continue + # py35: the result list/dict ordering isn't guaranteed + for res_item in result: + assert 'name' in res_item + for exp_item in expected: + if res_item['name'] == exp_item['name']: + assert res_item == exp_item diff --git a/example/urls.py b/example/urls.py index 72788060..9b882ce5 100644 --- a/example/urls.py +++ b/example/urls.py @@ -1,6 +1,11 @@ from django.conf import settings from django.conf.urls import include, url +from django.urls import path +from django.views.generic import TemplateView from rest_framework import routers +from rest_framework.schemas import get_schema_view + +from rest_framework_json_api.schemas.openapi import SchemaGenerator from example.views import ( AuthorRelationshipView, @@ -63,11 +68,21 @@ url(r'^authors/(?P[^/.]+)/relationships/(?P\w+)$', AuthorRelationshipView.as_view(), name='author-relationships'), + path('openapi', get_schema_view( + title="Example API", + description="API for all things …", + version="1.0.0", + generator_class=SchemaGenerator + ), name='openapi-schema'), + path('swagger-ui/', TemplateView.as_view( + template_name='swagger-ui.html', + extra_context={'schema_url': 'openapi-schema'} + ), name='swagger-ui'), ] - if settings.DEBUG: import debug_toolbar + urlpatterns = [ url(r'^__debug__/', include(debug_toolbar.urls)), ] + urlpatterns diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index ac092e33..95f36814 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,2 +1,4 @@ django-filter==2.4.0 django-polymorphic==3.0.0 +pyyaml==5.3 +uritemplate==3.0.1 diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 29acfa5c..814a79f3 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -122,3 +122,17 @@ def get_filterset_kwargs(self, request, queryset, view): 'request': request, 'filter_keys': filter_keys, } + + def get_schema_operation_parameters(self, view): + """ + Convert backend filter `name` to JSON:API-style `filter[name]`. + For filters that are relationship paths, rewrite ORM-style `__` to our preferred `.`. + For example: `blog__name__contains` becomes `filter[blog.name.contains]`. + + This is basically the reverse of `get_filterset_kwargs` above. + """ + result = super(DjangoFilterBackend, self).get_schema_operation_parameters(view) + for res in result: + if 'name' in res: + res['name'] = 'filter[{}]'.format(res['name']).replace('__', '.') + return result diff --git a/rest_framework_json_api/schemas/__init__.py b/rest_framework_json_api/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py new file mode 100644 index 00000000..13bd8b2c --- /dev/null +++ b/rest_framework_json_api/schemas/openapi.py @@ -0,0 +1,878 @@ +import warnings +from urllib.parse import urljoin + +from django.db.models.fields import related_descriptors as rd +from django.utils.module_loading import import_string as import_class_from_dotted_path +from rest_framework.fields import empty +from rest_framework.relations import ManyRelatedField +from rest_framework.schemas import openapi as drf_openapi +from rest_framework.schemas.utils import is_list_view + +from rest_framework_json_api import serializers +from rest_framework_json_api.views import RelationshipView + + +class SchemaGenerator(drf_openapi.SchemaGenerator): + """ + Extend DRF's SchemaGenerator to implement jsonapi-flavored generateschema command. + """ + #: These JSONAPI component definitions are referenced by the generated OAS schema. + #: If you need to add more or change these static component definitions, extend this dict. + jsonapi_components = { + 'schemas': { + 'jsonapi': { + 'type': 'object', + 'description': "The server's implementation", + 'properties': { + 'version': {'type': 'string'}, + 'meta': {'$ref': '#/components/schemas/meta'} + }, + 'additionalProperties': False + }, + 'ResourceIdentifierObject': { + 'type': 'object', + 'required': ['type', 'id'], + 'additionalProperties': False, + 'properties': { + 'type': { + '$ref': '#/components/schemas/type' + }, + 'id': { + '$ref': '#/components/schemas/id' + }, + }, + }, + 'resource': { + 'type': 'object', + 'required': ['type', 'id'], + 'additionalProperties': False, + 'properties': { + 'type': { + '$ref': '#/components/schemas/type' + }, + 'id': { + '$ref': '#/components/schemas/id' + }, + 'attributes': { + 'type': 'object', + # ... + }, + 'relationships': { + 'type': 'object', + # ... + }, + 'links': { + '$ref': '#/components/schemas/links' + }, + 'meta': {'$ref': '#/components/schemas/meta'}, + } + }, + 'link': { + 'oneOf': [ + { + 'description': "a string containing the link's URL", + 'type': 'string', + 'format': 'uri-reference' + }, + { + 'type': 'object', + 'required': ['href'], + 'properties': { + 'href': { + 'description': "a string containing the link's URL", + 'type': 'string', + 'format': 'uri-reference' + }, + 'meta': {'$ref': '#/components/schemas/meta'} + } + } + ] + }, + 'links': { + 'type': 'object', + 'additionalProperties': {'$ref': '#/components/schemas/link'} + }, + 'reltoone': { + 'description': "a singular 'to-one' relationship", + 'type': 'object', + 'properties': { + 'links': {'$ref': '#/components/schemas/relationshipLinks'}, + 'data': {'$ref': '#/components/schemas/relationshipToOne'}, + 'meta': {'$ref': '#/components/schemas/meta'} + } + }, + 'relationshipToOne': { + 'description': "reference to other resource in a to-one relationship", + 'anyOf': [ + {'$ref': '#/components/schemas/nulltype'}, + {'$ref': '#/components/schemas/linkage'} + ], + }, + 'reltomany': { + 'description': "a multiple 'to-many' relationship", + 'type': 'object', + 'properties': { + 'links': {'$ref': '#/components/schemas/relationshipLinks'}, + 'data': {'$ref': '#/components/schemas/relationshipToMany'}, + 'meta': {'$ref': '#/components/schemas/meta'} + } + }, + 'relationshipLinks': { + 'description': 'optional references to other resource objects', + 'type': 'object', + 'additionalProperties': True, + 'properties': { + 'self': {'$ref': '#/components/schemas/link'}, + 'related': {'$ref': '#/components/schemas/link'} + } + }, + 'relationshipToMany': { + 'description': "An array of objects each containing the " + "'type' and 'id' for to-many relationships", + 'type': 'array', + 'items': {'$ref': '#/components/schemas/linkage'}, + 'uniqueItems': True + }, + 'linkage': { + 'type': 'object', + 'description': "the 'type' and 'id'", + 'required': ['type', 'id'], + 'properties': { + 'type': {'$ref': '#/components/schemas/type'}, + 'id': {'$ref': '#/components/schemas/id'}, + 'meta': {'$ref': '#/components/schemas/meta'} + } + }, + 'pagination': { + 'type': 'object', + 'properties': { + 'first': {'$ref': '#/components/schemas/pageref'}, + 'last': {'$ref': '#/components/schemas/pageref'}, + 'prev': {'$ref': '#/components/schemas/pageref'}, + 'next': {'$ref': '#/components/schemas/pageref'}, + } + }, + 'pageref': { + 'oneOf': [ + {'type': 'string', 'format': 'uri-reference'}, + {'$ref': '#/components/schemas/nulltype'} + ] + }, + 'failure': { + 'type': 'object', + 'required': ['errors'], + 'properties': { + 'errors': {'$ref': '#/components/schemas/errors'}, + 'meta': {'$ref': '#/components/schemas/meta'}, + 'jsonapi': {'$ref': '#/components/schemas/jsonapi'}, + 'links': {'$ref': '#/components/schemas/links'} + } + }, + 'errors': { + 'type': 'array', + 'items': {'$ref': '#/components/schemas/error'}, + 'uniqueItems': True + }, + 'error': { + 'type': 'object', + 'additionalProperties': False, + 'properties': { + 'id': {'type': 'string'}, + 'status': {'type': 'string'}, + 'links': {'$ref': '#/components/schemas/links'}, + 'code': {'type': 'string'}, + 'title': {'type': 'string'}, + 'detail': {'type': 'string'}, + 'source': { + 'type': 'object', + 'properties': { + 'pointer': { + 'type': 'string', + 'description': + "A [JSON Pointer](https://tools.ietf.org/html/rfc6901) " + "to the associated entity in the request document " + "[e.g. `/data` for a primary data object, or " + "`/data/attributes/title` for a specific attribute." + }, + 'parameter': { + 'type': 'string', + 'description': + "A string indicating which query parameter " + "caused the error." + }, + 'meta': {'$ref': '#/components/schemas/meta'} + } + } + } + }, + 'onlymeta': { + 'additionalProperties': False, + 'properties': { + 'meta': {'$ref': '#/components/schemas/meta'} + } + }, + 'meta': { + 'type': 'object', + 'additionalProperties': True + }, + 'datum': { + 'description': 'singular item', + 'properties': { + 'data': {'$ref': '#/components/schemas/resource'} + } + }, + 'nulltype': { + 'type': 'object', + 'nullable': True, + 'default': None + }, + 'type': { + 'type': 'string', + 'description': + 'The [type]' + '(https://jsonapi.org/format/#document-resource-object-identification) ' + 'member is used to describe resource objects that share common attributes ' + 'and relationships.' + }, + 'id': { + 'type': 'string', + 'description': + "Each resource object’s type and id pair MUST " + "[identify]" + "(https://jsonapi.org/format/#document-resource-object-identification) " + "a single, unique resource." + }, + }, + 'parameters': { + 'include': { + 'name': 'include', + 'in': 'query', + 'description': '[list of included related resources]' + '(https://jsonapi.org/format/#fetching-includes)', + 'required': False, + 'style': 'form', + 'schema': { + 'type': 'string' + } + }, + # TODO: deepObject not well defined/supported: + # https://github.com/OAI/OpenAPI-Specification/issues/1706 + 'fields': { + 'name': 'fields', + 'in': 'query', + 'description': '[sparse fieldsets]' + '(https://jsonapi.org/format/#fetching-sparse-fieldsets).\n' + 'Use fields[\\]=field1,field2,...,fieldN', + 'required': False, + 'style': 'deepObject', + 'schema': { + 'type': 'object', + 'properties': { + '': { # placeholder for actual type names + 'type': 'string' + } + } + }, + 'explode': True + }, + 'sort': { + 'name': 'sort', + 'in': 'query', + 'description': '[list of fields to sort by]' + '(https://jsonapi.org/format/#fetching-sorting)', + 'required': False, + 'style': 'form', + 'schema': { + 'type': 'string' + } + }, + }, + } + + def get_schema(self, request=None, public=False): + """ + Generate a JSONAPI OpenAPI schema. + Overrides upstream DRF's get_schema. + """ + # TODO: avoid copying so much of upstream get_schema() + schema = super().get_schema(request, public) + + components_schemas = {} + security_schemes_schemas = {} + + # Iterate endpoints generating per method path operations. + paths = {} + _, view_endpoints = self._get_paths_and_endpoints(None if public else request) + + #: `expanded_endpoints` is like view_endpoints with one extra field tacked on: + #: - 'action' copy of current view.action (list/fetch) as this gets reset for each request. + expanded_endpoints = [] + for path, method, view in view_endpoints: + if isinstance(view, RelationshipView): + expanded_endpoints += self._expand_relationships(path, method, view) + elif hasattr(view, 'action') and view.action == 'retrieve_related': + expanded_endpoints += self._expand_related(path, method, view, view_endpoints) + else: + expanded_endpoints.append((path, method, view, + view.action if hasattr(view, 'action') else None)) + + for path, method, view, action in expanded_endpoints: + if not self.has_view_permissions(path, method, view): + continue + # kludge to preserve view.action as it changes "globally" for the same ViewSet + # whether it is used for a collection, item or related serializer. _expand_related + # sets it based on whether the related field is a toMany collection or toOne item. + current_action = None + if hasattr(view, 'action'): + current_action = view.action + view.action = action + operation = view.schema.get_operation(path, method, action) + components = view.schema.get_components(path, method) + for k in components.keys(): + if k not in components_schemas: + continue + if components_schemas[k] == components[k]: + continue + warnings.warn( + 'Schema component "{}" has been overriden with a different value.'.format(k)) + + components_schemas.update(components) + + if hasattr(view.schema, 'get_security_schemes'): + security_schemes = view.schema.get_security_schemes(path, method) + else: + security_schemes = {} + for k in security_schemes.keys(): + if k not in security_schemes_schemas: + continue + if security_schemes_schemas[k] == security_schemes[k]: + continue + warnings.warn('Securit scheme component "{}" has been overriden with a different ' + 'value.'.format(k)) + security_schemes_schemas.update(security_schemes) + + if hasattr(view, 'action'): + view.action = current_action + # Normalise path for any provided mount url. + if path.startswith('/'): + path = path[1:] + path = urljoin(self.url or '/', path) + + paths.setdefault(path, {}) + paths[path][method.lower()] = operation + + self.check_duplicate_operation_id(paths) + + # Compile final schema, overriding stuff from super class. + schema['paths'] = paths + schema['components'] = self.jsonapi_components + schema['components']['schemas'].update(components_schemas) + if len(security_schemes_schemas) > 0: + schema['components']['securitySchemes'] = security_schemes_schemas + + return schema + + def _expand_relationships(self, path, method, view): + """ + Expand path containing .../{id}/relationships/{related_field} into list of related fields. + :return:list[tuple(path, method, view, action)] + """ + queryset = view.get_queryset() + if not queryset.model: + return [(path, method, view, getattr(view, 'action', '')), ] + result = [] + # TODO: what about serializer-only (non-model) fields? + # Shouldn't this be iterating over serializer fields rather than model fields? + # Look at parent view's serializer to get the list of fields. + # OR maybe like _expand_related? + m = queryset.model + for field in [f for f in dir(m) if not f.startswith('_')]: + attr = getattr(m, field) + if isinstance(attr, (rd.ReverseManyToOneDescriptor, rd.ForwardOneToOneDescriptor)): + action = 'rels' if isinstance(attr, rd.ReverseManyToOneDescriptor) else 'rel' + result.append((path.replace('{related_field}', field), method, view, action)) + + return result + + def _expand_related(self, path, method, view, view_endpoints): + """ + Expand path containing .../{id}/{related_field} into list of related fields + and **their** views, making sure toOne relationship's views are a 'fetch' and toMany + relationship's are a 'list'. + :param path + :param method + :param view + :param view_endpoints + :return:list[tuple(path, method, view, action)] + """ + result = [] + serializer = view.get_serializer() + # It's not obvious if it's allowed to have both included_ and related_ serializers, + # so just merge both dicts. + serializers = {} + if hasattr(serializer, 'included_serializers'): + serializers = {**serializers, **serializer.included_serializers} + if hasattr(serializer, 'related_serializers'): + serializers = {**serializers, **serializer.related_serializers} + related_fields = [fs for fs in serializers.items()] + + for field, related_serializer in related_fields: + related_view = self._find_related_view(view_endpoints, related_serializer, view) + if related_view: + action = self._field_is_one_or_many(field, view) + result.append( + (path.replace('{related_field}', field), method, related_view, action) + ) + + return result + + def _find_related_view(self, view_endpoints, related_serializer, parent_view): + """ + For a given related_serializer, try to find it's "parent" view instance in view_endpoints. + :param view_endpoints: list of all view endpoints + :param related_serializer: the related serializer for a given related field + :param parent_view: the parent view (used to find toMany vs. toOne). + TODO: not actually used. + :return:view + """ + for path, method, view in view_endpoints: + view_serializer = view.get_serializer() + if not isinstance(related_serializer, type): + related_serializer_class = import_class_from_dotted_path(related_serializer) + else: + related_serializer_class = related_serializer + if isinstance(view_serializer, related_serializer_class): + return view + + return None + + def _field_is_one_or_many(self, field, view): + serializer = view.get_serializer() + if isinstance(serializer.fields[field], ManyRelatedField): + return 'list' + else: + return 'fetch' + + +class AutoSchema(drf_openapi.AutoSchema): + """ + Extend DRF's openapi.AutoSchema for JSONAPI serialization. + """ + #: ignore all the media types and only generate a JSONAPI schema. + content_types = ['application/vnd.api+json'] + + def get_operation(self, path, method, action=None): + """ + JSONAPI adds some standard fields to the API response that are not in upstream DRF: + - some that only apply to GET/HEAD methods. + - collections + - special handling for POST, PATCH, DELETE: + + :param action: One of the usual actions for a conventional path (list, retrieve, update, + partial_update, destroy) or special case 'rel' or 'rels' for a singular or + plural relationship. + """ + operation = {} + operation['operationId'] = self.get_operation_id(path, method) + operation['description'] = self.get_description(path, method) + if hasattr(self, 'get_security_requirements'): + security = self.get_security_requirements(path, method) + if security is not None: + operation['security'] = security + + parameters = [] + parameters += self.get_path_parameters(path, method) + # pagination, filters only apply to GET/HEAD of collections and items + if method in ['GET', 'HEAD']: + parameters += self._get_include_parameters(path, method) + parameters += self._get_fields_parameters(path, method) + parameters += self._get_sort_parameters(path, method) + parameters += self.get_pagination_parameters(path, method) + parameters += self.get_filter_parameters(path, method) + operation['parameters'] = parameters + + # get request and response code schemas + if method == 'GET': + if is_list_view(path, method, self.view): + self._get_collection_response(operation) + else: + self._get_item_response(operation) + elif method == 'POST': + self._post_item_response(operation, path, action) + elif method == 'PATCH': + self._patch_item_response(operation, path, action) + elif method == 'DELETE': + # should only allow deleting a resource, not a collection + # TODO: implement delete of a relationship in future release. + self._delete_item_response(operation, path, action) + return operation + + def get_operation_id(self, path, method): + """ + The upstream DRF version creates non-unique operationIDs, because the same view is + used for the main path as well as such as related and relationships. + This concatenates the (mapped) method name and path as the spec allows most any + """ + method_name = getattr(self.view, 'action', method.lower()) + if is_list_view(path, method, self.view): + action = 'List' + elif method_name not in self.method_mapping: + action = method_name + else: + action = self.method_mapping[method.lower()] + return action + path + + def _get_include_parameters(self, path, method): + """ + includes parameter: https://jsonapi.org/format/#fetching-includes + """ + return [{'$ref': '#/components/parameters/include'}] + + def _get_fields_parameters(self, path, method): + """ + sparse fieldsets https://jsonapi.org/format/#fetching-sparse-fieldsets + """ + # TODO: See if able to identify the specific types for fields[type]=... and return this: + # name: fields + # in: query + # description: '[sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets)' + # required: true + # style: deepObject + # schema: + # type: object + # properties: + # hello: + # type: string # noqa F821 + # world: + # type: string # noqa F821 + # explode: true + return [{'$ref': '#/components/parameters/fields'}] + + def _get_sort_parameters(self, path, method): + """ + sort parameter: https://jsonapi.org/format/#fetching-sorting + """ + return [{'$ref': '#/components/parameters/sort'}] + + def _get_collection_response(self, operation): + """ + jsonapi-structured 200 response for GET of a collection + """ + operation['responses'] = { + '200': self._get_toplevel_200_response(operation, collection=True) + } + self._add_get_4xx_responses(operation) + + def _get_item_response(self, operation): + """ + jsonapi-structured 200 response for GET of an item + """ + operation['responses'] = { + '200': self._get_toplevel_200_response(operation, collection=False) + } + self._add_get_4xx_responses(operation) + + def _get_toplevel_200_response(self, operation, collection=True): + """ + top-level JSONAPI GET 200 response + + :param collection: True for collections; False for individual items. + + Uses a $ref to the components.schemas. component definition. + """ + if collection: + data = {'type': 'array', 'items': self._get_reference(self.view.get_serializer())} + else: + data = self._get_reference(self.view.get_serializer()) + + return { + 'description': operation['operationId'], + 'content': { + 'application/vnd.api+json': { + 'schema': { + 'type': 'object', + 'required': ['data'], + 'properties': { + 'data': data, + 'included': { + 'type': 'array', + 'uniqueItems': True, + 'items': { + '$ref': '#/components/schemas/resource' + } + }, + 'links': { + 'description': 'Link members related to primary data', + 'allOf': [ + {'$ref': '#/components/schemas/links'}, + {'$ref': '#/components/schemas/pagination'} + ] + }, + 'jsonapi': { + '$ref': '#/components/schemas/jsonapi' + } + } + } + } + } + } + + def _post_item_response(self, operation, path, action): + """ + jsonapi-structured response for POST of an item + """ + operation['requestBody'] = self.get_request_body(path, 'POST', action) + operation['responses'] = { + '201': self._get_toplevel_200_response(operation, collection=False) + } + operation['responses']['201']['description'] = ( + '[Created](https://jsonapi.org/format/#crud-creating-responses-201). ' + 'Assigned `id` and/or any other changes are in this response.' + ) + self._add_async_response(operation) + operation['responses']['204'] = { + 'description': '[Created](https://jsonapi.org/format/#crud-creating-responses-204) ' + 'with the supplied `id`. No other changes from what was POSTed.' + } + self._add_post_4xx_responses(operation) + + def _patch_item_response(self, operation, path, action): + """ + jsonapi-structured response for PATCH of an item + """ + operation['requestBody'] = self.get_request_body(path, 'PATCH', action) + operation['responses'] = { + '200': self._get_toplevel_200_response(operation, collection=False) + } + self._add_patch_4xx_responses(operation) + + def _delete_item_response(self, operation, path, action): + """ + jsonapi-structured response for DELETE of an item or relationship(s) + """ + # Only DELETE of relationships has a requestBody + if action in ['rels', 'rel']: + operation['requestBody'] = self.get_request_body(path, 'DELETE', action) + self._add_delete_responses(operation) + + def get_request_body(self, path, method, action=None): + """ + A request body is required by jsonapi for POST, PATCH, and DELETE methods. + This has an added parameter which is not in upstream DRF: + + :param action: None for conventional path; 'rel' or 'rels' for a singular or plural + relationship of a related path, respectively. + """ + serializer = self.get_serializer(path, method) + if not isinstance(serializer, (serializers.BaseSerializer, )): + return {} + + # DRF uses a $ref to the component definition, but this + # doesn't work for jsonapi due to the different required fields based on + # the method, so make those changes and inline another copy of the schema. + # TODO: A future improvement could make this DRYer with multiple components? + item_schema = self.map_serializer(serializer).copy() + + # 'type' and 'id' are both required for: + # - all relationship operations + # - regular PATCH or DELETE + # Only 'type' is required for POST: system may assign the 'id'. + if action in ['rels', 'rel']: + item_schema['required'] = ['type', 'id'] + elif method in ['PATCH', 'DELETE']: + item_schema['required'] = ['type', 'id'] + elif method == 'POST': + item_schema['required'] = ['type'] + + if 'attributes' in item_schema['properties']: + # No required attributes for PATCH + if method in ['PATCH', 'PUT'] and 'required' in item_schema['properties']['attributes']: + del item_schema['properties']['attributes']['required'] + # No read_only fields for request. + for name, schema in item_schema['properties']['attributes']['properties'].copy().items(): # noqa E501 + if 'readOnly' in schema: + del item_schema['properties']['attributes']['properties'][name] + # relationships special case: plural request body (data is array of items) + if action == 'rels': + return { + 'content': { + ct: { + 'schema': { + 'required': ['data'], + 'properties': { + 'data': { + 'type': 'array', + 'items': item_schema + } + } + } + } + for ct in self.content_types + } + } + # singular request body for all other cases + else: + return { + 'content': { + ct: { + 'schema': { + 'required': ['data'], + 'properties': { + 'data': item_schema + } + } + } + for ct in self.content_types + } + } + + def map_serializer(self, serializer): + """ + Custom map_serializer that serializes the schema using the jsonapi spec. + Non-attributes like related and identity fields, are move to 'relationships' and 'links'. + """ + # TODO: remove attributes, etc. for relationshipView?? + required = [] + attributes = {} + relationships = {} + + for field in serializer.fields.values(): + if isinstance(field, serializers.HyperlinkedIdentityField): + # the 'url' is not an attribute but rather a self.link, so don't map it here. + continue + if isinstance(field, serializers.HiddenField): + continue + if isinstance(field, serializers.RelatedField): + relationships[field.field_name] = {'$ref': '#/components/schemas/reltoone'} + continue + if isinstance(field, serializers.ManyRelatedField): + relationships[field.field_name] = {'$ref': '#/components/schemas/reltomany'} + continue + + if field.required: + required.append(field.field_name) + + schema = self.map_field(field) + if field.read_only: + schema['readOnly'] = True + if field.write_only: + schema['writeOnly'] = True + if field.allow_null: + schema['nullable'] = True + if field.default and field.default != empty: + schema['default'] = field.default + if field.help_text: + # Ensure django gettext_lazy is rendered correctly + schema['description'] = str(field.help_text) + self.map_field_validators(field, schema) + + attributes[field.field_name] = schema + + result = { + 'type': 'object', + 'required': ['type', 'id'], + 'additionalProperties': False, + 'properties': { + 'type': {'$ref': '#/components/schemas/type'}, + 'id': {'$ref': '#/components/schemas/id'}, + 'links': { + 'type': 'object', + 'properties': { + 'self': {'$ref': '#/components/schemas/link'} + } + } + } + } + if attributes: + result['properties']['attributes'] = { + 'type': 'object', + 'properties': attributes + } + if required: + result['properties']['attributes']['required'] = required + + if relationships: + result['properties']['relationships'] = { + 'type': 'object', + 'properties': relationships + } + return result + + def _add_async_response(self, operation): + operation['responses']['202'] = { + 'description': 'Accepted for [asynchronous processing]' + '(https://jsonapi.org/recommendations/#asynchronous-processing)', + 'content': { + 'application/vnd.api+json': { + 'schema': {'$ref': '#/components/schemas/datum'} + } + } + } + + def _failure_response(self, reason): + return { + 'description': reason, + 'content': { + 'application/vnd.api+json': { + 'schema': {'$ref': '#/components/schemas/failure'} + } + } + } + + def _generic_failure_responses(self, operation): + for code, reason in [('401', 'not authorized'), ]: + operation['responses'][code] = self._failure_response(reason) + + def _add_get_4xx_responses(self, operation): + """ Add generic responses for get """ + self._generic_failure_responses(operation) + for code, reason in [('404', 'not found')]: + operation['responses'][code] = self._failure_response(reason) + + def _add_post_4xx_responses(self, operation): + """ Add error responses for post """ + self._generic_failure_responses(operation) + for code, reason in [ + ('403', '[Forbidden](https://jsonapi.org/format/#crud-creating-responses-403)'), + ('404', '[Related resource does not exist]' + '(https://jsonapi.org/format/#crud-creating-responses-404)'), + ('409', '[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)'), + ]: + operation['responses'][code] = self._failure_response(reason) + + def _add_patch_4xx_responses(self, operation): + """ Add error responses for patch """ + self._generic_failure_responses(operation) + for code, reason in [ + ('403', '[Forbidden](https://jsonapi.org/format/#crud-updating-responses-403)'), + ('404', '[Related resource does not exist]' + '(https://jsonapi.org/format/#crud-updating-responses-404)'), + ('409', '[Conflict]([Conflict]' + '(https://jsonapi.org/format/#crud-updating-responses-409)'), + ]: + operation['responses'][code] = self._failure_response(reason) + + def _add_delete_responses(self, operation): + """ Add generic responses for delete """ + # the 2xx statuses: + operation['responses'] = { + '200': { + 'description': '[OK](https://jsonapi.org/format/#crud-deleting-responses-200)', + 'content': { + 'application/vnd.api+json': { + 'schema': {'$ref': '#/components/schemas/onlymeta'} + } + } + } + } + self._add_async_response(operation) + operation['responses']['204'] = { + 'description': '[no content](https://jsonapi.org/format/#crud-deleting-responses-204)', + } + # the 4xx errors: + self._generic_failure_responses(operation) + for code, reason in [ + ('404', '[Resource does not exist]' + '(https://jsonapi.org/format/#crud-deleting-responses-404)'), + ]: + operation['responses'][code] = self._failure_response(reason) diff --git a/setup.cfg b/setup.cfg index d8247c1d..040c8e74 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,6 +16,7 @@ exclude = .tox, env .venv + example/tests/snapshots [isort] indent = 4 diff --git a/setup.py b/setup.py index 67fc22d1..dd3813c3 100755 --- a/setup.py +++ b/setup.py @@ -95,7 +95,8 @@ def get_package_data(package): ], extras_require={ 'django-polymorphic': ['django-polymorphic>=2.0'], - 'django-filter': ['django-filter>=2.0'] + 'django-filter': ['django-filter>=2.0'], + 'openapi': ['pyyaml>=5.3', 'uritemplate>=3.0.1'] }, setup_requires=wheel, python_requires=">=3.6", From e715638914bd440e5f4760e39ac8f12d39747e39 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Mon, 5 Oct 2020 19:53:23 -0400 Subject: [PATCH 02/15] my security schemes PR didn't make it into DRF 3.12 --- rest_framework_json_api/schemas/openapi.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 13bd8b2c..3f44cc3e 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -338,18 +338,18 @@ def get_schema(self, request=None, public=False): components_schemas.update(components) - if hasattr(view.schema, 'get_security_schemes'): + if hasattr(view.schema, 'get_security_schemes'): # pragma: no cover security_schemes = view.schema.get_security_schemes(path, method) else: security_schemes = {} - for k in security_schemes.keys(): + for k in security_schemes.keys(): # pragma: no cover if k not in security_schemes_schemas: continue if security_schemes_schemas[k] == security_schemes[k]: continue warnings.warn('Securit scheme component "{}" has been overriden with a different ' 'value.'.format(k)) - security_schemes_schemas.update(security_schemes) + security_schemes_schemas.update(security_schemes) # pragma: no cover if hasattr(view, 'action'): view.action = current_action @@ -367,7 +367,7 @@ def get_schema(self, request=None, public=False): schema['paths'] = paths schema['components'] = self.jsonapi_components schema['components']['schemas'].update(components_schemas) - if len(security_schemes_schemas) > 0: + if len(security_schemes_schemas) > 0: # pragma: no cover schema['components']['securitySchemes'] = security_schemes_schemas return schema @@ -475,7 +475,7 @@ def get_operation(self, path, method, action=None): operation = {} operation['operationId'] = self.get_operation_id(path, method) operation['description'] = self.get_description(path, method) - if hasattr(self, 'get_security_requirements'): + if hasattr(self, 'get_security_requirements'): # pragma: no cover security = self.get_security_requirements(path, method) if security is not None: operation['security'] = security From 28be828228483478c604fdec6a3a1283dcb9229f Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Thu, 8 Oct 2020 11:54:13 -0400 Subject: [PATCH 03/15] remove fields deepObject placeholder - gets added e.g. in swagger-ui and breaks queries --- rest_framework_json_api/schemas/openapi.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 3f44cc3e..b79dbebc 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -267,11 +267,6 @@ class SchemaGenerator(drf_openapi.SchemaGenerator): 'style': 'deepObject', 'schema': { 'type': 'object', - 'properties': { - '': { # placeholder for actual type names - 'type': 'string' - } - } }, 'explode': True }, From de2bece76700e0a3a37ec80659b47d62d4d84156 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Thu, 8 Oct 2020 12:04:32 -0400 Subject: [PATCH 04/15] make it clear this is an initial openapi release --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 388d1d35..dfe9e60a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ This release is not backwards compatible. For easy migration best upgrade first ### Removed + * Removed support for Python 3.5. * Removed support for Django 1.11. * Removed support for Django 2.1. From 8273f48ffc4ba5150478d7ad5b5e55afafcbd045 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Thu, 8 Oct 2020 13:45:10 -0400 Subject: [PATCH 05/15] getattr for improved readability --- rest_framework_json_api/schemas/openapi.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index b79dbebc..77aae3ab 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -308,8 +308,7 @@ def get_schema(self, request=None, public=False): elif hasattr(view, 'action') and view.action == 'retrieve_related': expanded_endpoints += self._expand_related(path, method, view, view_endpoints) else: - expanded_endpoints.append((path, method, view, - view.action if hasattr(view, 'action') else None)) + expanded_endpoints.append((path, method, view, getattr(view, 'action', None))) for path, method, view, action in expanded_endpoints: if not self.has_view_permissions(path, method, view): From ae223d46af95588839875521671d389e2f1c0ed9 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Thu, 8 Oct 2020 13:48:45 -0400 Subject: [PATCH 06/15] remove security objects until upstream https://github.com/encode/django-rest-framework/pull/7516 is merged --- rest_framework_json_api/schemas/openapi.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 77aae3ab..4d16ceec 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -293,7 +293,6 @@ def get_schema(self, request=None, public=False): schema = super().get_schema(request, public) components_schemas = {} - security_schemes_schemas = {} # Iterate endpoints generating per method path operations. paths = {} @@ -332,19 +331,6 @@ def get_schema(self, request=None, public=False): components_schemas.update(components) - if hasattr(view.schema, 'get_security_schemes'): # pragma: no cover - security_schemes = view.schema.get_security_schemes(path, method) - else: - security_schemes = {} - for k in security_schemes.keys(): # pragma: no cover - if k not in security_schemes_schemas: - continue - if security_schemes_schemas[k] == security_schemes[k]: - continue - warnings.warn('Securit scheme component "{}" has been overriden with a different ' - 'value.'.format(k)) - security_schemes_schemas.update(security_schemes) # pragma: no cover - if hasattr(view, 'action'): view.action = current_action # Normalise path for any provided mount url. @@ -361,8 +347,6 @@ def get_schema(self, request=None, public=False): schema['paths'] = paths schema['components'] = self.jsonapi_components schema['components']['schemas'].update(components_schemas) - if len(security_schemes_schemas) > 0: # pragma: no cover - schema['components']['securitySchemes'] = security_schemes_schemas return schema @@ -469,10 +453,6 @@ def get_operation(self, path, method, action=None): operation = {} operation['operationId'] = self.get_operation_id(path, method) operation['description'] = self.get_description(path, method) - if hasattr(self, 'get_security_requirements'): # pragma: no cover - security = self.get_security_requirements(path, method) - if security is not None: - operation['security'] = security parameters = [] parameters += self.get_path_parameters(path, method) From abd9c5127ec160476ec9d233d1c1b65018260671 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sun, 11 Oct 2020 11:56:32 -0400 Subject: [PATCH 07/15] no need for duplicated snapshots exclusion --- setup.cfg | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 040c8e74..d8247c1d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,7 +16,6 @@ exclude = .tox, env .venv - example/tests/snapshots [isort] indent = 4 From 61c27e031f838a954ec3e916a5f6ac415bfd8468 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sun, 11 Oct 2020 11:59:49 -0400 Subject: [PATCH 08/15] DRF 3.12 is now the minimum --- example/tests/test_openapi.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index 85fb4458..44381914 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -102,15 +102,6 @@ def test_delete_request(snapshot): {'delete': 'delete'} ) inspector = AutoSchema() - # DRF >=3.12 changes the capitalization of these method mappings which breaks the snapshot, - # so just override them to be consistent with >=3.12 - inspector.method_mapping = { - 'get': 'retrieve', - 'post': 'create', - 'put': 'update', - 'patch': 'partialUpdate', - 'delete': 'destroy', - } inspector.view = view operation = inspector.get_operation(path, method) From c8ea9237a6db5d9f316b3c66824d0c1dff742cd9 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Mon, 12 Oct 2020 09:44:59 -0400 Subject: [PATCH 09/15] use pytest style: remove superfluous TestBase --- example/tests/test_openapi.py | 62 +++++++++++++++++------------------ 1 file changed, 30 insertions(+), 32 deletions(-) diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index 44381914..f7acfbe6 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -8,7 +8,6 @@ from rest_framework_json_api.schemas.openapi import AutoSchema, SchemaGenerator from example import views -from example.tests import TestBase def create_request(path): @@ -126,34 +125,33 @@ def test_schema_construction(): assert 'components' in schema -class TestSchemaRelatedField(TestBase): - def test_schema_related_serializers(self): - """ - Confirm that paths are generated for related fields. For example: - url path '/authors/{pk}/{related_field>}/' generates: - /authors/{id}/relationships/comments/ - /authors/{id}/relationships/entries/ - /authors/{id}/relationships/first_entry/ -- Maybe? - /authors/{id}/comments/ - /authors/{id}/entries/ - /authors/{id}/first_entry/ - and confirm that the schema for the related field is properly rendered - """ - generator = SchemaGenerator() - request = create_request('/') - schema = generator.get_schema(request=request) - # make sure the path's relationship and related {related_field}'s got expanded - assert '/authors/{id}/relationships/entries' in schema['paths'] - assert '/authors/{id}/relationships/comments' in schema['paths'] - # first_entry is a special case (SerializerMethodRelatedField) - # TODO: '/authors/{id}/relationships/first_entry' supposed to be there? - # It fails when doing the actual GET, so this schema excluding it is OK. - # assert '/authors/{id}/relationships/first_entry/' in schema['paths'] - assert '/authors/{id}/comments/' in schema['paths'] - assert '/authors/{id}/entries/' in schema['paths'] - assert '/authors/{id}/first_entry/' in schema['paths'] - first_get = schema['paths']['/authors/{id}/first_entry/']['get']['responses']['200'] - first_schema = first_get['content']['application/vnd.api+json']['schema'] - first_props = first_schema['properties']['data'] - assert '$ref' in first_props - assert first_props['$ref'] == '#/components/schemas/Entry' +def test_schema_related_serializers(): + """ + Confirm that paths are generated for related fields. For example: + url path '/authors/{pk}/{related_field>}/' generates: + /authors/{id}/relationships/comments/ + /authors/{id}/relationships/entries/ + /authors/{id}/relationships/first_entry/ -- Maybe? + /authors/{id}/comments/ + /authors/{id}/entries/ + /authors/{id}/first_entry/ + and confirm that the schema for the related field is properly rendered + """ + generator = SchemaGenerator() + request = create_request('/') + schema = generator.get_schema(request=request) + # make sure the path's relationship and related {related_field}'s got expanded + assert '/authors/{id}/relationships/entries' in schema['paths'] + assert '/authors/{id}/relationships/comments' in schema['paths'] + # first_entry is a special case (SerializerMethodRelatedField) + # TODO: '/authors/{id}/relationships/first_entry' supposed to be there? + # It fails when doing the actual GET, so this schema excluding it is OK. + # assert '/authors/{id}/relationships/first_entry/' in schema['paths'] + assert '/authors/{id}/comments/' in schema['paths'] + assert '/authors/{id}/entries/' in schema['paths'] + assert '/authors/{id}/first_entry/' in schema['paths'] + first_get = schema['paths']['/authors/{id}/first_entry/']['get']['responses']['200'] + first_schema = first_get['content']['application/vnd.api+json']['schema'] + first_props = first_schema['properties']['data'] + assert '$ref' in first_props + assert first_props['$ref'] == '#/components/schemas/Entry' From 5594691db4ee548e1702197cd68f1b74cd3cdc2d Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Mon, 12 Oct 2020 09:47:48 -0400 Subject: [PATCH 10/15] copy() not necessary --- rest_framework_json_api/schemas/openapi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 4d16ceec..004cc972 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -645,7 +645,7 @@ def get_request_body(self, path, method, action=None): # doesn't work for jsonapi due to the different required fields based on # the method, so make those changes and inline another copy of the schema. # TODO: A future improvement could make this DRYer with multiple components? - item_schema = self.map_serializer(serializer).copy() + item_schema = self.map_serializer(serializer) # 'type' and 'id' are both required for: # - all relationship operations From 0ea816e1965ecdb8f77329a52988b4df52f49b28 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Mon, 12 Oct 2020 15:05:00 -0400 Subject: [PATCH 11/15] simplify relationships paths --- example/tests/test_openapi.py | 12 ++-------- rest_framework_json_api/schemas/openapi.py | 28 +--------------------- 2 files changed, 3 insertions(+), 37 deletions(-) diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index f7acfbe6..e7a2b6ca 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -128,10 +128,7 @@ def test_schema_construction(): def test_schema_related_serializers(): """ Confirm that paths are generated for related fields. For example: - url path '/authors/{pk}/{related_field>}/' generates: - /authors/{id}/relationships/comments/ - /authors/{id}/relationships/entries/ - /authors/{id}/relationships/first_entry/ -- Maybe? + /authors/{pk}/{related_field>} /authors/{id}/comments/ /authors/{id}/entries/ /authors/{id}/first_entry/ @@ -141,12 +138,7 @@ def test_schema_related_serializers(): request = create_request('/') schema = generator.get_schema(request=request) # make sure the path's relationship and related {related_field}'s got expanded - assert '/authors/{id}/relationships/entries' in schema['paths'] - assert '/authors/{id}/relationships/comments' in schema['paths'] - # first_entry is a special case (SerializerMethodRelatedField) - # TODO: '/authors/{id}/relationships/first_entry' supposed to be there? - # It fails when doing the actual GET, so this schema excluding it is OK. - # assert '/authors/{id}/relationships/first_entry/' in schema['paths'] + assert '/authors/{id}/relationships/{related_field}' in schema['paths'] assert '/authors/{id}/comments/' in schema['paths'] assert '/authors/{id}/entries/' in schema['paths'] assert '/authors/{id}/first_entry/' in schema['paths'] diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 004cc972..774d4ff4 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -1,7 +1,6 @@ import warnings from urllib.parse import urljoin -from django.db.models.fields import related_descriptors as rd from django.utils.module_loading import import_string as import_class_from_dotted_path from rest_framework.fields import empty from rest_framework.relations import ManyRelatedField @@ -9,7 +8,6 @@ from rest_framework.schemas.utils import is_list_view from rest_framework_json_api import serializers -from rest_framework_json_api.views import RelationshipView class SchemaGenerator(drf_openapi.SchemaGenerator): @@ -302,9 +300,7 @@ def get_schema(self, request=None, public=False): #: - 'action' copy of current view.action (list/fetch) as this gets reset for each request. expanded_endpoints = [] for path, method, view in view_endpoints: - if isinstance(view, RelationshipView): - expanded_endpoints += self._expand_relationships(path, method, view) - elif hasattr(view, 'action') and view.action == 'retrieve_related': + if hasattr(view, 'action') and view.action == 'retrieve_related': expanded_endpoints += self._expand_related(path, method, view, view_endpoints) else: expanded_endpoints.append((path, method, view, getattr(view, 'action', None))) @@ -350,28 +346,6 @@ def get_schema(self, request=None, public=False): return schema - def _expand_relationships(self, path, method, view): - """ - Expand path containing .../{id}/relationships/{related_field} into list of related fields. - :return:list[tuple(path, method, view, action)] - """ - queryset = view.get_queryset() - if not queryset.model: - return [(path, method, view, getattr(view, 'action', '')), ] - result = [] - # TODO: what about serializer-only (non-model) fields? - # Shouldn't this be iterating over serializer fields rather than model fields? - # Look at parent view's serializer to get the list of fields. - # OR maybe like _expand_related? - m = queryset.model - for field in [f for f in dir(m) if not f.startswith('_')]: - attr = getattr(m, field) - if isinstance(attr, (rd.ReverseManyToOneDescriptor, rd.ForwardOneToOneDescriptor)): - action = 'rels' if isinstance(attr, rd.ReverseManyToOneDescriptor) else 'rel' - result.append((path.replace('{related_field}', field), method, view, action)) - - return result - def _expand_related(self, path, method, view, view_endpoints): """ Expand path containing .../{id}/{related_field} into list of related fields From 71777da42c248c81c86c3728e29005612d7255c2 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Tue, 13 Oct 2020 13:19:03 -0400 Subject: [PATCH 12/15] Revert "simplify relationships paths" This reverts commit 5855b65e2f379598e4cba88dfe0514d54c91a7e7. --- example/tests/test_openapi.py | 12 ++++++++-- rest_framework_json_api/schemas/openapi.py | 28 +++++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index e7a2b6ca..f7acfbe6 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -128,7 +128,10 @@ def test_schema_construction(): def test_schema_related_serializers(): """ Confirm that paths are generated for related fields. For example: - /authors/{pk}/{related_field>} + url path '/authors/{pk}/{related_field>}/' generates: + /authors/{id}/relationships/comments/ + /authors/{id}/relationships/entries/ + /authors/{id}/relationships/first_entry/ -- Maybe? /authors/{id}/comments/ /authors/{id}/entries/ /authors/{id}/first_entry/ @@ -138,7 +141,12 @@ def test_schema_related_serializers(): request = create_request('/') schema = generator.get_schema(request=request) # make sure the path's relationship and related {related_field}'s got expanded - assert '/authors/{id}/relationships/{related_field}' in schema['paths'] + assert '/authors/{id}/relationships/entries' in schema['paths'] + assert '/authors/{id}/relationships/comments' in schema['paths'] + # first_entry is a special case (SerializerMethodRelatedField) + # TODO: '/authors/{id}/relationships/first_entry' supposed to be there? + # It fails when doing the actual GET, so this schema excluding it is OK. + # assert '/authors/{id}/relationships/first_entry/' in schema['paths'] assert '/authors/{id}/comments/' in schema['paths'] assert '/authors/{id}/entries/' in schema['paths'] assert '/authors/{id}/first_entry/' in schema['paths'] diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 774d4ff4..004cc972 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -1,6 +1,7 @@ import warnings from urllib.parse import urljoin +from django.db.models.fields import related_descriptors as rd from django.utils.module_loading import import_string as import_class_from_dotted_path from rest_framework.fields import empty from rest_framework.relations import ManyRelatedField @@ -8,6 +9,7 @@ from rest_framework.schemas.utils import is_list_view from rest_framework_json_api import serializers +from rest_framework_json_api.views import RelationshipView class SchemaGenerator(drf_openapi.SchemaGenerator): @@ -300,7 +302,9 @@ def get_schema(self, request=None, public=False): #: - 'action' copy of current view.action (list/fetch) as this gets reset for each request. expanded_endpoints = [] for path, method, view in view_endpoints: - if hasattr(view, 'action') and view.action == 'retrieve_related': + if isinstance(view, RelationshipView): + expanded_endpoints += self._expand_relationships(path, method, view) + elif hasattr(view, 'action') and view.action == 'retrieve_related': expanded_endpoints += self._expand_related(path, method, view, view_endpoints) else: expanded_endpoints.append((path, method, view, getattr(view, 'action', None))) @@ -346,6 +350,28 @@ def get_schema(self, request=None, public=False): return schema + def _expand_relationships(self, path, method, view): + """ + Expand path containing .../{id}/relationships/{related_field} into list of related fields. + :return:list[tuple(path, method, view, action)] + """ + queryset = view.get_queryset() + if not queryset.model: + return [(path, method, view, getattr(view, 'action', '')), ] + result = [] + # TODO: what about serializer-only (non-model) fields? + # Shouldn't this be iterating over serializer fields rather than model fields? + # Look at parent view's serializer to get the list of fields. + # OR maybe like _expand_related? + m = queryset.model + for field in [f for f in dir(m) if not f.startswith('_')]: + attr = getattr(m, field) + if isinstance(attr, (rd.ReverseManyToOneDescriptor, rd.ForwardOneToOneDescriptor)): + action = 'rels' if isinstance(attr, rd.ReverseManyToOneDescriptor) else 'rel' + result.append((path.replace('{related_field}', field), method, view, action)) + + return result + def _expand_related(self, path, method, view, view_endpoints): """ Expand path containing .../{id}/{related_field} into list of related fields From 7a6a08852f70788d315c405f4e815133e11c29a7 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Wed, 14 Oct 2020 11:25:33 -0400 Subject: [PATCH 13/15] documentation corrections for openapi --- README.rst | 5 ++++- docs/getting-started.md | 5 ++++- docs/usage.md | 28 +++++++++++++++++----------- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/README.rst b/README.rst index 8edc421e..6dddc08d 100644 --- a/README.rst +++ b/README.rst @@ -136,7 +136,10 @@ installed and activated: $ django-admin loaddata drf_example --settings=example.settings $ django-admin runserver --settings=example.settings -Browse to http://localhost:8000 +Browse to +* http://localhost:8000 for the list of available collections (in a non-JSONAPI format!), +* http://localhost:8000/swagger-ui/ for a Swagger user interface to the dynamic schema view, or +* http://localhost:8000/openapi for the schema view's OpenAPI specification document. Running Tests and linting diff --git a/docs/getting-started.md b/docs/getting-started.md index bd7b460f..da434b5e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -86,7 +86,10 @@ From Source django-admin runserver --settings=example.settings -Browse to http://localhost:8000 +Browse to +* [http://localhost:8000](http://localhost:8000) for the list of available collections (in a non-JSONAPI format!), +* [http://localhost:8000/swagger-ui/](http://localhost:8000/swagger-ui/) for a Swagger user interface to the dynamic schema view, or +* [http://localhost:8000/openapi](http://localhost:8000/openapi) for the schema view's OpenAPI specification document. ## Running Tests diff --git a/docs/usage.md b/docs/usage.md index fc50fcaf..c091cd4d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -958,8 +958,6 @@ In order to produce an OAS schema that properly represents the JSON:API structur you have to either add a `schema` attribute to each view class or set the `REST_FRAMEWORK['DEFAULT_SCHEMA_CLASS']` to DJA's version of AutoSchema. -You can also extend the OAS schema with additional static content (a feature not available in DRF at this time). - #### View-based ```python @@ -979,16 +977,16 @@ REST_FRAMEWORK = { } ``` -### Adding static OAS schema content +### Adding additional OAS schema content -You can optionally include an OAS schema document initialization by subclassing `SchemaGenerator` -and setting `schema_init`. +You can extend the OAS schema document by subclassing +[`SchemaGenerator`](https://www.django-rest-framework.org/api-guide/schemas/#schemagenerator) +and extending `get_schema`. -Here's an example that fills out OAS `info` and `servers` objects. -```python -# views.py +Here's an example that adds OAS `info` and `servers` objects. +```python from rest_framework_json_api.schemas.openapi import SchemaGenerator as JSONAPISchemaGenerator @@ -1047,14 +1045,22 @@ is published. ### Generate a Dynamic Schema in a View See [DRF documentation for a Dynamic Schema](https://www.django-rest-framework.org/api-guide/schemas/#generating-a-dynamic-schema-with-schemaview). -You will need to pass in your custom SchemaGenerator if you've created one. ```python from rest_framework.schemas import get_schema_view -from views import MySchemaGenerator urlpatterns = [ - path('openapi', get_schema_view(generator_class=MySchemaGenerator), name='openapi-schema'), + ... + path('openapi', get_schema_view( + title="Example API", + description="API for all things …", + version="1.0.0", + generator_class=MySchemaGenerator, + ), name='openapi-schema'), + path('swagger-ui/', TemplateView.as_view( + template_name='swagger-ui.html', + extra_context={'schema_url': 'openapi-schema'} + ), name='swagger-ui'), ... ] ``` From 407e119ced5d99752b10be6cac3a4a06df5803ae Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Thu, 15 Oct 2020 13:39:01 -0400 Subject: [PATCH 14/15] rename mutable private methods and improve their docstrings --- rest_framework_json_api/schemas/openapi.py | 67 ++++++++++++++-------- 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 004cc972..65c5e58e 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -468,17 +468,17 @@ def get_operation(self, path, method, action=None): # get request and response code schemas if method == 'GET': if is_list_view(path, method, self.view): - self._get_collection_response(operation) + self._add_get_collection_response(operation) else: - self._get_item_response(operation) + self._add_get_item_response(operation) elif method == 'POST': - self._post_item_response(operation, path, action) + self._add_post_item_response(operation, path, action) elif method == 'PATCH': - self._patch_item_response(operation, path, action) + self._add_patch_item_response(operation, path, action) elif method == 'DELETE': # should only allow deleting a resource, not a collection # TODO: implement delete of a relationship in future release. - self._delete_item_response(operation, path, action) + self._add_delete_item_response(operation, path, action) return operation def get_operation_id(self, path, method): @@ -528,18 +528,18 @@ def _get_sort_parameters(self, path, method): """ return [{'$ref': '#/components/parameters/sort'}] - def _get_collection_response(self, operation): + def _add_get_collection_response(self, operation): """ - jsonapi-structured 200 response for GET of a collection + Add GET 200 response for a collection to operation """ operation['responses'] = { '200': self._get_toplevel_200_response(operation, collection=True) } self._add_get_4xx_responses(operation) - def _get_item_response(self, operation): + def _add_get_item_response(self, operation): """ - jsonapi-structured 200 response for GET of an item + add GET 200 response for an item to operation """ operation['responses'] = { '200': self._get_toplevel_200_response(operation, collection=False) @@ -548,7 +548,7 @@ def _get_item_response(self, operation): def _get_toplevel_200_response(self, operation, collection=True): """ - top-level JSONAPI GET 200 response + return top-level JSONAPI GET 200 response :param collection: True for collections; False for individual items. @@ -591,9 +591,9 @@ def _get_toplevel_200_response(self, operation, collection=True): } } - def _post_item_response(self, operation, path, action): + def _add_post_item_response(self, operation, path, action): """ - jsonapi-structured response for POST of an item + add response for POST of an item to operation """ operation['requestBody'] = self.get_request_body(path, 'POST', action) operation['responses'] = { @@ -610,9 +610,9 @@ def _post_item_response(self, operation, path, action): } self._add_post_4xx_responses(operation) - def _patch_item_response(self, operation, path, action): + def _add_patch_item_response(self, operation, path, action): """ - jsonapi-structured response for PATCH of an item + Add PATCH response for an item to operation """ operation['requestBody'] = self.get_request_body(path, 'PATCH', action) operation['responses'] = { @@ -620,9 +620,9 @@ def _patch_item_response(self, operation, path, action): } self._add_patch_4xx_responses(operation) - def _delete_item_response(self, operation, path, action): + def _add_delete_item_response(self, operation, path, action): """ - jsonapi-structured response for DELETE of an item or relationship(s) + add DELETE response for item or relationship(s) to operation """ # Only DELETE of relationships has a requestBody if action in ['rels', 'rel']: @@ -773,6 +773,9 @@ def map_serializer(self, serializer): return result def _add_async_response(self, operation): + """ + Add async response to operation + """ operation['responses']['202'] = { 'description': 'Accepted for [asynchronous processing]' '(https://jsonapi.org/recommendations/#asynchronous-processing)', @@ -784,6 +787,9 @@ def _add_async_response(self, operation): } def _failure_response(self, reason): + """ + Return failure response reason as the description + """ return { 'description': reason, 'content': { @@ -793,19 +799,26 @@ def _failure_response(self, reason): } } - def _generic_failure_responses(self, operation): + def _add_generic_failure_responses(self, operation): + """ + Add generic failure response(s) to operation + """ for code, reason in [('401', 'not authorized'), ]: operation['responses'][code] = self._failure_response(reason) def _add_get_4xx_responses(self, operation): - """ Add generic responses for get """ - self._generic_failure_responses(operation) + """ + Add generic 4xx GET responses to operation + """ + self._add_generic_failure_responses(operation) for code, reason in [('404', 'not found')]: operation['responses'][code] = self._failure_response(reason) def _add_post_4xx_responses(self, operation): - """ Add error responses for post """ - self._generic_failure_responses(operation) + """ + Add POST 4xx error responses to operation + """ + self._add_generic_failure_responses(operation) for code, reason in [ ('403', '[Forbidden](https://jsonapi.org/format/#crud-creating-responses-403)'), ('404', '[Related resource does not exist]' @@ -815,8 +828,10 @@ def _add_post_4xx_responses(self, operation): operation['responses'][code] = self._failure_response(reason) def _add_patch_4xx_responses(self, operation): - """ Add error responses for patch """ - self._generic_failure_responses(operation) + """ + Add PATCH 4xx error responses to operation + """ + self._add_generic_failure_responses(operation) for code, reason in [ ('403', '[Forbidden](https://jsonapi.org/format/#crud-updating-responses-403)'), ('404', '[Related resource does not exist]' @@ -827,7 +842,9 @@ def _add_patch_4xx_responses(self, operation): operation['responses'][code] = self._failure_response(reason) def _add_delete_responses(self, operation): - """ Add generic responses for delete """ + """ + Add generic DELETE responses to operation + """ # the 2xx statuses: operation['responses'] = { '200': { @@ -844,7 +861,7 @@ def _add_delete_responses(self, operation): 'description': '[no content](https://jsonapi.org/format/#crud-deleting-responses-204)', } # the 4xx errors: - self._generic_failure_responses(operation) + self._add_generic_failure_responses(operation) for code, reason in [ ('404', '[Resource does not exist]' '(https://jsonapi.org/format/#crud-deleting-responses-404)'), From bb905bffe63dac6764ffb5d1289b726e0fe48b3c Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Fri, 23 Oct 2020 15:54:44 -0400 Subject: [PATCH 15/15] Fix RelationshipView. Improve comment describing the need to monkey-patch view.action. --- example/tests/test_openapi.py | 12 +- rest_framework_json_api/schemas/openapi.py | 169 +++++++-------------- 2 files changed, 61 insertions(+), 120 deletions(-) diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index f7acfbe6..e7a2b6ca 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -128,10 +128,7 @@ def test_schema_construction(): def test_schema_related_serializers(): """ Confirm that paths are generated for related fields. For example: - url path '/authors/{pk}/{related_field>}/' generates: - /authors/{id}/relationships/comments/ - /authors/{id}/relationships/entries/ - /authors/{id}/relationships/first_entry/ -- Maybe? + /authors/{pk}/{related_field>} /authors/{id}/comments/ /authors/{id}/entries/ /authors/{id}/first_entry/ @@ -141,12 +138,7 @@ def test_schema_related_serializers(): request = create_request('/') schema = generator.get_schema(request=request) # make sure the path's relationship and related {related_field}'s got expanded - assert '/authors/{id}/relationships/entries' in schema['paths'] - assert '/authors/{id}/relationships/comments' in schema['paths'] - # first_entry is a special case (SerializerMethodRelatedField) - # TODO: '/authors/{id}/relationships/first_entry' supposed to be there? - # It fails when doing the actual GET, so this schema excluding it is OK. - # assert '/authors/{id}/relationships/first_entry/' in schema['paths'] + assert '/authors/{id}/relationships/{related_field}' in schema['paths'] assert '/authors/{id}/comments/' in schema['paths'] assert '/authors/{id}/entries/' in schema['paths'] assert '/authors/{id}/first_entry/' in schema['paths'] diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 65c5e58e..fe6b095e 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -1,15 +1,13 @@ import warnings from urllib.parse import urljoin -from django.db.models.fields import related_descriptors as rd from django.utils.module_loading import import_string as import_class_from_dotted_path from rest_framework.fields import empty from rest_framework.relations import ManyRelatedField from rest_framework.schemas import openapi as drf_openapi from rest_framework.schemas.utils import is_list_view -from rest_framework_json_api import serializers -from rest_framework_json_api.views import RelationshipView +from rest_framework_json_api import serializers, views class SchemaGenerator(drf_openapi.SchemaGenerator): @@ -29,19 +27,6 @@ class SchemaGenerator(drf_openapi.SchemaGenerator): }, 'additionalProperties': False }, - 'ResourceIdentifierObject': { - 'type': 'object', - 'required': ['type', 'id'], - 'additionalProperties': False, - 'properties': { - 'type': { - '$ref': '#/components/schemas/type' - }, - 'id': { - '$ref': '#/components/schemas/id' - }, - }, - }, 'resource': { 'type': 'object', 'required': ['type', 'id'], @@ -133,6 +118,18 @@ class SchemaGenerator(drf_openapi.SchemaGenerator): 'items': {'$ref': '#/components/schemas/linkage'}, 'uniqueItems': True }, + # A RelationshipView uses a ResourceIdentifierObjectSerializer (hence the name + # ResourceIdentifierObject returned by get_component_name()) which serializes type and + # id. These can be lists or individual items depending on whether the relationship is + # toMany or toOne so offer both options since we are not iterating over all the + # possible {related_field}'s but rather rendering one path schema which may represent + # toMany and toOne relationships. + 'ResourceIdentifierObject': { + 'oneOf': [ + {'$ref': '#/components/schemas/relationshipToOne'}, + {'$ref': '#/components/schemas/relationshipToMany'} + ] + }, 'linkage': { 'type': 'object', 'description': "the 'type' and 'id'", @@ -302,9 +299,7 @@ def get_schema(self, request=None, public=False): #: - 'action' copy of current view.action (list/fetch) as this gets reset for each request. expanded_endpoints = [] for path, method, view in view_endpoints: - if isinstance(view, RelationshipView): - expanded_endpoints += self._expand_relationships(path, method, view) - elif hasattr(view, 'action') and view.action == 'retrieve_related': + if hasattr(view, 'action') and view.action == 'retrieve_related': expanded_endpoints += self._expand_related(path, method, view, view_endpoints) else: expanded_endpoints.append((path, method, view, getattr(view, 'action', None))) @@ -312,14 +307,15 @@ def get_schema(self, request=None, public=False): for path, method, view, action in expanded_endpoints: if not self.has_view_permissions(path, method, view): continue - # kludge to preserve view.action as it changes "globally" for the same ViewSet - # whether it is used for a collection, item or related serializer. _expand_related - # sets it based on whether the related field is a toMany collection or toOne item. + # kludge to preserve view.action as it is 'list' for the parent ViewSet + # but the related viewset that was expanded may be either 'fetch' (to_one) or 'list' + # (to_many). This patches the view.action appropriately so that + # view.schema.get_operation() "does the right thing" for fetch vs. list. current_action = None if hasattr(view, 'action'): current_action = view.action view.action = action - operation = view.schema.get_operation(path, method, action) + operation = view.schema.get_operation(path, method) components = view.schema.get_components(path, method) for k in components.keys(): if k not in components_schemas: @@ -350,28 +346,6 @@ def get_schema(self, request=None, public=False): return schema - def _expand_relationships(self, path, method, view): - """ - Expand path containing .../{id}/relationships/{related_field} into list of related fields. - :return:list[tuple(path, method, view, action)] - """ - queryset = view.get_queryset() - if not queryset.model: - return [(path, method, view, getattr(view, 'action', '')), ] - result = [] - # TODO: what about serializer-only (non-model) fields? - # Shouldn't this be iterating over serializer fields rather than model fields? - # Look at parent view's serializer to get the list of fields. - # OR maybe like _expand_related? - m = queryset.model - for field in [f for f in dir(m) if not f.startswith('_')]: - attr = getattr(m, field) - if isinstance(attr, (rd.ReverseManyToOneDescriptor, rd.ForwardOneToOneDescriptor)): - action = 'rels' if isinstance(attr, rd.ReverseManyToOneDescriptor) else 'rel' - result.append((path.replace('{related_field}', field), method, view, action)) - - return result - def _expand_related(self, path, method, view, view_endpoints): """ Expand path containing .../{id}/{related_field} into list of related fields @@ -439,16 +413,12 @@ class AutoSchema(drf_openapi.AutoSchema): #: ignore all the media types and only generate a JSONAPI schema. content_types = ['application/vnd.api+json'] - def get_operation(self, path, method, action=None): + def get_operation(self, path, method): """ JSONAPI adds some standard fields to the API response that are not in upstream DRF: - some that only apply to GET/HEAD methods. - collections - - special handling for POST, PATCH, DELETE: - - :param action: One of the usual actions for a conventional path (list, retrieve, update, - partial_update, destroy) or special case 'rel' or 'rels' for a singular or - plural relationship. + - special handling for POST, PATCH, DELETE """ operation = {} operation['operationId'] = self.get_operation_id(path, method) @@ -472,13 +442,13 @@ def get_operation(self, path, method, action=None): else: self._add_get_item_response(operation) elif method == 'POST': - self._add_post_item_response(operation, path, action) + self._add_post_item_response(operation, path) elif method == 'PATCH': - self._add_patch_item_response(operation, path, action) + self._add_patch_item_response(operation, path) elif method == 'DELETE': # should only allow deleting a resource, not a collection # TODO: implement delete of a relationship in future release. - self._add_delete_item_response(operation, path, action) + self._add_delete_item_response(operation, path) return operation def get_operation_id(self, path, method): @@ -591,11 +561,11 @@ def _get_toplevel_200_response(self, operation, collection=True): } } - def _add_post_item_response(self, operation, path, action): + def _add_post_item_response(self, operation, path): """ add response for POST of an item to operation """ - operation['requestBody'] = self.get_request_body(path, 'POST', action) + operation['requestBody'] = self.get_request_body(path, 'POST') operation['responses'] = { '201': self._get_toplevel_200_response(operation, collection=False) } @@ -610,55 +580,54 @@ def _add_post_item_response(self, operation, path, action): } self._add_post_4xx_responses(operation) - def _add_patch_item_response(self, operation, path, action): + def _add_patch_item_response(self, operation, path): """ Add PATCH response for an item to operation """ - operation['requestBody'] = self.get_request_body(path, 'PATCH', action) + operation['requestBody'] = self.get_request_body(path, 'PATCH') operation['responses'] = { '200': self._get_toplevel_200_response(operation, collection=False) } self._add_patch_4xx_responses(operation) - def _add_delete_item_response(self, operation, path, action): + def _add_delete_item_response(self, operation, path): """ add DELETE response for item or relationship(s) to operation """ # Only DELETE of relationships has a requestBody - if action in ['rels', 'rel']: - operation['requestBody'] = self.get_request_body(path, 'DELETE', action) + if isinstance(self.view, views.RelationshipView): + operation['requestBody'] = self.get_request_body(path, 'DELETE') self._add_delete_responses(operation) - def get_request_body(self, path, method, action=None): + def get_request_body(self, path, method): """ A request body is required by jsonapi for POST, PATCH, and DELETE methods. - This has an added parameter which is not in upstream DRF: - - :param action: None for conventional path; 'rel' or 'rels' for a singular or plural - relationship of a related path, respectively. """ serializer = self.get_serializer(path, method) if not isinstance(serializer, (serializers.BaseSerializer, )): return {} + is_relationship = isinstance(self.view, views.RelationshipView) - # DRF uses a $ref to the component definition, but this + # DRF uses a $ref to the component schema definition, but this # doesn't work for jsonapi due to the different required fields based on # the method, so make those changes and inline another copy of the schema. - # TODO: A future improvement could make this DRYer with multiple components? - item_schema = self.map_serializer(serializer) - - # 'type' and 'id' are both required for: - # - all relationship operations - # - regular PATCH or DELETE - # Only 'type' is required for POST: system may assign the 'id'. - if action in ['rels', 'rel']: - item_schema['required'] = ['type', 'id'] - elif method in ['PATCH', 'DELETE']: - item_schema['required'] = ['type', 'id'] - elif method == 'POST': - item_schema['required'] = ['type'] + # TODO: A future improvement could make this DRYer with multiple component schemas: + # A base schema for each viewset that has no required fields + # One subclassed from the base that requires some fields (`type` but not `id` for POST) + # Another subclassed from base with required type/id but no required attributes (PATCH) - if 'attributes' in item_schema['properties']: + if is_relationship: + item_schema = {'$ref': '#/components/schemas/ResourceIdentifierObject'} + else: + item_schema = self.map_serializer(serializer) + if method == 'POST': + # 'type' and 'id' are both required for: + # - all relationship operations + # - regular PATCH or DELETE + # Only 'type' is required for POST: system may assign the 'id'. + item_schema['required'] = ['type'] + + if 'properties' in item_schema and 'attributes' in item_schema['properties']: # No required attributes for PATCH if method in ['PATCH', 'PUT'] and 'required' in item_schema['properties']['attributes']: del item_schema['properties']['attributes']['required'] @@ -666,39 +635,19 @@ def get_request_body(self, path, method, action=None): for name, schema in item_schema['properties']['attributes']['properties'].copy().items(): # noqa E501 if 'readOnly' in schema: del item_schema['properties']['attributes']['properties'][name] - # relationships special case: plural request body (data is array of items) - if action == 'rels': - return { - 'content': { - ct: { - 'schema': { - 'required': ['data'], - 'properties': { - 'data': { - 'type': 'array', - 'items': item_schema - } - } - } - } - for ct in self.content_types - } - } - # singular request body for all other cases - else: - return { - 'content': { - ct: { - 'schema': { - 'required': ['data'], - 'properties': { - 'data': item_schema - } + return { + 'content': { + ct: { + 'schema': { + 'required': ['data'], + 'properties': { + 'data': item_schema } } - for ct in self.content_types } + for ct in self.content_types } + } def map_serializer(self, serializer): """