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

Documenteren van een Flask RESTful API met OpenAPI (Swagger) met gebruikmaking van APISpec

Maak uw eigen view class en utiliteitsfuncties om code te verminderen en fouten te vermijden.

22 april 2021
In API, Flask
post main image
https://unsplash.com/@sincerelymedia

Als je een API maakt, wil je die documenteren en tegenwoordig ligt het voor de hand om daarvoor OpenAPI te gebruiken. Ik gebruik nu al het pakket Marshmallow . Dezelfde mensen hebben ook het pakket APIspec ontwikkeld, met een Flask plugin in een extra pakket apispec-webframeworks. Om de OpenAPI documentatie te presenteren gebruik ik het package flask-swagger-ui en het package flask-jwt-extended wordt gebruikt om de eindpunten te beveiligen.

Hieronder laat ik een manier zien om je eigen API view class te maken samen met API view class utility functies om code te verminderen en fouten te vermijden.

Snel aan de slag met een voorbeeld

Er is zeer veel informatie op het internet te vinden over het bouwen van een API met Flask en Swagger. Ik was blij een leuk klein voorbeeld te vinden om mee te beginnen, 'REST API Development with Flask', zie links hieronder. Wat hier gebeurt is:

  • Stel je basis OpenAPI spec op
  • Documenteer uw eindpunten met behulp van docstrings
  • Scan alle view functies en voeg ze toe aan uw spec

Genereren van OpenAPI documentatie door scannen van docstrings

Is het een goed idee om docstrings te gebruiken om uw OpenAPI document te genereren? Docstrings zijn gebonden aan een functie of methode. Ik heb hier gemengde gevoelens over. Wanneer je veel functies of methoden in verschillende bestanden hebt, kan het aanbrengen van wijzigingen in je docstrings erg tijdrovend zijn.

Het alternatief zou zijn om een standalone OpenAPI document te maken. Hier staat alle informatie op één plaats, meestal in één document. U maakt eerst de specificaties en gebruikt dit document om uw functies en methoden te maken. Met de juiste software kun je waarschijnlijk grote delen van de code automatisch genereren. Dit maakt het ook erg gemakkelijk om wijzigingen aan te brengen.

Hoewel de laatste manier de voorkeur heeft, gebruik ik hier de scan-methode docstring .

Probleem: docstring duplicatie

Meestal hebben we een aantal modelklassen die min of meer hetzelfde worden behandeld. Ik heb een demo-applicatie gemaakt die uit de volgende klassen bestaat:

  • Friend, kan nul of één stad hebben, en nul of meer hobby's
  • City
  • Hobby
  • User

De OpenAPI docstrings voor deze klassen zijn min of meer gelijk. De bijwerkingsmethoden voor de City en de Hobby zijn bijvoorbeeld

class CitiesAPI(...):

    ...

     @jwt_required()
    def update_by_id(self, city_id):
        '''
        ---
        put:
          tags:
          -  City
          summary: Update city by id
          description: Update city by id
          parameters:
          - in: path
            schema: CitiesRequestPathParamsSchema
          requestBody:
            required: true
            content:
                 application/json:
                    schema: CitiesUpdateRequestBodyParamsSchema
          responses:
            400:
              description: One or more request parameters did not validate
            404:
              description:  City  not found
            200:
              description:  City  updated
              content:
                 application/json:
                  schema:
                    type: object
                    properties:
                      data: CitiesResponseSchema
        '''
        <code of the method>

en

class HobbiesAPI(...):

    ...

     @jwt_required()
    def update_by_id(self, hobby_id):
        """
        ---
        put:
          tags:
          -  Hobby
          summary: Update hobby by id
          description: Update hobby by id
          parameters:
          - in: path
            schema: HobbiesRequestPathParamsSchema
          requestBody:
            required: true
            content:
                 application/json:
                    schema: HobbiesUpdateRequestBodyParamsSchema
          responses:
            400:
              description: One or more request parameters did not validate
            404:
              description:  Hobby  not found
            200:
              description:  Hobby  updated
              content:
                 application/json:
                  schema:
                    type: object
                    properties:
                      data: HobbiesResponseSchema
        """
        <code of the method>

Merk op dat deze methoden versierd zijn met de @jwt_required() decorator. Ik neem aan dat u dit ook wilt doen (op een bepaald moment).

Om doublures te voorkomen heb ik een basis APIModelViewClass gemaakt en enkele utiliteitsfuncties in APIModelViewClassUtils.

Waarom u Flask niet kunt gebruiken MethodView

Ik heb gekeken naar Flask MethodView omdat in de documentatie staat dat het nuttig is bij het bouwen van APIs. Deze klasse heeft methoden zoals get, post die worden aangeroepen afhankelijk van de request methode. Dit betekent dat de GET request methode de get methode aanroept van de MethodView klasse.

