diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2452780 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env + +venv/ +.idea diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..488d25c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM alpine/git AS install + +RUN echo "unknow" > /git-version.txt + +# If the --dirty flag is left out, only the .git directory has to be copied +COPY . /git/ + +RUN find . -type d -name .git -exec git describe --always --dirty > /git-version.txt \; + + +FROM python:3.8 + +EXPOSE 8080 + +COPY test.sh / + +COPY OAS3.yml / + +COPY requirements.txt / +RUN pip install -r requirements.txt + +COPY *.py / + +COPY --from=install /git-version.txt / + +CMD ["python", "-u", "./app.py"] diff --git a/OAS3.yml b/OAS3.yml new file mode 100644 index 0000000..b676911 --- /dev/null +++ b/OAS3.yml @@ -0,0 +1,82 @@ +openapi: 3.0.0 +info: + title: Entities Service + version: 0.1.0 + description: + Query and manipulate the Netz39 entities database. + contact: + email: tux@netz39.de + +servers: + - url: http://localhost:8080/v0 +tags: + - name: mgmt + description: Common management functions + +paths: + /health: + get: + summary: Provides health information about the service + tags: + - mgmt + operationId: health + responses: + '200': + description: endpoint is healthy + content: + application/json: + schema: + $ref: '#/components/schemas/health' + '500': + $ref: '#/components/responses/InternalError' + /oas3: + get: + summary: get this endpoint's Open API 3 specification + tags: + - mgmt + responses: + '200': + description: returns the API spec + content: + text/plain: + schema: + type: string + '500': + $ref: '#/components/responses/InternalError' + + +components: + schemas: + health: + type: object + properties: + git-version: + type: string + api-version: + type: string + timestamp: + type: string + format: date-time + uptime: + type: string + example: ISO8601 conforming timespan + responses: + AuthenticationRequired: + description: Authentication is required (401) + NotAllowed: + description: The call is not allowed with the provided authentication (403) + InvalidInput: + description: One or more parameters are missing or invalid (400) + content: + text/plain: + schema: + type: string + example: error message + InternalError: + description: Internal error during execution (500) + content: + text/plain: + schema: + type: string + example: error message + diff --git a/README.md b/README.md index bce88ed..a6d6afb 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,12 @@ # Entities Service Query and manipulate the Netz39 entities database. + +## Running the Service + +### Configuration + +The service is configured via the following environment variables: +* `PORT`: Service port. defaults to 8080 +* `AUTH`: Authentication tokens, defaults to None. Example Configuration : `AUTH={"token_1": "user_1", "token_2": "user_2"}` + diff --git a/app.py b/app.py new file mode 100644 index 0000000..7bff5b8 --- /dev/null +++ b/app.py @@ -0,0 +1,95 @@ +#!/usr/bin/python3 +from abc import ABCMeta + +import tornado.web + +import os +import subprocess +from datetime import datetime +import isodate + +import json + +import util +from auth import AuthProvider + + +startup_timestamp = datetime.now() + + +class HealthHandler(tornado.web.RequestHandler, metaclass=ABCMeta): + # noinspection PyAttributeOutsideInit + def initialize(self): + self.git_version = self._load_git_version() + + @staticmethod + def _load_git_version(): + v = None + + # try file git-version.txt first + gitversion_file = "git-version.txt" + if os.path.exists(gitversion_file): + with open(gitversion_file) as f: + v = f.readline().strip() + + # if not available, try git + if v is None: + try: + v = subprocess.check_output(["git", "describe", "--always", "--dirty"], + cwd=os.path.dirname(__file__)).strip().decode() + except subprocess.CalledProcessError as e: + print("Checking git version lead to non-null return code ", e.returncode) + + return v + + def get(self): + health = dict() + health['api-version'] = 'v0' + + if self.git_version is not None: + health['git-version'] = self.git_version + + health['timestamp'] = isodate.datetime_isoformat(datetime.now()) + health['uptime'] = isodate.duration_isoformat(datetime.now() - startup_timestamp) + + self.set_header("Content-Type", "application/json") + self.write(json.dumps(health, indent=4)) + self.set_status(200) + + +class Oas3Handler(tornado.web.RequestHandler, metaclass=ABCMeta): + def get(self): + self.set_header("Content-Type", "text/plain") + # This is the proposed content type, + # but browsers like Firefox try to download instead of display the content + # self.set_header("Content-Type", "text/vnd.yml") + with open('OAS3.yml', 'r') as f: + oas3 = f.read() + self.write(oas3) + self.finish() + + +def make_app(_auth_provider=None): + version_path = r"/v[0-9]" + return tornado.web.Application([ + (version_path + r"/health", HealthHandler), + (version_path + r"/oas3", Oas3Handler), + ]) + + +def main(): + port = util.load_env('PORT', 8080) + + # Setup + auth_provider = AuthProvider.from_environment() + + util.run_tornado_server(make_app(auth_provider), + server_port=port) + + # Teardown + + print("Server stopped") + + +if __name__ == "__main__": + main() diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..66be0f5 --- /dev/null +++ b/auth.py @@ -0,0 +1,31 @@ +import json +from util import load_env + + +class AuthProvider(object): + @staticmethod + def from_environment(): + auth = load_env("AUTH", None) + + return AuthProvider(auth) + + def __init__(self, auth_token_config): + if auth_token_config == "": + self.auth_token_pool = [] + print("Service started without Authentication") + return + + try: + self.auth_token_pool = json.loads(auth_token_config) + except ValueError as e: + raise ValueError("Authentication configuration could not be parsed") from e + + def validate_token(self, token): + """Validate a token for fabrication functions""" + if token in self.auth_token_pool or not self.auth_token_pool: + return True + + return False + + def user_for_token(self, token): + return self.auth_token_pool.get(token) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..39e9458 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +# Do not forget to create the .env file (see template) +# before using this container! + +version: '2' + +services: + entities_service: + restart: always + build: . + env_file: + - .env + environment: + PORT: 8080 + ports: + - $PORT:8080 + diff --git a/dotenv.template b/dotenv.template new file mode 100644 index 0000000..25241b7 --- /dev/null +++ b/dotenv.template @@ -0,0 +1 @@ +PORT=8080 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..757931f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +tornado==6.0.4 +isodate==0.6.0 +pytest==5.4.1 diff --git a/test.py b/test.py new file mode 100644 index 0000000..84a6315 --- /dev/null +++ b/test.py @@ -0,0 +1,42 @@ +#!/usr/bin/python3 + +from app import make_app +import util + +import unittest +import tornado.testing +import json + +util.platform_setup() + + +class TestBaseAPI(tornado.testing.AsyncHTTPTestCase): + """Example test case""" + def get_app(self): + return make_app() + + def test_health_endpoint(self): + response = self.fetch('/v0/health', + method='GET') + self.assertEqual(200, response.code, "GET /health must be available") + + health = json.loads(response.body.decode()) + + self.assertIn('api-version', health, msg="api-version is not provided by health endpoint") + self.assertEqual("v0", health['api-version'], msg="API version should be v0") + self.assertIn('git-version', health, msg="git-version is not provided by health endpoint") + self.assertIn('timestamp', health, msg="timestamp is not provided by health endpoint") + self.assertIn('uptime', health, msg="uptime is not provided by health endpoint") + + def test_oas3(self): + response = self.fetch('/v0/oas3', + method='GET') + self.assertEqual(200, response.code, "GET /oas3 must be available") + + # check contents against local OAS3.yml + with open('OAS3.yml') as oas3f: + self.assertEqual(response.body.decode(), oas3f.read(), "OAS3 content differs from spec file!") + + +if __name__ == "__main__": + unittest.main() diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..bade22e --- /dev/null +++ b/test.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +python3 -m pytest test.py diff --git a/util.py b/util.py new file mode 100644 index 0000000..c1b0b02 --- /dev/null +++ b/util.py @@ -0,0 +1,69 @@ +#!/usr/bin/python3 + +import os +import signal +import platform +import asyncio + +import tornado.ioloop +import tornado.netutil +import tornado.httpserver + + +def load_env(key, default): + if key in os.environ: + return os.environ[key] + else: + return default + + +signal_received = False + + +def platform_setup(): + """Platform-specific setup, especially for asyncio.""" + if platform.system() == 'Windows': + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + +def run_tornado_server(app, server_port=8080): + platform_setup() + + sockets = tornado.netutil.bind_sockets(server_port, '') + server = tornado.httpserver.HTTPServer(app) + server.add_sockets(sockets) + + port = None + + for s in sockets: + print('Listening on %s, port %d' % s.getsockname()[:2]) + if port is None: + port = s.getsockname()[1] + + ioloop = tornado.ioloop.IOLoop.instance() + + def register_signal(sig, _frame): + # noinspection PyGlobalUndefined + global signal_received + print("%s received, stopping server" % sig) + server.stop() # no more requests are accepted + signal_received = True + + def stop_on_signal(): + # noinspection PyGlobalUndefined + global signal_received + if signal_received: + ioloop.stop() + print("IOLoop stopped") + + tornado.ioloop.PeriodicCallback(stop_on_signal, 1000).start() + signal.signal(signal.SIGTERM, register_signal) + print("Starting server") + + global signal_received + while not signal_received: + try: + ioloop.start() + except KeyboardInterrupt: + print("Keyboard interrupt") + register_signal(signal.SIGTERM, None)