kryten-api-gate 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ """kryten-api-gate: HTTP REST gateway for the Kryten ecosystem."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("kryten-api-gate")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0"
@@ -0,0 +1,126 @@
1
+ """Entry point for kryten-api-gate service."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import logging
6
+ import signal
7
+ import sys
8
+
9
+ import uvicorn
10
+ from kryten import KrytenClient
11
+
12
+ from .app import create_app
13
+ from .config import load_config
14
+
15
+ logger = logging.getLogger("kryten_api_gate")
16
+
17
+
18
+ def parse_args() -> argparse.Namespace:
19
+ parser = argparse.ArgumentParser(description="Kryten API Gate — HTTP REST gateway")
20
+ parser.add_argument(
21
+ "--config",
22
+ type=str,
23
+ default=None,
24
+ help="Path to config JSON file",
25
+ )
26
+ parser.add_argument(
27
+ "--log-level",
28
+ type=str,
29
+ default=None,
30
+ choices=["DEBUG", "INFO", "WARNING", "ERROR"],
31
+ help="Log level override",
32
+ )
33
+ return parser.parse_args()
34
+
35
+
36
+ def setup_logging(level: str) -> None:
37
+ logging.basicConfig(
38
+ level=getattr(logging, level.upper()),
39
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
40
+ datefmt="%Y-%m-%d %H:%M:%S",
41
+ )
42
+
43
+
44
+ async def main_async() -> None:
45
+ args = parse_args()
46
+ config = load_config(args.config)
47
+
48
+ log_level = args.log_level or config.log_level
49
+ setup_logging(log_level)
50
+
51
+ logger.info("Starting kryten-api-gate v0.1.0")
52
+ logger.info(f"Channel: {config.channel} @ {config.domain}")
53
+ logger.info(f"NATS: {config.nats_url}")
54
+ logger.info(f"HTTP: {config.http_host}:{config.http_port}")
55
+
56
+ # Create KrytenClient
57
+ client_config = {
58
+ "nats": {"servers": [config.nats_url]},
59
+ "channels": [{"domain": config.domain, "channel": config.channel}],
60
+ "service": {
61
+ "name": config.service_name,
62
+ "version": "0.1.0",
63
+ "enable_lifecycle": True,
64
+ "enable_heartbeat": True,
65
+ "enable_discovery": True,
66
+ },
67
+ }
68
+ client = KrytenClient(client_config)
69
+
70
+ # Connect to NATS
71
+ await client.connect()
72
+ logger.info("Connected to NATS")
73
+
74
+ # Create FastAPI app and inject state
75
+ app = create_app()
76
+ app.state.client = client
77
+ app.state.config = config
78
+
79
+ # Setup shutdown event
80
+ shutdown_event = asyncio.Event()
81
+
82
+ def _signal_handler() -> None:
83
+ logger.info("Shutdown signal received")
84
+ shutdown_event.set()
85
+
86
+ loop = asyncio.get_running_loop()
87
+ for sig in (signal.SIGINT, signal.SIGTERM):
88
+ try:
89
+ loop.add_signal_handler(sig, _signal_handler)
90
+ except NotImplementedError:
91
+ # Windows doesn't support add_signal_handler
92
+ signal.signal(sig, lambda s, f: _signal_handler())
93
+
94
+ # Run uvicorn
95
+ uv_config = uvicorn.Config(
96
+ app,
97
+ host=config.http_host,
98
+ port=config.http_port,
99
+ log_level=log_level.lower(),
100
+ access_log=log_level.upper() == "DEBUG",
101
+ )
102
+ server = uvicorn.Server(uv_config)
103
+
104
+ # Run server with shutdown monitoring
105
+ server_task = asyncio.create_task(server.serve())
106
+
107
+ await shutdown_event.wait()
108
+
109
+ # Graceful shutdown
110
+ logger.info("Shutting down...")
111
+ server.should_exit = True
112
+ await server_task
113
+ await client.disconnect("API gate shutting down")
114
+ logger.info("Shutdown complete")
115
+
116
+
117
+ def main() -> None:
118
+ try:
119
+ asyncio.run(main_async())
120
+ except KeyboardInterrupt:
121
+ pass
122
+ sys.exit(0)
123
+
124
+
125
+ if __name__ == "__main__":
126
+ main()
kryten_api_gate/app.py ADDED
@@ -0,0 +1,47 @@
1
+ """FastAPI application factory for kryten-api-gate."""
2
+
3
+ from fastapi import FastAPI
4
+
5
+ from . import __version__
6
+ from .routes import (
7
+ admin,
8
+ chat,
9
+ emotes,
10
+ filters,
11
+ kv,
12
+ library,
13
+ moderation,
14
+ playback,
15
+ playlist,
16
+ polls,
17
+ state,
18
+ system,
19
+ )
20
+
21
+
22
+ def create_app() -> FastAPI:
23
+ """Create and configure the FastAPI application."""
24
+ app = FastAPI(
25
+ title="kryten-api-gate",
26
+ version=__version__,
27
+ description="HTTP REST gateway for the Kryten ecosystem",
28
+ )
29
+
30
+ # Public routes (no auth)
31
+ app.include_router(system.public_router, prefix="/api/v1/system", tags=["system"])
32
+
33
+ # Protected routes (require API key)
34
+ app.include_router(system.router, prefix="/api/v1/system", tags=["system"])
35
+ app.include_router(chat.router, prefix="/api/v1/chat", tags=["chat"])
36
+ app.include_router(playlist.router, prefix="/api/v1/playlist", tags=["playlist"])
37
+ app.include_router(playback.router, prefix="/api/v1/playback", tags=["playback"])
38
+ app.include_router(moderation.router, prefix="/api/v1/moderation", tags=["moderation"])
39
+ app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
40
+ app.include_router(emotes.router, prefix="/api/v1/emotes", tags=["emotes"])
41
+ app.include_router(filters.router, prefix="/api/v1/filters", tags=["filters"])
42
+ app.include_router(polls.router, prefix="/api/v1/polls", tags=["polls"])
43
+ app.include_router(library.router, prefix="/api/v1/library", tags=["library"])
44
+ app.include_router(kv.router, prefix="/api/v1/kv", tags=["kv"])
45
+ app.include_router(state.router, prefix="/api/v1/state", tags=["state"])
46
+
47
+ return app
@@ -0,0 +1,36 @@
1
+ """Authentication dependency for kryten-api-gate."""
2
+
3
+ from fastapi import Depends, HTTPException, Request, status
4
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
5
+
6
+ from .config import Config
7
+
8
+ _bearer_scheme = HTTPBearer(auto_error=False)
9
+
10
+
11
+ async def verify_api_key(
12
+ request: Request,
13
+ credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
14
+ ) -> str:
15
+ """Validate Bearer token against configured API keys.
16
+
17
+ Returns the validated API key string.
18
+ Raises 401 if missing or invalid.
19
+ """
20
+ config: Config = request.app.state.config
21
+
22
+ if credentials is None:
23
+ raise HTTPException(
24
+ status_code=status.HTTP_401_UNAUTHORIZED,
25
+ detail="Missing authorization header",
26
+ headers={"WWW-Authenticate": "Bearer"},
27
+ )
28
+
29
+ if credentials.credentials not in config.api_keys:
30
+ raise HTTPException(
31
+ status_code=status.HTTP_401_UNAUTHORIZED,
32
+ detail="Invalid API key",
33
+ headers={"WWW-Authenticate": "Bearer"},
34
+ )
35
+
36
+ return credentials.credentials
@@ -0,0 +1,62 @@
1
+ """Configuration model for kryten-api-gate."""
2
+
3
+ import json
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # Config search paths in priority order
12
+ CONFIG_SEARCH_PATHS = [
13
+ Path("./config.json"),
14
+ Path("/etc/kryten/api-gate/config.json"),
15
+ Path("/opt/kryten/api-gate/config.json"),
16
+ ]
17
+
18
+
19
+ class Config(BaseModel):
20
+ """API gate configuration."""
21
+
22
+ nats_url: str = Field(default="nats://localhost:4222")
23
+ channel: str = Field(description="CyTube channel name")
24
+ domain: str = Field(default="cytu.be")
25
+ http_host: str = Field(default="127.0.0.1")
26
+ http_port: int = Field(default=28288)
27
+ api_keys: list[str] = Field(description="Authorized bearer tokens")
28
+ kv_read_only: bool = Field(default=False)
29
+ service_name: str = Field(default="api-gate")
30
+ log_level: str = Field(default="INFO")
31
+ disabled_routes: list[str] = Field(
32
+ default_factory=list,
33
+ description="List of route operation IDs to disable (e.g. 'playback_pause', 'playback_seek')",
34
+ )
35
+
36
+
37
+ def load_config(config_path: str | None = None) -> Config:
38
+ """Load configuration from JSON file.
39
+
40
+ Search order:
41
+ 1. Explicit path (if provided)
42
+ 2. ./config.json (cwd)
43
+ 3. /etc/kryten/api-gate/config.json
44
+ 4. /opt/kryten/api-gate/config.json
45
+ """
46
+ if config_path:
47
+ path = Path(config_path)
48
+ if not path.exists():
49
+ raise FileNotFoundError(f"Config file not found: {config_path}")
50
+ logger.info(f"Loading config from: {path}")
51
+ data = json.loads(path.read_text())
52
+ return Config(**data)
53
+
54
+ for path in CONFIG_SEARCH_PATHS:
55
+ if path.exists():
56
+ logger.info(f"Loading config from: {path}")
57
+ data = json.loads(path.read_text())
58
+ return Config(**data)
59
+
60
+ raise FileNotFoundError(
61
+ f"No config file found. Searched: {[str(p) for p in CONFIG_SEARCH_PATHS]}"
62
+ )
@@ -0,0 +1,16 @@
1
+ """Dependency injection for kryten-api-gate routes."""
2
+
3
+ from fastapi import Request
4
+ from kryten import KrytenClient
5
+
6
+ from .config import Config
7
+
8
+
9
+ def get_client(request: Request) -> KrytenClient:
10
+ """Get the shared KrytenClient instance."""
11
+ return request.app.state.client
12
+
13
+
14
+ def get_config(request: Request) -> Config:
15
+ """Get the loaded configuration."""
16
+ return request.app.state.config
@@ -0,0 +1 @@
1
+ """Route modules for kryten-api-gate."""
@@ -0,0 +1,187 @@
1
+ """Admin routes — MOTD, CSS, JS, options, permissions, ranks, channel log."""
2
+
3
+ from fastapi import APIRouter, Depends
4
+ from kryten import KrytenClient
5
+ from pydantic import BaseModel
6
+
7
+ from ..auth import verify_api_key
8
+ from ..config import Config
9
+ from ..deps import get_client, get_config
10
+
11
+ router = APIRouter(dependencies=[Depends(verify_api_key)])
12
+
13
+
14
+ class MotdRequest(BaseModel):
15
+ motd: str
16
+
17
+
18
+ class CssRequest(BaseModel):
19
+ css: str
20
+
21
+
22
+ class JsRequest(BaseModel):
23
+ js: str
24
+
25
+
26
+ class OptionsRequest(BaseModel):
27
+ options: dict
28
+
29
+
30
+ class PermissionsRequest(BaseModel):
31
+ permissions: dict
32
+
33
+
34
+ class SetRankRequest(BaseModel):
35
+ username: str
36
+ rank: int
37
+
38
+
39
+ # --- MOTD ---
40
+
41
+
42
+ @router.get("/motd")
43
+ async def get_motd(
44
+ client: KrytenClient = Depends(get_client),
45
+ config: Config = Depends(get_config),
46
+ ) -> dict:
47
+ motd = await client.get_state_motd(config.channel, domain=config.domain)
48
+ return {"motd": motd}
49
+
50
+
51
+ @router.put("/motd")
52
+ async def set_motd(
53
+ body: MotdRequest,
54
+ client: KrytenClient = Depends(get_client),
55
+ config: Config = Depends(get_config),
56
+ ) -> dict:
57
+ msg_id = await client.set_motd(config.channel, body.motd, domain=config.domain)
58
+ return {"message_id": msg_id}
59
+
60
+
61
+ # --- CSS ---
62
+
63
+
64
+ @router.get("/css")
65
+ async def get_channel_css(
66
+ client: KrytenClient = Depends(get_client),
67
+ config: Config = Depends(get_config),
68
+ ) -> dict:
69
+ css = await client.get_state_channel_css(config.channel, domain=config.domain)
70
+ return {"css": css}
71
+
72
+
73
+ @router.put("/css")
74
+ async def set_channel_css(
75
+ body: CssRequest,
76
+ client: KrytenClient = Depends(get_client),
77
+ config: Config = Depends(get_config),
78
+ ) -> dict:
79
+ msg_id = await client.set_channel_css(config.channel, body.css, domain=config.domain)
80
+ return {"message_id": msg_id}
81
+
82
+
83
+ # --- JS ---
84
+
85
+
86
+ @router.get("/js")
87
+ async def get_channel_js(
88
+ client: KrytenClient = Depends(get_client),
89
+ config: Config = Depends(get_config),
90
+ ) -> dict:
91
+ js = await client.get_state_channel_js(config.channel, domain=config.domain)
92
+ return {"js": js}
93
+
94
+
95
+ @router.put("/js")
96
+ async def set_channel_js(
97
+ body: JsRequest,
98
+ client: KrytenClient = Depends(get_client),
99
+ config: Config = Depends(get_config),
100
+ ) -> dict:
101
+ msg_id = await client.set_channel_js(config.channel, body.js, domain=config.domain)
102
+ return {"message_id": msg_id}
103
+
104
+
105
+ # --- Options ---
106
+
107
+
108
+ @router.get("/options")
109
+ async def get_options(
110
+ client: KrytenClient = Depends(get_client),
111
+ config: Config = Depends(get_config),
112
+ ) -> dict:
113
+ options = await client.get_state_channel_options(config.channel, domain=config.domain)
114
+ return {"options": options}
115
+
116
+
117
+ @router.put("/options")
118
+ async def set_options(
119
+ body: OptionsRequest,
120
+ client: KrytenClient = Depends(get_client),
121
+ config: Config = Depends(get_config),
122
+ ) -> dict:
123
+ msg_id = await client.set_options(config.channel, body.options, domain=config.domain)
124
+ return {"message_id": msg_id}
125
+
126
+
127
+ # --- Permissions ---
128
+
129
+
130
+ @router.get("/permissions")
131
+ async def get_permissions(
132
+ client: KrytenClient = Depends(get_client),
133
+ config: Config = Depends(get_config),
134
+ ) -> dict:
135
+ permissions = await client.get_state_channel_permissions(
136
+ config.channel, domain=config.domain
137
+ )
138
+ return {"permissions": permissions}
139
+
140
+
141
+ @router.put("/permissions")
142
+ async def set_permissions(
143
+ body: PermissionsRequest,
144
+ client: KrytenClient = Depends(get_client),
145
+ config: Config = Depends(get_config),
146
+ ) -> dict:
147
+ msg_id = await client.set_permissions(
148
+ config.channel, body.permissions, domain=config.domain
149
+ )
150
+ return {"message_id": msg_id}
151
+
152
+
153
+ # --- Ranks ---
154
+
155
+
156
+ @router.get("/ranks")
157
+ async def request_channel_ranks(
158
+ client: KrytenClient = Depends(get_client),
159
+ config: Config = Depends(get_config),
160
+ ) -> dict:
161
+ msg_id = await client.request_channel_ranks(config.channel, domain=config.domain)
162
+ return {"message_id": msg_id}
163
+
164
+
165
+ @router.put("/rank")
166
+ async def set_channel_rank(
167
+ body: SetRankRequest,
168
+ client: KrytenClient = Depends(get_client),
169
+ config: Config = Depends(get_config),
170
+ ) -> dict:
171
+ msg_id = await client.set_channel_rank(
172
+ config.channel, body.username, body.rank, domain=config.domain
173
+ )
174
+ return {"message_id": msg_id}
175
+
176
+
177
+ # --- Channel Log ---
178
+
179
+
180
+ @router.get("/log")
181
+ async def read_chan_log(
182
+ count: int = 100,
183
+ client: KrytenClient = Depends(get_client),
184
+ config: Config = Depends(get_config),
185
+ ) -> dict:
186
+ msg_id = await client.read_chan_log(config.channel, count, domain=config.domain)
187
+ return {"message_id": msg_id}
@@ -0,0 +1,42 @@
1
+ """Chat routes — send messages and PMs."""
2
+
3
+ from fastapi import APIRouter, Depends
4
+ from kryten import KrytenClient
5
+ from pydantic import BaseModel
6
+
7
+ from ..auth import verify_api_key
8
+ from ..config import Config
9
+ from ..deps import get_client, get_config
10
+
11
+ router = APIRouter(dependencies=[Depends(verify_api_key)])
12
+
13
+
14
+ class SendChatRequest(BaseModel):
15
+ message: str
16
+
17
+
18
+ class SendPmRequest(BaseModel):
19
+ username: str
20
+ message: str
21
+
22
+
23
+ @router.post("/send")
24
+ async def send_chat(
25
+ body: SendChatRequest,
26
+ client: KrytenClient = Depends(get_client),
27
+ config: Config = Depends(get_config),
28
+ ) -> dict:
29
+ msg_id = await client.send_chat(config.channel, body.message, domain=config.domain)
30
+ return {"message_id": msg_id}
31
+
32
+
33
+ @router.post("/pm")
34
+ async def send_pm(
35
+ body: SendPmRequest,
36
+ client: KrytenClient = Depends(get_client),
37
+ config: Config = Depends(get_config),
38
+ ) -> dict:
39
+ msg_id = await client.send_pm(
40
+ config.channel, body.username, body.message, domain=config.domain
41
+ )
42
+ return {"message_id": msg_id}
@@ -0,0 +1,71 @@
1
+ """Emotes routes — update, remove, bulk import/export emotes."""
2
+
3
+ from fastapi import APIRouter, Depends
4
+ from kryten import KrytenClient
5
+ from pydantic import BaseModel
6
+
7
+ from ..auth import verify_api_key
8
+ from ..config import Config
9
+ from ..deps import get_client, get_config
10
+
11
+ router = APIRouter(dependencies=[Depends(verify_api_key)])
12
+
13
+
14
+ class UpdateEmoteRequest(BaseModel):
15
+ image: str
16
+ source: str = "imgur"
17
+
18
+
19
+ class EmoteImportItem(BaseModel):
20
+ name: str
21
+ image: str
22
+ source: str = "imgur"
23
+
24
+
25
+ class BulkImportRequest(BaseModel):
26
+ emotes: list[EmoteImportItem]
27
+
28
+
29
+ @router.get("/")
30
+ async def export_emotes(
31
+ client: KrytenClient = Depends(get_client),
32
+ config: Config = Depends(get_config),
33
+ ) -> dict:
34
+ emotes = await client.export_emotes(config.channel, domain=config.domain)
35
+ return {"emotes": emotes}
36
+
37
+
38
+ @router.post("/import")
39
+ async def import_emotes(
40
+ body: BulkImportRequest,
41
+ client: KrytenClient = Depends(get_client),
42
+ config: Config = Depends(get_config),
43
+ ) -> dict:
44
+ emote_dicts = [e.model_dump() for e in body.emotes]
45
+ message_ids = await client.import_emotes(
46
+ config.channel, emote_dicts, domain=config.domain
47
+ )
48
+ return {"message_ids": message_ids, "count": len(message_ids)}
49
+
50
+
51
+ @router.put("/{name}")
52
+ async def update_emote(
53
+ name: str,
54
+ body: UpdateEmoteRequest,
55
+ client: KrytenClient = Depends(get_client),
56
+ config: Config = Depends(get_config),
57
+ ) -> dict:
58
+ msg_id = await client.update_emote(
59
+ config.channel, name, body.image, body.source, domain=config.domain
60
+ )
61
+ return {"message_id": msg_id}
62
+
63
+
64
+ @router.delete("/{name}")
65
+ async def remove_emote(
66
+ name: str,
67
+ client: KrytenClient = Depends(get_client),
68
+ config: Config = Depends(get_config),
69
+ ) -> dict:
70
+ msg_id = await client.remove_emote(config.channel, name, domain=config.domain)
71
+ return {"message_id": msg_id}
@@ -0,0 +1,77 @@
1
+ """Filters routes — add, update, remove chat filters."""
2
+
3
+ from fastapi import APIRouter, Depends
4
+ from kryten import KrytenClient
5
+ from pydantic import BaseModel
6
+
7
+ from ..auth import verify_api_key
8
+ from ..config import Config
9
+ from ..deps import get_client, get_config
10
+
11
+ router = APIRouter(dependencies=[Depends(verify_api_key)])
12
+
13
+
14
+ class FilterRequest(BaseModel):
15
+ name: str
16
+ source: str
17
+ flags: str = "gi"
18
+ replace: str = ""
19
+ filterlinks: bool = False
20
+ active: bool = True
21
+
22
+
23
+ class UpdateFilterRequest(BaseModel):
24
+ source: str
25
+ flags: str = "gi"
26
+ replace: str = ""
27
+ filterlinks: bool = False
28
+ active: bool = True
29
+
30
+
31
+ @router.post("/")
32
+ async def add_filter(
33
+ body: FilterRequest,
34
+ client: KrytenClient = Depends(get_client),
35
+ config: Config = Depends(get_config),
36
+ ) -> dict:
37
+ msg_id = await client.add_filter(
38
+ config.channel,
39
+ body.name,
40
+ body.source,
41
+ body.flags,
42
+ body.replace,
43
+ filterlinks=body.filterlinks,
44
+ active=body.active,
45
+ domain=config.domain,
46
+ )
47
+ return {"message_id": msg_id}
48
+
49
+
50
+ @router.put("/{name}")
51
+ async def update_filter(
52
+ name: str,
53
+ body: UpdateFilterRequest,
54
+ client: KrytenClient = Depends(get_client),
55
+ config: Config = Depends(get_config),
56
+ ) -> dict:
57
+ msg_id = await client.update_filter(
58
+ config.channel,
59
+ name,
60
+ body.source,
61
+ body.flags,
62
+ body.replace,
63
+ filterlinks=body.filterlinks,
64
+ active=body.active,
65
+ domain=config.domain,
66
+ )
67
+ return {"message_id": msg_id}
68
+
69
+
70
+ @router.delete("/{name}")
71
+ async def remove_filter(
72
+ name: str,
73
+ client: KrytenClient = Depends(get_client),
74
+ config: Config = Depends(get_config),
75
+ ) -> dict:
76
+ msg_id = await client.remove_filter(config.channel, name, domain=config.domain)
77
+ return {"message_id": msg_id}