diff --git a/build_number_generator/__init__.py b/build_number_generator/__init__.py new file mode 100644 index 0000000..f570fa4 --- /dev/null +++ b/build_number_generator/__init__.py @@ -0,0 +1,18 @@ +from flask import Flask +import os + +def create_app() -> None: + app_path = os.path.dirname(__file__) + app = Flask(__name__, root_path=app_path, instance_path=os.path.join(app_path, "instance"), instance_relative_config=True) + + app.config.from_pyfile("config.py") + + app.app_context() + + with app.app_context(): + from . import database + database.init() + + from .controller import build_number + + return app diff --git a/build_number_generator/controller/build_number.py b/build_number_generator/controller/build_number.py new file mode 100644 index 0000000..4156d2d --- /dev/null +++ b/build_number_generator/controller/build_number.py @@ -0,0 +1,100 @@ +from flask import Flask, jsonify, current_app, request +from build_number_generator.database import get_database + +import hashlib + +MIN_COMMIT_HASH_LENGTH = 16 + +def hash_token(token: str) -> str: + digest = hashlib.blake2b(digest_size=64) + digest.update(str.encode(token)) + return digest.hexdigest() + + +def fail(status_message: str, status_code: int = 400) -> tuple: + response = {} + response["status"] = status_code + response["message"] = status_message + return response, status_code + + +@current_app.errorhandler(405) +def method_not_allowed(e) -> tuple: + return fail("Method not allowed", 405) + + +@current_app.route("/build-number", methods=["POST"]) +@current_app.route("/build-number/", methods=["POST"]) +@current_app.route("/build-number//", methods=["POST"]) +def build_number(series=None, commit=None): + + token = request.form.get("token") + + if not token: + return fail("Unautherized", 401) + + if not hash_token(token) in current_app.config["AUTHORIZED_HASHES"]: + return fail("Unautherized", 401) + + if not series: + return fail("Required parameter not provided. Format: /build-number//") + + if series.count(".") != 1: + return fail("Invalid parameter: series. Format: .") + + major, minor = series.split(".") + + if not major.isnumeric() or not minor.isnumeric(): + return fail("Invalid parameter: series. Format: .") + + major = int(major) + minor = int(minor) + + if not commit: + return fail("Required parameter not provided. Format: /build-number//") + + if len(commit) < MIN_COMMIT_HASH_LENGTH: + return fail("Invalid parameter: commit. Must be at least %i characters long" % (MIN_COMMIT_HASH_LENGTH)) + + response = {} + + db = get_database() + + # Find or insert series + result = db.execute("SELECT * FROM series WHERE name='%i.%i'" % (major, minor)) + row = result.fetchone() + + if row is not None: + series_id = row["id"] + response["message"] = "Known series. " + else: + cursor = db.execute("INSERT INTO series VALUES (NULL, '%i.%i', NULL)" % (major, minor)) + series_id = cursor.lastrowid + response["message"] = "Unknown series. " + + # Find or insert build number + result = db.execute("SELECT * FROM build WHERE series_id=%i AND commit_hash='%s'" % (series_id, commit)) + row = result.fetchone() + + if row is not None: + build_number = row["build_number"] + + response["message"] += "Known commit hash." + response["stauts"] = 200 + else: + result = db.execute("SELECT build_number FROM build WHERE series_id=%i ORDER BY build_number DESC LIMIT 1" % (series_id)) + row = result.fetchone() + build_number = (row["build_number"] + 1) if row is not None else 1 + cursor = db.execute("INSERT INTO build VALUES (NULL, %i, '%s', %i, NULL)" % (series_id, commit, build_number)) + + response["message"] += "Unknown commit hash. New build number created." + response["stauts"] = 201 + + db.commit() + + response["commit_hash"] = commit + response["series"] = "%i.%i" % (major, minor) + response["series_id"] = series_id + response["build_number"] = build_number + + return response, response["stauts"] diff --git a/build_number_generator/database.py b/build_number_generator/database.py new file mode 100644 index 0000000..9c7f401 --- /dev/null +++ b/build_number_generator/database.py @@ -0,0 +1,45 @@ +import sqlite3 + +import click +from flask import current_app, g + + +def get_database() -> sqlite3.Connection: + """Gets the database connection for the current request. If this function is called for the first time for the given request, + this function also connects to the database, whereas later calls will simply reuse that connection""" + if "database" not in g: + g.database = sqlite3.connect( + current_app.config["DATABASE"], detect_types=sqlite3.PARSE_DECLTYPES + ) + g.database.row_factory = sqlite3.Row + + return g.database + + +def close_database(_ = None) -> None: + """If a connection to the database has been established, this function closes it again""" + database: sqlite3.Connection = g.pop("database", None) + + if database is not None: + database.close() + + +def init_database() -> None: + """This function (re)creates the database from scratch. If the database existed + before, its contents will be dropped""" + database: sqlite3.Connection = get_database() + + with current_app.open_resource("schema.sql") as file: + database.executescript(file.read().decode("utf-8")) + + +@click.command("init-db") +def init_db_command() -> None: + """Clear existing data and create new tables.""" + init_database() + click.echo("Database initialized") + + +def init() -> None: + current_app.teardown_appcontext(close_database) + current_app.cli.add_command(init_db_command) diff --git a/build_number_generator/schema.sql b/build_number_generator/schema.sql new file mode 100644 index 0000000..baa146b --- /dev/null +++ b/build_number_generator/schema.sql @@ -0,0 +1,18 @@ +DROP TABLE IF EXISTS series; +DROP TABLE IF EXISTS build; + +CREATE TABLE series ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_on DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE build ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + series_id INTEGER NOT NULL, + commit_hash TEXT NOT NULL, + build_number INTEGER NOT NULL, + created_on DEFAULT CURRENT_TIMESTAMP, + UNIQUE(series_id, commit_hash), + FOREIGN KEY (series_id) REFERENCES series (id) +); diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2077213 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Flask \ No newline at end of file diff --git a/setup b/setup new file mode 100755 index 0000000..490a45b --- /dev/null +++ b/setup @@ -0,0 +1,23 @@ +#!/bin/bash + +PYTHON_HOST_BIN=python + +which $PYTHON_HOST_BIN + +if [ $? -ne 0 ]; then + PYTHON_HOST_BIN=python3 +fi + +which $PYTHON_HOST_BIN + +if [ $? -ne 0 ]; then + echo "python executable not found" + exit 1 +fi + +$PYTHON_HOST_BIN --version + +$PYTHON_HOST_BIN -m venv venv +venv/bin/pip install -r requirements.txt + +. venv/bin/activate diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..4287ca8 --- /dev/null +++ b/test/__init__.py @@ -0,0 +1 @@ +# \ No newline at end of file diff --git a/test/requirements.txt b/test/requirements.txt new file mode 100644 index 0000000..16d3b77 --- /dev/null +++ b/test/requirements.txt @@ -0,0 +1,2 @@ +pytest +requests \ No newline at end of file diff --git a/test/test_build_number_generator.py b/test/test_build_number_generator.py new file mode 100644 index 0000000..1a12273 --- /dev/null +++ b/test/test_build_number_generator.py @@ -0,0 +1,58 @@ +import pytest +import json + +@pytest.fixture() +def app(): + from build_number_generator import create_app + from build_number_generator.database import init_database + + app = create_app() + app.testing = True + + app.config.update({ + "DATABASE": "/tmp/test.sqlite", + "AUTHORIZED_HASHES": { + # 'testpassword' + "ebe5a79f942bab98b3c122ceb72a9c23fb21991848cddb9ad4ce8a209c8269c25eb5f5146427afc23ef588de61798b38451282339e2c637c6dec51d635ab50c4" + } + }) + + with app.app_context(): + init_database() + + yield app + +@pytest.fixture() +def app_context(app): + with app.app_context(): + yield app + + +@pytest.fixture() +def client(app_context): + yield app_context.test_client() + + +def request(client, url, data=None): + response = client.post(url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}) + j = json.loads(response.data) + status = response.status_code + return j, status + + +def test_method_1(client): + response = client.get("/build-number") + j = json.loads(response.data) + status = response.status_code + assert status == 405 and j["status"] == 405 + + +def test_method_2(client): + response = client.get("/build-number") + j = json.loads(response.data) + status = response.status_code + assert status == 405 and j["status"] == 405 + #j, status = request(client, "/build-number/asd/asd", "") + #assert status == 400 and j["status"] == 400 + + #request(client, "/build-number/1.1/abcdefghijklmnopq", "token=testpassword")