Merge branch 'boilerplate'

This commit is contained in:
Stefan Haun 2020-08-23 22:07:56 +02:00
commit 48799e9139
12 changed files with 381 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
.env
venv/
.idea

26
Dockerfile Normal file
View file

@ -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"]

82
OAS3.yml Normal file
View file

@ -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

View file

@ -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"}`

95
app.py Normal file
View file

@ -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()

31
auth.py Normal file
View file

@ -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)

16
docker-compose.yml Normal file
View file

@ -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

1
dotenv.template Normal file
View file

@ -0,0 +1 @@
PORT=8080

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
tornado==6.0.4
isodate==0.6.0
pytest==5.4.1

42
test.py Normal file
View file

@ -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()

3
test.sh Executable file
View file

@ -0,0 +1,3 @@
#!/bin/sh
python3 -m pytest test.py

69
util.py Normal file
View file

@ -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)