Listings Bindick/Flask

Listing 1: Einfacher Flask-Webservice (hello.py)
from flask import Flask

app = Flask("my hello app")

@app.route("/hello")
def hello():
    return "Hello World!"

app.run()

------

Listing 2: Haupteinstiegspunkt der API (main.py)
from flask import Flask
from flask_restplus import Api

app = Flask("boardgame collection")
api = Api(app,
          version='0.1',
          title='boardgame collection',
          description='A web service to manage your boardgame collection',
          doc='swagger-ui')

if __name__ == '__main__':
    app.run(debug=True, port=5000)

------

Listing 3: Struktur und Aufbau der Anwendung
├── resources
│      └── __init__.py
│      └── boardgames.py
├── models
│      └── __init__.py
│      └── boardgame.py
│      └── boardgame-expansion.py
├── tests
│      └── __init__.py
│      └── test_boardgames_api.py
├── environments.py
└── main.py

------

Listing 4: Verschiedene Zielumgebungen konfigurieren (environments.py)
import os

class DevelopmentConfig:
    port = 5000
    debug = True
    log_path = "boardgames.log"
    documentation_path = "/swagger-ui"
    ...

class ProductionConfig:
    port = 8000
    debug = False
    log_path = "boardgames.log"
    documentation_path = None
    ...

configurations = {
    "dev":  DevelopmentConfig,
    "prod": ProductionConfig }

environment = os.environ.get("BG_CONFIG", "dev")
config = configurations[environment]

------

Listing 5: Haupteinstiegspunkt der API um Konfiguration aus environments ergänzt (main.py)
from flask import Flask
from flask_restplus import Api
from environments import config
import logging

app = Flask("boardgame collection")
api = Api(app,
          ...
          doc=config.documentation_path)

if __name__ == '__main__':
    logging.basicConfig(filename=config.log_path, level=logging.DEBUG)
    logging.info("start boardgame collection service ...")

    app.run(debug=config.debug, port=config.port)

------

Listing 6: Boardgame-Ressource (resources/boardgames.py)
from flask_restplus import Namespace, Resource
from flask import abort

bg_collection = {} # boardgames stored in memory
api = Namespace('boardgames', description='boardgame related operations')

@api.route('/<id>')
class Boardgame(Resource):
    def get(self, id):
      '''Gets a boardgame by id'''
      if id in bg_collection:
        return bg_collection[id], HTTPStatus.OK
      else:
          abort(HTTPStatus.NOT_FOUND, 'Boardgame {0} not found.'.format(id))

    def delete(self, id):
      '''Removes a boardgame from the collection'''
      if id in bg_collection:
        del bg_collection[id]
        return '', HTTPStatus.NO_CONTENT
      else:
        abort(HTTPStatus.NOT_FOUND, 'Boardgame {0} not found.'.format(id))

    def put(self, id):
      '''Updates a boardgame'''
      boardgame = request.json
      if id in bg_collection:
        bg_collection[id] = boardgame
        return boardgame, HTTPStatus.OK
      else:
        abort(HTTPStatus.NOT_FOUND, 'Boardgame {0} not found.'.format(id))

@api.route('')
class BoardgameList(Resource):
    def get(self):
      '''Lists all boardgames in collection'''
      bg_collection_list = list(bg_collection.values())
      return bg_collection_list, HTTPStatus.OK

    def post(self):
      '''Adds a boardgame to collection'''
      boardgame = request.json
      bg_collection[boardgame['id']] = boardgame
      return boardgame, HTTPStatus.CREATED

------

Listing 7: Namespace boardgame registrieren (main.py)
from flask import Flask
from flask_restplus import Api
from app.resources.boardgames import api as boardgames_api_namespace

app = Flask("boardgame collection")
api = Api(...)

api.add_namespace(boardgames_api_namespace)
...

------

Listing 8: Brettspiel erstellen und Sammlung über curl ausgeben
$ curl -X POST "http://localhost:5000/boardgames" -d "{ "id": "1",  
"name": "Wingspan", "designer": "Elizabeth Hargrave", "playing_time": "60 Min", 
"rating": 8.1,  "expansions": [{"name": "European Expansion", "rating": "8.5"}]}"

$ curl -X GET "http://localhost:5000/boardgames"
[{
    "id": "1",
    "name": "Wingspan",
    "designer": "Elizabeth Hargrave",
    "playing_time": "60 Min",
    "rating": 8.1,
    "expansions": 
      [{
        "name": "European Expansion",
        "rating": 8.5
      }]
}]

------

Listing 9: Boardgame-Ressource um Dokumentation erweitert (resources/boardgames.py)
...
@api.route('/<id>')
@api.param('id', 'The boardgame identifier.')
@api.response(404, 'Boardgame not found.')
class Boardgame(Resource):
    ...
    @api.response(204, 'Boardgame successfully deleted.')
    def delete(self, id):
      ...

    @api.response(200, 'Boardgame successfully updated.')
    def put(self, id):
      ...

@api.route('')
class BoardgameList(Resource):
    @api.response(200, 'All boardgames successfully fetched!')
    def get(self):
      ...
    @api.response(201, 'Boardgame successfully created.')
    def post(self):
      ...

------

Listing 10: Boardgame-Modell (model/boardgame.py)
from flask_restplus import fields
from models.expansion import create_expansion_model

