angle-uparrow-clockwisearrow-counterclockwisearrow-down-uparrow-leftatcalendarcard-listchatcheckenvelopefolderhouseinfo-circlepencilpeoplepersonperson-fillperson-plusphoneplusquestion-circlesearchtagtrashx

Flask Удовлетворительный запрос API проверка параметров запроса с помощью схем Маршмэллоу

Создавайте отдельные схемы для параметров пути, запроса и запроса к телу и проверяйте их с помощью одной функции.

30 марта 2021
В API, Flask
post main image

Когда вы создаете RESTful API , первое, что вы делаете, это определяете коды статуса и реакции на ошибки. RFC 7807 'Детали проблемы для HTTP APIs' определяет минимальные параметры, которые вы должны вернуть. Если вы не рассмотрели этот вопрос, предлагаю сделать это. Конечно, вы часто хотите включить более подробную информацию о том, что пошло не так. APIs предназначены для разработчиков, и мы действительно хотим, чтобы им было легко понять, почему вызов не удался.

Когда вы собираете API с Flask , практически невозможно игнорировать пакет сериализации / десериализации типа Marshmallow. А вместе с пакетом APISpec легко документировать ваш API.

При вызове методов API первым делом необходимо проверить параметры запроса. Ниже я покажу вам, как я это сделал с Marshmallow.

Проверка входа с помощью Marshmallow

Написание API отличается от написания веб-приложения. Для веб-приложения с формами Flask мы используем пакет WTForms для получения и проверки параметров запроса. Для API мы используем схемы. Схемы описывают, как взаимодействовать с API и позволяют нам документировать наш API , например, с помощью Swagger.

Пакет Marshmallow не только позволяет десериализовать входные параметры, но и содержит большой набор валидаторов.

Пример класса модели: Город

В данной статье я буду использовать (SQLAlchemy) класс модели City.

class City(Base):
    __tablename__ = 'city'

    id = Column(Integer, primary_key=True)
    name = Column(String(100), server_default='')

Нам нужны следующие операции:

  • Получить список городов с разбивкой по страницам и фильтром.
  • Узнать город по документам
  • Создать город
  • Обновить город по id
  • Удалить город по идентификатору

Тогда у нас есть следующие запросы:

GET       /cities?page=1&per_page=10&search=an
GET       /cities/4
POST      /cities, city name in request body
PUT       /cities/5, city name in request body
DELETE   /cities/7

И наши методы выглядят так:

@blueprint_cities.route('', methods=['GET'])
def cities_list():
    ...

@blueprint_cities.route('/<int:city_id>', methods=['GET'])
def cities_get(city_id):
    ...

@blueprint_cities.route('', methods=['POST'])
def cities_create():
    ...

@blueprint_cities.route('/<int:city_id>', methods=['PUT'])
def cities_update(city_id):
    ...

@blueprint_cities.route('/<int:city_id>', methods=['DELETE'])
def cities_delete(city_id):
    ...

Flask и параметры запроса

Существует три типа параметров запроса:

  • Параметры пути: city_id
  • Параметры запроса: страница, per_page, поиск
  • Параметры тела: имя

В Flask мы можем получить доступ к этим параметрам с помощью объекта запроса:

  • request.view_args
  • запрос.аргументы
  • заявка.форма

Перевод на схемы Маршаллоу

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

class RequestPathParamsSchema(Schema):
    pass

class RequestQueryParamsSchema(Schema):
    pass

class RequestBodyParamsSchema(Schema):
    pass

Затем создаем схемы для этих параметров:

class CitiesRequestQueryParamsSchema(PaginationQueryParamsSchema, NameFilterQueryParamsSchema):
    pass
    
class CitiesRequestPathParamsSchema(RequestPathParamsSchema):
    city_id = fields.Int(
        description='The id of the city.',
        validate=validate.Range(min=1, max=9999),
        required=True)

class CitiesCreateRequestBodyParamsSchema(RequestBodyParamsSchema):
    name = fields.Str(
        description='The name of the city',
        validate=validate.Length(min=2, max=40),
        required=True)

class CitiesUpdateRequestBodyParamsSchema(RequestBodyParamsSchema):
    name = fields.Str(
        description='The name of the city',
        validate=validate.Length(min=2, max=40),
        required=True)

Это очень похоже на использование WTForms. Выше я ссылался на два класса PaginationQueryParamsSchema и NameFilterQueryParamsSchema. Вот они:

class PaginationQueryParamsSchema(RequestQueryParamsSchema):
    page = fields.Int(
        missing=PAGINATION_PAGE_VALUE_DEFAULT,
        description='Pagination page number, first page is 1.',
        validate=validate.Range(min=PAGINATION_PAGE_VALUE_MIN, max=PAGINATION_PAGE_VALUE_MAX),
        required=False)
    per_page = fields.Int(
        missing=PAGINATION_PER_PAGE_VALUE_DEFAULT,
        description='Pagination items per page.',
        validate=validate.Range(min=PAGINATION_PER_PAGE_VALUE_MIN, max=PAGINATION_PER_PAGE_VALUE_MAX),
        required=False)

