Passed
Push — main ( 283ae9...2f4122 )
by Jochen
02:02
created

weitersager.http.Application.wsgi_app()   A

Complexity

Conditions 2

Size

Total Lines 9
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 9
nop 2
dl 0
loc 9
ccs 8
cts 8
cp 1
crap 2
rs 9.95
c 0
b 0
f 0
1
"""
2
weitersager.http
3
~~~~~~~~~~~~~~~~
4
5
HTTP server to receive messages
6
7
:Copyright: 2007-2022 Jochen Kupperschmidt
8
:License: MIT, see LICENSE for details.
9
"""
10
11 1
from __future__ import annotations
12 1
from dataclasses import dataclass
13 1
from http import HTTPStatus
14 1
import logging
15 1
import sys
16 1
from typing import Optional
17 1
from wsgiref.simple_server import make_server, ServerHandler, WSGIServer
18
19 1
from werkzeug.exceptions import abort, HTTPException
20 1
from werkzeug.routing import Map, Rule
21 1
from werkzeug.wrappers import Request, Response
22
23 1
from .config import HttpConfig
24 1
from .signals import message_received
25 1
from .util import start_thread
26
27
28 1
logger = logging.getLogger(__name__)
29
30
31 1
@dataclass(frozen=True)
32
class Message:
33 1
    channel: str
34 1
    text: str
35
36
37 1
def create_app(api_tokens: set[str]):
38 1
    return Application(api_tokens)
39
40
41 1
class Application:
42 1
    def __init__(self, api_tokens: set[str]):
43 1
        self._api_tokens = api_tokens
44
45 1
        self._url_map = Map(
46
            [
47
                Rule('/', endpoint='root'),
48
            ]
49
        )
50
51 1
    def __call__(self, environ, start_response):
52 1
        return self.wsgi_app(environ, start_response)
53
54 1
    @Request.application
55
    def wsgi_app(self, request):
56 1
        adapter = self._url_map.bind_to_environ(request.environ)
57 1
        try:
58 1
            endpoint, values = adapter.match()
59 1
            handler = getattr(self, f'on_{endpoint}')
60 1
            return handler(request, **values)
61 1
        except HTTPException as exc:
62 1
            return exc
63
64 1
    def on_root(self, request):
65 1
        if self._api_tokens:
66 1
            api_token = _get_api_token(request.headers)
67 1
            if not api_token:
68 1
                abort(HTTPStatus.UNAUTHORIZED)
69
70 1
            if api_token not in self._api_tokens:
71 1
                abort(HTTPStatus.FORBIDDEN)
72
73 1
        if not request.is_json:
74
            abort(HTTPStatus.UNSUPPORTED_MEDIA_TYPE)
75
76 1
        try:
77 1
            message = _parse_json_message(request.json)
78 1
        except (KeyError, ValueError):
79 1
            logger.info(
80
                'Invalid message received from %s.', request.remote_addr
81
            )
82 1
            abort(HTTPStatus.BAD_REQUEST)
83
84 1
        message_received.send(
85
            channel_name=message.channel,
86
            text=message.text,
87
            source_ip_address=request.remote_addr,
88
        )
89
90 1
        return Response('', status=HTTPStatus.ACCEPTED)
91
92
93 1
def _get_api_token(headers) -> Optional[str]:
94 1
    authorization_value = headers.get('Authorization')
95 1
    if not authorization_value:
96 1
        return None
97
98 1
    prefix = 'Token '
99 1
    if not authorization_value.startswith(prefix):
100
        return None
101
102 1
    return authorization_value[len(prefix) :]
103
104
105 1
def _parse_json_message(data: dict[str, str]) -> Message:
106
    """Extract message from JSON."""
107 1
    channel = data['channel']
108 1
    text = data['text']
109
110 1
    return Message(channel=channel, text=text)
111
112
113
# Override value of `Server:` header sent by wsgiref.
114 1
ServerHandler.server_software = 'Weitersager'
115
116
117 1
def create_server(config: HttpConfig) -> WSGIServer:
118
    """Create the HTTP server."""
119 1
    app = create_app(config.api_tokens)
120
121 1
    return make_server(config.host, config.port, app)
122
123
124 1
def start_receive_server(config: HttpConfig) -> None:
125
    """Start in a separate thread."""
126
    try:
127
        server = create_server(config)
128
    except OSError as e:
129
        sys.stderr.write(f'Error {e.errno:d}: {e.strerror}\n')
130
        sys.stderr.write(
131
            f'Probably no permission to open port {config.port}. '
132
            'Try to specify a port number above 1,024 (or even '
133
            '4,096) and up to 65,535.\n'
134
        )
135
        sys.exit(1)
136
137
    thread_name = server.__class__.__name__
138
    start_thread(server.serve_forever, thread_name)
139
    logger.info('Listening for HTTP requests on %s:%d.', *server.server_address)
140