diff --git a/CHANGELOG.md b/CHANGELOG.md index 9502f476..63865fbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ any parts of the framework not mentioned in the documentation should generally b * Removed support for Python 3.8. * Removed support for Django REST framework 3.14. * Removed support for Django 5.0. - +* Removed built-in support for generating OpenAPI schema. Use [drf-spectacular-json-api](https://github.com/jokiefer/drf-spectacular-json-api/) instead. ## [7.1.0] - 2024-10-25 diff --git a/README.rst b/README.rst index c0e95a19..05c01c04 100644 --- a/README.rst +++ b/README.rst @@ -114,7 +114,6 @@ Install using ``pip``... $ # for optional package integrations $ pip install djangorestframework-jsonapi['django-filter'] $ pip install djangorestframework-jsonapi['django-polymorphic'] - $ pip install djangorestframework-jsonapi['openapi'] or from source... @@ -156,8 +155,6 @@ installed and activated: Browse to * http://localhost:8000 for the list of available collections (in a non-JSON:API format!), -* http://localhost:8000/swagger-ui/ for a Swagger user interface to the dynamic schema view, or -* http://localhost:8000/openapi for the schema view's OpenAPI specification document. ----- diff --git a/docs/getting-started.md b/docs/getting-started.md index 4052450b..81040e8e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -69,7 +69,6 @@ Install using `pip`... # for optional package integrations pip install djangorestframework-jsonapi['django-filter'] pip install djangorestframework-jsonapi['django-polymorphic'] - pip install djangorestframework-jsonapi['openapi'] or from source... @@ -100,8 +99,6 @@ and add `rest_framework_json_api` to your `INSTALLED_APPS` setting below `rest_f Browse to * [http://localhost:8000](http://localhost:8000) for the list of available collections (in a non-JSON:API format!), -* [http://localhost:8000/swagger-ui/](http://localhost:8000/swagger-ui/) for a Swagger user interface to the dynamic schema view, or -* [http://localhost:8000/openapi](http://localhost:8000/openapi) for the schema view's OpenAPI specification document. ## Running Tests diff --git a/docs/usage.md b/docs/usage.md index 1a2cb195..00c12ee8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1054,139 +1054,6 @@ The `prefetch_related` case will issue 4 queries, but they will be small and fas ### Errors --> -## Generating an OpenAPI Specification (OAS) 3.0 schema document - -DRF has a [OAS schema functionality](https://www.django-rest-framework.org/api-guide/schemas/) to generate an -[OAS 3.0 schema](https://www.openapis.org/) as a YAML or JSON file. - -DJA extends DRF's schema support to generate an OAS schema in the JSON:API format. - ---- - -**Deprecation notice:** - -REST framework's built-in support for generating OpenAPI schemas is -**deprecated** in favor of 3rd party packages that can provide this -functionality instead. Therefore we have also deprecated the schema support in -Django REST framework JSON:API. The built-in support will be retired over the -next releases. - -As a full-fledged replacement, we recommend the [drf-spectacular-json-api] package. - ---- - -### AutoSchema Settings - -In order to produce an OAS schema that properly represents the JSON:API structure -you have to either add a `schema` attribute to each view class or set the `REST_FRAMEWORK['DEFAULT_SCHEMA_CLASS']` -to DJA's version of AutoSchema. - -#### View-based - -```python -from rest_framework_json_api.schemas.openapi import AutoSchema - -class MyViewset(ModelViewSet): - schema = AutoSchema - ... -``` - -#### Default schema class - -```python -REST_FRAMEWORK = { - # ... - 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema', -} -``` - -### Adding additional OAS schema content - -You can extend the OAS schema document by subclassing -[`SchemaGenerator`](https://www.django-rest-framework.org/api-guide/schemas/#schemagenerator) -and extending `get_schema`. - - -Here's an example that adds OAS `info` and `servers` objects. - -```python -from rest_framework_json_api.schemas.openapi import SchemaGenerator as JSONAPISchemaGenerator - - -class MySchemaGenerator(JSONAPISchemaGenerator): - """ - Describe my OAS schema info in detail (overriding what DRF put in) and list the servers where it can be found. - """ - def get_schema(self, request, public): - schema = super().get_schema(request, public) - schema['info'] = { - 'version': '1.0', - 'title': 'my demo API', - 'description': 'A demonstration of [OAS 3.0](https://www.openapis.org)', - 'contact': { - 'name': 'my name' - }, - 'license': { - 'name': 'BSD 2 clause', - 'url': 'https://github.com/django-json-api/django-rest-framework-json-api/blob/main/LICENSE', - } - } - schema['servers'] = [ - {'url': 'http://localhost/v1', 'description': 'local docker'}, - {'url': 'http://localhost:8000/v1', 'description': 'local dev'}, - {'url': 'https://api.example.com/v1', 'description': 'demo server'}, - {'url': '{serverURL}', 'description': 'provide your server URL', - 'variables': {'serverURL': {'default': 'http://localhost:8000/v1'}}} - ] - return schema -``` - -### Generate a Static Schema on Command Line - -See [DRF documentation for generateschema](https://www.django-rest-framework.org/api-guide/schemas/#generating-a-static-schema-with-the-generateschema-management-command) -To generate a static OAS schema document, using the `generateschema` management command, you **must override DRF's default** `generator_class` with the DJA-specific version: - -```text -$ ./manage.py generateschema --generator_class rest_framework_json_api.schemas.openapi.SchemaGenerator -``` - -You can then use any number of OAS tools such as -[swagger-ui-watcher](https://www.npmjs.com/package/swagger-ui-watcher) -to render the schema: -```text -$ swagger-ui-watcher myschema.yaml -``` - -Note: Swagger-ui-watcher will complain that "DELETE operations cannot have a requestBody" -but it will still work. This [error](https://github.com/OAI/OpenAPI-Specification/pull/2117) -in the OAS specification will be fixed when [OAS 3.1.0](https://www.openapis.org/blog/2020/06/18/openapi-3-1-0-rc0-its-here) -is published. - -([swagger-ui](https://www.npmjs.com/package/swagger-ui) will work silently.) - -### Generate a Dynamic Schema in a View - -See [DRF documentation for a Dynamic Schema](https://www.django-rest-framework.org/api-guide/schemas/#generating-a-dynamic-schema-with-schemaview). - -```python -from rest_framework.schemas import get_schema_view - -urlpatterns = [ - ... - path('openapi', get_schema_view( - title="Example API", - description="API for all things …", - version="1.0.0", - generator_class=MySchemaGenerator, - ), name='openapi-schema'), - path('swagger-ui/', TemplateView.as_view( - template_name='swagger-ui.html', - extra_context={'schema_url': 'openapi-schema'} - ), name='swagger-ui'), - ... -] -``` - ## Third Party Packages ### About Third Party Packages diff --git a/example/settings/dev.py b/example/settings/dev.py index 7b40e61f..05cab4d1 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -83,7 +83,6 @@ "rest_framework_json_api.renderers.BrowsableAPIRenderer", ), "DEFAULT_METADATA_CLASS": "rest_framework_json_api.metadata.JSONAPIMetadata", - "DEFAULT_SCHEMA_CLASS": "rest_framework_json_api.schemas.openapi.AutoSchema", "DEFAULT_FILTER_BACKENDS": ( "rest_framework_json_api.filters.OrderingFilter", "rest_framework_json_api.django_filters.DjangoFilterBackend", diff --git a/example/templates/swagger-ui.html b/example/templates/swagger-ui.html deleted file mode 100644 index 29776491..00000000 --- a/example/templates/swagger-ui.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - Swagger - - - - - -
- - - - \ No newline at end of file diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr deleted file mode 100644 index f72c6ff8..00000000 --- a/example/tests/__snapshots__/test_openapi.ambr +++ /dev/null @@ -1,1414 +0,0 @@ -# serializer version: 1 -# name: test_delete_request - ''' - { - "description": "", - "operationId": "destroy/authors/{id}", - "parameters": [ - { - "description": "A unique integer value identifying this author.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/onlymeta" - } - } - }, - "description": "[OK](https://jsonapi.org/format/#crud-deleting-responses-200)" - }, - "202": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/datum" - } - } - }, - "description": "Accepted for [asynchronous processing](https://jsonapi.org/recommendations/#asynchronous-processing)" - }, - "204": { - "description": "[no content](https://jsonapi.org/format/#crud-deleting-responses-204)" - }, - "400": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "bad request" - }, - "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)" - }, - "429": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "too many requests" - } - }, - "tags": [ - "authors" - ] - } - ''' -# --- -# name: test_patch_request - ''' - { - "description": "", - "operationId": "update/authors/{id}", - "parameters": [ - { - "description": "A unique integer value identifying this author.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "additionalProperties": false, - "properties": { - "attributes": { - "properties": { - "defaults": { - "default": "default", - "description": "help for defaults", - "maxLength": 20, - "minLength": 3, - "type": "string", - "writeOnly": true - }, - "email": { - "format": "email", - "maxLength": 254, - "type": "string" - }, - "fullName": { - "maxLength": 50, - "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": { - "authorType": { - "$ref": "#/components/schemas/reltoone" - }, - "bio": { - "$ref": "#/components/schemas/reltoone" - }, - "comments": { - "$ref": "#/components/schemas/reltomany" - }, - "entries": { - "$ref": "#/components/schemas/reltomany" - }, - "firstEntry": { - "$ref": "#/components/schemas/reltoone" - } - }, - "type": "object" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type", - "id" - ], - "type": "object" - } - }, - "required": [ - "data" - ] - } - } - } - }, - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/Author" - }, - "included": { - "items": { - "$ref": "#/components/schemas/include" - }, - "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}" - }, - "400": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "bad request" - }, - "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)" - }, - "429": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "too many requests" - } - }, - "tags": [ - "authors" - ] - } - ''' -# --- -# name: test_path_with_id_parameter - ''' - { - "description": "", - "operationId": "retrieve/authors/{id}/", - "parameters": [ - { - "description": "A unique integer value identifying this author.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - }, - { - "$ref": "#/components/parameters/include" - }, - { - "$ref": "#/components/parameters/fields" - }, - { - "description": "[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)", - "in": "query", - "name": "sort", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "author_type", - "in": "query", - "name": "filter[authorType]", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "name", - "in": "query", - "name": "filter[name]", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "A search term.", - "in": "query", - "name": "filter[search]", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/AuthorDetail" - }, - "included": { - "items": { - "$ref": "#/components/schemas/include" - }, - "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}/" - }, - "400": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "bad request" - }, - "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" - }, - "429": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "too many requests" - } - }, - "tags": [ - "authors" - ] - } - ''' -# --- -# name: test_path_without_parameters - ''' - { - "description": "", - "operationId": "List/authors/", - "parameters": [ - { - "$ref": "#/components/parameters/include" - }, - { - "$ref": "#/components/parameters/fields" - }, - { - "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": "[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)", - "in": "query", - "name": "sort", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "author_type", - "in": "query", - "name": "filter[authorType]", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "name", - "in": "query", - "name": "filter[name]", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "A search term.", - "in": "query", - "name": "filter[search]", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/AuthorList" - }, - "type": "array" - }, - "included": { - "items": { - "$ref": "#/components/schemas/include" - }, - "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/" - }, - "400": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "bad request" - }, - "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" - }, - "429": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "too many requests" - } - }, - "tags": [ - "authors" - ] - } - ''' -# --- -# name: test_post_request - ''' - { - "description": "", - "operationId": "create/authors/", - "parameters": [], - "requestBody": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "additionalProperties": false, - "properties": { - "attributes": { - "properties": { - "defaults": { - "default": "default", - "description": "help for defaults", - "maxLength": 20, - "minLength": 3, - "type": "string", - "writeOnly": true - }, - "email": { - "format": "email", - "maxLength": 254, - "type": "string" - }, - "fullName": { - "maxLength": 50, - "type": "string" - }, - "name": { - "maxLength": 50, - "type": "string" - } - }, - "required": [ - "name", - "fullName", - "email" - ], - "type": "object" - }, - "id": { - "$ref": "#/components/schemas/id" - }, - "links": { - "properties": { - "self": { - "$ref": "#/components/schemas/link" - } - }, - "type": "object" - }, - "relationships": { - "properties": { - "authorType": { - "$ref": "#/components/schemas/reltoone" - }, - "bio": { - "$ref": "#/components/schemas/reltoone" - }, - "comments": { - "$ref": "#/components/schemas/reltomany" - }, - "entries": { - "$ref": "#/components/schemas/reltomany" - }, - "firstEntry": { - "$ref": "#/components/schemas/reltoone" - } - }, - "required": [ - "bio", - "entries", - "comments" - ], - "type": "object" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type" - ], - "type": "object" - } - }, - "required": [ - "data" - ] - } - } - } - }, - "responses": { - "201": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/Author" - }, - "included": { - "items": { - "$ref": "#/components/schemas/include" - }, - "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." - }, - "400": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "bad request" - }, - "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)" - }, - "429": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "too many requests" - } - }, - "tags": [ - "authors" - ] - } - ''' -# --- -# name: test_schema_construction - ''' - { - "components": { - "parameters": { - "fields": { - "description": "[sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets).\nUse fields[\\]=field1,field2,...,fieldN", - "explode": true, - "in": "query", - "name": "fields", - "required": false, - "schema": { - "type": "object" - }, - "style": "deepObject" - }, - "include": { - "description": "[list of included related resources](https://jsonapi.org/format/#fetching-includes)", - "in": "query", - "name": "include", - "required": false, - "schema": { - "type": "string" - }, - "style": "form" - } - }, - "schemas": { - "AuthorList": { - "additionalProperties": false, - "properties": { - "attributes": { - "properties": { - "defaults": { - "default": "default", - "description": "help for defaults", - "maxLength": 20, - "minLength": 3, - "type": "string", - "writeOnly": true - }, - "email": { - "format": "email", - "maxLength": 254, - "type": "string" - }, - "fullName": { - "maxLength": 50, - "type": "string" - }, - "initials": { - "readOnly": true, - "type": "string" - }, - "name": { - "maxLength": 50, - "type": "string" - } - }, - "required": [ - "name", - "fullName", - "email" - ], - "type": "object" - }, - "id": { - "$ref": "#/components/schemas/id" - }, - "links": { - "properties": { - "self": { - "$ref": "#/components/schemas/link" - } - }, - "type": "object" - }, - "relationships": { - "properties": { - "authorType": { - "$ref": "#/components/schemas/reltoone" - }, - "bio": { - "$ref": "#/components/schemas/reltoone" - }, - "comments": { - "$ref": "#/components/schemas/reltomany" - }, - "entries": { - "$ref": "#/components/schemas/reltomany" - }, - "firstEntry": { - "$ref": "#/components/schemas/reltoone" - } - }, - "required": [ - "bio", - "entries", - "comments" - ], - "type": "object" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type", - "id" - ], - "type": "object" - }, - "ResourceIdentifierObject": { - "oneOf": [ - { - "$ref": "#/components/schemas/relationshipToOne" - }, - { - "$ref": "#/components/schemas/relationshipToMany" - } - ] - }, - "datum": { - "description": "singular item", - "properties": { - "data": { - "$ref": "#/components/schemas/resource" - } - } - }, - "error": { - "additionalProperties": false, - "properties": { - "code": { - "type": "string" - }, - "detail": { - "type": "string" - }, - "id": { - "type": "string" - }, - "links": { - "$ref": "#/components/schemas/links" - }, - "source": { - "properties": { - "meta": { - "$ref": "#/components/schemas/meta" - }, - "parameter": { - "description": "A string indicating which query parameter caused the error.", - "type": "string" - }, - "pointer": { - "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.", - "type": "string" - } - }, - "type": "object" - }, - "status": { - "type": "string" - }, - "title": { - "type": "string" - } - }, - "type": "object" - }, - "errors": { - "items": { - "$ref": "#/components/schemas/error" - }, - "type": "array", - "uniqueItems": true - }, - "failure": { - "properties": { - "errors": { - "$ref": "#/components/schemas/errors" - }, - "jsonapi": { - "$ref": "#/components/schemas/jsonapi" - }, - "links": { - "$ref": "#/components/schemas/links" - }, - "meta": { - "$ref": "#/components/schemas/meta" - } - }, - "required": [ - "errors" - ], - "type": "object" - }, - "id": { - "description": "Each resource object\u2019s type and id pair MUST [identify](https://jsonapi.org/format/#document-resource-object-identification) a single, unique resource.", - "type": "string" - }, - "include": { - "additionalProperties": false, - "properties": { - "attributes": { - "additionalProperties": true, - "type": "object" - }, - "id": { - "$ref": "#/components/schemas/id" - }, - "links": { - "$ref": "#/components/schemas/links" - }, - "meta": { - "$ref": "#/components/schemas/meta" - }, - "relationships": { - "additionalProperties": true, - "type": "object" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type", - "id" - ], - "type": "object" - }, - "jsonapi": { - "additionalProperties": false, - "description": "The server's implementation", - "properties": { - "meta": { - "$ref": "#/components/schemas/meta" - }, - "version": { - "type": "string" - } - }, - "type": "object" - }, - "link": { - "oneOf": [ - { - "description": "a string containing the link's URL", - "format": "uri-reference", - "type": "string" - }, - { - "properties": { - "href": { - "description": "a string containing the link's URL", - "format": "uri-reference", - "type": "string" - }, - "meta": { - "$ref": "#/components/schemas/meta" - } - }, - "required": [ - "href" - ], - "type": "object" - } - ] - }, - "linkage": { - "description": "the 'type' and 'id'", - "properties": { - "id": { - "$ref": "#/components/schemas/id" - }, - "meta": { - "$ref": "#/components/schemas/meta" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type", - "id" - ], - "type": "object" - }, - "links": { - "additionalProperties": { - "$ref": "#/components/schemas/link" - }, - "type": "object" - }, - "meta": { - "additionalProperties": true, - "type": "object" - }, - "nulltype": { - "default": null, - "nullable": true, - "type": "object" - }, - "onlymeta": { - "additionalProperties": false, - "properties": { - "meta": { - "$ref": "#/components/schemas/meta" - } - } - }, - "pageref": { - "oneOf": [ - { - "format": "uri-reference", - "type": "string" - }, - { - "$ref": "#/components/schemas/nulltype" - } - ] - }, - "pagination": { - "properties": { - "first": { - "$ref": "#/components/schemas/pageref" - }, - "last": { - "$ref": "#/components/schemas/pageref" - }, - "next": { - "$ref": "#/components/schemas/pageref" - }, - "prev": { - "$ref": "#/components/schemas/pageref" - } - }, - "type": "object" - }, - "relationshipLinks": { - "additionalProperties": true, - "description": "optional references to other resource objects", - "properties": { - "related": { - "$ref": "#/components/schemas/link" - }, - "self": { - "$ref": "#/components/schemas/link" - } - }, - "type": "object" - }, - "relationshipToMany": { - "description": "An array of objects each containing the 'type' and 'id' for to-many relationships", - "items": { - "$ref": "#/components/schemas/linkage" - }, - "type": "array", - "uniqueItems": true - }, - "relationshipToOne": { - "anyOf": [ - { - "$ref": "#/components/schemas/nulltype" - }, - { - "$ref": "#/components/schemas/linkage" - } - ], - "description": "reference to other resource in a to-one relationship" - }, - "reltomany": { - "description": "a multiple 'to-many' relationship", - "properties": { - "data": { - "$ref": "#/components/schemas/relationshipToMany" - }, - "links": { - "$ref": "#/components/schemas/relationshipLinks" - }, - "meta": { - "$ref": "#/components/schemas/meta" - } - }, - "type": "object" - }, - "reltoone": { - "description": "a singular 'to-one' relationship", - "properties": { - "data": { - "$ref": "#/components/schemas/relationshipToOne" - }, - "links": { - "$ref": "#/components/schemas/relationshipLinks" - }, - "meta": { - "$ref": "#/components/schemas/meta" - } - }, - "type": "object" - }, - "resource": { - "additionalProperties": false, - "properties": { - "attributes": { - "type": "object" - }, - "id": { - "$ref": "#/components/schemas/id" - }, - "links": { - "$ref": "#/components/schemas/links" - }, - "meta": { - "$ref": "#/components/schemas/meta" - }, - "relationships": { - "type": "object" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type", - "id" - ], - "type": "object" - }, - "type": { - "description": "The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships.", - "type": "string" - } - } - }, - "info": { - "title": "", - "version": "" - }, - "openapi": "3.0.2", - "paths": { - "/authors/": { - "get": { - "description": "", - "operationId": "List/authors/", - "parameters": [ - { - "$ref": "#/components/parameters/include" - }, - { - "$ref": "#/components/parameters/fields" - }, - { - "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": "[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)", - "in": "query", - "name": "sort", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "author_type", - "in": "query", - "name": "filter[authorType]", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "name", - "in": "query", - "name": "filter[name]", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "A search term.", - "in": "query", - "name": "filter[search]", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/AuthorList" - }, - "type": "array" - }, - "included": { - "items": { - "$ref": "#/components/schemas/include" - }, - "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/" - }, - "400": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "bad request" - }, - "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" - }, - "429": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "too many requests" - } - }, - "tags": [ - "authors" - ] - } - } - } - } - ''' -# --- diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py deleted file mode 100644 index fa2f9c73..00000000 --- a/example/tests/test_openapi.py +++ /dev/null @@ -1,230 +0,0 @@ -# largely based on DRF's test_openapi -import json - -import pytest -from django.test import RequestFactory, override_settings -from django.urls import re_path -from rest_framework.request import Request - -from rest_framework_json_api.schemas.openapi import AutoSchema, SchemaGenerator - -from example import views - -pytestmark = pytest.mark.filterwarnings("ignore:Built-in support") - - -def create_request(path): - factory = RequestFactory() - request = Request(factory.get(path)) - return request - - -def create_view_with_kw(view_cls, method, request, initkwargs): - generator = SchemaGenerator() - view = generator.create_view(view_cls.as_view(initkwargs), method, request) - return view - - -def test_path_without_parameters(snapshot): - path = "/authors/" - method = "GET" - - view = create_view_with_kw( - views.AuthorViewSet, method, create_request(path), {"get": "list"} - ) - inspector = AutoSchema() - inspector.view = view - - operation = inspector.get_operation(path, method) - assert snapshot == 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) - assert snapshot == 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) - assert snapshot == 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) - assert snapshot == 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) - assert snapshot == 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(snapshot): - """Construction of the top level dictionary.""" - patterns = [ - re_path("^authors/?$", views.AuthorViewSet.as_view({"get": "list"})), - ] - generator = SchemaGenerator(patterns=patterns) - - request = create_request("/") - schema = generator.get_schema(request=request) - - assert snapshot == json.dumps(schema, indent=2, sort_keys=True) - - -def test_schema_id_field(): - """ID field is only included in the root, not the attributes.""" - patterns = [ - re_path("^companies/?$", views.CompanyViewset.as_view({"get": "list"})), - ] - generator = SchemaGenerator(patterns=patterns) - - request = create_request("/") - schema = generator.get_schema(request=request) - - company_properties = schema["components"]["schemas"]["Company"]["properties"] - assert company_properties["id"] == {"$ref": "#/components/schemas/id"} - assert "id" not in company_properties["attributes"]["properties"] - - -def test_schema_subserializers(): - """Schema for child Serializers reflects the actual response structure.""" - patterns = [ - re_path( - "^questionnaires/?$", views.QuestionnaireViewset.as_view({"get": "list"}) - ), - ] - generator = SchemaGenerator(patterns=patterns) - - request = create_request("/") - schema = generator.get_schema(request=request) - - assert { - "type": "object", - "properties": { - "metadata": { - "type": "object", - "properties": { - "author": {"type": "string"}, - "producer": {"type": "string"}, - }, - "required": ["author"], - }, - "questions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "text": {"type": "string"}, - "required": {"type": "boolean", "default": False}, - }, - "required": ["text"], - }, - }, - "name": {"type": "string", "maxLength": 100}, - }, - "required": ["name", "questions", "metadata"], - } == schema["components"]["schemas"]["Questionnaire"]["properties"]["attributes"] - - -def test_schema_parameters_include(): - """Include paramater is only used when serializer defines included_serializers.""" - patterns = [ - re_path("^authors/?$", views.AuthorViewSet.as_view({"get": "list"})), - re_path("^project-types/?$", views.ProjectTypeViewset.as_view({"get": "list"})), - ] - generator = SchemaGenerator(patterns=patterns) - - request = create_request("/") - schema = generator.get_schema(request=request) - - include_ref = {"$ref": "#/components/parameters/include"} - assert include_ref in schema["paths"]["/authors/"]["get"]["parameters"] - assert include_ref not in schema["paths"]["/project-types/"]["get"]["parameters"] - - -def test_schema_serializer_method_resource_related_field(): - """SerializerMethodResourceRelatedField fieds have the correct relation ref.""" - patterns = [ - re_path("^entries/?$", views.EntryViewSet.as_view({"get": "list"})), - ] - generator = SchemaGenerator(patterns=patterns) - - request = Request(RequestFactory().get("/", {"include": "featured"})) - schema = generator.get_schema(request=request) - - entry_schema = schema["components"]["schemas"]["Entry"] - entry_relationships = entry_schema["properties"]["relationships"]["properties"] - - rel_to_many_ref = {"$ref": "#/components/schemas/reltomany"} - assert entry_relationships["suggested"] == rel_to_many_ref - assert entry_relationships["suggestedHyperlinked"] == rel_to_many_ref - - rel_to_one_ref = {"$ref": "#/components/schemas/reltoone"} - assert entry_relationships["featured"] == rel_to_one_ref - assert entry_relationships["featuredHyperlinked"] == rel_to_one_ref - - -def test_schema_related_serializers(): - """ - Confirm that paths are generated for related fields. For example: - /authors/{pk}/{related_field>} - /authors/{id}/comments/ - /authors/{id}/entries/ - /authors/{id}/first_entry/ - and confirm that the schema for the related field is properly rendered - """ - generator = SchemaGenerator() - request = create_request("/") - schema = generator.get_schema(request=request) - # make sure the path's relationship and related {related_field}'s got expanded - assert "/authors/{id}/relationships/{related_field}" in schema["paths"] - assert "/authors/{id}/comments/" in schema["paths"] - assert "/authors/{id}/entries/" in schema["paths"] - assert "/authors/{id}/first_entry/" in schema["paths"] - first_get = schema["paths"]["/authors/{id}/first_entry/"]["get"]["responses"]["200"] - first_schema = first_get["content"]["application/vnd.api+json"]["schema"] - first_props = first_schema["properties"]["data"] - assert "$ref" in first_props - assert first_props["$ref"] == "#/components/schemas/Entry" diff --git a/example/tests/unit/test_filter_schema_params.py b/example/tests/unit/test_filter_schema_params.py deleted file mode 100644 index d7cb4fb8..00000000 --- a/example/tests/unit/test_filter_schema_params.py +++ /dev/null @@ -1,107 +0,0 @@ -from rest_framework import filters as drf_filters - -from rest_framework_json_api import filters as dja_filters -from rest_framework_json_api.django_filters import backends - -from example.views import EntryViewSet - - -class DummyEntryViewSet(EntryViewSet): - filter_backends = ( - dja_filters.QueryParameterValidationFilter, - dja_filters.OrderingFilter, - backends.DjangoFilterBackend, - drf_filters.SearchFilter, - ) - filterset_fields = { - "id": ("exact",), - "headline": ("exact", "contains"), - "blog__name": ("contains",), - } - - def __init__(self, **kwargs): - # dummy up self.request since PreloadIncludesMixin expects it to be defined - self.request = None - super().__init__(**kwargs) - - -def test_filters_get_schema_params(): - """ - test all my filters for `get_schema_operation_parameters()` - """ - # list of tuples: (filter, expected result) - filters = [ - (dja_filters.QueryParameterValidationFilter, []), - ( - backends.DjangoFilterBackend, - [ - { - "name": "filter[id]", - "required": False, - "in": "query", - "description": "id", - "schema": {"type": "string"}, - }, - { - "name": "filter[headline]", - "required": False, - "in": "query", - "description": "headline", - "schema": {"type": "string"}, - }, - { - "name": "filter[headline.contains]", - "required": False, - "in": "query", - "description": "headline__contains", - "schema": {"type": "string"}, - }, - { - "name": "filter[blog.name.contains]", - "required": False, - "in": "query", - "description": "blog__name__contains", - "schema": {"type": "string"}, - }, - ], - ), - ( - dja_filters.OrderingFilter, - [ - { - "name": "sort", - "required": False, - "in": "query", - "description": "[list of fields to sort by]" - "(https://jsonapi.org/format/#fetching-sorting)", - "schema": {"type": "string"}, - } - ], - ), - ( - drf_filters.SearchFilter, - [ - { - "name": "filter[search]", - "required": False, - "in": "query", - "description": "A search term.", - "schema": {"type": "string"}, - } - ], - ), - ] - view = DummyEntryViewSet() - - for c, expected in filters: - f = c() - result = f.get_schema_operation_parameters(view) - assert len(result) == len(expected) - if len(result) == 0: - continue - # py35: the result list/dict ordering isn't guaranteed - for res_item in result: - assert "name" in res_item - for exp_item in expected: - if res_item["name"] == exp_item["name"]: - assert res_item == exp_item diff --git a/example/urls.py b/example/urls.py index 413d058d..471fbe81 100644 --- a/example/urls.py +++ b/example/urls.py @@ -1,9 +1,5 @@ from django.urls import include, path, re_path -from django.views.generic import TemplateView from rest_framework import routers -from rest_framework.schemas import get_schema_view - -from rest_framework_json_api.schemas.openapi import SchemaGenerator from example.views import ( AuthorRelationshipView, @@ -87,22 +83,4 @@ AuthorRelationshipView.as_view(), name="author-relationships", ), - path( - "openapi", - get_schema_view( - title="Example API", - description="API for all things …", - version="1.0.0", - generator_class=SchemaGenerator, - ), - name="openapi-schema", - ), - path( - "swagger-ui/", - TemplateView.as_view( - template_name="swagger-ui.html", - extra_context={"schema_url": "openapi-schema"}, - ), - name="swagger-ui", - ), ] diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 589636e6..3db600e2 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -3,5 +3,3 @@ django-filter==24.3 # should be set to pinned version again # see https://github.com/django-polymorphic/django-polymorphic/pull/541 django-polymorphic@git+https://github.com/django-polymorphic/django-polymorphic@master # pyup: ignore -pyyaml==6.0.2 -uritemplate==4.1.1 diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index c0044839..70e543c1 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -4,7 +4,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.settings import api_settings -from rest_framework_json_api.utils import format_field_name, undo_format_field_name +from rest_framework_json_api.utils import undo_format_field_name class DjangoFilterBackend(DjangoFilterBackend): @@ -129,18 +129,3 @@ def get_filterset_kwargs(self, request, queryset, view): "request": request, "filter_keys": filter_keys, } - - def get_schema_operation_parameters(self, view): - """ - Convert backend filter `name` to JSON:API-style `filter[name]`. - For filters that are relationship paths, rewrite ORM-style `__` to our preferred `.`. - For example: `blog__name__contains` becomes `filter[blog.name.contains]`. - - This is basically the reverse of `get_filterset_kwargs` above. - """ - result = super().get_schema_operation_parameters(view) - for res in result: - if "name" in res: - name = format_field_name(res["name"].replace("__", ".")) - res["name"] = f"filter[{name}]" - return result diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py deleted file mode 100644 index 6892e991..00000000 --- a/rest_framework_json_api/schemas/openapi.py +++ /dev/null @@ -1,905 +0,0 @@ -import warnings -from urllib.parse import urljoin - -from rest_framework.fields import empty -from rest_framework.relations import ManyRelatedField -from rest_framework.schemas import openapi as drf_openapi -from rest_framework.schemas.utils import is_list_view - -from rest_framework_json_api import serializers, views -from rest_framework_json_api.relations import ManySerializerMethodResourceRelatedField -from rest_framework_json_api.utils import format_field_name - - -class SchemaGenerator(drf_openapi.SchemaGenerator): - """ - Extend DRF's SchemaGenerator to implement JSON:API flavored generateschema command. - """ - - #: These JSON:API component definitions are referenced by the generated OAS schema. - #: If you need to add more or change these static component definitions, extend this dict. - jsonapi_components = { - "schemas": { - "jsonapi": { - "type": "object", - "description": "The server's implementation", - "properties": { - "version": {"type": "string"}, - "meta": {"$ref": "#/components/schemas/meta"}, - }, - "additionalProperties": False, - }, - "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"}, - }, - }, - "include": { - "type": "object", - "required": ["type", "id"], - "additionalProperties": False, - "properties": { - "type": {"$ref": "#/components/schemas/type"}, - "id": {"$ref": "#/components/schemas/id"}, - "attributes": { - "type": "object", - "additionalProperties": True, - # ... - }, - "relationships": { - "type": "object", - "additionalProperties": True, - # ... - }, - "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, - }, - # A RelationshipView uses a ResourceIdentifierObjectSerializer (hence the name - # ResourceIdentifierObject returned by get_component_name()) which serializes type - # and id. These can be lists or individual items depending on whether the - # relationship is toMany or toOne so offer both options since we are not iterating - # over all the possible {related_field}'s but rather rendering one path schema - # which may represent toMany and toOne relationships. - "ResourceIdentifierObject": { - "oneOf": [ - {"$ref": "#/components/schemas/relationshipToOne"}, - {"$ref": "#/components/schemas/relationshipToMany"}, - ] - }, - "linkage": { - "type": "object", - "description": "the 'type' and 'id'", - "required": ["type", "id"], - "properties": { - "type": {"$ref": "#/components/schemas/type"}, - "id": {"$ref": "#/components/schemas/id"}, - "meta": {"$ref": "#/components/schemas/meta"}, - }, - }, - "pagination": { - "type": "object", - "properties": { - "first": {"$ref": "#/components/schemas/pageref"}, - "last": {"$ref": "#/components/schemas/pageref"}, - "prev": {"$ref": "#/components/schemas/pageref"}, - "next": {"$ref": "#/components/schemas/pageref"}, - }, - }, - "pageref": { - "oneOf": [ - {"type": "string", "format": "uri-reference"}, - {"$ref": "#/components/schemas/nulltype"}, - ] - }, - "failure": { - "type": "object", - "required": ["errors"], - "properties": { - "errors": {"$ref": "#/components/schemas/errors"}, - "meta": {"$ref": "#/components/schemas/meta"}, - "jsonapi": {"$ref": "#/components/schemas/jsonapi"}, - "links": {"$ref": "#/components/schemas/links"}, - }, - }, - "errors": { - "type": "array", - "items": {"$ref": "#/components/schemas/error"}, - "uniqueItems": True, - }, - "error": { - "type": "object", - "additionalProperties": False, - "properties": { - "id": {"type": "string"}, - "status": {"type": "string"}, - "links": {"$ref": "#/components/schemas/links"}, - "code": {"type": "string"}, - "title": {"type": "string"}, - "detail": {"type": "string"}, - "source": { - "type": "object", - "properties": { - "pointer": { - "type": "string", - "description": ( - "A [JSON Pointer](https://tools.ietf.org/html/rfc6901) " - "to the associated entity in the request document " - "[e.g. `/data` for a primary data object, or " - "`/data/attributes/title` for a specific attribute." - ), - }, - "parameter": { - "type": "string", - "description": "A string indicating which query parameter " - "caused the error.", - }, - "meta": {"$ref": "#/components/schemas/meta"}, - }, - }, - }, - }, - "onlymeta": { - "additionalProperties": False, - "properties": {"meta": {"$ref": "#/components/schemas/meta"}}, - }, - "meta": {"type": "object", "additionalProperties": True}, - "datum": { - "description": "singular item", - "properties": {"data": {"$ref": "#/components/schemas/resource"}}, - }, - "nulltype": {"type": "object", "nullable": True, "default": None}, - "type": { - "type": "string", - "description": "The [type]" - "(https://jsonapi.org/format/#document-resource-object-identification) " - "member is used to describe resource objects that share common attributes " - "and relationships.", - }, - "id": { - "type": "string", - "description": "Each resource object’s type and id pair MUST " - "[identify]" - "(https://jsonapi.org/format/#document-resource-object-identification) " - "a single, unique resource.", - }, - }, - "parameters": { - "include": { - "name": "include", - "in": "query", - "description": "[list of included related resources]" - "(https://jsonapi.org/format/#fetching-includes)", - "required": False, - "style": "form", - "schema": {"type": "string"}, - }, - # TODO: deepObject not well defined/supported: - # https://github.com/OAI/OpenAPI-Specification/issues/1706 - "fields": { - "name": "fields", - "in": "query", - "description": "[sparse fieldsets]" - "(https://jsonapi.org/format/#fetching-sparse-fieldsets).\n" - "Use fields[\\]=field1,field2,...,fieldN", - "required": False, - "style": "deepObject", - "schema": { - "type": "object", - }, - "explode": True, - }, - }, - } - - def get_schema(self, request=None, public=False): - """ - Generate a JSON:API OpenAPI schema. - Overrides upstream DRF's get_schema. - """ - # TODO: avoid copying so much of upstream get_schema() - schema = super().get_schema(request, public) - - components_schemas = {} - - # Iterate endpoints generating per method path operations. - paths = {} - _, view_endpoints = self._get_paths_and_endpoints(None if public else request) - - #: `expanded_endpoints` is like view_endpoints with one extra field tacked on: - #: - 'action' copy of current view.action (list/fetch) as this gets reset for - # each request. - expanded_endpoints = [] - for path, method, view in view_endpoints: - if hasattr(view, "action") and view.action == "retrieve_related": - expanded_endpoints += self._expand_related( - path, method, view, view_endpoints - ) - else: - expanded_endpoints.append( - (path, method, view, getattr(view, "action", None)) - ) - - 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 is 'list' for the parent ViewSet - # but the related viewset that was expanded may be either 'fetch' (to_one) or 'list' - # (to_many). This patches the view.action appropriately so that - # view.schema.get_operation() "does the right thing" for fetch vs. list. - current_action = None - if hasattr(view, "action"): - current_action = view.action - view.action = action - operation = view.schema.get_operation(path, method) - components = view.schema.get_components(path, method) - for k in components.keys(): - if k not in components_schemas: - continue - if components_schemas[k] == components[k]: - continue - warnings.warn( - f'Schema component "{k}" has been overriden with a different value.', - stacklevel=1, - ) - - components_schemas.update(components) - - if hasattr(view, "action"): - view.action = current_action - # Normalise path for any provided mount url. - if path.startswith("/"): - path = path[1:] - path = urljoin(self.url or "/", path) - - paths.setdefault(path, {}) - paths[path][method.lower()] = operation - - self.check_duplicate_operation_id(paths) - - # Compile final schema, overriding stuff from super class. - schema["paths"] = paths - schema["components"] = self.jsonapi_components - schema["components"]["schemas"].update(components_schemas) - - return schema - - def _expand_related(self, path, method, view, view_endpoints): - """ - Expand path containing .../{id}/{related_field} into list of related fields - and **their** views, making sure toOne relationship's views are a 'fetch' and toMany - relationship's are a 'list'. - :param path - :param method - :param view - :param view_endpoints - :return:list[tuple(path, method, view, action)] - """ - result = [] - serializer = view.get_serializer() - # It's not obvious if it's allowed to have both included_ and related_ serializers, - # so just merge both dicts. - serializers = {} - if hasattr(serializer, "included_serializers"): - serializers = {**serializers, **serializer.included_serializers} - if hasattr(serializer, "related_serializers"): - serializers = {**serializers, **serializer.related_serializers} - related_fields = [fs for fs in serializers.items()] - - for field, related_serializer in related_fields: - related_view = self._find_related_view( - view_endpoints, related_serializer, view - ) - if related_view: - action = self._field_is_one_or_many(field, view) - result.append( - ( - path.replace("{related_field}", field), - method, - related_view, - action, - ) - ) - - return result - - def _find_related_view(self, view_endpoints, related_serializer, parent_view): - """ - For a given related_serializer, try to find it's "parent" view instance. - - :param view_endpoints: list of all view endpoints - :param related_serializer: the related serializer for a given related field - :param parent_view: the parent view (used to find toMany vs. toOne). - TODO: not actually used. - :return:view - """ - for _path, _method, view in view_endpoints: - view_serializer = view.get_serializer() - if isinstance(view_serializer, related_serializer): - 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 JSON:API serialization. - """ - - #: ignore all the media types and only generate a JSON:API schema. - content_types = ["application/vnd.api+json"] - - def get_operation(self, path, method): - """ - JSON:API adds some standard fields to the API response that are not in upstream DRF: - - some that only apply to GET/HEAD methods. - - collections - - special handling for POST, PATCH, DELETE - """ - - warnings.warn( - DeprecationWarning( - "Built-in support for generating OpenAPI schema is deprecated. " - "Use drf-spectacular-json-api instead see " - "https://github.com/jokiefer/drf-spectacular-json-api/" - ), - stacklevel=2, - ) - - operation = {} - operation["operationId"] = self.get_operation_id(path, method) - operation["description"] = self.get_description(path, method) - - serializer = self.get_response_serializer(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, serializer) - parameters += self._get_fields_parameters(path, method) - parameters += self.get_pagination_parameters(path, method) - parameters += self.get_filter_parameters(path, method) - operation["parameters"] = parameters - operation["tags"] = self.get_tags(path, method) - - # get request and response code schemas - if method == "GET": - if is_list_view(path, method, self.view): - self._add_get_collection_response(operation, path) - else: - self._add_get_item_response(operation, path) - elif method == "POST": - self._add_post_item_response(operation, path) - elif method == "PATCH": - self._add_patch_item_response(operation, path) - elif method == "DELETE": - # should only allow deleting a resource, not a collection - # TODO: implement delete of a relationship in future release. - self._add_delete_item_response(operation, path) - return operation - - def get_operation_id(self, path, method): - """ - The upstream DRF version creates non-unique operationIDs, because the same view is - used for the main path as well as such as related and relationships. - This concatenates the (mapped) method name and path as the spec allows most any - """ - method_name = getattr(self.view, "action", method.lower()) - if is_list_view(path, method, self.view): - action = "List" - elif method_name not in self.method_mapping: - action = method_name - else: - action = self.method_mapping[method.lower()] - return action + path - - def _get_include_parameters(self, path, method, serializer): - """ - includes parameter: https://jsonapi.org/format/#fetching-includes - """ - if getattr(serializer, "included_serializers", {}): - return [{"$ref": "#/components/parameters/include"}] - return [] - - def _get_fields_parameters(self, path, method): - """ - sparse fieldsets https://jsonapi.org/format/#fetching-sparse-fieldsets - """ - # TODO: See if able to identify the specific types for fields[type]=... and return this: - # name: fields - # in: query - # description: '[sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets)' # noqa: B950 - # required: true - # style: deepObject - # schema: - # type: object - # properties: - # hello: - # type: string # noqa F821 - # world: - # type: string # noqa F821 - # explode: true - return [{"$ref": "#/components/parameters/fields"}] - - def _add_get_collection_response(self, operation, path): - """ - Add GET 200 response for a collection to operation - """ - operation["responses"] = { - "200": self._get_toplevel_200_response( - operation, path, "GET", collection=True - ) - } - self._add_get_4xx_responses(operation) - - def _add_get_item_response(self, operation, path): - """ - add GET 200 response for an item to operation - """ - operation["responses"] = { - "200": self._get_toplevel_200_response( - operation, path, "GET", collection=False - ) - } - self._add_get_4xx_responses(operation) - - def _get_toplevel_200_response(self, operation, path, method, collection=True): - """ - return top-level JSON:API GET 200 response - - :param collection: True for collections; False for individual items. - - Uses a $ref to the components.schemas. component definition. - """ - if collection: - data = { - "type": "array", - "items": self.get_reference(self.get_response_serializer(path, method)), - } - else: - data = self.get_reference(self.get_response_serializer(path, method)) - - 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/include"}, - }, - "links": { - "description": "Link members related to primary data", - "allOf": [ - {"$ref": "#/components/schemas/links"}, - {"$ref": "#/components/schemas/pagination"}, - ], - }, - "jsonapi": {"$ref": "#/components/schemas/jsonapi"}, - }, - } - } - }, - } - - def _add_post_item_response(self, operation, path): - """ - add response for POST of an item to operation - """ - operation["requestBody"] = self.get_request_body(path, "POST") - operation["responses"] = { - "201": self._get_toplevel_200_response( - operation, path, "POST", 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 _add_patch_item_response(self, operation, path): - """ - Add PATCH response for an item to operation - """ - operation["requestBody"] = self.get_request_body(path, "PATCH") - operation["responses"] = { - "200": self._get_toplevel_200_response( - operation, path, "PATCH", collection=False - ) - } - self._add_patch_4xx_responses(operation) - - def _add_delete_item_response(self, operation, path): - """ - add DELETE response for item or relationship(s) to operation - """ - # Only DELETE of relationships has a requestBody - if isinstance(self.view, views.RelationshipView): - operation["requestBody"] = self.get_request_body(path, "DELETE") - self._add_delete_responses(operation) - - def get_request_body(self, path, method): - """ - A request body is required by JSON:API for POST, PATCH, and DELETE methods. - """ - serializer = self.get_request_serializer(path, method) - if not isinstance(serializer, (serializers.BaseSerializer,)): - return {} - is_relationship = isinstance(self.view, views.RelationshipView) - - # DRF uses a $ref to the component schema definition, but this - # doesn't work for JSON:API due to the different required fields based on - # the method, so make those changes and inline another copy of the schema. - - # TODO: A future improvement could make this DRYer with multiple component schemas: - # A base schema for each viewset that has no required fields - # One subclassed from the base that requires some fields (`type` but not `id` for POST) - # Another subclassed from base with required type/id but no required attributes (PATCH) - - if is_relationship: - item_schema = {"$ref": "#/components/schemas/ResourceIdentifierObject"} - else: - item_schema = self.map_serializer(serializer) - if method == "POST": - # 'type' and 'id' are both required for: - # - all relationship operations - # - regular PATCH or DELETE - # Only 'type' is required for POST: system may assign the 'id'. - item_schema["required"] = ["type"] - - if "properties" in item_schema and "attributes" in item_schema["properties"]: - # No required attributes for PATCH - if ( - method in ["PATCH", "PUT"] - and "required" in item_schema["properties"]["attributes"] - ): - del item_schema["properties"]["attributes"]["required"] - # No read_only fields for request. - for name, schema in ( - item_schema["properties"]["attributes"]["properties"].copy().items() - ): # noqa E501 - if "readOnly" in schema: - del item_schema["properties"]["attributes"]["properties"][name] - - if "properties" in item_schema and "relationships" in item_schema["properties"]: - # No required relationships for PATCH - if ( - method in ["PATCH", "PUT"] - and "required" in item_schema["properties"]["relationships"] - ): - del item_schema["properties"]["relationships"]["required"] - - return { - "content": { - ct: { - "schema": { - "required": ["data"], - "properties": {"data": item_schema}, - } - } - for ct in self.content_types - } - } - - def map_serializer(self, serializer): - """ - Custom map_serializer that serializes the schema using the JSON:API spec. - - Non-attributes like related and identity fields, are moved to 'relationships' - and 'links'. - """ - # TODO: remove attributes, etc. for relationshipView?? - if isinstance( - serializer.parent, (serializers.ListField, serializers.BaseSerializer) - ): - # Return plain non-JSON:API serializer schema for serializers nested inside - # a Serializer or a ListField, as those don't use the full JSON:API - # serializer schemas. - return super().map_serializer(serializer) - - required = [] - attributes = {} - relationships_required = [] - 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.ManyRelatedField, - ManySerializerMethodResourceRelatedField, - ), - ): - if field.required: - relationships_required.append(format_field_name(field.field_name)) - relationships[format_field_name(field.field_name)] = { - "$ref": "#/components/schemas/reltomany" - } - continue - if isinstance(field, serializers.RelatedField): - if field.required: - relationships_required.append(format_field_name(field.field_name)) - relationships[format_field_name(field.field_name)] = { - "$ref": "#/components/schemas/reltoone" - } - continue - if field.field_name == "id": - # ID is always provided in the root of JSON:API and removed from the - # attributes in JSONRenderer. - continue - - if field.required: - required.append(format_field_name(field.field_name)) - - schema = self.map_field(field) - if field.read_only: - schema["readOnly"] = True - if field.write_only: - schema["writeOnly"] = True - if field.allow_null: - schema["nullable"] = True - if field.default and field.default != empty and not callable(field.default): - schema["default"] = field.default - if field.help_text: - # Ensure django gettext_lazy is rendered correctly - schema["description"] = str(field.help_text) - self.map_field_validators(field, schema) - - attributes[format_field_name(field.field_name)] = schema - - result = { - "type": "object", - "required": ["type", "id"], - "additionalProperties": False, - "properties": { - "type": {"$ref": "#/components/schemas/type"}, - "id": {"$ref": "#/components/schemas/id"}, - "links": { - "type": "object", - "properties": {"self": {"$ref": "#/components/schemas/link"}}, - }, - }, - } - if attributes: - result["properties"]["attributes"] = { - "type": "object", - "properties": attributes, - } - if required: - result["properties"]["attributes"]["required"] = required - - if relationships: - result["properties"]["relationships"] = { - "type": "object", - "properties": relationships, - } - if relationships_required: - result["properties"]["relationships"][ - "required" - ] = relationships_required - return result - - def _add_async_response(self, operation): - """ - Add async response to operation - """ - operation["responses"]["202"] = { - "description": "Accepted for [asynchronous processing]" - "(https://jsonapi.org/recommendations/#asynchronous-processing)", - "content": { - "application/vnd.api+json": { - "schema": {"$ref": "#/components/schemas/datum"} - } - }, - } - - def _failure_response(self, reason): - """ - Return failure response reason as the description - """ - return { - "description": reason, - "content": { - "application/vnd.api+json": { - "schema": {"$ref": "#/components/schemas/failure"} - } - }, - } - - def _add_generic_failure_responses(self, operation): - """ - Add generic failure response(s) to operation - """ - for code, reason in [ - ("400", "bad request"), - ("401", "not authorized"), - ("429", "too many requests"), - ]: - operation["responses"][code] = self._failure_response(reason) - - def _add_get_4xx_responses(self, operation): - """ - Add generic 4xx GET responses to operation - """ - self._add_generic_failure_responses(operation) - for code, reason in [("404", "not found")]: - operation["responses"][code] = self._failure_response(reason) - - def _add_post_4xx_responses(self, operation): - """ - Add POST 4xx error responses to operation - """ - self._add_generic_failure_responses(operation) - for code, reason in [ - ( - "403", - "[Forbidden](https://jsonapi.org/format/#crud-creating-responses-403)", - ), - ( - "404", - "[Related resource does not exist]" - "(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 PATCH 4xx error responses to operation - """ - self._add_generic_failure_responses(operation) - for code, reason in [ - ( - "403", - "[Forbidden](https://jsonapi.org/format/#crud-updating-responses-403)", - ), - ( - "404", - "[Related resource does not exist]" - "(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 DELETE responses to operation - """ - # 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)", # noqa: B950 - } - # the 4xx errors: - self._add_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 4230dcbb..92606700 100644 --- a/setup.cfg +++ b/setup.cfg @@ -63,9 +63,6 @@ DJANGO_SETTINGS_MODULE=example.settings.test filterwarnings = error::DeprecationWarning error::PendingDeprecationWarning - # Django filter schema generation. Can be removed once we remove - # schema support - ignore:Built-in schema generation is deprecated. testpaths = example tests diff --git a/setup.py b/setup.py index de61b0d1..0b88f4c9 100755 --- a/setup.py +++ b/setup.py @@ -112,7 +112,6 @@ def get_package_data(package): extras_require={ "django-polymorphic": ["django-polymorphic>=3.0"], "django-filter": ["django-filter>=2.4"], - "openapi": ["pyyaml>=5.4", "uritemplate>=3.0.1"], }, setup_requires=wheel, python_requires=">=3.9", diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py deleted file mode 100644 index 427f18fc..00000000 --- a/tests/schemas/test_openapi.py +++ /dev/null @@ -1,11 +0,0 @@ -from rest_framework_json_api.schemas.openapi import AutoSchema -from tests.serializers import CallableDefaultSerializer - - -class TestAutoSchema: - def test_schema_callable_default(self): - inspector = AutoSchema() - result = inspector.map_serializer(CallableDefaultSerializer()) - assert result["properties"]["attributes"]["properties"]["field"] == { - "type": "string", - }