• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

tcalmant / ipopo / 25075446803

28 Apr 2026 08:16PM UTC coverage: 84.544% (+1.2%) from 83.323%
25075446803

Pull #176

github

web-flow
Merge 1b4fad084 into 80da9234f
Pull Request #176: HTTP asynchronous server

667 of 786 new or added lines in 10 files covered. (84.86%)

14840 of 17553 relevant lines covered (84.54%)

3.38 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.8
/pelix/http/basic_async.py
1
#!/usr/bin/env python
2
# -- Content-Encoding: UTF-8 --
3
"""
4
Pelix basic asynchronous HTTP service bundle.
5

6
Provides an implementation of the Pelix HTTP service based on aiohttp.
7

8
:author: Thomas Calmant
9
:copyright: Copyright 2025, Thomas Calmant
10
:license: Apache License 2.0
11
:version: 3.1.0
12

13
..
14

15
    Copyright 2025 Thomas Calmant
16

17
    Licensed under the Apache License, Version 2.0 (the "License");
18
    you may not use this file except in compliance with the License.
19
    You may obtain a copy of the License at
20

21
        https://www.apache.org/licenses/LICENSE-2.0
22

23
    Unless required by applicable law or agreed to in writing, software
24
    distributed under the License is distributed on an "AS IS" BASIS,
25
    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
26
    See the License for the specific language governing permissions and
27
    limitations under the License.
28
"""
29

30
import asyncio
4✔
31
import concurrent.futures
4✔
32
import html
4✔
33
import io
4✔
34
import logging
4✔
35
import re
4✔
36
import socket
4✔
37
import ssl
4✔
38
import sys
4✔
39
import threading
4✔
40
import traceback
4✔
41
from typing import IO, TYPE_CHECKING, Any, Dict, List, Optional, Tuple, cast
4✔
42

43
import aiohttp.client_exceptions
4✔
44
import aiohttp.web
4✔
45

46
import pelix.constants as fw_constants
4✔
47
import pelix.http as http
4✔
48
import pelix.ipopo.constants as constants
4✔
49
import pelix.remote
4✔
50
import pelix.utilities as utilities
4✔
51
from pelix.internals.registry import ServiceReference
4✔
52
from pelix.ipopo.decorators import (
4✔
53
    BindField,
54
    ComponentFactory,
55
    HiddenProperty,
56
    Invalidate,
57
    Property,
58
    Provides,
59
    Requires,
60
    UnbindField,
61
    UpdateField,
62
    Validate,
63
)
64

65
if TYPE_CHECKING:
66
    from pelix.framework import BundleContext
67

68

69
# ------------------------------------------------------------------------------
70

71
# Module version
72
__version_info__ = (3, 1, 0)
4✔
73
__version__ = ".".join(str(x) for x in __version_info__)
4✔
74

75
# Documentation strings format
76
__docformat__ = "restructuredtext en"
4✔
77

78
# ------------------------------------------------------------------------------
79

80
HTTP_SERVICE_EXTRA = "http.extra"
4✔
81
""" HTTP service extra properties (dictionary) """
4✔
82

83
DEFAULT_BIND_ADDRESS = "0.0.0.0"
4✔
84
""" By default, bind to all IPv4 interfaces """
4✔
85

86
LOCALHOST_ADDRESS = "127.0.0.1"
4✔
87
"""
4✔
88
Local address, if None is given as binding address, instead of the default one
89
"""
90

91

92
class _SyncHTTPServletRequest(http.AbstractHTTPServletRequest):
4✔
93
    """
94
    HTTP Servlet request helper
95
    """
96

97
    def __init__(self, request: aiohttp.web.Request, full_path: str, prefix: str, content: bytes) -> None:
4✔
98
        """
99
        Sets up the request helper
100

101
        :param request: The aiohttp Request object
102
        :param full_path: The full request path, including the prefix
103
        :param prefix: The path to the servlet root
104
        :param content: The request content
105
        """
106
        self._request = request
4✔
107
        self._prefix = prefix
4✔
108
        self._content = content
4✔
109

110
        # Compute the sub path
111
        self._sub_path = full_path[len(prefix) :]
4✔
112
        if not self._sub_path.startswith("/"):
4✔
113
            self._sub_path = f"/{self._sub_path}"
4✔
114

115
        self._sub_path = re.sub("/+", "/", self._sub_path)
4✔
116

117
    def get_command(self) -> str:
4✔
118
        """
119
        Returns the HTTP verb (GET, POST, ...) used for the request
120
        """
121
        return self._request.method.upper()
4✔
122

123
    def get_client_address(self) -> Tuple[str, int]:
4✔
124
        """
125
        Retrieves the address of the client
126

127
        :return: A (host, port) tuple
128
        """
129
        if self._request.transport is None:
4✔
130
            # No transport, no address
NEW
131
            raise IOError("No transport available for the request")
×
132

133
        peer_name = self._request.transport.get_extra_info("peername")
4✔
134
        if not peer_name:
4✔
NEW
135
            raise IOError("No peer name available for the request")
×
136
        return peer_name[:2]
4✔
137

138
    def get_header(self, name: str, default: Optional[Any] = None) -> Any:
4✔
139
        """
140
        Retrieves the value of a header
141
        """
142
        return self._request.headers.get(name, default)
4✔
143

144
    def get_headers(self) -> Dict[str, Any]:
4✔
145
        """
146
        Retrieves all headers
147
        """
148
        return cast(Dict[str, Any], self._request.headers)
4✔
149

150
    def get_path(self) -> str:
4✔
151
        """
152
        Retrieves the request full path
153
        """
NEW
154
        return self._request.path
×
155

156
    def get_prefix_path(self) -> str:
4✔
157
        """
158
        Returns the path to the servlet root
159

160
        :return: A request path (string)
161
        """
162
        return self._prefix
4✔
163

164
    def get_sub_path(self) -> str:
4✔
165
        """
166
        Returns the servlet-relative path, i.e. after the prefix
167

168
        :return: A request path (string)
169
        """
170
        return self._sub_path
4✔
171

172
    def get_rfile(self) -> IO[bytes]:
4✔
173
        """
174
        Retrieves the input as a file stream
175
        """
NEW
176
        return io.BytesIO(self._content)
×
177

178

179
class _WriteWrapper(IO[bytes]):
4✔
180
    def __init__(self):
4✔
181
        self._buffer = io.BytesIO()
4✔
182
        self._closed = False
4✔
183

184
    def get(self) -> bytes:
4✔
185
        """
186
        Retrieves the written data as bytes.
187
        This method should be called after the response has been sent.
188

189
        :return: The written data
190
        """
191
        return self._buffer.getvalue()
4✔
192

193
    def read(self, size: int = -1) -> bytes:
4✔
NEW
194
        raise IOError("This stream is not readable")
×
195

196
    def write(self, b: bytes) -> int:
4✔
197
        return self._buffer.write(b)
4✔
198

199
    def seek(self, offset: int, whence: int = io.SEEK_SET) -> int:
4✔
NEW
200
        raise IOError("This stream is not seekable")
×
201

202
    def tell(self) -> int:
4✔
NEW
203
        raise IOError("This stream is not seekable")
×
204

205
    def close(self) -> None:
4✔
206
        self._closed = True
4✔
207

208
    def flush(self) -> None:
4✔
NEW
209
        pass
×
210

211
    def readable(self) -> bool:
