mirror of
https://github.com/encode/django-rest-framework.git
synced 2025-07-27 16:40:03 +03:00
Merge branch 'master' into patch-1
This commit is contained in:
commit
d8cbc54411
10
.travis.yml
10
.travis.yml
|
@ -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 }
|
||||
|
|
|
@ -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'))
|
||||
]
|
||||
```
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -205,6 +205,10 @@ This will allow the client to filter the items in the list by making queries suc
|
|||
You can also perform a related lookup on a ForeignKey or ManyToManyField with the lookup API double-underscore notation:
|
||||
|
||||
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.
|
||||
|
||||
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
queryset=Event.objects.all(),
|
||||
fields=['room_number', 'date']
|
||||
)
|
||||
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']
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"/>
|
||||
|
|
BIN
docs/img/books/dfa-cover.jpg
Normal file
BIN
docs/img/books/dfa-cover.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 49 KiB |
Binary file not shown.
Before Width: | Height: | Size: 14 KiB |
|
@ -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/
|
||||
|
|
|
@ -137,12 +137,12 @@ Finally, we're including default login and logout views for use with the browsab
|
|||
|
||||
## Pagination
|
||||
Pagination allows you to control how many objects per page are returned. To enable it add the following lines to `tutorial/settings.py`
|
||||
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||
'PAGE_SIZE': 10
|
||||
}
|
||||
|
||||
|
||||
## Settings
|
||||
|
||||
Add `'rest_framework'` to `INSTALLED_APPS`. The settings module will be in `tutorial/settings.py`
|
||||
|
@ -224,5 +224,5 @@ If you want to get a more in depth understanding of how REST framework fits toge
|
|||
|
||||
[image]: ../img/quickstart.png
|
||||
[tutorial]: 1-serialization.md
|
||||
[guide]: ../#api-guide
|
||||
[guide]: ../api-guide/requests.md
|
||||
[httpie]: https://github.com/jakubroztocil/httpie#installation
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -6,7 +6,7 @@ pre {
|
|||
|
||||
.dropdown .dropdown-menu {
|
||||
display: none;
|
||||
overflow-y: scroll;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dropdown.open .dropdown-menu {
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
# MkDocs to build our documentation.
|
||||
mkdocs==1.0.4
|
||||
mkdocs==1.1
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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. "
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -108,7 +108,7 @@ class GenericAPIView(views.APIView):
|
|||
serializer_class = self.get_serializer_class()
|
||||
|
||||
kwargs.setdefault('context', self.get_serializer_context())
|
||||
|
||||
|
||||
return serializer_class(*args, **kwargs)
|
||||
|
||||
def get_serializer_class(self):
|
||||
|
|
|
@ -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,7 +41,12 @@ class Command(BaseCommand):
|
|||
schema = generator.get_schema(request=None, public=True)
|
||||
renderer = self.get_renderer(options['format'])
|
||||
output = renderer.render(schema, renderer_context={})
|
||||
self.stdout.write(output.decode())
|
||||
|
||||
if options['file']:
|
||||
with open(options['file'], 'wb') as f:
|
||||
f.write(output)
|
||||
else:
|
||||
self.stdout.write(output.decode())
|
||||
|
||||
def get_renderer(self, format):
|
||||
if self.get_mode() == COREAPI_MODE:
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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):
|
||||
content = {
|
||||
'type': 'number'
|
||||
}
|
||||
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)
|
||||
|
|
|
@ -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,28 +1508,55 @@ 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)):
|
||||
validator = UniqueTogetherValidator(
|
||||
queryset=parent_class._default_manager,
|
||||
fields=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]),
|
||||
)
|
||||
)
|
||||
validators.append(validator)
|
||||
|
||||
field_names = tuple(source_map[f][0] for f in unique_together)
|
||||
validator = UniqueTogetherValidator(
|
||||
queryset=parent_class._default_manager,
|
||||
fields=field_names
|
||||
)
|
||||
validators.append(validator)
|
||||
return validators
|
||||
|
||||
def get_unique_for_date_validators(self):
|
||||
|
|
File diff suppressed because one or more lines are too long
2
rest_framework/static/rest_framework/js/jquery-3.5.1.min.js
vendored
Normal file
2
rest_framework/static/rest_framework/js/jquery-3.5.1.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
6
setup.py
6
setup.py
|
@ -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',
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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,18 +377,25 @@ class TestOperationIntrospection(TestCase):
|
|||
'schema': {
|
||||
'type': 'array',
|
||||
'items': {
|
||||
'properties': {
|
||||
'text': {
|
||||
'type': 'string',
|
||||
},
|
||||
},
|
||||
'required': ['text'],
|
||||
'$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):
|
||||
"""Test that pagination properties are added for a paginated list view."""
|
||||
|
@ -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,12 +435,7 @@ class TestOperationIntrospection(TestCase):
|
|||
'item': {
|
||||
'type': 'array',
|
||||
'items': {
|
||||
'properties': {
|
||||
'text': {
|
||||
'type': 'string',
|
||||
},
|
||||
},
|
||||
'required': ['text'],
|
||||
'$ref': '#/components/schemas/Item'
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -364,6 +443,18 @@ class TestOperationIntrospection(TestCase):
|
|||
},
|
||||
},
|
||||
}
|
||||
components = inspector.get_components(path, method)
|
||||
assert components == {
|
||||
'Item': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'text': {
|
||||
'type': 'string',
|
||||
},
|
||||
},
|
||||
'required': ['text'],
|
||||
}
|
||||
}
|
||||
|
||||
def test_delete_response_body_generation(self):
|
||||
"""Test that a view's delete method generates a proper response body schema."""
|
||||
|
@ -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,25 +594,33 @@ 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': {
|
||||
'properties': {
|
||||
'text': {
|
||||
'type': 'string',
|
||||
},
|
||||
},
|
||||
'required': ['text'],
|
||||
'$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):
|
||||
path = '/'
|
||||
method = 'GET'
|
||||
|
@ -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']
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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..
|
||||
|
|
|
@ -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
11
tox.ini
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue
Block a user