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 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
+ ]
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>
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "Chapkit Landing Page",
3
+ "version": "1.0.0",
4
+ "prefix": "/",
5
+ "description": "Default landing page showing service information",
6
+ "author": "Chapkit Team",
7
+ "entry": "index.html"
8
+ }
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