4✔
NEW
212
        return False
×
213

214
    def writable(self) -> bool:
4✔
NEW
215
        return True
×
216

217
    def seekable(self) -> bool:
4✔
NEW
218
        return False
×
219

220
    @property
4✔
221
    def closed(self) -> bool:
4✔
NEW
222
        return self._closed
×
223

224

225
class _SyncHTTPServletResponse(http.AbstractHTTPServletResponse):
4✔
226
    """
227
    HTTP Servlet response helper
228
    """
229

230
    def __init__(self, request: aiohttp.web.Request, loop: asyncio.AbstractEventLoop) -> None:
4✔
231
        """
232
        Sets up the response helper
233

234
        :param request: The aiohttp Request object
235
        :param loop: The asyncio event loop
236
        """
237
        self._request = request
4✔
238
        self._loop = loop
4✔
239
        self._headers: Dict[str, str] = {}
4✔
240
        self._headers_set: bool = False
4✔
241
        self._code: int = 200
4✔
242
        self._message: str | None = None
4✔
243
        self._writer = _WriteWrapper()
4✔
244

245
    def to_aiohttp_response(self) -> aiohttp.web.StreamResponse:
4✔
246
        """
247
        Converts the response to an aiohttp StreamResponse object.
248
        This method should be called after all headers have been set.
249

250
        :return: The aiohttp StreamResponse object
251
        """
252
        return aiohttp.web.Response(
4✔
253
            body=self._writer.get(), status=self._code, reason=self._message, headers=self._headers
254
        )
255

256
    def set_response(self, code: int, message: Optional[str] = None) -> None:
4✔
257
        """
258
        Sets the response line.
259
        This method should be the first called when sending an answer.
260

261
        :param code: HTTP result code
262
        :param message: Associated message
263
        """
264
        if self._headers_set:
4✔
NEW
265
            raise IOError("Headers have already been set, cannot change the response code")
×
266

267
        self._code = code
4✔
268
        self._message = message
4✔
269

270
    def set_header(self, name: str, value: Any) -> None:
4✔
271
        """
272
        Sets the value of a header.
273
        This method should not be called after ``end_headers()``.
274

275
        :param name: Header name
276
        :param value: Header value
277
        """
278
        if self._headers_set:
4✔
NEW
279
            raise IOError("Headers have already been set, cannot change them")
×
280

281
        if value is None:
4✔
NEW
282
            self._headers.pop(name.lower(), None)
×
283
        else:
284
            self._headers[name.lower()] = str(value)
4✔
285

286
    def is_header_set(self, name: str) -> bool:
4✔
287
        """
288
        Checks if the given header has already been set
289

290
        :param name: Header name
291
        :return: True if it has already been set
292
        """
293
        return name.lower() in self._headers
4✔
294

295
    def end_headers(self) -> None:
4✔
296
        """
297
        Ends the headers part
298
        """
299
        self._headers_set = True
4✔
300

301
    def get_wfile(self) -> IO[bytes]:
4✔
302
        """
303
        Retrieves the output as a file stream.
304
        ``end_headers()`` should have been called before, except if you want
305
        to write your own headers.
306

307
        :return: The output file-like object
308
        """
309
        if not self._headers_set:
4✔
NEW
310
            self.end_headers()
×
311

312
        return self._writer
4✔
313

314
    def write(self, data: bytes) -> None:
4✔
315
        """
316
        Writes the given data.
317
        ``end_headers()`` should have been called before, except if you want
318
        to write your own headers.
319

320
        :param data: Data to be written
321
        """
322
        writer = self.get_wfile()
4✔
323
        writer.write(data)
4✔
324
        writer.close()
4✔
325

326

327
# ------------------------------------------------------------------------------
328

329

330
class _AsyncHTTPServletRequest(http.AbstractAsyncHTTPServletRequest):
4✔
331
    """
332
    HTTP Servlet request helper
333
    """
334

335
    def __init__(self, request: aiohttp.web.Request, full_path: str, prefix: str) -> None:
4✔
336
        """
337
        Sets up the request helper
338

339
        :param request: The aiohttp Request object
340
        :param full_path: The full request path, including the prefix
341
        :param prefix: The path to the servlet root
342
        """
343
        self._request = request
4✔
344
        self._prefix = prefix
4✔
345

346
        # Compute the sub path
347
        self._sub_path = full_path[len(prefix) :]
4✔
348
        if not self._sub_path.startswith("/"):
4✔
349
            self._sub_path = f"/{self._sub_path}"
4✔
350

351
        self._sub_path = re.sub("/+", "/", self._sub_path)
4✔
352

353
    def get_command(self) -> str:
4✔
354
        """
355
        Returns the HTTP verb (GET, POST, ...) used for the request
356
        """
NEW
357
        return self._request.method.upper()
×
358

359
    def get_client_address(self) -> Tuple[str, int]:
4✔
360
        """
361
        Retrieves the address of the client
362

363
        :return: A (host, port) tuple
364
        """
365
        if self._request.transport is None:
4✔
366
            # No transport, no address
NEW
367
            raise IOError("No transport available for the request")
×
368

369
        peer_name = self._request.transport.get_extra_info("peername")
4✔
370
        if not peer_name:
4✔
NEW
371
            raise IOError("No peer name available for the request")
×
372
        return peer_name[:2]
4✔
373

374
    async def get_header(self, name: str, default: Optional[Any] = None) -> Any:
4✔
375
        """
376
        Retrieves the value of a header
377
        """
378
        return self._request.headers.get(name, default)
4✔
379

380
    async def get_headers(self) -> Dict[str, Any]:
4✔
381
        """
382
        Retrieves all headers
383
        """
384
        return cast(Dict[str, Any], self._request.headers)
4✔
385

386
    def get_path(self) -> str:
4✔
387
        """
388
        Retrieves the request full path
389
        """
NEW
390
        return self._request.path
×
391

392
    def get_prefix_path(self) -> str:
4✔
393
        """
394
        Returns the path to the servlet root
395

396
        :return: A request path (string)
397
        """
NEW
398
        return self._prefix
×
399

400
    def get_sub_path(self) -> str:
4✔
401
        """
402
        Returns the servlet-relative path, i.e. after the prefix
403

404
        :return: A request path (string)
405
        """
NEW
406
        return self._sub_path
×
407

408
    def get_rfile(self) -> asyncio.StreamReader:
4✔
409
        """
410
        Retrieves the input as a file stream
411
        """
412
        # aiohttp StreamReader is compatible with the asyncio one
NEW
413
        return cast(asyncio.StreamReader, self._request.content)
×
414

415

416
class _AioHttpWriter(http.AbstractAsyncWriter):
4✔
417
    """
418
    Wrapper for aiohttp StreamResponse
419
    """
420

421
    def __init__(self, response: aiohttp.web.StreamResponse) -> None:
4✔
NEW
422
        self._response = response
×
423

424
    async def write(self, raw: bytes) -> int:
4✔
NEW
425
        await self._response.write(raw)
×
NEW
426
        return len(raw)
×
427

428
    async def flush(self) -> None:
4✔
NEW
429
        await self._response.drain()
×
430

431

432
class _AsyncHTTPServletResponse(http.AbstractAsyncHTTPServletResponse):
4✔
433
    """
434
    HTTP Servlet response helper
435
    """
436

437
    def __init__(self, request: aiohttp.web.Request) -> None:
