Разработка Django RESTful API через тестирование

В этом руководстве рассматривается процесс разработки RESTful API на основе CRUD с помощью Django и Django REST Framework, который используется для быстрого создания RESTful API на основе моделей Django.

Это приложение использует:

  • Python v3.6.0
  • Django v1.11.0
  • Django REST Framework v3.6.2
  • Postgres v9.6.1
  • Psycopg2 v2.7.1

Цели

После завершения этого урока вы сможете...

  1. Обсуждение преимуществ использования Django REST Framework для начального этапа разработки RESTful API
  2. Валидация наборов запросов моделей с помощью сериализаторов
  3. Оцените функцию Browsable API в Django REST Framework для создания более чистой и хорошо документированной версии ваших API
  4. Практика разработки на основе тестирования

Почему Django REST Framework?

Django REST Framework (REST Framework) предоставляет ряд мощных возможностей "из коробки", которые хорошо сочетаются с идиоматическим Django, включая:

  1. Browsable API: Документирует ваш API с помощью удобного для человека HTML-вывода, предоставляя красивый интерфейс, похожий на форму, для отправки данных на ресурсы и получения из них с помощью стандартных методов HTTP.
  2. Поддержка авторизации: REST Framework имеет богатую поддержку различных протоколов аутентификации, а также разрешений и политики дросселирования, которые могут быть настроены на основе каждого представления.
  3. Сериализаторы: Сериализаторы - это элегантный способ проверки кверисетов/экземпляров моделей и преобразования их в собственные типы данных Python, которые могут быть легко преобразованы в JSON и XML.
  4. Регулирование: Регулирование: регулирование - это способ определить, авторизован ли запрос или нет, и может быть интегрирован с различными разрешениями. Обычно он используется для ограничения скорости запросов API от одного пользователя.

Кроме того, документация легко читается и полна примеров. Если вы создаете RESTful API, где у вас есть связь один-к-одному между конечными точками API и вашими моделями, то REST Framework - это то, что вам нужно.

Установка проекта Django

Создайте и активируйте virtualenv:

$ mkdir django-puppy-store
$ cd django-puppy-store
$ python3.6 -m venv env
$ source env/bin/activate

Install Django and set up a new project:

(env)$ pip install django==1.11.0
(env)$ django-admin startproject puppy_store

Ваша текущая структура проекта должна выглядеть следующим образом:

└── puppy_store
    ├── manage.py
    └── puppy_store
        ├── __init__.py
        ├── settings.py
        ├── urls.py
        └── wsgi.py

Настройка приложений Django и REST Framework

Start by creating the puppies app and installing REST Framework inside your virtualenv:

(env)$ cd puppy_store
(env)$ python manage.py startapp puppies
(env)$ pip install djangorestframework==3.6.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 для всех запросов.

ПРИМЕЧАНИЕ: Неограниченный доступ подходит для локальной разработки, но в производственной среде вам может понадобиться ограничить доступ к определенным конечным точкам. Обязательно обновите это. Просмотрите документы для получения дополнительной информации.

Ваша текущая структура проекта теперь должна выглядеть следующим образом:

└── 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 и применим к ней все миграции.

NOTE: Feel free to swap out Postgres for the relational database of your choice!

Если в вашей системе есть работающий сервер Postgres, откройте интерактивную оболочку Postgres и создайте базу данных:

$ psql
# CREATE DATABASE puppy_store_drf;
CREATE DATABASE
# \q

Установите psycopg2, чтобы мы могли взаимодействовать с сервером Postgres через Python:

(env)$ pip install psycopg2==2.7.1

Update the database configuration in settings.py, adding the appropriate username and password:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'puppy_store_drf',
        'USER': '<your-user>',
        'PASSWORD': '<your-password>',
        'HOST': '127.0.0.1',
        'PORT': '5432'
    }
}

Затем определите модель щенка с некоторыми основными атрибутами в 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.'

Now apply the migration:

(env)$ python manage.py makemigrations
(env)$ python manage.py migrate

Проверка

Снова зайдите в psql и убедитесь, что puppies_puppy был создан:

$ 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 puppies, если хотите посмотреть фактические данные таблицы.

Перед тем как двигаться дальше, давайте напишем быстрый модульный тест для модели 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».

