servicekit 0.3.5__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.
- servicekit/.DS_Store +0 -0
- servicekit/__init__.py +78 -0
- servicekit/api/.DS_Store +0 -0
- servicekit/api/__init__.py +72 -0
- servicekit/api/app.py +225 -0
- servicekit/api/apps/.DS_Store +0 -0
- servicekit/api/apps/landing/index.html +146 -0
- servicekit/api/apps/landing/manifest.json +8 -0
- servicekit/api/auth.py +162 -0
- servicekit/api/crud.py +368 -0
- servicekit/api/dependencies.py +70 -0
- servicekit/api/middleware.py +163 -0
- servicekit/api/monitoring.py +100 -0
- servicekit/api/pagination.py +27 -0
- servicekit/api/registration.py +287 -0
- servicekit/api/router.py +28 -0
- servicekit/api/routers/__init__.py +18 -0
- servicekit/api/routers/health.py +114 -0
- servicekit/api/routers/job.py +126 -0
- servicekit/api/routers/metrics.py +47 -0
- servicekit/api/routers/system.py +77 -0
- servicekit/api/service_builder.py +793 -0
- servicekit/api/sse.py +23 -0
- servicekit/api/utilities.py +80 -0
- servicekit/data/README.md +185 -0
- servicekit/data/__init__.py +22 -0
- servicekit/data/dataframe.py +1104 -0
- servicekit/database.py +252 -0
- servicekit/exceptions.py +138 -0
- servicekit/gunicorn.conf.py +109 -0
- servicekit/logging.py +98 -0
- servicekit/manager.py +311 -0
- servicekit/models.py +26 -0
- servicekit/py.typed +0 -0
- servicekit/repository.py +173 -0
- servicekit/scheduler.py +302 -0
- servicekit/schemas.py +184 -0
- servicekit/types.py +94 -0
- servicekit-0.3.5.dist-info/METADATA +184 -0
- servicekit-0.3.5.dist-info/RECORD +41 -0
- servicekit-0.3.5.dist-info/WHEEL +4 -0
servicekit/.DS_Store
ADDED
|
Binary file
|
servicekit/__init__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Core framework components - generic interfaces and base classes."""
|
|
2
|
+
|
|
3
|
+
# ruff: noqa: F401
|
|
4
|
+
|
|
5
|
+
# Base infrastructure (framework-agnostic)
|
|
6
|
+
from .database import Database, SqliteDatabase, SqliteDatabaseBuilder
|
|
7
|
+
from .exceptions import (
|
|
8
|
+
BadRequestError,
|
|
9
|
+
ConflictError,
|
|
10
|
+
ErrorType,
|
|
11
|
+
ForbiddenError,
|
|
12
|
+
InvalidULIDError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
ServicekitException,
|
|
15
|
+
UnauthorizedError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
)
|
|
18
|
+
from .logging import add_request_context, clear_request_context, configure_logging, get_logger, reset_request_context
|
|
19
|
+
from .manager import BaseManager, LifecycleHooks, Manager
|
|
20
|
+
from .models import Base, Entity
|
|
21
|
+
from .repository import BaseRepository, Repository
|
|
22
|
+
from .scheduler import AIOJobScheduler, JobScheduler
|
|
23
|
+
from .schemas import (
|
|
24
|
+
BulkOperationError,
|
|
25
|
+
BulkOperationResult,
|
|
26
|
+
EntityIn,
|
|
27
|
+
EntityOut,
|
|
28
|
+
JobRecord,
|
|
29
|
+
JobStatus,
|
|
30
|
+
PaginatedResponse,
|
|
31
|
+
ProblemDetail,
|
|
32
|
+
)
|
|
33
|
+
from .types import JsonSafe, ULIDType
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
# Base infrastructure
|
|
37
|
+
"Database",
|
|
38
|
+
"SqliteDatabase",
|
|
39
|
+
"SqliteDatabaseBuilder",
|
|
40
|
+
"Repository",
|
|
41
|
+
"BaseRepository",
|
|
42
|
+
"Manager",
|
|
43
|
+
"LifecycleHooks",
|
|
44
|
+
"BaseManager",
|
|
45
|
+
# ORM and types
|
|
46
|
+
"Base",
|
|
47
|
+
"Entity",
|
|
48
|
+
"ULIDType",
|
|
49
|
+
"JsonSafe",
|
|
50
|
+
# Schemas
|
|
51
|
+
"EntityIn",
|
|
52
|
+
"EntityOut",
|
|
53
|
+
"PaginatedResponse",
|
|
54
|
+
"BulkOperationResult",
|
|
55
|
+
"BulkOperationError",
|
|
56
|
+
"ProblemDetail",
|
|
57
|
+
"JobRecord",
|
|
58
|
+
"JobStatus",
|
|
59
|
+
# Job scheduling
|
|
60
|
+
"JobScheduler",
|
|
61
|
+
"AIOJobScheduler",
|
|
62
|
+
# Exceptions
|
|
63
|
+
"ErrorType",
|
|
64
|
+
"ServicekitException",
|
|
65
|
+
"NotFoundError",
|
|
66
|
+
"ValidationError",
|
|
67
|
+
"ConflictError",
|
|
68
|
+
"InvalidULIDError",
|
|
69
|
+
"BadRequestError",
|
|
70
|
+
"UnauthorizedError",
|
|
71
|
+
"ForbiddenError",
|
|
72
|
+
# Logging
|
|
73
|
+
"configure_logging",
|
|
74
|
+
"get_logger",
|
|
75
|
+
"add_request_context",
|
|
76
|
+
"clear_request_context",
|
|
77
|
+
"reset_request_context",
|
|
78
|
+
]
|
servicekit/api/.DS_Store
ADDED
|
Binary file
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""FastAPI framework layer - routers, middleware, utilities."""
|
|
2
|
+
|
|
3
|
+
from .app import App, AppInfo, AppLoader, AppManager, AppManifest
|
|
4
|
+
from .auth import APIKeyMiddleware, load_api_keys_from_env, load_api_keys_from_file, validate_api_key_format
|
|
5
|
+
from .crud import CrudPermissions, CrudRouter
|
|
6
|
+
from .dependencies import (
|
|
7
|
+
get_app_manager,
|
|
8
|
+
get_database,
|
|
9
|
+
get_scheduler,
|
|
10
|
+
get_session,
|
|
11
|
+
set_app_manager,
|
|
12
|
+
set_database,
|
|
13
|
+
set_scheduler,
|
|
14
|
+
)
|
|
15
|
+
from .middleware import add_error_handlers, add_logging_middleware, database_error_handler, validation_error_handler
|
|
16
|
+
from .pagination import PaginationParams, create_paginated_response
|
|
17
|
+
from .router import Router
|
|
18
|
+
from .routers import HealthRouter, HealthState, HealthStatus, JobRouter, SystemInfo, SystemRouter
|
|
19
|
+
from .service_builder import BaseServiceBuilder, ServiceInfo
|
|
20
|
+
from .sse import SSE_HEADERS, format_sse_event, format_sse_model_event
|
|
21
|
+
from .utilities import build_location_url, run_app
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
# Base router classes
|
|
25
|
+
"Router",
|
|
26
|
+
"CrudRouter",
|
|
27
|
+
"CrudPermissions",
|
|
28
|
+
# Service builder
|
|
29
|
+
"BaseServiceBuilder",
|
|
30
|
+
"ServiceInfo",
|
|
31
|
+
# App system
|
|
32
|
+
"App",
|
|
33
|
+
"AppInfo",
|
|
34
|
+
"AppLoader",
|
|
35
|
+
"AppManifest",
|
|
36
|
+
"AppManager",
|
|
37
|
+
# Authentication
|
|
38
|
+
"APIKeyMiddleware",
|
|
39
|
+
"load_api_keys_from_env",
|
|
40
|
+
"load_api_keys_from_file",
|
|
41
|
+
"validate_api_key_format",
|
|
42
|
+
# Dependencies
|
|
43
|
+
"get_app_manager",
|
|
44
|
+
"set_app_manager",
|
|
45
|
+
"get_database",
|
|
46
|
+
"set_database",
|
|
47
|
+
"get_session",
|
|
48
|
+
"get_scheduler",
|
|
49
|
+
"set_scheduler",
|
|
50
|
+
# Middleware
|
|
51
|
+
"add_error_handlers",
|
|
52
|
+
"add_logging_middleware",
|
|
53
|
+
"database_error_handler",
|
|
54
|
+
"validation_error_handler",
|
|
55
|
+
# Pagination
|
|
56
|
+
"PaginationParams",
|
|
57
|
+
"create_paginated_response",
|
|
58
|
+
# System routers
|
|
59
|
+
"HealthRouter",
|
|
60
|
+
"HealthState",
|
|
61
|
+
"HealthStatus",
|
|
62
|
+
"JobRouter",
|
|
63
|
+
"SystemRouter",
|
|
64
|
+
"SystemInfo",
|
|
65
|
+
# SSE utilities
|
|
66
|
+
"SSE_HEADERS",
|
|
67
|
+
"format_sse_event",
|
|
68
|
+
"format_sse_model_event",
|
|
69
|
+
# Utilities
|
|
70
|
+
"build_location_url",
|
|
71
|
+
"run_app",
|
|
72
|
+
]
|
servicekit/api/app.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""App system for hosting static web applications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
11
|
+
|
|
12
|
+
from servicekit.logging import get_logger
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AppManifest(BaseModel):
|
|
18
|
+
"""App manifest configuration."""
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(extra="forbid")
|
|
21
|
+
|
|
22
|
+
name: str = Field(description="Human-readable app name")
|
|
23
|
+
version: str = Field(description="Semantic version")
|
|
24
|
+
prefix: str = Field(description="URL prefix for mounting")
|
|
25
|
+
description: str | None = Field(default=None, description="App description")
|
|
26
|
+
author: str | None = Field(default=None, description="Author name")
|
|
27
|
+
entry: str = Field(default="index.html", description="Entry point filename")
|
|
28
|
+
|
|
29
|
+
@field_validator("prefix")
|
|
30
|
+
@classmethod
|
|
31
|
+
def validate_prefix(cls, v: str) -> str:
|
|
32
|
+
"""Validate mount prefix format."""
|
|
33
|
+
if not v.startswith("/"):
|
|
34
|
+
raise ValueError("prefix must start with '/'")
|
|
35
|
+
if ".." in v:
|
|
36
|
+
raise ValueError("prefix cannot contain '..'")
|
|
37
|
+
if v.startswith("/api/") or v == "/api":
|
|
38
|
+
raise ValueError("prefix cannot be '/api' or start with '/api/'")
|
|
39
|
+
return v
|
|
40
|
+
|
|
41
|
+
@field_validator("entry")
|
|
42
|
+
@classmethod
|
|
43
|
+
def validate_entry(cls, v: str) -> str:
|
|
44
|
+
"""Validate entry file path for security."""
|
|
45
|
+
if ".." in v:
|
|
46
|
+
raise ValueError("entry cannot contain '..'")
|
|
47
|
+
if v.startswith("/"):
|
|
48
|
+
raise ValueError("entry must be a relative path")
|
|
49
|
+
# Normalize and check for path traversal
|
|
50
|
+
normalized = Path(v).as_posix()
|
|
51
|
+
if normalized.startswith("../") or "/../" in normalized:
|
|
52
|
+
raise ValueError("entry cannot contain path traversal")
|
|
53
|
+
return v
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class App:
|
|
58
|
+
"""Represents a loaded app with manifest and directory."""
|
|
59
|
+
|
|
60
|
+
manifest: AppManifest
|
|
61
|
+
directory: Path
|
|
62
|
+
prefix: str # May differ from manifest if overridden
|
|
63
|
+
is_package: bool # True if loaded from package resources
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AppLoader:
|
|
67
|
+
"""Loads and validates apps from filesystem or package resources."""
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def load(path: str | Path | tuple[str, str], prefix: str | None = None) -> App:
|
|
71
|
+
"""Load and validate app from filesystem path or package resource tuple."""
|
|
72
|
+
# Detect source type and resolve to directory
|
|
73
|
+
if isinstance(path, tuple):
|
|
74
|
+
# Package resource
|
|
75
|
+
dir_path, is_package = AppLoader._resolve_package_path(path)
|
|
76
|
+
else:
|
|
77
|
+
# Filesystem path
|
|
78
|
+
dir_path = Path(path).resolve()
|
|
79
|
+
is_package = False
|
|
80
|
+
|
|
81
|
+
if not dir_path.exists():
|
|
82
|
+
raise FileNotFoundError(f"App directory not found: {dir_path}")
|
|
83
|
+
if not dir_path.is_dir():
|
|
84
|
+
raise NotADirectoryError(f"App path is not a directory: {dir_path}")
|
|
85
|
+
|
|
86
|
+
# Load and validate manifest
|
|
87
|
+
manifest_path = dir_path / "manifest.json"
|
|
88
|
+
if not manifest_path.exists():
|
|
89
|
+
raise FileNotFoundError(f"manifest.json not found in: {dir_path}")
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
with manifest_path.open() as f:
|
|
93
|
+
manifest_data = json.load(f)
|
|
94
|
+
except json.JSONDecodeError as e:
|
|
95
|
+
raise ValueError(f"Invalid JSON in manifest.json: {e}") from e
|
|
96
|
+
|
|
97
|
+
manifest = AppManifest(**manifest_data)
|
|
98
|
+
|
|
99
|
+
# Validate entry file exists
|
|
100
|
+
entry_path = dir_path / manifest.entry
|
|
101
|
+
if not entry_path.exists():
|
|
102
|
+
raise FileNotFoundError(f"Entry file '{manifest.entry}' not found in: {dir_path}")
|
|
103
|
+
|
|
104
|
+
# Use override or manifest prefix
|
|
105
|
+
final_prefix = prefix if prefix is not None else manifest.prefix
|
|
106
|
+
|
|
107
|
+
# Re-validate prefix if overridden
|
|
108
|
+
if prefix is not None:
|
|
109
|
+
validated = AppManifest(
|
|
110
|
+
name=manifest.name,
|
|
111
|
+
version=manifest.version,
|
|
112
|
+
prefix=final_prefix,
|
|
113
|
+
)
|
|
114
|
+
final_prefix = validated.prefix
|
|
115
|
+
|
|
116
|
+
return App(
|
|
117
|
+
manifest=manifest,
|
|
118
|
+
directory=dir_path,
|
|
119
|
+
prefix=final_prefix,
|
|
120
|
+
is_package=is_package,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def discover(path: str | Path | tuple[str, str]) -> list[App]:
|
|
125
|
+
"""Discover all apps with manifest.json in directory."""
|
|
126
|
+
# Resolve directory
|
|
127
|
+
if isinstance(path, tuple):
|
|
128
|
+
dir_path, _ = AppLoader._resolve_package_path(path)
|
|
129
|
+
else:
|
|
130
|
+
dir_path = Path(path).resolve()
|
|
131
|
+
|
|
132
|
+
if not dir_path.exists():
|
|
133
|
+
raise FileNotFoundError(f"Apps directory not found: {dir_path}")
|
|
134
|
+
if not dir_path.is_dir():
|
|
135
|
+
raise NotADirectoryError(f"Apps path is not a directory: {dir_path}")
|
|
136
|
+
|
|
137
|
+
# Scan for subdirectories with manifest.json
|
|
138
|
+
apps: list[App] = []
|
|
139
|
+
for subdir in dir_path.iterdir():
|
|
140
|
+
if subdir.is_dir() and (subdir / "manifest.json").exists():
|
|
141
|
+
try:
|
|
142
|
+
# Determine if we're in a package context
|
|
143
|
+
if isinstance(path, tuple):
|
|
144
|
+
# Build tuple path for subdirectory
|
|
145
|
+
package_name: str = path[0]
|
|
146
|
+
base_path: str = path[1]
|
|
147
|
+
subdir_name = subdir.name
|
|
148
|
+
subpath = f"{base_path}/{subdir_name}" if base_path else subdir_name
|
|
149
|
+
app = AppLoader.load((package_name, subpath))
|
|
150
|
+
else:
|
|
151
|
+
app = AppLoader.load(subdir)
|
|
152
|
+
apps.append(app)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
# Log but don't fail discovery for invalid apps
|
|
155
|
+
logger.warning(
|
|
156
|
+
"app.discovery.failed",
|
|
157
|
+
directory=str(subdir),
|
|
158
|
+
error=str(e),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return apps
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _resolve_package_path(package_tuple: tuple[str, str]) -> tuple[Path, bool]:
|
|
165
|
+
"""Resolve package resource tuple to filesystem path."""
|
|
166
|
+
package_name, subpath = package_tuple
|
|
167
|
+
|
|
168
|
+
# Validate subpath for security
|
|
169
|
+
if ".." in subpath:
|
|
170
|
+
raise ValueError(f"subpath cannot contain '..' (got: {subpath})")
|
|
171
|
+
if subpath.startswith("/"):
|
|
172
|
+
raise ValueError(f"subpath must be relative (got: {subpath})")
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
spec = importlib.util.find_spec(package_name)
|
|
176
|
+
except (ModuleNotFoundError, ValueError) as e:
|
|
177
|
+
raise ValueError(f"Package '{package_name}' could not be found") from e
|
|
178
|
+
|
|
179
|
+
if spec is None or spec.origin is None:
|
|
180
|
+
raise ValueError(f"Package '{package_name}' could not be found")
|
|
181
|
+
|
|
182
|
+
# Resolve to package directory
|
|
183
|
+
package_dir = Path(spec.origin).parent
|
|
184
|
+
app_dir = package_dir / subpath
|
|
185
|
+
|
|
186
|
+
# Verify resolved path is still within package directory
|
|
187
|
+
try:
|
|
188
|
+
app_dir.resolve().relative_to(package_dir.resolve())
|
|
189
|
+
except ValueError as e:
|
|
190
|
+
raise ValueError(f"App path '{subpath}' escapes package directory") from e
|
|
191
|
+
|
|
192
|
+
if not app_dir.exists():
|
|
193
|
+
raise FileNotFoundError(f"App path '{subpath}' not found in package '{package_name}'")
|
|
194
|
+
if not app_dir.is_dir():
|
|
195
|
+
raise NotADirectoryError(f"App path '{subpath}' in package '{package_name}' is not a directory")
|
|
196
|
+
|
|
197
|
+
return app_dir, True
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class AppInfo(BaseModel):
|
|
201
|
+
"""App metadata for API responses."""
|
|
202
|
+
|
|
203
|
+
name: str = Field(description="Human-readable app name")
|
|
204
|
+
version: str = Field(description="Semantic version")
|
|
205
|
+
prefix: str = Field(description="URL prefix for mounting")
|
|
206
|
+
description: str | None = Field(default=None, description="App description")
|
|
207
|
+
author: str | None = Field(default=None, description="Author name")
|
|
208
|
+
entry: str = Field(description="Entry point filename")
|
|
209
|
+
is_package: bool = Field(description="Whether app is loaded from package resources")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class AppManager:
|
|
213
|
+
"""Lightweight manager for app metadata queries."""
|
|
214
|
+
|
|
215
|
+
def __init__(self, apps: list[App]):
|
|
216
|
+
"""Initialize with loaded apps."""
|
|
217
|
+
self._apps = apps
|
|
218
|
+
|
|
219
|
+
def list(self) -> list[App]:
|
|
220
|
+
"""Return all installed apps."""
|
|
221
|
+
return self._apps
|
|
222
|
+
|
|
223
|
+
def get(self, prefix: str) -> App | None:
|
|
224
|
+
"""Get app by mount prefix."""
|
|
225
|
+
return next((app for app in self._apps if app.prefix == prefix), None)
|
|
Binary file
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Service Information</title>
|
|
7
|
+
<style>
|
|
8
|
+
body {
|
|
9
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
10
|
+
max-width: 800px;
|
|
11
|
+
margin: 2rem auto;
|
|
12
|
+
padding: 0 1rem;
|
|
13
|
+
background: #f5f5f5;
|
|
14
|
+
}
|
|
15
|
+
.container {
|
|
16
|
+
background: white;
|
|
17
|
+
border-radius: 8px;
|
|
18
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
19
|
+
padding: 2rem;
|
|
20
|
+
}
|
|
21
|
+
h1 {
|
|
22
|
+
margin: 0 0 0.5rem 0;
|
|
23
|
+
color: #333;
|
|
24
|
+
}
|
|
25
|
+
.nav-links {
|
|
26
|
+
display: flex;
|
|
27
|
+
gap: 1rem;
|
|
28
|
+
margin-bottom: 1.5rem;
|
|
29
|
+
padding-bottom: 1rem;
|
|
30
|
+
border-bottom: 1px solid #e5e5e5;
|
|
31
|
+
}
|
|
32
|
+
.nav-links a {
|
|
33
|
+
color: #2563eb;
|
|
34
|
+
text-decoration: none;
|
|
35
|
+
font-size: 0.875rem;
|
|
36
|
+
}
|
|
37
|
+
.nav-links a:hover {
|
|
38
|
+
text-decoration: underline;
|
|
39
|
+
}
|
|
40
|
+
.info-grid {
|
|
41
|
+
display: grid;
|
|
42
|
+
gap: 1rem;
|
|
43
|
+
margin-top: 1.5rem;
|
|
44
|
+
}
|
|
45
|
+
.info-item {
|
|
46
|
+
display: grid;
|
|
47
|
+
gap: 0.25rem;
|
|
48
|
+
}
|
|
49
|
+
.info-label {
|
|
50
|
+
font-size: 0.75rem;
|
|
51
|
+
text-transform: uppercase;
|
|
52
|
+
color: #666;
|
|
53
|
+
font-weight: 600;
|
|
54
|
+
letter-spacing: 0.05em;
|
|
55
|
+
}
|
|
56
|
+
.info-value {
|
|
57
|
+
color: #333;
|
|
58
|
+
font-size: 1rem;
|
|
59
|
+
}
|
|
60
|
+
pre {
|
|
61
|
+
background: #f5f5f5;
|
|
62
|
+
padding: 1rem;
|
|
63
|
+
border-radius: 4px;
|
|
64
|
+
overflow-x: auto;
|
|
65
|
+
margin: 0;
|
|
66
|
+
font-size: 0.875rem;
|
|
67
|
+
}
|
|
68
|
+
.loading {
|
|
69
|
+
text-align: center;
|
|
70
|
+
color: #666;
|
|
71
|
+
padding: 2rem;
|
|
72
|
+
}
|
|
73
|
+
.error {
|
|
74
|
+
color: #dc2626;
|
|
75
|
+
padding: 1rem;
|
|
76
|
+
background: #fee;
|
|
77
|
+
border-radius: 4px;
|
|
78
|
+
}
|
|
79
|
+
</style>
|
|
80
|
+
</head>
|
|
81
|
+
<body>
|
|
82
|
+
<div class="container">
|
|
83
|
+
<div id="content" class="loading">Loading service information...</div>
|
|
84
|
+
</div>
|
|
85
|
+
<script>
|
|
86
|
+
fetch('/api/v1/info')
|
|
87
|
+
.then(res => res.json())
|
|
88
|
+
.then(info => {
|
|
89
|
+
const items = [];
|
|
90
|
+
|
|
91
|
+
items.push(`<h1>${info.display_name}</h1>`);
|
|
92
|
+
items.push(`<div class="nav-links">
|
|
93
|
+
<a href="/docs">📚 API Documentation</a>
|
|
94
|
+
<a href="/redoc">📖 ReDoc</a>
|
|
95
|
+
</div>`);
|
|
96
|
+
items.push(`<div class="info-grid">`);
|
|
97
|
+
|
|
98
|
+
items.push(`<div class="info-item">
|
|
99
|
+
<div class="info-label">Version</div>
|
|
100
|
+
<div class="info-value">${info.version}</div>
|
|
101
|
+
</div>`);
|
|
102
|
+
|
|
103
|
+
if (info.summary) {
|
|
104
|
+
items.push(`<div class="info-item">
|
|
105
|
+
<div class="info-label">Summary</div>
|
|
106
|
+
<div class="info-value">${info.summary}</div>
|
|
107
|
+
</div>`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (info.description) {
|
|
111
|
+
items.push(`<div class="info-item">
|
|
112
|
+
<div class="info-label">Description</div>
|
|
113
|
+
<div class="info-value">${info.description}</div>
|
|
114
|
+
</div>`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (info.contact) {
|
|
118
|
+
const contactJson = JSON.stringify(info.contact, null, 2);
|
|
119
|
+
items.push(`<div class="info-item">
|
|
120
|
+
<div class="info-label">Contact</div>
|
|
121
|
+
<div class="info-value"><pre>${contactJson}</pre></div>
|
|
122
|
+
</div>`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (info.license_info) {
|
|
126
|
+
const licenseJson = JSON.stringify(info.license_info, null, 2);
|
|
127
|
+
items.push(`<div class="info-item">
|
|
128
|
+
<div class="info-label">License</div>
|
|
129
|
+
<div class="info-value"><pre>${licenseJson}</pre></div>
|
|
130
|
+
</div>`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
items.push(`</div>`);
|
|
134
|
+
const contentEl = document.getElementById('content');
|
|
135
|
+
contentEl.className = '';
|
|
136
|
+
contentEl.innerHTML = items.join('');
|
|
137
|
+
})
|
|
138
|
+
.catch(err => {
|
|
139
|
+
const errorMsg = 'Failed to load service information: ' + err.message;
|
|
140
|
+
const contentEl = document.getElementById('content');
|
|
141
|
+
contentEl.className = '';
|
|
142
|
+
contentEl.innerHTML = `<div class="error">${errorMsg}</div>`;
|
|
143
|
+
});
|
|
144
|
+
</script>
|
|
145
|
+
</body>
|
|
146
|
+
</html>
|
servicekit/api/auth.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""API key authentication middleware and utilities."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Set
|
|
6
|
+
|
|
7
|
+
from fastapi import Request, Response, status
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
10
|
+
|
|
11
|
+
from servicekit.logging import get_logger
|
|
12
|
+
from servicekit.schemas import ProblemDetail
|
|
13
|
+
|
|
14
|
+
from .middleware import MiddlewareCallNext
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class APIKeyMiddleware(BaseHTTPMiddleware):
|
|
20
|
+
"""Middleware for API key authentication via X-API-Key header."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
app: Any,
|
|
25
|
+
*,
|
|
26
|
+
api_keys: Set[str],
|
|
27
|
+
header_name: str = "X-API-Key",
|
|
28
|
+
unauthenticated_paths: Set[str],
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Initialize API key middleware.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
app: ASGI application
|
|
34
|
+
api_keys: Set of valid API keys
|
|
35
|
+
header_name: HTTP header name for API key
|
|
36
|
+
unauthenticated_paths: Paths that don't require authentication
|
|
37
|
+
"""
|
|
38
|
+
super().__init__(app)
|
|
39
|
+
self.api_keys = api_keys
|
|
40
|
+
self.header_name = header_name
|
|
41
|
+
self.unauthenticated_paths = unauthenticated_paths
|
|
42
|
+
|
|
43
|
+
async def dispatch(self, request: Request, call_next: MiddlewareCallNext) -> Response:
|
|
44
|
+
"""Process request with API key authentication."""
|
|
45
|
+
# Allow unauthenticated access to specific paths
|
|
46
|
+
if request.url.path in self.unauthenticated_paths:
|
|
47
|
+
return await call_next(request)
|
|
48
|
+
|
|
49
|
+
# Extract API key from header
|
|
50
|
+
api_key = request.headers.get(self.header_name)
|
|
51
|
+
|
|
52
|
+
if not api_key:
|
|
53
|
+
logger.warning(
|
|
54
|
+
"auth.missing_key",
|
|
55
|
+
path=request.url.path,
|
|
56
|
+
method=request.method,
|
|
57
|
+
)
|
|
58
|
+
problem = ProblemDetail(
|
|
59
|
+
type="urn:servicekit:error:unauthorized",
|
|
60
|
+
title="Unauthorized",
|
|
61
|
+
status=status.HTTP_401_UNAUTHORIZED,
|
|
62
|
+
detail=f"Missing authentication header: {self.header_name}",
|
|
63
|
+
instance=str(request.url.path),
|
|
64
|
+
)
|
|
65
|
+
return JSONResponse(
|
|
66
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
67
|
+
content=problem.model_dump(exclude_none=True),
|
|
68
|
+
media_type="application/problem+json",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Validate API key
|
|
72
|
+
if api_key not in self.api_keys:
|
|
73
|
+
# Log only prefix for security
|
|
74
|
+
key_prefix = api_key[:7] if len(api_key) >= 7 else "***"
|
|
75
|
+
logger.warning(
|
|
76
|
+
"auth.invalid_key",
|
|
77
|
+
key_prefix=key_prefix,
|
|
78
|
+
path=request.url.path,
|
|
79
|
+
method=request.method,
|
|
80
|
+
)
|
|
81
|
+
problem = ProblemDetail(
|
|
82
|
+
type="urn:servicekit:error:unauthorized",
|
|
83
|
+
title="Unauthorized",
|
|
84
|
+
status=status.HTTP_401_UNAUTHORIZED,
|
|
85
|
+
detail="Invalid API key",
|
|
86
|
+
instance=str(request.url.path),
|
|
87
|
+
)
|
|
88
|
+
return JSONResponse(
|
|
89
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
90
|
+
content=problem.model_dump(exclude_none=True),
|
|
91
|
+
media_type="application/problem+json",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Attach key prefix to request state for logging
|
|
95
|
+
request.state.api_key_prefix = api_key[:7] if len(api_key) >= 7 else "***"
|
|
96
|
+
|
|
97
|
+
logger.info(
|
|
98
|
+
"auth.success",
|
|
99
|
+
key_prefix=request.state.api_key_prefix,
|
|
100
|
+
path=request.url.path,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return await call_next(request)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_api_keys_from_env(env_var: str = "SERVICEKIT_API_KEYS") -> Set[str]:
|
|
107
|
+
"""Load API keys from environment variable (comma-separated).
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
env_var: Environment variable name
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Set of API keys
|
|
114
|
+
"""
|
|
115
|
+
env_value = os.getenv(env_var, "")
|
|
116
|
+
if not env_value:
|
|
117
|
+
return set()
|
|
118
|
+
return {key.strip() for key in env_value.split(",") if key.strip()}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load_api_keys_from_file(file_path: str | Path) -> Set[str]:
|
|
122
|
+
"""Load API keys from file (one key per line).
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
file_path: Path to file containing API keys
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Set of API keys
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
FileNotFoundError: If file doesn't exist
|
|
132
|
+
"""
|
|
133
|
+
path = Path(file_path)
|
|
134
|
+
if not path.exists():
|
|
135
|
+
raise FileNotFoundError(f"API key file not found: {file_path}")
|
|
136
|
+
|
|
137
|
+
keys = set()
|
|
138
|
+
with path.open("r") as f:
|
|
139
|
+
for line in f:
|
|
140
|
+
line = line.strip()
|
|
141
|
+
if line and not line.startswith("#"): # Skip empty lines and comments
|
|
142
|
+
keys.add(line)
|
|
143
|
+
|
|
144
|
+
return keys
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def validate_api_key_format(key: str) -> bool:
|
|
148
|
+
"""Validate API key format.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
key: API key to validate
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
True if key format is valid
|
|
155
|
+
"""
|
|
156
|
+
# Basic validation: minimum length
|
|
157
|
+
if len(key) < 16:
|
|
158
|
+
return False
|
|
159
|
+
# Optional: Check for prefix pattern like sk_env_random
|
|
160
|
+
# if not key.startswith("sk_"):
|
|
161
|
+
# return False
|
|
162
|
+
return True
|