4✔
438
        """
439
        Sets up the response helper
440

441
        :param request: The aiohttp Request object
442
        """
443
        self._request = request
4✔
444
        self._headers_set: bool = False
4✔
445
        self._sse_set: bool = False
4✔
446
        self._response = aiohttp.web.StreamResponse()
4✔
447

448
    def to_aiohttp_response(self) -> aiohttp.web.StreamResponse:
4✔
449
        """
450
        Converts the response to an aiohttp StreamResponse object.
451
        This method should be called after all headers have been set.
452

453
        :return: The aiohttp StreamResponse object
454
        """
455
        return self._response
4✔
456

457
    def set_response(self, code: int, message: Optional[str] = None) -> None:
4✔
458
        """
459
        Sets the response line.
460
        This method should be the first called when sending an answer.
461

462
        :param code: HTTP result code
463
        :param message: Associated message
464
        """
465
        if self._headers_set:
4✔
NEW
466
            raise IOError("Headers have already been set, cannot change the response code")
×
467

468
        self._response.set_status(code, message)
4✔
469

470
    def set_header(self, name: str, value: Any) -> None:
4✔
471
        """
472
        Sets the value of a header.
473
        This method should not be called after ``end_headers()``.
474

475
        :param name: Header name
476
        :param value: Header value
477
        """
478
        if self._headers_set:
4✔
NEW
479
            raise IOError("Headers have already been set, cannot change them")
×
480

481
        if value is None:
4✔
NEW
482
            self._response.headers.popall(name.lower(), None)
×
483
        else:
484
            self._response.headers.add(name.lower(), str(value))
4✔
485

486
    def is_header_set(self, name: str) -> bool:
4✔
487
        """
488
        Checks if the given header has already been set
489

490
        :param name: Header name
491
        :return: True if it has already been set
492
        """
493
        return self._response.headers.get(name.lower(), None) is not None
4✔
494

495
    def setup_sse(self, strict: bool = True) -> None:
4✔
496
        """
497
        Sets up the response for Server-Sent Events (SSE)
498

499
        :param strict: If True, raises an error if the request is not for SSE
500
        """
501
        if self._sse_set:
4✔
502
            # Already set up for SSE
NEW
503
            return
×
504

505
        if strict and not any(
4✔
506
            "text/event-stream" in accepted for accepted in self._request.headers.getall("accept", "")
507
        ):
NEW
508
            raise ValueError("Cannot set up SSE for a non-SSE request")
×
509

510
        if self._headers_set:
4✔
NEW
511
            raise IOError("Headers have already been set, cannot change them")
×
512

513
        self._response.headers["Content-Type"] = "text/event-stream"
4✔
514
        self._response.headers["Cache-Control"] = "no-cache"
4✔
515
        self._response.headers["Connection"] = "keep-alive"
4✔
516
        self._sse_set = True
4✔
517

518
    async def end_headers(self) -> None:
4✔
519
        """
520
        Ends the headers part
521
        """
522
        self._headers_set = True
4✔
523
        await self._response.prepare(self._request)
4✔
524

525
    def get_wfile(self) -> http.AbstractAsyncWriter:
4✔
526
        """
527
        Retrieves the output as a writer.
528
        ``end_headers()`` should have been called before.
529

530
        :return: A writer for the output stream
531
        """
NEW
532
        return _AioHttpWriter(self._response)
×
533

534
    async def write(self, data: bytes) -> None:
4✔
535
        """
536
        Writes the given data.
537
        ``end_headers()`` should have been called before, except if you want
538
        to write your own headers.
539

540
        :param data: Data to be written
541
        """
542
        await self._response.write(data)
4✔
543

544
    async def send_sse(self, event: str | None = None, data: str | None = None, id: str | None = None) -> None:
4✔
545
        """
546
        Sends a Server-Sent Event (SSE) message.
547

548
        :param event: Optional event type (e.g., "message", "update")
549
        :param data: The event data (without newline characters)
550
        :param id: Optional event ID (set to "" to reset the ID)
551
        """
552
        if not self._sse_set:
4✔
NEW
553
            raise IOError("SSE not set up, call setup_sse() first")
×
554

555
        # Prepare the SSE message
556
        parts: list[str] = []
4✔
557
        if id:
4✔
NEW
558
            parts.append(f"id: {id}")
×
559
        elif id is not None:
4✔
560
            # Reset ID
NEW
561
            parts.append("id")
×
562

563
        if event:
4✔
NEW
564
            parts.append(f"event: {event}")
×
565
        elif event is not None:
4✔
NEW
566
            parts.append("event")
×
567

568
        if data:
4✔
569
            # Split the data into lines and prefix each line with "data: "
570
            for line in data.splitlines() or [""]:
4✔
571
                parts.append(f"data: {line}")
4✔
572
        else:
573
            # Empty data line
NEW
574
            parts.append("data")
×
575

576
        # End of the event
577
        parts.append("")
4✔
578
        parts.append("")
4✔
579

580
        try:
4✔
581
            await self.write("\n".join(parts).encode("utf-8"))
4✔
582
        except aiohttp.client_exceptions.ClientConnectionResetError:
4✔
583
            raise IOError("Client connection reset during SSE send") from None
4✔
584

585

586
class WSSession(http.WebSocketSession):
4✔
587
    def __init__(
4✔
588
        self,
589
        ws_handler: http.WebSocketHandler,
590
        servlet_request: _AsyncHTTPServletRequest,
591
        ws_response: aiohttp.web.WebSocketResponse,
592
    ) -> None:
593
        """
594
        Initializes the WebSocket session
595

596
        :param ws_handler: The WebSocket handler
597
        :param servlet_request: The servlet request
598
        :param ws_response: The WebSocket response
599
        """
600
        self._handler = ws_handler
4✔
601
        self._request = servlet_request
4✔
602
        self._response = ws_response
4✔
603

604
    def get_client_address(self) -> Tuple[str, int]:
4✔
605
        """
606
        Returns the address of the client
607

608
        :return: A (host, port) tuple
609
        """
NEW
610
        return self._request.get_client_address()
×
611

612
    async def send_binary(self, message: bytes) -> None:
4✔
613
        """
614
        Sends a binary message to the client
615

616
        :param message: Binary message to send
617
        """
NEW
618
        if self._response.closed:
×
NEW
619
            raise IOError("WebSocket session is closed")
×
620

NEW
621
        await self._response.send_bytes(message)
×
622

623
    async def send_text(self, message: str) -> None:
4✔
624
        """
625
        Sends a message to the client
626

627
        :param message: Message to send
628
        """
629
        if self._response.closed:
4✔
NEW
630
            raise IOError("WebSocket session is closed")
×
631

632
        await self._response.send_str(message)
4✔
633

634
    async def close(self, code: int = 1000, reason: str | None = None) -> None:
4✔
635
        """
636
        Closes the WebSocket session
637

638
        :param code: Close code (default is 1000, normal closure)
639
        :param reason: Optional reason for the closure
640
        """
641
        if not self._response.closed:
4✔
642
            await self._response.close(code=code, message=(reason or "").encode("utf-8"))
4✔
643

644

645
# ------------------------------------------------------------------------------
646

647