Давайте проведем наш первый тест:

(env)$ 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 и создает Pythonic типы данных для работы.

Add the following snippet to 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 для нашей модели щенка, проверяя все упомянутые поля. Короче говоря, если у вас есть связь один-к-одному между конечными точками API и вашими моделями - что, вероятно, необходимо, если вы создаете RESTful API - то вы можете использовать ModelSerializer для создания сериализатора.

Получив базу данных, мы можем приступить к созданию RESTful API...

RESTful Structure

В RESTful API конечные точки (URL) определяют структуру API и то, как конечные пользователи получают доступ к данным из нашего приложения с помощью методов HTTP - GET, POST, PUT, DELETE. Конечные точки должны быть логически организованы вокруг коллекций и элементов, которые являются ресурсами.

In our case, we have one single resource, puppies, so we will use the following URLS - /puppies/ and /puppies/<id> for collections and elements, respectively:

Endpoint HTTP Method CRUD Method Result
puppies GET READ Get all puppies
puppies/:id GET READ Get a single puppy
puppies POST CREATE Add a single puppy
puppies/:id PUT UPDATE Update a single puppy
puppies/:id DELETE DELETE Delete a single puppy

Маршруты и тестирование (TDD)

Мы будем использовать подход, основанный на тестировании, а не на тщательном тестировании, в рамках которого мы пройдем через следующий процесс:

  • add a unit test, just enough code to fail
  • then update the code to make it pass the test.

После того как тест пройден, начните с того же процесса для нового теста.

Начнем с создания нового файла 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({})

Create the respective URLs to match the views in django-puppy-store/puppy_store/puppies/urls.py:

from django.conf.urls import url
from . import views


urlpatterns = [
    url(
        r'^api/v1/puppies/(?P<pk>[0-9]+)$',
        views.get_delete_update_puppy,
        name='get_delete_update_puppy'
    ),
    url(
        r'^api/v1/puppies/$',
        views.get_post_puppies,
        name='get_post_puppies'
    )
]

Update django-puppy-store/puppy_store/puppy_store/urls.py as well:

from django.conf.urls import include, url
from django.contrib import admin

urlpatterns = [
    url(r'^', include('puppies.urls')),
    url(
        r'^api-auth/',
        include('rest_framework.urls', namespace='rest_framework')
    ),
    url(r'^admin/', admin.site.urls),
]

Просматриваемый API

Когда все маршруты подключены к функциям представления, давайте откроем интерфейс REST Framework's Browsable API и проверим, все ли URL работают так, как ожидалось.

Сначала запустите сервер разработки:

(env)$ python manage.py runserver

Make sure to comment out all the attributes in REST_FRAMEWORK section of our settings.py file, to bypass login. Now visit http://localhost:8000/api/v1/puppies

Вы увидите интерактивный HTML-макет для ответа API. Аналогичным образом мы можем протестировать другие URL-адреса и убедиться, что все URL-адреса работают отлично.

Начнем с наших модульных тестов для каждого маршрута.

Маршруты

Получить все

Начните с теста для проверки найденных записей:

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({})

Here, we get all the records for puppies and validate each using the PuppySerializer.

Запустите тесты, чтобы убедиться, что все они прошли:

Ran 2 tests in 0.072s

OK

Получить одного

Получение одного щенка включает в себя два тестовых случая:

  1. Get valid puppy - e.g., the puppy exists
  2. Get invalid puppy - e.g., the puppy does not exists

Добавьте тесты:

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

Вставка новой записи также включает два случая:

  1. Inserting a valid puppy
  2. Inserting a invalid puppy

Сначала напишите для него тесты:

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 с использованием подхода "тест-первый".

Бесплатный бонус: Нажмите здесь, чтобы загрузить копию руководства "Примеры REST API" и получить практическое введение в принципы Python + REST API на практических примерах.

Что дальше? Чтобы сделать наш RESTful API надежным и безопасным, мы можем реализовать разрешения и дросселирование для производственной среды, чтобы обеспечить ограниченный доступ на основе учетных данных аутентификации и ограничение скорости, чтобы избежать любых DDoS-атак. Также не забудьте запретить доступ к Browsable API в производственной среде.

Источник: https://realpython.com/test-driven-development-of-a-django-restful-api/

Вернуться на верх