From 381e4357f9f93f6e2d81ede6b1a00a6d3fb5b80a Mon Sep 17 00:00:00 2001 From: Sumukh Sridhara Date: Tue, 24 Mar 2020 00:47:24 -0700 Subject: [PATCH] First commit --- .gitignore | 48 ++++++++++++++++++++++++++++++++ README.md | 32 +++++++++++++++++++++ app.json | 29 +++++++++++++++++++ conftest.py | 7 +++++ dev-server.sh | 1 + requirements.txt | 29 +++++++++++++++++++ server/__init__.py | 16 +++++++++++ server/api/__init__.py | 46 ++++++++++++++++++++++++++++++ server/api/categories.py | 24 ++++++++++++++++ server/api/info.py | 8 ++++++ server/api/wallpaper.py | 26 +++++++++++++++++ server/extensions.py | 0 server/settings.py | 14 ++++++++++ services/images.py | 54 ++++++++++++++++++++++++++++++++++++ tests/test_categories_api.py | 10 +++++++ tests/test_info.py | 10 +++++++ tests/test_settings.py | 7 +++++ tests/test_wallpaper_api.py | 27 ++++++++++++++++++ wsgi.py | 14 ++++++++++ 19 files changed, 402 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.json create mode 100644 conftest.py create mode 100755 dev-server.sh create mode 100644 requirements.txt create mode 100644 server/__init__.py create mode 100644 server/api/__init__.py create mode 100644 server/api/categories.py create mode 100644 server/api/info.py create mode 100644 server/api/wallpaper.py create mode 100644 server/extensions.py create mode 100644 server/settings.py create mode 100644 services/images.py create mode 100644 tests/test_categories_api.py create mode 100644 tests/test_info.py create mode 100644 tests/test_settings.py create mode 100644 tests/test_wallpaper_api.py create mode 100644 wsgi.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..efb232d --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +*.pyc +*.pyo +*.swp +*~ + +# Packages +*.egg +*.egg-info +dist +build/ +eggs +bin +sdist + +#Sphinx builds +_build + +# Installer logs +pip-log.txt + +# Flake8 violation file +violations.flake8.txt + +# Unit test / coverage reports +.coverage +.tox +nosetests.xml +__pycache__ +database.db +.vscode +.pytest_cache + +# Sphinx +docs/_build + +# Virtual environments +env +env* + +# Env Vars +*.env* + +.webassets-cache +*.cache +.DS_Store +*.sublime-* + +appname/static/public/* \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a39441 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Create Flask API + +A boilerplate Flask application on which to build APIs using best practices. + +- ✅ RESTful API (with argument validation & output schemas) +- ✅ 100% Code Coverage in Tests +- ✅ Using Flask Best Practices +- ✅ Support Heroku Deployment +- ✅ With an example image API + +## Installation + +```bash +$ git clone +$ cd create-flask-api +$ python3 -m venv env; source env/bin/activate # To set up an virtual env +$ ./dev-server.sh # runs: FLASK_DEBUG="true" FLASK_APP="server:create_app" flask run +``` + +### Key Files: + +The API Endpoints are defined in `server/api` and registered to specific routes in `server/api/__init__.py`. + +## Testing + +To test with a coverage report: + +`pytest --cov-report term-missing --cov=server` + +## Limitations + +This repo is designed to be the barebones for an API. If you want to hook into a database or do authentication, you should look into [Flask Ignite](https://github.com/sumukh/ignite) diff --git a/app.json b/app.json new file mode 100644 index 0000000..614d353 --- /dev/null +++ b/app.json @@ -0,0 +1,29 @@ +{ + "name": "Create Flask API", + "description": "Flask API", + "keywords": [ + "flask", + "api", + "scaffolding", + "ignite" + ], + "website": "https://github.com/sumukh/create-flask-api/", + "repository": "https://github.com/sumukh/create-flask-apiapi/", + "logo": "https://github.com/Sumukh/Ignite/raw/master/appname/static/public/ignite/ignite-icon.png", + "success_url": "/", + "scripts": {}, + "env": { + "SECRET_TOKEN": { + "description": "A secret key for verifying the integrity of signed cookies.", + "generator": "secret" + }, + "FLASK_APP": { + "description": "Where the flask app lives.", + "value": "wsgi.py" + }, + "FLASK_ENV": { + "description": "What environment is this in", + "value": "prod" + } + } +} \ No newline at end of file diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..9b2015f --- /dev/null +++ b/conftest.py @@ -0,0 +1,7 @@ +import pytest + +from server import create_app + +@pytest.fixture +def app(): + return create_app('test') diff --git a/dev-server.sh b/dev-server.sh new file mode 100755 index 0000000..4da9641 --- /dev/null +++ b/dev-server.sh @@ -0,0 +1 @@ +FLASK_DEBUG="true" FLASK_APP="server:create_app" flask run \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..196844b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,29 @@ +Flask==1.1.1 +jinja2==2.11.1 +Werkzeug~=0.16.1 + +requests +gunicorn + +Flask-Caching>=1.3.3 +Flask-RESTful==0.3.8 +Flask-Limiter + +# Flask-SocketIO>=3.1.0 # Realtime Websockets +# python-engineio>=3.0.0 # Needed to fix startup error + +# Timezones +pytz +arrow + +# Other +itsdangerous==1.1.0 +hashids==1.2.0 +humanize==2.0.0 + +# Testing +pytest==5.4.1 +pytest-cov==2.8.1 +pytest-flask +mccabe==0.6.1 +flake8==3.7.9 diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..7b6b8d7 --- /dev/null +++ b/server/__init__.py @@ -0,0 +1,16 @@ +from flask import Flask +from server.api import api_blueprint + +def create_app(object_name='server.settings.DevConfig'): + """ + An flask application factory, as explained here: + http://flask.pocoo.org/docs/patterns/appfactories/ + Arguments: + object_name: the python path of the config object, + e.g. appname.settings.ProdConfig + """ + + app = Flask(__name__) + app.config.from_object(object_name) + app.register_blueprint(api_blueprint, url_prefix="/api") + return app diff --git a/server/api/__init__.py b/server/api/__init__.py new file mode 100644 index 0000000..a36ab21 --- /dev/null +++ b/server/api/__init__.py @@ -0,0 +1,46 @@ +from flask import Blueprint, request +import flask_restful as restful + +from server.api.info import ApiInfo +from server.api.categories import CategoriesApi +from server.api.wallpaper import (UnsplashSearchApi, UnsplashCategoryApi, + RedditSearchApi) + +api_blueprint = Blueprint('api', __name__) +api_blueprint.config = {} + +api = restful.Api(api_blueprint) + +@api_blueprint.record +def record_params(setup_state): + """ Load used app configs into local config on registration from + server/__init__.py """ + app = setup_state.app + api_blueprint.config['tz'] = app.config.get('TIMEZONE', 'utc') + api_blueprint.config['debug'] = app.debug + +@api.representation('application/json') +def envelope_api(data, code, headers=None): + """ API response envelope (for metadata/pagination). + Optionally wraps JSON response in envelope. + This is for successful requests only. + data is the object returned by the API. + code is the HTTP status code as an int + """ + if not request.args.get('envelope'): + return restful.representations.json.output_json(data, code, headers) + message = 'success' + data = { + 'data': data, + 'code': code, + 'message': message + } + return restful.representations.json.output_json(data, code, headers) + + +# If you want to version your API, you can do that by adding a prefix to the route here +api.add_resource(ApiInfo, '/info') +api.add_resource(CategoriesApi, '/categories') +api.add_resource(UnsplashSearchApi, '/wallpaper/unsplash/search') +api.add_resource(UnsplashCategoryApi, '/wallpaper/unsplash/category/') +api.add_resource(RedditSearchApi, '/wallpaper/reddit/') diff --git a/server/api/categories.py b/server/api/categories.py new file mode 100644 index 0000000..2927baa --- /dev/null +++ b/server/api/categories.py @@ -0,0 +1,24 @@ +from flask_restful import Resource, fields, marshal_with + +class Category: + def __init__(self, id, name, state='featured'): + self.id = id + self.name = name + self.state = state + +# Example of returning objects and using a schema to marshal out specific fields +class CategoriesApi(Resource): + get_fields = { + 'name': fields.String, + 'id': fields.Integer + } + + @marshal_with(get_fields) + def get(self): + return [ + Category(3330448, 'nature'), + Category(3356570, 'travel'), + Category(1065976, 'wallpapers'), + Category(3330445, 'patterns'), + Category(3694365, 'gradients'), + ] diff --git a/server/api/info.py b/server/api/info.py new file mode 100644 index 0000000..546547b --- /dev/null +++ b/server/api/info.py @@ -0,0 +1,8 @@ +from flask_restful import Resource + +class ApiInfo(Resource): + def get(self): + return { + 'version': '1.0', + 'example_endpoints': ['/categories'] + } diff --git a/server/api/wallpaper.py b/server/api/wallpaper.py new file mode 100644 index 0000000..a57e976 --- /dev/null +++ b/server/api/wallpaper.py @@ -0,0 +1,26 @@ +from flask_restful import Resource, reqparse +from services.images import (search_unsplash, get_unsplash_collection, + get_reddit_images) + +class UnsplashSearchApi(Resource): + parser = reqparse.RequestParser() + parser.add_argument('query', required=True) + parser.add_argument('page', type=int, required=False) + + def get(self): + args = self.parser.parse_args() + response = search_unsplash(args['query'], page=args['page']) + return response + +class UnsplashCategoryApi(Resource): + parser = reqparse.RequestParser() + parser.add_argument('page', required=False) + + def get(self, category_id=None): + args = self.parser.parse_args() + return get_unsplash_collection(collection_id=category_id, page=args['page']) + +class RedditSearchApi(Resource): + def get(self, subreddit): + response = get_reddit_images(subreddit) + return response diff --git a/server/extensions.py b/server/extensions.py new file mode 100644 index 0000000..e69de29 diff --git a/server/settings.py b/server/settings.py new file mode 100644 index 0000000..38fdce4 --- /dev/null +++ b/server/settings.py @@ -0,0 +1,14 @@ +import os + +class BaseConfig: + TESTING = False + +class DevConfig(BaseConfig): + SERVICE_API_KEY = os.getenv('SERVICE_API_KEY', "ABC") + +class TestConfig(BaseConfig): + TESTING = True + SERVICE_API_KEY = os.getenv('SERVICE_API_KEY', "BDC") + +class ProdConfig(BaseConfig): + SERVICE_API_KEY = os.getenv('SERVICE_API_KEY', "DEF") diff --git a/services/images.py b/services/images.py new file mode 100644 index 0000000..c2b5199 --- /dev/null +++ b/services/images.py @@ -0,0 +1,54 @@ +import requests + +USER_AGENT = 'Create Flask Api 1.0' + +def get_reddit_images(subreddit): + response = requests.get("https://reddit.com/r/{}.json".format(subreddit), + headers={'User-agent': USER_AGENT}).json() + if 'error' in response: + return [] + posts = response["data"]["children"] + posts_with_image = [p["data"] for p in posts + if p["data"].get("post_hint") == "image"] + return [{ + 'url': p['url'], + 'title': p['title'], + 'source': "https://reddit.com{}".format(p['permalink']), + 'author': { + 'name': 'Reddit', + 'description': subreddit, + 'profile_pic': '', + 'link': "https://reddit.com/r/{}".format(subreddit), + } + } for p in posts_with_image if p and not p['over_18']] + +def format_unsplash_image(p): + return { + 'url': p['urls']['raw'], + 'title': p['description'] or p['alt_description'], + 'source': p['links']['html'], + 'author': { + 'name': p['user']['name'], + 'description': p['user']['bio'], + 'profile_pic': p['user']['profile_image'].get('medium'), + 'link': p['user']['links']['html'], + } + } + +def get_unsplash_collection(collection_id=3330448, page=1): + endpoint_url = 'https://unsplash.com/napi/collections/{}/photos'.format( + collection_id + ) + photos = requests.get(endpoint_url, {'page': page}, + headers={'User-agent': USER_AGENT}).json() + + return [format_unsplash_image(p) for p in photos if p] + +def search_unsplash(search_term, page=1): + response = requests.get('https://unsplash.com/napi/search/photos', { + 'query': search_term, + 'per_page': 25, + 'page': page, + }, headers={'User-agent': USER_AGENT}) + results = response.json().get('results', []) + return [format_unsplash_image(p) for p in results] diff --git a/tests/test_categories_api.py b/tests/test_categories_api.py new file mode 100644 index 0000000..24059d3 --- /dev/null +++ b/tests/test_categories_api.py @@ -0,0 +1,10 @@ +import pytest + +def test_categories_endpoint(client): + response = client.get('/api/categories') + assert response.status_code == 200 + data = response.json + assert len(data) > 0 + assert data[0]['id'] is not None + assert data[0]['name'] is not None + diff --git a/tests/test_info.py b/tests/test_info.py new file mode 100644 index 0000000..6288a15 --- /dev/null +++ b/tests/test_info.py @@ -0,0 +1,10 @@ +import pytest + +def test_info_endpoint(client): + response = client.get('/api/info') + assert response.status_code == 200 + +def test_envelope(client): + response = client.get('/api/info?envelope=1') + assert response.status_code == 200 + diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..ddac946 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,7 @@ +import pytest +from server.settings import DevConfig, TestConfig, ProdConfig + +def test_testing_settings(): + assert DevConfig.TESTING == False + assert ProdConfig.TESTING == False + assert TestConfig.TESTING == True diff --git a/tests/test_wallpaper_api.py b/tests/test_wallpaper_api.py new file mode 100644 index 0000000..5d710ad --- /dev/null +++ b/tests/test_wallpaper_api.py @@ -0,0 +1,27 @@ +import pytest + +def test_reddit_endpoint(client): + response = client.get('/api/wallpaper/reddit/wallpapers') + assert response.status_code == 200 + data = response.json + assert len(data) > 0 + assert data[0]['url'] is not None + +def test_unsplash_search_endpoint(client): + response = client.get('/api/wallpaper/unsplash/search?query=food') + assert response.status_code == 200 + data = response.json + assert len(data) > 0 + assert data[0]['url'] is not None + +def test_invalid_unsplash_search_endpoint(client): + response = client.get('/api/wallpaper/unsplash/search') + assert response.status_code == 400 + assert b'Missing required' in response.data + +def test_unsplash_category(client): + response = client.get('/api/wallpaper/unsplash/category/3330448') + assert response.status_code == 200 + data = response.json + assert len(data) > 0 + assert data[0]['url'] is not None diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..2c4904c --- /dev/null +++ b/wsgi.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +""" +For WSGI Server +To run: +$ gunicorn -b 0.0.0.0:5000 wsgi:app +OR +$ export FLASK_APP=wsgi +$ flask run +""" +import os +from server import create_app + +env = os.environ.get('FLASK_ENV', 'dev') +app = create_app('server.settings.%sConfig' % env.capitalize())