Разработка на основе тестов Django RESTful API
В этой статье рассматривается процесс разработки CRUD RESTful API с использованием Django и Django REST Framework, который используется для быстрого создания API на основе моделей Django.
Это приложение использует:
- Python v3.6.0
- Django v2.1.7
- Django REST Framework v3.9.2
- Postgres v9.6.1
- Psycopg2 v2.7.7
Цели
К концу этой статьи вы сможете…
- Узнаете преимущества использования Django REST Framework для разработки RESTful API
- Научитесь создавать валидарованные запросы к базе с использованием сериализаторов
- Оцените возможность просмотра API в Django REST Framework, чтобы получить более чистую и хорошо документированную версию ваших API.
- Попрактикуетесь в разработке через тестирование
Почему Django REST Framework?
Django REST Framework (REST Framework) предоставляет ряд мощных фич из коробки, которые хорошо сочетаются с идиоматическим Django, включая:
- Browsable API(API с возможностью просмотра): ваше API автоматически документируется с помощью удобного и понятного для пользователя вывода в формате HTML, предоставляя красивый интерфейс в виде формы для отправки данных и извлечения из них стандартными методами HTTP.
- Поддержка аутентификации: REST Framework имеет расширенную поддержку различных протоколов аутентификации, а также политик разрешений и регулирования, которые можно настраивать для каждой вьюхи отдельно.
- Сериализаторы: Сериализаторы — это элегантный способ создания валидированных запросов к моделям и преобразования их в собственные типы данных Python, которые можно легко преобразовать в JSON и XML.
- Throttling: Throttling — это способ определить, является ли запрос авторизованным или нет, и его можно интегрировать с различными разрешениями. Обычно используется для ограничения скорости запросов API от одного пользователя.
Настройка проекта Django
Создайте и активируйте shell pipenv:
$ mkdir django-puppy-store $ cd django-puppy-store $ pipenv --python 3.6 $ pipenv shell
Установите Django и настройте новый проект:
(django-puppy-store) bash-3.2$ pipenv install django==2.1.7 (django-puppy-store) bash-3.2$ django-admin startproject puppy_store
Ваша текущая структура проекта должна выглядеть следующим образом:
└── puppy_store ├── manage.py └── puppy_store ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py
Настройка Django приложения и REST Framework
Начните с создания приложения puppies и установки REST Framework внутри вашего virtualenv:
(django-puppy-store) bash-3.2$ cd puppy_store (django-puppy-store) bash-3.2$ python manage.py startapp puppies (django-puppy-store) bash-3.2$ pipenv install djangorestframework==3.9.2
Теперь нам нужно настроить наш проект Django для использования REST Framework.
Сначала добавьте приложение puppies и rest_framework в раздел INSTALLED_APPS в puppy_store/puppy_store/settings.py:
INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'puppies', 'rest_framework' ]
Затем определите глобальные параметры для REST Framework в файле settings.py:
REST_FRAMEWORK = { # Use Django's standard `django.contrib.auth` permissions, # or allow read-only access for unauthenticated users. 'DEFAULT_PERMISSION_CLASSES': [], 'TEST_REQUEST_DEFAULT_FORMAT': 'json' }
Это обеспечивает неограниченный доступ к API и устанавливает формат теста по умолчанию JSON для всех запросов.
ПРИМЕЧАНИЕ. Неограниченный доступ подходит для локальной разработки, но в производственной среде вам может потребоваться ограничить доступ к определенным url. Обязательно поправте это. Просмотрите документацию для получения дополнительной информации.
Ваша текущая структура проекта теперь должна выглядеть так:
└── puppy_store ├── manage.py ├── puppies │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── tests.py │ └── views.py └── puppy_store ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py
Настройка базы данных и модели
Давайте настроим базу данных Postgres и применим к ней все миграции.
Когда в вашей системе будет работающий сервер Postgres, откройте интерактивную оболочку Postgres и создайте базу данных:
$ psql # CREATE DATABASE puppy_store_drf; CREATE DATABASE # \q
Установите psycopg2, так чтобы мы могли взаимодействовать с сервером Postgres через Python:
(django-puppy-store) bash-3.2$ pipenv install psycopg2==2.7.7
Обновите конфигурацию базы данных в файле settings.py, добавив соответствующее имя пользователя и пароль:
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', 'NAME': 'puppy_store_drf', 'USER': '<your-user>', 'PASSWORD': '<your-password>', 'HOST': '127.0.0.1', 'PORT': '5432' } }
Затем определите модель puppy с некоторыми основными атрибутами в django-puppy-store/puppy_store/puppies/models.py:
from django.db import models class Puppy(models.Model): """ Puppy Model Defines the attributes of a puppy """ name = models.CharField(max_length=255) age = models.IntegerField() breed = models.CharField(max_length=255) color = models.CharField(max_length=255) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def get_breed(self): return self.name + ' belongs to ' + self.breed + ' breed.' def __repr__(self): return self.name + ' is added.'
Теперь примените миграцию:
(django-puppy-store) bash-3.2$ python manage.py makemigrations (django-puppy-store) bash-3.2$ python manage.py migrate
Дополнительная проверка
Перейдите в psql снова и убедитесь, что puppies_puppy был создан:
(django-puppy-store) bash-3.2$ psql # \c puppy_store_drf You are now connected to database "puppy_store_drf". puppy_store_drf=# \dt List of relations Schema | Name | Type | Owner --------+----------------------------+-------+---------------- public | auth_group | table | michael.herman public | auth_group_permissions | table | michael.herman public | auth_permission | table | michael.herman public | auth_user | table | michael.herman public | auth_user_groups | table | michael.herman public | auth_user_user_permissions | table | michael.herman public | django_admin_log | table | michael.herman public | django_content_type | table | michael.herman public | django_migrations | table | michael.herman public | django_session | table | michael.herman public | puppies_puppy | table | michael.herman (11 rows)
ПРИМЕЧАНИЕ. Вы можете запустить \ d + puppies_puppy, если хотите посмотреть детали таблицы.
Прежде чем двигаться дальше, давайте напишем быстрый модульный тест для модели Puppy.
Добавьте следующий код в новый файл с именем test_models.py в новую папку с именем «tests» в «django-puppy-store/puppy_store/puppies»:
from django.test import TestCase from ..models import Puppy class PuppyTest(TestCase): """ Test module for Puppy model """ def setUp(self): Puppy.objects.create( name='Casper', age=3, breed='Bull Dog', color='Black') Puppy.objects.create( name='Muffin', age=1, breed='Gradane', color='Brown') def test_puppy_breed(self): puppy_casper = Puppy.objects.get(name='Casper') puppy_muffin = Puppy.objects.get(name='Muffin') self.assertEqual( puppy_casper.get_breed(), "Casper belongs to Bull Dog breed.") self.assertEqual( puppy_muffin.get_breed(), "Muffin belongs to Gradane breed.")
В приведенном выше тесте мы добавили фиктивные записи в нашу таблицу puppy с помощью метода setUp() из django.test.TestCase и заявили, что метод get_breed() будет возвращать определенную строку.
Добавте файл __init__.py в каталог “tests” и удалите файл tests.py из каталога “django-puppy-store/puppy_store/puppies”.
Давайте запустим наш первый тест:
(django-puppy-store) bash-3.2$ python manage.py test Creating test database for alias 'default'... . ---------------------------------------------------------------------- Ran 1 test in 0.007s OK Destroying test database for alias 'default'...
Наш первый модульный тест прошел!
Сериализаторы
Прежде чем перейти к созданию API, давайте определим сериализатор для нашей модели Puppy, который будет валидировать запросы (querysets) к модели и конвертировать данные из базы в типы данных Python для работы с ними.
Добавьте следующий фрагмент в django-puppy-store/puppy_store/puppies/serializers.py:
from rest_framework import serializers from .models import Puppy class PuppySerializer(serializers.ModelSerializer): class Meta: model = Puppy fields = ('name', 'age', 'breed', 'color', 'created_at', 'updated_at')
В приведенном выше фрагменте мы использовали ModelSerializer для нашей модели puppy, проверяя все упомянутые поля. Короче говоря, если у вас есть взаимно-однозначные отношения между вашими url API и вашими моделями — что вам, вероятно, всегда стараться делать — тогда вам проще всего использовать ModelSerializer для создания сериализатора.
Теперь приступим к созданию RESTful API…
Структура RESTful
В RESTful API конечные точки (URL) определяют структуру API и то, как пользователи смогут получить доступ к данным из нашего приложения с помощью методов HTTP — GET, POST, PUT, DELETE. Конечные точки должны быть логически организованы вокруг коллекций и элементов, которые являются ресурсами.
В нашем случае у нас есть один единственный ресурс, таблица puppies, поэтому мы будем использовать следующие URL-адреса — /puppies/ и /puppies/<id> для коллекций и элементов соответственно:
Endpoint | HTTP Method | CRUD Method | Result |
---|---|---|---|
puppies | GET | READ | Получить всех puppies |
puppies/:id | GET | READ | Получить одного выбранного puppy |
puppies | POST | CREATE | Добавить одного puppy |
puppies/:id | PUT | UPDATE | Обновить одного puppy |
puppies/:id | DELETE | DELETE | Удалить одного puppy |
Маршруты и тестирование (TDD)
Далее мы будем использовать упрощенный подход «вначале тестирование» (test-first), а не возможно более тщательный, основанный на разработке через тестирование (thorough test-driven). Наш подход будет заключаться в следующем процессе:
- добавление модульного (unit) тест, и достаточно кода, чтобы тест был неудачным
- затем обновляем код, чтобы тест проходил успешно
После того, как тест будет пройден, можно будет начать процесс заново для нового теста.
Начнем с создания нового файла django-puppy-store/puppy_store/puppies/tests/test_views.py, в котором будут храниться все тесты для наших вьюх и создан новый тестовый клиент для нашего приложения:
import json from rest_framework import status from django.test import TestCase, Client from django.urls import reverse from ..models import Puppy from ..serializers import PuppySerializer # initialize the APIClient app client = Client()
Прежде чем начать создавать все маршруты API, давайте сначала создадим скелет всех функций вьюх, которые будут возвращать пустые ответы и сопоставим их с соответствующими URL-адресами в файле django-puppy-store/puppy_store/puppies/views.py:
from rest_framework.decorators import api_view from rest_framework.response import Response from rest_framework import status from .models import Puppy from .serializers import PuppySerializer @api_view(['GET', 'DELETE', 'PUT']) def get_delete_update_puppy(request, pk): try: puppy = Puppy.objects.get(pk=pk) except Puppy.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) # get details of a single puppy if request.method == 'GET': return Response({}) # delete a single puppy elif request.method == 'DELETE': return Response({}) # update details of a single puppy elif request.method == 'PUT': return Response({}) @api_view(['GET', 'POST']) def get_post_puppies(request): # get all puppies if request.method == 'GET': return Response({}) # insert a new record for a puppy elif request.method == 'POST': return Response({})
Далее создадим соответствующие URL-адреса в соответствии с вьюхами в django-puppy-store/puppy_store/puppies/urls.py:
from django.urls import path from . import views urlpatterns = [ path( 'api/v1/puppies/(?P<pk>[0-9]+)', views.get_delete_update_puppy, name='get_delete_update_puppy' ), path( 'api/v1/puppies/', views.get_post_puppies, name='get_post_puppies' ) ]
Также обновим django-puppy-store/puppy_store/puppy_store/urls.py:
from django.conf.urls import include from django.urls import path from django.contrib import admin urlpatterns = [ path('', include('puppies.urls')), path( 'api-auth/', include('rest_framework.urls', namespace='rest_framework') ), path('admin/', admin.site.urls), ]
Browsable API
Теперь, когда все маршруты подключены к функциям вьюх, давайте откроем интерфейс REST Framework Browsable API и проверим, все ли URL работают должным образом.
Сначала запустите сервер разработки:
(django-puppy-store) bash-3.2$ python manage.py runserver
Обязательно закомментируйте все атрибуты в разделе REST_FRAMEWORK вашего файла settings.py, чтобы обойти вход в систему. Теперь зайдите на http://localhost:8000/api/v1/puppies
Вы увидите интерактивный HTML-макет для ответа API. Точно так же мы можем проверить другие URL и убедиться, что все URL работают отлично.
Давайте начнем с наших юнит-тестов для каждого маршрута.
Маршруты
Получение всех записей GET ALL
Начнем с тестов, чтобы проверить извлечение записей. Занесите следующий код в файл django-puppy-store/puppy_store/puppies/tests/test_views.py:
class GetAllPuppiesTest(TestCase): """ Test module for GET all puppies API """ def setUp(self): Puppy.objects.create( name='Casper', age=3, breed='Bull Dog', color='Black') Puppy.objects.create( name='Muffin', age=1, breed='Gradane', color='Brown') Puppy.objects.create( name='Rambo', age=2, breed='Labrador', color='Black') Puppy.objects.create( name='Ricky', age=6, breed='Labrador', color='Brown') def test_get_all_puppies(self): # get API response response = client.get(reverse('get_post_puppies')) # get data from db puppies = Puppy.objects.all() serializer = PuppySerializer(puppies, many=True) self.assertEqual(response.data, serializer.data) self.assertEqual(response.status_code, status.HTTP_200_OK)
Запустите тест. Вы должны увидеть следующую ошибку:
self.assertEqual(response.data, serializer.data) AssertionError: {} != [OrderedDict([('name', 'Casper'), ('age',[687 chars])])]
Обновите вьюху, чтобы пройти тест.
@api_view(['GET', 'POST']) def get_post_puppies(request): # get all puppies if request.method == 'GET': puppies = Puppy.objects.all() serializer = PuppySerializer(puppies, many=True) return Response(serializer.data) # insert a new record for a puppy elif request.method == 'POST': return Response({})
Здесь мы получаем все записи для puppies и проверяем каждого с помощью PuppySerializer.
Запустите тесты, чтобы убедиться, что они все прошли:
Ran 2 tests in 0.072s OK
Получение одной записи GET Single
Выбор одного puppy включает в себя два теста:
- Получить действительную запись — например, запись puppy существует
- Получите недействительного запись — например, запись puppy не существует
Добавим тесты:
class GetSinglePuppyTest(TestCase): """ Test module for GET single puppy API """ def setUp(self): self.casper = Puppy.objects.create( name='Casper', age=3, breed='Bull Dog', color='Black') self.muffin = Puppy.objects.create( name='Muffin', age=1, breed='Gradane', color='Brown') self.rambo = Puppy.objects.create( name='Rambo', age=2, breed='Labrador', color='Black') self.ricky = Puppy.objects.create( name='Ricky', age=6, breed='Labrador', color='Brown') def test_get_valid_single_puppy(self): response = client.get( reverse('get_delete_update_puppy', kwargs={'pk': self.rambo.pk})) puppy = Puppy.objects.get(pk=self.rambo.pk) serializer = PuppySerializer(puppy) self.assertEqual(response.data, serializer.data) self.assertEqual(response.status_code, status.HTTP_200_OK) def test_get_invalid_single_puppy(self): response = client.get( reverse('get_delete_update_puppy', kwargs={'pk': 30})) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
Запустите тесты. Вы должны увидеть следующую ошибку:
self.assertEqual(response.data, serializer.data) AssertionError: {} != {'name': 'Rambo', 'age': 2, 'breed': 'Labr[109 chars]26Z'}
Обновить вьюху:
@api_view(['GET', 'UPDATE', 'DELETE']) def get_delete_update_puppy(request, pk): try: puppy = Puppy.objects.get(pk=pk) except Puppy.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) # get details of a single puppy if request.method == 'GET': serializer = PuppySerializer(puppy) return Response(serializer.data)
В приведенном выше фрагменте мы получаем одну запись с помощью идентификатора. Далее запустите тесты, чтобы убедиться, что они все прошли.
POST запрос
Вставка новой записи также включает два случая:
- Вставка действительную запись
- Вставка неверную запись
Сначала напишем для запроса тесты:
class CreateNewPuppyTest(TestCase): """ Test module for inserting a new puppy """ def setUp(self): self.valid_payload = { 'name': 'Muffin', 'age': 4, 'breed': 'Pamerion', 'color': 'White' } self.invalid_payload = { 'name': '', 'age': 4, 'breed': 'Pamerion', 'color': 'White' } def test_create_valid_puppy(self): response = client.post( reverse('get_post_puppies'), data=json.dumps(self.valid_payload), content_type='application/json' ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) def test_create_invalid_puppy(self): response = client.post( reverse('get_post_puppies'), data=json.dumps(self.invalid_payload), content_type='application/json' ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
Запустите тесты. Вы должны увидеть две ошибки:
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) AssertionError: 200 != 400 self.assertEqual(response.status_code, status.HTTP_201_CREATED) AssertionError: 200 != 201
Снова обновим вьюху, чтобы пройти тесты:
@api_view(['GET', 'POST']) def get_post_puppies(request): # get all puppies if request.method == 'GET': puppies = Puppy.objects.all() serializer = PuppySerializer(puppies, many=True) return Response(serializer.data) # insert a new record for a puppy if request.method == 'POST': data = { 'name': request.data.get('name'), 'age': int(request.data.get('age')), 'breed': request.data.get('breed'), 'color': request.data.get('color') } serializer = PuppySerializer(data=data) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
Здесь мы вставили новую запись, сериализовав и проверив данные запроса перед вставкой в базу данных.
Запустите тесты снова, чтобы убедиться, что они прошли.
Вы также можете проверить это с помощью Browsable API. Снова запустите сервер разработки и перейдите по адресу http://localhost:8000/api/v1/puppies/. Затем в форме POST отправьте следующее с типом запроса application/json:
{ "name": "Muffin", "age": 4, "breed": "Pamerion", "color": "White" }
Вы так же можете проверить, что GET ALL и Get Single работают.
Запрос PUT
Начнем с теста для обновления записи. Подобно добавлению записи, нам снова нужно проверить как действительные, так и недействительные обновления:
class UpdateSinglePuppyTest(TestCase): """ Test module for updating an existing puppy record """ def setUp(self): self.casper = Puppy.objects.create( name='Casper', age=3, breed='Bull Dog', color='Black') self.muffin = Puppy.objects.create( name='Muffy', age=1, breed='Gradane', color='Brown') self.valid_payload = { 'name': 'Muffy', 'age': 2, 'breed': 'Labrador', 'color': 'Black' } self.invalid_payload = { 'name': '', 'age': 4, 'breed': 'Pamerion', 'color': 'White' } def test_valid_update_puppy(self): response = client.put( reverse('get_delete_update_puppy', kwargs={'pk': self.muffin.pk}), data=json.dumps(self.valid_payload), content_type='application/json' ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) def test_invalid_update_puppy(self): response = client.put( reverse('get_delete_update_puppy', kwargs={'pk': self.muffin.pk}), data=json.dumps(self.invalid_payload), content_type='application/json') self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
Запустите тесты.
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) AssertionError: 405 != 400 self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) AssertionError: 405 != 204
Обновить вьюху:
@api_view(['GET', 'DELETE', 'PUT']) def get_delete_update_puppy(request, pk): try: puppy = Puppy.objects.get(pk=pk) except Puppy.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) # get details of a single puppy if request.method == 'GET': serializer = PuppySerializer(puppy) return Response(serializer.data) # update details of a single puppy if request.method == 'PUT': serializer = PuppySerializer(puppy, data=request.data) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_204_NO_CONTENT) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) # delete a single puppy elif request.method == 'DELETE': return Response({})
В приведенном выше фрагменте, похожем на вставку, мы сериализуем и проверяем данные запроса, а затем отвечаем соответствующим образом.
Запустите тесты еще раз, чтобы убедиться, что все тесты пройдены.
Запрос DELETE
Чтобы удалить одну запись, требуется ее идентификатор:
class DeleteSinglePuppyTest(TestCase): """ Test module for deleting an existing puppy record """ def setUp(self): self.casper = Puppy.objects.create( name='Casper', age=3, breed='Bull Dog', color='Black') self.muffin = Puppy.objects.create( name='Muffy', age=1, breed='Gradane', color='Brown') def test_valid_delete_puppy(self): response = client.delete( reverse('get_delete_update_puppy', kwargs={'pk': self.muffin.pk})) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) def test_invalid_delete_puppy(self): response = client.delete( reverse('get_delete_update_puppy', kwargs={'pk': 30})) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
Запустите тесты.
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) AssertionError: 200 != 204
Обновим вьюху:
@api_view(['GET', 'DELETE', 'PUT']) def get_delete_update_puppy(request, pk): try: puppy = Puppy.objects.get(pk=pk) except Puppy.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) # get details of a single puppy if request.method == 'GET': serializer = PuppySerializer(puppy) return Response(serializer.data) # update details of a single puppy if request.method == 'PUT': serializer = PuppySerializer(puppy, data=request.data) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_204_NO_CONTENT) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) # delete a single puppy if request.method == 'DELETE': puppy.delete() return Response(status=status.HTTP_204_NO_CONTENT)
Запустите тесты снова. Убедитесь, что все они прошли. Обязательно проверьте функциональность UPDATE и DELETE в Browsable API!
Заключение и последующие шаги
В этом руководстве мы рассмотрели процесс создания RESTful API с использованием Django REST Framework с подходом, основанным на тестировании.
Что дальше? Чтобы сделать наш RESTful API надежным и безопасным, мы можем реализовать авторизацию и аутентификацию, чтобы разрешить ограниченный доступ на основе учетных данных, так же можно добавить ограничения скорости, чтобы избежать любого рода DDoS-атак. Кроме того, не забудьте запретить доступ к API Browsable в производственной среде.
Не стесняйтесь делиться своими комментариями, вопросами или советами в комментариях ниже.
Оригинал: Test Driven Development of a Django RESTful API
Спасибо за статью и в целом за труд/блог!
Хотел бы на не точность указать — в path нужно передавать строку, а если нужно регулярку, то надо re_path использовать.
Спасибо за комментарий