class NameFilterQueryParamsSchema(RequestQueryParamsSchema):
    name = fields.Str(
        description='The (part of the) name to search for',
        validate=validate.Length(min=2, max=40),
        required=False)

Для десериализации и валидации объектов Маршмеллоу дает нам метод load(). Этот метод возвращает словарь имен полей, привязанных к значениям. В случае ошибки валидации поднимается ValidationError.

Например, для проверки пути запроса Url параметра city_id в методах GET, UPDATE и DELETE, мы вызываем метод load() следующим образом:

try:
    result = CitiesRequestPathParamsSchema().load(request.view_args)
except ValidationError as err:
    ...

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

{'city_id': 5}

То же самое мы можем сделать и для параметров запроса Url и параметров тела запроса.

Одна функция проверяет все схемы

С учетом вышесказанного нам все равно придется вызывать метод load() для всех схем в методе. В нашем примере только запрос метода PUT имеет две схемы, но в более реалистичном API можно ожидать больше схем. Я искал способ объединить это, чтобы избежать повторения и сократить код.

Поскольку наши схемы городов наследуются от наших базовых классов RequestPathParamsSchema, RequestQueryParamsSchema и RequestBodyParamsSchema, мы можем использовать их для выбора параметров, которые будут передаваться в метод load(). Для этого используется функция Python isinstance(). Она также возвращает True при проверке, наследуется ли объект от базового класса.

Я создал вспомогательный класс APIUtils с методом request_schemas_load(), в котором могу передать одну или несколько схем для проверки и загрузки.

class  APIUtils:
    ...

    @classmethod
    def schema_check(cls, schema=None, json_data=None, title=None):
        # load and validate
        try:
            return schema.load(data=json_data, partial=True)

        except ValidationError as err:
            raise  APIError(
                status_code=400,
                title=title,
                messages=err.messages,
                data=err.data,
                valid_data=err.valid_data,
            )

    @classmethod
    def request_schemas_load(cls, schemas):
        if not isinstance(schemas, list):
            schemas = [schemas]

        result_path, result_query, result_body = {}, {}, {}
        for schema in schemas:
            if isinstance(schema, api_spec.RequestPathParamsSchema):
                # path params
                result_path.update(cls.schema_check(
                    schema=schema,
                    json_data=request.view_args,
                    title='One or more request url path parameters did not validate'))

            if isinstance(schema, api_spec.RequestQueryParamsSchema):
                # query params
                result_query.update(cls.schema_check(
                    schema=schema, 
                    json_data=request.args,
                    title='One or more request url query parameters did not validate'))

            if isinstance(schema, api_spec.RequestBodyParamsSchema):
                # body params
                result_body.update(cls.schema_check(
                    schema=schema, 
                    json_data=request.get_json(),
                    title='One or more request body parameters did not validate'))

        return {
            'path': result_path,
            'query': result_query,
            'body': result_body,
        }

    @classmethod
    def get_by_id_or_404(cls, res, res_id, res_name, res_id_name):
        obj = app_db.session.query(res).get(res_id)
        if obj is  None:
            raise  APIError(
                status_code=404,
                title='The requested resource could not be found',
                messages={
                    res_name: [
                        'Not found',
                    ]
                },
                data={
                    res_id_name: res_id,
                },
            )
        return obj

В коде выше мы выполняем итерацию по схемам, загружаем и проверяем их по очереди. В результате получаем словарь результатов Marshmallow schema.load(). Предполагается, что внутри типа запроса (путь, запрос, тело) у нас нет различных параметров с одинаковым именем.

Заголовок - RFC 7807. Это сообщение для всех видов ошибок в параметрах запроса.

В случае ошибки возникает исключение. Данные сообщений ValidationError также включаются в ответ
и содержат подробную информацию об ошибке:

  • сообщения об ошибках
  • неверные данные
  • неверные_данные

Также я добавляю метод get_by_id_or_404(), чтобы показать ответ в случае, если ресурс не найден.

Код метода обновления города

С помощью метода помощника APIUtils request_schemas_load() теперь мы можем написать код для метода обновления City:

@blueprint_cities.route('/<int:city_id>', methods=['PUT'])
def cities_update(city_id):
    result =  APIUtils.request_schemas_load([
        CitiesRequestPathParamsSchema(),
        CitiesUpdateRequestBodyParamsSchema()])

    city =  APIUtils.get_by_id_or_404(City, city_id, 'City', 'city_id')

    for k, v in result['body'].items():
        setattr(city, k, v)
    app_db.session.commit()

    return jsonify({
        'data': CitiesResponseSchema().dump(city)
    }), 200

В приведенном выше методе я использую метод APIUtils.get_by_id_or_404() для получения одного города по id. Если город не существует, то поднимается APIError. Когда ошибок нет и город найден, я обновляю параметры тела со значениями в запросе.

Некоторые ответы на ошибки API

Я использую Curl , чтобы показать некоторые результаты. Я уже загрузил базу данных с некоторыми городами.

Сначала мы получаем два города:

curl -i "http://127.0.0.1:5000/api/v1/cities?page=1&per_page=2"

Ответ:

