From f92946e12f9acfa394c04a140a2a4d025a5fe3ca Mon Sep 17 00:00:00 2001 From: lmn Date: Tue, 13 Feb 2018 13:11:57 +0100 Subject: RethinkDB API Views #yqhg * Refactoring the database implementation into a class of its own. * Refactoring the database implementation into a class of its own. * healthcheck should belong to the API. * dynamic subdomain loading, setting up basic handling for staff.pythondiscord.com, and started on a TagView for a bot tag feature. * Oops, forgot to fix some merges. * Some quality of life updates - default values for env variables that aren't secret, and starting to get through the tag view. * Refactoring the database implementation into a class of its own. * healthcheck should belong to the API. * dynamic subdomain loading, setting up basic handling for staff.pythondiscord.com, and started on a TagView for a bot tag feature. * Oops, forgot to fix some merges. * Some quality of life updates - default values for env variables that aren't secret, and starting to get through the tag view. * API validation added to the APIView class, TagView should be finished as well. * super important commit you guys * fixed a bug with the RethinkDB class where host and port attributes were accessed before being created * Fixed my editor now you guys * Fixing up some of the problems brought up in gdude's review * Handling GET requests with param indata and POST with JSON. Fixed error handling to use the constants and the baseclass self.error(). * Get API-key from headers, context manage the db assignment, and default env var values * Changed API-KEY header to X_API_Key. Added a default for secret key. it should no longer be necessary with environment variables to run this system locally. * Changing back the nav to have relative paths * Why am I like this --- pysite/base_route.py | 21 ++++++++++- pysite/constants.py | 3 +- pysite/database.py | 43 +++++++++++++++++++++++ pysite/route_manager.py | 76 +++++++++++++--------------------------- pysite/views/__init__.py | 0 pysite/views/api/bot/__init__.py | 0 pysite/views/api/bot/tag.py | 66 ++++++++++++++++++++++++++++++++++ pysite/views/api/healthcheck.py | 4 +-- pysite/views/staff/__init__.py | 0 pysite/views/staff/index.py | 10 ++++++ 10 files changed, 167 insertions(+), 56 deletions(-) create mode 100644 pysite/database.py create mode 100644 pysite/views/__init__.py create mode 100644 pysite/views/api/bot/__init__.py create mode 100644 pysite/views/api/bot/tag.py create mode 100644 pysite/views/staff/__init__.py create mode 100644 pysite/views/staff/index.py (limited to 'pysite') diff --git a/pysite/base_route.py b/pysite/base_route.py index a3e8615b..c705a350 100644 --- a/pysite/base_route.py +++ b/pysite/base_route.py @@ -1,4 +1,8 @@ # coding=utf-8 +import os +import random +import string + from flask import Blueprint, jsonify, render_template from flask.views import MethodView @@ -27,7 +31,17 @@ class RouteView(BaseView): class APIView(RouteView): + def validate_key(self, api_key: str): + """ Placeholder! """ + return api_key == os.environ("API_KEY") + + def generate_api_key(self): + """ Generate a random string of n characters. """ + pool = random.choices(string.ascii_letters + string.digits, k=32) + return "".join(pool) + def error(self, error_code: ErrorCodes): + data = { "error_code": error_code.value, "error_message": "Unknown error" @@ -40,7 +54,12 @@ class APIView(RouteView): http_code = 404 elif error_code is ErrorCodes.unauthorized: data["error_message"] = "Unauthorized" - http_code = 403 + http_code = 401 + elif error_code is ErrorCodes.invalid_api_key: + data["error_message"] = "Invalid API-key" + http_code = 401 + elif error_code is ErrorCodes.missing_parameters: + data["error_message"] = "Not all required parameters were provided" response = jsonify(data) response.status_code = http_code diff --git a/pysite/constants.py b/pysite/constants.py index e5a283a7..32d7fd14 100644 --- a/pysite/constants.py +++ b/pysite/constants.py @@ -1,5 +1,4 @@ # coding=utf-8 -__author__ = "Gareth Coles" from enum import IntEnum @@ -7,3 +6,5 @@ from enum import IntEnum class ErrorCodes(IntEnum): unknown_route = 0 unauthorized = 1 + invalid_api_key = 2 + missing_parameters = 3 diff --git a/pysite/database.py b/pysite/database.py new file mode 100644 index 00000000..75f01378 --- /dev/null +++ b/pysite/database.py @@ -0,0 +1,43 @@ +# coding=utf-8 + +import os + +from flask import abort + +import rethinkdb + + +class RethinkDB: + + def __init__(self, loop_type: str = "gevent"): + self.host = os.environ.get("RETHINKDB_HOST", "127.0.0.1") + self.port = os.environ.get("RETHINKDB_PORT", "28016") + self.database = os.environ.get("RETHINKDB_DATABASE", "pythondiscord") + self.conn = None + + rethinkdb.set_loop_type(loop_type) + + with self.get_connection(connect_database=False) as conn: + try: + rethinkdb.db_create(self.database).run(conn) + print(f"Database created: {self.database}") + except rethinkdb.RqlRuntimeError: + print(f"Database found: {self.database}") + + def get_connection(self, connect_database: bool = True): + if connect_database: + return rethinkdb.connect(host=self.host, port=self.port, db=self.database) + else: + return rethinkdb.connect(host=self.host, port=self.port) + + def before_request(self): + try: + self.conn = self.get_connection() + except rethinkdb.RqlDriverError: + abort(503, "Database connection could not be established.") + + def teardown_request(self, _): + try: + self.conn.close() + except AttributeError: + pass diff --git a/pysite/route_manager.py b/pysite/route_manager.py index d7cf0fa1..6f973767 100644 --- a/pysite/route_manager.py +++ b/pysite/route_manager.py @@ -3,16 +3,10 @@ import importlib import inspect import os -from flask import Blueprint, Flask, abort, g - -import rethinkdb +from flask import Blueprint, Flask, g from pysite.base_route import APIView, BaseView, ErrorView, RouteView - -DB_HOST = os.environ.get("RETHINKDB_HOST") -DB_PORT = os.environ.get("RETHINKDB_PORT") -DB_DATABASE = os.environ.get("RETHINKDB_DATABASE") -DB_TABLE = os.environ.get("RETHINKDB_TABLE") +from pysite.database import RethinkDB TEMPLATES_PATH = "../templates" STATIC_PATH = "../static" @@ -20,29 +14,42 @@ STATIC_PATH = "../static" class RouteManager: def __init__(self): + + # Set up the app and the database self.app = Flask( __name__, template_folder=TEMPLATES_PATH, static_folder=STATIC_PATH, static_url_path="/static", ) - self.app.secret_key = os.environ.get("WEBPAGE_SECRET_KEY") - self.app.config["SERVER_NAME"] = os.environ.get("SERVER_NAME", "localhost") + self.db = RethinkDB() + self.app.secret_key = os.environ.get("WEBPAGE_SECRET_KEY", "super_secret") + self.app.config["SERVER_NAME"] = os.environ.get("SERVER_NAME", "pythondiscord.com:8080") + self.app.before_request(self.db.before_request) + self.app.teardown_request(self.db.teardown_request) - self.main_blueprint = Blueprint("main", __name__) + # Store the database in the Flask global context + with self.app.app_context(): + g.db = self.db # type: RethinkDB + # Load the main blueprint + self.main_blueprint = Blueprint("main", __name__) print(f"Loading Blueprint: {self.main_blueprint.name}") self.load_views(self.main_blueprint, "pysite/views/main") self.app.register_blueprint(self.main_blueprint) print("") - self.api_blueprint = Blueprint("api", __name__, subdomain="api") + # Load the subdomains + self.subdomains = ['api', 'staff'] - print(f"Loading Blueprint: {self.api_blueprint.name}") - self.load_views(self.api_blueprint, "pysite/views/api") - self.app.register_blueprint(self.api_blueprint) - print("") + for sub in self.subdomains: + self.sub_blueprint = Blueprint(sub, __name__, subdomain=sub) + + print(f"Loading Blueprint: {self.sub_blueprint.name}") + self.load_views(self.sub_blueprint, f"pysite/views/{sub}") + self.app.register_blueprint(self.sub_blueprint) + print("") def run(self): self.app.run( - port=int(os.environ.get("WEBPAGE_PORT")), debug="FLASK_DEBUG" in os.environ + port=int(os.environ.get("WEBPAGE_PORT", 8080)), debug="FLASK_DEBUG" in os.environ ) def load_views(self, blueprint, location="pysite/views"): @@ -66,38 +73,3 @@ class RouteManager: ): cls.setup(blueprint) print(f">> View loaded: {cls.name: <15} ({module.__name__}.{cls_name})") - - def setup_db(self): - connection = self.get_db_connection(connect_database=False) - - try: - rethinkdb.db_create(DB_DATABASE).run(connection) - rethinkdb.db(DB_DATABASE).table_create(DB_TABLE).run(connection) - print("Database created") - except rethinkdb.RqlRuntimeError: - print("Database found") - finally: - connection.close() - - self.app.before_request(self.db_before_request) - self.app.teardown_request(self.db_teardown_request) - - def get_db_connection(self, connect_database=True): - if connect_database: - return rethinkdb.connect(host=DB_HOST, port=DB_PORT, db=DB_DATABASE) - else: - return rethinkdb.connect(host=DB_HOST, port=DB_PORT) - - def db_before_request(self): - try: - # g is the Flask global context object - g.rdb_conn = self.get_db_connection() - except rethinkdb.RqlDriverError: - abort(503, "Database connection could be established.") - - def db_teardown_request(self, _): - try: - # g is the Flask global context object - g.rdb_conn.close() - except AttributeError: - pass diff --git a/pysite/views/__init__.py b/pysite/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pysite/views/api/bot/__init__.py b/pysite/views/api/bot/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pysite/views/api/bot/tag.py b/pysite/views/api/bot/tag.py new file mode 100644 index 00000000..acbdbc0c --- /dev/null +++ b/pysite/views/api/bot/tag.py @@ -0,0 +1,66 @@ +# coding=utf-8 + +from flask import g, jsonify, request + +import rethinkdb + +from pysite.base_route import APIView +from pysite.constants import ErrorCodes + + +class TagView(APIView): + path = '/tag' + name = 'tag' + table = 'tag' + + def __init__(self): + # make sure the table exists + with g.db.get_connection() as conn: + try: + rethinkdb.db(g.db.database).table_create(self.table, {'primary_key': 'tag_name'}).run(conn) + except rethinkdb.RqlRuntimeError: + print(f'Table {self.table} exists') + + def get(self): + """ + Indata must be provided as params, + API key must be provided as header + """ + rdb = rethinkdb.table(self.table) + api_key = request.headers.get('X-API-Key') + tag_name = request.args.get('tag_name') + + if self.validate_key(api_key): + if tag_name: + data = rdb.get(tag_name).run(g.db.conn) + data = dict(data) if data else {} + else: + data = rdb.pluck('tag_name').run(g.db.conn) + data = list(data) if data else [] + else: + self.error(ErrorCodes.invalid_api_key) + + return jsonify(data) + + def post(self): + """ Indata must be provided as JSON. """ + rdb = rethinkdb.table(self.table) + indata = request.get_json() + tag_name = indata.get('tag_name') + tag_content = indata.get('tag_content') + tag_category = indata.get('tag_category') + api_key = indata.get('api_key') + + if self.validate_key(api_key): + if tag_name and tag_content: + rdb.insert({ + 'tag_name': tag_name, + 'tag_content': tag_content, + 'tag_category': tag_category + }).run(g.db.conn) + else: + self.error(ErrorCodes.missing_parameters) + else: + self.error(ErrorCodes.invalid_api_key) + + return jsonify({'success': True}) diff --git a/pysite/views/api/healthcheck.py b/pysite/views/api/healthcheck.py index 9d1f681a..2ff5dfb0 100644 --- a/pysite/views/api/healthcheck.py +++ b/pysite/views/api/healthcheck.py @@ -1,10 +1,10 @@ # coding=utf-8 from flask import jsonify -from pysite.base_route import RouteView +from pysite.base_route import APIView -class IndexView(RouteView): +class HealthCheckView(APIView): path = "/healthcheck" name = "healthcheck" diff --git a/pysite/views/staff/__init__.py b/pysite/views/staff/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pysite/views/staff/index.py b/pysite/views/staff/index.py new file mode 100644 index 00000000..c23af392 --- /dev/null +++ b/pysite/views/staff/index.py @@ -0,0 +1,10 @@ +# coding=utf-8 +from pysite.base_route import RouteView + + +class StaffView(RouteView): + path = "/" + name = "staff" + + def get(self): + return self.render("staff.html") -- cgit v1.2.3