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

Flask RESTful API verzoekparametervalidatie met Marshmallow-schema's

Maak aparte schema's voor path, query en body request parameters en valideer ze met een enkele functie.

30 maart 2021
In API, Flask
post main image

Wanneer u een RESTful API bouwt, is het eerste wat u doet de statuscodes en de foutreacties definiëren. RFC 7807 "Problem Details for HTTP APIs" specificeert de minimale parameters die je moet retourneren. Als je hier nog niet naar gekeken hebt, stel ik voor dat je dat doet. Natuurlijk wil je vaak meer details toevoegen over wat er mis ging. APIs zijn voor ontwikkelaars en we willen het voor hen wel gemakkelijk maken om te begrijpen waarom een aanroep mislukte.

Wanneer je een API bouwt met Flask is het bijna onmogelijk om een serialisatie / deserialisatie pakket zoals Marshmallow te negeren. En samen met het pakket APISpec is het eenvoudig om uw API te documenteren.

Wanneer je je API methods aanroept is het eerste wat je moet doen het valideren van de request parameters. Hieronder laat ik zien hoe ik dit met Marshmallow heb gedaan.

Invoer validatie met Marshmallow

Het schrijven van een API is anders dan het schrijven van een web applicatie. Voor een Flask web applicatie met formulieren, gebruiken we het WTForms pakket om de request parameters op te halen en te valideren. Voor een API gebruiken we schema's. Schema's beschrijven hoe te communiceren met een API en stellen ons in staat om onze API te documenteren met bijvoorbeeld Swagger.

Het Marshmallow pakket laat ons niet alleen input parameters deserialiseren maar bevat ook een grote set van validators.

Een voorbeeld model klasse: City

In dit artikel zal ik gebruik maken van de (SQLAlchemy) model class City.

class City(Base):
    __tablename__ = 'city'

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

We willen de volgende operaties:

  • Een lijst van steden opvragen, met paginering en filter
  • Verkrijg een stad op id
  • Aanmaken van een stad
  • Update een stad op id
  • Een stad op id verwijderen

Dan hebben we de volgende verzoeken:

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

En onze methodes zien er als volgt uit:

@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 en verzoekparameters

Er zijn drie soorten verzoekparameters:

  • Path parameters: city_id
  • Query parameters: pagina, per_pagina, zoeken
  • Body parameters: naam

In Flask kunnen we toegang krijgen tot deze parameters met behulp van het request object:

  • request.view_args
  • verzoek.args
  • request.form

Vertaling naar Marshmallow schema's

We beginnen met het maken van basis klassen, schema's, voor de drie soorten verzoek parameters:

class RequestPathParamsSchema(Schema):
    pass

class RequestQueryParamsSchema(Schema):
    pass

class RequestBodyParamsSchema(Schema):
    pass

Vervolgens maken we schema's voor deze parameters:

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)

Dit lijkt erg veel op wanneer we WTForms gebruiken. Hierboven verwees ik naar twee klassen PaginationQueryParamsSchema en NameFilterQueryParamsSchema. Hier zijn ze:

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)

Voor het deserialiseren en valideren van objecten geeft Marshmallow ons de methode load(). Deze methode retourneert een woordenboek van veldnamen gemapt naar waarden. Als er een validatiefout optreedt, wordt er een ValidationError opgewekt.

Om bijvoorbeeld de request Url path parameter city_id te controleren in de GET, UPDATE en DELETE methods, roepen we load() als volgt aan:

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

Als er geen fouten worden gevonden, ziet het resultaat er zo uit:

{'city_id': 5}

We kunnen hetzelfde doen voor de request Url query parameters en request body parameters.

Een enkele functie die alle schema's controleert

Met het bovenstaande moeten we nog steeds de load() methode aanroepen voor alle schema's in een methode. In ons voorbeeld heeft alleen de request PUT methode twee schema's, maar in een meer realistische API kun je meer schema's verwachten. Ik was op zoek naar een manier om dit te combineren om herhaling te voorkomen en code te verminderen.

