Учебник
В этом руководстве мы пройдем процесс создания API для простой службы совместного использования изображений. По пути мы обсудим основные функции Falcon и представим терминологию, используемую в рамках фреймворка.
Первые шаги
Первое, что мы сделаем, это установка Falcon внутри свежего virtualenv. Для этого давайте создадим новую папку проекта под названием «look» и настроим виртуальную среду внутри неё, которую мы можем использовать для учебника:
$ mkdir look $ cd look $ virtualenv .venv $ source .venv/bin/activate $ pip install falcon
Принято, чтобы модуль верхнего уровня проекта назывался так же, как и проект, поэтому давайте создадим ещё одну папку «look» внутри первой и отметим её как модуль Python, создав пустой __init__.py файл в ней:
$ mkdir look $ touch look/__init__.py
Далее, давайте создадим новый файл, который будет точкой входа в ваше приложение:
$ touch look/app.py
Иерархия файлов должна теперь выглядеть так:
look
├── .venv
└── look
├── __init__.py
└── app.py
Теперь откройте app.py в вашем любимом текстовом редакторе и добавьте следующие строки:
import falcon api = application = falcon.API()
Этот код создаёт ваше WSGI-приложение и даёт ему псевдоним api. Вы можете использовать любые имена переменных, которые вам нравятся, но мы будем использовать application, так как Gunicorn по умолчанию ожидает, что оно так называется (мы увидим, как это работает в следующем разделе учебника).
Примечание
WSGI-приложение — это просто вызываемый объект с хорошо определённой сигнатурой, так что вы можете разместить приложение с любым веб-сервером, который понимает протокол WSGI.
Далее, давайте посмотрим на класс falcon.API. Установите IPython и запустите его:
$ pip install ipython $ ipython
Теперь введите следующее, чтобы получить информацию о вызываемом объекте falcon.API:
In [1]: import falcon In [2]: falcon.API.__call__?
В качестве альтернативы вы можете использовать стандартную Python-функцию help():
In [3]: help(falcon.API.__call__)
Обратите внимание на сигнатуру метода. env и start_response — стандартные параметры WSGI. Falcon добавляет тонкий абстрактный слой поверх этих параметров, чтобы вам не приходилось взаимодействовать с ними напрямую.
Фреймворк Falcon содержит подробную встроенную документацию, которую вы можете запросить с помощью вышеописанного метода.
Подсказка
В дополнение к IPython, сообщество Python поддерживает несколько других мощных REPL, которые вы можете попробовать, включая bpython и ptpython.
Размещение вашего приложения
Теперь, когда у вас есть простое приложение Falcon, вы можете запустить его с помощью WSGI-сервера. Python включает справочный сервер для самохостинга, но давайте воспользуемся чем-то более надёжным, что вы могли бы использовать в производстве.
Откройте новую консоль и выполните следующее:
$ source .venv/bin/activate $ pip install gunicorn $ gunicorn --reload look.app
(Обратите внимание на использование опции --reload для указания Gunicorn перезагружать приложение всякий раз, когда код изменяется.)
Если вы пользователь Windows, вместо Gunicorn можно использовать Waitress, так как последний не работает под Windows:
$ pip install waitress $ waitress-serve --port=8000 look:app
Теперь в другой консоли попробуйте запросить работающее приложение с помощью curl:
$ curl -v localhost:8000
Вы должны получить 404. Это нормально, потому что мы ещё не определили никаких маршрутов. Falcon включает обработчик по умолчанию для ответа 404, который будет срабатывать для любого запрошенного пути, для которого не существует маршрута.
Хотя curl вполне справляется с задачей, им немного неудобно пользоваться. HTTPie — современная, удобная альтернатива. Давайте установим HTTPie и будем использовать его отныне:
$ source .venv/bin/activate $ pip install httpie $ http localhost:8000
Создание ресурсов
Дизайн Falcon заимствует несколько ключевых концепций из архитектурного стиля REST.
Центральной концепцией как REST, так и фреймворка Falcon является «ресурс». Ресурсы — это все элементы вашего API или приложения, которые можно получить по URL. Например, приложение для бронирования событий может иметь такие ресурсы, как «билет» и «место», а бэкенд видеоигры может иметь такие ресурсы, как «достижения» и «игрок».
URL-адреса обеспечивают способ уникальной идентификации ресурсов. Например, /players может идентифицировать ресурс «список всех игроков», в то время как /players/45301f54 может идентифицировать «отдельного игрока с идентификатором 45301f54», а /players/45301f54/achievements — «список всех достижений для ресурса игрока с идентификатором 45301f54».
POST /players/45301f54/achievements └──────┘ └────────────────────────────────┘ Action Resource Identifier
В архитектурном стиле REST URL только идентифицирует ресурс; он не указывает, какое действие следует выполнить над этим ресурсом. Вместо этого пользователи выбирают из набора стандартных методов. Для HTTP это знакомые GET, POST, HEAD и т. д. Клиенты могут запросить ресурс, чтобы узнать, какие методы он поддерживает.
Примечание
Это одно из ключевых отличий между архитектурными стилями REST и RPC. REST применяет стандартный набор глаголов ко всем ресурсам, в отличие от того, чтобы каждое приложение определяло свой собственный уникальный набор методов.
В зависимости от запрошенного действия сервер может или не может вернуть представление клиенту. Представления могут быть закодированы в любом из нескольких типов медиа, например, JSON и HTML.
Falcon использует классы Python для представления ресурсов. На практике эти классы выступают в роли контроллеров в вашем приложении. Они преобразуют входящий запрос в одно или несколько внутренних действий, а затем составляют ответ клиенту на основе результатов этих действий.
┌────────────┐
request → │ │
│ Resource │ ↻ Orchestrate the requested action
│ Controller │ ↻ Compose the result
response ← │ │
└────────────┘
Ресурс в Falcon — это обычный класс Python, который включает один или несколько методов, представляющих стандартные HTTP-глаголы, поддерживаемые этим ресурсом. Каждый запрошенный URL отображается на определённый ресурс.
Поскольку мы создаём API для совместного использования изображений, давайте начнём с создания ресурса «изображения». Создайте новый модуль images.py рядом с app.py, и добавьте в него следующий код:
import json
import falcon
class Resource(object):
def on_get(self, req, resp):
doc = {
'images': [
{
'href': '/images/1eaf6ef1-7f2d-4ecc-a8d5-6e8adba7cc0e.png'
}
]
}
# Create a JSON representation of the resource
resp.body = json.dumps(doc, ensure_ascii=False)
# The following line can be omitted because 200 is the default
# status returned by the framework, but it is included here to
# illustrate how this may be overridden as needed.
resp.status = falcon.HTTP_200
Как видите, Resource — это просто обычный класс. Вы можете назвать класс как угодно. Falcon использует утиную типизацию, поэтому вам не нужно наследовать от какого-либо специального базового класса.
Ресурс изображения выше определяет один метод on_get(). Для любого HTTP-метода, который вы хотите, чтобы поддерживал ваш ресурс, просто добавьте метод on_*(), где * — любой из стандартных HTTP-методов, в нижнем регистре (например, on_get(), on_put(), on_head(), и т. д.).
Эти известные методы мы называем «ответчиками». Каждый ответчик принимает (по крайней мере) два параметра: один представляющий HTTP-запрос, и один представляющий HTTP-ответ на этот запрос. По соглашению они называются req и resp, соответственно. Шаблоны маршрутов и хуки могут вводить дополнительные параметры, как мы увидим позже.
Сейчас ресурс изображений отвечает на GET-запросы с простым 200 OK и JSON-телом. По умолчанию Falcon использует тип медиа application/json, но вы можете установить любой другой. Достойные альтернативы JSON включают YAML и MessagePack.
Далее, давайте подключим этот ресурс и посмотрим его в действии. Вернитесь к app.py и измените его так, чтобы он выглядел примерно так:
import falcon
from .images import Resource
api = application = falcon.API()
images = Resource()
api.add_route('/images', images)
Теперь, когда приходит запрос на /images, Falcon вызовет ответчика на ресурсе изображений, соответствующем запрошенному HTTP-методу.
Давайте попробуем. Перезапустите Gunicorn (если вы не используете --reload) и отправьте GET-запрос на ресурс:
$ http localhost:8000/images
Вы должны получить 200 OK ответ, включая JSON-кодированное представление ресурса «изображения».
Примечание
add_route() ожидает экземпляр класса ресурса, а не сам класс. Один и тот же экземпляр используется для всех запросов. Эта стратегия улучшает производительность и уменьшает использование памяти, но это также означает, что если вы размещаете своё приложение с помощью многопоточного веб-сервера, ресурсы и их зависимости должны быть потокобезопасными.
До сих пор мы реализовали только ответчик для GET. Давайте посмотрим, что произойдёт, когда будет запрошен другой метод:
$ http PUT localhost:8000/images
На этот раз вы должны получить 405 Method Not Allowed, так как ресурс не поддерживает метод PUT. Обратите внимание на значение заголовка Allow:
allow: GET, OPTIONS
Он генерируется автоматически Falcon на основе набора методов, реализованных целевым ресурсом. Если ресурс не включает свой собственный ответчик OPTIONS, фреймворк предоставляет реализацию по умолчанию. Следовательно, OPTIONS всегда включён в список разрешённых методов.
Примечание
Если вы имеете большой опыт работы с другими фреймворками Python для веб-приложений, вы, возможно, привыкли использовать декораторы для настройки маршрутов. Специфический подход Falcon предоставляет следующие преимущества:
- Структура URL-адресов приложения централизована. Это упрощает понимание и поддержку API со временем.
- Использование классов ресурсов естественным образом сопоставляется с архитектурным стилем REST, в котором URL используется только для идентификации ресурса, а не действия, которое нужно выполнить над этим ресурсом.
- Методы класса ресурсов обеспечивают единый интерфейс, который не нужно изобретать (и поддерживать) для каждого класса и приложения.
Теперь, для интереса, давайте изменим наш ресурс, чтобы использовать MessagePack вместо JSON. Начните с установки соответствующего пакета:
$ pip install msgpack-python
Затем обновите ответчик, чтобы использовать новый тип медиа:
import falcon
import msgpack
class Resource(object):
def on_get(self, req, resp):
doc = {
'images': [
{
'href': '/images/1eaf6ef1-7f2d-4ecc-a8d5-6e8adba7cc0e.png'
}
]
}
resp.data = msgpack.packb(doc, use_bin_type=True)
resp.content_type = falcon.MEDIA_MSGPACK
resp.status = falcon.HTTP_200
Обратите внимание на использование resp.data вместо resp.body. Если вы присвоёте строку байтов последнему, Falcon её распознает, но вы можете получить небольшое повышение производительности, присвоив её напрямую resp.data.
Также обратите внимание на использование falcon.MEDIA_MSGPACK. Модуль falcon предоставляет ряд констант для распространённых типов медиа, включая falcon.MEDIA_JSON, falcon.MEDIA_MSGPACK, falcon.MEDIA_YAML, falcon.MEDIA_XML, falcon.MEDIA_HTML, falcon.MEDIA_JS, falcon.MEDIA_TEXT, falcon.MEDIA_JPEG, falcon.MEDIA_PNG, и falcon.MEDIA_GIF.
Перезапустите Gunicorn (если вы не используете --reload), а затем попробуйте отправить GET-запрос на изменённый ресурс:
$ http localhost:8000/images
Тестирование вашего приложения
Полное тестирование вашего кода имеет решающее значение для создания надежного приложения. Давайте на мгновение напишем тест для того, что было реализовано до сих пор.
Сначала создайте директорию tests с __init__.py и модулем тестирования (test_app.py) внутри нее. Структура проекта теперь должна выглядеть так:
look
├── .venv
├── look
│ ├── __init__.py
│ ├── app.py
│ └── images.py
└── tests
├── __init__.py
└── test_app.py
Falcon поддерживает тестирование своего объекта API, моделируя HTTP-запросы.
Тесты могут быть написаны с использованием стандартного модуля Python unittest или с помощью любого из множества фреймворков сторонних разработчиков, таких как pytest. Для этого урока мы будем использовать pytest, так как он позволяет создавать более питонический код тестов по сравнению с модулем unittest, вдохновлённым JUnit.
Давайте начнем с установки пакета pytest:
$ pip install pytest
Далее отредактируйте test_app.py так, чтобы он выглядел следующим образом:
import falcon
from falcon import testing
import msgpack
import pytest
from look.app import api
@pytest.fixture
def client():
return testing.TestClient(api)
# pytest will inject the object returned by the "client" function
# as an additional parameter.
def test_list_images(client):
doc = {
'images': [
{
'href': '/images/1eaf6ef1-7f2d-4ecc-a8d5-6e8adba7cc0e.png'
}
]
}
response = client.simulate_get('/images')
result_doc = msgpack.unpackb(response.content, encoding='utf-8')
assert result_doc == doc
assert response.status == falcon.HTTP_OK
Из основной директории проекта запустите свой новый тест, выполнив pytest в директории tests:
$ pytest tests
Если pytest сообщит об ошибках, займитесь их исправлением, прежде чем перейти к следующему разделу руководства.
Объекты Request и Response
Каждый обработчик в ресурсе получает объект Request, который можно использовать для чтения заголовков, параметров запроса и тела запроса. Вы можете использовать стандартную функцию help() или магическую функцию IPython ? для перечисления атрибутов и методов класса Falcon Request:
In [1]: import falcon In [2]: falcon.Request?
Каждый обработчик также получает объект Response, который можно использовать для установки кода состояния, заголовков и тела ответа:
In [3]: falcon.Response?
Это будет полезно при создании конечной точки POST в приложении, которая может добавлять новые ресурсы изображений в нашу коллекцию. Мы займёмся этой функциональностью далее.
Мы будем использовать TDD на этот раз, чтобы продемонстрировать, как применять эту стратегию тестирования при разработке приложения Falcon. С помощью тестов мы сначала точно определим, что должно делать приложение, а затем будем писать код до тех пор, пока тесты не скажут нам, что всё готово.
Примечание
Чтобы узнать больше о TDD, вы можете прочитать одну из многочисленных книг по этой теме, например Test Driven Development with Python. Примеры в этой конкретной книге используют фреймворк Django и даже JavaScript, но автор охватывает ряд принципов тестирования, которые широко применимы.
Давайте начнем с добавления дополнительной инструкции import в test_app.py. Нам нужно импортировать два модуля из unittest.mock если вы используете Python 3, или из mock если вы используете Python 2.
# Python 3 from unittest.mock import mock_open, call # Python 2 from mock import mock_open, call
Для Python 2 вам также потребуется установить пакет mock:
$ pip install mock
Теперь добавьте следующий тест:
# "monkeypatch" is a special built-in pytest fixture that can be
# used to install mocks.
def test_posted_image_gets_saved(client, monkeypatch):
mock_file_open = mock_open()
monkeypatch.setattr('io.open', mock_file_open)
fake_uuid = '123e4567-e89b-12d3-a456-426655440000'
monkeypatch.setattr('uuid.uuid4', lambda: fake_uuid)
# When the service receives an image through POST...
fake_image_bytes = b'fake-image-bytes'
response = client.simulate_post(
'/images',
body=fake_image_bytes,
headers={'content-type': 'image/png'}
)
# ...it must return a 201 code, save the file, and return the
# image's resource location.
assert response.status == falcon.HTTP_CREATED
assert call().write(fake_image_bytes) in mock_file_open.mock_calls
assert response.headers['location'] == '/images/{}.png'.format(fake_uuid)
Как видите, этот тест сильно полагается на подстановку, что делает его несколько хрупким при изменениях реализации. Мы вернёмся к этому позже. Сейчас давайте запустим тесты и посмотрим, чтобы убедиться, что они не пройдут. Ключевой шаг в процессе TDD — убедиться, что ваши тесты не проходят, прежде чем переходить к реализации:
$ pytest tests
Чтобы заставить новый тест пройти, нам нужно добавить новый метод для обработки POST-запросов. Откройте images.py и добавьте обработчик POST в класс Resource следующим образом:
import io
import os
import uuid
import mimetypes
import falcon
import msgpack
class Resource(object):
_CHUNK_SIZE_BYTES = 4096
# The resource object must now be initialized with a path used during POST
def __init__(self, storage_path):
self._storage_path = storage_path
# This is the method we implemented before
def on_get(self, req, resp):
doc = {
'images': [
{
'href': '/images/1eaf6ef1-7f2d-4ecc-a8d5-6e8adba7cc0e.png'
}
]
}
resp.data = msgpack.packb(doc, use_bin_type=True)
resp.content_type = falcon.MEDIA_MSGPACK
resp.status = falcon.HTTP_200
def on_post(self, req, resp):
ext = mimetypes.guess_extension(req.content_type)
name = '{uuid}{ext}'.format(uuid=uuid.uuid4(), ext=ext)
image_path = os.path.join(self._storage_path, name)
with io.open(image_path, 'wb') as image_file:
while True:
chunk = req.stream.read(self._CHUNK_SIZE_BYTES)
if not chunk:
break
image_file.write(chunk)
resp.status = falcon.HTTP_201
resp.location = '/images/' + name
Как видите, мы генерируем уникальное имя для изображения и затем записываем его, считывая из req.stream. Оно называется stream вместо body, чтобы подчеркнуть, что вы действительно читаете из потока ввода; по умолчанию Falcon не буферизует и не декодирует данные запроса, а вместо этого предоставляет вам прямой доступ к поступающему двоичному потоку, предоставленному сервером WSGI.
Обратите внимание на использование falcon.HTTP_201 для установки кода состояния ответа на «201 Created». Мы также могли использовать псевдоним falcon.HTTP_CREATED. Для получения полного списка предопределённых строк состояния вызовите help() на falcon.status_codes:
In [4]: help(falcon.status_codes)
Последняя строка в обработчике on_post() устанавливает заголовок Location для нового созданного ресурса. (Мы создадим маршрут для этого пути через несколько минут.) Классы Request и Response содержат удобные атрибуты для чтения и установки общих заголовков, но вы всегда можете получить доступ к любому заголовку по имени с помощью методов req.get_header() и resp.set_header().
Потратьте некоторое время на повторный запуск pytest, чтобы проверить свой прогресс:
$ pytest tests
Вы должны увидеть TypeError, как следствие добавления параметра storage_path к Resource.__init__().
Для исправления просто отредактируйте app.py и передайте путь к инициализатору. Пока используйте рабочую директорию, из которой вы запустили службу:
images = Resource(storage_path='.')
Попробуйте запустить тесты ещё раз. На этот раз они должны пройти успешно!
$ pytest tests
Наконец, перезапустите Gunicorn и затем попробуйте отправить POST-запрос на ресурс из командной строки (заменив test.png на путь к любому PNG, который вы хотите).
$ http POST localhost:8000/images Content-Type:image/png < test.png
Теперь, если вы проверите директорию хранения, она должна содержать копию изображения, которое вы только что отправили POST-запросом.
Вперёд!
Рефакторинг для повышения тестируемости
Ранее мы отметили, что наш тест POST сильно опирается на подстановку, основываясь на предположениях, которые могут или не могут быть верными по мере развития кода. Для минимизации этой проблемы нам нужно будет не только переработать тесты, но и само приложение.
Мы начнём с выделения логики приложения из обработчика POST ресурса в images.py, чтобы можно было тестировать её независимо. В данном случае «логика приложения» — это просто операция сохранения изображения:
import io
import mimetypes
import os
import uuid
import falcon
import msgpack
class Resource(object):
def __init__(self, image_store):
self._image_store = image_store
def on_get(self, req, resp):
doc = {
'images': [
{
'href': '/images/1eaf6ef1-7f2d-4ecc-a8d5-6e8adba7cc0e.png'
}
]
}
resp.data = msgpack.packb(doc, use_bin_type=True)
resp.content_type = falcon.MEDIA_MSGPACK
resp.status = falcon.HTTP_200
def on_post(self, req, resp):
name = self._image_store.save(req.stream, req.content_type)
resp.status = falcon.HTTP_201
resp.location = '/images/' + name
class ImageStore(object):
_CHUNK_SIZE_BYTES = 4096
# Note the use of dependency injection for standard library
# methods. We'll use these later to avoid monkey-patching.
def __init__(self, storage_path, uuidgen=uuid.uuid4, fopen=io.open):
self._storage_path = storage_path
self._uuidgen = uuidgen
self._fopen = fopen
def save(self, image_stream, image_content_type):
ext = mimetypes.guess_extension(image_content_type)
name = '{uuid}{ext}'.format(uuid=self._uuidgen(), ext=ext)
image_path = os.path.join(self._storage_path, name)
with self._fopen(image_path, 'wb') as image_file:
while True:
chunk = image_stream.read(self._CHUNK_SIZE_BYTES)
if not chunk:
break
image_file.write(chunk)
return name
Давайте проверим, не сломали ли мы что-нибудь с помощью вышеуказанных изменений:
$ pytest tests
Хмм, похоже, мы забыли обновить app.py. Давайте сделаем это сейчас:
import falcon
from .images import ImageStore, Resource
api = application = falcon.API()
image_store = ImageStore('.')
images = Resource(image_store)
api.add_route('/images', images)
Давайте попробуем снова:
$ pytest tests
Теперь вы должны увидеть проваленное утверждение о mock_file_open. Для исправления нам нужно перейти от подстановки к инъекции зависимостей. Вернитесь к app.py и измените его так, чтобы он выглядел похожим на следующее:
import falcon
from .images import ImageStore, Resource
def create_app(image_store):
image_resource = Resource(image_store)
api = falcon.API()
api.add_route('/images', image_resource)
return api
def get_app():
image_store = ImageStore('.')
return create_app(image_store)
Как вы можете видеть, основная логика настройки перемещена в create_app(), которая может быть использована для получения объекта API как для тестирования, так и для размещения в продакшене. get_app() отвечает за создание дополнительных ресурсов и настройку приложения для размещения.
Команда для запуска приложения теперь:
$ gunicorn --reload 'look.app:get_app()'
Наконец, нам нужно обновить код теста. Измените test_app.py так, чтобы он выглядел примерно так:
import io
# Python 3
from unittest.mock import call, MagicMock, mock_open
# Python 2
# from mock import call, MagicMock, mock_open
import falcon
from falcon import testing
import msgpack
import pytest
import look.app
import look.images
@pytest.fixture
def mock_store():
return MagicMock()
@pytest.fixture
def client(mock_store):
api = look.app.create_app(mock_store)
return testing.TestClient(api)
def test_list_images(client):
doc = {
'images': [
{
'href': '/images/1eaf6ef1-7f2d-4ecc-a8d5-6e8adba7cc0e.png'
}
]
}
response = client.simulate_get('/images')
result_doc = msgpack.unpackb(response.content, encoding='utf-8')
assert result_doc == doc
assert response.status == falcon.HTTP_OK
# With clever composition of fixtures, we can observe what happens with
# the mock injected into the image resource.
def test_post_image(client, mock_store):
file_name = 'fake-image-name.xyz'
# We need to know what ImageStore method will be used
mock_store.save.return_value = file_name
image_content_type = 'image/xyz'
response = client.simulate_post(
'/images',
body=b'some-fake-bytes',
headers={'content-type': image_content_type}
)
assert response.status == falcon.HTTP_CREATED
assert response.headers['location'] == '/images/{}'.format(file_name)
saver_call = mock_store.save.call_args
# saver_call is a unittest.mock.call tuple. It's first element is a
# tuple of positional arguments supplied when calling the mock.
assert isinstance(saver_call[0][0], falcon.request_helpers.BoundedStream)
assert saver_call[0][1] == image_content_type
Как вы видите, мы повторно сделали POST. Хотя подстановок меньше, утверждения стали более подробными, чтобы правильно проверить взаимодействия на границах интерфейса.
Давайте проверим свой прогресс:
$ pytest tests
Всё зелёное! Но так как мы использовали заглушку, мы больше не покрываем фактическое сохранение изображения. Давайте добавим тест для этого:
def test_saving_image(monkeypatch):
# This still has some mocks, but they are more localized and do not
# have to be monkey-patched into standard library modules (always a
# risky business).
mock_file_open = mock_open()
fake_uuid = '123e4567-e89b-12d3-a456-426655440000'
def mock_uuidgen():
return fake_uuid
fake_image_bytes = b'fake-image-bytes'
fake_request_stream = io.BytesIO(fake_image_bytes)
storage_path = 'fake-storage-path'
store = look.images.ImageStore(
storage_path,
uuidgen=mock_uuidgen,
fopen=mock_file_open
)
assert store.save(fake_request_stream, 'image/png') == fake_uuid + '.png'
assert call().write(fake_image_bytes) in mock_file_open.mock_calls
Теперь попробуйте:
$ pytest tests -k test_saving_image
Как и предыдущий тест, этот всё ещё использует заглушки. Но структура кода была улучшена с помощью методов компонентной архитектуры и инверсии зависимостей, что делает приложение более гибким и тестируемым.
Совет
Проверка покрытия кода с помощью coverage помогла бы нам обнаружить недостающий тест выше; всегда полезно включать тестирование покрытия в ваш рабочий процесс, чтобы убедиться, что у вас нет скрытых ошибок в недопроверенных участках кода.
Функциональные тесты
Функциональные тесты определяют поведение приложения со стороны внешнего наблюдателя. При использовании TDD это может быть более естественным началом по сравнению с низкоуровневым модульным тестированием, так как сложно заранее предвидеть, какие внутренние интерфейсы и компоненты нужны до определения пользовательской функциональности приложения.
В случае рефакторинга из предыдущего раздела мы могли случайно ввести функциональную ошибку в приложение, которую наши модульные тесты не обнаружили бы. Это может произойти, когда ошибка является результатом непредвиденного взаимодействия между несколькими модулями, между приложением и веб-сервером или между приложением и любыми внешними сервисами, от которых оно зависит.
С помощью таких вспомогательных функций для тестирования, как simulate_get() и simulate_post(), мы можем создавать тесты, охватывающие несколько модулей. Но мы также можем сделать ещё один шаг вперёд и запустить приложение как обычный отдельный процесс (например, с Gunicorn). Затем мы можем написать тесты, которые взаимодействуют с работающим процессом через HTTP, ведя себя как обычный клиент.
Давайте посмотрим на это в действии. Создайте новый модуль тестирования tests/test_integration.py со следующим содержимым:
import os
import requests
def test_posted_image_gets_saved():
file_save_prefix = '/tmp/'
location_prefix = '/images/'
fake_image_bytes = b'fake-image-bytes'
response = requests.post(
'http://localhost:8000/images',
data=fake_image_bytes,
headers={'content-type': 'image/png'}
)
assert response.status_code == 201
location = response.headers['location']
assert location.startswith(location_prefix)
image_name = location.replace(location_prefix, '')
file_path = file_save_prefix + image_name
with open(file_path, 'rb') as image_file:
assert image_file.read() == fake_image_bytes
os.remove(file_path)
Далее установите пакет requests (как требуется новым тестом) и убедитесь, что Gunicorn запущен:
$ pip install requests $ gunicorn 'look.app:get_app()'
Затем в другом терминале попробуйте запустить новый тест:
$ pytest tests -k test_posted_image_gets_saved
Тест не пройдёт, так как он ожидает, что файл изображения будет находиться в /tmp. Для исправления измените app.py таким образом, чтобы добавить возможность настройки директории хранения изображений с помощью переменной окружения:
import os
import falcon
from .images import ImageStore, Resource
def create_app(image_store):
image_resource = Resource(image_store)
api = falcon.API()
api.add_route('/images', image_resource)
return api
def get_app():
storage_path = os.environ.get('LOOK_STORAGE_PATH', '.')
image_store = ImageStore(storage_path)
return create_app(image_store)
Теперь вы можете перезапустить приложение с нужной директорией хранения:
$ LOOK_STORAGE_PATH=/tmp gunicorn --reload 'look.app:get_app()'
Теперь вы должны сможете перезапустить тест и увидеть, что он пройдёт:
$ pytest tests -k test_posted_image_gets_saved
Примечание
Вышеуказанный процесс запуска, тестирования, остановки и очистки после каждого выполнения теста можно (и нужно) автоматизировать. В зависимости от ваших потребностей, вы можете разработать свои собственные фикстуры для автоматизации или использовать библиотеку, такую как mountepy.
Многие разработчики предпочитают писать тесты, подобные приведенному выше, чтобы проверить основную функциональность своего приложения, оставив основную часть тестирования симулированным запросам и модульным тестам. Последние типы тестов, как правило, выполняются гораздо быстрее и обеспечивают более точные утверждения о тесте по сравнению с функциональными и системными тестами высокого уровня. При этом стратегии тестирования сильно различаются, и вы должны выбрать ту, которая лучше всего подходит для ваших потребностей.
На этом этапе вы должны хорошо понимать, как применять распространенные стратегии тестирования к своему приложению Falcon. Ради краткости мы опустим дальнейшие инструкции по тестированию в последующих разделах, сосредоточившись вместо этого на демонстрации дополнительных функций Falcon.
Отображение изображений
Теперь, когда у нас есть способ размещения изображений в службе, нам, конечно, нужен способ их извлечения. Мы хотим возвращать изображение при его запросе, используя путь, возвращённый в заголовке Location.
Попробуйте выполнить следующее:
$ http localhost:8000/images/db79e518-c8d3-4a87-93fe-38b620f9d410.png
В ответ вы должны получить 404 Not Found. Это стандартный ответ, предоставляемый Falcon, когда он не находит ресурс, соответствующий запрошенному пути URL.
Давайте решим эту проблему, создав отдельный класс для представления отдельного ресурса изображения. Затем мы добавим метод on_get() для ответа на указанный выше путь.
Отредактируйте файл images.py, чтобы он выглядел примерно так:
import io
import os
import re
import uuid
import mimetypes
import falcon
import msgpack
class Collection(object):
def __init__(self, image_store):
self._image_store = image_store
def on_get(self, req, resp):
# TODO: Modify this to return a list of href's based on
# what images are actually available.
doc = {
'images': [
{
'href': '/images/1eaf6ef1-7f2d-4ecc-a8d5-6e8adba7cc0e.png'
}
]
}
resp.data = msgpack.packb(doc, use_bin_type=True)
resp.content_type = falcon.MEDIA_MSGPACK
resp.status = falcon.HTTP_200
def on_post(self, req, resp):
name = self._image_store.save(req.stream, req.content_type)
resp.status = falcon.HTTP_201
resp.location = '/images/' + name
class Item(object):
def __init__(self, image_store):
self._image_store = image_store
def on_get(self, req, resp, name):
resp.content_type = mimetypes.guess_type(name)[0]
resp.stream, resp.stream_len = self._image_store.open(name)
class ImageStore(object):
_CHUNK_SIZE_BYTES = 4096
_IMAGE_NAME_PATTERN = re.compile(
'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z]{2,4}$'
)
def __init__(self, storage_path, uuidgen=uuid.uuid4, fopen=io.open):
self._storage_path = storage_path
self._uuidgen = uuidgen
self._fopen = fopen
def save(self, image_stream, image_content_type):
ext = mimetypes.guess_extension(image_content_type)
name = '{uuid}{ext}'.format(uuid=self._uuidgen(), ext=ext)
image_path = os.path.join(self._storage_path, name)
with self._fopen(image_path, 'wb') as image_file:
while True:
chunk = image_stream.read(self._CHUNK_SIZE_BYTES)
if not chunk:
break
image_file.write(chunk)
return name
def open(self, name):
# Always validate untrusted input!
if not self._IMAGE_NAME_PATTERN.match(name):
raise IOError('File not found')
image_path = os.path.join(self._storage_path, name)
stream = self._fopen(image_path, 'rb')
stream_len = os.path.getsize(image_path)
return stream, stream_len
Как видите, мы переименовали Resource в Collection и добавили новый класс Item для представления отдельного ресурса изображения. Обратите также внимание на параметр name для ответчика on_get(). Любые параметры URI, которые вы укажете в своих маршрутах, будут преобразованы в соответствующие значения kwargs и переданы целевому обработчику как таковые. Мы увидим, как указать параметры URI чуть позже.
Внутри обработчика on_get() мы устанавливаем заголовок Content-Type на основе расширения файла и затем непосредственно передаём изображение из открытого дескриптора файла. Обратите внимание на использование resp.stream_len. При использовании resp.stream вместо resp.body или resp.data, вы обычно также указываете ожидаемую длину потока, чтобы веб-клиент знал, сколько данных нужно прочитать из ответа.
Примечание
Если вы не знаете размер потока заранее, вы можете обойти эту проблему, используя кодирование по частям, но это выходит за рамки данного руководства.
Если resp.status не задано явно, оно по умолчанию равно 200 OK, что и есть то, что мы хотим, чтобы on_get() делал.
Теперь давайте всё подключим и попробуем. Откорректируйте app.py таким образом, чтобы он выглядел примерно так:
import os
import falcon
import images
def create_app(image_store):
api = falcon.API()
api.add_route('/images', images.Collection(image_store))
api.add_route('/images/{name}', images.Item(image_store))
return api
def get_app():
storage_path = os.environ.get('LOOK_STORAGE_PATH', '.')
image_store = images.ImageStore(storage_path)
return create_app(image_store)
Как видите, мы указали новый маршрут /images/{name}. Это заставляет Falcon ожидать, что все связанные обработчики примут аргумент name.
Примечание
Falcon также поддерживает более сложные параметризованные сегменты пути, содержащие несколько значений. Например, API контроля версий может использовать следующую шаблон маршрута для сравнения двух ветвей кода:
/repos/{org}/{repo}/compare/{usr0}:{branch0}...{usr1}:{branch1}
Теперь перезапустите приложение и попробуйте отправить ещё одну картинку:
$ http POST localhost:8000/images Content-Type:image/png < test.png
Обратите внимание на путь, возвращённый в заголовке Location, и используйте его для получения изображения:
$ http localhost:8000/images/dddff30e-d2a6-4b57-be6a-b985ee67fa87.png
HTTPie не отобразит изображение, но вы можете увидеть, что заголовки ответа были установлены правильно. Просто ради забавы, вставьте вышеуказанный URI в свой браузер. Изображение должно отобразиться корректно.
Вводные сведения о хуках
На этом этапе вы должны достаточно хорошо понимать основные части, составляющие API на основе Falcon. Прежде чем закончить, давайте потратим несколько минут на то, чтобы привести код в порядок и добавить обработку ошибок.
Сначала давайте проверим входящий тип данных носителя при отправке сообщения, чтобы убедиться, что это один из распространённых типов изображений. Мы реализуем это с помощью хука before.
Начните с определения списка типов данных носителя, которые будет принимать служба. Разместите эту константу в начале, сразу после операторов импорта в images.py.
ALLOWED_IMAGE_TYPES = (
'image/gif',
'image/jpeg',
'image/png',
)
Здесь идея заключается в том, чтобы принимать только изображения GIF, JPEG и PNG. Вы можете добавить другие в список, если хотите.
Далее, создадим хук, который будет выполняться перед каждым запросом на отправку сообщения. Добавьте этот метод ниже определения ALLOWED_IMAGE_TYPES:
def validate_image_type(req, resp, resource, params):
if req.content_type not in ALLOWED_IMAGE_TYPES:
msg = 'Image type not allowed. Must be PNG, JPEG, or GIF'
raise falcon.HTTPBadRequest('Bad request', msg)
Затем прикрепите хук к обработчику on_post():
@falcon.before(validate_image_type)
def on_post(self, req, resp):
# ...
Теперь перед каждым вызовом этого обработчика Falcon сначала вызовет validate_image_type(). В этой функции нет ничего особенного, кроме того, что она должна принимать четыре аргумента. Каждый хук принимает в качестве первых двух аргументов ссылку на те же объекты req и resp, которые передаются обработчикам. Аргумент resource — это экземпляр Resource, связанный с запросом. Четвёртый аргумент, по соглашению названный params, — это ссылка на словарь kwargs, который Falcon создаёт для каждого запроса. params будет содержать параметры шаблона URI маршрута и их значения, если таковые имеются.
Как видно из приведённого выше примера, вы можете использовать req для получения информации о входящем запросе. Однако вы также можете использовать resp для работы с HTTP-ответом по мере необходимости, и вы даже можете использовать хуки для ввода дополнительных значений kwargs.
def extract_project_id(req, resp, resource, params):
"""Adds `project_id` to the list of params for all responders.
Meant to be used as a `before` hook.
"""
params['project_id'] = req.get_header('X-PROJECT-ID')
Теперь вы можете представить, что такой хук должен применяться ко всем обработчикам для ресурса. На самом деле, хуки можно применять к всему ресурсу, просто отформатировав класс:
@falcon.before(extract_project_id)
class Message(object):
# ...
Аналогичная логика может быть применена глобально с помощью middleware. (См. также: falcon.middleware)
Теперь, когда вы добавили хук для проверки типа данных носителя, вы можете увидеть его в действии, попытавшись отправить что-то неподходящее:
$ http POST localhost:8000/images Content-Type:image/jpx
Вы должны получить код состояния 400 Bad Request и аккуратно структурированное тело ошибки.
Подсказка
Когда что-то идёт не так, вы обычно хотите предоставить своим пользователям некоторую информацию, чтобы помочь им устранить проблему. Исключением из этого правила является случай, когда ошибка возникает, потому что пользователь запросил что-то, к чему он не имеет доступа. В этом случае вы можете просто вернуть 404 Not Found с пустым телом, на случай, если злоумышленник пытается получить информацию, которая поможет ему взломать ваше приложение.
Ознакомьтесь с ссылками на хуки, чтобы узнать больше.
Обработка ошибок
Как правило, Falcon предполагает, что обработчики ресурсов (on_get(), on_post(), и т. д.) в основном будут делать всё правильно. Другими словами, Falcon не пытается сильно защитить код обработчиков от самих себя.
Этот подход уменьшает количество (часто) излишних проверок, которые Falcon должен был бы выполнять, что делает фреймворк более эффективным. При этом для написания высококачественного API на основе Falcon необходимо:
- Обработчики ресурсов устанавливают переменные ответа в разумные значения.
- Ненадёжные данные (т. е. данные от внешнего клиента или службы) проверяются.
- Ваш код хорошо протестирован с высоким покрытием кода.
- Ошибки предсказываются, обнаруживаются, регистрируются и обрабатываются надлежащим образом в каждом обработчике или с помощью глобальных обработчиков ошибок.
Что касается обработки ошибок, вы всегда можете напрямую установить код состояния ошибки, соответствующие заголовки ответа и тело ошибки, используя объект resp. Однако Falcon пытается немного упростить задачу, предоставляя набор классов ошибок, которые вы можете поднять, когда что-то пойдёт не так. Falcon преобразует любой экземпляр или подкласс класса falcon.HTTPError, поднятый обработчиком, хуком или компонентом middleware, в соответствующий HTTP-ответ.
Вы можете поднять экземпляр falcon.HTTPError напрямую или использовать любой из ряда предопределённых ошибок, которые предназначены для установки заголовков и тела ответа соответственно для каждого типа ошибок.
Подсказка
Falcon будет повторно поднимать ошибки, которые не наследуются от falcon.HTTPError, если вы не зарегистрировали пользовательский обработчик ошибок для этого типа.
Обработчики ошибок могут быть зарегистрированы для любого типа, включая HTTPError. Эта функция предоставляет централизованное место для регистрации и обработки исключений, поднятых обработчиками, хуками и компонентами middleware.
См. также: add_error_handler().
Давайте посмотрим быстрый пример того, как это работает. Попробуйте запросить недействительное имя изображения из вашего приложения:
$ http localhost:8000/images/voltron.png
Как видите, результат не очень приятный. Чтобы исправить это, нам нужно добавить обработку исключений. Измените класс Item следующим образом:
class Item(object):
def __init__(self, image_store):
self._image_store = image_store
def on_get(self, req, resp, name):
resp.content_type = mimetypes.guess_type(name)[0]
try:
resp.stream, resp.stream_len = self._image_store.open(name)
except IOError:
# Normally you would also log the error.
raise falcon.HTTPNotFound()
Теперь давайте попробуем этот запрос снова:
$ http localhost:8000/images/voltron.png
Дополнительная информация об обработке ошибок доступна в справочнике по обработке ошибок.
Что дальше?
Наша дружная община готова ответить на ваши вопросы и помочь вам преодолеть сложные проблемы. См. также: Получение помощи.
Как упоминалось ранее, docstrings Falcon довольно обширны, и вы можете многому научиться, просто покопавшись в модулях Falcon из Python REPL, таких как IPython или bpython.
Также не стесняйтесь просматривать исходный код Falcon на GitHub или в вашем любимом текстовом редакторе. Команда постаралась сделать код максимально простым и понятным; там, где другая документация может подвести, код, по сути, не может быть ошибочным.
Для использования в ваших проектах доступно множество дополнений, шаблонов и дополнительных пакетов Falcon. Мы перечислили некоторые из них на вики Falcon в качестве отправной точки, но вы также можете поискать дополнительные ресурсы на PyPI.
END_OF_DOCUMENT_MARKER
© 2012–2017 by Rackspace Hosting, Inc. and other contributors
Licensed under the Apache License, Version 2.0.
https://falcon.readthedocs.io/en/1.4.1/user/tutorial.html