def create_boardgame_model(api):
    boardgame_model = api.model('Boardgame', {
      'id': fields.String(description='unique boardgame identifier',
                                    required=True),
      'name': fields.String(description='boardgame name',
                                           min_length=3,
                                           max_length=128,
                                           required=True),
      'designer': fields.String(description='boardgame designer',
                                                required=True),
      'playing_time': fields.String(description='aprox. playing time',
                                                        enum=["15 Min", "30 Min", "60 Min"],
                                                        required=True),
      'rating': fields.Float(description='rating [0 to 10]',
                                          min=0.0,
                                          max=10.0,
                                          required=False),
      'expansions': fields.List(fields.Nested(create_expansion_model(api),
                                                                         description='list of expansions',
                                                                         required=False))
    })

    return boardgame_model

------

Listing 11: Expansion-Modell (model/expansion.py)
from flask_restplus import fields

def create_expansion_model(api):
    expansion_model = api.model('Expansion', {
      'name': fields.String(description='boardgame expansion name',
                                           min_length=3,
                                           max_length=128,
                                           required=True),
      'rating': fields.Float(description='rating [0 to 10]',
                                          min=0.0,
                                          max=10.0,
                                          required=False)
    })
    return expansion_model

------

Listing 12: Boardgame-Ressource um Modelle ergänzt (resources/boardgames.py)
...
from models.boardgame import create_boardgame_model

bg_collection = {} # boardgames stored in memory
api = Namespace('boardgames', description='boardgame related operations')

boardgame = create_boardgame_model(api)

@api.route('/<id>')
@api.param('id', 'The boardgame identifier.')
@api.response(404, 'Boardgame not found.')
class Boardgame(Resource):
    @api.marshal_with(boardgame)
    def get(self, id):
      ...

    @api.response(200, 'Boardgame successfully updated.')
    @api.expect(boardgame, validate=True)
    def put(self, id):
      ...


@api.route('')
class BoardgameList(Resource):
    @api.response(200, 'All boardgames successfully fetched!')
    @api.marshal_list_with(boardgame)
    def get(self):
      ...

    @api.response(201, 'Boardgame successfully created.')
    @api.expect(boardgame, validate=True)
    def post(self):
      ...

------

Listing 13: Fehlermeldung aufgrund fehlgeschlagener Inputvalidierung für die post-Route
$ curl -X POST "http://localhost:5000/boardgames" -d "{ "id": "2", "rating": 11.0
"name": "King of Tokyo", "playing_time": "30 Min", "expansions": []}"

400 BAD REQUEST
{
    "errors": {
      "designer": "'designer' is a required property",
      "rating": "11.0 is greater than the maximum of 10.0"
    },
    "message": "Input payload validation failed"
}

------

Listing 14: Ressource BoardgameList um URL-Parameter für Pagination und Sortierung erweitert (resources/boardgames.py)
@api.route('')
class BoardgameList(Resource):
    @api.param('offset', 
                           'The offset of the first boardgame in the list to return.',  
                            type=int, 
                           default=0)
    @api.param('limit', 
                           'The maximum number of boardgames to return.', 
                           type=int, 
                          default=100)
    @api.param('sort_by', 
                           'Sort the returned boardgames by key.', 
                            default="id", 
                            enum=["id", "name", "designer", "rating"])
    @api.param('sort_order', 
                           'Ascending or descending sort order.', 
                           default="asc", enum=["asc", "desc"])
    @api.response(200, 'All boardgames successfully fetched!')
    @api.marshal_list_with(boardgame)
    def get(self):
        '''Lists all boardgames in collection'''
        offset = request.args.get('offset')
        ...

------

Listing 15: Boardgame-API testen (tests/test_boardgames_api.py)
import unittest
from main import app

class BoardgamesApiTest(unittest.TestCase):
    def setUp(self):
      # 1. ARRANGE
      self.app = app.test_client()
      # initialize app with first boardgame       
      self.app.post('/boardgames', json={
          "name": "Wingspan",
          "designer": "Elizabeth Hargrave",
          "playing_time": "60 Min",
          "rating": 8.1,
          "id": "1",
          "expansions": [{"name": "European Expansion", "rating": 8.5}]
      })
    
    def test_get_boardgame_by_id(self):
      # 2. ACT
      response = self.app.get('/boardgames/1')
      # 3. ASSERT
      self.assertEqual('200 OK', response.status)
      self.assertEqual('Wingspan', response.json['name'])

    def test_get_boardgame_for_unknwon_id(self):
      # 2. ACT
      response = self.app.get('/boardgames/2')
      # 3. ASSERT
      self.assertEqual('404 NOT FOUND', response.status)
      self.assertTrue("Boardgame 2 not found." in response.json['message'])

    def test_delete_boardgame_by_id(self):
      # 2. ACT
      response = self.app.delete('/boardgames/1')
      # 3. ASSERT
      self.assertEqual('204 NO CONTENT', response.status)
      response = self.app.get('/boardgames')
      self.assertEqual(0, len(response.json))

    def test_post_new_boardgame(self):
      # 2. ACT
      response = self.app.post('/boardgames', json={
          "name": "King of Tokyo",
          "designer": "Richard Garfield",
          "playing_time": "30 Min",
          "rating": 7.2,
          "id": "2",
          "expansions": []
      })
      # 3. ASSERT
      self.assertEqual('201 CREATED', response.status)
      self.assertEqual('King of Tokyo', response.json['name'])
