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
cache: pip
dist: xenial
dist: bionic
matrix:
fast_finish: true
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.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=3.0 }
- { 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=3.0 }
- { 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
* 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
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:
```python
from django.conf.urls import url, include
from django.urls import path, include
from django.contrib.auth.models import User
from rest_framework import serializers, viewsets, routers
@ -114,8 +114,8 @@ router.register(r'users', UserViewSet)
# Wire up our API using automatic URL routing.
# Additionally, we include login URLs for the browsable API.
urlpatterns = [
url(r'^', include(router.urls)),
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework'))
path('', include(router.urls)),
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.:
* [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
@ -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.
## 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
@ -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
[djoser]: https://github.com/sunscrapers/djoser
[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-knox]: https://github.com/James1345/django-rest-knox
[drfpasswordless]: https://github.com/aaronn/django-rest-framework-passwordless

View File

@ -206,6 +206,10 @@ You can also perform a related lookup on a ForeignKey or ManyToManyField with th
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.
The search behavior may be restricted by prepending various characters to the `search_fields`.
@ -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-url-filter]: https://github.com/miki725/django-url-filter
[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.
@action(methods=['GET'], detail=False)
def schema(self, request):
def api_schema(self, request):
meta = self.metadata_class()
data = meta.determine_metadata(request, self)
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

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.
from django.utils.encoding import smart_unicode
from django.utils.encoding import smart_text
from rest_framework import renderers
@ -282,7 +282,7 @@ The following is an example plaintext renderer that will return a response with
format = 'txt'
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

View File

@ -18,19 +18,19 @@ Django REST Framework provides support for automatic generation of
## Generating an OpenAPI Schema
### Install `pyyaml`
### Install dependencies
You'll need to install `pyyaml`, so that you can render your generated schema
into the commonly used YAML-based OpenAPI format.
pip install pyyaml uritemplate
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
If your schema is static, you can use the `generateschema` management command:
```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
@ -122,7 +122,7 @@ on a per-view basis.
### 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
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
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-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-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:
# Each room only has one event per day.
validators = UniqueTogetherValidator(
validators = [
UniqueTogetherValidator(
queryset=Event.objects.all(),
fields=['room_number', 'date']
)
]
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):
profile_data = validated_data.pop('profile')
# 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.
profile = instance.profile
@ -382,8 +384,8 @@ This manager class now more nicely encapsulates that user instances and profile
def create(self, validated_data):
return User.objects.create(
username=validated_data['username'],
email=validated_data['email']
is_premium_member=validated_data['profile']['is_premium_member']
email=validated_data['email'],
is_premium_member=validated_data['profile']['is_premium_member'],
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
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
@ -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
[configuration]: #configuration
[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.
[cite]: https://guides.rubyonrails.org/routing.html
[cite]: https://guides.rubyonrails.org/action_controller_overview.html
[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.3
**Date**: 4th September 2019
* Include API version in OpenAPI schema generation, defaulting to empty string.
* Add pagination properties 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.
* Fix `minLength`/`maxLength`/`minItems`/`maxItems` properties in OpenAPI schemas.
* Only call `FileField.url` once in serialization, for improved performance.
* Fix an edge case where throttling calcualtions could error after a configuration change.
* TODO
* Fix an edge case where throttling calculations could error after a configuration change.
### 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]
* Test using Django 2.1 final release. [#6109][gh6109]
* 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]
* Ensure that html forms (multipart form data) respect optional fields [#5927][gh5927]
* 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 -->
[gh6680]: https://github.com/encode/django-rest-framework/issues/6680
[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
* [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
@ -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.
* [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.
* [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
[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
[drf-access-policy]: https://github.com/rsinger86/drf-access-policy
[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
[djangorestframework-dataclasses]: https://github.com/oxan/djangorestframework-dataclasses
[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
[drf-viewset-profiler]: https://github.com/fvlima/drf-viewset-profiler
[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">
<img src="../../img/books/tsd-cover.png"/>
</a>
<a class="book-cover" href="https://wsvincent.com/books/">
<img src="../../img/books/rad-cover.png"/>
<a class="book-cover" href="https://djangoforapis.com">
<img src="../../img/books/dfa-cover.jpg"/>
</a>
<a class="book-cover" href="https://books.agiliq.com/projects/django-api-polls-tutorial/en/latest/">
<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:
* 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
each Python and Django series.
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.
* [Pygments][pygments] (2.4.0+) - Add syntax highlighting to Markdown processing.
* [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.
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 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.
# Additionally, we include login URLs for the browsable API.
urlpatterns = [
url(r'^', include(router.urls)),
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework'))
path('', include(router.urls)),
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.
@ -237,7 +237,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
[redhat]: https://www.redhat.com/
[heroku]: https://www.heroku.com/
[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/
[pygments]: https://pypi.org/project/Pygments/
[django-filter]: https://pypi.org/project/django-filter/

View File

@ -224,5 +224,5 @@ If you want to get a more in depth understanding of how REST framework fits toge
[image]: ../img/quickstart.png
[tutorial]: 1-serialization.md
[guide]: ../#api-guide
[guide]: ../api-guide/requests.md
[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>
<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 %}

View File

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

View File

@ -5,22 +5,18 @@
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta charset="utf-8">
<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 }}" />
<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="author" content="Tom Christie">
<!-- Le styles -->
<link href="{{ base_url }}/css/prettify.css" rel="stylesheet">
<link href="{{ base_url }}/css/bootstrap.css" rel="stylesheet">
<link href="{{ base_url }}/css/bootstrap-responsive.css" rel="stylesheet">
<link href="{{ base_url }}/css/default.css" rel="stylesheet">
<link href="{{ 'css/prettify.css'|url }}" rel="stylesheet">
<link href="{{ 'css/bootstrap.css'|url }}" rel="stylesheet">
<link href="{{ 'css/bootstrap-responsive.css'|url }}" 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">
var _gaq = _gaq || [];
@ -102,7 +98,7 @@
{% endfor %}
<div class="promo">
<hr/>
{% if page.toc %}<hr/>{% endif %}
<div id="sidebarInclude">
</div>
</ul>
@ -139,10 +135,10 @@
================================================== -->
<!-- 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 src="{{ base_url }}/js/jquery-1.8.1-min.js"></script>
<script src="{{ base_url }}/js/prettify-1.0.js"></script>
<script src="{{ base_url }}/js/bootstrap-2.1.1-min.js"></script>
<script src="{{ base_url }}/js/theme.js"></script>
<script src="{{ 'js/jquery-1.8.1-min.js'|url }}"></script>
<script src="{{ 'js/prettify-1.0.js'|url }}"></script>
<script src="{{ 'js/bootstrap-2.1.1-min.js'|url }}"></script>
<script src="{{ 'js/theme.js'|url }}"></script>
<script>var base_url = '{{ base_url }}';</script>
{% for path in config.extra_javascript %}

View File

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

View File

@ -1,2 +1,2 @@
# 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.
psycopg2-binary>=2.8.2, <2.9
psycopg2-binary>=2.8.5, <2.9
markdown==3.1.1
pygments==2.4.2
django-guardian==2.1.0
django-guardian==2.2.0
django-filter>=2.2.0, <2.3
coreapi==2.3.1
coreschema==0.0.4

View File

@ -1,8 +1,8 @@
# Wheel for PyPI installs.
wheel==0.30.0
wheel==0.34.2
# Twine for secured PyPI uploads.
twine==1.11.0
twine==3.1.1
# 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>=5.0,<5.1
pytest-django>=3.5.1,<3.6
pytest>=5.4.1,<5.5
pytest-django>=3.9.0,<3.10
pytest-cov>=2.7.1

View File

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

View File

@ -74,7 +74,11 @@ class BasicAuthentication(BaseAuthentication):
raise exceptions.AuthenticationFailed(msg)
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):
msg = _('Invalid basic header. Credentials not correctly base64 encoded.')
raise exceptions.AuthenticationFailed(msg)

View File

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

View File

@ -5,11 +5,19 @@ from rest_framework import serializers
class AuthTokenSerializer(serializers.Serializer):
username = serializers.CharField(label=_("Username"))
username = serializers.CharField(
label=_("Username"),
write_only=True
)
password = serializers.CharField(
label=_("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):

View File

@ -4,6 +4,7 @@ from rest_framework.authtoken.serializers import AuthTokenSerializer
from rest_framework.compat import coreapi, coreschema
from rest_framework.response import Response
from rest_framework.schemas import ManualSchema
from rest_framework.schemas import coreapi as coreapi_schema
from rest_framework.views import APIView
@ -13,7 +14,8 @@ class ObtainAuthToken(APIView):
parser_classes = (parsers.FormParser, parsers.MultiPartParser, parsers.JSONParser,)
renderer_classes = (renderers.JSONRenderer,)
serializer_class = AuthTokenSerializer
if coreapi is not None and coreschema is not None:
if coreapi_schema.is_enabled():
schema = ManualSchema(
fields=[
coreapi.Field(
@ -38,9 +40,19 @@ class ObtainAuthToken(APIView):
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):
serializer = self.serializer_class(data=request.data,
context={'request': request})
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.validated_data['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:
errors.append(
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.",
hint="The default for DEFAULT_PAGINATION_CLASS is None. "
"In previous versions this was PageNumberPagination. "

View File

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

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('--urlconf', dest="urlconf", 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):
if options['generator_class']:
@ -40,6 +41,11 @@ class Command(BaseCommand):
schema = generator.get_schema(request=None, public=True)
renderer = self.get_renderer(options['format'])
output = renderer.render(schema, renderer_context={})
if options['file']:
with open(options['file'], 'wb') as f:
f.write(output)
else:
self.stdout.write(output.decode())
def get_renderer(self, format):

View File

@ -236,10 +236,16 @@ class PageNumberPagination(BasePagination):
'next': {
'type': 'string',
'nullable': True,
'format': 'uri',
'example': 'http://api.example.org/accounts/?{page_query_param}=4'.format(
page_query_param=self.page_query_param)
},
'previous': {
'type': 'string',
'nullable': True,
'format': 'uri',
'example': 'http://api.example.org/accounts/?{page_query_param}=2'.format(
page_query_param=self.page_query_param)
},
'results': schema,
},
@ -403,10 +409,16 @@ class LimitOffsetPagination(BasePagination):
'next': {
'type': 'string',
'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': {
'type': 'string',
'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,
},

View File

@ -1053,7 +1053,11 @@ class OpenAPIRenderer(BaseRenderer):
assert yaml, 'Using OpenAPIRenderer, but `pyyaml` is not installed.'
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):

View File

@ -179,6 +179,13 @@ class Request:
forced_auth = ForcedAuthentication(force_user, force_token)
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):
return api_settings.DEFAULT_CONTENT_NEGOTIATION_CLASS()

View File

@ -1,4 +1,7 @@
import re
import warnings
from collections import OrderedDict
from decimal import Decimal
from operator import attrgetter
from urllib.parse import urljoin
@ -9,9 +12,12 @@ from django.core.validators import (
from django.db import models
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.fields import _UnvalidatedField, empty
from rest_framework.settings import api_settings
from .generators import BaseSchemaGenerator
from .inspectors import ViewInspector
@ -32,14 +38,40 @@ class SchemaGenerator(BaseSchemaGenerator):
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):
"""
Generate a OpenAPI schema.
"""
self._initialise_endpoints()
components_schemas = {}
# Iterate endpoints generating per method path operations.
# TODO: …and reference components.
paths = {}
_, view_endpoints = self._get_paths_and_endpoints(None if public else request)
for path, method, view in view_endpoints:
@ -47,6 +79,16 @@ class SchemaGenerator(BaseSchemaGenerator):
continue
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.
if path.startswith('/'):
path = path[1:]
@ -55,6 +97,8 @@ class SchemaGenerator(BaseSchemaGenerator):
paths.setdefault(path, {})
paths[path][method.lower()] = operation
self.check_duplicate_operation_id(paths)
# Compile final schema.
schema = {
'openapi': '3.0.2',
@ -62,6 +106,11 @@ class SchemaGenerator(BaseSchemaGenerator):
'paths': paths,
}
if len(components_schemas) > 0:
schema['components'] = {
'schemas': components_schemas
}
return schema
# View Inspectors
@ -69,56 +118,112 @@ class SchemaGenerator(BaseSchemaGenerator):
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 = []
response_media_types = []
method_mapping = {
'get': 'Retrieve',
'post': 'Create',
'put': 'Update',
'patch': 'PartialUpdate',
'delete': 'Destroy',
'get': 'retrieve',
'post': 'create',
'put': 'update',
'patch': 'partialUpdate',
'delete': 'destroy',
}
def get_operation(self, path, method):
operation = {}
operation['operationId'] = self._get_operation_id(path, method)
operation['operationId'] = self.get_operation_id(path, method)
operation['description'] = self.get_description(path, method)
parameters = []
parameters += self._get_path_parameters(path, method)
parameters += self._get_pagination_parameters(path, method)
parameters += self._get_filter_parameters(path, method)
parameters += self.get_path_parameters(path, method)
parameters += self.get_pagination_parameters(path, method)
parameters += self.get_filter_parameters(path, method)
operation['parameters'] = parameters
request_body = self._get_request_body(path, method)
request_body = self.get_request_body(path, method)
if 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
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 is_list_view(path, method, self.view):
action = 'list'
elif method_name not in self.method_mapping:
action = method_name
else:
action = self.method_mapping[method.lower()]
if self.component_name is not None:
return self.component_name
# use the serializer's class name as the component name.
component_name = serializer.__class__.__name__
# We remove the "serializer" string from the class name.
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
model = getattr(getattr(self.view, 'queryset', None), 'model', None)
if model is not None:
elif model is not None:
name = model.__name__
# Try with the serializer class name
elif self._get_serializer(path, method) is not None:
name = self._get_serializer(path, method).__class__.__name__
elif self.get_serializer(path, method) is not None:
name = self.get_serializer(path, method).__class__.__name__
if name.endswith('Serializer'):
name = name[:-10]
@ -138,9 +243,25 @@ class AutoSchema(ViewInspector):
if action == 'list' and not name.endswith('s'): # listThings instead of listThing
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
def _get_path_parameters(self, path, method):
def get_path_parameters(self, path, method):
"""
Return a list of parameters from templated path variables.
"""
@ -176,15 +297,15 @@ class AutoSchema(ViewInspector):
return parameters
def _get_filter_parameters(self, path, method):
if not self._allows_filters(path, method):
def get_filter_parameters(self, path, method):
if not self.allows_filters(path, method):
return []
parameters = []
for filter_backend in self.view.filter_backends:
parameters += filter_backend().get_schema_operation_parameters(self.view)
return parameters
def _allows_filters(self, path, method):
def allows_filters(self, path, method):
"""
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 method.lower() in ["get", "put", "patch", "delete"]
def _get_pagination_parameters(self, path, method):
def get_pagination_parameters(self, path, method):
view = self.view
if not is_list_view(path, method, view):
return []
paginator = self._get_paginator()
paginator = self.get_paginator()
if not paginator:
return []
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.
if isinstance(field, serializers.ListSerializer):
return {
'type': 'array',
'items': self._map_serializer(field.child)
'items': self.map_serializer(field.child)
}
if isinstance(field, serializers.Serializer):
data = self._map_serializer(field)
data = self.map_serializer(field)
data['type'] = 'object'
return data
@ -226,7 +375,7 @@ class AutoSchema(ViewInspector):
if isinstance(field, serializers.ManyRelatedField):
return {
'type': 'array',
'items': self._map_field(field.child_relation)
'items': self.map_field(field.child_relation)
}
if isinstance(field, serializers.PrimaryKeyRelatedField):
model = getattr(field.queryset, 'model', None)
@ -242,15 +391,11 @@ class AutoSchema(ViewInspector):
if isinstance(field, serializers.MultipleChoiceField):
return {
'type': 'array',
'items': {
'enum': list(field.choices)
},
'items': self.map_choicefield(field)
}
if isinstance(field, serializers.ChoiceField):
return {
'enum': list(field.choices),
}
return self.map_choicefield(field)
# ListField.
if isinstance(field, serializers.ListField):
@ -259,7 +404,7 @@ class AutoSchema(ViewInspector):
'items': {},
}
if not isinstance(field.child, _UnvalidatedField):
mapping['items'] = self._map_field(field.child)
mapping['items'] = self.map_field(field.child)
return mapping
# DateField and DateTimeField type is string
@ -304,11 +449,17 @@ class AutoSchema(ViewInspector):
content['format'] = field.protocol
return content
# DecimalField has multipleOf based on decimal_places
if isinstance(field, serializers.DecimalField):
if getattr(field, 'coerce_to_string', api_settings.COERCE_DECIMAL_TO_STRING):
content = {
'type': 'string',
'format': 'decimal',
}
else:
content = {
'type': 'number'
}
if field.decimal_places:
content['multipleOf'] = float('.' + (field.decimal_places - 1) * '0' + '1')
if field.max_whole_digits:
@ -319,7 +470,7 @@ class AutoSchema(ViewInspector):
if isinstance(field, serializers.FloatField):
content = {
'type': 'number'
'type': 'number',
}
self._map_min_max(field, content)
return content
@ -355,12 +506,8 @@ class AutoSchema(ViewInspector):
if 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.
# 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 = []
properties = {}
@ -371,22 +518,23 @@ class AutoSchema(ViewInspector):
if field.required:
required.append(field.field_name)
schema = self._map_field(field)
schema = self.map_field(field)
if field.read_only:
schema['readOnly'] = True
if field.write_only:
schema['writeOnly'] = True
if field.allow_null:
schema['nullable'] = True
if field.default and field.default != empty and not callable(field.default):
if field.default is not None and field.default != empty and not callable(field.default):
schema['default'] = field.default
if 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
result = {
'type': 'object',
'properties': properties
}
if required:
@ -394,7 +542,7 @@ class AutoSchema(ViewInspector):
return result
def _map_field_validators(self, field, schema):
def map_field_validators(self, field, schema):
"""
map field validators
"""
@ -421,7 +569,8 @@ class AutoSchema(ViewInspector):
schema['maximum'] = v.limit_value
elif isinstance(v, MinValueValidator):
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:
schema['multipleOf'] = float('.' + (v.decimal_places - 1) * '0' + '1')
if v.max_digits:
@ -431,7 +580,7 @@ class AutoSchema(ViewInspector):
schema['maximum'] = int(digits * '9') + 1
schema['minimum'] = -schema['maximum']
def _get_paginator(self):
def get_paginator(self):
pagination_class = getattr(self.view, 'pagination_class', None)
if pagination_class:
return pagination_class()
@ -449,7 +598,7 @@ class AutoSchema(ViewInspector):
media_types.append(renderer.media_type)
return media_types
def _get_serializer(self, path, method):
def get_serializer(self, path, method):
view = self.view
if not hasattr(view, 'get_serializer'):
@ -464,35 +613,30 @@ class AutoSchema(ViewInspector):
.format(view.__class__.__name__, method, path))
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'):
return {}
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):
return {}
content = self._map_serializer(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]
item_schema = {}
else:
item_schema = self._get_reference(serializer)
return {
'content': {
ct: {'schema': content}
ct: {'schema': item_schema}
for ct in self.request_media_types
}
}
def _get_responses(self, path, method):
# TODO: Handle multiple codes and pagination classes.
def get_responses(self, path, method):
if method == 'DELETE':
return {
'204': {
@ -502,31 +646,26 @@ class AutoSchema(ViewInspector):
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):
item_schema = self._map_serializer(serializer)
# No write_only fields for response.
for name, schema in item_schema['properties'].copy().items():
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 not isinstance(serializer, serializers.Serializer):
item_schema = {}
else:
item_schema = self._get_reference(serializer)
if is_list_view(path, method, self.view):
response_schema = {
'type': 'array',
'items': item_schema,
}
paginator = self._get_paginator()
paginator = self.get_paginator()
if paginator:
response_schema = paginator.get_paginated_response_schema(response_schema)
else:
response_schema = item_schema
status_code = '201' if method == 'POST' else '200'
return {
'200': {
status_code: {
'content': {
ct: {'schema': response_schema}
for ct in self.response_media_types
@ -537,3 +676,112 @@ class AutoSchema(ViewInspector):
'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 inspect
import traceback
from collections import OrderedDict
from collections import OrderedDict, defaultdict
from collections.abc import Mapping
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
@ -1508,26 +1508,53 @@ class ModelSerializer(Serializer):
# 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
# including those.
field_names = {
field.source for field in self._writable_fields
field_sources = OrderedDict(
(field.field_name, field.source) for field in self._writable_fields
if (field.source != '*') and ('.' not in field.source)
}
)
# Special Case: Add read_only fields with defaults.
field_names |= {
field.source for field in self.fields.values()
field_sources.update(OrderedDict(
(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)
}
))
# 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
# base model class, but also on any parent classes.
validators = []
for parent_class in model_class_inheritance_tree:
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
if not set(source_map).issuperset(set(unique_together)):
continue
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]),
)
)
field_names = tuple(source_map[f][0] for f in unique_together)
validator = UniqueTogetherValidator(
queryset=parent_class._default_manager,
fields=unique_together
fields=field_names
)
validators.append(validator)
return validators

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 }}"
};
</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/csrf.js" %}"></script>
<script src="{% static "rest_framework/js/bootstrap.min.js" %}"></script>

View File

@ -293,7 +293,7 @@
csrfToken: "{% if request %}{{ csrf_token }}{% endif %}"
};
</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/csrf.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>
</html>

View File

@ -38,7 +38,7 @@
{% include "rest_framework/docs/auth/basic.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/docs/js/jquery.json-view.min.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)
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.authenticators and not request.successful_authenticator:
raise exceptions.NotAuthenticated()
raise exceptions.PermissionDenied(detail=message)
raise exceptions.PermissionDenied(detail=message, code=code)
def throttled(self, request, wait):
"""
@ -331,7 +331,9 @@ class APIView(View):
for permission in self.get_permissions():
if not permission.has_permission(request, self):
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):
@ -342,7 +344,9 @@ class APIView(View):
for permission in self.get_permissions():
if not permission.has_object_permission(request, self, obj):
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):

View File

@ -25,11 +25,12 @@ from django.utils.decorators import classonlymethod
from django.views.decorators.csrf import csrf_exempt
from rest_framework import generics, mixins, views
from rest_framework.decorators import MethodMapper
from rest_framework.reverse import reverse
def _is_extra_action(attr):
return hasattr(attr, 'mapping')
return hasattr(attr, 'mapping') and isinstance(attr.mapping, MethodMapper)
class ViewSetMixin:
@ -92,6 +93,10 @@ class ViewSetMixin:
def view(request, *args, **kwargs):
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,
# so that we can later set the action attribute.
# eg. `self.action = 'list'` on an incoming GET request.
@ -103,9 +108,6 @@ class ViewSetMixin:
handler = getattr(self, action)
setattr(self, method, handler)
if hasattr(self, 'get') and not hasattr(self, 'head'):
self.head = self.get
self.request = request
self.args = args
self.kwargs = kwargs

View File

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

View File

@ -159,6 +159,25 @@ class BasicAuthTests(TestCase):
)
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__)
class SessionAuthTests(TestCase):

View File

@ -1,4 +1,6 @@
import io
import os
import tempfile
import pytest
from django.conf.urls import url
@ -73,6 +75,21 @@ class GenerateSchemaTests(TestCase):
out_json = yaml.safe_load(self.out.getvalue())
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.')
@override_settings(REST_FRAMEWORK={'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.AutoSchema'})
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
from django.conf.urls import url
from django.test import RequestFactory, TestCase, override_settings
from django.utils.translation import gettext_lazy as _
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.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.schemas.openapi import AutoSchema, SchemaGenerator
@ -44,6 +48,8 @@ class TestBasics(TestCase):
class TestFieldMapping(TestCase):
def test_list_field_mapping(self):
uuid1 = uuid.uuid4()
uuid2 = uuid.uuid4()
inspector = AutoSchema()
cases = [
(serializers.ListField(), {'items': {}, 'type': 'array'}),
@ -53,23 +59,54 @@ class TestFieldMapping(TestCase):
(serializers.ListField(child=serializers.IntegerField(max_value=4294967295)),
{'items': {'type': 'integer', 'maximum': 4294967295, 'format': 'int64'}, 'type': 'array'}),
(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),
{'type': 'integer', 'minimum': 2147483648, 'format': 'int64'}),
]
for field, mapping in cases:
with self.subTest(field=field):
assert inspector._map_field(field) == mapping
assert inspector.map_field(field) == mapping
def test_lazy_string_field(self):
class Serializer(serializers.Serializer):
class ItemSerializer(serializers.Serializer):
text = serializers.CharField(help_text=_('lazy string'))
inspector = AutoSchema()
data = inspector._map_serializer(Serializer())
data = inspector.map_serializer(ItemSerializer())
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.')
class TestOperationIntrospection(TestCase):
@ -91,6 +128,7 @@ class TestOperationIntrospection(TestCase):
'operationId': 'listDocStringExamples',
'description': 'A description of my GET operation.',
'parameters': [],
'tags': ['example'],
'responses': {
'200': {
'description': '',
@ -120,7 +158,7 @@ class TestOperationIntrospection(TestCase):
operation = inspector.get_operation(path, method)
assert operation == {
'operationId': 'RetrieveDocStringExampleDetail',
'operationId': 'retrieveDocStringExampleDetail',
'description': 'A description of my GET operation.',
'parameters': [{
'description': '',
@ -131,6 +169,7 @@ class TestOperationIntrospection(TestCase):
'type': 'string',
},
}],
'tags': ['example'],
'responses': {
'200': {
'description': '',
@ -148,6 +187,33 @@ class TestOperationIntrospection(TestCase):
path = '/'
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):
text = serializers.CharField()
read_only = serializers.CharField(read_only=True)
@ -163,20 +229,22 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema()
inspector.view = view
request_body = inspector._get_request_body(path, method)
assert request_body['content']['application/json']['schema']['required'] == ['text']
assert list(request_body['content']['application/json']['schema']['properties'].keys()) == ['text']
serializer = inspector.get_serializer(path, method)
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):
path = '/'
method = 'POST'
class Serializer(serializers.Serializer):
class ItemSerializer(serializers.Serializer):
read_only = serializers.CharField(read_only=True)
write_only = serializers.CharField(write_only=True, required=False)
class View(generics.GenericAPIView):
serializer_class = Serializer
serializer_class = ItemSerializer
view = create_view(
View,
@ -186,23 +254,24 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema()
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
assert 'required' not in request_body['content']['application/json']['schema']
assert 'required' not in component
for response in inspector._get_responses(path, method).values():
assert 'required' not in response['content']['application/json']['schema']
for response in inspector.get_responses(path, method).values():
assert 'required' not in component
def test_empty_required_with_patch_method(self):
path = '/'
method = 'PATCH'
class Serializer(serializers.Serializer):
class ItemSerializer(serializers.Serializer):
read_only = serializers.CharField(read_only=True)
write_only = serializers.CharField(write_only=True, required=False)
class View(generics.GenericAPIView):
serializer_class = Serializer
serializer_class = ItemSerializer
view = create_view(
View,
@ -212,22 +281,23 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema()
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
assert 'required' not in request_body['content']['application/json']['schema']
for response in inspector._get_responses(path, method).values():
assert 'required' not in response['content']['application/json']['schema']
assert 'required' not in component
for response in inspector.get_responses(path, method).values():
assert 'required' not in component
def test_response_body_generation(self):
path = '/'
method = 'POST'
class Serializer(serializers.Serializer):
class ItemSerializer(serializers.Serializer):
text = serializers.CharField()
write_only = serializers.CharField(write_only=True)
class View(generics.GenericAPIView):
serializer_class = Serializer
serializer_class = ItemSerializer
view = create_view(
View,
@ -237,10 +307,13 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema()
inspector.view = view
responses = inspector._get_responses(path, method)
assert responses['200']['content']['application/json']['schema']['required'] == ['text']
assert list(responses['200']['content']['application/json']['schema']['properties'].keys()) == ['text']
assert 'description' in responses['200']
responses = inspector.get_responses(path, method)
assert responses['201']['content']['application/json']['schema']['$ref'] == '#/components/schemas/Item'
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):
path = '/'
@ -249,12 +322,12 @@ class TestOperationIntrospection(TestCase):
class NestedSerializer(serializers.Serializer):
number = serializers.IntegerField()
class Serializer(serializers.Serializer):
class ItemSerializer(serializers.Serializer):
text = serializers.CharField()
nested = NestedSerializer()
class View(generics.GenericAPIView):
serializer_class = Serializer
serializer_class = ItemSerializer
view = create_view(
View,
@ -264,8 +337,12 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema()
inspector.view = view
responses = inspector._get_responses(path, method)
schema = responses['200']['content']['application/json']['schema']
responses = inspector.get_responses(path, method)
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(list(schema['properties'].keys())) == ['nested', 'text']
assert schema['properties']['nested']['type'] == 'object'
@ -291,7 +368,7 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema()
inspector.view = view
responses = inspector._get_responses(path, method)
responses = inspector.get_responses(path, method)
assert responses == {
'200': {
'description': '',
@ -300,17 +377,24 @@ class TestOperationIntrospection(TestCase):
'schema': {
'type': 'array',
'items': {
'$ref': '#/components/schemas/Item'
},
},
},
},
},
}
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):
@ -340,7 +424,7 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema()
inspector.view = view
responses = inspector._get_responses(path, method)
responses = inspector.get_responses(path, method)
assert responses == {
'200': {
'description': '',
@ -351,18 +435,25 @@ class TestOperationIntrospection(TestCase):
'item': {
'type': 'array',
'items': {
'$ref': '#/components/schemas/Item'
},
},
},
},
},
},
}
components = inspector.get_components(path, method)
assert components == {
'Item': {
'type': 'object',
'properties': {
'text': {
'type': 'string',
},
},
'required': ['text'],
},
},
},
},
},
},
}
}
def test_delete_response_body_generation(self):
@ -381,7 +472,7 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema()
inspector.view = view
responses = inspector._get_responses(path, method)
responses = inspector.get_responses(path, method)
assert responses == {
'204': {
'description': '',
@ -405,7 +496,7 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema()
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 'multipart/form-data' in request_body['content']
@ -428,7 +519,7 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema()
inspector.view = view
responses = inspector._get_responses(path, method)
responses = inspector.get_responses(path, method)
# TODO this should be changed once the multiple response
# schema support is there
success_response = responses['200']
@ -436,6 +527,19 @@ class TestOperationIntrospection(TestCase):
assert len(success_response['content'].keys()) == 1
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):
path = '/{id}/'
method = 'POST'
@ -454,10 +558,10 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema()
inspector.view = view
request_body = inspector._get_request_body(path, method)
mp_media = request_body['content']['multipart/form-data']
attachment = mp_media['schema']['properties']['attachment']
assert attachment['format'] == 'binary'
components = inspector.get_components(path, method)
component = components['Item']
properties = component['properties']
assert properties['attachment']['format'] == 'binary'
def test_retrieve_response_body_generation(self):
"""
@ -490,23 +594,31 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema()
inspector.view = view
responses = inspector._get_responses(path, method)
responses = inspector.get_responses(path, method)
assert responses == {
'200': {
'description': '',
'content': {
'application/json': {
'schema': {
'$ref': '#/components/schemas/Item'
},
},
},
},
}
components = inspector.get_components(path, method)
assert components == {
'Item': {
'type': 'object',
'properties': {
'text': {
'type': 'string',
},
},
'required': ['text'],
},
},
},
},
}
}
def test_operation_id_generation(self):
@ -521,9 +633,75 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema()
inspector.view = view
operationId = inspector._get_operation_id(path, method)
operationId = inspector.get_operation_id(path, method)
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):
router = routers.SimpleRouter()
router.register('account', views.ExampleGenericViewSet, basename="account")
@ -539,6 +717,41 @@ class TestOperationIntrospection(TestCase):
assert schema_str.count("newExample") == 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):
path = '/'
method = 'GET'
@ -550,9 +763,9 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema()
inspector.view = view
responses = inspector._get_responses(path, method)
response_schema = responses['200']['content']['application/json']['schema']
properties = response_schema['items']['properties']
components = inspector.get_components(path, method)
component = components['Example']
properties = component['properties']
assert properties['date']['type'] == properties['datetime']['type'] == 'string'
assert properties['date']['format'] == 'date'
assert properties['datetime']['format'] == 'date-time'
@ -568,9 +781,9 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema()
inspector.view = view
responses = inspector._get_responses(path, method)
response_schema = responses['200']['content']['application/json']['schema']
properties = response_schema['items']['properties']
components = inspector.get_components(path, method)
component = components['Example']
properties = component['properties']
assert properties['hstore']['type'] == 'object'
def test_serializer_callable_default(self):
@ -584,9 +797,9 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema()
inspector.view = view
responses = inspector._get_responses(path, method)
response_schema = responses['200']['content']['application/json']['schema']
properties = response_schema['items']['properties']
components = inspector.get_components(path, method)
component = components['Example']
properties = component['properties']
assert 'default' not in properties['uuid_field']
def test_serializer_validators(self):
@ -600,9 +813,9 @@ class TestOperationIntrospection(TestCase):
inspector = AutoSchema()
inspector.view = view
responses = inspector._get_responses(path, method)
response_schema = responses['200']['content']['application/json']['schema']
properties = response_schema['items']['properties']
components = inspector.get_components(path, method)
component = components['ExampleValidated']
properties = component['properties']
assert properties['integer']['type'] == 'integer'
assert properties['integer']['maximum'] == 99
@ -625,6 +838,16 @@ class TestOperationIntrospection(TestCase):
assert properties['decimal2']['type'] == 'number'
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']['format'] == 'email'
assert properties['email']['default'] == 'foo@bar.com'
@ -645,6 +868,56 @@ class TestOperationIntrospection(TestCase):
assert properties['ip']['type'] == 'string'
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.')
@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']['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,
MinLengthValidator, MinValueValidator, RegexValidator
)
from django.db import models
from rest_framework import generics, permissions, serializers
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.schemas.openapi import AutoSchema
from rest_framework.views import APIView
from rest_framework.viewsets import GenericViewSet
from rest_framework.viewsets import GenericViewSet, ViewSet
class ExampleListView(APIView):
@ -117,9 +119,13 @@ class ExampleValidatedSerializer(serializers.Serializer):
MinLengthValidator(limit_value=2),
)
)
decimal1 = serializers.DecimalField(max_digits=6, decimal_places=2)
decimal2 = serializers.DecimalField(max_digits=5, decimal_places=0,
decimal1 = serializers.DecimalField(max_digits=6, decimal_places=2, coerce_to_string=False)
decimal2 = serializers.DecimalField(max_digits=5, decimal_places=0, coerce_to_string=False,
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')
url = serializers.URLField(default='http://www.example.com', allow_null=True)
uuid = serializers.UUIDField()
@ -137,3 +143,101 @@ class ExampleValidatedAPIView(generics.GenericAPIView):
url='http://localhost', uuid=uuid.uuid4(), ip4='127.0.0.1', ip6='::1',
ip='192.168.1.1')
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):
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):
"""
If @api_view is not applied correct, we should raise an assertion.

View File

@ -1,9 +1,11 @@
import datetime
from importlib import reload as reload_module
import django
import pytest
from django.core.exceptions import ImproperlyConfigured
from django.db import models
from django.db.models import CharField, Transform
from django.db.models.functions import Concat, Upper
from django.test import TestCase
from django.test.utils import override_settings
@ -189,6 +191,42 @@ class SearchFilterTests(TestCase):
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):
label = models.CharField(max_length=32)
@ -368,6 +406,21 @@ class SearchFilterAnnotatedFieldTests(TestCase):
assert len(response.data) == 1
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):
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):
with pytest.raises(Http404):
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 decimal
import sys
import tempfile
from collections import OrderedDict
import django
@ -71,7 +72,7 @@ class RegularFieldsModel(models.Model):
time_field = models.TimeField()
url_field = models.URLField(max_length=100)
custom_field = CustomField()
file_path_field = models.FilePathField(path='/tmp/')
file_path_field = models.FilePathField(path=tempfile.gettempdir())
def method(self):
return 'method'
@ -191,8 +192,8 @@ class TestRegularFieldMappings(TestCase):
time_field = TimeField()
url_field = URLField(max_length=100)
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)

View File

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

View File

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

View File

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

View File

@ -137,7 +137,9 @@ class MockView(APIView):
class EchoView(APIView):
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):
@ -272,6 +274,12 @@ class TestSecure(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):
http_request = factory.get('/')
request = Request(http_request)
@ -300,7 +308,7 @@ class TestHttpRequest(TestCase):
`RawPostDataException` being raised.
"""
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
assert request.content_type.startswith('application/json')
@ -319,7 +327,7 @@ class TestHttpRequest(TestCase):
the duplicate stream parse exception.
"""
response = APIClient().post('/echo/', data={'a': 'b'})
request = response.renderer_context['request']
request = response._request
# ensure that request stream was consumed by form parser
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
response = EchoView.as_view()(request._request)
request = response.renderer_context['request']
request = response._request
# ensure that request stream was consumed by form parser
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):
"""
Ensure validators can be explicitly removed..

View File

@ -37,14 +37,20 @@ class ActionViewSet(GenericViewSet):
queryset = Action.objects.all()
def list(self, request, *args, **kwargs):
return Response()
response = Response()
response.view = self
return response
def retrieve(self, request, *args, **kwargs):
return Response()
response = Response()
response.view = self
return response
@action(detail=False)
def list_action(self, request, *args, **kwargs):
raise NotImplementedError
response = Response()
response.view = self
return response
@action(detail=False, url_name='list-custom')
def custom_list_action(self, request, *args, **kwargs):
@ -66,7 +72,9 @@ class ActionViewSet(GenericViewSet):
class ActionNamesViewSet(GenericViewSet):
def retrieve(self, request, *args, **kwargs):
return Response()
response = Response()
response.view = self
return response
@action(detail=True)
def unnamed_action(self, request, *args, **kwargs):
@ -81,10 +89,20 @@ class ActionNamesViewSet(GenericViewSet):
raise NotImplementedError
class ThingWithMapping:
def __init__(self):
self.mapping = {}
class ActionViewSetWithMapping(ActionViewSet):
mapper = ThingWithMapping()
router = SimpleRouter()
router.register(r'actions', ActionViewSet)
router.register(r'actions-alt', ActionViewSet, basename='actions-alt')
router.register(r'names', ActionNamesViewSet, basename='names')
router.register(r'mapping', ActionViewSetWithMapping, basename='mapping')
urlpatterns = [
@ -103,7 +121,7 @@ class InitializeViewSetsTestCase(TestCase):
assert response.status_code == status.HTTP_200_OK
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')
my_view = BasicViewSet.as_view(actions={
'get': 'list',
@ -145,6 +163,22 @@ class InitializeViewSetsTestCase(TestCase):
self.assertNotIn(attribute, dir(bare_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):
@ -161,13 +195,25 @@ class GetExtraActionsTests(TestCase):
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')
class GetExtraActionUrlMapTests(TestCase):
def test_list_view(self):
response = self.client.get('/api/actions/')
view = response.renderer_context['view']
view = response.view
expected = OrderedDict([
('Custom list action', 'http://testserver/api/actions/custom_list_action/'),
@ -178,7 +224,7 @@ class GetExtraActionUrlMapTests(TestCase):
def test_detail_view(self):
response = self.client.get('/api/actions/1/')
view = response.renderer_context['view']
view = response.view
expected = OrderedDict([
('Custom detail action', 'http://testserver/api/actions/1/custom_detail_action/'),
@ -194,7 +240,7 @@ class GetExtraActionUrlMapTests(TestCase):
def test_action_names(self):
# Action 'name' and 'suffix' kwargs should be respected
response = self.client.get('/api/names/1/')
view = response.renderer_context['view']
view = response.view
expected = OrderedDict([
('Custom Name', 'http://testserver/api/names/1/named_action/'),

11
tox.ini
View File

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