648
@ComponentFactory(http.FACTORY_HTTP_ASYNC)
4✔
649
@Provides(http.HTTP_SERVICE)
4✔
650
@Requires("_servlets_services", http.Servlet, True, True)
4✔
651
@Requires("_servlets_async_services", http.AsyncServlet, True, True)
4✔
652
@Requires("_websocket_handler_services", http.WebSocketHandler, True, True)
4✔
653
@Requires("_error_handler", http.ErrorHandler, optional=True)
4✔
654
@Property("_address", http.HTTP_SERVICE_ADDRESS, DEFAULT_BIND_ADDRESS)
4✔
655
@Property("_port", http.HTTP_SERVICE_PORT, 8080)
4✔
656
@Property("_uses_ssl", http.HTTP_USES_SSL, False)
4✔
657
@Property("_cert_file", http.HTTPS_CERT_FILE, None)
4✔
658
@Property("_key_file", http.HTTPS_KEY_FILE, None)
4✔
659
@HiddenProperty("_key_password", http.HTTPS_KEY_PASSWORD, None)
4✔
660
@Property("_extra", HTTP_SERVICE_EXTRA, None)
4✔
661
@Property("_instance_name", constants.IPOPO_INSTANCE_NAME)
4✔
662
@Property("_logger_name", "pelix.http.logger.name", "")
4✔
663
@Property("_logger_level", "pelix.http.logger.level", None)
4✔
664
class AsyncHttpServiceImpl(http.HTTPService):
4✔
665
    """
666
    Asynchronous HTTP service component
667
    """
668

669
    def __init__(self) -> None:
4✔
670
        # Properties
671
        self._address = "0.0.0.0"
4✔
672
        self._port = 8080
4✔
673
        self._uses_ssl = False
4✔
674
        self._extra: Optional[Dict[str, Any]] = None
4✔
675
        self._instance_name: Optional[str] = None
4✔
676
        self._logger_name: Optional[str] = None
4✔
677
        self._logger_level: str | int | None = None
4✔
678

679
        # SSL Parameters
680
        self._cert_file: Optional[str] = None
4✔
681
        self._key_file: Optional[str] = None
4✔
682
        self._key_password: Optional[str] = None
4✔
683

684
        # Validation flag
685
        self._validated = False
4✔
686

687
        # The logger
688
        self._logger: logging.Logger = logging.getLogger(f"{__name__}#init")
4✔
689

690
        # Servlets registry lock
691
        self._lock = threading.RLock()
4✔
692

693
        # Path -> (servlet, parameters, type)
694
        self._servlets: Dict[
4✔
695
            str, Tuple[http.Servlet | http.AsyncServlet, Dict[str, Any], http.ServletType]
696
        ] = {}
697

698
        # Fields injected by iPOPO
699
        self._servlets_services: List[http.Servlet] = []
4✔
700
        self._servlets_async_services: List[http.AsyncServlet] = []
4✔
701
        self._websocket_handler_services: List[http.WebSocketHandler] = []
4✔
702
        self._error_handler: Optional[http.ErrorHandler] = None
4✔
703

704
        # Servlet -> ServiceReference
705
        self._servlets_refs: Dict[
4✔
706
            http.Servlet | http.AsyncServlet | http.WebSocketHandler,
707
            ServiceReference[http.Servlet | http.AsyncServlet | http.WebSocketHandler],
708
        ] = {}
709
        self._binding_lock = threading.RLock()
4✔
710

711
        # Server control
712
        self._bound_address: tuple[str, int] | None = None
4✔
713
        self._app: aiohttp.web.Application | None = None
4✔
714
        self._thread: threading.Thread | None = None
4✔
715
        self._executor: concurrent.futures.ThreadPoolExecutor | None = None
4✔
716
        self._loop: asyncio.AbstractEventLoop | None = None
4✔
717
        self._start_done_event: utilities.EventData[tuple[str, int]] = utilities.EventData()
4✔
718
        self._stop_event: asyncio.Event = asyncio.Event()
4✔
719
        self._stop_done_event: threading.Event = threading.Event()
4✔
720

721
    def __str__(self) -> str:
4✔
722
        """
723
        String representation of the instance
724
        """
NEW
725
        return f"BasicHttpService({self._address}, {self._port})"
×
726

727
    @Validate
4✔
728
    def validate(self, context: "BundleContext") -> None:
4✔
729
        """
730
        Component validated
731
        """
732
        # Check if we'll use an SSL connection
733
        self._uses_ssl = self._cert_file is not None
4✔
734

735
        if not self._address:
4✔
736
            # No address given, use the localhost address
737
            self._address = LOCALHOST_ADDRESS
4✔
738

739
        if self._port is None:
4✔
740
            # Random port
741
            self._port = 0
4✔
742
        else:
743
            # Ensure we have an integer
744
            self._port = int(self._port)
4✔
745
            if self._port < 0:
4✔
746
                # Random port
NEW
747
                self._port = 0
×
748

749
        # Normalize the extra properties
750
        if not isinstance(self._extra, dict):
4✔
751
            self._extra = {}
4✔
752

753
        # Set up the logger
754
        if not self._logger_name:
4✔
755
            # Empty name, use the instance name
756
            self._logger_name = self._instance_name
4✔
757

758
        self._logger = logging.getLogger(self._logger_name)
4✔
759

760
        level: int | None = None
4✔
761
        if self._logger_level is None:
4✔
762
            level = logging.INFO
4✔
763
        elif isinstance(self._logger_level, int):
4✔
NEW
764
            level = self._logger_level
×
765
        else:
766
            level = utilities.get_log_level(self._logger_level)
4✔
767
            if level is None:
4✔
NEW
768
                try:
×
NEW
769
                    level = int(self._logger_level)
×
NEW
770
                except ValueError:
×
771
                    # Invalid level
NEW
772
                    level = None
×
773

774
        self._logger.level = level if level is not None else logging.INFO
4✔
775

776
        self.log(
4✔
777
            logging.INFO,
778
            "Starting HTTP%s server: [%s]:%d ...",
779
            "S" if self._uses_ssl else "",
780
            self._address,
781
            self._port,
782
        )
783

784
        # Create the server
785
        app = aiohttp.web.Application(logger=self._logger)
4✔
786
        app.add_routes([aiohttp.web.route("*", "/{tail:.*}", self.__global_handler)])
4✔
787
        self._app = app
4✔
788

789
        # Start the server in a separate thread
790
        self._stop_event.clear()
4✔
791
        self._stop_done_event.clear()
4✔
792
        self._start_done_event.clear()
4✔
793
        self._thread = threading.Thread(target=self._run_server_thread, name="Pelix Async HTTP Server Thread")
4✔
794
        self._thread.daemon = True
4✔
795
        self._thread.start()
4✔
796

797
        # Wait for the server to be ready
798
        if not self._start_done_event.wait(10):
4✔
NEW
799
            self._logger.error("HTTP server did not start in time")
×
NEW
800
            raise IOError("HTTP server did not start in time")
×
801

802
        if self._start_done_event.data is None:
4✔
NEW
803
            self._logger.error("HTTP server did not bind to an address")
×
NEW
804
            raise IOError("HTTP server did not bind to an address")
×
805

806
        host, port = self._start_done_event.data
4✔
807
        self._bound_address = (host, port)
4✔
808
        self._port = port
4✔
809

810
        with self._binding_lock:
4✔
811
            # Set the validation flag up, once the server is ready
812
            self._validated = True
4✔
813

814
            # Register bound servlets
815
            for service, svc_ref in self._servlets_refs.items():
