Coverage for src / lilbee / server / routes / general.py: 100%

49 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-04-29 19:16 +0000

1"""General routes — health, status, config.""" 

2 

3from __future__ import annotations 

4 

5from pathlib import Path 

6from typing import Any 

7 

8from litestar import Response, get, patch 

9from pydantic import ValidationError 

10 

11from lilbee.server import handlers 

12from lilbee.server.auth import read_only 

13from lilbee.server.models import ( 

14 ConfigResponse, 

15 ConfigUpdateResponse, 

16 HealthResponse, 

17 SourceContentResponse, 

18 StatusResponse, 

19) 

20 

21 

22@get("/api/health") 

23@read_only 

24async def health_route() -> HealthResponse: 

25 """Service health check returning server version and uptime status.""" 

26 return await handlers.health() 

27 

28 

29@get("/api/status") 

30@read_only 

31async def status_route() -> StatusResponse: 

32 """Current configuration, indexed document sources, and chunk counts.""" 

33 return await handlers.status() 

34 

35 

36@get("/api/config") 

37@read_only 

38async def config_route() -> ConfigResponse: 

39 """Return all user-facing configuration values.""" 

40 return await handlers.get_config() 

41 

42 

43@get("/api/config/defaults") 

44@read_only 

45async def config_defaults_route() -> ConfigResponse: 

46 """Return canonical defaults for every writable, public configuration field.""" 

47 return await handlers.get_config_defaults() 

48 

49 

50@patch("/api/config") 

51async def config_update_route(data: dict[str, Any]) -> ConfigUpdateResponse: 

52 """Partial update of writable configuration fields.""" 

53 try: 

54 return await handlers.update_config(data) 

55 except (ValueError, ValidationError) as exc: 

56 from litestar.exceptions import ValidationException 

57 

58 raise ValidationException(str(exc)) from exc 

59 

60 

61@get("/api/source") 

62@read_only 

63async def source_content_route( 

64 source: str, raw: bool = False 

65) -> SourceContentResponse | Response[bytes]: 

66 """Return stored source file as JSON (``raw=0``) or raw bytes (``raw=1``).""" 

67 from litestar.exceptions import NotFoundException 

68 

69 try: 

70 result = await handlers.get_source_content(source, raw=raw) 

71 except FileNotFoundError as exc: 

72 raise NotFoundException(f"source not found: {source}") from exc 

73 except ValueError as exc: 

74 from litestar.exceptions import ValidationException 

75 

76 raise ValidationException(str(exc)) from exc 

77 

78 # ``raw=True`` returns ``(bytes, content_type)``; narrow via ``isinstance`` 

79 # so mypy sees the tuple branch without leaning on ``type: ignore``. 

80 if isinstance(result, tuple): 

81 body, content_type = result 

82 # nosniff blocks browser MIME-sniffing fallbacks; attachment forces a 

83 # download for any type the handler degraded to octet-stream so 

84 # attacker-named files don't render inline anywhere. 

85 headers = {"X-Content-Type-Options": "nosniff"} 

86 if content_type == "application/octet-stream": 

87 headers["Content-Disposition"] = f'attachment; filename="{Path(source).name}"' 

88 return Response(content=body, media_type=content_type, status_code=200, headers=headers) 

89 return result