Omdat onze Cities schema's geërfd zijn van onze basisklassen RequestPathParamsSchema, RequestQueryParamsSchema en RequestBodyParamsSchema, kunnen we deze gebruiken om de parameters te selecteren die doorgegeven moeten worden aan de load() methode. Hiervoor wordt de functie Python isinstance() gebruikt. Deze functie geeft ook True terug wanneer gecontroleerd wordt of een object geërfd is van een basisklasse.

Ik heb een helper klasse APIUtils gemaakt met een methode request_schemas_load() waar ik een of meer schema's kan doorgeven die gevalideerd en geladen moeten worden.

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

In de bovenstaande code itereren we door de schema's en laden en valideren we ze één voor één. Het resultaat is een woordenboek van de Marshmallow schema.load() result dictionaries. De aanname is dat we geen verschillende parameters met dezelfde naam hebben binnen een request type (path, query, body).

Titel is de RFC 7807 titel. Dit is een bericht voor alle soorten van request parameter fouten.

In geval van een fout wordt een exceptie opgewekt. De gegevens van de ValidationError-berichten worden ook opgenomen in het antwoord
en bevatten details over de fout:

  • err.messages
  • err.data
  • err.valid_data

Ik voeg ook de methode get_by_id_or_404() toe om de respons te tonen voor het geval een bron niet gevonden werd.

Code voor de City update methode

Met APIUtils helper methode request_schemas_load() kunnen we nu de code schrijven voor de City update methode:

@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

In het bovenstaande gebruik ik de methode APIUtils.get_by_id_of_404() om een enkele Stad op id te krijgen. Als de stad niet bestaat, wordt een APIError opgewekt. Als er geen fouten zijn, en de stad is gevonden, update ik de body parameters met de waarden in het verzoek.

Enkele API foutreacties

Ik gebruik Curl om enkele resultaten te laten zien. Ik heb de database al geladen met enkele steden.

Eerst krijgen we twee steden:

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

Het antwoord:

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
  }
}

Laten we nu de stadsnaam Berlijn updaten naar Hamburg, maar met fouten.

Voorbeeld 1: slechte pad parameter, city_id = 0

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

Het antwoord:

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
  }
}

Voorbeeld 2: slechte body parameter, naam = H

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

Het antwoord:

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"
  }
}

Voorbeeld 3: voeg onbekende parameter toe aan lichaam: iets = niets

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

Het antwoord:

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"
  }
}

Voorbeeld 4: selecteer onbekende stad, om 404 te tonen

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

Het antwoord:

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
  }
}

Schema pre-processing en post-processing gebruiken

U kunt tegen problemen aanlopen bij het gebruik van standaard (optionele) waarden. Gelukkig is Marshmallow flexibel genoeg om u uw eigen verwerking te laten doen. Je kunt je schema uitbreiden met de pre-processing en post-processing methoden pre_load() en post_load().

Ik heb bijvoorbeeld het PaginationQueryParamsSchema uitgebreid om de pagination defaults goed af te handelen door de pre_load() methode toe te voegen:

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

    page = fields.Int(
		...

    per_page = fields.Int(
		...

Samenvatting

Marshmallow maakt het niet alleen makkelijk om schema's te gebruiken bij het dumpen van objecten, maar bevat ook een uitgebreide set schema validatie functies die we kunnen gebruiken om request parameters te controleren. In deze post heb ik een manier laten zien om de request parameters van een RESTful API te verwerken. Natuurlijk zijn er nog andere manieren. Met behulp van het Flask request object kunnen we request.view_args voor de Url path parameters, request.args voor de Url query parameters en request.form voor de body parameters, doorgeven aan de schema.load() methode.

Links / credits

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

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

Lees meer

API Flask

Laat een reactie achter

Reageer anoniem of log in om commentaar te geven.

Opmerkingen (1)

Laat een antwoord achter

Antwoord anoniem of log in om te antwoorden.

avatar

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