4✔
NEW
816
                self.__register_servlet_service(service, svc_ref)
×
817

818
        self._logger.info(
4✔
819
            "HTTP%s server bound to: [%s]:%d ...",
820
            "S" if self._uses_ssl else "",
821
            self._address,
822
            self._port,
823
        )
824

825
    @Invalidate
4✔
826
    def invalidate(self, context: "BundleContext") -> None:
4✔
827
        """
828
        Component invalidated
829
        """
830
        # Clear the validation flag
831
        self._validated = False
4✔
832

833
        self.log(
4✔
834
            logging.INFO,
835
            "Stopping HTTP%s server: [%s]:%d ...",
836
            "S" if self._uses_ssl else "",
837
            self._address,
838
            self._port,
839
        )
840

841
        # Set the stop event
842
        if self._loop is not None:
4✔
843
            # Call for a stop
844
            async def async_stop_caller():
4✔
845
                self._stop_event.set()
4✔
846

847
            asyncio.run_coroutine_threadsafe(async_stop_caller(), self._loop).result()
4✔
848

849
        # Wait for the stop done event to be set
850
        self._logger.debug("Waiting for the stop done event to be set...")
4✔
851
        if not self._stop_done_event.wait(timeout=5):
4✔
NEW
852
            self.log(
×
853
                logging.WARNING,
854
                "The stop done event was not set in time, the server may not have stopped properly",
855
            )
856

857
        # Wait for the server thread to stop
858
        if self._thread is not None and self._thread.is_alive():
4✔
NEW
859
            self._thread.join(timeout=0.5)
×
NEW
860
            if self._thread.is_alive():
×
NEW
861
                self.log(logging.WARNING, "HTTP server thread did not stop in time")
×
862

863
        self._thread = None
4✔
864

865
        # Close the event loop
866
        if self._loop is not None and not self._loop.is_closed():
4✔
867
            self._loop.close()
4✔
868

869
        # Clear references
870
        self._loop = None
4✔
871
        self._app = None
4✔
872

873
    def _run_server_thread(self) -> None:
4✔
874
        """
875
        Runs the HTTP server in a separate thread.
876
        """
877
        try:
4✔
878
            # Set the event loop for this thread
879
            if sys.platform.startswith("win"):
4✔
880
                # aiodns requires a specific event loop on Windows
NEW
881
                asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
×
882

883
            self._loop = asyncio.new_event_loop()
4✔
884
            asyncio.set_event_loop(self._loop)
4✔
885

886
            # Setup the server
887
            self._loop.run_until_complete(self._run_server())
4✔
NEW
888
        except Exception:
×
NEW
889
            self._logger.exception("Error running async HTTP server")
×
890
        finally:
891
            if self._loop is not None:
4✔
892
                self._loop.stop()
4✔
893
            self._stop_done_event.set()
4✔
894

895
    async def _run_server(self) -> None:
4✔
896
        try:
4✔
897
            assert self._app is not None, "Application must be initialized before running the server"
4✔
898

899
            # Create the server
900
            runner = aiohttp.web.AppRunner(self._app)
4✔
901
            await runner.setup()
4✔
902

903
            # Prepare SSL context if needed
904
            ssl_context: ssl.SSLContext | None = None
4✔
905
            if self._uses_ssl:
4✔
906
                assert self._cert_file is not None, "Certificate file must be set for HTTPS"
4✔
907

908
                # Create the SSL context
909
                ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
4✔
910
                ssl_context.load_cert_chain(
4✔
911
                    certfile=self._cert_file, keyfile=self._key_file, password=self._key_password
912
                )
913

914
            # Create the site
915
            site = aiohttp.web.TCPSite(runner, self._address, self._port, ssl_context=ssl_context)
4✔
916

917
            with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
4✔
918
                self._executor = executor
4✔
919

920
                # Start the site
921
                await site.start()
4✔
922

923
                # Get bound address and port
924
                sock = cast(asyncio.Server, site._server).sockets[0]
4✔
925
                host, port = sock.getsockname()[:2]
4✔
926

927
                # We're ready
928
                self._start_done_event.set((host, port))
4✔
929

930
                # Keep the thread alive until the server is stopped
931
                await self._stop_event.wait()
4✔
932

933
                # Clean up the server
934
                await site.stop()
4✔
935
                await runner.shutdown()
4✔
936
                await runner.cleanup()
4✔
937
                await self._app.shutdown()
4✔
938
                await self._app.cleanup()
4✔
NEW
939
        except Exception as ex:
×
940
            # Anything went wrong, log the error
NEW
941
            self._logger.error("Error running the HTTP server: %s", ex)
×
NEW
942
            self._start_done_event.raise_exception(ex)
×
943

944
    async def __global_handler(self, request: aiohttp.web.Request) -> aiohttp.web.StreamResponse:
4✔
945
        """
946
        Global handler for the Pelix async HTTP service.
947

948
        :param request: The incoming request
949
        :return: The response to send
950
        """
951
        assert self._loop is not None, "EventLoop must be initialized before handling requests"
4✔
952

953
        if self._executor is None:
4✔
954
            # No executor available, cannot handle the request
NEW
955
            return aiohttp.web.Response(status=503, text="Service unavailable")
×
956

957
        # Remove the double-slashes in the request path
958
        path = re.sub("/+", "/", request.path)
4✔
959

960
        # Get the corresponding servlet
961
        found_servlet = self.get_servlet(path)
4✔
962
        if found_servlet is not None:
4✔
963
            servlet, _, prefix, servlet_type = found_servlet
4✔
964

965
            async_name = f"do_async_{request.method.upper()}"
4✔
966
            sync_name = f"do_{request.method.upper()}"
4✔
967

968
            try:
4✔
969
                match servlet_type:
4✔
970
                    case http.ServletType.ASYNC if hasattr(servlet, async_name):
4✔
971
                        # Prepare the helpers
972
                        servlet_request = _AsyncHTTPServletRequest(request, path, prefix)
4✔
973
                        servlet_response = _AsyncHTTPServletResponse(request)
4✔
974

975
                        # Handle the request
976
                        handler_method = getattr(servlet, async_name)
4✔
977
                        await handler_method(servlet_request, servlet_response)
4✔
978
                        return servlet_response.to_aiohttp_response()
4✔
979

980
                    case http.ServletType.SYNC if hasattr(servlet, sync_name):
4✔
981
                        # Read the request content
982
                        # FIXME: find a better way to handle the content, wrapping the request
983
                        #        in a file-like object
984
                        content = await request.read()
4✔
985

986
                        # Prepare the helpers
987
                        servlet_request = _SyncHTTPServletRequest(request, path, prefix, content)
4✔
988
                        servlet_response = _SyncHTTPServletResponse(request, self._loop)
4✔
989

990
                        # Handle the request in the executor
991
                        handler_method = getattr(servlet, sync_name)
4✔
992
                        await self._loop.run_in_executor(
4✔
993
                            self._executor, handler_method, servlet_request, servlet_response
994
                        )
995
                        return servlet_response.to_aiohttp_response()
4✔
996

997
                    case http.ServletType.WEBSOCKET if isinstance(servlet, http.WebSocketHandler):
4✔
998
                        # Prepare the WebSocket handler
999
                        ws_handler = cast(http.WebSocketHandler, servlet)
4✔
1000
                        servlet_request = _AsyncHTTPServletRequest(request, path, prefix)
4✔
1001

