Merge branch 'master' into patch-1

This commit is contained in:
Tom Christie 2020-05-13 11:16:27 +01:00 committed by GitHub
commit d8cbc54411
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
64 changed files with 1572 additions and 318 deletions

View File

@ -1,24 +1,16 @@
language: python language: python
cache: pip cache: pip
dist: xenial dist: bionic
matrix: matrix:
fast_finish: true fast_finish: true
include: include:
- { python: "3.5", env: DJANGO=1.11 }
- { python: "3.5", env: DJANGO=2.0 }
- { python: "3.5", env: DJANGO=2.1 }
- { python: "3.5", env: DJANGO=2.2 } - { python: "3.5", env: DJANGO=2.2 }
- { python: "3.6", env: DJANGO=1.11 }
- { python: "3.6", env: DJANGO=2.0 }
- { python: "3.6", env: DJANGO=2.1 }
- { python: "3.6", env: DJANGO=2.2 } - { python: "3.6", env: DJANGO=2.2 }
- { python: "3.6", env: DJANGO=3.0 } - { python: "3.6", env: DJANGO=3.0 }
- { python: "3.6", env: DJANGO=master } - { python: "3.6", env: DJANGO=master }
- { python: "3.7", env: DJANGO=2.0 }
- { python: "3.7", env: DJANGO=2.1 }
- { python: "3.7", env: DJANGO=2.2 } - { python: "3.7", env: DJANGO=2.2 }
- { python: "3.7", env: DJANGO=3.0 } - { python: "3.7", env: DJANGO=3.0 }
- { python: "3.7", env: DJANGO=master } - { python: "3.7", env: DJANGO=master }

View File

@ -55,7 +55,7 @@ There is a live example API for testing purposes, [available here][sandbox].
# Requirements # Requirements
* Python (3.5, 3.6, 3.7, 3.8) * Python (3.5, 3.6, 3.7, 3.8)
* Django (1.11, 2.0, 2.1, 2.2, 3.0) * Django (2.2, 3.0)
We **highly recommend** and only officially support the latest patch release of We **highly recommend** and only officially support the latest patch release of
each Python and Django series. each Python and Django series.
@ -89,7 +89,7 @@ Startup up a new project like so...
Now edit the `example/urls.py` module in your project: Now edit the `example/urls.py` module in your project:
```python ```python
from django.conf.urls import url, include from django.urls import path, include
from django.contrib.auth.models import User from django.contrib.auth.models import User
from rest_framework import serializers, viewsets, routers from rest_framework import serializers, viewsets, routers
@ -114,8 +114,8 @@ router.register(r'users', UserViewSet)
# Wire up our API using automatic URL routing. # Wire up our API using automatic URL routing.
# Additionally, we include login URLs for the browsable API. # Additionally, we include login URLs for the browsable API.
urlpatterns = [ urlpatterns = [
url(r'^', include(router.urls)), path('', include(router.urls)),
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')) path('api-auth/', include('rest_framework.urls', namespace='rest_framework'))
] ]
``` ```

View File

