Merge branch 'boilerplate'
This commit is contained in:
commit
48799e9139
12 changed files with 381 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
.env
|
||||
|
||||
venv/
|
||||
.idea
|
26
Dockerfile
Normal file
26
Dockerfile
Normal 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
82
OAS3.yml
Normal 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
|
||||
|
|
@ -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
95
app.py
Normal 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
31
auth.py
Normal 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
16
docker-compose.yml
Normal 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
1
dotenv.template
Normal file
|
@ -0,0 +1 @@
|
|||
PORT=8080
|
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
@ -0,0 +1,3 @@
|
|||
tornado==6.0.4
|
||||
isodate==0.6.0
|
||||
pytest==5.4.1
|
42
test.py
Normal file
42
test.py
Normal 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
3
test.sh
Executable file
|
@ -0,0 +1,3 @@
|
|||
#!/bin/sh
|
||||
|
||||
python3 -m pytest test.py
|
69
util.py
Normal file
69
util.py
Normal 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)
|
Loading…
Reference in a new issue