1002
                        # Prepare the WebSocket response
1003
                        ws_response = aiohttp.web.WebSocketResponse()
4✔
1004

1005
                        # Prepare a session
1006
                        ws_session = WSSession(ws_handler, servlet_request, ws_response)
4✔
1007

1008
                        # Early check
1009
                        if not await ws_handler.ws_accept(servlet_request):
4✔
1010
                            # The handler does not accept the WebSocket connection
NEW
1011
                            return aiohttp.web.Response(status=400, text="WebSocket connection refused")
×
1012

1013
                        # Prepare the WebSocket response
1014
                        await ws_response.prepare(request)
4✔
1015

1016
                        try:
4✔
1017
                            # Notify the WebSocket handler of the new connection
1018
                            await ws_handler.ws_open(ws_session, servlet_request)
4✔
1019

1020
                            async for msg in ws_response:
4✔
1021
                                # Handle incoming messages
1022
                                match msg.type:
4✔
1023
                                    case aiohttp.WSMsgType.ERROR:
4✔
1024
                                        # Error message received
NEW
1025
                                        self._logger.error("WebSocket error: %s", ws_response.exception())
×
NEW
1026
                                        await ws_handler.ws_error(ws_session, msg.data)
×
1027

1028
                                    case aiohttp.WSMsgType.PING:
4✔
1029
                                        # Ping message received
NEW
1030
                                        await ws_response.pong(msg.data)
×
1031

1032
                                    case aiohttp.WSMsgType.BINARY:
4✔
1033
                                        # Binary message received
NEW
1034
                                        await ws_handler.ws_binary(ws_session, msg.data)
×
1035

1036
                                    case aiohttp.WSMsgType.TEXT:
4✔
1037
                                        # Text message received
1038
                                        await ws_handler.ws_message(ws_session, msg.data)
4✔
1039
                            else:
1040
                                code = ws_response.close_code or aiohttp.WSCloseCode.GOING_AWAY
4✔
1041
                                await ws_handler.ws_close(ws_session, code, "Session closed")
4✔
NEW
1042
                        except Exception as ex:
×
NEW
1043
                            self._logger.exception("Error handling WebSocket connection: %s", ex)
×
NEW
1044
                            await ws_handler.ws_error(ws_session, str(ex))
×
1045
                        finally:
1046
                            if not ws_response.closed:
4✔
NEW
1047
                                await ws_response.close()
×
1048

1049
                        return ws_response
4✔
1050
            except Exception:
4✔
1051
                # Send a 500 error page on error
1052
                self._logger.exception("Error handling %s request to %s", request.method, path)
4✔
1053
                return self.send_exception(path)
4✔
1054

1055
        # Return the super implementation if needed
1056
        return aiohttp.web.Response(status=404, text=self.make_not_found_page(path), content_type="text/html")
4✔
1057

1058
    def send_exception(self, path: str) -> aiohttp.web.Response:
4✔
1059
        """
1060
        Sends an exception page with a 500 error code.
1061
        Must be called from inside the exception handling block.
1062

1063
        :param path: Erroneous request path
1064
        :return: The aiohttp Response to send
1065
        """
1066
        # Get a formatted stack trace
1067
        stack = traceback.format_exc()
4✔
1068

1069
        # Log the error
1070
        self._logger.error("Error handling request upon: %s\n%s\n", path, stack)
4✔
1071

1072
        # Send the page
1073
        return aiohttp.web.Response(status=500, text=self.make_exception_page(path, stack))
4✔
1074

1075
    def __safe_callback(self, instance: http.Servlet, method: str, *args: Any, **kwargs: Any) -> Any:
4✔
1076
        """
1077
        Safely calls the given method in the given instance.
1078
        Returns True on method absence.
1079
        Returns False on error.
1080
        Returns the method result if found.
1081

1082
        :param instance: The instance to call
1083
        :param method: The method to call in the instance
1084
        :return: The method result or True on method absence or False on error
1085
        """
1086
        # Call back the method
1087
        if instance is None:
4✔
1088
            # Consider invalidity as a failure
NEW
1089
            return False
×
1090

1091
        try:
4✔
1092
            callback = getattr(instance, method)
4✔
1093
        except AttributeError:
4✔
1094
            # Consider absence as a success
1095
            return True
4✔
1096

1097
        try:
4✔
1098
            result = callback(*args, **kwargs)
4✔
1099
            if result is None:
4✔
1100
                # Special case: consider None as success
1101
                return True
4✔
1102

1103
            return result
4✔
1104

1105
        except Exception as ex:
4✔
1106
            self.log_exception("Error calling back an instance: %s", ex)
4✔
1107

1108
        return False
4✔
1109

1110
    def __register_servlet_service(
4✔
1111
        self,
1112
        service: http.Servlet | http.AsyncServlet | http.WebSocketHandler,
1113
        service_reference: ServiceReference[http.Servlet | http.AsyncServlet | http.WebSocketHandler],
1114
    ) -> None:
1115
        """
1116
        Registers a servlet according to its service properties
1117

1118
        :param service: A servlet service
1119
        :param service_reference: The associated ServiceReference
1120
        """
1121
        spec = cast(list[str], service_reference.get_property(fw_constants.OBJECTCLASS))
4✔
1122
        if http.HTTP_SERVLET in spec:
4✔
1123
            # Servlet bound
1124
            sync_servlet = cast(http.Servlet, service)
4✔
1125
            paths = service_reference.get_property(http.HTTP_SERVLET_PATH)
4✔
1126
            if utilities.is_string(paths):
4✔
1127
                # Register the servlet to a single path
1128
                self.register_servlet(paths, sync_servlet, {}, http.ServletType.SYNC)
4✔
1129
            elif isinstance(paths, (list, tuple)):
4✔
1130
                # Register the servlet to multiple paths
1131
                for path in paths:
4✔
1132
                    self.register_servlet(path, sync_servlet, {}, http.ServletType.SYNC)
4✔
1133

1134
        # No else here: a service could implement both specifications
1135
        if http.HTTP_SERVLET_ASYNC in spec:
4✔
1136
            # Asynchronous servlet bound
1137
            async_servlet = cast(http.AsyncServlet, service)
4✔
1138
            paths = service_reference.get_property(http.HTTP_SERVLET_ASYNC_PATH)
4✔
1139
            if utilities.is_string(paths):
4✔
1140
                # Register the servlet to a single path
1141
                self.register_servlet(paths, async_servlet, {}, http.ServletType.ASYNC)
4✔
1142
            elif isinstance(paths, (list, tuple)):
4✔
1143
                # Register the servlet to multiple paths
1144
                for path in paths:
4✔
1145
                    self.register_servlet(path, async_servlet, {}, http.ServletType.ASYNC)
4✔
1146

1147
        # No else here: a service could implement both specifications
1148
        if http.HTTP_WEBSOCKET_HANDLER in spec:
4✔
1149
            # WebSocket handler bound
NEW
1150
            websocket_handler = cast(http.WebSocketHandler, service)
×
NEW
1151
            paths = service_reference.get_property(http.HTTP_WEBSOCKET_PATH)
×
NEW
1152
            if utilities.is_string(paths):
×
1153
                # Register the WebSocket handler to a single path
NEW
1154
                self.register_servlet(paths, websocket_handler, {}, http.ServletType.WEBSOCKET)
×
NEW
1155
            elif isinstance(paths, (list, tuple)):