Voor een City moet mijn API de volgende functies hebben:

list            GET    /cities
create          POST   /cities
get_by_id       GET    /cities/<int:city_id>
update_by_id    GET    /cities/<int:city_id>
delete_by_id    GET    /cities/<int:city_id>

We gebruiken de GET methode zowel voor een lijst van steden als voor een enkele stad. De Flask documentatie suggereert dat we binnen de get methode beslissen welke functie we moeten uitvoeren:

    def get(self, city_id):
        if city_id is  None:
            # return a list of citys
            pass
        else:
            # expose a single city
            pass

Maar nu kunnen we geen docstring meer toevoegen ... :-(

Een manier om hier uit te komen is om twee MethodView klassen te gebruiken. Een voor de list functie en create functies en een voor de 'by_id' functies. Maar zelfs met deze oplossing zijn er problemen. Het blijkt dat bij gebruik van Blueprints niet alle functies door apispec worden toegevoegd, zie ook 'Only single path added for Flask API MethodViews #14'.

Ik weet niet zeker wat de status hiervan is op dit moment, dus heb ik besloten om mijn eigen 'MethodView' te maken, genaamd APIModelViewClass, en mijn eigen utilities class APIModelViewClassUtils.

Een decorator gebruiken om de docstrings toe te voegen

De voor de hand liggende keuze is om een decorator te gebruiken om de OpenAPI docstring aan de API methoden toe te voegen. Zodra ze daar zijn, kunnen ze worden gescand en geladen door apispec.

Veel van mijn API methods zijn al gedecoreerd met de @jwt_required() decorator, wat betekent dat we deze decorators moeten combineren.

Ik heb een klasse APIModelViewClass aangemaakt, die op het ogenblik slechts een stub is, en een klasse APIModelViewClassUtils
die utiliteitsfuncties bevat, die op het ogenblik drie methoden bevatten:

  • decorate_with_docstring()
  • endpoint_decorator()
  • endpoints_register()

Later kan ik deze klassen samenvoegen. Hieronder staat een deel van de code.

class  APIModelViewClassUtils:

    def decorate_with_docstring(fn, op, params):
        tags = params.get('tags')
        item_name_cap, item_name, item_name_plural_cap, item_name_plural = params.get('item_names')
        in_path_schema = params.get('in_path_schema')
        in_query_schema = params.get('in_query_schema')
        in_body_schema_create = params.get('in_body_schema_create')
        in_body_schema_update = params.get('in_body_schema_update')
        out_data_schema = params.get('out_data_schema')

        '''
        if op == 'list':
            fn.__doc__ = """
            ...

        elif op == 'update_by_id':
            fn.__doc__ = """
            ---
            put:
              tags:
              - {tags}
              summary: Update {item_name} by id
              description: Update {item_name} by id
              parameters:
              - in: path
                schema: {in_path_schema}
              requestBody:
                required: true
                content:
                     application/json:
                        schema: {in_body_schema_update}
              responses:
                400:
                  description: One or more request parameters did not validate
                404:
                  description: {item_name_cap} not found
                200:
                  description: {item_name_cap} updated
                  content:
                     application/json:
                      schema:
                        type: object
                        properties:
                          data: {out_data_schema}
                    """.format(
                        tags=tags,
                        item_name=item_name,
                        item_name_cap=item_name_cap,
                        in_path_schema=in_path_schema,
                        in_body_schema_update=in_body_schema_update,
                        out_data_schema=out_data_schema)
        '''

    @classmethod
    def endpoint_decorator(cls, params):
        def wrapper(fn):

            op = fn.__name__

            # decorate endpoint
            cls.decorate_with_docstring(fn, op, params)

            @wraps(fn)
            def  decorator(*args, **kwargs):

                # verify_jwt_in_request works like @jwt_required
                verify_jwt_in_request()

                return fn(*args, **kwargs)

        return wrapper

    @staticmethod
    def endpoints_register(bp, api_model_view_class, url, id_attr_name, id_type='int'):
        model_obj = api_model_view_class()
        
        '''
        # update_by_id
        if hasattr(model_obj, 'update_by_id'):
            bp.add_url_rule(url_by_id, view_func=model_obj.update_by_id, methods=['PUT'])
        '''


class  APIModelViewClass():
    pass

In decorate_with_docstring() versieren we de methode met een opgemaakte docstring. In endpoint_decorator() versieren we eerst de methode en controleren dan of toegang wordt verleend met verify_jwt_in_request(). In endpoints_register registreren we de door deze klasse gebruikte endpoints.

Nu ziet (een deel van) de CityAPI er als volgt uit:

class CitiesAPI(APIModelViewClass):
    params = {
        'tags': 'City',
        'item_names': ['City', 'city', 'Cities', 'cities'],
        'in_query_schema': 'CitiesRequestQueryParamsSchema',
        'in_path_schema': 'CitiesRequestPathParamsSchema',
        'in_body_schema_create': 'CitiesCreateRequestBodyParamsSchema',
        'in_body_schema_update': 'CitiesUpdateRequestBodyParamsSchema',
        'out_data_schema': 'CitiesResponseSchema',
    }

    '''

    @APIModelViewClassUtils.endpoint_decorator(params)
    def update_by_id(self, 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

We definiëren de parameters voor de decorator buiten de __init__() functie. Het zijn constanten en nemen niet veel ruimte in. Het bovenstaande kan geoptimaliseerd en beter gedaan worden, maar het is in ieder geval gemakkelijk te lezen en te onderhouden.

Open API beveiliging

Hierboven heb ik laten zien hoe ik flask_jwt_extended heb gebruikt om eindpunten te beveiligen. Nu moeten we OpenAPI vertellen wat we gedaan hebben. Ik kies ervoor om standaard alle endpoints te beschermen in OpenAPI en later deze bescherming te verwijderen in de docstrings van onbeschermde methoden.

# create  apispec
spec =  APISpec(
    title=title,
    version=spec_version,
    openapi_version=openapi_version,
    plugins=[FlaskPlugin(),  MarshmallowPlugin()],
    **settings
)

security_scheme_bearer = {
    "type": "http",
    "description": "Enter JWT Bearer token",
    "scheme": "bearer",
    "bearerFormat": "JWT"
}

security_scheme_basic_auth = {
    "type": "http",
    "scheme": "basic"
}

spec.components.security_scheme("BearerAuth", security_scheme_bearer)
spec.components.security_scheme("BasicAuth", security_scheme_basic_auth)

In de docstrings van methoden die beschermd zijn doen we niets. In de docstrings van methodes die niet beschermd zijn voeg ik toe:

    security: []

Testing

Het testen van een API die gedocumenteerd is met OpenAPI is ... moeilijk. We hebben de eigenlijke interface en de gedocumenteerde (OpenAPI) interface. We zouden op zijn minst een testsuite moeten schrijven voor de gedocumenteerde interface. Ik heb dit nog niet gedaan, maar in plaats daarvan Pytest en het requests pakket gebruikt om functionele testen van de API te doen. Ik ben begonnen met het doen van enkele testen met postman.

Samenvatting

Het coderen van mijn API met behulp van het Marshmallow pakket was een leuke ervaring. Het versturen van consistente (fout)berichten was een uitdaging omdat elk pakket zijn eigen standaard formaat heeft. En het documenteren van mijn API met OpenAPI (Swagger) met Python was als een nachtmerrie. Flask MethodView en Blueprints niet werkend zoals verwacht met apispec, onduidelijke documentatie in apispec-webframeworks.

Ik moest nieuwe dingen leren, zoals hoe ik OpenAPI regels met de hand moest schrijven. Ik ben tevreden met het eindresultaat, en weet precies hoe het werkt, wat betekent dat het gemakkelijk is om te verbeteren en veranderingen aan te brengen.

Er moeten betere manieren zijn om dit te doen. Ik heb gelezen over FastAPI, dit is een Python framework gewijd aan het bouwen van snelle APIs. Het heeft veel betere prestaties vergeleken met Flask en lijkt veel dingen out-of-the-box te ondersteunen. Misschien zal ik dit de volgende keer proberen, of deze API omzetten naar FastAPI. Maar het heeft ook 540 Open issues op Github ... kan goed zijn, kan slecht zijn.

Links / credits

apispec
https://apispec.readthedocs.io/en/latest/

Authentication and Authorization (OAS3)
https://swagger.io/docs/specification/authentication/

FastAPI - The Good, the bad and the ugly
https://dev.to/fuadrafid/fastapi-the-good-the-bad-and-the-ugly-20ob

Flask-JWT-Extended’s Documentation
https://flask-jwt-extended.readthedocs.io/en/stable/

Only single path added for Flask API MethodViews #14
https://github.com/marshmallow-code/apispec-webframeworks/issues/14

Pluggable Views
https://flask.palletsprojects.com/en/1.1.x/views/

Postman | The Collaboration Platform for API Development
https://www.postman.com

REST API Development with Flask
https://www.datascienceblog.net/post/programming/flask-api-development/

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

Why can't a MethodView be used? I beleive there is an error in your assessment:
list GET /cities
create POST /cities
get_by_id GET /cities/<int:city_id>
update_by_id PUT /cities/<int:city_id>
delete_by_id DELETE /cities/<int:city_id>
You hadnt made use of DELETE and PUT - so docstrings can be preserved!