diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b658b20..25b46b65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ This release is not backwards compatible. For easy migration best upgrade first ### Added * Add support for Django REST framework 3.10. +* Add support for `generateschema` management command. ### Removed diff --git a/docs/usage.md b/docs/usage.md index a655687a..86609243 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,4 +1,4 @@ - +` # Usage The DJA package implements a custom renderer, parser, exception handler, query filter backends, and @@ -32,6 +32,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', @@ -876,3 +877,97 @@ 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.10 added a new management command: `generateschema` which can generate an +[OAS 3.0 schema](https://www.openapis.org/) as a YAML or JSON file. + +### Settings needed + +In order to produce an OAS schema that properly represents the JSON:API structure, +DJA has this same command as an override of DRF's. In order to make sure the DJA +version of the command is used, you must add DJA **ahead of** DRF in the +`INSTALLED_APPS` settings as in this example: +```python +INSTALLED_APPS = [ + 'django.contrib.contenttypes', + 'django.contrib.staticfiles', + 'django.contrib.sites', + 'django.contrib.sessions', + 'django.contrib.auth', + 'rest_framework_json_api', + 'rest_framework', + 'polymorphic', + 'example', + 'debug_toolbar', + 'django_filters', +] +``` + +You'll also need to make sure you are using the DJA AutoSchema class, either as the default schema class or +explicitly as a view's `schema`: + +### Default schema class + +```python +REST_FRAMEWORK = { + # ... + 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema', +} +``` + +### View-based + +You can explicitly use DJA's AutoSchema in your view definition, optionally including an OAS schema document +initializer: + +```python +from rest_framework_json_api.schemas.openapi import AutoSchema + +openapi_schema = { + 'info': { + 'version': '1.0', + 'title': 'my demo API', + 'description': 'A demonstration of [OAS 3.0](https://www.openapis.org) AutoSchema', + 'contact': { + 'name': 'my name' + }, + 'license': { + 'name': 'BSD 2 clause', + 'url': 'https://github.com/django-json-api/django-rest-framework-json-api/blob/master/LICENSE', + } + }, + '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'}}} + ] +} + + +class MyViewSet(ModelViewSet): + schema = AutoSchema(openapi_schema=openapi_schema) +``` + +To generate an OAS schema document, use something like: + +```text +$ django-admin generateschema --settings=example.settings >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/1937) +in the OAS specification is expected to be fixed soon. +([swagger-ui](https://www.npmjs.com/package/swagger-ui) will work silently.) + 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/tests/snapshots/__init__.py b/example/tests/snapshots/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/example/tests/snapshots/snap_test_openapi.py b/example/tests/snapshots/snap_test_openapi.py new file mode 100644 index 00000000..870e2406 --- /dev/null +++ b/example/tests/snapshots/snap_test_openapi.py @@ -0,0 +1,862 @@ +# -*- 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'] = '''{ + "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": { + "additionalProperties": false, + "properties": { + "attributes": { + "properties": { + "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", + "id" + ], + "type": "object" + }, + "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'] = '''{ + "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": { + "additionalProperties": false, + "properties": { + "attributes": { + "properties": { + "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", + "id" + ], + "type": "object" + }, + "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'] = '''{ + "operationId": "create/authors/", + "parameters": [], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "attributes": { + "properties": { + "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": { + "additionalProperties": false, + "properties": { + "attributes": { + "properties": { + "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", + "id" + ], + "type": "object" + }, + "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'] = '''{ + "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": { + "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": { + "additionalProperties": false, + "properties": { + "attributes": { + "properties": { + "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", + "id" + ], + "type": "object" + }, + "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'] = '''{ + "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_openapi.py b/example/tests/test_openapi.py new file mode 100644 index 00000000..4b4d0bde --- /dev/null +++ b/example/tests/test_openapi.py @@ -0,0 +1,165 @@ +# largely based on DRF's test_openapi +import json + +from django.conf.urls import url +from django.test import RequestFactory, override_settings +from rest_framework.request import Request + +from rest_framework_json_api.management.commands.generateschema import Command +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() + 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 = [ + url(r'^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 + + +def test_generateschema_command(): + command = Command() + assert command.get_generator_class() == SchemaGenerator + + +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']['properties']['attributes']['properties'] + assert 'headline' in first_props + assert first_props['headline'] == {'type': 'string', 'maxLength': 255} 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 79d3b1c1..72788060 100644 --- a/example/urls.py +++ b/example/urls.py @@ -30,20 +30,20 @@ urlpatterns = [ url(r'^', include(router.urls)), - url(r'^entries/(?P[^/.]+)/suggested/', + url(r'^entries/(?P[^/.]+)/suggested/$', EntryViewSet.as_view({'get': 'list'}), name='entry-suggested' ), - url(r'entries/(?P[^/.]+)/blog', + url(r'entries/(?P[^/.]+)/blog$', BlogViewSet.as_view({'get': 'retrieve'}), name='entry-blog'), - url(r'entries/(?P[^/.]+)/comments', + url(r'entries/(?P[^/.]+)/comments$', CommentViewSet.as_view({'get': 'list'}), name='entry-comments'), - url(r'entries/(?P[^/.]+)/authors', + url(r'entries/(?P[^/.]+)/authors$', AuthorViewSet.as_view({'get': 'list'}), name='entry-authors'), - url(r'entries/(?P[^/.]+)/featured', + url(r'entries/(?P[^/.]+)/featured$', EntryViewSet.as_view({'get': 'retrieve'}), name='entry-featured'), @@ -51,16 +51,16 @@ AuthorViewSet.as_view({'get': 'retrieve_related'}), name='author-related'), - url(r'^entries/(?P[^/.]+)/relationships/(?P\w+)', + url(r'^entries/(?P[^/.]+)/relationships/(?P\w+)$', EntryRelationshipView.as_view(), name='entry-relationships'), - url(r'^blogs/(?P[^/.]+)/relationships/(?P\w+)', + url(r'^blogs/(?P[^/.]+)/relationships/(?P\w+)$', BlogRelationshipView.as_view(), name='blog-relationships'), - url(r'^comments/(?P[^/.]+)/relationships/(?P\w+)', + url(r'^comments/(?P[^/.]+)/relationships/(?P\w+)$', CommentRelationshipView.as_view(), name='comment-relationships'), - url(r'^authors/(?P[^/.]+)/relationships/(?P\w+)', + url(r'^authors/(?P[^/.]+)/relationships/(?P\w+)$', AuthorRelationshipView.as_view(), name='author-relationships'), ] diff --git a/example/urls_test.py b/example/urls_test.py index e51121ac..020ab2f3 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -44,30 +44,30 @@ url(r'^', include(router.urls)), # old tests - url(r'identities/default/(?P\d+)', + url(r'identities/default/(?P\d+)$', GenericIdentity.as_view(), name='user-default'), - url(r'^entries/(?P[^/.]+)/blog', + url(r'^entries/(?P[^/.]+)/blog$', BlogViewSet.as_view({'get': 'retrieve'}), name='entry-blog' ), - url(r'^entries/(?P[^/.]+)/comments', + url(r'^entries/(?P[^/.]+)/comments$', CommentViewSet.as_view({'get': 'list'}), name='entry-comments' ), - url(r'^entries/(?P[^/.]+)/suggested/', + url(r'^entries/(?P[^/.]+)/suggested/$', EntryViewSet.as_view({'get': 'list'}), name='entry-suggested' ), - url(r'^drf-entries/(?P[^/.]+)/suggested/', + url(r'^drf-entries/(?P[^/.]+)/suggested/$', DRFEntryViewSet.as_view({'get': 'list'}), name='drf-entry-suggested' ), - url(r'entries/(?P[^/.]+)/authors', + url(r'entries/(?P[^/.]+)/authors$', AuthorViewSet.as_view({'get': 'list'}), name='entry-authors'), - url(r'entries/(?P[^/.]+)/featured', + url(r'entries/(?P[^/.]+)/featured$', EntryViewSet.as_view({'get': 'retrieve'}), name='entry-featured'), @@ -75,16 +75,16 @@ AuthorViewSet.as_view({'get': 'retrieve_related'}), name='author-related'), - url(r'^entries/(?P[^/.]+)/relationships/(?P\w+)', + url(r'^entries/(?P[^/.]+)/relationships/(?P\w+)$', EntryRelationshipView.as_view(), name='entry-relationships'), - url(r'^blogs/(?P[^/.]+)/relationships/(?P\w+)', + url(r'^blogs/(?P[^/.]+)/relationships/(?P\w+)$', BlogRelationshipView.as_view(), name='blog-relationships'), - url(r'^comments/(?P[^/.]+)/relationships/(?P\w+)', + url(r'^comments/(?P[^/.]+)/relationships/(?P\w+)$', CommentRelationshipView.as_view(), name='comment-relationships'), - url(r'^authors/(?P[^/.]+)/relationships/(?P\w+)', + url(r'^authors/(?P[^/.]+)/relationships/(?P\w+)$', AuthorRelationshipView.as_view(), name='author-relationships'), ] diff --git a/requirements-development.txt b/requirements-development.txt index 6b0f6da9..6c6d3add 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -15,3 +15,6 @@ recommonmark==0.6.0 Sphinx==2.1.2 sphinx_rtd_theme==0.4.3 twine==1.13.0 +coreapi==2.3.3 +pyyaml==5.1.2 +snapshottest==0.5.1 diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 0c4b80d3..4377bbfb 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -122,3 +122,18 @@ 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 = [] + for res in super(DjangoFilterBackend, self).get_schema_operation_parameters(view): + if 'name' in res: + res['name'] = 'filter[{}]'.format(res['name']).replace('__', '.') + result.append(res) + return result diff --git a/rest_framework_json_api/management/__init__.py b/rest_framework_json_api/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rest_framework_json_api/management/commands/__init__.py b/rest_framework_json_api/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rest_framework_json_api/management/commands/generateschema.py b/rest_framework_json_api/management/commands/generateschema.py new file mode 100644 index 00000000..3c97b0d4 --- /dev/null +++ b/rest_framework_json_api/management/commands/generateschema.py @@ -0,0 +1,10 @@ +from rest_framework.management.commands.generateschema import Command as DRFCommand + +from rest_framework_json_api.schemas.openapi import SchemaGenerator + + +class Command(DRFCommand): + help = "Generates jsonapi.org schema for project." + + def get_generator_class(self): + return SchemaGenerator 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..0913c7e1 --- /dev/null +++ b/rest_framework_json_api/schemas/openapi.py @@ -0,0 +1,853 @@ +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 import exceptions +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 + +#: static OAS 3.0 component definitions that are referenced by AutoSchema. +JSONAPI_COMPONENTS = { + 'schemas': { + 'jsonapi': { + 'type': 'object', + 'description': "The server's implementation", + 'properties': { + 'version': {'type': 'string'}, + 'meta': {'$ref': '#/components/schemas/meta'} + }, + 'additionalProperties': False + }, + '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)', + 'required': False, + 'style': 'deepObject', + 'schema': { + 'type': 'object', + }, + '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' + } + }, + }, +} + + +class SchemaGenerator(drf_openapi.SchemaGenerator): + """ + Extend DRF's SchemaGenerator to implement jsonapi-flavored generateschema command + """ + def __init__(self, *args, **kwargs): + self.openapi_schema = {} + super().__init__(*args, **kwargs) + + def get_schema(self, request=None, public=False): + """ + Generate a JSONAPI OpenAPI schema. + """ + self._initialise_endpoints() + + paths = self.get_paths(None if public else request) + if not paths: + return None + schema = { + 'openapi': '3.0.2', + 'info': self.get_info(), + 'paths': paths, + 'components': JSONAPI_COMPONENTS, + } + + return {**schema, **self.openapi_schema} + + def get_paths(self, request=None): + """ + **Replacement** for rest_framework.schemas.openapi.SchemaGenerator.get_paths(): + - expand the paths for RelationshipViews and retrieve_related actions: + {related_field} gets replaced by the related field names. + - Merges in any openapi_schema initializer that the view has. + """ + result = {} + + paths, view_endpoints = self._get_paths_and_endpoints(request) + + # Only generate the path prefix for paths that will be included + if not paths: + return None + + #: `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. + # TODO: define an endpoint_inspector_cls that extends EndpointEnumerator + # instead of doing it here. + expanded_endpoints = [] + for path, method, view in view_endpoints: + action = view.action if hasattr(view, 'action') else None + if isinstance(view, RelationshipView): + expanded_endpoints += self._expand_relationships(path, method, view) + elif action == 'retrieve_related': + expanded_endpoints += self._expand_related(path, method, view, view_endpoints) + else: + expanded_endpoints.append((path, method, view, action)) + + 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) + if hasattr(view, 'action'): + view.action = current_action + operation['description'] = operation['operationId'] # TODO: kludge + if 'responses' in operation and '200' in operation['responses']: + operation['responses']['200']['description'] = operation['operationId'] # TODO:! + # Normalise path for any provided mount url. + if path.startswith('/'): + path = path[1:] + path = urljoin(self.url or '/', path) + + result.setdefault(path, {}) + result[path][method.lower()] = operation + if hasattr(view.schema, 'openapi_schema'): + # TODO: shallow or deep merge? + self.openapi_schema = {**self.openapi_schema, **view.schema.openapi_schema} + + return result + + 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? + # TODO: Shouldn't this be iterating over serializer fields rather than model fields? + # TODO: Look at parent view's serializer to get the list of fields. + # TODO: 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). + :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. + """ + content_types = ['application/vnd.api+json'] + + def __init__(self, openapi_schema={}): + """ + Initialize the JSONAPI OAS schema generator + :param openapi_schema: dict: OAS 3.0 document with initial values. + """ + super().__init__() + #: allow initialization of OAS schema doc + self.openapi_schema = openapi_schema + # static JSONAPI fields that get $ref'd to in the view mappings + jsonapi_ref = { + 'components': JSONAPI_COMPONENTS + } + # merge in our reference data on top of anything provided by the init. + # TODO: shallow or deep merge? + self.openapi_schema = {**self.openapi_schema, **jsonapi_ref} + + def get_operation(self, path, method, action=None): + """ basically a copy of AutoSchema.get_operation """ + operation = {} + operation['operationId'] = self._get_operation_id(path, method) + # operation['security'] = self._get_security(path, method) + + 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(operation) + else: + self._get_item(operation) + elif method == 'POST': + self._post_item(operation, path, action) + elif method == 'PATCH': + self._patch_item(operation, path, action) + elif method == 'DELETE': + # should only allow deleting a resource, not a collection + # TODO: delete of a relationship is different. + self._delete_item(operation, path, action) + return operation + + def _get_operation_id(self, path, method): + """ create a unique operationId """ + # The DRF version creates non-unique operationIDs, especially when the same view is used + # for different paths. Just make a simple concatenation of (mapped) method name and path. + 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 + """ + 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(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(self, operation): + """ jsonapi-structured 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 """ + if collection: + data = {'type': 'array', 'items': self._get_item_schema(operation)} + else: + data = self._get_item_schema(operation) + + 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 _get_item_schema(self, operation): + """ + get the schema for item + """ + content = {} + view = self.view + if hasattr(view, 'get_serializer'): + try: + serializer = view.get_serializer() + except exceptions.APIException: + serializer = None + warnings.warn('{}.get_serializer() raised an exception during ' + 'schema generation. Serializer fields will not be ' + 'generated.'.format(view.__class__.__name__)) + + if isinstance(serializer, serializers.BaseSerializer): + content = self._map_serializer(serializer) + # No write_only fields for response. + for name, schema in content['properties'].copy().items(): + if 'writeOnly' in schema: + del content['properties'][name] + content['required'] = [f for f in content['required'] if f != name] + content['properties']['type'] = {'$ref': '#/components/schemas/type'} + content['properties']['id'] = {'$ref': '#/components/schemas/id'} + + return content + + def _post_item(self, operation, path, action): + """ jsonapi-strucutred 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(self, operation, path, action): + """ jsomapi-strucutred 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(self, operation, path, action): + """ jsonapi-structured response for DELETE of an item or relationship? """ + # 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): + """ jsonapi-flavored request_body """ + # TODO: if a RelationshipView, check for toMany (data array) vs. toOne. + content = {} + view = self.view + + if not hasattr(view, 'get_serializer'): + return {} + + try: + serializer = view.get_serializer() + except exceptions.APIException: + serializer = None + warnings.warn('{}.get_serializer() raised an exception during ' + 'schema generation. Serializer fields will not be ' + 'generated for {} {}.' + .format(view.__class__.__name__, method, path)) + + # ResourceIdentifierObjectSerializer + if not isinstance(serializer, (serializers.BaseSerializer, )): + return {} + + content = 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']: + content['required'] = ['type', 'id'] + elif method in ['PATCH', 'DELETE']: + content['required'] = ['type', 'id'] + elif method == 'POST': + content['required'] = ['type'] + + if 'attributes' in content['properties']: + # No required attributes for PATCH + if method in ['PATCH', 'PUT'] and 'required' in content['properties']['attributes']: + del content['properties']['attributes']['required'] + # No read_only fields for request. + for name, schema in content['properties']['attributes']['properties'].copy().items(): + if 'readOnly' in schema: + del content['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': content + } + } + } + } + for ct in self.content_types + } + } + else: + return { + 'content': { + ct: { + 'schema': { + 'required': ['data'], + 'properties': { + 'data': content + } + } + } + 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.help_text: + schema['description'] = field.help_text + self._map_field_validators(field.validators, schema) + if field.read_only: + schema['readOnly'] = True + if field.write_only: + schema['writeOnly'] = True + if field.allow_null: + schema['nullable'] = True + + 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 relationships: + result['properties']['relationships'] = { + 'type': 'object', + 'properties': relationships + } + if required: + result['properties']['attributes']['required'] = required + 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 a14ea984..9e455288 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,6 +15,7 @@ exclude = .tox, env .venv + example/tests/snapshots [isort] indent = 4 diff --git a/tox.ini b/tox.ini index bcbff27d..45d777e0 100644 --- a/tox.ini +++ b/tox.ini @@ -10,6 +10,8 @@ deps = django22: Django>=2.2,<2.3 drf310: djangorestframework>=3.10.2,<3.11 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip + coreapi>=2.3.1 + snapshottest>=0.5.1 setenv = PYTHONPATH = {toxinidir}