×
1156
                # Register the WebSocket handler to multiple paths
NEW
1157
                for path in paths:
×
NEW
1158
                    self.register_servlet(path, websocket_handler, {}, http.ServletType.WEBSOCKET)
×
1159

1160
    @BindField("_servlets_services")
4✔
1161
    @BindField("_servlets_async_services")
4✔
1162
    @BindField("_websocket_handler_services")
4✔
1163
    def _bind_servlet(
4✔
1164
        self,
1165
        _: str,
1166
        service: http.Servlet | http.AsyncServlet | http.WebSocketHandler,
1167
        service_reference: ServiceReference[http.Servlet | http.AsyncServlet | http.WebSocketHandler],
1168
    ) -> None:
1169
        """
1170
        Called by iPOPO when a service is bound
1171
        """
1172
        # Ignore imported services
1173
        if self.__is_imported(service_reference):
4✔
NEW
1174
            self._logger.debug("Ignoring imported service as it is imported: %s", service_reference)
×
NEW
1175
            return
×
1176

1177
        with self._binding_lock:
4✔
1178
            self._servlets_refs[service] = service_reference
4✔
1179

1180
            if self._validated:
4✔
1181
                # We've been validated, register the service
1182
                self.__register_servlet_service(service, service_reference)
4✔
1183

1184
    @UpdateField("_servlets_services")
4✔
1185
    @UpdateField("_servlets_async_services")
4✔
1186
    @UpdateField("_websocket_handler_services")
4✔
1187
    def _update_servlet(
4✔
1188
        self,
1189
        _: str,
1190
        service: http.Servlet | http.AsyncServlet | http.WebSocketHandler,
1191
        service_reference: ServiceReference[http.Servlet | http.AsyncServlet | http.WebSocketHandler],
1192
        old_properties: Dict[str, Any],
1193
    ) -> None:
1194
        """
1195
        Called by iPOPO when the properties of a service have been updated
1196
        """
1197
        # Ignore imported services
1198
        if self.__is_imported(service_reference):
4✔
NEW
1199
            return
×
1200
        # Check if the property concerns the registration
1201
        old_path = old_properties.get(http.HTTP_SERVLET_PATH)
4✔
1202
        new_path = service_reference.get_property(http.HTTP_SERVLET_PATH)
4✔
1203

1204
        old_async_path = old_properties.get(http.HTTP_SERVLET_ASYNC_PATH)
4✔
1205
        new_async_path = service_reference.get_property(http.HTTP_SERVLET_ASYNC_PATH)
4✔
1206

1207
        old_ws_path = old_properties.get(http.HTTP_WEBSOCKET_PATH)
4✔
1208
        new_ws_path = service_reference.get_property(http.HTTP_WEBSOCKET_PATH)
4✔
1209

1210
        if old_path == new_path and old_async_path == new_async_path and old_ws_path == new_ws_path:
4✔
1211
            # Nothing to do
NEW
1212
            return
×
1213

1214
        with self._binding_lock:
4✔
1215
            # Unregister the previous paths
1216
            self.unregister(None, service)
4✔
1217

1218
            if self._validated:
4✔
1219
                # Register the service with its new properties
1220
                self.__register_servlet_service(service, service_reference)
4✔
1221

1222
    @UnbindField("_servlets_services")
4✔
1223
    @UnbindField("_servlets_async_services")
4✔
1224
    @UnbindField("_websocket_handler_services")
4✔
1225
    def _unbind_servlet(
4✔
1226
        self,
1227
        _: str,
1228
        service: http.Servlet | http.AsyncServlet | http.WebSocketHandler,
1229
        service_reference: ServiceReference[http.Servlet | http.AsyncServlet | http.WebSocketHandler],
1230
    ) -> None:
1231
        """
1232
        Called by iPOPO when a service is gone
1233
        """
1234
        # Ignore imported services
1235
        if self.__is_imported(service_reference):
4✔
NEW
1236
            return
×
1237

1238
        with self._binding_lock:
4✔
1239
            # Servlet gone: unregister all paths associated to this servlet
1240
            self.unregister(None, service)
4✔
1241

1242
            # Remove the service reference
1243
            try:
4✔
1244
                del self._servlets_refs[service]
4✔
NEW
1245
            except KeyError:
×
1246
                # Service reference not found, nothing to do
NEW
1247
                pass
×
1248

1249
    def get_access(self) -> Tuple[str, int]:
4✔
1250
        """
1251
        Retrieves the (address, port) tuple to access the server
1252
        """
1253
        assert self._bound_address is not None, "Server must be started before accessing its address"
4✔
1254
        return self._bound_address
4✔
1255

1256
    def get_hostname(self) -> str:
4✔
1257
        """
1258
        Retrieves the server host name
1259

1260
        :return: The server host name
1261
        """
1262
        return socket.gethostname()
4✔
1263

1264
    def is_https(self) -> bool:
4✔
1265
        """
1266
        Returns True if this is an HTTPS server
1267

1268
        :return: True if this server uses SSL
1269
        """
NEW
1270
        return self._uses_ssl
×
1271

1272
    def get_registered_paths(self) -> List[str]:
4✔
1273
        """
1274
        Returns the paths registered by servlets
1275

1276
        :return: The paths registered by servlets (sorted list)
1277
        """
1278
        return sorted(self._servlets)
4✔
1279

1280
    def get_servlet(
4✔
1281
        self, path: Optional[str]
1282
    ) -> Optional[Tuple[http.Servlet | http.AsyncServlet, Dict[str, Any], str, http.ServletType]]:
1283
        """
1284
        Retrieves the servlet matching the given path and its parameters.
1285
        Returns None if no servlet matches the given path.
1286

1287
        :param path: A request URI
1288
        :return: A tuple (servlet, parameters, prefix, type) or None
1289
        """
1290
        if not path or path[0] != "/":
4✔
1291
            # No path, nothing to return
1292
            return None
4✔
1293

1294
        # Use lower case for comparison
1295
        path = path.lower()
4✔
1296

1297
        if path[-1] != "/":
4✔
1298
            # Add a trailing slash
1299
            path += "/"
4✔
1300

1301
        with self._lock:
4✔
1302
            longest_match = ""
4✔
1303
            longest_match_len = 0
4✔
1304
            for servlet_path in self._servlets:
4✔
1305
                tested_path = servlet_path
4✔
1306
                if tested_path[-1] != "/":
4✔
1307
                    # Add a trailing slash
1308
                    tested_path += "/"
4✔
1309

1310
                if path.startswith(tested_path) and len(servlet_path) > longest_match_len:
4✔
1311
                    # Found a corresponding servlet
1312
                    # which is deeper than the previous one
1313
                    longest_match = servlet_path
4✔
1314
                    longest_match_len = len(servlet_path)
4✔
1315

1316
            # Return the found servlet
1317
            if not longest_match:
4✔
1318
                # No match found
1319
                return None
4✔
1320

1321
            # Retrieve the stored information
1322
            servlet, params, servlet_type = self._servlets[longest_match]
4✔
1323
            return servlet, params, longest_match, servlet_type
4✔
1324

1325
    def make_not_found_page(self, path: str) -> str:
4✔
1326
        """
1327
        Prepares a "page not found" page for a 404 error
1328

1329
        :param path: Request path
1330
        :return: A HTML page
1331
        """
1332
        page = None
4✔
1333
        if self._error_handler is not None:
4✔
NEW
1334
            page = self._error_handler.make_not_found_page(path)
×
1335

1336
        if not page:
4✔
1337
            page = f"""<html>
4✔
1338
<head>
1339
<title>404 - Page not found</title>
1340
</head>
1341
<body>
1342
<h1>Page not found</h1>
1343
<p>No servlet is associated to this path:</p>
1344
<code>{html.escape(path)}</code>
1345
<h2>Registered paths:</h2>
1346
{http.make_html_list(self.get_registered_paths())}
1347
</body>
1348
</html>"""
1349
        return page
4✔
1350

1351
    def make_exception_page(self, path: str, stack: str) -> str:
4✔
1352
        """
1353
        Prepares a page printing an exception stack trace in a 500 error
1354

1355
        :param path: Request path
1356
        :param stack: Exception stack trace
1357
        :return: A HTML page
1358
        """
1359
        page = None
4✔
1360
        if self._error_handler is not None:
4✔
NEW
1361
            page = self._error_handler.make_exception_page(path, stack)
×
1362

1363
        if not page:
4✔
1364
            page = f"""<html>
4✔
1365
<head>
1366
<title>500 - Internal Server Error</title>
1367
</head>
1368
<body>
1369
<h1>Internal Server Error</h1>
1370
<p>Error handling request upon: <code>{html.escape(path)}</code></p>
1371
<pre>
1372
{html.escape(stack)}
1373
</pre>
1374
</body>
1375
</html>"""
1376
        return page
4✔
1377

1378
    def register_servlet(
4✔
1379
        self,
1380
        path: str,
1381
        servlet: http.Servlet | http.AsyncServlet,
1382
        parameters: Optional[Dict[str, Any]] = None,
1383
        servlet_type: http.ServletType = http.ServletType.SYNC,
1384
    ) -> bool:
1385
        """
1386
        Registers a servlet
1387

1388
        :param path: Path handled by this servlet
1389
        :param servlet: The servlet instance
1390
        :param parameters: The parameters associated to this path
1391
        :param servlet_type: The type of servlet (sync, async, websocket, ...)
1392
        :return: True if the servlet has been registered, False if it refused the binding.
1393
        :raise ValueError: Invalid path or handler
1394
        """
1395
        if servlet is None:
4✔
1396
            raise ValueError("Invalid servlet instance")
4✔
1397

1398
        if not path or path[0] != "/":
4✔
1399
            raise ValueError("Invalid path given to register the servlet: {0}".format(path))
4✔
1400

1401
        # Use lower-case paths
1402
        path = path.lower()
4✔
1403

1404
        # Prepare the parameters
1405
        if parameters is None:
4✔
1406
            parameters = {}
4✔
1407

1408
        with self._lock:
4✔
1409
            if path in self._servlets:
4✔
1410
                # Already registered path
1411
                if self._servlets[path][0] is servlet:
4✔
1412
                    # Double-registration: Nothing to do
1413
                    return True
4✔
1414
                else:
1415
                    # Path is already taken by another servlet
1416
                    already_taken = True
4✔
1417
            else:
1418
                # Path is available
1419
                already_taken = False
4✔
1420

1421
            # Add server information in parameters
1422
            parameters[http.PARAM_ADDRESS] = self._address
4✔
1423
            parameters[http.PARAM_PORT] = self._port
4✔
1424
            parameters[http.PARAM_HTTPS] = self._uses_ssl
4✔
1425
            parameters[http.PARAM_NAME] = self._instance_name
4✔
1426
            parameters[http.PARAM_EXTRA] = self._extra.copy() if self._extra else None
4✔
1427
            parameters[http.PARAM_ASYNC] = servlet_type != http.ServletType.SYNC
4✔
1428

1429
            # The servlet might refuse to be bound to this server
1430
            if not self.__safe_callback(servlet, "accept_binding", path, parameters):
4✔
1431
                # Server refused: stop right there
1432
                # => No need to raise the "already taken path" exception
1433
                return False
4✔
1434

1435
            if already_taken:
4✔
1436
                # The path is already taken by another servlet
1437
                raise ValueError("A servlet is already registered on {0}".format(path))
4✔
1438

1439
            # Tell the servlet it can be bound to the path
1440
            if self.__safe_callback(servlet, "bound_to", path, parameters):
4✔
1441
                # Store the servlet
1442
                self._servlets[path] = (servlet, parameters, servlet_type)
4✔
1443
                return True
4✔
1444

1445
            # The servlet refused the binding
1446
            return False
4✔
1447

1448
    def unregister(self, path: Optional[str], servlet: Optional[http.Servlet] = None) -> bool:
4✔
1449
        """
1450
        Unregisters the servlet for the given path
1451

1452
        :param path: The path to a servlet
1453
        :param servlet: If given, unregisters all the paths handled by this servlet
1454
        :return: True if at least one path as been unregistered, else False
1455
        """
1456
        if servlet is not None:
4✔
1457
            with self._lock:
4✔
1458
                # Unregister all paths for this servlet
1459
                paths = [
4✔
1460
                    servlet_path
1461
                    for (servlet_path, servlet_info) in self._servlets.items()
1462
                    if servlet_info[0] == servlet
1463
                ]
1464

1465
            result = False
4✔
1466
            for servlet_path in paths:
4✔
1467
                result |= self.unregister(servlet_path)
4✔
1468

1469
            return result
4✔
1470
        else:
1471
            if not path:
4✔
1472
                # Invalid path
1473
                return False
4✔
1474

1475
            # Always use lower case to compare paths
1476
            path = path.lower()
4✔
1477

1478
            with self._lock:
4✔
1479
                # Notify the servlet
1480
                servlet_info = self._servlets.get(path)
4✔
1481
                if servlet_info is None:
4✔
1482
                    # Unknown path
1483
                    return False
4✔
1484

1485
                self.__safe_callback(servlet_info[0], "unbound_from", path, servlet_info[1])
4✔
1486

1487
                # Remove the servlet
1488
                try:
4✔
1489
                    del self._servlets[path]
4✔
NEW
1490
                except KeyError:
×
NEW
1491
                    self.log(logging.DEBUG, "Tried to remove an unknown servlet path: %s", path)
×
1492
                return True
4✔
1493

1494
    def log(self, level: int, message: str, *args: Any, **kwargs: Any) -> None:
4✔
1495
        """
1496
        Logs the given message
1497

1498
        :param level: Log entry level
1499
        :param message: Log message (Python logging format)
1500
        """
1501
        if self._logger is not None:
4✔
1502
            # Log the message
1503
            self._logger.log(level, message, *args, **kwargs)
4✔
1504

1505
    def log_exception(self, message: str, *args: Any, **kwargs: Any) -> None:
4✔
1506
        """
1507
        Logs an exception
1508

1509
        :param message: Log message (Python logging format)
1510
        """
1511
        if self._logger is not None:
4✔
1512
            # Log the exception
1513
            self._logger.exception(message, *args, **kwargs)
4✔
1514

1515
    @staticmethod
4✔
1516
    def __is_imported(service_reference: ServiceReference[Any]) -> bool:
4✔
1517
        """
1518
        Tests if the given service has been imported by Remote Services
1519

1520
        :param service_reference: The reference of the service to check
1521
        :return: True if the service is flagged as imported
1522
        """
1523
        return cast(bool, service_reference.get_property(pelix.remote.PROP_IMPORTED))
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc