@weirdfingers/baseboards 0.2.0
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.
- package/README.md +191 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +887 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
- package/templates/README.md +120 -0
- package/templates/api/.env.example +62 -0
- package/templates/api/Dockerfile +32 -0
- package/templates/api/README.md +132 -0
- package/templates/api/alembic/env.py +106 -0
- package/templates/api/alembic/script.py.mako +28 -0
- package/templates/api/alembic/versions/20250101_000000_initial_schema.py +448 -0
- package/templates/api/alembic/versions/20251022_174729_remove_provider_name_from_generations.py +71 -0
- package/templates/api/alembic/versions/20251023_165852_switch_to_declarative_base_and_mapping.py +411 -0
- package/templates/api/alembic/versions/2025925_62735_add_seed_data_for_default_tenant.py +85 -0
- package/templates/api/alembic.ini +36 -0
- package/templates/api/config/generators.yaml +25 -0
- package/templates/api/config/storage_config.yaml +26 -0
- package/templates/api/docs/ADDING_GENERATORS.md +409 -0
- package/templates/api/docs/GENERATORS_API.md +502 -0
- package/templates/api/docs/MIGRATIONS.md +472 -0
- package/templates/api/docs/storage_providers.md +337 -0
- package/templates/api/pyproject.toml +165 -0
- package/templates/api/src/boards/__init__.py +10 -0
- package/templates/api/src/boards/api/app.py +171 -0
- package/templates/api/src/boards/api/auth.py +75 -0
- package/templates/api/src/boards/api/endpoints/__init__.py +3 -0
- package/templates/api/src/boards/api/endpoints/jobs.py +76 -0
- package/templates/api/src/boards/api/endpoints/setup.py +505 -0
- package/templates/api/src/boards/api/endpoints/sse.py +129 -0
- package/templates/api/src/boards/api/endpoints/storage.py +74 -0
- package/templates/api/src/boards/api/endpoints/tenant_registration.py +296 -0
- package/templates/api/src/boards/api/endpoints/webhooks.py +13 -0
- package/templates/api/src/boards/auth/__init__.py +15 -0
- package/templates/api/src/boards/auth/adapters/__init__.py +20 -0
- package/templates/api/src/boards/auth/adapters/auth0.py +220 -0
- package/templates/api/src/boards/auth/adapters/base.py +73 -0
- package/templates/api/src/boards/auth/adapters/clerk.py +172 -0
- package/templates/api/src/boards/auth/adapters/jwt.py +122 -0
- package/templates/api/src/boards/auth/adapters/none.py +102 -0
- package/templates/api/src/boards/auth/adapters/oidc.py +284 -0
- package/templates/api/src/boards/auth/adapters/supabase.py +110 -0
- package/templates/api/src/boards/auth/context.py +35 -0
- package/templates/api/src/boards/auth/factory.py +115 -0
- package/templates/api/src/boards/auth/middleware.py +221 -0
- package/templates/api/src/boards/auth/provisioning.py +129 -0
- package/templates/api/src/boards/auth/tenant_extraction.py +278 -0
- package/templates/api/src/boards/cli.py +354 -0
- package/templates/api/src/boards/config.py +116 -0
- package/templates/api/src/boards/database/__init__.py +7 -0
- package/templates/api/src/boards/database/cli.py +110 -0
- package/templates/api/src/boards/database/connection.py +252 -0
- package/templates/api/src/boards/database/models.py +19 -0
- package/templates/api/src/boards/database/seed_data.py +182 -0
- package/templates/api/src/boards/dbmodels/__init__.py +455 -0
- package/templates/api/src/boards/generators/__init__.py +57 -0
- package/templates/api/src/boards/generators/artifacts.py +53 -0
- package/templates/api/src/boards/generators/base.py +140 -0
- package/templates/api/src/boards/generators/implementations/__init__.py +12 -0
- package/templates/api/src/boards/generators/implementations/audio/__init__.py +3 -0
- package/templates/api/src/boards/generators/implementations/audio/whisper.py +66 -0
- package/templates/api/src/boards/generators/implementations/image/__init__.py +3 -0
- package/templates/api/src/boards/generators/implementations/image/dalle3.py +93 -0
- package/templates/api/src/boards/generators/implementations/image/flux_pro.py +85 -0
- package/templates/api/src/boards/generators/implementations/video/__init__.py +3 -0
- package/templates/api/src/boards/generators/implementations/video/lipsync.py +70 -0
- package/templates/api/src/boards/generators/loader.py +253 -0
- package/templates/api/src/boards/generators/registry.py +114 -0
- package/templates/api/src/boards/generators/resolution.py +515 -0
- package/templates/api/src/boards/generators/testmods/class_gen.py +34 -0
- package/templates/api/src/boards/generators/testmods/import_side_effect.py +35 -0
- package/templates/api/src/boards/graphql/__init__.py +7 -0
- package/templates/api/src/boards/graphql/access_control.py +136 -0
- package/templates/api/src/boards/graphql/mutations/root.py +136 -0
- package/templates/api/src/boards/graphql/queries/root.py +116 -0
- package/templates/api/src/boards/graphql/resolvers/__init__.py +8 -0
- package/templates/api/src/boards/graphql/resolvers/auth.py +12 -0
- package/templates/api/src/boards/graphql/resolvers/board.py +1055 -0
- package/templates/api/src/boards/graphql/resolvers/generation.py +889 -0
- package/templates/api/src/boards/graphql/resolvers/generator.py +50 -0
- package/templates/api/src/boards/graphql/resolvers/user.py +25 -0
- package/templates/api/src/boards/graphql/schema.py +81 -0
- package/templates/api/src/boards/graphql/types/board.py +102 -0
- package/templates/api/src/boards/graphql/types/generation.py +130 -0
- package/templates/api/src/boards/graphql/types/generator.py +17 -0
- package/templates/api/src/boards/graphql/types/user.py +47 -0
- package/templates/api/src/boards/jobs/repository.py +104 -0
- package/templates/api/src/boards/logging.py +195 -0
- package/templates/api/src/boards/middleware.py +339 -0
- package/templates/api/src/boards/progress/__init__.py +4 -0
- package/templates/api/src/boards/progress/models.py +25 -0
- package/templates/api/src/boards/progress/publisher.py +64 -0
- package/templates/api/src/boards/py.typed +0 -0
- package/templates/api/src/boards/redis_pool.py +118 -0
- package/templates/api/src/boards/storage/__init__.py +52 -0
- package/templates/api/src/boards/storage/base.py +363 -0
- package/templates/api/src/boards/storage/config.py +187 -0
- package/templates/api/src/boards/storage/factory.py +278 -0
- package/templates/api/src/boards/storage/implementations/__init__.py +27 -0
- package/templates/api/src/boards/storage/implementations/gcs.py +340 -0
- package/templates/api/src/boards/storage/implementations/local.py +201 -0
- package/templates/api/src/boards/storage/implementations/s3.py +294 -0
- package/templates/api/src/boards/storage/implementations/supabase.py +218 -0
- package/templates/api/src/boards/tenant_isolation.py +446 -0
- package/templates/api/src/boards/validation.py +262 -0
- package/templates/api/src/boards/workers/__init__.py +1 -0
- package/templates/api/src/boards/workers/actors.py +201 -0
- package/templates/api/src/boards/workers/cli.py +125 -0
- package/templates/api/src/boards/workers/context.py +188 -0
- package/templates/api/src/boards/workers/middleware.py +58 -0
- package/templates/api/src/py.typed +0 -0
- package/templates/compose.dev.yaml +39 -0
- package/templates/compose.yaml +109 -0
- package/templates/docker/env.example +23 -0
- package/templates/web/.env.example +28 -0
- package/templates/web/Dockerfile +51 -0
- package/templates/web/components.json +22 -0
- package/templates/web/imageLoader.js +18 -0
- package/templates/web/next-env.d.ts +5 -0
- package/templates/web/next.config.js +36 -0
- package/templates/web/package.json +37 -0
- package/templates/web/postcss.config.mjs +7 -0
- package/templates/web/public/favicon.ico +0 -0
- package/templates/web/src/app/boards/[boardId]/page.tsx +232 -0
- package/templates/web/src/app/globals.css +120 -0
- package/templates/web/src/app/layout.tsx +21 -0
- package/templates/web/src/app/page.tsx +35 -0
- package/templates/web/src/app/providers.tsx +18 -0
- package/templates/web/src/components/boards/ArtifactInputSlots.tsx +142 -0
- package/templates/web/src/components/boards/ArtifactPreview.tsx +125 -0
- package/templates/web/src/components/boards/GenerationGrid.tsx +45 -0
- package/templates/web/src/components/boards/GenerationInput.tsx +251 -0
- package/templates/web/src/components/boards/GeneratorSelector.tsx +89 -0
- package/templates/web/src/components/header.tsx +30 -0
- package/templates/web/src/components/ui/button.tsx +58 -0
- package/templates/web/src/components/ui/card.tsx +92 -0
- package/templates/web/src/components/ui/navigation-menu.tsx +168 -0
- package/templates/web/src/lib/utils.ts +6 -0
- package/templates/web/tsconfig.json +47 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized logging configuration using structlog
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import logging
|
|
7
|
+
import secrets
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from contextvars import ContextVar
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import structlog
|
|
14
|
+
from fastapi import Request
|
|
15
|
+
|
|
16
|
+
# Context variables for request tracking
|
|
17
|
+
request_id_ctx: ContextVar[str | None] = ContextVar("request_id", default=None)
|
|
18
|
+
user_id_ctx: ContextVar[str | None] = ContextVar("user_id", default=None)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RequestContextFilter:
|
|
22
|
+
"""Add request context to log records."""
|
|
23
|
+
|
|
24
|
+
def __call__(self, logger: Any, method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
|
|
25
|
+
"""Add request context to the event dict."""
|
|
26
|
+
# Suppress unused parameter warnings - these are required by structlog interface
|
|
27
|
+
_ = logger, method_name
|
|
28
|
+
|
|
29
|
+
request_id = request_id_ctx.get()
|
|
30
|
+
user_id = user_id_ctx.get()
|
|
31
|
+
|
|
32
|
+
if request_id:
|
|
33
|
+
event_dict["request_id"] = request_id
|
|
34
|
+
|
|
35
|
+
if user_id:
|
|
36
|
+
event_dict["user_id"] = user_id
|
|
37
|
+
|
|
38
|
+
return event_dict
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def configure_logging(debug: bool = False) -> None:
|
|
42
|
+
"""Configure structlog with appropriate processors and formatters.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
debug: If True, use human-readable console output. If False, use JSON.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
# Determine log level
|
|
49
|
+
log_level = logging.DEBUG if debug else logging.INFO
|
|
50
|
+
|
|
51
|
+
# Configure stdlib logging
|
|
52
|
+
logging.basicConfig(
|
|
53
|
+
level=log_level,
|
|
54
|
+
stream=sys.stdout,
|
|
55
|
+
format="%(message)s",
|
|
56
|
+
force=True,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Configure structlog processors
|
|
60
|
+
processors = [
|
|
61
|
+
# Filter out keys with underscores (internal)
|
|
62
|
+
structlog.stdlib.filter_by_level,
|
|
63
|
+
# Add logger name to event dict
|
|
64
|
+
structlog.stdlib.add_logger_name,
|
|
65
|
+
# Add log level to event dict
|
|
66
|
+
structlog.stdlib.add_log_level,
|
|
67
|
+
# Add request context
|
|
68
|
+
RequestContextFilter(),
|
|
69
|
+
# Add timestamp
|
|
70
|
+
structlog.processors.TimeStamper(fmt="ISO", utc=True),
|
|
71
|
+
# Perform %-style string formatting
|
|
72
|
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
|
73
|
+
# Stack info processor (for exceptions)
|
|
74
|
+
structlog.processors.StackInfoRenderer(),
|
|
75
|
+
# Exception info processor
|
|
76
|
+
structlog.processors.format_exc_info,
|
|
77
|
+
# Unicode decoder processor
|
|
78
|
+
structlog.processors.UnicodeDecoder(),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
if debug:
|
|
82
|
+
# Development: human-readable console output
|
|
83
|
+
processors.append(structlog.dev.ConsoleRenderer(colors=True))
|
|
84
|
+
else:
|
|
85
|
+
# Production: JSON output
|
|
86
|
+
processors.append(structlog.processors.JSONRenderer())
|
|
87
|
+
|
|
88
|
+
# Configure structlog
|
|
89
|
+
structlog.configure(
|
|
90
|
+
processors=processors,
|
|
91
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
92
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
93
|
+
context_class=dict,
|
|
94
|
+
cache_logger_on_first_use=True,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_logger(name: str) -> structlog.BoundLogger:
|
|
99
|
+
"""Get a structlog logger instance.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
name: Logger name (typically __name__)
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Configured structlog logger
|
|
106
|
+
"""
|
|
107
|
+
return structlog.get_logger(name)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def generate_request_id() -> str:
|
|
111
|
+
"""Generate a compact, secure request ID with timestamp and randomness.
|
|
112
|
+
|
|
113
|
+
Uses microsecond precision timestamp with added randomness for security.
|
|
114
|
+
Format: 11-character base64 string (e.g., 'Ab3X9mF2xYz')
|
|
115
|
+
|
|
116
|
+
Provides high uniqueness probability while preventing predictable enumeration.
|
|
117
|
+
"""
|
|
118
|
+
# Get microseconds since epoch (8 bytes when encoded as int64)
|
|
119
|
+
timestamp_us = int(time.time() * 1_000_000)
|
|
120
|
+
|
|
121
|
+
# Add 2 bytes of cryptographically secure randomness
|
|
122
|
+
random_bytes = secrets.token_bytes(2)
|
|
123
|
+
|
|
124
|
+
# Convert timestamp to bytes (8 bytes for int64) and combine with random bytes
|
|
125
|
+
timestamp_bytes = timestamp_us.to_bytes(8, byteorder="big")
|
|
126
|
+
combined_bytes = timestamp_bytes + random_bytes
|
|
127
|
+
|
|
128
|
+
# Encode as base64 and strip padding
|
|
129
|
+
b64 = base64.urlsafe_b64encode(combined_bytes).decode("ascii").rstrip("=")
|
|
130
|
+
|
|
131
|
+
return b64
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def set_request_context(request_id: str | None = None, user_id: str | None = None) -> None:
|
|
135
|
+
"""Set request context variables.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
request_id: Request ID to set (generates one if None)
|
|
139
|
+
user_id: User ID to set
|
|
140
|
+
"""
|
|
141
|
+
if request_id is None:
|
|
142
|
+
request_id = generate_request_id()
|
|
143
|
+
|
|
144
|
+
request_id_ctx.set(request_id)
|
|
145
|
+
if user_id is not None:
|
|
146
|
+
user_id_ctx.set(user_id)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def clear_request_context() -> None:
|
|
150
|
+
"""Clear request context variables."""
|
|
151
|
+
request_id_ctx.set(None)
|
|
152
|
+
user_id_ctx.set(None)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_request_id() -> str | None:
|
|
156
|
+
"""Get the current request ID."""
|
|
157
|
+
return request_id_ctx.get()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_user_id() -> str | None:
|
|
161
|
+
"""Get the current user ID."""
|
|
162
|
+
return user_id_ctx.get()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def extract_user_id_from_request(request: Request) -> str | None:
|
|
166
|
+
"""Extract user ID from FastAPI request.
|
|
167
|
+
|
|
168
|
+
This should be customized based on your authentication implementation.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
request: FastAPI request object
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
User ID if authenticated, None otherwise
|
|
175
|
+
"""
|
|
176
|
+
# TODO: Implement based on your auth strategy
|
|
177
|
+
# Examples:
|
|
178
|
+
# - JWT token in Authorization header
|
|
179
|
+
# - Session data
|
|
180
|
+
# - Supabase auth token
|
|
181
|
+
|
|
182
|
+
# For now, look for a user ID in headers (customize as needed)
|
|
183
|
+
auth_header = request.headers.get("authorization")
|
|
184
|
+
if auth_header:
|
|
185
|
+
# This is a placeholder - implement actual token parsing
|
|
186
|
+
# For example, if using Bearer tokens:
|
|
187
|
+
# if auth_header.startswith("Bearer "):
|
|
188
|
+
# token = auth_header[7:]
|
|
189
|
+
# # Parse JWT token to extract user_id
|
|
190
|
+
# return parsed_user_id
|
|
191
|
+
|
|
192
|
+
# For now, return None until auth is implemented
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
return None
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Middleware for request context and logging
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from fastapi import Request, Response
|
|
11
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
12
|
+
|
|
13
|
+
from .config import settings
|
|
14
|
+
from .logging import (
|
|
15
|
+
clear_request_context,
|
|
16
|
+
extract_user_id_from_request,
|
|
17
|
+
get_logger,
|
|
18
|
+
set_request_context,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sanitize_query_params(params: dict[str, Any]) -> dict[str, Any]:
|
|
25
|
+
"""Remove sensitive query parameters from logging.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
params: Dictionary of query parameters
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Dictionary with sensitive parameters redacted
|
|
32
|
+
"""
|
|
33
|
+
sensitive_keys = {
|
|
34
|
+
"password",
|
|
35
|
+
"token",
|
|
36
|
+
"api_key",
|
|
37
|
+
"secret",
|
|
38
|
+
"auth",
|
|
39
|
+
"authorization",
|
|
40
|
+
"access_token",
|
|
41
|
+
"refresh_token",
|
|
42
|
+
"key",
|
|
43
|
+
"private_key",
|
|
44
|
+
"jwt",
|
|
45
|
+
"session",
|
|
46
|
+
"session_id",
|
|
47
|
+
"cookie",
|
|
48
|
+
"credentials",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
sanitized = {}
|
|
52
|
+
for key, value in params.items():
|
|
53
|
+
# Check if any sensitive keyword is in the parameter name (case insensitive)
|
|
54
|
+
if any(sensitive in key.lower() for sensitive in sensitive_keys):
|
|
55
|
+
sanitized[key] = "[REDACTED]"
|
|
56
|
+
else:
|
|
57
|
+
sanitized[key] = value
|
|
58
|
+
|
|
59
|
+
return sanitized
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# 1) Extend extractor to support GET /graphql as well
|
|
63
|
+
async def extract_graphql_operation_name(request: Request) -> str | None:
|
|
64
|
+
if request.url.path != "/graphql":
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
# Handle GET /graphql (operationName in query params, or parse from query)
|
|
68
|
+
if request.method == "GET":
|
|
69
|
+
params = dict(request.query_params)
|
|
70
|
+
op = params.get("operationName")
|
|
71
|
+
if isinstance(op, str) and op:
|
|
72
|
+
return op
|
|
73
|
+
|
|
74
|
+
q = params.get("query", "")
|
|
75
|
+
if not isinstance(q, str) or not q:
|
|
76
|
+
return None
|
|
77
|
+
if "__schema" in q or "IntrospectionQuery" in q:
|
|
78
|
+
return "__introspection"
|
|
79
|
+
|
|
80
|
+
match = re.search(r"\bquery\s+(\w+)", q) or re.search(r"\bmutation\s+(\w+)", q)
|
|
81
|
+
if match:
|
|
82
|
+
kind = "mutation:" if q.lstrip().startswith("mutation") else ""
|
|
83
|
+
return f"{kind}{match.group(1)}"
|
|
84
|
+
return "unnamed_operation"
|
|
85
|
+
|
|
86
|
+
# Existing POST /graphql logic
|
|
87
|
+
if request.method == "POST":
|
|
88
|
+
try:
|
|
89
|
+
body = await request.body()
|
|
90
|
+
if not body:
|
|
91
|
+
return None
|
|
92
|
+
data = json.loads(body)
|
|
93
|
+
op = data.get("operationName")
|
|
94
|
+
if isinstance(op, str) and op:
|
|
95
|
+
return op
|
|
96
|
+
q = data.get("query", "")
|
|
97
|
+
if not isinstance(q, str) or not q:
|
|
98
|
+
return None
|
|
99
|
+
if "__schema" in q or "IntrospectionQuery" in q:
|
|
100
|
+
return "__introspection"
|
|
101
|
+
m = re.search(r"\bquery\s+(\w+)", q) or re.search(r"\bmutation\s+(\w+)", q)
|
|
102
|
+
if m:
|
|
103
|
+
kind = "mutation:" if q.lstrip().startswith("mutation") else ""
|
|
104
|
+
return f"{kind}{m.group(1)}"
|
|
105
|
+
return "unnamed_operation"
|
|
106
|
+
except (json.JSONDecodeError, TypeError):
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class LoggingContextMiddleware(BaseHTTPMiddleware):
|
|
113
|
+
"""Middleware to set logging context for each request."""
|
|
114
|
+
|
|
115
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
116
|
+
"""Process request and set logging context."""
|
|
117
|
+
|
|
118
|
+
# Extract user ID from request (implement based on your auth)
|
|
119
|
+
user_id = extract_user_id_from_request(request)
|
|
120
|
+
|
|
121
|
+
# Set request context
|
|
122
|
+
set_request_context(user_id=user_id)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
# Sanitize query parameters for logging
|
|
126
|
+
sanitized_params = None
|
|
127
|
+
if request.query_params:
|
|
128
|
+
sanitized_params = sanitize_query_params(dict(request.query_params))
|
|
129
|
+
# If hitting /graphql, never log raw GraphQL payload in query string
|
|
130
|
+
# Comment this out to see the raw GraphQL payload in the query string
|
|
131
|
+
if request.url.path == "/graphql" and isinstance(sanitized_params, dict):
|
|
132
|
+
for k in ("query", "variables", "extensions"):
|
|
133
|
+
if k in sanitized_params:
|
|
134
|
+
sanitized_params[k] = "[REDACTED]"
|
|
135
|
+
|
|
136
|
+
# Extract GraphQL operation name if applicable
|
|
137
|
+
graphql_operation = await extract_graphql_operation_name(request)
|
|
138
|
+
|
|
139
|
+
# Log request start
|
|
140
|
+
log_data = {
|
|
141
|
+
"method": request.method,
|
|
142
|
+
"path": request.url.path,
|
|
143
|
+
"query_params": sanitized_params,
|
|
144
|
+
"user_agent": request.headers.get("user-agent"),
|
|
145
|
+
"remote_addr": request.client.host if request.client else None,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# Add GraphQL operation name if available
|
|
149
|
+
if graphql_operation:
|
|
150
|
+
log_data["graphql_operation"] = graphql_operation
|
|
151
|
+
|
|
152
|
+
logger.info("Request started", **log_data)
|
|
153
|
+
|
|
154
|
+
# Process request
|
|
155
|
+
response = await call_next(request)
|
|
156
|
+
|
|
157
|
+
# Log request completion
|
|
158
|
+
logger.info(
|
|
159
|
+
"Request completed",
|
|
160
|
+
status_code=response.status_code,
|
|
161
|
+
method=request.method,
|
|
162
|
+
path=request.url.path,
|
|
163
|
+
graphql_operation=graphql_operation, # Include in completion log too
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return response
|
|
167
|
+
|
|
168
|
+
except Exception as e:
|
|
169
|
+
# Log request error
|
|
170
|
+
logger.error(
|
|
171
|
+
"Request failed",
|
|
172
|
+
method=request.method,
|
|
173
|
+
path=request.url.path,
|
|
174
|
+
error=str(e),
|
|
175
|
+
)
|
|
176
|
+
raise
|
|
177
|
+
|
|
178
|
+
finally:
|
|
179
|
+
# Clear context
|
|
180
|
+
clear_request_context()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class TenantRoutingMiddleware(BaseHTTPMiddleware):
|
|
184
|
+
"""
|
|
185
|
+
Middleware for tenant-aware request routing and validation.
|
|
186
|
+
|
|
187
|
+
This middleware:
|
|
188
|
+
1. Validates X-Tenant headers in multi-tenant mode
|
|
189
|
+
2. Enforces tenant isolation rules
|
|
190
|
+
3. Sets up request context for tenant-scoped operations
|
|
191
|
+
4. Provides early tenant validation before auth processing
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
195
|
+
"""Process request with tenant routing and validation."""
|
|
196
|
+
|
|
197
|
+
# Extract tenant information from headers
|
|
198
|
+
x_tenant = request.headers.get("X-Tenant")
|
|
199
|
+
|
|
200
|
+
# Log incoming request with tenant info
|
|
201
|
+
logger.debug(
|
|
202
|
+
"Processing request with tenant context",
|
|
203
|
+
method=request.method,
|
|
204
|
+
path=request.url.path,
|
|
205
|
+
x_tenant=x_tenant,
|
|
206
|
+
multi_tenant_mode=settings.multi_tenant_mode,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Validate tenant header in multi-tenant mode
|
|
210
|
+
if settings.multi_tenant_mode:
|
|
211
|
+
tenant_validation_result = await self._validate_tenant_header(x_tenant, request)
|
|
212
|
+
if tenant_validation_result is not None:
|
|
213
|
+
return tenant_validation_result
|
|
214
|
+
|
|
215
|
+
# Add tenant context to request state for downstream use
|
|
216
|
+
request.state.tenant_slug = x_tenant or settings.default_tenant_slug
|
|
217
|
+
request.state.multi_tenant_mode = settings.multi_tenant_mode
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
response = await call_next(request)
|
|
221
|
+
|
|
222
|
+
# Add tenant information to response headers for debugging
|
|
223
|
+
if settings.debug:
|
|
224
|
+
response.headers["X-Tenant-Resolved"] = request.state.tenant_slug
|
|
225
|
+
if settings.multi_tenant_mode:
|
|
226
|
+
response.headers["X-Multi-Tenant-Mode"] = "true"
|
|
227
|
+
|
|
228
|
+
return response
|
|
229
|
+
|
|
230
|
+
except Exception as e:
|
|
231
|
+
logger.error(
|
|
232
|
+
"Request processing failed",
|
|
233
|
+
error=str(e),
|
|
234
|
+
tenant_slug=getattr(request.state, "tenant_slug", "unknown"),
|
|
235
|
+
path=request.url.path,
|
|
236
|
+
)
|
|
237
|
+
raise
|
|
238
|
+
|
|
239
|
+
async def _validate_tenant_header(
|
|
240
|
+
self, x_tenant: str | None, request: Request
|
|
241
|
+
) -> Response | None:
|
|
242
|
+
"""
|
|
243
|
+
Validate X-Tenant header in multi-tenant mode.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Response if validation fails (error response), None if validation passes
|
|
247
|
+
"""
|
|
248
|
+
from fastapi.responses import JSONResponse
|
|
249
|
+
|
|
250
|
+
# In multi-tenant mode, some endpoints may require X-Tenant header
|
|
251
|
+
if self._requires_tenant_header(request):
|
|
252
|
+
if not x_tenant:
|
|
253
|
+
logger.warning(
|
|
254
|
+
"Missing required X-Tenant header in multi-tenant mode",
|
|
255
|
+
path=request.url.path,
|
|
256
|
+
method=request.method,
|
|
257
|
+
)
|
|
258
|
+
return JSONResponse(
|
|
259
|
+
status_code=400,
|
|
260
|
+
content={
|
|
261
|
+
"error": "Missing X-Tenant header",
|
|
262
|
+
"detail": (
|
|
263
|
+
"X-Tenant header is required in multi-tenant mode " "for this endpoint"
|
|
264
|
+
),
|
|
265
|
+
"multi_tenant_mode": True,
|
|
266
|
+
},
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Validate tenant slug format if provided
|
|
270
|
+
if x_tenant:
|
|
271
|
+
validation_error = self._validate_tenant_slug_format(x_tenant)
|
|
272
|
+
if validation_error:
|
|
273
|
+
logger.warning(
|
|
274
|
+
"Invalid X-Tenant header format",
|
|
275
|
+
x_tenant=x_tenant,
|
|
276
|
+
error=validation_error,
|
|
277
|
+
)
|
|
278
|
+
return JSONResponse(
|
|
279
|
+
status_code=400,
|
|
280
|
+
content={
|
|
281
|
+
"error": "Invalid X-Tenant header format",
|
|
282
|
+
"detail": validation_error,
|
|
283
|
+
"provided_tenant": x_tenant,
|
|
284
|
+
},
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return None # Validation passed
|
|
288
|
+
|
|
289
|
+
def _requires_tenant_header(self, request: Request) -> bool:
|
|
290
|
+
"""
|
|
291
|
+
Determine if the request requires an X-Tenant header.
|
|
292
|
+
|
|
293
|
+
In multi-tenant mode, most API endpoints require tenant specification,
|
|
294
|
+
except for certain system endpoints like health checks.
|
|
295
|
+
"""
|
|
296
|
+
path = request.url.path
|
|
297
|
+
|
|
298
|
+
# System endpoints that don't require tenant specification
|
|
299
|
+
system_endpoints = {
|
|
300
|
+
"/health",
|
|
301
|
+
"/api/setup/status",
|
|
302
|
+
"/docs",
|
|
303
|
+
"/redoc",
|
|
304
|
+
"/openapi.json",
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if path in system_endpoints:
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
# Setup endpoints are special - they help create tenants
|
|
311
|
+
if path.startswith("/api/setup/"):
|
|
312
|
+
return False
|
|
313
|
+
|
|
314
|
+
# All other API endpoints require tenant in multi-tenant mode
|
|
315
|
+
if path.startswith("/api/") or path.startswith("/graphql"):
|
|
316
|
+
return True
|
|
317
|
+
|
|
318
|
+
return False
|
|
319
|
+
|
|
320
|
+
def _validate_tenant_slug_format(self, tenant_slug: str) -> str | None:
|
|
321
|
+
"""
|
|
322
|
+
Validate tenant slug format.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
Error message if invalid, None if valid
|
|
326
|
+
"""
|
|
327
|
+
if not tenant_slug:
|
|
328
|
+
return "Tenant slug cannot be empty"
|
|
329
|
+
|
|
330
|
+
if len(tenant_slug) > 255:
|
|
331
|
+
return "Tenant slug too long (max 255 characters)"
|
|
332
|
+
|
|
333
|
+
if not re.match(r"^[a-z0-9-]+$", tenant_slug):
|
|
334
|
+
return "Tenant slug must contain only lowercase letters, numbers, and hyphens"
|
|
335
|
+
|
|
336
|
+
if tenant_slug.startswith("-") or tenant_slug.endswith("-"):
|
|
337
|
+
return "Tenant slug cannot start or end with hyphen"
|
|
338
|
+
|
|
339
|
+
return None # Valid
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Pydantic models for progress updates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ArtifactInfo(BaseModel):
|
|
12
|
+
url: str
|
|
13
|
+
type: str
|
|
14
|
+
metadata: dict = {}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProgressUpdate(BaseModel):
|
|
18
|
+
job_id: str
|
|
19
|
+
status: str # Use string to avoid tight coupling to GraphQL enums
|
|
20
|
+
progress: float
|
|
21
|
+
phase: Literal["queued", "initializing", "processing", "finalizing"]
|
|
22
|
+
message: str | None = None
|
|
23
|
+
estimated_completion: datetime | None = None
|
|
24
|
+
artifacts: list[ArtifactInfo] = []
|
|
25
|
+
timestamp: datetime = datetime.now(UTC)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Publisher for progress updates via Redis pub/sub with DB persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..config import Settings
|
|
6
|
+
from ..database.connection import get_async_session
|
|
7
|
+
from ..jobs import repository as jobs_repo
|
|
8
|
+
from ..logging import get_logger
|
|
9
|
+
from ..redis_pool import get_redis_client
|
|
10
|
+
from .models import ProgressUpdate
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProgressPublisher:
|
|
16
|
+
def __init__(self, settings: Settings | None = None) -> None:
|
|
17
|
+
self.settings = settings or Settings()
|
|
18
|
+
# Use the shared Redis connection pool
|
|
19
|
+
self._redis = get_redis_client()
|
|
20
|
+
|
|
21
|
+
async def publish_progress(self, job_id: str, update: ProgressUpdate) -> None:
|
|
22
|
+
"""Publish progress update to Redis and persist to database."""
|
|
23
|
+
channel = f"job:{job_id}:progress"
|
|
24
|
+
await self._persist_update(job_id, update)
|
|
25
|
+
json_data = update.model_dump_json()
|
|
26
|
+
logger.info(
|
|
27
|
+
"Publishing progress update to Redis",
|
|
28
|
+
job_id=job_id,
|
|
29
|
+
channel=channel,
|
|
30
|
+
status=update.status,
|
|
31
|
+
progress=update.progress,
|
|
32
|
+
data_length=len(json_data),
|
|
33
|
+
)
|
|
34
|
+
await self._redis.publish(channel, json_data)
|
|
35
|
+
logger.debug("Progress update published successfully", job_id=job_id)
|
|
36
|
+
|
|
37
|
+
async def publish_only(self, job_id: str, update: ProgressUpdate) -> None:
|
|
38
|
+
"""Publish progress update to Redis without persisting to database.
|
|
39
|
+
|
|
40
|
+
Use this when the database has already been updated separately,
|
|
41
|
+
e.g., after calling finalize_success in the repository.
|
|
42
|
+
"""
|
|
43
|
+
channel = f"job:{job_id}:progress"
|
|
44
|
+
json_data = update.model_dump_json()
|
|
45
|
+
logger.info(
|
|
46
|
+
"Publishing progress update to Redis (no DB persist)",
|
|
47
|
+
job_id=job_id,
|
|
48
|
+
channel=channel,
|
|
49
|
+
status=update.status,
|
|
50
|
+
progress=update.progress,
|
|
51
|
+
data_length=len(json_data),
|
|
52
|
+
)
|
|
53
|
+
await self._redis.publish(channel, json_data)
|
|
54
|
+
logger.debug("Progress update published successfully", job_id=job_id)
|
|
55
|
+
|
|
56
|
+
async def _persist_update(self, job_id: str, update: ProgressUpdate) -> None:
|
|
57
|
+
async with get_async_session() as session:
|
|
58
|
+
await jobs_repo.update_progress(
|
|
59
|
+
session,
|
|
60
|
+
generation_id=job_id,
|
|
61
|
+
status=update.status,
|
|
62
|
+
progress=(update.progress * 100 if update.progress <= 1.0 else update.progress),
|
|
63
|
+
error_message=update.message if update.status == "failed" else None,
|
|
64
|
+
)
|
|
File without changes
|