From 7ad59e8d08a5aa0c412b35099621924145f599ab Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Mon, 12 Aug 2019 16:57:47 -0400 Subject: [PATCH 01/11] initial implementation of OAS 3.0 generateschema --- CHANGELOG.md | 1 + docs/usage.md | 97 +- example/settings/dev.py | 2 + example/tests/snapshots/__init__.py | 0 example/tests/snapshots/snap_test_openapi.py | 862 ++++++++++++++++++ example/tests/test_openapi.py | 176 ++++ .../tests/unit/test_filter_schema_params.py | 76 ++ requirements-development.txt | 3 + .../management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/generateschema.py | 10 + rest_framework_json_api/schemas/__init__.py | 0 rest_framework_json_api/schemas/openapi.py | 845 +++++++++++++++++ setup.cfg | 1 + tox.ini | 2 + 15 files changed, 2074 insertions(+), 1 deletion(-) create mode 100644 example/tests/snapshots/__init__.py 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/management/__init__.py create mode 100644 rest_framework_json_api/management/commands/__init__.py create mode 100644 rest_framework_json_api/management/commands/generateschema.py create mode 100644 rest_framework_json_api/schemas/__init__.py create mode 100644 rest_framework_json_api/schemas/openapi.py 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..ab6eb669 --- /dev/null +++ b/example/tests/test_openapi.py @@ -0,0 +1,176 @@ +# 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.schemas.openapi import AutoSchema, SchemaGenerator +from rest_framework_json_api.views import ModelViewSet + +from example import models, serializers, views + + +def create_request(path): + factory = RequestFactory() + request = Request(factory.get(path)) + return request + + +def create_view(view_cls, method, request): + generator = SchemaGenerator() + view = generator.create_view(view_cls.as_view(), method, request) + return view + + +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 + + +# TODO: figure these out +def test_schema_related(): + class AuthorBioViewSet(ModelViewSet): + queryset = models.AuthorBio.objects.all() + serializer_class = serializers.AuthorBioSerializer + + patterns = [ + url(r'^authors/(?P[^/.]+)/(?P\w+)/$', + views.AuthorViewSet.as_view({'get': 'retrieve_related'}), + name='author-related'), + url(r'^bios/(?P[^/.]+)/$', + AuthorBioViewSet, + name='author-bio') + ] + generator = SchemaGenerator(patterns=patterns) + + request = create_request('/authors/123/bio/') + schema = generator.get_schema(request=request) + # TODO: finish this test + print(schema) + +# def test_retrieve_relationships(): +# path = '/authors/{id}/relationships/bio/' +# method = 'GET' +# +# view = create_view_with_kw( +# views.AuthorViewSet, +# method, +# create_request(path), +# {'get': 'retrieve_related'} +# ) +# inspector = AutoSchema() +# inspector.view = view +# +# operation = inspector.get_operation(path, method) +# assert 'responses' in operation +# assert '200' in operation['responses'] +# resp = operation['responses']['200']['content'] +# data = resp['application/vnd.api+json']['schema']['properties']['data'] +# assert data['type'] == 'object' +# assert data['required'] == ['type', 'id'] +# assert data['properties']['type'] == {'$ref': '#/components/schemas/type'} 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..2352a59b --- /dev/null +++ b/example/tests/unit/test_filter_schema_params.py @@ -0,0 +1,76 @@ +import pytest +from rest_framework import VERSION as DRFVERSION +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',), + } + + def __init__(self): + # dummy up self.request since PreloadIncludesMixin expects it to be defined + self.request = None + + +# get_schema_operation_parameters is only available in DRF >= 3.10 +drf_version = tuple(int(x) for x in DRFVERSION.split('.')) +pytestmark = pytest.mark.skipif(drf_version < (3, 10), reason="requires DRF 3.10 or higher") + + +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'} + } + ]), + (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: + return + # 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 + return + assert False 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/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..494a0ee6 --- /dev/null +++ b/rest_framework_json_api/schemas/openapi.py @@ -0,0 +1,845 @@ +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: + if isinstance(view, RelationshipView): + expanded_endpoints += self._expand_relationships(path, method, view) + elif view.action == 'retrieve_related': + expanded_endpoints += self._expand_related(path, method, view, view_endpoints) + else: + expanded_endpoints.append((path, method, view, 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 or not queryset.model: + return [(path, method, view, getattr(view, 'action', '')), ] + result = [] + 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() + if hasattr(serializer, 'related_serializers'): + related_fields = [fs for fs in serializer.related_serializers.items()] + elif hasattr(serializer, 'included_serializers'): + related_fields = [fs for fs in serializer.included_serializers.items()] + else: + related_fields = [] + 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} From 4a700204c908bfac548e8c8c27c35487890a05c7 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Thu, 22 Aug 2019 14:49:35 -0400 Subject: [PATCH 02/11] implement missing DjangoFilterBackend.get_schema_operation_parameters() --- .../tests/unit/test_filter_schema_params.py | 27 ++++++++++--------- .../django_filters/backends.py | 15 +++++++++++ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/example/tests/unit/test_filter_schema_params.py b/example/tests/unit/test_filter_schema_params.py index 2352a59b..2044c467 100644 --- a/example/tests/unit/test_filter_schema_params.py +++ b/example/tests/unit/test_filter_schema_params.py @@ -1,5 +1,3 @@ -import pytest -from rest_framework import VERSION as DRFVERSION from rest_framework import filters as drf_filters from rest_framework_json_api import filters as dja_filters @@ -13,17 +11,14 @@ class DummyEntryViewSet(EntryViewSet): backends.DjangoFilterBackend, drf_filters.SearchFilter) filterset_fields = { 'id': ('exact',), - 'headline': ('exact',), + 'headline': ('exact', 'contains'), + 'blog__name': ('contains', ), } - def __init__(self): + def __init__(self, **kwargs): # dummy up self.request since PreloadIncludesMixin expects it to be defined self.request = None - - -# get_schema_operation_parameters is only available in DRF >= 3.10 -drf_version = tuple(int(x) for x in DRFVERSION.split('.')) -pytestmark = pytest.mark.skipif(drf_version < (3, 10), reason="requires DRF 3.10 or higher") + super(DummyEntryViewSet, self).__init__(**kwargs) def test_filters_get_schema_params(): @@ -41,7 +36,15 @@ def test_filters_get_schema_params(): { '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, [ { @@ -65,12 +68,10 @@ def test_filters_get_schema_params(): result = f.get_schema_operation_parameters(view) assert len(result) == len(expected) if len(result) == 0: - return + 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 - return - assert False 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 From 4e9b43cc1a454b4b502e9e59020ff2877477c868 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Fri, 23 Aug 2019 12:35:36 -0400 Subject: [PATCH 03/11] tests and improvements for related fields --- example/tests/test_openapi.py | 101 +++++++++++---------- rest_framework_json_api/schemas/openapi.py | 18 ++-- 2 files changed, 62 insertions(+), 57 deletions(-) diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index ab6eb669..118de5b5 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -5,10 +5,11 @@ 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 rest_framework_json_api.views import ModelViewSet -from example import models, serializers, views +from example import views +from example.tests import TestBase def create_request(path): @@ -17,12 +18,6 @@ def create_request(path): return request -def create_view(view_cls, method, request): - generator = SchemaGenerator() - view = generator.create_view(view_cls.as_view(), method, request) - return view - - def create_view_with_kw(view_cls, method, request, initkwargs): generator = SchemaGenerator() view = generator.create_view(view_cls.as_view(initkwargs), method, request) @@ -132,45 +127,51 @@ def test_schema_construction(): assert 'components' in schema -# TODO: figure these out -def test_schema_related(): - class AuthorBioViewSet(ModelViewSet): - queryset = models.AuthorBio.objects.all() - serializer_class = serializers.AuthorBioSerializer - - patterns = [ - url(r'^authors/(?P[^/.]+)/(?P\w+)/$', - views.AuthorViewSet.as_view({'get': 'retrieve_related'}), - name='author-related'), - url(r'^bios/(?P[^/.]+)/$', - AuthorBioViewSet, - name='author-bio') - ] - generator = SchemaGenerator(patterns=patterns) - - request = create_request('/authors/123/bio/') - schema = generator.get_schema(request=request) - # TODO: finish this test - print(schema) - -# def test_retrieve_relationships(): -# path = '/authors/{id}/relationships/bio/' -# method = 'GET' -# -# view = create_view_with_kw( -# views.AuthorViewSet, -# method, -# create_request(path), -# {'get': 'retrieve_related'} -# ) -# inspector = AutoSchema() -# inspector.view = view -# -# operation = inspector.get_operation(path, method) -# assert 'responses' in operation -# assert '200' in operation['responses'] -# resp = operation['responses']['200']['content'] -# data = resp['application/vnd.api+json']['schema']['properties']['data'] -# assert data['type'] == 'object' -# assert data['required'] == ['type', 'id'] -# assert data['properties']['type'] == {'$ref': '#/components/schemas/type'} +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}/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) + 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} + + # def test_retrieve_relationships(self): + # path = '/authors/{id}/relationships/bio/' + # method = 'GET' + # + # view = create_view_with_kw( + # views.AuthorViewSet, + # method, + # create_request(path), + # {'get': 'retrieve_related'} + # ) + # inspector = AutoSchema() + # inspector.view = view + # + # operation = inspector.get_operation(path, method) + # assert 'responses' in operation + # assert '200' in operation['responses'] + # resp = operation['responses']['200']['content'] + # data = resp['application/vnd.api+json']['schema']['properties']['data'] + # assert data['type'] == 'object' + # assert data['required'] == ['type', 'id'] + # assert data['properties']['type'] == {'$ref': '#/components/schemas/type'} diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 494a0ee6..dbe6a0de 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -312,12 +312,13 @@ def get_paths(self, request=None): # 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 view.action == 'retrieve_related': + elif action == 'retrieve_related': expanded_endpoints += self._expand_related(path, method, view, view_endpoints) else: - expanded_endpoints.append((path, method, view, view.action)) + expanded_endpoints.append((path, method, view, action)) for path, method, view, action in expanded_endpoints: if not self.has_view_permissions(path, method, view): @@ -379,12 +380,15 @@ def _expand_related(self, path, method, view, view_endpoints): """ 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'): - related_fields = [fs for fs in serializer.related_serializers.items()] - elif hasattr(serializer, 'included_serializers'): - related_fields = [fs for fs in serializer.included_serializers.items()] - else: - related_fields = [] + 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: From 29eb7762b36425c791abe5e214e6043d39b17fa6 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Fri, 23 Aug 2019 15:18:19 -0400 Subject: [PATCH 04/11] add trailing /$ to urlpatterns - works around a bug in django.contrib.admindocs.utils.replace_named_groups that fails to replace a named group if there's no trailing / - only make the change to urls.py; urls_test.py has a bunch of tests that expect the / to be missing. --- example/urls.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/example/urls.py b/example/urls.py index 79d3b1c1..18a70a4f 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'), ] From d43f7cade42fddf239c3dd36b38b667c94ab4107 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sat, 24 Aug 2019 09:29:49 -0400 Subject: [PATCH 05/11] add trailing / to urls_test as well --- .../test_non_paginated_responses.py | 20 ++++---- example/tests/integration/test_pagination.py | 10 ++-- example/tests/test_filters.py | 10 ++-- example/tests/test_relations.py | 8 +-- example/tests/test_views.py | 50 +++++++++---------- example/urls_test.py | 10 ++-- 6 files changed, 54 insertions(+), 54 deletions(-) diff --git a/example/tests/integration/test_non_paginated_responses.py b/example/tests/integration/test_non_paginated_responses.py index 9f1f532e..94abfe7a 100644 --- a/example/tests/integration/test_non_paginated_responses.py +++ b/example/tests/integration/test_non_paginated_responses.py @@ -35,7 +35,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "blogHyperlinked": { "links": { "related": "http://testserver/entries/1/blog", - "self": "http://testserver/entries/1/relationships/blog_hyperlinked" + "self": "http://testserver/entries/1/relationships/blog_hyperlinked/" } }, "authors": { @@ -49,27 +49,27 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "commentsHyperlinked": { "links": { "related": "http://testserver/entries/1/comments", - "self": "http://testserver/entries/1/relationships/comments_hyperlinked" + "self": "http://testserver/entries/1/relationships/comments_hyperlinked/" # noqa: E501 } }, "suggested": { "data": [{"type": "entries", "id": "2"}], "links": { "related": "http://testserver/entries/1/suggested/", - "self": "http://testserver/entries/1/relationships/suggested" + "self": "http://testserver/entries/1/relationships/suggested/" } }, "suggestedHyperlinked": { "links": { "related": "http://testserver/entries/1/suggested/", "self": "http://testserver/entries/1" - "/relationships/suggested_hyperlinked" + "/relationships/suggested_hyperlinked/" } }, "featuredHyperlinked": { "links": { "related": "http://testserver/entries/1/featured", - "self": "http://testserver/entries/1/relationships/featured_hyperlinked" + "self": "http://testserver/entries/1/relationships/featured_hyperlinked/" # noqa: E501 } }, "tags": { @@ -98,7 +98,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "blogHyperlinked": { "links": { "related": "http://testserver/entries/2/blog", - "self": "http://testserver/entries/2/relationships/blog_hyperlinked", + "self": "http://testserver/entries/2/relationships/blog_hyperlinked/", } }, "authors": { @@ -112,27 +112,27 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "commentsHyperlinked": { "links": { "related": "http://testserver/entries/2/comments", - "self": "http://testserver/entries/2/relationships/comments_hyperlinked" + "self": "http://testserver/entries/2/relationships/comments_hyperlinked/" # noqa: E501 } }, "suggested": { "data": [{"type": "entries", "id": "1"}], "links": { "related": "http://testserver/entries/2/suggested/", - "self": "http://testserver/entries/2/relationships/suggested" + "self": "http://testserver/entries/2/relationships/suggested/" } }, "suggestedHyperlinked": { "links": { "related": "http://testserver/entries/2/suggested/", "self": "http://testserver/entries/2" - "/relationships/suggested_hyperlinked" + "/relationships/suggested_hyperlinked/" } }, "featuredHyperlinked": { "links": { "related": "http://testserver/entries/2/featured", - "self": "http://testserver/entries/2/relationships/featured_hyperlinked" + "self": "http://testserver/entries/2/relationships/featured_hyperlinked/" # noqa: E501 } }, "tags": { diff --git a/example/tests/integration/test_pagination.py b/example/tests/integration/test_pagination.py index 25d01c44..b7f6b983 100644 --- a/example/tests/integration/test_pagination.py +++ b/example/tests/integration/test_pagination.py @@ -35,7 +35,7 @@ def test_pagination_with_single_entry(single_entry, client): "blogHyperlinked": { "links": { "related": "http://testserver/entries/1/blog", - "self": "http://testserver/entries/1/relationships/blog_hyperlinked", + "self": "http://testserver/entries/1/relationships/blog_hyperlinked/", } }, "authors": { @@ -49,27 +49,27 @@ def test_pagination_with_single_entry(single_entry, client): "commentsHyperlinked": { "links": { "related": "http://testserver/entries/1/comments", - "self": "http://testserver/entries/1/relationships/comments_hyperlinked" + "self": "http://testserver/entries/1/relationships/comments_hyperlinked/" # noqa: E501 } }, "suggested": { "data": [], "links": { "related": "http://testserver/entries/1/suggested/", - "self": "http://testserver/entries/1/relationships/suggested" + "self": "http://testserver/entries/1/relationships/suggested/" } }, "suggestedHyperlinked": { "links": { "related": "http://testserver/entries/1/suggested/", "self": "http://testserver/entries/1" - "/relationships/suggested_hyperlinked" + "/relationships/suggested_hyperlinked/" } }, "featuredHyperlinked": { "links": { "related": "http://testserver/entries/1/featured", - "self": "http://testserver/entries/1/relationships/featured_hyperlinked" + "self": "http://testserver/entries/1/relationships/featured_hyperlinked/" # noqa: E501 } }, "tags": { diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index a42480c2..e805307a 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -362,7 +362,7 @@ def test_search_keywords(self): }, 'blogHyperlinked': { 'links': { - 'self': 'http://testserver/entries/7/relationships/blog_hyperlinked', # noqa: E501 + 'self': 'http://testserver/entries/7/relationships/blog_hyperlinked/', # noqa: E501 'related': 'http://testserver/entries/7/blog'} }, 'authors': { @@ -379,13 +379,13 @@ def test_search_keywords(self): }, 'commentsHyperlinked': { 'links': { - 'self': 'http://testserver/entries/7/relationships/comments_hyperlinked', # noqa: E501 + 'self': 'http://testserver/entries/7/relationships/comments_hyperlinked/', # noqa: E501 'related': 'http://testserver/entries/7/comments' } }, 'suggested': { 'links': { - 'self': 'http://testserver/entries/7/relationships/suggested', + 'self': 'http://testserver/entries/7/relationships/suggested/', 'related': 'http://testserver/entries/7/suggested/' }, 'data': [ @@ -404,7 +404,7 @@ def test_search_keywords(self): }, 'suggestedHyperlinked': { 'links': { - 'self': 'http://testserver/entries/7/relationships/suggested_hyperlinked', # noqa: E501 + 'self': 'http://testserver/entries/7/relationships/suggested_hyperlinked/', # noqa: E501 'related': 'http://testserver/entries/7/suggested/'} }, 'tags': { @@ -412,7 +412,7 @@ def test_search_keywords(self): }, 'featuredHyperlinked': { 'links': { - 'self': 'http://testserver/entries/7/relationships/featured_hyperlinked', # noqa: E501 + 'self': 'http://testserver/entries/7/relationships/featured_hyperlinked/', # noqa: E501 'related': 'http://testserver/entries/7/featured' } } diff --git a/example/tests/test_relations.py b/example/tests/test_relations.py index 94db188a..21c91233 100644 --- a/example/tests/test_relations.py +++ b/example/tests/test_relations.py @@ -177,7 +177,7 @@ def test_single_hyperlinked_related_field(self): self.assertRaises(SkipField, field.get_attribute, self.entry) links_expected = { - 'self': 'http://testserver/entries/{}/relationships/blog'.format(self.entry.pk), + 'self': 'http://testserver/entries/{}/relationships/blog/'.format(self.entry.pk), 'related': 'http://testserver/entries/{}/blog'.format(self.entry.pk) } got = field.get_links(self.entry) @@ -198,7 +198,7 @@ def test_many_hyperlinked_related_field(self): self.assertRaises(SkipField, field.get_attribute, self.entry) links_expected = { - 'self': 'http://testserver/entries/{}/relationships/comments'.format(self.entry.pk), + 'self': 'http://testserver/entries/{}/relationships/comments/'.format(self.entry.pk), 'related': 'http://testserver/entries/{}/comments'.format(self.entry.pk) } got = field.child_relation.get_links(self.entry) @@ -221,7 +221,7 @@ def test_single_serializer_method_hyperlinked_related_field(self): self.assertRaises(SkipField, field.get_attribute, self.entry) expected = { - 'self': 'http://testserver/entries/{}/relationships/blog'.format(self.entry.pk), + 'self': 'http://testserver/entries/{}/relationships/blog/'.format(self.entry.pk), 'related': 'http://testserver/entries/{}/blog'.format(self.entry.pk) } got = field.get_links(self.entry) @@ -241,7 +241,7 @@ def test_many_serializer_method_hyperlinked_related_field(self): self.assertRaises(SkipField, field.get_attribute, self.entry) expected = { - 'self': 'http://testserver/entries/{}/relationships/comments'.format(self.entry.pk), + 'self': 'http://testserver/entries/{}/relationships/comments/'.format(self.entry.pk), 'related': 'http://testserver/entries/{}/comments'.format(self.entry.pk) } got = field.get_links(self.entry) diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 2997154e..e7adf617 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -68,24 +68,24 @@ def test_get_entry_relationship_invalid_field(self): assert response.status_code == 404 def test_get_blog_relationship_entry_set(self): - response = self.client.get('/blogs/{}/relationships/entry_set'.format(self.blog.id)) + response = self.client.get('/blogs/{}/relationships/entry_set/'.format(self.blog.id)) expected_data = [{'type': format_resource_type('Entry'), 'id': str(self.first_entry.id)}, {'type': format_resource_type('Entry'), 'id': str(self.second_entry.id)}] assert response.data == expected_data def test_put_entry_relationship_blog_returns_405(self): - url = '/entries/{}/relationships/blog'.format(self.first_entry.id) + url = '/entries/{}/relationships/blog/'.format(self.first_entry.id) response = self.client.put(url, data={}) assert response.status_code == 405 def test_patch_invalid_entry_relationship_blog_returns_400(self): - url = '/entries/{}/relationships/blog'.format(self.first_entry.id) + url = '/entries/{}/relationships/blog/'.format(self.first_entry.id) response = self.client.patch(url, data={'data': {'invalid': ''}}) assert response.status_code == 400 def test_relationship_view_errors_format(self): - url = '/entries/{}/relationships/blog'.format(self.first_entry.id) + url = '/entries/{}/relationships/blog/'.format(self.first_entry.id) response = self.client.patch(url, data={'data': {'invalid': ''}}) assert response.status_code == 400 @@ -95,24 +95,24 @@ def test_relationship_view_errors_format(self): assert 'errors' in result def test_get_empty_to_one_relationship(self): - url = '/comments/{}/relationships/author'.format(self.first_entry.id) + url = '/comments/{}/relationships/author/'.format(self.first_entry.id) response = self.client.get(url) expected_data = None assert response.data == expected_data def test_get_to_many_relationship_self_link(self): - url = '/authors/{}/relationships/comments'.format(self.author.id) + url = '/authors/{}/relationships/comments/'.format(self.author.id) response = self.client.get(url) expected_data = { - 'links': {'self': 'http://testserver/authors/1/relationships/comments'}, + 'links': {'self': 'http://testserver/authors/1/relationships/comments/'}, 'data': [{'id': str(self.second_comment.id), 'type': format_resource_type('Comment')}] } assert json.loads(response.content.decode('utf-8')) == expected_data def test_patch_to_one_relationship(self): - url = '/entries/{}/relationships/blog'.format(self.first_entry.id) + url = '/entries/{}/relationships/blog/'.format(self.first_entry.id) request_data = { 'data': {'type': format_resource_type('Blog'), 'id': str(self.other_blog.id)} } @@ -124,7 +124,7 @@ def test_patch_to_one_relationship(self): assert response.data == request_data['data'] def test_patch_one_to_many_relationship(self): - url = '/blogs/{}/relationships/entry_set'.format(self.first_entry.id) + url = '/blogs/{}/relationships/entry_set/'.format(self.first_entry.id) request_data = { 'data': [{'type': format_resource_type('Entry'), 'id': str(self.first_entry.id)}, ] } @@ -136,7 +136,7 @@ def test_patch_one_to_many_relationship(self): assert response.data == request_data['data'] def test_patch_many_to_many_relationship(self): - url = '/entries/{}/relationships/authors'.format(self.first_entry.id) + url = '/entries/{}/relationships/authors/'.format(self.first_entry.id) request_data = { 'data': [ { @@ -153,7 +153,7 @@ def test_patch_many_to_many_relationship(self): assert response.data == request_data['data'] def test_post_to_one_relationship_should_fail(self): - url = '/entries/{}/relationships/blog'.format(self.first_entry.id) + url = '/entries/{}/relationships/blog/'.format(self.first_entry.id) request_data = { 'data': {'type': format_resource_type('Blog'), 'id': str(self.other_blog.id)} } @@ -161,7 +161,7 @@ def test_post_to_one_relationship_should_fail(self): assert response.status_code == 405, response.content.decode() def test_post_to_many_relationship_with_no_change(self): - url = '/entries/{}/relationships/comments'.format(self.first_entry.id) + url = '/entries/{}/relationships/comments/'.format(self.first_entry.id) request_data = { 'data': [{'type': format_resource_type('Comment'), 'id': str(self.first_comment.id)}, ] } @@ -170,7 +170,7 @@ def test_post_to_many_relationship_with_no_change(self): assert len(response.rendered_content) == 0, response.rendered_content.decode() def test_post_to_many_relationship_with_change(self): - url = '/entries/{}/relationships/comments'.format(self.first_entry.id) + url = '/entries/{}/relationships/comments/'.format(self.first_entry.id) request_data = { 'data': [{'type': format_resource_type('Comment'), 'id': str(self.second_comment.id)}, ] } @@ -180,7 +180,7 @@ def test_post_to_many_relationship_with_change(self): assert request_data['data'][0] in response.data def test_delete_to_one_relationship_should_fail(self): - url = '/entries/{}/relationships/blog'.format(self.first_entry.id) + url = '/entries/{}/relationships/blog/'.format(self.first_entry.id) request_data = { 'data': {'type': format_resource_type('Blog'), 'id': str(self.other_blog.id)} } @@ -205,7 +205,7 @@ def test_delete_relationship_overriding_with_none(self): assert response.data['author'] is None def test_delete_to_many_relationship_with_no_change(self): - url = '/entries/{}/relationships/comments'.format(self.first_entry.id) + url = '/entries/{}/relationships/comments/'.format(self.first_entry.id) request_data = { 'data': [{'type': format_resource_type('Comment'), 'id': str(self.second_comment.id)}, ] } @@ -214,7 +214,7 @@ def test_delete_to_many_relationship_with_no_change(self): assert len(response.rendered_content) == 0, response.rendered_content.decode() def test_delete_one_to_many_relationship_with_not_null_constraint(self): - url = '/entries/{}/relationships/comments'.format(self.first_entry.id) + url = '/entries/{}/relationships/comments/'.format(self.first_entry.id) request_data = { 'data': [{'type': format_resource_type('Comment'), 'id': str(self.first_comment.id)}, ] } @@ -222,7 +222,7 @@ def test_delete_one_to_many_relationship_with_not_null_constraint(self): assert response.status_code == 409, response.content.decode() def test_delete_to_many_relationship_with_change(self): - url = '/authors/{}/relationships/comments'.format(self.author.id) + url = '/authors/{}/relationships/comments/'.format(self.author.id) request_data = { 'data': [{'type': format_resource_type('Comment'), 'id': str(self.second_comment.id)}, ] } @@ -233,7 +233,7 @@ def test_new_comment_data_patch_to_many_relationship(self): entry = EntryFactory(blog=self.blog, authors=(self.author,)) comment = CommentFactory(entry=entry) - url = '/authors/{}/relationships/comments'.format(self.author.id) + url = '/authors/{}/relationships/comments/'.format(self.author.id) request_data = { 'data': [{'type': format_resource_type('Comment'), 'id': str(comment.id)}, ] } @@ -244,7 +244,7 @@ def test_new_comment_data_patch_to_many_relationship(self): } ], 'links': { - 'self': 'http://testserver/authors/{}/relationships/comments'.format( + 'self': 'http://testserver/authors/{}/relationships/comments/'.format( self.author.id ) } @@ -261,7 +261,7 @@ def test_new_comment_data_patch_to_many_relationship(self): } ], 'links': { - 'self': 'http://testserver/authors/{}/relationships/comments'.format( + 'self': 'http://testserver/authors/{}/relationships/comments/'.format( self.author.id ) } @@ -557,7 +557,7 @@ def test_get_object_gives_correct_entry(self): 'related': 'http://testserver/entries/{}' '/blog'.format(self.second_entry.id), 'self': 'http://testserver/entries/{}' - '/relationships/blog_hyperlinked'.format(self.second_entry.id) + '/relationships/blog_hyperlinked/'.format(self.second_entry.id) } }, 'comments': { @@ -569,7 +569,7 @@ def test_get_object_gives_correct_entry(self): 'related': 'http://testserver/entries/{}' '/comments'.format(self.second_entry.id), 'self': 'http://testserver/entries/{}/relationships' - '/comments_hyperlinked'.format(self.second_entry.id) + '/comments_hyperlinked/'.format(self.second_entry.id) } }, 'featuredHyperlinked': { @@ -577,7 +577,7 @@ def test_get_object_gives_correct_entry(self): 'related': 'http://testserver/entries/{}' '/featured'.format(self.second_entry.id), 'self': 'http://testserver/entries/{}/relationships' - '/featured_hyperlinked'.format(self.second_entry.id) + '/featured_hyperlinked/'.format(self.second_entry.id) } }, 'suggested': { @@ -586,7 +586,7 @@ def test_get_object_gives_correct_entry(self): 'related': 'http://testserver/entries/{}' '/suggested/'.format(self.second_entry.id), 'self': 'http://testserver/entries/{}' - '/relationships/suggested'.format(self.second_entry.id) + '/relationships/suggested/'.format(self.second_entry.id) } }, 'suggestedHyperlinked': { @@ -594,7 +594,7 @@ def test_get_object_gives_correct_entry(self): 'related': 'http://testserver/entries/{}' '/suggested/'.format(self.second_entry.id), 'self': 'http://testserver/entries/{}/relationships' - '/suggested_hyperlinked'.format(self.second_entry.id) + '/suggested_hyperlinked/'.format(self.second_entry.id) } }, 'tags': {'data': []}}, diff --git a/example/urls_test.py b/example/urls_test.py index e51121ac..dbd1b4e0 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -44,7 +44,7 @@ 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'), @@ -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'), ] From e126b62595448283890a2d29825e34d90ce8c8fe Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sat, 24 Aug 2019 13:15:16 -0400 Subject: [PATCH 06/11] fix _expand_relationships to actually do that --- example/tests/test_openapi.py | 31 +++++++--------------- rest_framework_json_api/schemas/openapi.py | 2 +- 2 files changed, 10 insertions(+), 23 deletions(-) diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index 118de5b5..922c19c4 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -137,6 +137,9 @@ 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/ @@ -145,6 +148,12 @@ def test_schema_related_serializers(self): 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 weird case (SerializerMethodRelatedField) + # TODO: '/authors/{id}/relationships/first_entry' supposed to be there? + # 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'] @@ -153,25 +162,3 @@ def test_schema_related_serializers(self): first_props = first_schema['properties']['data']['properties']['attributes']['properties'] assert 'headline' in first_props assert first_props['headline'] == {'type': 'string', 'maxLength': 255} - - # def test_retrieve_relationships(self): - # path = '/authors/{id}/relationships/bio/' - # method = 'GET' - # - # view = create_view_with_kw( - # views.AuthorViewSet, - # method, - # create_request(path), - # {'get': 'retrieve_related'} - # ) - # inspector = AutoSchema() - # inspector.view = view - # - # operation = inspector.get_operation(path, method) - # assert 'responses' in operation - # assert '200' in operation['responses'] - # resp = operation['responses']['200']['content'] - # data = resp['application/vnd.api+json']['schema']['properties']['data'] - # assert data['type'] == 'object' - # assert data['required'] == ['type', 'id'] - # assert data['properties']['type'] == {'$ref': '#/components/schemas/type'} diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index dbe6a0de..3a4fd9ca 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -355,7 +355,7 @@ def _expand_relationships(self, path, method, view): :return:list[tuple(path, method, view, action)] """ queryset = view.get_queryset() - if not queryset or not queryset.model: + if not queryset.model: return [(path, method, view, getattr(view, 'action', '')), ] result = [] m = queryset.model From 2e54524218a9b785d6073f59e7eb5787391b773b Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sat, 24 Aug 2019 13:48:54 -0400 Subject: [PATCH 07/11] add a TODO for SerializerMethodRelatedField schema --- example/tests/test_openapi.py | 3 ++- rest_framework_json_api/schemas/openapi.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index 922c19c4..cf0f2745 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -151,8 +151,9 @@ def test_schema_related_serializers(self): # 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 weird case (SerializerMethodRelatedField) + # 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'] diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 3a4fd9ca..7954cb36 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -358,6 +358,7 @@ def _expand_relationships(self, path, method, view): if not queryset.model: return [(path, method, view, getattr(view, 'action', '')), ] result = [] + # TODO what about serializer-only (non-model) fields? m = queryset.model for field in [f for f in dir(m) if not f.startswith('_')]: attr = getattr(m, field) From 518313ea05bd6d581d7918acc01fcfc1353e8eff Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Fri, 30 Aug 2019 12:55:43 -0400 Subject: [PATCH 08/11] update TODO list --- rest_framework_json_api/schemas/openapi.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 7954cb36..0913c7e1 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -358,7 +358,10 @@ def _expand_relationships(self, path, method, view): if not queryset.model: return [(path, method, view, getattr(view, 'action', '')), ] result = [] - # TODO what about serializer-only (non-model) fields? + # 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) From 1ac6ff613aa83a49ab4e0de76f99de022a873a84 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Fri, 30 Aug 2019 13:22:13 -0400 Subject: [PATCH 09/11] Revert "add trailing / to urls_test as well" This reverts commit d43f7cade42fddf239c3dd36b38b667c94ab4107. --- .../test_non_paginated_responses.py | 20 ++++---- example/tests/integration/test_pagination.py | 10 ++-- example/tests/test_filters.py | 10 ++-- example/tests/test_relations.py | 8 +-- example/tests/test_views.py | 50 +++++++++---------- example/urls_test.py | 10 ++-- 6 files changed, 54 insertions(+), 54 deletions(-) diff --git a/example/tests/integration/test_non_paginated_responses.py b/example/tests/integration/test_non_paginated_responses.py index 94abfe7a..9f1f532e 100644 --- a/example/tests/integration/test_non_paginated_responses.py +++ b/example/tests/integration/test_non_paginated_responses.py @@ -35,7 +35,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "blogHyperlinked": { "links": { "related": "http://testserver/entries/1/blog", - "self": "http://testserver/entries/1/relationships/blog_hyperlinked/" + "self": "http://testserver/entries/1/relationships/blog_hyperlinked" } }, "authors": { @@ -49,27 +49,27 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "commentsHyperlinked": { "links": { "related": "http://testserver/entries/1/comments", - "self": "http://testserver/entries/1/relationships/comments_hyperlinked/" # noqa: E501 + "self": "http://testserver/entries/1/relationships/comments_hyperlinked" } }, "suggested": { "data": [{"type": "entries", "id": "2"}], "links": { "related": "http://testserver/entries/1/suggested/", - "self": "http://testserver/entries/1/relationships/suggested/" + "self": "http://testserver/entries/1/relationships/suggested" } }, "suggestedHyperlinked": { "links": { "related": "http://testserver/entries/1/suggested/", "self": "http://testserver/entries/1" - "/relationships/suggested_hyperlinked/" + "/relationships/suggested_hyperlinked" } }, "featuredHyperlinked": { "links": { "related": "http://testserver/entries/1/featured", - "self": "http://testserver/entries/1/relationships/featured_hyperlinked/" # noqa: E501 + "self": "http://testserver/entries/1/relationships/featured_hyperlinked" } }, "tags": { @@ -98,7 +98,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "blogHyperlinked": { "links": { "related": "http://testserver/entries/2/blog", - "self": "http://testserver/entries/2/relationships/blog_hyperlinked/", + "self": "http://testserver/entries/2/relationships/blog_hyperlinked", } }, "authors": { @@ -112,27 +112,27 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "commentsHyperlinked": { "links": { "related": "http://testserver/entries/2/comments", - "self": "http://testserver/entries/2/relationships/comments_hyperlinked/" # noqa: E501 + "self": "http://testserver/entries/2/relationships/comments_hyperlinked" } }, "suggested": { "data": [{"type": "entries", "id": "1"}], "links": { "related": "http://testserver/entries/2/suggested/", - "self": "http://testserver/entries/2/relationships/suggested/" + "self": "http://testserver/entries/2/relationships/suggested" } }, "suggestedHyperlinked": { "links": { "related": "http://testserver/entries/2/suggested/", "self": "http://testserver/entries/2" - "/relationships/suggested_hyperlinked/" + "/relationships/suggested_hyperlinked" } }, "featuredHyperlinked": { "links": { "related": "http://testserver/entries/2/featured", - "self": "http://testserver/entries/2/relationships/featured_hyperlinked/" # noqa: E501 + "self": "http://testserver/entries/2/relationships/featured_hyperlinked" } }, "tags": { diff --git a/example/tests/integration/test_pagination.py b/example/tests/integration/test_pagination.py index b7f6b983..25d01c44 100644 --- a/example/tests/integration/test_pagination.py +++ b/example/tests/integration/test_pagination.py @@ -35,7 +35,7 @@ def test_pagination_with_single_entry(single_entry, client): "blogHyperlinked": { "links": { "related": "http://testserver/entries/1/blog", - "self": "http://testserver/entries/1/relationships/blog_hyperlinked/", + "self": "http://testserver/entries/1/relationships/blog_hyperlinked", } }, "authors": { @@ -49,27 +49,27 @@ def test_pagination_with_single_entry(single_entry, client): "commentsHyperlinked": { "links": { "related": "http://testserver/entries/1/comments", - "self": "http://testserver/entries/1/relationships/comments_hyperlinked/" # noqa: E501 + "self": "http://testserver/entries/1/relationships/comments_hyperlinked" } }, "suggested": { "data": [], "links": { "related": "http://testserver/entries/1/suggested/", - "self": "http://testserver/entries/1/relationships/suggested/" + "self": "http://testserver/entries/1/relationships/suggested" } }, "suggestedHyperlinked": { "links": { "related": "http://testserver/entries/1/suggested/", "self": "http://testserver/entries/1" - "/relationships/suggested_hyperlinked/" + "/relationships/suggested_hyperlinked" } }, "featuredHyperlinked": { "links": { "related": "http://testserver/entries/1/featured", - "self": "http://testserver/entries/1/relationships/featured_hyperlinked/" # noqa: E501 + "self": "http://testserver/entries/1/relationships/featured_hyperlinked" } }, "tags": { diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index e805307a..a42480c2 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -362,7 +362,7 @@ def test_search_keywords(self): }, 'blogHyperlinked': { 'links': { - 'self': 'http://testserver/entries/7/relationships/blog_hyperlinked/', # noqa: E501 + 'self': 'http://testserver/entries/7/relationships/blog_hyperlinked', # noqa: E501 'related': 'http://testserver/entries/7/blog'} }, 'authors': { @@ -379,13 +379,13 @@ def test_search_keywords(self): }, 'commentsHyperlinked': { 'links': { - 'self': 'http://testserver/entries/7/relationships/comments_hyperlinked/', # noqa: E501 + 'self': 'http://testserver/entries/7/relationships/comments_hyperlinked', # noqa: E501 'related': 'http://testserver/entries/7/comments' } }, 'suggested': { 'links': { - 'self': 'http://testserver/entries/7/relationships/suggested/', + 'self': 'http://testserver/entries/7/relationships/suggested', 'related': 'http://testserver/entries/7/suggested/' }, 'data': [ @@ -404,7 +404,7 @@ def test_search_keywords(self): }, 'suggestedHyperlinked': { 'links': { - 'self': 'http://testserver/entries/7/relationships/suggested_hyperlinked/', # noqa: E501 + 'self': 'http://testserver/entries/7/relationships/suggested_hyperlinked', # noqa: E501 'related': 'http://testserver/entries/7/suggested/'} }, 'tags': { @@ -412,7 +412,7 @@ def test_search_keywords(self): }, 'featuredHyperlinked': { 'links': { - 'self': 'http://testserver/entries/7/relationships/featured_hyperlinked/', # noqa: E501 + 'self': 'http://testserver/entries/7/relationships/featured_hyperlinked', # noqa: E501 'related': 'http://testserver/entries/7/featured' } } diff --git a/example/tests/test_relations.py b/example/tests/test_relations.py index 21c91233..94db188a 100644 --- a/example/tests/test_relations.py +++ b/example/tests/test_relations.py @@ -177,7 +177,7 @@ def test_single_hyperlinked_related_field(self): self.assertRaises(SkipField, field.get_attribute, self.entry) links_expected = { - 'self': 'http://testserver/entries/{}/relationships/blog/'.format(self.entry.pk), + 'self': 'http://testserver/entries/{}/relationships/blog'.format(self.entry.pk), 'related': 'http://testserver/entries/{}/blog'.format(self.entry.pk) } got = field.get_links(self.entry) @@ -198,7 +198,7 @@ def test_many_hyperlinked_related_field(self): self.assertRaises(SkipField, field.get_attribute, self.entry) links_expected = { - 'self': 'http://testserver/entries/{}/relationships/comments/'.format(self.entry.pk), + 'self': 'http://testserver/entries/{}/relationships/comments'.format(self.entry.pk), 'related': 'http://testserver/entries/{}/comments'.format(self.entry.pk) } got = field.child_relation.get_links(self.entry) @@ -221,7 +221,7 @@ def test_single_serializer_method_hyperlinked_related_field(self): self.assertRaises(SkipField, field.get_attribute, self.entry) expected = { - 'self': 'http://testserver/entries/{}/relationships/blog/'.format(self.entry.pk), + 'self': 'http://testserver/entries/{}/relationships/blog'.format(self.entry.pk), 'related': 'http://testserver/entries/{}/blog'.format(self.entry.pk) } got = field.get_links(self.entry) @@ -241,7 +241,7 @@ def test_many_serializer_method_hyperlinked_related_field(self): self.assertRaises(SkipField, field.get_attribute, self.entry) expected = { - 'self': 'http://testserver/entries/{}/relationships/comments/'.format(self.entry.pk), + 'self': 'http://testserver/entries/{}/relationships/comments'.format(self.entry.pk), 'related': 'http://testserver/entries/{}/comments'.format(self.entry.pk) } got = field.get_links(self.entry) diff --git a/example/tests/test_views.py b/example/tests/test_views.py index e7adf617..2997154e 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -68,24 +68,24 @@ def test_get_entry_relationship_invalid_field(self): assert response.status_code == 404 def test_get_blog_relationship_entry_set(self): - response = self.client.get('/blogs/{}/relationships/entry_set/'.format(self.blog.id)) + response = self.client.get('/blogs/{}/relationships/entry_set'.format(self.blog.id)) expected_data = [{'type': format_resource_type('Entry'), 'id': str(self.first_entry.id)}, {'type': format_resource_type('Entry'), 'id': str(self.second_entry.id)}] assert response.data == expected_data def test_put_entry_relationship_blog_returns_405(self): - url = '/entries/{}/relationships/blog/'.format(self.first_entry.id) + url = '/entries/{}/relationships/blog'.format(self.first_entry.id) response = self.client.put(url, data={}) assert response.status_code == 405 def test_patch_invalid_entry_relationship_blog_returns_400(self): - url = '/entries/{}/relationships/blog/'.format(self.first_entry.id) + url = '/entries/{}/relationships/blog'.format(self.first_entry.id) response = self.client.patch(url, data={'data': {'invalid': ''}}) assert response.status_code == 400 def test_relationship_view_errors_format(self): - url = '/entries/{}/relationships/blog/'.format(self.first_entry.id) + url = '/entries/{}/relationships/blog'.format(self.first_entry.id) response = self.client.patch(url, data={'data': {'invalid': ''}}) assert response.status_code == 400 @@ -95,24 +95,24 @@ def test_relationship_view_errors_format(self): assert 'errors' in result def test_get_empty_to_one_relationship(self): - url = '/comments/{}/relationships/author/'.format(self.first_entry.id) + url = '/comments/{}/relationships/author'.format(self.first_entry.id) response = self.client.get(url) expected_data = None assert response.data == expected_data def test_get_to_many_relationship_self_link(self): - url = '/authors/{}/relationships/comments/'.format(self.author.id) + url = '/authors/{}/relationships/comments'.format(self.author.id) response = self.client.get(url) expected_data = { - 'links': {'self': 'http://testserver/authors/1/relationships/comments/'}, + 'links': {'self': 'http://testserver/authors/1/relationships/comments'}, 'data': [{'id': str(self.second_comment.id), 'type': format_resource_type('Comment')}] } assert json.loads(response.content.decode('utf-8')) == expected_data def test_patch_to_one_relationship(self): - url = '/entries/{}/relationships/blog/'.format(self.first_entry.id) + url = '/entries/{}/relationships/blog'.format(self.first_entry.id) request_data = { 'data': {'type': format_resource_type('Blog'), 'id': str(self.other_blog.id)} } @@ -124,7 +124,7 @@ def test_patch_to_one_relationship(self): assert response.data == request_data['data'] def test_patch_one_to_many_relationship(self): - url = '/blogs/{}/relationships/entry_set/'.format(self.first_entry.id) + url = '/blogs/{}/relationships/entry_set'.format(self.first_entry.id) request_data = { 'data': [{'type': format_resource_type('Entry'), 'id': str(self.first_entry.id)}, ] } @@ -136,7 +136,7 @@ def test_patch_one_to_many_relationship(self): assert response.data == request_data['data'] def test_patch_many_to_many_relationship(self): - url = '/entries/{}/relationships/authors/'.format(self.first_entry.id) + url = '/entries/{}/relationships/authors'.format(self.first_entry.id) request_data = { 'data': [ { @@ -153,7 +153,7 @@ def test_patch_many_to_many_relationship(self): assert response.data == request_data['data'] def test_post_to_one_relationship_should_fail(self): - url = '/entries/{}/relationships/blog/'.format(self.first_entry.id) + url = '/entries/{}/relationships/blog'.format(self.first_entry.id) request_data = { 'data': {'type': format_resource_type('Blog'), 'id': str(self.other_blog.id)} } @@ -161,7 +161,7 @@ def test_post_to_one_relationship_should_fail(self): assert response.status_code == 405, response.content.decode() def test_post_to_many_relationship_with_no_change(self): - url = '/entries/{}/relationships/comments/'.format(self.first_entry.id) + url = '/entries/{}/relationships/comments'.format(self.first_entry.id) request_data = { 'data': [{'type': format_resource_type('Comment'), 'id': str(self.first_comment.id)}, ] } @@ -170,7 +170,7 @@ def test_post_to_many_relationship_with_no_change(self): assert len(response.rendered_content) == 0, response.rendered_content.decode() def test_post_to_many_relationship_with_change(self): - url = '/entries/{}/relationships/comments/'.format(self.first_entry.id) + url = '/entries/{}/relationships/comments'.format(self.first_entry.id) request_data = { 'data': [{'type': format_resource_type('Comment'), 'id': str(self.second_comment.id)}, ] } @@ -180,7 +180,7 @@ def test_post_to_many_relationship_with_change(self): assert request_data['data'][0] in response.data def test_delete_to_one_relationship_should_fail(self): - url = '/entries/{}/relationships/blog/'.format(self.first_entry.id) + url = '/entries/{}/relationships/blog'.format(self.first_entry.id) request_data = { 'data': {'type': format_resource_type('Blog'), 'id': str(self.other_blog.id)} } @@ -205,7 +205,7 @@ def test_delete_relationship_overriding_with_none(self): assert response.data['author'] is None def test_delete_to_many_relationship_with_no_change(self): - url = '/entries/{}/relationships/comments/'.format(self.first_entry.id) + url = '/entries/{}/relationships/comments'.format(self.first_entry.id) request_data = { 'data': [{'type': format_resource_type('Comment'), 'id': str(self.second_comment.id)}, ] } @@ -214,7 +214,7 @@ def test_delete_to_many_relationship_with_no_change(self): assert len(response.rendered_content) == 0, response.rendered_content.decode() def test_delete_one_to_many_relationship_with_not_null_constraint(self): - url = '/entries/{}/relationships/comments/'.format(self.first_entry.id) + url = '/entries/{}/relationships/comments'.format(self.first_entry.id) request_data = { 'data': [{'type': format_resource_type('Comment'), 'id': str(self.first_comment.id)}, ] } @@ -222,7 +222,7 @@ def test_delete_one_to_many_relationship_with_not_null_constraint(self): assert response.status_code == 409, response.content.decode() def test_delete_to_many_relationship_with_change(self): - url = '/authors/{}/relationships/comments/'.format(self.author.id) + url = '/authors/{}/relationships/comments'.format(self.author.id) request_data = { 'data': [{'type': format_resource_type('Comment'), 'id': str(self.second_comment.id)}, ] } @@ -233,7 +233,7 @@ def test_new_comment_data_patch_to_many_relationship(self): entry = EntryFactory(blog=self.blog, authors=(self.author,)) comment = CommentFactory(entry=entry) - url = '/authors/{}/relationships/comments/'.format(self.author.id) + url = '/authors/{}/relationships/comments'.format(self.author.id) request_data = { 'data': [{'type': format_resource_type('Comment'), 'id': str(comment.id)}, ] } @@ -244,7 +244,7 @@ def test_new_comment_data_patch_to_many_relationship(self): } ], 'links': { - 'self': 'http://testserver/authors/{}/relationships/comments/'.format( + 'self': 'http://testserver/authors/{}/relationships/comments'.format( self.author.id ) } @@ -261,7 +261,7 @@ def test_new_comment_data_patch_to_many_relationship(self): } ], 'links': { - 'self': 'http://testserver/authors/{}/relationships/comments/'.format( + 'self': 'http://testserver/authors/{}/relationships/comments'.format( self.author.id ) } @@ -557,7 +557,7 @@ def test_get_object_gives_correct_entry(self): 'related': 'http://testserver/entries/{}' '/blog'.format(self.second_entry.id), 'self': 'http://testserver/entries/{}' - '/relationships/blog_hyperlinked/'.format(self.second_entry.id) + '/relationships/blog_hyperlinked'.format(self.second_entry.id) } }, 'comments': { @@ -569,7 +569,7 @@ def test_get_object_gives_correct_entry(self): 'related': 'http://testserver/entries/{}' '/comments'.format(self.second_entry.id), 'self': 'http://testserver/entries/{}/relationships' - '/comments_hyperlinked/'.format(self.second_entry.id) + '/comments_hyperlinked'.format(self.second_entry.id) } }, 'featuredHyperlinked': { @@ -577,7 +577,7 @@ def test_get_object_gives_correct_entry(self): 'related': 'http://testserver/entries/{}' '/featured'.format(self.second_entry.id), 'self': 'http://testserver/entries/{}/relationships' - '/featured_hyperlinked/'.format(self.second_entry.id) + '/featured_hyperlinked'.format(self.second_entry.id) } }, 'suggested': { @@ -586,7 +586,7 @@ def test_get_object_gives_correct_entry(self): 'related': 'http://testserver/entries/{}' '/suggested/'.format(self.second_entry.id), 'self': 'http://testserver/entries/{}' - '/relationships/suggested/'.format(self.second_entry.id) + '/relationships/suggested'.format(self.second_entry.id) } }, 'suggestedHyperlinked': { @@ -594,7 +594,7 @@ def test_get_object_gives_correct_entry(self): 'related': 'http://testserver/entries/{}' '/suggested/'.format(self.second_entry.id), 'self': 'http://testserver/entries/{}/relationships' - '/suggested_hyperlinked/'.format(self.second_entry.id) + '/suggested_hyperlinked'.format(self.second_entry.id) } }, 'tags': {'data': []}}, diff --git a/example/urls_test.py b/example/urls_test.py index dbd1b4e0..e51121ac 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -44,7 +44,7 @@ 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'), @@ -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'), ] From 616caa8d156fd29ef18f285fb550e7980657c5f9 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Fri, 30 Aug 2019 13:22:24 -0400 Subject: [PATCH 10/11] Revert "add trailing /$ to urlpatterns" This reverts commit 29eb7762b36425c791abe5e214e6043d39b17fa6. --- example/urls.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/example/urls.py b/example/urls.py index 18a70a4f..79d3b1c1 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'), ] From 18c677b012595c07a23cf1376a54e2db086c4977 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Fri, 30 Aug 2019 13:29:44 -0400 Subject: [PATCH 11/11] revert adding /'s to ends of urls and instead add $ --- example/tests/test_openapi.py | 4 ++-- example/urls.py | 18 +++++++++--------- example/urls_test.py | 22 +++++++++++----------- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index cf0f2745..4b4d0bde 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -149,8 +149,8 @@ def test_schema_related_serializers(self): 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'] + 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. 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'), ]