From 423806c414fe3047869743e9491a57542f04713c Mon Sep 17 00:00:00 2001 From: Grey Li Date: Fri, 25 Jun 2021 21:04:40 +0800 Subject: [PATCH] Add Flask example and tests --- examples/flask/README.md | 78 ++++++++++++++++++++ examples/flask/app.py | 115 ++++++++++++++++++++++++++++++ examples/flask/requirements.in | 2 + examples/flask/requirements.txt | 26 +++++++ test_examples.py | 122 ++++++++++++++++++++++++++++++++ 5 files changed, 343 insertions(+) create mode 100644 examples/flask/README.md create mode 100644 examples/flask/app.py create mode 100644 examples/flask/requirements.in create mode 100644 examples/flask/requirements.txt create mode 100644 test_examples.py diff --git a/examples/flask/README.md b/examples/flask/README.md new file mode 100644 index 0000000..3fd451e --- /dev/null +++ b/examples/flask/README.md @@ -0,0 +1,78 @@ +# Flask + +Flask is a lightweight WSGI web application framework, and it's also a good option +for writing RESTful APIs. + + +## Installation + +``` +$ pip install -r requirements.txt +$ flask run + * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) +``` + +See the [top level README](https://github.com/pallets-eco/flask-api-examples#readme) +for the environment setup and how to play with the API. + + +## Introduction + +Flask provides some route shortcuts for web APIs: + +- `app.get()` +- `app.post()` +- `app.put()` +- `app.patch()` +- `app.delete()` + +So the following usage: + +```python +@app.route('/pets', methods=['POST']) +def create_pet(): + pass +``` + +Can be simplified to: + +```python +@app.post('/pets') +def create_pet(): + pass +``` + +With `flask.views.MethodView`, you can also organize the APIs with class. Each +method of the class maps to the corresponding HTTP method when dispatching +the request to this class: + +```python +from flask.views import MethodView + + +class PetResource(MethodView): + + def get(self): + pass + + def patch(self): + pass + + def put(self): + pass + + def delete(self): + pass + + +app.add_url_rule('/pets/', view_func=PetResource.as_view('pet')) +``` + +See more details in [Method Based Dispatching](https://flask.palletsprojects.com/views/#method-based-dispatching). + + +## Resources + +- Documentation: https://flask.palletsprojects.com/ +- Source code: https://github.com/pallets/flask/ +- Chat: https://discord.gg/pallets diff --git a/examples/flask/app.py b/examples/flask/app.py new file mode 100644 index 0000000..318ac6b --- /dev/null +++ b/examples/flask/app.py @@ -0,0 +1,115 @@ +from flask import Flask, request +from werkzeug.exceptions import HTTPException +from flask_sqlalchemy import SQLAlchemy + +PET_CATEGORIES = ['cat', 'dog'] +PET_REQUIRED_FIELD_ERROR = 'The name and category field is required.' +PET_NAME_LENGTH_ERROR = 'The length of the name must be between 0 and 10.' +PET_CATEGORY_ERROR = 'The category must be one of: dog, cat.' +PET_ID_ERROR = 'Pet not found.' + +app = Flask(__name__) +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + +db = SQLAlchemy(app) + + +class PetModel(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(10)) + category = db.Column(db.String(10)) + + +@app.before_first_request +def init_database(): + """Create the table and add some fake data.""" + pets = [ + {'name': 'Kitty', 'category': 'cat'}, + {'name': 'Coco', 'category': 'dog'}, + {'name': 'Flash', 'category': 'cat'} + ] + db.create_all() + for pet_data in pets: + pet = PetModel(**pet_data) + db.session.add(pet) + db.session.commit() + + +@app.errorhandler(HTTPException) +def handle_http_errors(error): + return {'message': error.name}, error.code + + +def pet_schema(pet): + return { + 'id': pet.id, + 'name': pet.name, + 'category': pet.category + } + + +@app.get('/') +def say_hello(): + return {'message': 'Hello!'} + + +@app.get('/pets/') +def get_pet(pet_id): + pet = PetModel.query.get(pet_id) + if pet is None: + return {'message': PET_ID_ERROR}, 404 + return pet_schema(pet) + + +@app.get('/pets') +def get_pets(): + pets = PetModel.query.all() + return {'pets': [pet_schema(pet) for pet in pets]} + + +@app.post('/pets') +def create_pet(): + data = request.json + if 'name' not in data or 'category' not in data: + return {'message': PET_REQUIRED_FIELD_ERROR}, 400 + if len(data['name']) > 10: + return {'message': PET_NAME_LENGTH_ERROR}, 400 + if data['category'] not in PET_CATEGORIES: + return {'message': PET_CATEGORY_ERROR}, 400 + + pet = PetModel(name=data['name'], category=data['category']) + db.session.add(pet) + db.session.commit() + return pet_schema(pet), 201 + + +@app.patch('/pets/') +def update_pet(pet_id): + pet = PetModel.query.get(pet_id) + if pet is None: + return {'message': PET_ID_ERROR}, 404 + + data = request.json + if 'name' in data: + if len(data['name']) > 10: + return {'message': PET_NAME_LENGTH_ERROR}, 400 + else: + pet.name = data['name'] + if 'category' in data: + if data['category'] not in PET_CATEGORIES: + return {'message': PET_CATEGORY_ERROR}, 400 + else: + pet.category = data['category'] + db.session.commit() + return pet_schema(pet) + + +@app.delete('/pets/') +def delete_pet(pet_id): + pet = PetModel.query.get(pet_id) + if pet is None: + return {'message': PET_ID_ERROR}, 404 + db.session.delete(pet) + db.session.commit() + return '', 204 diff --git a/examples/flask/requirements.in b/examples/flask/requirements.in new file mode 100644 index 0000000..521e32d --- /dev/null +++ b/examples/flask/requirements.in @@ -0,0 +1,2 @@ +flask +flask-sqlalchemy diff --git a/examples/flask/requirements.txt b/examples/flask/requirements.txt new file mode 100644 index 0000000..968407a --- /dev/null +++ b/examples/flask/requirements.txt @@ -0,0 +1,26 @@ +# +# This file is autogenerated by pip-compile with python 3.8 +# To update, run: +# +# pip-compile requirements.in +# +click==8.0.1 + # via flask +flask==2.0.1 + # via + # -r requirements.in + # flask-sqlalchemy +flask-sqlalchemy==2.5.1 + # via -r requirements.in +greenlet==1.1.0 + # via sqlalchemy +itsdangerous==2.0.1 + # via flask +jinja2==3.0.1 + # via flask +markupsafe==2.0.1 + # via jinja2 +sqlalchemy==1.4.20 + # via flask-sqlalchemy +werkzeug==2.0.1 + # via flask diff --git a/test_examples.py b/test_examples.py new file mode 100644 index 0000000..243ab36 --- /dev/null +++ b/test_examples.py @@ -0,0 +1,122 @@ +from importlib import reload +import sys + +import pytest + +examples = [ + 'flask', +] + + +@pytest.fixture +def client(request): + app_path = f'examples/{request.param}' + sys.path.insert(0, app_path) + import app + app = reload(app) + _app = app.app + _app.testing = True + sys.path.remove(app_path) + return _app.test_client() + + +@pytest.mark.parametrize('client', examples, indirect=True) +def test_say_hello(client): + rv = client.get('/') + assert rv.status_code == 200 + assert rv.json + assert rv.json['message'] == 'Hello!' + + +@pytest.mark.parametrize('client', examples, indirect=True) +def test_get_pet(client): + rv = client.get('/pets/1') + assert rv.status_code == 200 + assert rv.json + assert rv.json['name'] == 'Kitty' + assert rv.json['category'] == 'cat' + + rv = client.get('/pets/13') + assert rv.status_code == 404 + assert rv.json + + +@pytest.mark.parametrize('client', examples, indirect=True) +def test_get_pets(client): + rv = client.get('/pets') + assert rv.status_code == 200 + assert rv.json + assert len(rv.json['pets']) == 3 + assert rv.json['pets'][0]['name'] == 'Kitty' + assert rv.json['pets'][0]['category'] == 'cat' + + +@pytest.mark.parametrize('client', examples, indirect=True) +def test_create_pet(client): + rv = client.post('/pets', json={ + 'name': 'Grey', + 'category': 'cat' + }) + assert rv.status_code == 201 + assert rv.json + assert rv.json['name'] == 'Grey' + assert rv.json['category'] == 'cat' + + +@pytest.mark.parametrize('client', examples, indirect=True) +@pytest.mark.parametrize('data', [ + {'name': 'Grey', 'category': 'human'}, + {'name': 'Fyodor Mikhailovich Dostoevsky', 'category': 'cat'}, + {'category': 'cat'}, + {'name': 'Grey'} +]) +def test_create_pet_with_bad_data(client, data): + rv = client.post('/pets', json=data) + assert rv.status_code == 400 + assert rv.json + + +@pytest.mark.parametrize('client', examples, indirect=True) +def test_update_pet(client): + new_data = { + 'name': 'Ghost', + 'category': 'dog' + } + + rv = client.patch('/pets/1', json=new_data) + assert rv.status_code == 200 + assert rv.json + + rv = client.get('/pets/1') + assert rv.status_code == 200 + assert rv.json['name'] == new_data['name'] + assert rv.json['category'] == new_data['category'] + + rv = client.patch('/pets/13', json=new_data) + assert rv.status_code == 404 + assert rv.json + + +@pytest.mark.parametrize('client', examples, indirect=True) +@pytest.mark.parametrize('data', [ + {'name': 'Fyodor Mikhailovich Dostoevsky'}, + {'category': 'human'} +]) +def test_update_pet_with_bad_data(client, data): + rv = client.patch('/pets/1', json=data) + assert rv.status_code == 400 + assert rv.json + + +@pytest.mark.parametrize('client', examples, indirect=True) +def test_delete_pet(client): + rv = client.delete('/pets/1') + assert rv.status_code == 204 + + rv = client.get('/pets/1') + assert rv.status_code == 404 + assert rv.json + + rv = client.delete('/pets/13') + assert rv.status_code == 404 + assert rv.json