HTTP/1.0 200 OK
Content-Type:  application/json
Content-Length: 247
Server: Werkzeug/1.0.1  Python/3.8.5
Date: Mon, 29 Mar 2021 15:51:39 GMT

{
  "data": [
    {
      "id": 1, 
      "name": "Beijing"
    }, 
    {
      "id": 2, 
      "name": "Berlin"
    }
  ], 
  "meta": {
    "count": 2, 
    "limit": 2, 
    "offset": 0, 
    "page": 1, 
    "per_page": 2, 
    "total": 11
  }
}

Теперь давайте обновим название города Берлин в Гамбург, но с ошибками.

Пример 1: плохой параметр пути, city_id = 0.

curl -i -X PUT  -H "Content-Type:  application/json" -d '{"name":"Hamburg"}'  http://127.0.0.1:5000/api/cities/0

Ответ:

HTTP/1.0 400 BAD REQUEST
Content-Type:  application/json
Content-Length: 247
Server: Werkzeug/1.0.1  Python/3.8.5
Date: Mon, 29 Mar 2021 16:13:22 GMT

{
  "status": 400, 
  "title": "One or more request url path parameters did not validate", 
  "messages": {
    "city_id": [
      "Must be greater than or equal to 1 and less than or equal to 9999."
    ]
  }, 
  "data": {
    "city_id": 0
  }
}

Пример 2: плохой параметр тела, имя = H

curl -i -X PUT  -H "Content-Type:  application/json" -d '{"name":"H"}'  http://127.0.0.1:5000/api/v1/cities/2

Ответ:

HTTP/1.0 400 BAD REQUEST
Content-Type:  application/json
Content-Length: 205
Server: Werkzeug/1.0.1  Python/3.8.5
Date: Mon, 29 Mar 2021 16:15:46 GMT

{
  "status": 400, 
  "title": "One or more request body parameters did not validate", 
  "messages": {
    "name": [
      "Length must be between 2 and 40."
    ]
  }, 
  "data": {
    "name": "H"
  }
}

Пример 3: добавить неизвестный параметр в тело: что-то = ничего.

curl -i -X PUT  -H "Content-Type:  application/json" -d '{"name":"Hamburg", "something": "nothing"}'  http://127.0.0.1:5000/api/v1/cities/2

Ответ:

HTTP/1.0 400 BAD REQUEST
Content-Type:  application/json
Content-Length: 273
Server: Werkzeug/1.0.1  Python/3.8.5
Date: Mon, 29 Mar 2021 16:21:40 GMT

{
  "status": 400,
  "title": "One or more request body parameters did not validate", 
  "messages": {
    "something": [
      "Unknown field."
    ]
  }, 
  "data": {
    "name": "Hamburg", 
    "something": "nothing"
  },
  "valid_data": {
    "name": "Hamburg"
  }
}

Пример 4: выберите неизвестный город, чтобы показать 404.

curl -i -X PUT  -H "Content-Type:  application/json" -d '{"name":"Hamburg"}'  http://127.0.0.1:5000/api/v1/cities/20

Ответ:

HTTP/1.0 404 NOT FOUND
Content-Type:  application/json
Content-Length: 173
Server: Werkzeug/1.0.1  Python/3.8.5
Date: Tue, 30 Mar 2021 16:26:38 GMT

{
  "status": 404, 
  "title": "The requested resource could not be found", 
  "messages": {
    "City": [
      "Not found"
    ]
  }, 
  "data": {
    "city_id": 20
  }
}

Использование предварительной обработки схемы и пост-обработки

При использовании значений по умолчанию (необязательных) могут возникнуть проблемы. К счастью, Marshmallow достаточно гибкий, чтобы вы могли делать свою собственную обработку. Вы можете расширить свою схему с помощью методов препроцессинга и постобработки pre_load() и post_load().

Например, я расширил PaginationQueryParamsSchema, чтобы правильно обрабатывать значения пагинации по умолчанию, добавив метод pre_load():

class PaginationQueryParamsSchema(RequestQueryParamsSchema):
    def pre_load_(self, data, many, **kwargs):
		# your processing here
		...
        return data

    page = fields.Int(
		...

    per_page = fields.Int(
		...

Резюме

Marshmallow не только упрощает использование схем при сбросе объектов, но и включает в себя обширный набор функций проверки схем, которые мы можем использовать для проверки параметров запроса. В этой заметке я показал способ обработки параметров запроса RESTful API. Конечно, есть и другие способы. С помощью объекта запроса Flask мы можем передать request.view_args для параметров пути Url, request.args для параметров запроса Url и request.form для параметров тела, в метод schema.load().

Ссылки / кредиты

marshmallow: simplified object serialization
https://marshmallow.readthedocs.io/en/stable/

Problem Details for HTTP APIs
https://tools.ietf.org/html/rfc7807

Подробнее

API Flask

Оставить комментарий

Комментируйте анонимно или войдите в систему, чтобы прокомментировать.

Комментарии (1)

Оставьте ответ

Ответьте анонимно или войдите в систему, чтобы ответить.

avatar

yo please change your font colors. its horrible to read the text/code.