@ -304,7 +304,7 @@ If successfully authenticated, `RemoteUserAuthentication` provides the following
Consult your web server's documentation for information about configuring an authentication method, e.g.: Consult your web server's documentation for information about configuring an authentication method, e.g.:
* [Apache Authentication How-To](https://httpd.apache.org/docs/2.4/howto/auth.html) * [Apache Authentication How-To](https://httpd.apache.org/docs/2.4/howto/auth.html)
* [NGINX (Restricting Access)](https://www.nginx.com/resources/admin-guide/#restricting_access) * [NGINX (Restricting Access)](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/)
# Custom authentication # Custom authentication
@ -410,9 +410,15 @@ HTTP Signature (currently a [IETF draft][http-signature-ietf-draft]) provides a
[Djoser][djoser] library provides a set of views to handle basic actions such as registration, login, logout, password reset and account activation. The package works with a custom user model and it uses token based authentication. This is a ready to use REST implementation of Django authentication system. [Djoser][djoser] library provides a set of views to handle basic actions such as registration, login, logout, password reset and account activation. The package works with a custom user model and it uses token based authentication. This is a ready to use REST implementation of Django authentication system.
## django-rest-auth ## django-rest-auth / dj-rest-auth
[Django-rest-auth][django-rest-auth] library provides a set of REST API endpoints for registration, authentication (including social media authentication), password reset, retrieve and update user details, etc. By having these API endpoints, your client apps such as AngularJS, iOS, Android, and others can communicate to your Django backend site independently via REST APIs for user management. This library provides a set of REST API endpoints for registration, authentication (including social media authentication), password reset, retrieve and update user details, etc. By having these API endpoints, your client apps such as AngularJS, iOS, Android, and others can communicate to your Django backend site independently via REST APIs for user management.
There are currently two forks of this project.
* [Django-rest-auth][django-rest-auth] is the original project, [but is not currently receiving updates](https://github.com/Tivix/django-rest-auth/issues/568).
* [Dj-rest-auth][dj-rest-auth] is a newer fork of the project.
## django-rest-framework-social-oauth2 ## django-rest-framework-social-oauth2
@ -456,6 +462,7 @@ HTTP Signature (currently a [IETF draft][http-signature-ietf-draft]) provides a
[mac]: https://tools.ietf.org/html/draft-hammer-oauth-v2-mac-token-05 [mac]: https://tools.ietf.org/html/draft-hammer-oauth-v2-mac-token-05
[djoser]: https://github.com/sunscrapers/djoser [djoser]: https://github.com/sunscrapers/djoser
[django-rest-auth]: https://github.com/Tivix/django-rest-auth [django-rest-auth]: https://github.com/Tivix/django-rest-auth
[dj-rest-auth]: https://github.com/jazzband/dj-rest-auth
[django-rest-framework-social-oauth2]: https://github.com/PhilipGarnero/django-rest-framework-social-oauth2 [django-rest-framework-social-oauth2]: https://github.com/PhilipGarnero/django-rest-framework-social-oauth2
[django-rest-knox]: https://github.com/James1345/django-rest-knox [django-rest-knox]: https://github.com/James1345/django-rest-knox
[drfpasswordless]: https://github.com/aaronn/django-rest-framework-passwordless [drfpasswordless]: https://github.com/aaronn/django-rest-framework-passwordless

View File

@ -205,6 +205,10 @@ This will allow the client to filter the items in the list by making queries suc
You can also perform a related lookup on a ForeignKey or ManyToManyField with the lookup API double-underscore notation: You can also perform a related lookup on a ForeignKey or ManyToManyField with the lookup API double-underscore notation:
search_fields = ['username', 'email', 'profile__profession'] search_fields = ['username', 'email', 'profile__profession']
For [JSONField][JSONField] and [HStoreField][HStoreField] fields you can filter based on nested values within the data structure using the same double-underscore notation:
search_fields = ['data__breed', 'data__owner__other_pets__0__name']
By default, searches will use case-insensitive partial matches. The search parameter may contain multiple search terms, which should be whitespace and/or comma separated. If multiple search terms are used then objects will be returned in the list only if all the provided terms are matched. By default, searches will use case-insensitive partial matches. The search parameter may contain multiple search terms, which should be whitespace and/or comma separated. If multiple search terms are used then objects will be returned in the list only if all the provided terms are matched.
@ -360,3 +364,5 @@ The [djangorestframework-word-filter][django-rest-framework-word-search-filter]
[django-rest-framework-word-search-filter]: https://github.com/trollknurr/django-rest-framework-word-search-filter [django-rest-framework-word-search-filter]: https://github.com/trollknurr/django-rest-framework-word-search-filter
[django-url-filter]: https://github.com/miki725/django-url-filter [django-url-filter]: https://github.com/miki725/django-url-filter
[drf-url-filter]: https://github.com/manjitkumar/drf-url-filters [drf-url-filter]: https://github.com/manjitkumar/drf-url-filters
[HStoreField]: https://docs.djangoproject.com/en/3.0/ref/contrib/postgres/fields/#hstorefield
[JSONField]: https://docs.djangoproject.com/en/3.0/ref/contrib/postgres/fields/#jsonfield

View File

@ -71,7 +71,7 @@ If you have specific requirements for creating schema endpoints that are accesse
For example, the following additional route could be used on a viewset to provide a linkable schema endpoint. For example, the following additional route could be used on a viewset to provide a linkable schema endpoint.
@action(methods=['GET'], detail=False) @action(methods=['GET'], detail=False)
def schema(self, request): def api_schema(self, request):
meta = self.metadata_class() meta = self.metadata_class()
data = meta.determine_metadata(request, self) data = meta.determine_metadata(request, self)
return Response(data) return Response(data)

View File

@ -231,7 +231,7 @@ If you need to test if a request is a read operation or a write operation, you s
--- ---
Custom permissions will raise a `PermissionDenied` exception if the test fails. To change the error message associated with the exception, implement a `message` attribute directly on your custom permission. Otherwise the `default_detail` attribute from `PermissionDenied` will be used. Custom permissions will raise a `PermissionDenied` exception if the test fails. To change the error message associated with the exception, implement a `message` attribute directly on your custom permission. Otherwise the `default_detail` attribute from `PermissionDenied` will be used. Similarly, to change the code identifier associated with the exception, implement a `code` attribute directly on your custom permission - otherwise the `default_code` attribute from `PermissionDenied` will be used.
from rest_framework import permissions from rest_framework import permissions

View File

@ -273,7 +273,7 @@ By default this will include the following keys: `view`, `request`, `response`,
The following is an example plaintext renderer that will return a response with the `data` parameter as the content of the response. The following is an example plaintext renderer that will return a response with the `data` parameter as the content of the response.
from django.utils.encoding import smart_unicode from django.utils.encoding import smart_text
from rest_framework import renderers from rest_framework import renderers
@ -282,7 +282,7 @@ The following is an example plaintext renderer that will return a response with
format = 'txt' format = 'txt'
def render(self, data, media_type=None, renderer_context=None): def render(self, data, media_type=None, renderer_context=None):
return data.encode(self.charset) return smart_text(data, encoding=self.charset)
## Setting the character set ## Setting the character set

View File

@ -18,19 +18,19 @@ Django REST Framework provides support for automatic generation of
## Generating an OpenAPI Schema ## Generating an OpenAPI Schema
### Install `pyyaml` ### Install dependencies
You'll need to install `pyyaml`, so that you can render your generated schema pip install pyyaml uritemplate
into the commonly used YAML-based OpenAPI format.
pip install pyyaml * `pyyaml` is used to generate schema into YAML-based OpenAPI format.
* `uritemplate` is used internally to get parameters in path.
### Generating a static schema with the `generateschema` management command ### Generating a static schema with the `generateschema` management command
If your schema is static, you can use the `generateschema` management command: If your schema is static, you can use the `generateschema` management command:
```bash ```bash
./manage.py generateschema > openapi-schema.yml ./manage.py generateschema --file openapi-schema.yml
``` ```
Once you've generated a schema in this way you can annotate it with any Once you've generated a schema in this way you can annotate it with any
@ -122,7 +122,7 @@ on a per-view basis.
### Schema Level Customization ### Schema Level Customization
In order to customize the top-level schema sublass In order to customize the top-level schema subclass
`rest_framework.schemas.openapi.SchemaGenerator` and provide it as an argument `rest_framework.schemas.openapi.SchemaGenerator` and provide it as an argument
to the `generateschema` command or `get_schema_view()` helper function. to the `generateschema` command or `get_schema_view()` helper function.
@ -215,6 +215,180 @@ This also applies to extra actions for `ViewSet`s:
If you wish to provide a base `AutoSchema` subclass to be used throughout your If you wish to provide a base `AutoSchema` subclass to be used throughout your
project you may adjust `settings.DEFAULT_SCHEMA_CLASS` appropriately. project you may adjust `settings.DEFAULT_SCHEMA_CLASS` appropriately.
### Grouping Operations With Tags
Tags can be used to group logical operations. Each tag name in the list MUST be unique.
---
#### Django REST Framework generates tags automatically with the following logic:
Tag name will be first element from the path. Also, any `_` in path name will be replaced by a `-`.
Consider below examples.
Example 1: Consider a user management system. The following table will illustrate the tag generation logic.
Here first element from the paths is: `users`. Hence tag wil be `users`
Http Method | Path | Tags
-------------------------------------|-------------------|-------------
PUT, PATCH, GET(Retrieve), DELETE | /users/{id}/ | ['users']
POST, GET(List) | /users/ | ['users']
Example 2: Consider a restaurant management system. The System has restaurants. Each restaurant has branches.
Consider REST APIs to deal with a branch of a particular restaurant.
Here first element from the paths is: `restaurants`. Hence tag wil be `restaurants`.
Http Method | Path | Tags
-------------------------------------|----------------------------------------------------|-------------------
PUT, PATCH, GET(Retrieve), DELETE: | /restaurants/{restaurant_id}/branches/{branch_id} | ['restaurants']
POST, GET(List): | /restaurants/{restaurant_id}/branches/ | ['restaurants']
Example 3: Consider Order items for an e commerce company.
Http Method | Path | Tags
-------------------------------------|-------------------------|-------------
PUT, PATCH, GET(Retrieve), DELETE | /order_items/{id}/ | ['order-items']
POST, GET(List) | /order_items/ | ['order-items']
---
#### Overriding auto generated tags:
You can override auto-generated tags by passing `tags` argument to the constructor of `AutoSchema`. `tags` argument must be a list or tuple of string.
```python
from rest_framework.schemas.openapi import AutoSchema
from rest_framework.views import APIView
class MyView(APIView):
schema = AutoSchema(tags=['tag1', 'tag2'])
...
```
If you need more customization, you can override the `get_tags` method of `AutoSchema` class. Consider the following example:
```python
from rest_framework.schemas.openapi import AutoSchema
from rest_framework.views import APIView
class MySchema(AutoSchema):
...
def get_tags(self, path, method):
if method == 'POST':
tags = ['tag1', 'tag2']
elif method == 'GET':
tags = ['tag2', 'tag3']
elif path == '/example/path/':
tags = ['tag3', 'tag4']
else:
tags = ['tag5', 'tag6', 'tag7']
return tags
class MyView(APIView):
schema = MySchema()
...
```
### OperationId
The schema generator generates an [operationid][openapi-operationid] for each operation. This `operationId` is deduced from the model name, serializer name or view name. The operationId may looks like "listItems", "retrieveItem", "updateItem", etc..
The `operationId` is camelCase by convention.
If you have several views with the same model, the generator may generate duplicate operationId.
In order to work around this, you can override the second part of the operationId: operation name.
```python
from rest_framework.schemas.openapi import AutoSchema
class ExampleView(APIView):
"""APIView subclass with custom schema introspection."""
schema = AutoSchema(operation_id_base="Custom")
```
The previous example will generate the following operationId: "listCustoms", "retrieveCustom", "updateCustom", "partialUpdateCustom", "destroyCustom".
You need to provide the singular form of he operation name. For the list operation, a "s" will be appended at the end of the operation.
If you need more configuration over the `operationId` field, you can override the `get_operation_id_base` and `get_operation_id` methods from the `AutoSchema` class:
```python
class CustomSchema(AutoSchema):
def get_operation_id_base(self, path, method, action):
pass
def get_operation_id(self, path, method):
pass
class MyView(APIView):
schema = AutoSchema(component_name="Ulysses")
```
### Components
Since DRF 3.12, Schema uses the [OpenAPI Components][openapi-components]. This method defines components in the schema and [references them][openapi-reference] inside request and response objects. By default, the component's name is deduced from the Serializer's name.
Using OpenAPI's components provides the following advantages:
* The schema is more readable and lightweight.
* If you use the schema to generate an SDK (using [openapi-generator][openapi-generator] or [swagger-codegen][swagger-codegen]). The generator can name your SDK's models.
### Handling component's schema errors
You may get the following error while generating the schema:
```
"Serializer" is an invalid class name for schema generation.
Serializer's class name should be unique and explicit. e.g. "ItemSerializer".
```
This error occurs when the Serializer name is "Serializer". You should choose a component's name unique across your schema and different than "Serializer".
You may also get the following warning:
```
Schema component "ComponentName" has been overriden with a different value.
```
This warning occurs when different components have the same name in one schema. Your component name should be unique across your project. This is likely an error that may lead to an invalid schema.
You have two ways to solve the previous issues:
* You can rename your serializer with a unique name and another name than "Serializer".
* You can set the `component_name` kwarg parameter of the AutoSchema constructor (see below).
* You can override the `get_component_name` method of the AutoSchema class (see below).
#### Set a custom component's name for your view
To override the component's name in your view, you can use the `component_name` parameter of the AutoSchema constructor:
```python
from rest_framework.schemas.openapi import AutoSchema
class MyView(APIView):
schema = AutoSchema(component_name="Ulysses")
```
#### Override the default implementation
If you want to have more control and customization about how the schema's components are generated, you can override the `get_component_name` and `get_components` method from the AutoSchema class.
```python
from rest_framework.schemas.openapi import AutoSchema
class CustomSchema(AutoSchema):
def get_components(self, path, method):
# Implement your custom implementation
def get_component_name(self, serializer):
# Implement your custom implementation
class CustomView(APIView):
"""APIView subclass with custom schema introspection."""
schema = CustomSchema()
```
[openapi]: https://github.com/OAI/OpenAPI-Specification [openapi]: https://github.com/OAI/OpenAPI-Specification
[openapi-specification-extensions]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#specification-extensions [openapi-specification-extensions]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#specification-extensions
[openapi-operation]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#operationObject [openapi-operation]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#operationObject
[openapi-tags]: https://swagger.io/specification/#tagObject
[openapi-operationid]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#fixed-fields-17
[openapi-components]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#componentsObject
[openapi-reference]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#referenceObject
[openapi-generator]: https://github.com/OpenAPITools/openapi-generator
[swagger-codegen]: https://github.com/swagger-api/swagger-codegen

View File

@ -238,10 +238,12 @@ Serializer classes can also include reusable validators that are applied to the
class Meta: class Meta:
# Each room only has one event per day. # Each room only has one event per day.
validators = UniqueTogetherValidator( validators = [
queryset=Event.objects.all(), UniqueTogetherValidator(
fields=['room_number', 'date'] queryset=Event.objects.all(),
) fields=['room_number', 'date']
)
]
For more information see the [validators documentation](validators.md). For more information see the [validators documentation](validators.md).
@ -333,7 +335,7 @@ Here's an example for an `.update()` method on our previous `UserSerializer` cla
def update(self, instance, validated_data): def update(self, instance, validated_data):
profile_data = validated_data.pop('profile') profile_data = validated_data.pop('profile')
# Unless the application properly enforces that this field is # Unless the application properly enforces that this field is
# always set, the follow could raise a `DoesNotExist`, which # always set, the following could raise a `DoesNotExist`, which
# would need to be handled. # would need to be handled.
profile = instance.profile profile = instance.profile
@ -382,8 +384,8 @@ This manager class now more nicely encapsulates that user instances and profile
def create(self, validated_data): def create(self, validated_data):
return User.objects.create( return User.objects.create(
username=validated_data['username'], username=validated_data['username'],
email=validated_data['email'] email=validated_data['email'],
is_premium_member=validated_data['profile']['is_premium_member'] is_premium_member=validated_data['profile']['is_premium_member'],
has_support_contract=validated_data['profile']['has_support_contract'] has_support_contract=validated_data['profile']['has_support_contract']
) )

View File

@ -221,7 +221,7 @@ If you're using `RequestsClient` you'll want to ensure that test setup, and resu
## Headers & Authentication ## Headers & Authentication
Custom headers and authentication credentials can be provided in the same way Custom headers and authentication credentials can be provided in the same way
as [when using a standard `requests.Session` instance](http://docs.python-requests.org/en/master/user/advanced/#session-objects). as [when using a standard `requests.Session` instance][session_objects].
from requests.auth import HTTPBasicAuth from requests.auth import HTTPBasicAuth
@ -414,3 +414,4 @@ For example, to add support for using `format='html'` in test requests, you migh
[requestfactory]: https://docs.djangoproject.com/en/stable/topics/testing/advanced/#django.test.client.RequestFactory [requestfactory]: https://docs.djangoproject.com/en/stable/topics/testing/advanced/#django.test.client.RequestFactory
[configuration]: #configuration [configuration]: #configuration
[refresh_from_db_docs]: https://docs.djangoproject.com/en/1.11/ref/models/instances/#django.db.models.Model.refresh_from_db [refresh_from_db_docs]: https://docs.djangoproject.com/en/1.11/ref/models/instances/#django.db.models.Model.refresh_from_db
[session_objects]: https://requests.readthedocs.io/en/master/user/advanced/#session-objects

View File

@ -317,5 +317,5 @@ To create a base viewset class that provides `create`, `list` and `retrieve` ope
By creating your own base `ViewSet` classes, you can provide common behavior that can be reused in multiple viewsets across your API. By creating your own base `ViewSet` classes, you can provide common behavior that can be reused in multiple viewsets across your API.
[cite]: https://guides.rubyonrails.org/routing.html [cite]: https://guides.rubyonrails.org/action_controller_overview.html
[routers]: routers.md [routers]: routers.md

View File

@ -34,10 +34,32 @@ You can determine your currently installed version using `pip show`:
--- ---
## 3.11.x series
### 3.11.0
**Date**: 12th December 2019
* Drop `.set_context` API [in favour of a `requires_context` marker](../3.11-announcement#validator-default-context).
* Changed default widget for TextField with choices to select box. [#6892][gh6892]
* Supported nested writes on non-relational fields, such as JSONField. [#6916][gh6916]
* Include request/response media types in OpenAPI schemas, based on configured parsers/renderers. [#6865][gh6865]
* Include operation descriptions in OpenAPI schemas, based on the docstring on the view. [#6898][gh6898]
* Fix representation of serializers with all optional fields in OpenAPI schemas. [#6941][gh6941], [#6944][gh6944]
* Fix representation of `serializers.HStoreField` in OpenAPI schemas. [#6914][gh6914]
* Fix OpenAPI generation when title or version is not provided. [#6912][gh6912]
* Use `int64` representation for large integers in OpenAPI schemas. [#7018][gh7018]
* Improved error messages if no `.to_representation` implementation is provided on a field subclass. [#6996][gh6996]
* Fix for serializer classes that use multiple inheritance. [#6980][gh6980]
* Fix for reversing Hyperlinked URL fields with percent encoded components in the path. [#7059][gh7059]
* Update bootstrap to 3.4.1. [#6923][gh6923]
## 3.10.x series ## 3.10.x series
### 3.10.3 ### 3.10.3
**Date**: 4th September 2019
* Include API version in OpenAPI schema generation, defaulting to empty string. * Include API version in OpenAPI schema generation, defaulting to empty string.
* Add pagination properties to OpenAPI response schemas. * Add pagination properties to OpenAPI response schemas.
* Add missing "description" property to OpenAPI response schemas. * Add missing "description" property to OpenAPI response schemas.
@ -47,9 +69,7 @@ You can determine your currently installed version using `pip show`:
* Use consistent `lowerInitialCamelCase` style in OpenAPI operation IDs. * Use consistent `lowerInitialCamelCase` style in OpenAPI operation IDs.
* Fix `minLength`/`maxLength`/`minItems`/`maxItems` properties in OpenAPI schemas. * Fix `minLength`/`maxLength`/`minItems`/`maxItems` properties in OpenAPI schemas.
* Only call `FileField.url` once in serialization, for improved performance. * Only call `FileField.url` once in serialization, for improved performance.
* Fix an edge case where throttling calcualtions could error after a configuration change. * Fix an edge case where throttling calculations could error after a configuration change.
* TODO
### 3.10.2 ### 3.10.2
@ -154,7 +174,7 @@ Be sure to upgrade to Python 3 before upgrading to Django REST Framework 3.10.
* Add testing of Python 3.7 support [#6141][gh6141] * Add testing of Python 3.7 support [#6141][gh6141]
* Test using Django 2.1 final release. [#6109][gh6109] * Test using Django 2.1 final release. [#6109][gh6109]
* Added djangorestframework-datatables to third-party packages [#5931][gh5931] * Added djangorestframework-datatables to third-party packages [#5931][gh5931]
* Change ISO 8601 date format to exclude year/month [#5936][gh5936] * Change ISO 8601 date format to exclude year/month-only options [#5936][gh5936]
* Update all pypi.python.org URLs to pypi.org [#5942][gh5942] * Update all pypi.python.org URLs to pypi.org [#5942][gh5942]
* Ensure that html forms (multipart form data) respect optional fields [#5927][gh5927] * Ensure that html forms (multipart form data) respect optional fields [#5927][gh5927]
* Allow hashing of ErrorDetail. [#5932][gh5932] * Allow hashing of ErrorDetail. [#5932][gh5932]
@ -2175,3 +2195,18 @@ For older release notes, [please see the version 2.x documentation][old-release-
<!-- 3.10.0 --> <!-- 3.10.0 -->
[gh6680]: https://github.com/encode/django-rest-framework/issues/6680 [gh6680]: https://github.com/encode/django-rest-framework/issues/6680
[gh6317]: https://github.com/encode/django-rest-framework/issues/6317 [gh6317]: https://github.com/encode/django-rest-framework/issues/6317
<!-- 3.11.0 -->
[gh6892]: https://github.com/encode/django-rest-framework/issues/6892
[gh6916]: https://github.com/encode/django-rest-framework/issues/6916
[gh6865]: https://github.com/encode/django-rest-framework/issues/6865
[gh6898]: https://github.com/encode/django-rest-framework/issues/6898
[gh6941]: https://github.com/encode/django-rest-framework/issues/6941
[gh6944]: https://github.com/encode/django-rest-framework/issues/6944
[gh6914]: https://github.com/encode/django-rest-framework/issues/6914
[gh6912]: https://github.com/encode/django-rest-framework/issues/6912
[gh7018]: https://github.com/encode/django-rest-framework/issues/7018
[gh6996]: https://github.com/encode/django-rest-framework/issues/6996
[gh6980]: https://github.com/encode/django-rest-framework/issues/6980
[gh7059]: https://github.com/encode/django-rest-framework/issues/7059
[gh6923]: https://github.com/encode/django-rest-framework/issues/6923

View File

@ -223,6 +223,7 @@ To submit new content, [open an issue][drf-create-issue] or [create a pull reque
### Views ### Views
* [django-rest-multiple-models][django-rest-multiple-models] - Provides a generic view (and mixin) for sending multiple serialized models and/or querysets via a single API request. * [django-rest-multiple-models][django-rest-multiple-models] - Provides a generic view (and mixin) for sending multiple serialized models and/or querysets via a single API request.
* [drf-typed-views][drf-typed-views] - Use Python type annotations to validate/deserialize request parameters. Inspired by API Star, Hug and FastAPI.
### Routers ### Routers
@ -272,6 +273,7 @@ To submit new content, [open an issue][drf-create-issue] or [create a pull reque
* [djangorestframework-mvt][djangorestframework-mvt] - An extension for creating views that serve Postgres data as Map Box Vector Tiles. * [djangorestframework-mvt][djangorestframework-mvt] - An extension for creating views that serve Postgres data as Map Box Vector Tiles.
* [drf-viewset-profiler][drf-viewset-profiler] - Lib to profile all methods from a viewset line by line. * [drf-viewset-profiler][drf-viewset-profiler] - Lib to profile all methods from a viewset line by line.
* [djangorestframework-features][djangorestframework-features] - Advanced schema generation and more based on named features. * [djangorestframework-features][djangorestframework-features] - Advanced schema generation and more based on named features.
* [django-elasticsearch-dsl-drf][django-elasticsearch-dsl-drf] - Integrate Elasticsearch DSL with Django REST framework. Package provides views, serializers, filter backends, pagination and other handy add-ons.
[cite]: http://www.software-ecosystems.com/Software_Ecosystems/Ecosystems.html [cite]: http://www.software-ecosystems.com/Software_Ecosystems/Ecosystems.html
[cookiecutter]: https://github.com/jpadilla/cookiecutter-django-rest-framework [cookiecutter]: https://github.com/jpadilla/cookiecutter-django-rest-framework
@ -347,6 +349,7 @@ To submit new content, [open an issue][drf-create-issue] or [create a pull reque
[django-rest-witchcraft]: https://github.com/shosca/django-rest-witchcraft [django-rest-witchcraft]: https://github.com/shosca/django-rest-witchcraft
[drf-access-policy]: https://github.com/rsinger86/drf-access-policy [drf-access-policy]: https://github.com/rsinger86/drf-access-policy
[drf-flex-fields]: https://github.com/rsinger86/drf-flex-fields [drf-flex-fields]: https://github.com/rsinger86/drf-flex-fields
[drf-typed-views]: https://github.com/rsinger86/drf-typed-views
[drf-action-serializer]: https://github.com/gregschmit/drf-action-serializer [drf-action-serializer]: https://github.com/gregschmit/drf-action-serializer
[djangorestframework-dataclasses]: https://github.com/oxan/djangorestframework-dataclasses [djangorestframework-dataclasses]: https://github.com/oxan/djangorestframework-dataclasses
[django-restql]: https://github.com/yezyilomo/django-restql [django-restql]: https://github.com/yezyilomo/django-restql
@ -354,3 +357,4 @@ To submit new content, [open an issue][drf-create-issue] or [create a pull reque
[django-rest-framework-guardian]: https://github.com/rpkilby/django-rest-framework-guardian [django-rest-framework-guardian]: https://github.com/rpkilby/django-rest-framework-guardian
[drf-viewset-profiler]: https://github.com/fvlima/drf-viewset-profiler [drf-viewset-profiler]: https://github.com/fvlima/drf-viewset-profiler
[djangorestframework-features]: https://github.com/cloudcode-hungary/django-rest-framework-features/ [djangorestframework-features]: https://github.com/cloudcode-hungary/django-rest-framework-features/
[django-elasticsearch-dsl-drf]: https://github.com/barseghyanartur/django-elasticsearch-dsl-drf

View File

@ -11,8 +11,8 @@ There are a wide range of resources available for learning and using Django REST
<a class="book-cover" href="https://www.twoscoopspress.com/products/two-scoops-of-django-1-11"> <a class="book-cover" href="https://www.twoscoopspress.com/products/two-scoops-of-django-1-11">
<img src="../../img/books/tsd-cover.png"/> <img src="../../img/books/tsd-cover.png"/>
</a> </a>
<a class="book-cover" href="https://wsvincent.com/books/"> <a class="book-cover" href="https://djangoforapis.com">
<img src="../../img/books/rad-cover.png"/> <img src="../../img/books/dfa-cover.jpg"/>
</a> </a>
<a class="book-cover" href="https://books.agiliq.com/projects/django-api-polls-tutorial/en/latest/"> <a class="book-cover" href="https://books.agiliq.com/projects/django-api-polls-tutorial/en/latest/">
<img src="../../img/books/bda-cover.png"/> <img src="../../img/books/bda-cover.png"/>

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -86,14 +86,14 @@ continued development by **[signing up for a paid plan][funding]**.
REST framework requires the following: REST framework requires the following:
* Python (3.5, 3.6, 3.7, 3.8) * Python (3.5, 3.6, 3.7, 3.8)
* Django (1.11, 2.0, 2.1, 2.2, 3.0) * Django (2.2, 3.0)
We **highly recommend** and only officially support the latest patch release of We **highly recommend** and only officially support the latest patch release of
each Python and Django series. each Python and Django series.
The following packages are optional: The following packages are optional:
* [coreapi][coreapi] (1.32.0+) - Schema generation support. * [PyYAML][pyyaml], [uritemplate][uriteemplate] (5.1+, 3.0.0+) - Schema generation support.
* [Markdown][markdown] (3.0.0+) - Markdown support for the browsable API. * [Markdown][markdown] (3.0.0+) - Markdown support for the browsable API.
* [Pygments][pygments] (2.4.0+) - Add syntax highlighting to Markdown processing. * [Pygments][pygments] (2.4.0+) - Add syntax highlighting to Markdown processing.
* [django-filter][django-filter] (1.0.1+) - Filtering support. * [django-filter][django-filter] (1.0.1+) - Filtering support.
@ -148,7 +148,7 @@ Don't forget to make sure you've also added `rest_framework` to your `INSTALLED_
We're ready to create our API now. We're ready to create our API now.
Here's our project's root `urls.py` module: Here's our project's root `urls.py` module:
from django.conf.urls import url, include from django.urls import path, include
from django.contrib.auth.models import User from django.contrib.auth.models import User
from rest_framework import routers, serializers, viewsets from rest_framework import routers, serializers, viewsets
@ -170,8 +170,8 @@ Here's our project's root `urls.py` module:
# Wire up our API using automatic URL routing. # Wire up our API using automatic URL routing.
# Additionally, we include login URLs for the browsable API. # Additionally, we include login URLs for the browsable API.
urlpatterns = [ urlpatterns = [
url(r'^', include(router.urls)), path('', include(router.urls)),
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')) path('api-auth/', include('rest_framework.urls', namespace='rest_framework'))
] ]
You can now open the API in your browser at [http://127.0.0.1:8000/](http://127.0.0.1:8000/), and view your new 'users' API. If you use the login control in the top right corner you'll also be able to add, create and delete users from the system. You can now open the API in your browser at [http://127.0.0.1:8000/](http://127.0.0.1:8000/), and view your new 'users' API. If you use the login control in the top right corner you'll also be able to add, create and delete users from the system.
@ -237,7 +237,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
[redhat]: https://www.redhat.com/ [redhat]: https://www.redhat.com/
[heroku]: https://www.heroku.com/ [heroku]: https://www.heroku.com/
[eventbrite]: https://www.eventbrite.co.uk/about/ [eventbrite]: https://www.eventbrite.co.uk/about/
[coreapi]: https://pypi.org/project/coreapi/ [pyyaml]: https://pypi.org/project/PyYAML/
[uriteemplate]: https://pypi.org/project/uritemplate/
[markdown]: https://pypi.org/project/Markdown/ [markdown]: https://pypi.org/project/Markdown/
[pygments]: https://pypi.org/project/Pygments/ [pygments]: https://pypi.org/project/Pygments/
[django-filter]: https://pypi.org/project/django-filter/ [django-filter]: https://pypi.org/project/django-filter/

View File

@ -137,12 +137,12 @@ Finally, we're including default login and logout views for use with the browsab
## Pagination ## Pagination
Pagination allows you to control how many objects per page are returned. To enable it add the following lines to `tutorial/settings.py` Pagination allows you to control how many objects per page are returned. To enable it add the following lines to `tutorial/settings.py`
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 10 'PAGE_SIZE': 10
} }
## Settings ## Settings
Add `'rest_framework'` to `INSTALLED_APPS`. The settings module will be in `tutorial/settings.py` Add `'rest_framework'` to `INSTALLED_APPS`. The settings module will be in `tutorial/settings.py`
@ -224,5 +224,5 @@ If you want to get a more in depth understanding of how REST framework fits toge
[image]: ../img/quickstart.png [image]: ../img/quickstart.png
[tutorial]: 1-serialization.md [tutorial]: 1-serialization.md
[guide]: ../#api-guide [guide]: ../api-guide/requests.md
[httpie]: https://github.com/jakubroztocil/httpie#installation [httpie]: https://github.com/jakubroztocil/httpie#installation

View File

@ -4,6 +4,6 @@
<h1 id="404-page-not-found" style="text-align: center">404</h1> <h1 id="404-page-not-found" style="text-align: center">404</h1>
<p style="text-align: center"><strong>Page not found</strong></p> <p style="text-align: center"><strong>Page not found</strong></p>
<p style="text-align: center">Try the <a href="https://www.django-rest-framework.org/">homepage</a>, or <a href="#searchModal" data-toggle="modal">search the documentation</a>.</p> <p style="text-align: center">Try the <a href="{{ base_url }}">homepage</a>, or <a href="#mkdocs_search_modal" data-toggle="modal">search the documentation</a>.</p>
{% endblock %} {% endblock %}

View File

@ -6,7 +6,7 @@ pre {
.dropdown .dropdown-menu { .dropdown .dropdown-menu {
display: none; display: none;
overflow-y: scroll; overflow-y: auto;
} }
.dropdown.open .dropdown-menu { .dropdown.open .dropdown-menu {

View File

@ -5,22 +5,18 @@
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta charset="utf-8"> <meta charset="utf-8">
<title>{% if page.title %}{{ page.title }} - {% endif %}{{ config.site_name }}</title> <title>{% if page.title %}{{ page.title }} - {% endif %}{{ config.site_name }}</title>
<link href="{{ base_url }}/img/favicon.ico" rel="icon" type="image/x-icon"> <link href="{{ 'img/favicon.ico'|url }}" rel="icon" type="image/x-icon">
<link rel="canonical" href="{{ page.canonical_url|url }}" /> <link rel="canonical" href="{{ page.canonical_url|url }}" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Django, API, REST{% if page %}, {{ page.title }}{% endif %}"> <meta name="description" content="Django, API, REST{% if page %}, {{ page.title }}{% endif %}">
<meta name="author" content="Tom Christie"> <meta name="author" content="Tom Christie">
<!-- Le styles --> <!-- Le styles -->
<link href="{{ base_url }}/css/prettify.css" rel="stylesheet"> <link href="{{ 'css/prettify.css'|url }}" rel="stylesheet">
<link href="{{ base_url }}/css/bootstrap.css" rel="stylesheet"> <link href="{{ 'css/bootstrap.css'|url }}" rel="stylesheet">
<link href="{{ base_url }}/css/bootstrap-responsive.css" rel="stylesheet"> <link href="{{ 'css/bootstrap-responsive.css'|url }}" rel="stylesheet">
<link href="{{ base_url }}/css/default.css" rel="stylesheet"> <link href="{{ 'css/default.css'|url }}" rel="stylesheet">
<!-- Le HTML5 shim, for IE6-8 support of HTML5 elements -->
<!--[if lt IE 9]>
<script src="http://html5shim.googlecode.com/svn/trunk/html5.js"></script>
<![endif]-->
<script type="text/javascript"> <script type="text/javascript">
var _gaq = _gaq || []; var _gaq = _gaq || [];
@ -102,7 +98,7 @@
{% endfor %} {% endfor %}
<div class="promo"> <div class="promo">
<hr/> {% if page.toc %}<hr/>{% endif %}
<div id="sidebarInclude"> <div id="sidebarInclude">
</div> </div>
</ul> </ul>
@ -139,10 +135,10 @@
================================================== --> ================================================== -->
<!-- Placed at the end of the document so the pages load faster --> <!-- Placed at the end of the document so the pages load faster -->
<script async src="https://fund.django-rest-framework.org/sidebar_include.js"></script> <script async src="https://fund.django-rest-framework.org/sidebar_include.js"></script>
<script src="{{ base_url }}/js/jquery-1.8.1-min.js"></script> <script src="{{ 'js/jquery-1.8.1-min.js'|url }}"></script>
<script src="{{ base_url }}/js/prettify-1.0.js"></script> <script src="{{ 'js/prettify-1.0.js'|url }}"></script>
<script src="{{ base_url }}/js/bootstrap-2.1.1-min.js"></script> <script src="{{ 'js/bootstrap-2.1.1-min.js'|url }}"></script>
<script src="{{ base_url }}/js/theme.js"></script> <script src="{{ 'js/theme.js'|url }}"></script>
<script>var base_url = '{{ base_url }}';</script> <script>var base_url = '{{ base_url }}';</script>
{% for path in config.extra_javascript %} {% for path in config.extra_javascript %}

View File

@ -1,6 +1,6 @@
# PEP8 code linting, which we run on all commits. # PEP8 code linting, which we run on all commits.
flake8==3.7.8 flake8==3.7.9
flake8-tidy-imports==3.0.0 flake8-tidy-imports==4.1.0
pycodestyle==2.5.0 pycodestyle==2.5.0
# Sort and lint imports # Sort and lint imports

View File

@ -1,2 +1,2 @@
# MkDocs to build our documentation. # MkDocs to build our documentation.
mkdocs==1.0.4 mkdocs==1.1

View File

@ -1,8 +1,8 @@
# Optional packages which may be used with REST framework. # Optional packages which may be used with REST framework.
psycopg2-binary>=2.8.2, <2.9 psycopg2-binary>=2.8.5, <2.9
markdown==3.1.1 markdown==3.1.1
pygments==2.4.2 pygments==2.4.2
django-guardian==2.1.0 django-guardian==2.2.0
django-filter>=2.2.0, <2.3 django-filter>=2.2.0, <2.3
coreapi==2.3.1 coreapi==2.3.1
coreschema==0.0.4 coreschema==0.0.4

View File

@ -1,8 +1,8 @@
# Wheel for PyPI installs. # Wheel for PyPI installs.
wheel==0.30.0 wheel==0.34.2
# Twine for secured PyPI uploads. # Twine for secured PyPI uploads.
twine==1.11.0 twine==3.1.1
# Transifex client for managing translation resources. # Transifex client for managing translation resources.
transifex-client==0.11 transifex-client==0.13.9

View File

@ -1,4 +1,4 @@
# Pytest for running the tests. # Pytest for running the tests.
pytest>=5.0,<5.1 pytest>=5.4.1,<5.5
pytest-django>=3.5.1,<3.6 pytest-django>=3.9.0,<3.10
pytest-cov>=2.7.1 pytest-cov>=2.7.1

View File

@ -25,9 +25,9 @@ ISO_8601 = 'iso-8601'
default_app_config = 'rest_framework.apps.RestFrameworkConfig' default_app_config = 'rest_framework.apps.RestFrameworkConfig'
class RemovedInDRF312Warning(DeprecationWarning): class RemovedInDRF313Warning(DeprecationWarning):
pass pass
class RemovedInDRF313Warning(PendingDeprecationWarning): class RemovedInDRF314Warning(PendingDeprecationWarning):
pass pass

View File

@ -74,7 +74,11 @@ class BasicAuthentication(BaseAuthentication):
raise exceptions.AuthenticationFailed(msg) raise exceptions.AuthenticationFailed(msg)
try: try:
auth_parts = base64.b64decode(auth[1]).decode(HTTP_HEADER_ENCODING).partition(':') try:
auth_decoded = base64.b64decode(auth[1]).decode('utf-8')
except UnicodeDecodeError:
auth_decoded = base64.b64decode(auth[1]).decode('latin-1')
auth_parts = auth_decoded.partition(':')
except (TypeError, UnicodeDecodeError, binascii.Error): except (TypeError, UnicodeDecodeError, binascii.Error):
msg = _('Invalid basic header. Credentials not correctly base64 encoded.') msg = _('Invalid basic header. Credentials not correctly base64 encoded.')
raise exceptions.AuthenticationFailed(msg) raise exceptions.AuthenticationFailed(msg)

View File

@ -3,10 +3,8 @@ from django.contrib import admin
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
@admin.register(Token)
class TokenAdmin(admin.ModelAdmin): class TokenAdmin(admin.ModelAdmin):
list_display = ('key', 'user', 'created') list_display = ('key', 'user', 'created')
fields = ('user',) fields = ('user',)
ordering = ('-created',) ordering = ('-created',)
admin.site.register(Token, TokenAdmin)

View File

@ -5,11 +5,19 @@ from rest_framework import serializers
class AuthTokenSerializer(serializers.Serializer): class AuthTokenSerializer(serializers.Serializer):
username = serializers.CharField(label=_("Username")) username = serializers.CharField(
label=_("Username"),
write_only=True
)
password = serializers.CharField( password = serializers.CharField(
label=_("Password"), label=_("Password"),
style={'input_type': 'password'}, style={'input_type': 'password'},
trim_whitespace=False trim_whitespace=False,
write_only=True
)
token = serializers.CharField(
label=_("Token"),
read_only=True
) )
def validate(self, attrs): def validate(self, attrs):

View File

@ -4,6 +4,7 @@ from rest_framework.authtoken.serializers import AuthTokenSerializer
from rest_framework.compat import coreapi, coreschema from rest_framework.compat import coreapi, coreschema
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.schemas import ManualSchema from rest_framework.schemas import ManualSchema
from rest_framework.schemas import coreapi as coreapi_schema
from rest_framework.views import APIView from rest_framework.views import APIView
@ -13,7 +14,8 @@ class ObtainAuthToken(APIView):
parser_classes = (parsers.FormParser, parsers.MultiPartParser, parsers.JSONParser,) parser_classes = (parsers.FormParser, parsers.MultiPartParser, parsers.JSONParser,)
renderer_classes = (renderers.JSONRenderer,) renderer_classes = (renderers.JSONRenderer,)
serializer_class = AuthTokenSerializer serializer_class = AuthTokenSerializer
if coreapi is not None and coreschema is not None:
if coreapi_schema.is_enabled():
schema = ManualSchema( schema = ManualSchema(
fields=[ fields=[
coreapi.Field( coreapi.Field(
@ -38,9 +40,19 @@ class ObtainAuthToken(APIView):
encoding="application/json", encoding="application/json",
) )
def get_serializer_context(self):
return {
'request': self.request,
'format': self.format_kwarg,
'view': self
}
def get_serializer(self, *args, **kwargs):
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
serializer = self.serializer_class(data=request.data, serializer = self.get_serializer(data=request.data)
context={'request': request})
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
user = serializer.validated_data['user'] user = serializer.validated_data['user']
token, created = Token.objects.get_or_create(user=user) token, created = Token.objects.get_or_create(user=user)

View File

@ -9,7 +9,7 @@ def pagination_system_check(app_configs, **kwargs):
if api_settings.PAGE_SIZE and not api_settings.DEFAULT_PAGINATION_CLASS: if api_settings.PAGE_SIZE and not api_settings.DEFAULT_PAGINATION_CLASS:
errors.append( errors.append(
Warning( Warning(
"You have specified a default PAGE_SIZE pagination rest_framework setting," "You have specified a default PAGE_SIZE pagination rest_framework setting, "
"without specifying also a DEFAULT_PAGINATION_CLASS.", "without specifying also a DEFAULT_PAGINATION_CLASS.",
hint="The default for DEFAULT_PAGINATION_CLASS is None. " hint="The default for DEFAULT_PAGINATION_CLASS is None. "
"In previous versions this was PageNumberPagination. " "In previous versions this was PageNumberPagination. "

View File

@ -8,7 +8,6 @@ from functools import reduce
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.db import models from django.db import models
from django.db.models.constants import LOOKUP_SEP from django.db.models.constants import LOOKUP_SEP
from django.db.models.sql.constants import ORDER_PATTERN
from django.template import loader from django.template import loader
from django.utils.encoding import force_str from django.utils.encoding import force_str
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -86,7 +85,7 @@ class SearchFilter(BaseFilterBackend):
search_field = search_field[1:] search_field = search_field[1:]
# Annotated fields do not need to be distinct # Annotated fields do not need to be distinct
if isinstance(queryset, models.QuerySet) and search_field in queryset.query.annotations: if isinstance(queryset, models.QuerySet) and search_field in queryset.query.annotations:
return False continue
parts = search_field.split(LOOKUP_SEP) parts = search_field.split(LOOKUP_SEP)
for part in parts: for part in parts:
field = opts.get_field(part) field = opts.get_field(part)
@ -97,6 +96,9 @@ class SearchFilter(BaseFilterBackend):
if any(path.m2m for path in path_info): if any(path.m2m for path in path_info):
# This field is a m2m relation so we know we need to call distinct # This field is a m2m relation so we know we need to call distinct
return True return True
else:
# This field has a custom __ query transform but is not a relational field.
break
return False return False
def filter_queryset(self, request, queryset, view): def filter_queryset(self, request, queryset, view):
@ -256,7 +258,13 @@ class OrderingFilter(BaseFilterBackend):
def remove_invalid_fields(self, queryset, fields, view, request): def remove_invalid_fields(self, queryset, fields, view, request):
valid_fields = [item[0] for item in self.get_valid_fields(queryset, view, {'request': request})] valid_fields = [item[0] for item in self.get_valid_fields(queryset, view, {'request': request})]
return [term for term in fields if term.lstrip('-') in valid_fields and ORDER_PATTERN.match(term)]
def term_valid(term):
if term.startswith("-"):
term = term[1:]
return term in valid_fields
return [term for term in fields if term_valid(term)]
def filter_queryset(self, request, queryset, view): def filter_queryset(self, request, queryset, view):
ordering = self.get_ordering(request, queryset, view) ordering = self.get_ordering(request, queryset, view)

View File

@ -108,7 +108,7 @@ class GenericAPIView(views.APIView):
serializer_class = self.get_serializer_class() serializer_class = self.get_serializer_class()
kwargs.setdefault('context', self.get_serializer_context()) kwargs.setdefault('context', self.get_serializer_context())
return serializer_class(*args, **kwargs) return serializer_class(*args, **kwargs)
def get_serializer_class(self): def get_serializer_class(self):

View File

@ -25,6 +25,7 @@ class Command(BaseCommand):
parser.add_argument('--format', dest="format", choices=['openapi', 'openapi-json'], default='openapi', type=str) parser.add_argument('--format', dest="format", choices=['openapi', 'openapi-json'], default='openapi', type=str)
parser.add_argument('--urlconf', dest="urlconf", default=None, type=str) parser.add_argument('--urlconf', dest="urlconf", default=None, type=str)
parser.add_argument('--generator_class', dest="generator_class", default=None, type=str) parser.add_argument('--generator_class', dest="generator_class", default=None, type=str)
parser.add_argument('--file', dest="file", default=None, type=str)
def handle(self, *args, **options): def handle(self, *args, **options):
if options['generator_class']: if options['generator_class']:
@ -40,7 +41,12 @@ class Command(BaseCommand):
schema = generator.get_schema(request=None, public=True) schema = generator.get_schema(request=None, public=True)
renderer = self.get_renderer(options['format']) renderer = self.get_renderer(options['format'])
output = renderer.render(schema, renderer_context={}) output = renderer.render(schema, renderer_context={})
self.stdout.write(output.decode())
if options['file']:
with open(options['file'], 'wb') as f:
f.write(output)
else:
self.stdout.write(output.decode())
def get_renderer(self, format): def get_renderer(self, format):
if self.get_mode() == COREAPI_MODE: if self.get_mode() == COREAPI_MODE:

View File

@ -236,10 +236,16 @@ class PageNumberPagination(BasePagination):
'next': { 'next': {
'type': 'string', 'type': 'string',
'nullable': True, 'nullable': True,
'format': 'uri',
'example': 'http://api.example.org/accounts/?{page_query_param}=4'.format(
page_query_param=self.page_query_param)
}, },
'previous': { 'previous': {
'type': 'string', 'type': 'string',
'nullable': True, 'nullable': True,
'format': 'uri',
'example': 'http://api.example.org/accounts/?{page_query_param}=2'.format(
page_query_param=self.page_query_param)
}, },
'results': schema, 'results': schema,
}, },
@ -403,10 +409,16 @@ class LimitOffsetPagination(BasePagination):
'next': { 'next': {
'type': 'string', 'type': 'string',
'nullable': True, 'nullable': True,
'format': 'uri',
'example': 'http://api.example.org/accounts/?{offset_param}=400&{limit_param}=100'.format(
offset_param=self.offset_query_param, limit_param=self.limit_query_param),
}, },
'previous': { 'previous': {
'type': 'string', 'type': 'string',
'nullable': True, 'nullable': True,
'format': 'uri',
'example': 'http://api.example.org/accounts/?{offset_param}=200&{limit_param}=100'.format(
offset_param=self.offset_query_param, limit_param=self.limit_query_param),
}, },
'results': schema, 'results': schema,
}, },

View File

@ -1053,7 +1053,11 @@ class OpenAPIRenderer(BaseRenderer):
assert yaml, 'Using OpenAPIRenderer, but `pyyaml` is not installed.' assert yaml, 'Using OpenAPIRenderer, but `pyyaml` is not installed.'
def render(self, data, media_type=None, renderer_context=None): def render(self, data, media_type=None, renderer_context=None):
return yaml.dump(data, default_flow_style=False, sort_keys=False).encode('utf-8') # disable yaml advanced feature 'alias' for clean, portable, and readable output
class Dumper(yaml.Dumper):
def ignore_aliases(self, data):
return True
return yaml.dump(data, default_flow_style=False, sort_keys=False, Dumper=Dumper).encode('utf-8')
class JSONOpenAPIRenderer(BaseRenderer): class JSONOpenAPIRenderer(BaseRenderer):

View File

@ -179,6 +179,13 @@ class Request:
forced_auth = ForcedAuthentication(force_user, force_token) forced_auth = ForcedAuthentication(force_user, force_token)
self.authenticators = (forced_auth,) self.authenticators = (forced_auth,)
def __repr__(self):
return '<%s.%s: %s %r>' % (
self.__class__.__module__,
self.__class__.__name__,
self.method,
self.get_full_path())
def _default_negotiator(self): def _default_negotiator(self):
return api_settings.DEFAULT_CONTENT_NEGOTIATION_CLASS() return api_settings.DEFAULT_CONTENT_NEGOTIATION_CLASS()

View File

@ -1,4 +1,7 @@
import re
import warnings import warnings
from collections import OrderedDict
from decimal import Decimal
from operator import attrgetter from operator import attrgetter
from urllib.parse import urljoin from urllib.parse import urljoin
@ -9,9 +12,12 @@ from django.core.validators import (
from django.db import models from django.db import models
from django.utils.encoding import force_str from django.utils.encoding import force_str
from rest_framework import exceptions, renderers, serializers from rest_framework import (
RemovedInDRF314Warning, exceptions, renderers, serializers
)
from rest_framework.compat import uritemplate from rest_framework.compat import uritemplate
from rest_framework.fields import _UnvalidatedField, empty from rest_framework.fields import _UnvalidatedField, empty
from rest_framework.settings import api_settings
from .generators import BaseSchemaGenerator from .generators import BaseSchemaGenerator
from .inspectors import ViewInspector from .inspectors import ViewInspector
@ -32,14 +38,40 @@ class SchemaGenerator(BaseSchemaGenerator):
return info return info
def check_duplicate_operation_id(self, paths):
ids = {}
for route in paths:
for method in paths[route]:
if 'operationId' not in paths[route][method]:
continue
operation_id = paths[route][method]['operationId']
if operation_id in ids:
warnings.warn(
'You have a duplicated operationId in your OpenAPI schema: {operation_id}\n'
'\tRoute: {route1}, Method: {method1}\n'
'\tRoute: {route2}, Method: {method2}\n'
'\tAn operationId has to be unique accros your schema. Your schema may not work in other tools.'
.format(
route1=ids[operation_id]['route'],
method1=ids[operation_id]['method'],
route2=route,
method2=method,
operation_id=operation_id
)
)
ids[operation_id] = {
'route': route,
'method': method
}
def get_schema(self, request=None, public=False): def get_schema(self, request=None, public=False):
""" """
Generate a OpenAPI schema. Generate a OpenAPI schema.
""" """
self._initialise_endpoints() self._initialise_endpoints()
components_schemas = {}
# Iterate endpoints generating per method path operations. # Iterate endpoints generating per method path operations.
# TODO: …and reference components.
paths = {} paths = {}
_, view_endpoints = self._get_paths_and_endpoints(None if public else request) _, view_endpoints = self._get_paths_and_endpoints(None if public else request)
for path, method, view in view_endpoints: for path, method, view in view_endpoints:
@ -47,6 +79,16 @@ class SchemaGenerator(BaseSchemaGenerator):
continue continue
operation = view.schema.get_operation(path, method) 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('Schema component "{}" has been overriden with a different value.'.format(k))
components_schemas.update(components)
# Normalise path for any provided mount url. # Normalise path for any provided mount url.
if path.startswith('/'): if path.startswith('/'):
path = path[1:] path = path[1:]
@ -55,6 +97,8 @@ class SchemaGenerator(BaseSchemaGenerator):
paths.setdefault(path, {}) paths.setdefault(path, {})
paths[path][method.lower()] = operation paths[path][method.lower()] = operation
self.check_duplicate_operation_id(paths)
# Compile final schema. # Compile final schema.
schema = { schema = {
'openapi': '3.0.2', 'openapi': '3.0.2',
@ -62,6 +106,11 @@ class SchemaGenerator(BaseSchemaGenerator):
'paths': paths, 'paths': paths,
} }
if len(components_schemas) > 0:
schema['components'] = {
'schemas': components_schemas
}
return schema return schema
# View Inspectors # View Inspectors
@ -69,56 +118,112 @@ class SchemaGenerator(BaseSchemaGenerator):
class AutoSchema(ViewInspector): class AutoSchema(ViewInspector):
def __init__(self, tags=None, operation_id_base=None, component_name=None):
"""
:param operation_id_base: user-defined name in operationId. If empty, it will be deducted from the Model/Serializer/View name.
:param component_name: user-defined component's name. If empty, it will be deducted from the Serializer's class name.
"""
if tags and not all(isinstance(tag, str) for tag in tags):
raise ValueError('tags must be a list or tuple of string.')
self._tags = tags
self.operation_id_base = operation_id_base
self.component_name = component_name
super().__init__()
request_media_types = [] request_media_types = []
response_media_types = [] response_media_types = []
method_mapping = { method_mapping = {
'get': 'Retrieve', 'get': 'retrieve',
'post': 'Create', 'post': 'create',
'put': 'Update', 'put': 'update',
'patch': 'PartialUpdate', 'patch': 'partialUpdate',
'delete': 'Destroy', 'delete': 'destroy',
} }
def get_operation(self, path, method): def get_operation(self, path, method):
operation = {} operation = {}
operation['operationId'] = self._get_operation_id(path, method) operation['operationId'] = self.get_operation_id(path, method)
operation['description'] = self.get_description(path, method) operation['description'] = self.get_description(path, method)
parameters = [] parameters = []
parameters += self._get_path_parameters(path, method) parameters += self.get_path_parameters(path, method)
parameters += self._get_pagination_parameters(path, method) parameters += self.get_pagination_parameters(path, method)
parameters += self._get_filter_parameters(path, method) parameters += self.get_filter_parameters(path, method)
operation['parameters'] = parameters operation['parameters'] = parameters
request_body = self._get_request_body(path, method) request_body = self.get_request_body(path, method)
if request_body: if request_body:
operation['requestBody'] = request_body operation['requestBody'] = request_body
operation['responses'] = self._get_responses(path, method) operation['responses'] = self.get_responses(path, method)
operation['tags'] = self.get_tags(path, method)
return operation return operation
def _get_operation_id(self, path, method): def get_component_name(self, serializer):
""" """
Compute an operation ID from the model, serializer or view name. Compute the component's name from the serializer.
Raise an exception if the serializer's class name is "Serializer" (case-insensitive).
""" """
method_name = getattr(self.view, 'action', method.lower()) if self.component_name is not None:
if is_list_view(path, method, self.view): return self.component_name
action = 'list'
elif method_name not in self.method_mapping: # use the serializer's class name as the component name.
action = method_name component_name = serializer.__class__.__name__
else: # We remove the "serializer" string from the class name.
action = self.method_mapping[method.lower()] pattern = re.compile("serializer", re.IGNORECASE)
component_name = pattern.sub("", component_name)
if component_name == "":
raise Exception(
'"{}" is an invalid class name for schema generation. '
'Serializer\'s class name should be unique and explicit. e.g. "ItemSerializer"'
.format(serializer.__class__.__name__)
)
return component_name
def get_components(self, path, method):
"""
Return components with their properties from the serializer.
"""
if method.lower() == 'delete':
return {}
serializer = self.get_serializer(path, method)
if not isinstance(serializer, serializers.Serializer):
return {}
component_name = self.get_component_name(serializer)
content = self.map_serializer(serializer)
return {component_name: content}
def _to_camel_case(self, snake_str):
components = snake_str.split('_')
# We capitalize the first letter of each component except the first one
# with the 'title' method and join them together.
return components[0] + ''.join(x.title() for x in components[1:])
def get_operation_id_base(self, path, method, action):
"""
Compute the base part for operation ID from the model, serializer or view name.
"""
model = getattr(getattr(self.view, 'queryset', None), 'model', None)
if self.operation_id_base is not None:
name = self.operation_id_base
# Try to deduce the ID from the view's model # Try to deduce the ID from the view's model
model = getattr(getattr(self.view, 'queryset', None), 'model', None) elif model is not None:
if model is not None:
name = model.__name__ name = model.__name__
# Try with the serializer class name # Try with the serializer class name
elif self._get_serializer(path, method) is not None: elif self.get_serializer(path, method) is not None:
name = self._get_serializer(path, method).__class__.__name__ name = self.get_serializer(path, method).__class__.__name__
if name.endswith('Serializer'): if name.endswith('Serializer'):
name = name[:-10] name = name[:-10]
@ -138,9 +243,25 @@ class AutoSchema(ViewInspector):
if action == 'list' and not name.endswith('s'): # listThings instead of listThing if action == 'list' and not name.endswith('s'): # listThings instead of listThing
name += 's' name += 's'
return name
def get_operation_id(self, path, method):
"""
Compute an operation ID from the view type and get_operation_id_base method.
"""
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 = self._to_camel_case(method_name)
else:
action = self.method_mapping[method.lower()]
name = self.get_operation_id_base(path, method, action)
return action + name return action + name
def _get_path_parameters(self, path, method): def get_path_parameters(self, path, method):
""" """
Return a list of parameters from templated path variables. Return a list of parameters from templated path variables.
""" """
@ -176,15 +297,15 @@ class AutoSchema(ViewInspector):
return parameters return parameters
def _get_filter_parameters(self, path, method): def get_filter_parameters(self, path, method):
if not self._allows_filters(path, method): if not self.allows_filters(path, method):
return [] return []
parameters = [] parameters = []
for filter_backend in self.view.filter_backends: for filter_backend in self.view.filter_backends:
parameters += filter_backend().get_schema_operation_parameters(self.view) parameters += filter_backend().get_schema_operation_parameters(self.view)
return parameters return parameters
def _allows_filters(self, path, method): def allows_filters(self, path, method):
""" """
Determine whether to include filter Fields in schema. Determine whether to include filter Fields in schema.
@ -197,28 +318,56 @@ class AutoSchema(ViewInspector):
return self.view.action in ["list", "retrieve", "update", "partial_update", "destroy"] return self.view.action in ["list", "retrieve", "update", "partial_update", "destroy"]
return method.lower() in ["get", "put", "patch", "delete"] return method.lower() in ["get", "put", "patch", "delete"]
def _get_pagination_parameters(self, path, method): def get_pagination_parameters(self, path, method):
view = self.view view = self.view
if not is_list_view(path, method, view): if not is_list_view(path, method, view):
return [] return []
paginator = self._get_paginator() paginator = self.get_paginator()
if not paginator: if not paginator:
return [] return []
return paginator.get_schema_operation_parameters(view) return paginator.get_schema_operation_parameters(view)
def _map_field(self, field): def map_choicefield(self, field):
choices = list(OrderedDict.fromkeys(field.choices)) # preserve order and remove duplicates
if all(isinstance(choice, bool) for choice in choices):
type = 'boolean'
elif all(isinstance(choice, int) for choice in choices):
type = 'integer'
elif all(isinstance(choice, (int, float, Decimal)) for choice in choices): # `number` includes `integer`
# Ref: https://tools.ietf.org/html/draft-wright-json-schema-validation-00#section-5.21
type = 'number'
elif all(isinstance(choice, str) for choice in choices):
type = 'string'
else:
type = None
mapping = {
# The value of `enum` keyword MUST be an array and SHOULD be unique.
# Ref: https://tools.ietf.org/html/draft-wright-json-schema-validation-00#section-5.20
'enum': choices
}
# If We figured out `type` then and only then we should set it. It must be a string.
# Ref: https://swagger.io/docs/specification/data-models/data-types/#mixed-type
# It is optional but it can not be null.
# Ref: https://tools.ietf.org/html/draft-wright-json-schema-validation-00#section-5.21
if type:
mapping['type'] = type
return mapping
def map_field(self, field):
# Nested Serializers, `many` or not. # Nested Serializers, `many` or not.
if isinstance(field, serializers.ListSerializer): if isinstance(field, serializers.ListSerializer):
return { return {
'type': 'array', 'type': 'array',
'items': self._map_serializer(field.child) 'items': self.map_serializer(field.child)
} }
if isinstance(field, serializers.Serializer): if isinstance(field, serializers.Serializer):
data = self._map_serializer(field) data = self.map_serializer(field)
data['type'] = 'object' data['type'] = 'object'
return data return data
@ -226,7 +375,7 @@ class AutoSchema(ViewInspector):
if isinstance(field, serializers.ManyRelatedField): if isinstance(field, serializers.ManyRelatedField):
return { return {
'type': 'array', 'type': 'array',
'items': self._map_field(field.child_relation) 'items': self.map_field(field.child_relation)
} }
if isinstance(field, serializers.PrimaryKeyRelatedField): if isinstance(field, serializers.PrimaryKeyRelatedField):
model = getattr(field.queryset, 'model', None) model = getattr(field.queryset, 'model', None)
@ -242,15 +391,11 @@ class AutoSchema(ViewInspector):
if isinstance(field, serializers.MultipleChoiceField): if isinstance(field, serializers.MultipleChoiceField):
return { return {
'type': 'array', 'type': 'array',
'items': { 'items': self.map_choicefield(field)
'enum': list(field.choices)
},
} }
if isinstance(field, serializers.ChoiceField): if isinstance(field, serializers.ChoiceField):
return { return self.map_choicefield(field)
'enum': list(field.choices),
}
# ListField. # ListField.
if isinstance(field, serializers.ListField): if isinstance(field, serializers.ListField):
@ -259,7 +404,7 @@ class AutoSchema(ViewInspector):
'items': {}, 'items': {},
} }
if not isinstance(field.child, _UnvalidatedField): if not isinstance(field.child, _UnvalidatedField):
mapping['items'] = self._map_field(field.child) mapping['items'] = self.map_field(field.child)
return mapping return mapping
# DateField and DateTimeField type is string # DateField and DateTimeField type is string
@ -304,11 +449,17 @@ class AutoSchema(ViewInspector):
content['format'] = field.protocol content['format'] = field.protocol
return content return content
# DecimalField has multipleOf based on decimal_places
if isinstance(field, serializers.DecimalField): if isinstance(field, serializers.DecimalField):
content = { if getattr(field, 'coerce_to_string', api_settings.COERCE_DECIMAL_TO_STRING):
'type': 'number' content = {
} 'type': 'string',
'format': 'decimal',
}
else:
content = {
'type': 'number'
}
if field.decimal_places: if field.decimal_places:
content['multipleOf'] = float('.' + (field.decimal_places - 1) * '0' + '1') content['multipleOf'] = float('.' + (field.decimal_places - 1) * '0' + '1')
if field.max_whole_digits: if field.max_whole_digits:
@ -319,7 +470,7 @@ class AutoSchema(ViewInspector):
if isinstance(field, serializers.FloatField): if isinstance(field, serializers.FloatField):
content = { content = {
'type': 'number' 'type': 'number',
} }
self._map_min_max(field, content) self._map_min_max(field, content)
return content return content
@ -355,12 +506,8 @@ class AutoSchema(ViewInspector):
if field.min_value: if field.min_value:
content['minimum'] = field.min_value content['minimum'] = field.min_value
def _map_serializer(self, serializer): def map_serializer(self, serializer):
# Assuming we have a valid serializer instance. # Assuming we have a valid serializer instance.
# TODO:
# - field is Nested or List serializer.
# - Handle read_only/write_only for request/response differences.
# - could do this with readOnly/writeOnly and then filter dict.
required = [] required = []
properties = {} properties = {}
@ -371,22 +518,23 @@ class AutoSchema(ViewInspector):
if field.required: if field.required:
required.append(field.field_name) required.append(field.field_name)
schema = self._map_field(field) schema = self.map_field(field)
if field.read_only: if field.read_only:
schema['readOnly'] = True schema['readOnly'] = True
if field.write_only: if field.write_only:
schema['writeOnly'] = True schema['writeOnly'] = True
if field.allow_null: if field.allow_null:
schema['nullable'] = True schema['nullable'] = True
if field.default and field.default != empty and not callable(field.default): if field.default is not None and field.default != empty and not callable(field.default):
schema['default'] = field.default schema['default'] = field.default
if field.help_text: if field.help_text:
schema['description'] = str(field.help_text) schema['description'] = str(field.help_text)
self._map_field_validators(field, schema) self.map_field_validators(field, schema)
properties[field.field_name] = schema properties[field.field_name] = schema
result = { result = {
'type': 'object',
'properties': properties 'properties': properties
} }
if required: if required:
@ -394,7 +542,7 @@ class AutoSchema(ViewInspector):
return result return result
def _map_field_validators(self, field, schema): def map_field_validators(self, field, schema):
""" """
map field validators map field validators
""" """
@ -421,7 +569,8 @@ class AutoSchema(ViewInspector):
schema['maximum'] = v.limit_value schema['maximum'] = v.limit_value
elif isinstance(v, MinValueValidator): elif isinstance(v, MinValueValidator):
schema['minimum'] = v.limit_value schema['minimum'] = v.limit_value
elif isinstance(v, DecimalValidator): elif isinstance(v, DecimalValidator) and \
not getattr(field, 'coerce_to_string', api_settings.COERCE_DECIMAL_TO_STRING):
if v.decimal_places: if v.decimal_places:
schema['multipleOf'] = float('.' + (v.decimal_places - 1) * '0' + '1') schema['multipleOf'] = float('.' + (v.decimal_places - 1) * '0' + '1')
if v.max_digits: if v.max_digits:
@ -431,7 +580,7 @@ class AutoSchema(ViewInspector):
schema['maximum'] = int(digits * '9') + 1 schema['maximum'] = int(digits * '9') + 1
schema['minimum'] = -schema['maximum'] schema['minimum'] = -schema['maximum']
def _get_paginator(self): def get_paginator(self):
pagination_class = getattr(self.view, 'pagination_class', None) pagination_class = getattr(self.view, 'pagination_class', None)
if pagination_class: if pagination_class:
return pagination_class() return pagination_class()
@ -449,7 +598,7 @@ class AutoSchema(ViewInspector):
media_types.append(renderer.media_type) media_types.append(renderer.media_type)
return media_types return media_types
def _get_serializer(self, path, method): def get_serializer(self, path, method):
view = self.view view = self.view
if not hasattr(view, 'get_serializer'): if not hasattr(view, 'get_serializer'):
@ -464,35 +613,30 @@ class AutoSchema(ViewInspector):
.format(view.__class__.__name__, method, path)) .format(view.__class__.__name__, method, path))
return None return None
def _get_request_body(self, path, method): def _get_reference(self, serializer):
return {'$ref': '#/components/schemas/{}'.format(self.get_component_name(serializer))}
def get_request_body(self, path, method):
if method not in ('PUT', 'PATCH', 'POST'): if method not in ('PUT', 'PATCH', 'POST'):
return {} return {}
self.request_media_types = self.map_parsers(path, method) self.request_media_types = self.map_parsers(path, method)
serializer = self._get_serializer(path, method) serializer = self.get_serializer(path, method)
if not isinstance(serializer, serializers.Serializer): if not isinstance(serializer, serializers.Serializer):
return {} item_schema = {}
else:
content = self._map_serializer(serializer) item_schema = self._get_reference(serializer)
# No required fields for PATCH
if method == 'PATCH':
content.pop('required', None)
# No read_only fields for request.
for name, schema in content['properties'].copy().items():
if 'readOnly' in schema:
del content['properties'][name]
return { return {
'content': { 'content': {
ct: {'schema': content} ct: {'schema': item_schema}
for ct in self.request_media_types for ct in self.request_media_types
} }
} }
def _get_responses(self, path, method): def get_responses(self, path, method):
# TODO: Handle multiple codes and pagination classes.
if method == 'DELETE': if method == 'DELETE':
return { return {
'204': { '204': {
@ -502,31 +646,26 @@ class AutoSchema(ViewInspector):
self.response_media_types = self.map_renderers(path, method) self.response_media_types = self.map_renderers(path, method)
item_schema = {} serializer = self.get_serializer(path, method)
serializer = self._get_serializer(path, method)
if isinstance(serializer, serializers.Serializer): if not isinstance(serializer, serializers.Serializer):
item_schema = self._map_serializer(serializer) item_schema = {}
# No write_only fields for response. else:
for name, schema in item_schema['properties'].copy().items(): item_schema = self._get_reference(serializer)
if 'writeOnly' in schema:
del item_schema['properties'][name]
if 'required' in item_schema:
item_schema['required'] = [f for f in item_schema['required'] if f != name]
if is_list_view(path, method, self.view): if is_list_view(path, method, self.view):
response_schema = { response_schema = {
'type': 'array', 'type': 'array',
'items': item_schema, 'items': item_schema,
} }
paginator = self._get_paginator() paginator = self.get_paginator()
if paginator: if paginator:
response_schema = paginator.get_paginated_response_schema(response_schema) response_schema = paginator.get_paginated_response_schema(response_schema)
else: else:
response_schema = item_schema response_schema = item_schema
status_code = '201' if method == 'POST' else '200'
return { return {
'200': { status_code: {
'content': { 'content': {
ct: {'schema': response_schema} ct: {'schema': response_schema}
for ct in self.response_media_types for ct in self.response_media_types
@ -537,3 +676,112 @@ class AutoSchema(ViewInspector):
'description': "" 'description': ""
} }
} }
def get_tags(self, path, method):
# If user have specified tags, use them.
if self._tags:
return self._tags
# First element of a specific path could be valid tag. This is a fallback solution.
# PUT, PATCH, GET(Retrieve), DELETE: /user_profile/{id}/ tags = [user-profile]
# POST, GET(List): /user_profile/ tags = [user-profile]
if path.startswith('/'):
path = path[1:]
return [path.split('/')[0].replace('_', '-')]
def _get_path_parameters(self, path, method):
warnings.warn(
"Method `_get_path_parameters()` has been renamed to `get_path_parameters()`. "
"The old name will be removed in DRF v3.14.",
RemovedInDRF314Warning, stacklevel=2
)
return self.get_path_parameters(path, method)
def _get_filter_parameters(self, path, method):
warnings.warn(
"Method `_get_filter_parameters()` has been renamed to `get_filter_parameters()`. "
"The old name will be removed in DRF v3.14.",
RemovedInDRF314Warning, stacklevel=2
)
return self.get_filter_parameters(path, method)
def _get_responses(self, path, method):
warnings.warn(
"Method `_get_responses()` has been renamed to `get_responses()`. "
"The old name will be removed in DRF v3.14.",
RemovedInDRF314Warning, stacklevel=2
)
return self.get_responses(path, method)
def _get_request_body(self, path, method):
warnings.warn(
"Method `_get_request_body()` has been renamed to `get_request_body()`. "
"The old name will be removed in DRF v3.14.",
RemovedInDRF314Warning, stacklevel=2
)
return self.get_request_body(path, method)
def _get_serializer(self, path, method):
warnings.warn(
"Method `_get_serializer()` has been renamed to `get_serializer()`. "
"The old name will be removed in DRF v3.14.",
RemovedInDRF314Warning, stacklevel=2
)
return self.get_serializer(path, method)
def _get_paginator(self):
warnings.warn(
"Method `_get_paginator()` has been renamed to `get_paginator()`. "
"The old name will be removed in DRF v3.14.",
RemovedInDRF314Warning, stacklevel=2
)
return self.get_paginator()
def _map_field_validators(self, field, schema):
warnings.warn(
"Method `_map_field_validators()` has been renamed to `map_field_validators()`. "
"The old name will be removed in DRF v3.14.",
RemovedInDRF314Warning, stacklevel=2
)
return self.map_field_validators(field, schema)
def _map_serializer(self, serializer):
warnings.warn(
"Method `_map_serializer()` has been renamed to `map_serializer()`. "
"The old name will be removed in DRF v3.14.",
RemovedInDRF314Warning, stacklevel=2
)
return self.map_serializer(serializer)
def _map_field(self, field):
warnings.warn(
"Method `_map_field()` has been renamed to `map_field()`. "
"The old name will be removed in DRF v3.14.",
RemovedInDRF314Warning, stacklevel=2
)
return self.map_field(field)
def _map_choicefield(self, field):
warnings.warn(
"Method `_map_choicefield()` has been renamed to `map_choicefield()`. "
"The old name will be removed in DRF v3.14.",
RemovedInDRF314Warning, stacklevel=2
)
return self.map_choicefield(field)
def _get_pagination_parameters(self, path, method):
warnings.warn(
"Method `_get_pagination_parameters()` has been renamed to `get_pagination_parameters()`. "
"The old name will be removed in DRF v3.14.",
RemovedInDRF314Warning, stacklevel=2
)
return self.get_pagination_parameters(path, method)
def _allows_filters(self, path, method):
warnings.warn(
"Method `_allows_filters()` has been renamed to `allows_filters()`. "
"The old name will be removed in DRF v3.14.",
RemovedInDRF314Warning, stacklevel=2
)
return self.allows_filters(path, method)

View File

@ -13,7 +13,7 @@ response content is handled by parsers and renderers.
import copy import copy
import inspect import inspect
import traceback import traceback
from collections import OrderedDict from collections import OrderedDict, defaultdict
from collections.abc import Mapping from collections.abc import Mapping
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
@ -1508,28 +1508,55 @@ class ModelSerializer(Serializer):
# which may map onto a model field. Any dotted field name lookups # which may map onto a model field. Any dotted field name lookups
# cannot map to a field, and must be a traversal, so we're not # cannot map to a field, and must be a traversal, so we're not
# including those. # including those.
field_names = { field_sources = OrderedDict(
field.source for field in self._writable_fields (field.field_name, field.source) for field in self._writable_fields
if (field.source != '*') and ('.' not in field.source) if (field.source != '*') and ('.' not in field.source)
} )
# Special Case: Add read_only fields with defaults. # Special Case: Add read_only fields with defaults.
field_names |= { field_sources.update(OrderedDict(
field.source for field in self.fields.values() (field.field_name, field.source) for field in self.fields.values()
if (field.read_only) and (field.default != empty) and (field.source != '*') and ('.' not in field.source) if (field.read_only) and (field.default != empty) and (field.source != '*') and ('.' not in field.source)
} ))
# Invert so we can find the serializer field names that correspond to
# the model field names in the unique_together sets. This also allows
# us to check that multiple fields don't map to the same source.
source_map = defaultdict(list)
for name, source in field_sources.items():
source_map[source].append(name)
# Note that we make sure to check `unique_together` both on the # Note that we make sure to check `unique_together` both on the
# base model class, but also on any parent classes. # base model class, but also on any parent classes.
validators = [] validators = []
for parent_class in model_class_inheritance_tree: for parent_class in model_class_inheritance_tree:
for unique_together in parent_class._meta.unique_together: for unique_together in parent_class._meta.unique_together:
if field_names.issuperset(set(unique_together)): # Skip if serializer does not map to all unique together sources
validator = UniqueTogetherValidator( if not set(source_map).issuperset(set(unique_together)):
queryset=parent_class._default_manager, continue
fields=unique_together
for source in unique_together:
assert len(source_map[source]) == 1, (
"Unable to create `UniqueTogetherValidator` for "
"`{model}.{field}` as `{serializer}` has multiple "
"fields ({fields}) that map to this model field. "
"Either remove the extra fields, or override "
"`Meta.validators` with a `UniqueTogetherValidator` "
"using the desired field names."
.format(
model=self.Meta.model.__name__,
serializer=self.__class__.__name__,
field=source,
fields=', '.join(source_map[source]),
)
) )
validators.append(validator)
field_names = tuple(source_map[f][0] for f in unique_together)
validator = UniqueTogetherValidator(
queryset=parent_class._default_manager,
fields=field_names
)
validators.append(validator)
return validators return validators
def get_unique_for_date_validators(self): def get_unique_for_date_validators(self):

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -250,7 +250,7 @@
csrfToken: "{{ csrf_token }}" csrfToken: "{{ csrf_token }}"
}; };
</script> </script>
<script src="{% static "rest_framework/js/jquery-3.4.1.min.js" %}"></script> <script src="{% static "rest_framework/js/jquery-3.5.1.min.js" %}"></script>
<script src="{% static "rest_framework/js/ajax-form.js" %}"></script> <script src="{% static "rest_framework/js/ajax-form.js" %}"></script>
<script src="{% static "rest_framework/js/csrf.js" %}"></script> <script src="{% static "rest_framework/js/csrf.js" %}"></script>
<script src="{% static "rest_framework/js/bootstrap.min.js" %}"></script> <script src="{% static "rest_framework/js/bootstrap.min.js" %}"></script>

View File

@ -293,7 +293,7 @@
csrfToken: "{% if request %}{{ csrf_token }}{% endif %}" csrfToken: "{% if request %}{{ csrf_token }}{% endif %}"
}; };
</script> </script>
<script src="{% static "rest_framework/js/jquery-3.4.1.min.js" %}"></script> <script src="{% static "rest_framework/js/jquery-3.5.1.min.js" %}"></script>
<script src="{% static "rest_framework/js/ajax-form.js" %}"></script> <script src="{% static "rest_framework/js/ajax-form.js" %}"></script>
<script src="{% static "rest_framework/js/csrf.js" %}"></script> <script src="{% static "rest_framework/js/csrf.js" %}"></script>
<script src="{% static "rest_framework/js/bootstrap.min.js" %}"></script> <script src="{% static "rest_framework/js/bootstrap.min.js" %}"></script>

View File

@ -66,6 +66,6 @@ at <code>rest_framework/docs/error.html</code>.</p>
<script src="{% static 'rest_framework/js/jquery-3.4.1.min.js' %}"></script> <script src="{% static 'rest_framework/js/jquery-3.5.1.min.js' %}"></script>
</body> </body>
</html> </html>

View File

@ -38,7 +38,7 @@
{% include "rest_framework/docs/auth/basic.html" %} {% include "rest_framework/docs/auth/basic.html" %}
{% include "rest_framework/docs/auth/session.html" %} {% include "rest_framework/docs/auth/session.html" %}
<script src="{% static 'rest_framework/js/jquery-3.4.1.min.js' %}"></script> <script src="{% static 'rest_framework/js/jquery-3.5.1.min.js' %}"></script>
<script src="{% static 'rest_framework/js/bootstrap.min.js' %}"></script> <script src="{% static 'rest_framework/js/bootstrap.min.js' %}"></script>
<script src="{% static 'rest_framework/docs/js/jquery.json-view.min.js' %}"></script> <script src="{% static 'rest_framework/docs/js/jquery.json-view.min.js' %}"></script>
<script src="{% static 'rest_framework/docs/js/api.js' %}"></script> <script src="{% static 'rest_framework/docs/js/api.js' %}"></script>

View File

@ -166,13 +166,13 @@ class APIView(View):
""" """
raise exceptions.MethodNotAllowed(request.method) raise exceptions.MethodNotAllowed(request.method)
def permission_denied(self, request, message=None): def permission_denied(self, request, message=None, code=None):
""" """
If request is not permitted, determine what kind of exception to raise. If request is not permitted, determine what kind of exception to raise.
""" """
if request.authenticators and not request.successful_authenticator: if request.authenticators and not request.successful_authenticator:
raise exceptions.NotAuthenticated() raise exceptions.NotAuthenticated()
raise exceptions.PermissionDenied(detail=message) raise exceptions.PermissionDenied(detail=message, code=code)
def throttled(self, request, wait): def throttled(self, request, wait):
""" """
@ -331,7 +331,9 @@ class APIView(View):
for permission in self.get_permissions(): for permission in self.get_permissions():
if not permission.has_permission(request, self): if not permission.has_permission(request, self):
self.permission_denied( self.permission_denied(
request, message=getattr(permission, 'message', None) request,
message=getattr(permission, 'message', None),
code=getattr(permission, 'code', None)
) )
def check_object_permissions(self, request, obj): def check_object_permissions(self, request, obj):
@ -342,7 +344,9 @@ class APIView(View):
for permission in self.get_permissions(): for permission in self.get_permissions():
if not permission.has_object_permission(request, self, obj): if not permission.has_object_permission(request, self, obj):
self.permission_denied( self.permission_denied(
request, message=getattr(permission, 'message', None) request,
message=getattr(permission, 'message', None),
code=getattr(permission, 'code', None)
) )
def check_throttles(self, request): def check_throttles(self, request):

View File

@ -25,11 +25,12 @@ from django.utils.decorators import classonlymethod
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from rest_framework import generics, mixins, views from rest_framework import generics, mixins, views
from rest_framework.decorators import MethodMapper
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
def _is_extra_action(attr): def _is_extra_action(attr):
return hasattr(attr, 'mapping') return hasattr(attr, 'mapping') and isinstance(attr.mapping, MethodMapper)
class ViewSetMixin: class ViewSetMixin:
@ -92,6 +93,10 @@ class ViewSetMixin:
def view(request, *args, **kwargs): def view(request, *args, **kwargs):
self = cls(**initkwargs) self = cls(**initkwargs)
if 'get' in actions and 'head' not in actions:
actions['head'] = actions['get']
# We also store the mapping of request methods to actions, # We also store the mapping of request methods to actions,
# so that we can later set the action attribute. # so that we can later set the action attribute.
# eg. `self.action = 'list'` on an incoming GET request. # eg. `self.action = 'list'` on an incoming GET request.
@ -103,9 +108,6 @@ class ViewSetMixin:
handler = getattr(self, action) handler = getattr(self, action)
setattr(self, method, handler) setattr(self, method, handler)
if hasattr(self, 'get') and not hasattr(self, 'head'):
self.head = self.get
self.request = request self.request = request
self.args = args self.args = args
self.kwargs = kwargs self.kwargs = kwargs

View File

@ -82,17 +82,15 @@ setup(
author_email='tom@tomchristie.com', # SEE NOTE BELOW (*) author_email='tom@tomchristie.com', # SEE NOTE BELOW (*)
packages=find_packages(exclude=['tests*']), packages=find_packages(exclude=['tests*']),
include_package_data=True, include_package_data=True,
install_requires=["django>=1.11"], install_requires=["django>=2.2"],
python_requires=">=3.5", python_requires=">=3.5",
zip_safe=False, zip_safe=False,
classifiers=[ classifiers=[
'Development Status :: 5 - Production/Stable', 'Development Status :: 5 - Production/Stable',
'Environment :: Web Environment', 'Environment :: Web Environment',
'Framework :: Django', 'Framework :: Django',
'Framework :: Django :: 1.11',
'Framework :: Django :: 2.0',
'Framework :: Django :: 2.1',
'Framework :: Django :: 2.2', 'Framework :: Django :: 2.2',
'Framework :: Django :: 3.0',
'Intended Audience :: Developers', 'Intended Audience :: Developers',
'License :: OSI Approved :: BSD License', 'License :: OSI Approved :: BSD License',
'Operating System :: OS Independent', 'Operating System :: OS Independent',

View File

@ -159,6 +159,25 @@ class BasicAuthTests(TestCase):
) )
assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_decoding_of_utf8_credentials(self):
username = 'walterwhité'
email = 'walterwhite@example.com'
password = 'pässwörd'
User.objects.create_user(
username, email, password
)
credentials = ('%s:%s' % (username, password))
base64_credentials = base64.b64encode(
credentials.encode('utf-8')
).decode(HTTP_HEADER_ENCODING)
auth = 'Basic %s' % base64_credentials
response = self.csrf_client.post(
'/basic/',
{'example': 'example'},
HTTP_AUTHORIZATION=auth
)
assert response.status_code == status.HTTP_200_OK
@override_settings(ROOT_URLCONF=__name__) @override_settings(ROOT_URLCONF=__name__)
class SessionAuthTests(TestCase): class SessionAuthTests(TestCase):

View File

@ -1,4 +1,6 @@
import io import io
import os
import tempfile
import pytest import pytest
from django.conf.urls import url from django.conf.urls import url
@ -73,6 +75,21 @@ class GenerateSchemaTests(TestCase):
out_json = yaml.safe_load(self.out.getvalue()) out_json = yaml.safe_load(self.out.getvalue())
assert out_json == CustomSchemaGenerator.SCHEMA assert out_json == CustomSchemaGenerator.SCHEMA
def test_writes_schema_to_file_on_parameter(self):
fd, path = tempfile.mkstemp()
try:
call_command('generateschema', '--file={}'.format(path), stdout=self.out)
# nothing on stdout
assert not self.out.getvalue()
call_command('generateschema', stdout=self.out)
expected_out = self.out.getvalue()
# file output identical to stdout output
with os.fdopen(fd) as fh:
assert expected_out and fh.read() == expected_out
finally:
os.remove(path)
@pytest.mark.skipif(yaml is None, reason='PyYAML is required.') @pytest.mark.skipif(yaml is None, reason='PyYAML is required.')
@override_settings(REST_FRAMEWORK={'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.AutoSchema'}) @override_settings(REST_FRAMEWORK={'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.AutoSchema'})
def test_coreapi_renders_default_schema_with_custom_title_url_and_description(self): def test_coreapi_renders_default_schema_with_custom_title_url_and_description(self):

View File

@ -1,12 +1,16 @@
import uuid
import warnings
import pytest import pytest
from django.conf.urls import url from django.conf.urls import url
from django.test import RequestFactory, TestCase, override_settings from django.test import RequestFactory, TestCase, override_settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework import filters, generics, pagination, routers, serializers from rest_framework import filters, generics, pagination, routers, serializers
from rest_framework.authtoken.views import obtain_auth_token
from rest_framework.compat import uritemplate from rest_framework.compat import uritemplate
from rest_framework.parsers import JSONParser, MultiPartParser from rest_framework.parsers import JSONParser, MultiPartParser
from rest_framework.renderers import JSONRenderer from rest_framework.renderers import JSONRenderer, OpenAPIRenderer
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.schemas.openapi import AutoSchema, SchemaGenerator from rest_framework.schemas.openapi import AutoSchema, SchemaGenerator
@ -44,6 +48,8 @@ class TestBasics(TestCase):
class TestFieldMapping(TestCase): class TestFieldMapping(TestCase):
def test_list_field_mapping(self): def test_list_field_mapping(self):
uuid1 = uuid.uuid4()
uuid2 = uuid.uuid4()
inspector = AutoSchema() inspector = AutoSchema()
cases = [ cases = [
(serializers.ListField(), {'items': {}, 'type': 'array'}), (serializers.ListField(), {'items': {}, 'type': 'array'}),
@ -53,23 +59,54 @@ class TestFieldMapping(TestCase):
(serializers.ListField(child=serializers.IntegerField(max_value=4294967295)), (serializers.ListField(child=serializers.IntegerField(max_value=4294967295)),
{'items': {'type': 'integer', 'maximum': 4294967295, 'format': 'int64'}, 'type': 'array'}), {'items': {'type': 'integer', 'maximum': 4294967295, 'format': 'int64'}, 'type': 'array'}),
(serializers.ListField(child=serializers.ChoiceField(choices=[('a', 'Choice A'), ('b', 'Choice B')])), (serializers.ListField(child=serializers.ChoiceField(choices=[('a', 'Choice A'), ('b', 'Choice B')])),
{'items': {'enum': ['a', 'b']}, 'type': 'array'}), {'items': {'enum': ['a', 'b'], 'type': 'string'}, 'type': 'array'}),
(serializers.ListField(child=serializers.ChoiceField(choices=[(1, 'One'), (2, 'Two')])),
{'items': {'enum': [1, 2], 'type': 'integer'}, 'type': 'array'}),
(serializers.ListField(child=serializers.ChoiceField(choices=[(1.1, 'First'), (2.2, 'Second')])),
{'items': {'enum': [1.1, 2.2], 'type': 'number'}, 'type': 'array'}),
(serializers.ListField(child=serializers.ChoiceField(choices=[(True, 'true'), (False, 'false')])),
{'items': {'enum': [True, False], 'type': 'boolean'}, 'type': 'array'}),
(serializers.ListField(child=serializers.ChoiceField(choices=[(uuid1, 'uuid1'), (uuid2, 'uuid2')])),
{'items': {'enum': [uuid1, uuid2]}, 'type': 'array'}),
(serializers.ListField(child=serializers.ChoiceField(choices=[(1, 'One'), ('a', 'Choice A')])),
{'items': {'enum': [1, 'a']}, 'type': 'array'}),
(serializers.ListField(child=serializers.ChoiceField(choices=[
(1, 'One'), ('a', 'Choice A'), (1.1, 'First'), (1.1, 'First'), (1, 'One'), ('a', 'Choice A'), (1, 'One')
])),
{'items': {'enum': [1, 'a', 1.1]}, 'type': 'array'}),
(serializers.ListField(child=serializers.ChoiceField(choices=[
(1, 'One'), (2, 'Two'), (3, 'Three'), (2, 'Two'), (3, 'Three'), (1, 'One'),
])),
{'items': {'enum': [1, 2, 3], 'type': 'integer'}, 'type': 'array'}),
(serializers.IntegerField(min_value=2147483648), (serializers.IntegerField(min_value=2147483648),
{'type': 'integer', 'minimum': 2147483648, 'format': 'int64'}), {'type': 'integer', 'minimum': 2147483648, 'format': 'int64'}),
] ]
for field, mapping in cases: for field, mapping in cases:
with self.subTest(field=field): with self.subTest(field=field):
assert inspector._map_field(field) == mapping assert inspector.map_field(field) == mapping
def test_lazy_string_field(self): def test_lazy_string_field(self):
class Serializer(serializers.Serializer): class ItemSerializer(serializers.Serializer):
text = serializers.CharField(help_text=_('lazy string')) text = serializers.CharField(help_text=_('lazy string'))
inspector = AutoSchema() inspector = AutoSchema()
data = inspector._map_serializer(Serializer()) data = inspector.map_serializer(ItemSerializer())
assert isinstance(data['properties']['text']['description'], str), "description must be str" assert isinstance(data['properties']['text']['description'], str), "description must be str"
def test_boolean_default_field(self):
class Serializer(serializers.Serializer):
default_true = serializers.BooleanField(default=True)
default_false = serializers.BooleanField(default=False)
without_default = serializers.BooleanField()
inspector = AutoSchema()
data = inspector.map_serializer(Serializer())
assert data['properties']['default_true']['default'] is True, "default must be true"
assert data['properties']['default_false']['default'] is False, "default must be false"
assert 'default' not in data['properties']['without_default'], "default must not be defined"
@pytest.mark.skipif(uritemplate is None, reason='uritemplate not installed.') @pytest.mark.skipif(uritemplate is None, reason='uritemplate not installed.')
class TestOperationIntrospection(TestCase): class TestOperationIntrospection(TestCase):
@ -91,6 +128,7 @@ class TestOperationIntrospection(TestCase):
'operationId': 'listDocStringExamples', 'operationId': 'listDocStringExamples',
'description': 'A description of my GET operation.', 'description': 'A description of my GET operation.',
'parameters': [], 'parameters': [],
'tags': ['example'],
'responses': { 'responses': {
'200': { '200': {
'description': '', 'description': '',
@ -120,7 +158,7 @@ class TestOperationIntrospection(TestCase):
operation = inspector.get_operation(path, method) operation = inspector.get_operation(path, method)
assert operation == { assert operation == {
'operationId': 'RetrieveDocStringExampleDetail', 'operationId': 'retrieveDocStringExampleDetail',
'description': 'A description of my GET operation.', 'description': 'A description of my GET operation.',
'parameters': [{ 'parameters': [{
'description': '', 'description': '',
@ -131,6 +169,7 @@ class TestOperationIntrospection(TestCase):
'type': 'string', 'type': 'string',
}, },
}], }],
'tags': ['example'],
'responses': { 'responses': {
'200': { '200': {
'description': '', 'description': '',
@ -148,6 +187,33 @@ class TestOperationIntrospection(TestCase):
path = '/' path = '/'
method = 'POST' method = 'POST'
class ItemSerializer(serializers.Serializer):
text = serializers.CharField()
read_only = serializers.CharField(read_only=True)
class View(generics.GenericAPIView):
serializer_class = ItemSerializer
view = create_view(
View,
method,
create_request(path)
)
inspector = AutoSchema()
inspector.view = view
request_body = inspector.get_request_body(path, method)
print(request_body)
assert request_body['content']['application/json']['schema']['$ref'] == '#/components/schemas/Item'
components = inspector.get_components(path, method)
assert components['Item']['required'] == ['text']
assert sorted(list(components['Item']['properties'].keys())) == ['read_only', 'text']
def test_invalid_serializer_class_name(self):
path = '/'
method = 'POST'
class Serializer(serializers.Serializer): class Serializer(serializers.Serializer):
text = serializers.CharField() text = serializers.CharField()
read_only = serializers.CharField(read_only=True) read_only = serializers.CharField(read_only=True)
@ -163,20 +229,22 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema() inspector = AutoSchema()
inspector.view = view inspector.view = view
request_body = inspector._get_request_body(path, method) serializer = inspector.get_serializer(path, method)
assert request_body['content']['application/json']['schema']['required'] == ['text']
assert list(request_body['content']['application/json']['schema']['properties'].keys()) == ['text'] with pytest.raises(Exception) as exc:
inspector.get_component_name(serializer)
assert "is an invalid class name for schema generation" in str(exc.value)
def test_empty_required(self): def test_empty_required(self):
path = '/' path = '/'
method = 'POST' method = 'POST'
class Serializer(serializers.Serializer): class ItemSerializer(serializers.Serializer):
read_only = serializers.CharField(read_only=True) read_only = serializers.CharField(read_only=True)
write_only = serializers.CharField(write_only=True, required=False) write_only = serializers.CharField(write_only=True, required=False)
class View(generics.GenericAPIView): class View(generics.GenericAPIView):
serializer_class = Serializer serializer_class = ItemSerializer
view = create_view( view = create_view(
View, View,
@ -186,23 +254,24 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema() inspector = AutoSchema()
inspector.view = view inspector.view = view
request_body = inspector._get_request_body(path, method) components = inspector.get_components(path, method)
component = components['Item']
# there should be no empty 'required' property, see #6834 # there should be no empty 'required' property, see #6834
assert 'required' not in request_body['content']['application/json']['schema'] assert 'required' not in component
for response in inspector._get_responses(path, method).values(): for response in inspector.get_responses(path, method).values():
assert 'required' not in response['content']['application/json']['schema'] assert 'required' not in component
def test_empty_required_with_patch_method(self): def test_empty_required_with_patch_method(self):
path = '/' path = '/'
method = 'PATCH' method = 'PATCH'
class Serializer(serializers.Serializer): class ItemSerializer(serializers.Serializer):
read_only = serializers.CharField(read_only=True) read_only = serializers.CharField(read_only=True)
write_only = serializers.CharField(write_only=True, required=False) write_only = serializers.CharField(write_only=True, required=False)
class View(generics.GenericAPIView): class View(generics.GenericAPIView):
serializer_class = Serializer serializer_class = ItemSerializer
view = create_view( view = create_view(
View, View,
@ -212,22 +281,23 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema() inspector = AutoSchema()
inspector.view = view inspector.view = view
request_body = inspector._get_request_body(path, method) components = inspector.get_components(path, method)
component = components['Item']
# there should be no empty 'required' property, see #6834 # there should be no empty 'required' property, see #6834
assert 'required' not in request_body['content']['application/json']['schema'] assert 'required' not in component
for response in inspector._get_responses(path, method).values(): for response in inspector.get_responses(path, method).values():
assert 'required' not in response['content']['application/json']['schema'] assert 'required' not in component
def test_response_body_generation(self): def test_response_body_generation(self):
path = '/' path = '/'
method = 'POST' method = 'POST'
class Serializer(serializers.Serializer): class ItemSerializer(serializers.Serializer):
text = serializers.CharField() text = serializers.CharField()
write_only = serializers.CharField(write_only=True) write_only = serializers.CharField(write_only=True)
class View(generics.GenericAPIView): class View(generics.GenericAPIView):
serializer_class = Serializer serializer_class = ItemSerializer
view = create_view( view = create_view(
View, View,
@ -237,10 +307,13 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema() inspector = AutoSchema()
inspector.view = view inspector.view = view
responses = inspector._get_responses(path, method) responses = inspector.get_responses(path, method)
assert responses['200']['content']['application/json']['schema']['required'] == ['text'] assert responses['201']['content']['application/json']['schema']['$ref'] == '#/components/schemas/Item'
assert list(responses['200']['content']['application/json']['schema']['properties'].keys()) == ['text']
assert 'description' in responses['200'] components = inspector.get_components(path, method)
assert sorted(components['Item']['required']) == ['text', 'write_only']
assert sorted(list(components['Item']['properties'].keys())) == ['text', 'write_only']
assert 'description' in responses['201']
def test_response_body_nested_serializer(self): def test_response_body_nested_serializer(self):
path = '/' path = '/'
@ -249,12 +322,12 @@ class TestOperationIntrospection(TestCase):
class NestedSerializer(serializers.Serializer): class NestedSerializer(serializers.Serializer):
number = serializers.IntegerField() number = serializers.IntegerField()
class Serializer(serializers.Serializer): class ItemSerializer(serializers.Serializer):
text = serializers.CharField() text = serializers.CharField()
nested = NestedSerializer() nested = NestedSerializer()
class View(generics.GenericAPIView): class View(generics.GenericAPIView):
serializer_class = Serializer serializer_class = ItemSerializer
view = create_view( view = create_view(
View, View,
@ -264,8 +337,12 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema() inspector = AutoSchema()
inspector.view = view inspector.view = view
responses = inspector._get_responses(path, method) responses = inspector.get_responses(path, method)
schema = responses['200']['content']['application/json']['schema'] assert responses['201']['content']['application/json']['schema']['$ref'] == '#/components/schemas/Item'
components = inspector.get_components(path, method)
assert components['Item']
schema = components['Item']
assert sorted(schema['required']) == ['nested', 'text'] assert sorted(schema['required']) == ['nested', 'text']
assert sorted(list(schema['properties'].keys())) == ['nested', 'text'] assert sorted(list(schema['properties'].keys())) == ['nested', 'text']
assert schema['properties']['nested']['type'] == 'object' assert schema['properties']['nested']['type'] == 'object'
@ -291,7 +368,7 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema() inspector = AutoSchema()
inspector.view = view inspector.view = view
responses = inspector._get_responses(path, method) responses = inspector.get_responses(path, method)
assert responses == { assert responses == {
'200': { '200': {
'description': '', 'description': '',
@ -300,18 +377,25 @@ class TestOperationIntrospection(TestCase):
'schema': { 'schema': {
'type': 'array', 'type': 'array',
'items': { 'items': {
'properties': { '$ref': '#/components/schemas/Item'
'text': {
'type': 'string',
},
},
'required': ['text'],
}, },
}, },
}, },
}, },
}, },
} }
components = inspector.get_components(path, method)
assert components == {
'Item': {
'type': 'object',
'properties': {
'text': {
'type': 'string',
},
},
'required': ['text'],
}
}
def test_paginated_list_response_body_generation(self): def test_paginated_list_response_body_generation(self):
"""Test that pagination properties are added for a paginated list view.""" """Test that pagination properties are added for a paginated list view."""
@ -340,7 +424,7 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema() inspector = AutoSchema()
inspector.view = view inspector.view = view
responses = inspector._get_responses(path, method) responses = inspector.get_responses(path, method)
assert responses == { assert responses == {
'200': { '200': {
'description': '', 'description': '',
@ -351,12 +435,7 @@ class TestOperationIntrospection(TestCase):
'item': { 'item': {
'type': 'array', 'type': 'array',
'items': { 'items': {
'properties': { '$ref': '#/components/schemas/Item'
'text': {
'type': 'string',
},
},
'required': ['text'],
}, },
}, },
}, },
@ -364,6 +443,18 @@ class TestOperationIntrospection(TestCase):
}, },
}, },
} }
components = inspector.get_components(path, method)
assert components == {
'Item': {
'type': 'object',
'properties': {
'text': {
'type': 'string',
},
},
'required': ['text'],
}
}
def test_delete_response_body_generation(self): def test_delete_response_body_generation(self):
"""Test that a view's delete method generates a proper response body schema.""" """Test that a view's delete method generates a proper response body schema."""
@ -381,7 +472,7 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema() inspector = AutoSchema()
inspector.view = view inspector.view = view
responses = inspector._get_responses(path, method) responses = inspector.get_responses(path, method)
assert responses == { assert responses == {
'204': { '204': {
'description': '', 'description': '',
@ -405,7 +496,7 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema() inspector = AutoSchema()
inspector.view = view inspector.view = view
request_body = inspector._get_request_body(path, method) request_body = inspector.get_request_body(path, method)
assert len(request_body['content'].keys()) == 2 assert len(request_body['content'].keys()) == 2
assert 'multipart/form-data' in request_body['content'] assert 'multipart/form-data' in request_body['content']
@ -428,7 +519,7 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema() inspector = AutoSchema()
inspector.view = view inspector.view = view
responses = inspector._get_responses(path, method) responses = inspector.get_responses(path, method)
# TODO this should be changed once the multiple response # TODO this should be changed once the multiple response
# schema support is there # schema support is there
success_response = responses['200'] success_response = responses['200']
@ -436,6 +527,19 @@ class TestOperationIntrospection(TestCase):
assert len(success_response['content'].keys()) == 1 assert len(success_response['content'].keys()) == 1
assert 'application/json' in success_response['content'] assert 'application/json' in success_response['content']
def test_openapi_yaml_rendering_without_aliases(self):
renderer = OpenAPIRenderer()
reused_object = {'test': 'test'}
data = {
'o1': reused_object,
'o2': reused_object,
}
assert (
renderer.render(data) == b'o1:\n test: test\no2:\n test: test\n' or
renderer.render(data) == b'o2:\n test: test\no1:\n test: test\n' # py <= 3.5
)
def test_serializer_filefield(self): def test_serializer_filefield(self):
path = '/{id}/' path = '/{id}/'
method = 'POST' method = 'POST'
@ -454,10 +558,10 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema() inspector = AutoSchema()
inspector.view = view inspector.view = view
request_body = inspector._get_request_body(path, method) components = inspector.get_components(path, method)
mp_media = request_body['content']['multipart/form-data'] component = components['Item']
attachment = mp_media['schema']['properties']['attachment'] properties = component['properties']
assert attachment['format'] == 'binary' assert properties['attachment']['format'] == 'binary'
def test_retrieve_response_body_generation(self): def test_retrieve_response_body_generation(self):
""" """
@ -490,25 +594,33 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema() inspector = AutoSchema()
inspector.view = view inspector.view = view
responses = inspector._get_responses(path, method) responses = inspector.get_responses(path, method)
assert responses == { assert responses == {
'200': { '200': {
'description': '', 'description': '',
'content': { 'content': {
'application/json': { 'application/json': {
'schema': { 'schema': {
'properties': { '$ref': '#/components/schemas/Item'
'text': {
'type': 'string',
},
},
'required': ['text'],
}, },
}, },
}, },
}, },
} }
components = inspector.get_components(path, method)
assert components == {
'Item': {
'type': 'object',
'properties': {
'text': {
'type': 'string',
},
},
'required': ['text'],
}
}
def test_operation_id_generation(self): def test_operation_id_generation(self):
path = '/' path = '/'
method = 'GET' method = 'GET'
@ -521,9 +633,75 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema() inspector = AutoSchema()
inspector.view = view inspector.view = view
operationId = inspector._get_operation_id(path, method) operationId = inspector.get_operation_id(path, method)
assert operationId == 'listExamples' assert operationId == 'listExamples'
def test_operation_id_custom_operation_id_base(self):
path = '/'
method = 'GET'
view = create_view(
views.ExampleGenericAPIView,
method,
create_request(path),
)
inspector = AutoSchema(operation_id_base="Ulysse")
inspector.view = view
operationId = inspector.get_operation_id(path, method)
assert operationId == 'listUlysses'
def test_operation_id_custom_name(self):
path = '/'
method = 'GET'
view = create_view(
views.ExampleGenericAPIView,
method,
create_request(path),
)
inspector = AutoSchema(operation_id_base='Ulysse')
inspector.view = view
operationId = inspector.get_operation_id(path, method)
assert operationId == 'listUlysses'
def test_operation_id_override_get(self):
class CustomSchema(AutoSchema):
def get_operation_id(self, path, method):
return 'myCustomOperationId'
path = '/'
method = 'GET'
view = create_view(
views.ExampleGenericAPIView,
method,
create_request(path),
)
inspector = CustomSchema()
inspector.view = view
operationId = inspector.get_operation_id(path, method)
assert operationId == 'myCustomOperationId'
def test_operation_id_override_base(self):
class CustomSchema(AutoSchema):
def get_operation_id_base(self, path, method, action):
return 'Item'
path = '/'
method = 'GET'
view = create_view(
views.ExampleGenericAPIView,
method,
create_request(path),
)
inspector = CustomSchema()
inspector.view = view
operationId = inspector.get_operation_id(path, method)
assert operationId == 'listItem'
def test_repeat_operation_ids(self): def test_repeat_operation_ids(self):
router = routers.SimpleRouter() router = routers.SimpleRouter()
router.register('account', views.ExampleGenericViewSet, basename="account") router.register('account', views.ExampleGenericViewSet, basename="account")
@ -539,6 +717,41 @@ class TestOperationIntrospection(TestCase):
assert schema_str.count("newExample") == 1 assert schema_str.count("newExample") == 1
assert schema_str.count("oldExample") == 1 assert schema_str.count("oldExample") == 1
def test_duplicate_operation_id(self):
patterns = [
url(r'^duplicate1/?$', views.ExampleOperationIdDuplicate1.as_view()),
url(r'^duplicate2/?$', views.ExampleOperationIdDuplicate2.as_view()),
]
generator = SchemaGenerator(patterns=patterns)
request = create_request('/')
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter('always')
generator.get_schema(request=request)
assert len(w) == 1
assert issubclass(w[-1].category, UserWarning)
print(str(w[-1].message))
assert 'You have a duplicated operationId' in str(w[-1].message)
def test_operation_id_viewset(self):
router = routers.SimpleRouter()
router.register('account', views.ExampleViewSet, basename="account")
urlpatterns = router.urls
generator = SchemaGenerator(patterns=urlpatterns)
request = create_request('/')
schema = generator.get_schema(request=request)
print(schema)
assert schema['paths']['/account/']['get']['operationId'] == 'listExampleViewSets'
assert schema['paths']['/account/']['post']['operationId'] == 'createExampleViewSet'
assert schema['paths']['/account/{id}/']['get']['operationId'] == 'retrieveExampleViewSet'
assert schema['paths']['/account/{id}/']['put']['operationId'] == 'updateExampleViewSet'
assert schema['paths']['/account/{id}/']['patch']['operationId'] == 'partialUpdateExampleViewSet'
assert schema['paths']['/account/{id}/']['delete']['operationId'] == 'destroyExampleViewSet'
def test_serializer_datefield(self): def test_serializer_datefield(self):
path = '/' path = '/'
method = 'GET' method = 'GET'
@ -550,9 +763,9 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema() inspector = AutoSchema()
inspector.view = view inspector.view = view
responses = inspector._get_responses(path, method) components = inspector.get_components(path, method)
response_schema = responses['200']['content']['application/json']['schema'] component = components['Example']
properties = response_schema['items']['properties'] properties = component['properties']
assert properties['date']['type'] == properties['datetime']['type'] == 'string' assert properties['date']['type'] == properties['datetime']['type'] == 'string'
assert properties['date']['format'] == 'date' assert properties['date']['format'] == 'date'
assert properties['datetime']['format'] == 'date-time' assert properties['datetime']['format'] == 'date-time'
@ -568,9 +781,9 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema() inspector = AutoSchema()
inspector.view = view inspector.view = view
responses = inspector._get_responses(path, method) components = inspector.get_components(path, method)
response_schema = responses['200']['content']['application/json']['schema'] component = components['Example']
properties = response_schema['items']['properties'] properties = component['properties']
assert properties['hstore']['type'] == 'object' assert properties['hstore']['type'] == 'object'
def test_serializer_callable_default(self): def test_serializer_callable_default(self):
@ -584,9 +797,9 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema() inspector = AutoSchema()
inspector.view = view inspector.view = view
responses = inspector._get_responses(path, method) components = inspector.get_components(path, method)
response_schema = responses['200']['content']['application/json']['schema'] component = components['Example']
properties = response_schema['items']['properties'] properties = component['properties']
assert 'default' not in properties['uuid_field'] assert 'default' not in properties['uuid_field']
def test_serializer_validators(self): def test_serializer_validators(self):
@ -600,9 +813,9 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema() inspector = AutoSchema()
inspector.view = view inspector.view = view
responses = inspector._get_responses(path, method) components = inspector.get_components(path, method)
response_schema = responses['200']['content']['application/json']['schema'] component = components['ExampleValidated']
properties = response_schema['items']['properties'] properties = component['properties']
assert properties['integer']['type'] == 'integer' assert properties['integer']['type'] == 'integer'
assert properties['integer']['maximum'] == 99 assert properties['integer']['maximum'] == 99
@ -625,6 +838,16 @@ class TestOperationIntrospection(TestCase):
assert properties['decimal2']['type'] == 'number' assert properties['decimal2']['type'] == 'number'
assert properties['decimal2']['multipleOf'] == .0001 assert properties['decimal2']['multipleOf'] == .0001
assert properties['decimal3'] == {
'type': 'string', 'format': 'decimal', 'maximum': 1000000, 'minimum': -1000000, 'multipleOf': 0.01
}
assert properties['decimal4'] == {
'type': 'string', 'format': 'decimal', 'maximum': 1000000, 'minimum': -1000000, 'multipleOf': 0.01
}
assert properties['decimal5'] == {
'type': 'string', 'format': 'decimal', 'maximum': 10000, 'minimum': -10000, 'multipleOf': 0.01
}
assert properties['email']['type'] == 'string' assert properties['email']['type'] == 'string'
assert properties['email']['format'] == 'email' assert properties['email']['format'] == 'email'
assert properties['email']['default'] == 'foo@bar.com' assert properties['email']['default'] == 'foo@bar.com'
@ -645,6 +868,56 @@ class TestOperationIntrospection(TestCase):
assert properties['ip']['type'] == 'string' assert properties['ip']['type'] == 'string'
assert 'format' not in properties['ip'] assert 'format' not in properties['ip']
def test_overridden_tags(self):
class ExampleStringTagsViewSet(views.ExampleGenericAPIView):
schema = AutoSchema(tags=['example1', 'example2'])
url_patterns = [
url(r'^test/?$', ExampleStringTagsViewSet.as_view()),
]
generator = SchemaGenerator(patterns=url_patterns)
schema = generator.get_schema(request=create_request('/'))
assert schema['paths']['/test/']['get']['tags'] == ['example1', 'example2']
def test_overridden_get_tags_method(self):
class MySchema(AutoSchema):
def get_tags(self, path, method):
if path.endswith('/new/'):
tags = ['tag1', 'tag2']
elif path.endswith('/old/'):
tags = ['tag2', 'tag3']
else:
tags = ['tag4', 'tag5']
return tags
class ExampleStringTagsViewSet(views.ExampleGenericViewSet):
schema = MySchema()
router = routers.SimpleRouter()
router.register('example', ExampleStringTagsViewSet, basename="example")
generator = SchemaGenerator(patterns=router.urls)
schema = generator.get_schema(request=create_request('/'))
assert schema['paths']['/example/new/']['get']['tags'] == ['tag1', 'tag2']
assert schema['paths']['/example/old/']['get']['tags'] == ['tag2', 'tag3']
def test_auto_generated_apiview_tags(self):
class RestaurantAPIView(views.ExampleGenericAPIView):
schema = AutoSchema(operation_id_base="restaurant")
pass
class BranchAPIView(views.ExampleGenericAPIView):
pass
url_patterns = [
url(r'^any-dash_underscore/?$', RestaurantAPIView.as_view()),
url(r'^restaurants/branches/?$', BranchAPIView.as_view())
]
generator = SchemaGenerator(patterns=url_patterns)
schema = generator.get_schema(request=create_request('/'))
assert schema['paths']['/any-dash_underscore/']['get']['tags'] == ['any-dash-underscore']
assert schema['paths']['/restaurants/branches/']['get']['tags'] == ['restaurants']
@pytest.mark.skipif(uritemplate is None, reason='uritemplate not installed.') @pytest.mark.skipif(uritemplate is None, reason='uritemplate not installed.')
@override_settings(REST_FRAMEWORK={'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.openapi.AutoSchema'}) @override_settings(REST_FRAMEWORK={'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.openapi.AutoSchema'})
@ -744,3 +1017,95 @@ class TestGenerator(TestCase):
assert schema['info']['title'] == '' assert schema['info']['title'] == ''
assert schema['info']['version'] == '' assert schema['info']['version'] == ''
def test_serializer_model(self):
"""Construction of the top level dictionary."""
patterns = [
url(r'^example/?$', views.ExampleGenericAPIViewModel.as_view()),
]
generator = SchemaGenerator(patterns=patterns)
request = create_request('/')
schema = generator.get_schema(request=request)
print(schema)
assert 'components' in schema
assert 'schemas' in schema['components']
assert 'ExampleModel' in schema['components']['schemas']
def test_authtoken_serializer(self):
patterns = [
url(r'^api-token-auth/', obtain_auth_token)
]
generator = SchemaGenerator(patterns=patterns)
request = create_request('/')
schema = generator.get_schema(request=request)
print(schema)
route = schema['paths']['/api-token-auth/']['post']
body_schema = route['requestBody']['content']['application/json']['schema']
assert body_schema == {
'$ref': '#/components/schemas/AuthToken'
}
assert schema['components']['schemas']['AuthToken'] == {
'type': 'object',
'properties': {
'username': {'type': 'string', 'writeOnly': True},
'password': {'type': 'string', 'writeOnly': True},
'token': {'type': 'string', 'readOnly': True},
},
'required': ['username', 'password']
}
def test_component_name(self):
patterns = [
url(r'^example/?$', views.ExampleAutoSchemaComponentName.as_view()),
]
generator = SchemaGenerator(patterns=patterns)
request = create_request('/')
schema = generator.get_schema(request=request)
print(schema)
assert 'components' in schema
assert 'schemas' in schema['components']
assert 'Ulysses' in schema['components']['schemas']
def test_duplicate_component_name(self):
patterns = [
url(r'^duplicate1/?$', views.ExampleAutoSchemaDuplicate1.as_view()),
url(r'^duplicate2/?$', views.ExampleAutoSchemaDuplicate2.as_view()),
]
generator = SchemaGenerator(patterns=patterns)
request = create_request('/')
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter('always')
schema = generator.get_schema(request=request)
assert len(w) == 1
assert issubclass(w[-1].category, UserWarning)
assert 'has been overriden with a different value.' in str(w[-1].message)
assert 'components' in schema
assert 'schemas' in schema['components']
assert 'Duplicate' in schema['components']['schemas']
def test_component_should_not_be_generated_for_delete_method(self):
class ExampleView(generics.DestroyAPIView):
schema = AutoSchema(operation_id_base='example')
url_patterns = [
url(r'^example/?$', ExampleView.as_view()),
]
generator = SchemaGenerator(patterns=url_patterns)
schema = generator.get_schema(request=create_request('/'))
assert 'components' not in schema
assert 'content' not in schema['paths']['/example/']['delete']['responses']['204']

View File

@ -4,12 +4,14 @@ from django.core.validators import (
DecimalValidator, MaxLengthValidator, MaxValueValidator, DecimalValidator, MaxLengthValidator, MaxValueValidator,
MinLengthValidator, MinValueValidator, RegexValidator MinLengthValidator, MinValueValidator, RegexValidator
) )
from django.db import models
from rest_framework import generics, permissions, serializers from rest_framework import generics, permissions, serializers
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.schemas.openapi import AutoSchema
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.viewsets import GenericViewSet from rest_framework.viewsets import GenericViewSet, ViewSet
class ExampleListView(APIView): class ExampleListView(APIView):
@ -117,9 +119,13 @@ class ExampleValidatedSerializer(serializers.Serializer):
MinLengthValidator(limit_value=2), MinLengthValidator(limit_value=2),
) )
) )
decimal1 = serializers.DecimalField(max_digits=6, decimal_places=2) decimal1 = serializers.DecimalField(max_digits=6, decimal_places=2, coerce_to_string=False)
decimal2 = serializers.DecimalField(max_digits=5, decimal_places=0, decimal2 = serializers.DecimalField(max_digits=5, decimal_places=0, coerce_to_string=False,
validators=(DecimalValidator(max_digits=17, decimal_places=4),)) validators=(DecimalValidator(max_digits=17, decimal_places=4),))
decimal3 = serializers.DecimalField(max_digits=8, decimal_places=2, coerce_to_string=True)
decimal4 = serializers.DecimalField(max_digits=8, decimal_places=2, coerce_to_string=True,
validators=(DecimalValidator(max_digits=17, decimal_places=4),))
decimal5 = serializers.DecimalField(max_digits=6, decimal_places=2)
email = serializers.EmailField(default='foo@bar.com') email = serializers.EmailField(default='foo@bar.com')
url = serializers.URLField(default='http://www.example.com', allow_null=True) url = serializers.URLField(default='http://www.example.com', allow_null=True)
uuid = serializers.UUIDField() uuid = serializers.UUIDField()
@ -137,3 +143,101 @@ class ExampleValidatedAPIView(generics.GenericAPIView):
url='http://localhost', uuid=uuid.uuid4(), ip4='127.0.0.1', ip6='::1', url='http://localhost', uuid=uuid.uuid4(), ip4='127.0.0.1', ip6='::1',
ip='192.168.1.1') ip='192.168.1.1')
return Response(serializer.data) return Response(serializer.data)
# Serializer with model.
class OpenAPIExample(models.Model):
first_name = models.CharField(max_length=30)
class ExampleSerializerModel(serializers.Serializer):
date = serializers.DateField()
datetime = serializers.DateTimeField()
hstore = serializers.HStoreField()
uuid_field = serializers.UUIDField(default=uuid.uuid4)
class Meta:
model = OpenAPIExample
class ExampleOperationIdDuplicate1(generics.GenericAPIView):
serializer_class = ExampleSerializerModel
def get(self, *args, **kwargs):
pass
class ExampleOperationIdDuplicate2(generics.GenericAPIView):
serializer_class = ExampleSerializerModel
def get(self, *args, **kwargs):
pass
class ExampleGenericAPIViewModel(generics.GenericAPIView):
serializer_class = ExampleSerializerModel
def get(self, *args, **kwargs):
from datetime import datetime
now = datetime.now()
serializer = self.get_serializer(data=now.date(), datetime=now)
return Response(serializer.data)
class ExampleAutoSchemaComponentName(generics.GenericAPIView):
serializer_class = ExampleSerializerModel
schema = AutoSchema(component_name="Ulysses")
def get(self, *args, **kwargs):
from datetime import datetime
now = datetime.now()
serializer = self.get_serializer(data=now.date(), datetime=now)
return Response(serializer.data)
class ExampleAutoSchemaDuplicate1(generics.GenericAPIView):
serializer_class = ExampleValidatedSerializer
schema = AutoSchema(component_name="Duplicate")
def get(self, *args, **kwargs):
from datetime import datetime
now = datetime.now()
serializer = self.get_serializer(data=now.date(), datetime=now)
return Response(serializer.data)
class ExampleAutoSchemaDuplicate2(generics.GenericAPIView):
serializer_class = ExampleSerializerModel
schema = AutoSchema(component_name="Duplicate")
def get(self, *args, **kwargs):
from datetime import datetime
now = datetime.now()
serializer = self.get_serializer(data=now.date(), datetime=now)
return Response(serializer.data)
class ExampleViewSet(ViewSet):
serializer_class = ExampleSerializerModel
def list(self, request):
pass
def create(self, request):
pass
def retrieve(self, request, pk=None):
pass
def update(self, request, pk=None):
pass
def partial_update(self, request, pk=None):
pass
def destroy(self, request, pk=None):
pass

View File

@ -22,10 +22,6 @@ class DecoratorTestCase(TestCase):
def setUp(self): def setUp(self):
self.factory = APIRequestFactory() self.factory = APIRequestFactory()
def _finalize_response(self, request, response, *args, **kwargs):
response.request = request
return APIView.finalize_response(self, request, response, *args, **kwargs)
def test_api_view_incorrect(self): def test_api_view_incorrect(self):
""" """
If @api_view is not applied correct, we should raise an assertion. If @api_view is not applied correct, we should raise an assertion.

View File

@ -1,9 +1,11 @@
import datetime import datetime
from importlib import reload as reload_module from importlib import reload as reload_module
import django
import pytest import pytest
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.db import models from django.db import models
from django.db.models import CharField, Transform
from django.db.models.functions import Concat, Upper from django.db.models.functions import Concat, Upper
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
@ -189,6 +191,42 @@ class SearchFilterTests(TestCase):
assert terms == ['asdf'] assert terms == ['asdf']
@pytest.mark.skipif(django.VERSION[:2] < (2, 2), reason="requires django 2.2 or higher")
def test_search_field_with_additional_transforms(self):
from django.test.utils import register_lookup
class SearchListView(generics.ListAPIView):
queryset = SearchFilterModel.objects.all()
serializer_class = SearchFilterSerializer
filter_backends = (filters.SearchFilter,)
search_fields = ('text__trim', )
view = SearchListView.as_view()
# an example custom transform, that trims `a` from the string.
class TrimA(Transform):
function = 'TRIM'
lookup_name = 'trim'
def as_sql(self, compiler, connection):
sql, params = compiler.compile(self.lhs)
return "trim(%s, 'a')" % sql, params
with register_lookup(CharField, TrimA):
# Search including `a`
request = factory.get('/', {'search': 'abc'})
response = view(request)
assert response.data == []
# Search excluding `a`
request = factory.get('/', {'search': 'bc'})
response = view(request)
assert response.data == [
{'id': 1, 'title': 'z', 'text': 'abc'},
{'id': 2, 'title': 'zz', 'text': 'bcd'},
]
class AttributeModel(models.Model): class AttributeModel(models.Model):
label = models.CharField(max_length=32) label = models.CharField(max_length=32)
@ -368,6 +406,21 @@ class SearchFilterAnnotatedFieldTests(TestCase):
assert len(response.data) == 1 assert len(response.data) == 1
assert response.data[0]['title_text'] == 'ABCDEF' assert response.data[0]['title_text'] == 'ABCDEF'
def test_must_call_distinct_subsequent_m2m_fields(self):
f = filters.SearchFilter()
queryset = SearchFilterModelM2M.objects.annotate(
title_text=Upper(
Concat(models.F('title'), models.F('text'))
)
).all()
# Sanity check that m2m must call distinct
assert f.must_call_distinct(queryset, ['attributes'])
# Annotated field should not prevent m2m must call distinct
assert f.must_call_distinct(queryset, ['title_text', 'attributes'])
class OrderingFilterModel(models.Model): class OrderingFilterModel(models.Model):
title = models.CharField(max_length=20, verbose_name='verbose title') title = models.CharField(max_length=20, verbose_name='verbose title')

View File

@ -662,3 +662,33 @@ class GetObjectOr404Tests(TestCase):
def test_get_object_or_404_with_invalid_string_for_uuid(self): def test_get_object_or_404_with_invalid_string_for_uuid(self):
with pytest.raises(Http404): with pytest.raises(Http404):
generics.get_object_or_404(UUIDForeignKeyTarget, pk='not-a-uuid') generics.get_object_or_404(UUIDForeignKeyTarget, pk='not-a-uuid')
class TestSerializer(TestCase):
def test_serializer_class_not_provided(self):
class NoSerializerClass(generics.GenericAPIView):
pass
with pytest.raises(AssertionError) as excinfo:
NoSerializerClass().get_serializer_class()
assert str(excinfo.value) == (
"'NoSerializerClass' should either include a `serializer_class` "
"attribute, or override the `get_serializer_class()` method.")
def test_given_context_not_overridden(self):
context = object()
class View(generics.ListAPIView):
serializer_class = serializers.Serializer
def list(self, request):
response = Response()
response.serializer = self.get_serializer(context=context)
return response
response = View.as_view()(factory.get('/'))
serializer = response.serializer
assert serializer.context is context

View File

@ -8,6 +8,7 @@ an appropriate set of serializer fields for each case.
import datetime import datetime
import decimal import decimal
import sys import sys
import tempfile
from collections import OrderedDict from collections import OrderedDict
import django import django
@ -71,7 +72,7 @@ class RegularFieldsModel(models.Model):
time_field = models.TimeField() time_field = models.TimeField()
url_field = models.URLField(max_length=100) url_field = models.URLField(max_length=100)
custom_field = CustomField() custom_field = CustomField()
file_path_field = models.FilePathField(path='/tmp/') file_path_field = models.FilePathField(path=tempfile.gettempdir())
def method(self): def method(self):
return 'method' return 'method'
@ -191,8 +192,8 @@ class TestRegularFieldMappings(TestCase):
time_field = TimeField() time_field = TimeField()
url_field = URLField(max_length=100) url_field = URLField(max_length=100)
custom_field = ModelField(model_field=<tests.test_model_serializer.CustomField: custom_field>) custom_field = ModelField(model_field=<tests.test_model_serializer.CustomField: custom_field>)
file_path_field = FilePathField(path='/tmp/') file_path_field = FilePathField(path=%r)
""") """ % tempfile.gettempdir())
self.assertEqual(repr(TestSerializer()), expected) self.assertEqual(repr(TestSerializer()), expected)

View File

@ -281,10 +281,14 @@ class TestPageNumberPagination:
'next': { 'next': {
'type': 'string', 'type': 'string',
'nullable': True, 'nullable': True,
'format': 'uri',
'example': 'http://api.example.org/accounts/?page=4',
}, },
'previous': { 'previous': {
'type': 'string', 'type': 'string',
'nullable': True, 'nullable': True,
'format': 'uri',
'example': 'http://api.example.org/accounts/?page=2',
}, },
'results': unpaginated_schema, 'results': unpaginated_schema,
}, },
@ -588,10 +592,14 @@ class TestLimitOffset:
'next': { 'next': {
'type': 'string', 'type': 'string',
'nullable': True, 'nullable': True,
'format': 'uri',
'example': 'http://api.example.org/accounts/?offset=400&limit=100',
}, },
'previous': { 'previous': {
'type': 'string', 'type': 'string',
'nullable': True, 'nullable': True,
'format': 'uri',
'example': 'http://api.example.org/accounts/?offset=200&limit=100',
}, },
'results': unpaginated_schema, 'results': unpaginated_schema,
}, },

View File

@ -438,6 +438,7 @@ class BasicPerm(permissions.BasePermission):
class BasicPermWithDetail(permissions.BasePermission): class BasicPermWithDetail(permissions.BasePermission):
message = 'Custom: You cannot access this resource' message = 'Custom: You cannot access this resource'
code = 'permission_denied_custom'
def has_permission(self, request, view): def has_permission(self, request, view):
return False return False
@ -450,6 +451,7 @@ class BasicObjectPerm(permissions.BasePermission):
class BasicObjectPermWithDetail(permissions.BasePermission): class BasicObjectPermWithDetail(permissions.BasePermission):
message = 'Custom: You cannot access this resource' message = 'Custom: You cannot access this resource'
code = 'permission_denied_custom'
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
return False return False
@ -492,30 +494,35 @@ class CustomPermissionsTests(TestCase):
credentials = basic_auth_header('username', 'password') credentials = basic_auth_header('username', 'password')
self.request = factory.get('/1', format='json', HTTP_AUTHORIZATION=credentials) self.request = factory.get('/1', format='json', HTTP_AUTHORIZATION=credentials)
self.custom_message = 'Custom: You cannot access this resource' self.custom_message = 'Custom: You cannot access this resource'
self.custom_code = 'permission_denied_custom'
def test_permission_denied(self): def test_permission_denied(self):
response = denied_view(self.request, pk=1) response = denied_view(self.request, pk=1)
detail = response.data.get('detail') detail = response.data.get('detail')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertNotEqual(detail, self.custom_message) self.assertNotEqual(detail, self.custom_message)
self.assertNotEqual(detail.code, self.custom_code)
def test_permission_denied_with_custom_detail(self): def test_permission_denied_with_custom_detail(self):
response = denied_view_with_detail(self.request, pk=1) response = denied_view_with_detail(self.request, pk=1)
detail = response.data.get('detail') detail = response.data.get('detail')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(detail, self.custom_message) self.assertEqual(detail, self.custom_message)
self.assertEqual(detail.code, self.custom_code)
def test_permission_denied_for_object(self): def test_permission_denied_for_object(self):
response = denied_object_view(self.request, pk=1) response = denied_object_view(self.request, pk=1)
detail = response.data.get('detail') detail = response.data.get('detail')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertNotEqual(detail, self.custom_message) self.assertNotEqual(detail, self.custom_message)
self.assertNotEqual(detail.code, self.custom_code)
def test_permission_denied_for_object_with_custom_detail(self): def test_permission_denied_for_object_with_custom_detail(self):
response = denied_object_view_with_detail(self.request, pk=1) response = denied_object_view_with_detail(self.request, pk=1)
detail = response.data.get('detail') detail = response.data.get('detail')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(detail, self.custom_message) self.assertEqual(detail, self.custom_message)
self.assertEqual(detail.code, self.custom_code)
class PermissionsCompositionTests(TestCase): class PermissionsCompositionTests(TestCase):

View File

@ -741,6 +741,11 @@ class AdminRendererTests(TestCase):
class DummyGenericViewsetLike(APIView): class DummyGenericViewsetLike(APIView):
lookup_field = 'test' lookup_field = 'test'
def get(self, request):
response = Response()
response.view = self
return response
def reverse_action(view, *args, **kwargs): def reverse_action(view, *args, **kwargs):
self.assertEqual(kwargs['kwargs']['test'], 1) self.assertEqual(kwargs['kwargs']['test'], 1)
return '/example/' return '/example/'
@ -749,7 +754,7 @@ class AdminRendererTests(TestCase):
view = DummyGenericViewsetLike.as_view() view = DummyGenericViewsetLike.as_view()
request = factory.get('/') request = factory.get('/')
response = view(request) response = view(request)
view = response.renderer_context['view'] view = response.view
self.assertEqual(self.renderer.get_result_url({'test': 1}, view), '/example/') self.assertEqual(self.renderer.get_result_url({'test': 1}, view), '/example/')
self.assertIsNone(self.renderer.get_result_url({}, view)) self.assertIsNone(self.renderer.get_result_url({}, view))
@ -760,11 +765,16 @@ class AdminRendererTests(TestCase):
class DummyView(APIView): class DummyView(APIView):
lookup_field = 'test' lookup_field = 'test'
def get(self, request):
response = Response()
response.view = self
return response
# get the view instance instead of the view function # get the view instance instead of the view function
view = DummyView.as_view() view = DummyView.as_view()
request = factory.get('/') request = factory.get('/')
response = view(request) response = view(request)
view = response.renderer_context['view'] view = response.view
self.assertIsNone(self.renderer.get_result_url({'test': 1}, view)) self.assertIsNone(self.renderer.get_result_url({'test': 1}, view))
self.assertIsNone(self.renderer.get_result_url({}, view)) self.assertIsNone(self.renderer.get_result_url({}, view))

View File

@ -137,7 +137,9 @@ class MockView(APIView):
class EchoView(APIView): class EchoView(APIView):
def post(self, request): def post(self, request):
return Response(status=status.HTTP_200_OK, data=request.data) response = Response(status=status.HTTP_200_OK, data=request.data)
response._request = request # test client sets `request` input
return response
class FileUploadView(APIView): class FileUploadView(APIView):
@ -272,6 +274,12 @@ class TestSecure(TestCase):
class TestHttpRequest(TestCase): class TestHttpRequest(TestCase):
def test_repr(self):
http_request = factory.get('/path')
request = Request(http_request)
assert repr(request) == "<rest_framework.request.Request: GET '/path'>"
def test_attribute_access_proxy(self): def test_attribute_access_proxy(self):
http_request = factory.get('/') http_request = factory.get('/')
request = Request(http_request) request = Request(http_request)
@ -300,7 +308,7 @@ class TestHttpRequest(TestCase):
`RawPostDataException` being raised. `RawPostDataException` being raised.
""" """
response = APIClient().post('/echo/', data={'a': 'b'}, format='json') response = APIClient().post('/echo/', data={'a': 'b'}, format='json')
request = response.renderer_context['request'] request = response._request
# ensure that request stream was consumed by json parser # ensure that request stream was consumed by json parser
assert request.content_type.startswith('application/json') assert request.content_type.startswith('application/json')
@ -319,7 +327,7 @@ class TestHttpRequest(TestCase):
the duplicate stream parse exception. the duplicate stream parse exception.
""" """
response = APIClient().post('/echo/', data={'a': 'b'}) response = APIClient().post('/echo/', data={'a': 'b'})
request = response.renderer_context['request'] request = response._request
# ensure that request stream was consumed by form parser # ensure that request stream was consumed by form parser
assert request.content_type.startswith('multipart/form-data') assert request.content_type.startswith('multipart/form-data')
@ -327,7 +335,7 @@ class TestHttpRequest(TestCase):
# pass same HttpRequest to view, form data set on underlying request # pass same HttpRequest to view, form data set on underlying request
response = EchoView.as_view()(request._request) response = EchoView.as_view()(request._request)
request = response.renderer_context['request'] request = response._request
# ensure that request stream was consumed by form parser # ensure that request stream was consumed by form parser
assert request.content_type.startswith('multipart/form-data') assert request.content_type.startswith('multipart/form-data')

View File

@ -344,6 +344,49 @@ class TestUniquenessTogetherValidation(TestCase):
] ]
} }
def test_default_validator_with_fields_with_source(self):
class TestSerializer(serializers.ModelSerializer):
name = serializers.CharField(source='race_name')
class Meta:
model = UniquenessTogetherModel
fields = ['name', 'position']
serializer = TestSerializer()
expected = dedent("""
TestSerializer():
name = CharField(source='race_name')
position = IntegerField()
class Meta:
validators = [<UniqueTogetherValidator(queryset=UniquenessTogetherModel.objects.all(), fields=('name', 'position'))>]
""")
assert repr(serializer) == expected
def test_default_validator_with_multiple_fields_with_same_source(self):
class TestSerializer(serializers.ModelSerializer):
name = serializers.CharField(source='race_name')
other_name = serializers.CharField(source='race_name')
class Meta:
model = UniquenessTogetherModel
fields = ['name', 'other_name', 'position']
serializer = TestSerializer(data={
'name': 'foo',
'other_name': 'foo',
'position': 1,
})
with pytest.raises(AssertionError) as excinfo:
serializer.is_valid()
expected = (
"Unable to create `UniqueTogetherValidator` for "
"`UniquenessTogetherModel.race_name` as `TestSerializer` has "
"multiple fields (name, other_name) that map to this model field. "
"Either remove the extra fields, or override `Meta.validators` "
"with a `UniqueTogetherValidator` using the desired field names.")
assert str(excinfo.value) == expected
def test_allow_explict_override(self): def test_allow_explict_override(self):
""" """
Ensure validators can be explicitly removed.. Ensure validators can be explicitly removed..

View File

@ -37,14 +37,20 @@ class ActionViewSet(GenericViewSet):
queryset = Action.objects.all() queryset = Action.objects.all()
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
return Response() response = Response()
response.view = self
return response
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
return Response() response = Response()
response.view = self
return response
@action(detail=False) @action(detail=False)
def list_action(self, request, *args, **kwargs): def list_action(self, request, *args, **kwargs):
raise NotImplementedError response = Response()
response.view = self
return response
@action(detail=False, url_name='list-custom') @action(detail=False, url_name='list-custom')
def custom_list_action(self, request, *args, **kwargs): def custom_list_action(self, request, *args, **kwargs):
@ -66,7 +72,9 @@ class ActionViewSet(GenericViewSet):
class ActionNamesViewSet(GenericViewSet): class ActionNamesViewSet(GenericViewSet):
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
return Response() response = Response()
response.view = self
return response
@action(detail=True) @action(detail=True)
def unnamed_action(self, request, *args, **kwargs): def unnamed_action(self, request, *args, **kwargs):
@ -81,10 +89,20 @@ class ActionNamesViewSet(GenericViewSet):
raise NotImplementedError raise NotImplementedError
class ThingWithMapping:
def __init__(self):
self.mapping = {}
class ActionViewSetWithMapping(ActionViewSet):
mapper = ThingWithMapping()
router = SimpleRouter() router = SimpleRouter()
router.register(r'actions', ActionViewSet) router.register(r'actions', ActionViewSet)
router.register(r'actions-alt', ActionViewSet, basename='actions-alt') router.register(r'actions-alt', ActionViewSet, basename='actions-alt')
router.register(r'names', ActionNamesViewSet, basename='names') router.register(r'names', ActionNamesViewSet, basename='names')
router.register(r'mapping', ActionViewSetWithMapping, basename='mapping')
urlpatterns = [ urlpatterns = [
@ -103,7 +121,7 @@ class InitializeViewSetsTestCase(TestCase):
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
assert response.data == {'ACTION': 'LIST'} assert response.data == {'ACTION': 'LIST'}
def testhead_request_against_viewset(self): def test_head_request_against_viewset(self):
request = factory.head('/', '', content_type='application/json') request = factory.head('/', '', content_type='application/json')
my_view = BasicViewSet.as_view(actions={ my_view = BasicViewSet.as_view(actions={
'get': 'list', 'get': 'list',
@ -145,6 +163,22 @@ class InitializeViewSetsTestCase(TestCase):
self.assertNotIn(attribute, dir(bare_view)) self.assertNotIn(attribute, dir(bare_view))
self.assertIn(attribute, dir(view)) self.assertIn(attribute, dir(view))
def test_viewset_action_attr(self):
view = ActionViewSet.as_view(actions={'get': 'list'})
get = view(factory.get('/'))
head = view(factory.head('/'))
assert get.view.action == 'list'
assert head.view.action == 'list'
def test_viewset_action_attr_for_extra_action(self):
view = ActionViewSet.as_view(actions=dict(ActionViewSet.list_action.mapping))
get = view(factory.get('/'))
head = view(factory.head('/'))
assert get.view.action == 'list_action'
assert head.view.action == 'list_action'
class GetExtraActionsTests(TestCase): class GetExtraActionsTests(TestCase):
@ -161,13 +195,25 @@ class GetExtraActionsTests(TestCase):
self.assertEqual(actual, expected) self.assertEqual(actual, expected)
def test_should_only_return_decorated_methods(self):
view = ActionViewSetWithMapping()
actual = [action.__name__ for action in view.get_extra_actions()]
expected = [
'custom_detail_action',
'custom_list_action',
'detail_action',
'list_action',
'unresolvable_detail_action',
]
self.assertEqual(actual, expected)
@override_settings(ROOT_URLCONF='tests.test_viewsets') @override_settings(ROOT_URLCONF='tests.test_viewsets')
class GetExtraActionUrlMapTests(TestCase): class GetExtraActionUrlMapTests(TestCase):
def test_list_view(self): def test_list_view(self):
response = self.client.get('/api/actions/') response = self.client.get('/api/actions/')
view = response.renderer_context['view'] view = response.view
expected = OrderedDict([ expected = OrderedDict([
('Custom list action', 'http://testserver/api/actions/custom_list_action/'), ('Custom list action', 'http://testserver/api/actions/custom_list_action/'),
@ -178,7 +224,7 @@ class GetExtraActionUrlMapTests(TestCase):
def test_detail_view(self): def test_detail_view(self):
response = self.client.get('/api/actions/1/') response = self.client.get('/api/actions/1/')
view = response.renderer_context['view'] view = response.view
expected = OrderedDict([ expected = OrderedDict([
('Custom detail action', 'http://testserver/api/actions/1/custom_detail_action/'), ('Custom detail action', 'http://testserver/api/actions/1/custom_detail_action/'),
@ -194,7 +240,7 @@ class GetExtraActionUrlMapTests(TestCase):
def test_action_names(self): def test_action_names(self):
# Action 'name' and 'suffix' kwargs should be respected # Action 'name' and 'suffix' kwargs should be respected
response = self.client.get('/api/names/1/') response = self.client.get('/api/names/1/')
view = response.renderer_context['view'] view = response.view
expected = OrderedDict([ expected = OrderedDict([
('Custom Name', 'http://testserver/api/names/1/named_action/'), ('Custom Name', 'http://testserver/api/names/1/named_action/'),

11
tox.ini
View File

@ -1,18 +1,12 @@
[tox] [tox]
envlist = envlist =
{py35,py36}-django111, {py35,py36,py37}-django22,
{py35,py36,py37}-django20,
{py35,py36,py37}-django21
{py35,py36,py37}-django22
{py36,py37,py38}-django30, {py36,py37,py38}-django30,
{py36,py37,py38}-djangomaster, {py36,py37,py38}-djangomaster,
base,dist,lint,docs, base,dist,lint,docs,
[travis:env] [travis:env]
DJANGO = DJANGO =
1.11: django111
2.0: django20
2.1: django21
2.2: django22 2.2: django22
3.0: django30 3.0: django30
master: djangomaster master: djangomaster
@ -24,9 +18,6 @@ setenv =
PYTHONDONTWRITEBYTECODE=1 PYTHONDONTWRITEBYTECODE=1
PYTHONWARNINGS=once PYTHONWARNINGS=once
deps = deps =
django111: Django>=1.11,<2.0
django20: Django>=2.0,<2.1
django21: Django>=2.1,<2.2
django22: Django>=2.2,<3.0 django22: Django>=2.2,<3.0
django30: Django>=3.0,<3.1 django30: Django>=3.0,<3.1
djangomaster: https://github.com/django/django/archive/master.tar.gz djangomaster: https://github.com/django/django/archive/master.tar.gz