weitersager.http.Application.on_root()   A
last analyzed

Complexity

Conditions 4

Size

Total Lines 18
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 4

Importance

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