flowmine-studio 0.1.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.
Files changed (57) hide show
  1. flowmine_studio/__init__.py +0 -0
  2. flowmine_studio/api/__init__.py +0 -0
  3. flowmine_studio/api/audit.py +27 -0
  4. flowmine_studio/api/auth/__init__.py +0 -0
  5. flowmine_studio/api/auth/providers.py +36 -0
  6. flowmine_studio/api/cli.py +71 -0
  7. flowmine_studio/api/config.py +70 -0
  8. flowmine_studio/api/db.py +57 -0
  9. flowmine_studio/api/dbtypes.py +36 -0
  10. flowmine_studio/api/deps.py +65 -0
  11. flowmine_studio/api/errors.py +53 -0
  12. flowmine_studio/api/logging_setup.py +25 -0
  13. flowmine_studio/api/main.py +134 -0
  14. flowmine_studio/api/models.py +244 -0
  15. flowmine_studio/api/routers/__init__.py +0 -0
  16. flowmine_studio/api/routers/admin_users.py +96 -0
  17. flowmine_studio/api/routers/artifacts.py +31 -0
  18. flowmine_studio/api/routers/auth.py +71 -0
  19. flowmine_studio/api/routers/connections.py +191 -0
  20. flowmine_studio/api/routers/datasets.py +204 -0
  21. flowmine_studio/api/routers/health.py +26 -0
  22. flowmine_studio/api/routers/jobs.py +66 -0
  23. flowmine_studio/api/routers/projects.py +265 -0
  24. flowmine_studio/api/routers/workspaces.py +31 -0
  25. flowmine_studio/api/runner.py +53 -0
  26. flowmine_studio/api/security.py +76 -0
  27. flowmine_studio/api/static/assets/antd-Bd57aUix.js +460 -0
  28. flowmine_studio/api/static/assets/graph-BloDhLCu.js +356 -0
  29. flowmine_studio/api/static/assets/index-C1TGpmAW.js +39 -0
  30. flowmine_studio/api/static/assets/plotly-DZtPyeg0.js +4030 -0
  31. flowmine_studio/api/static/index.html +14 -0
  32. flowmine_studio/scripts/seed.py +163 -0
  33. flowmine_studio/shared/__init__.py +0 -0
  34. flowmine_studio/shared/enums.py +44 -0
  35. flowmine_studio/shared/graph_filter.py +67 -0
  36. flowmine_studio/shared/labels.py +33 -0
  37. flowmine_studio/shared/schemas.py +180 -0
  38. flowmine_studio/worker/__init__.py +0 -0
  39. flowmine_studio/worker/connectors/__init__.py +27 -0
  40. flowmine_studio/worker/connectors/base.py +30 -0
  41. flowmine_studio/worker/connectors/files.py +65 -0
  42. flowmine_studio/worker/connectors/sql_base.py +138 -0
  43. flowmine_studio/worker/connectors/sql_dialects.py +106 -0
  44. flowmine_studio/worker/connectors/xes.py +93 -0
  45. flowmine_studio/worker/engine/__init__.py +1 -0
  46. flowmine_studio/worker/engine/adapter.py +393 -0
  47. flowmine_studio/worker/jobs.py +61 -0
  48. flowmine_studio/worker/storage.py +81 -0
  49. flowmine_studio/worker/tasks/__init__.py +0 -0
  50. flowmine_studio/worker/tasks/analysis.py +140 -0
  51. flowmine_studio/worker/tasks/ingest.py +129 -0
  52. flowmine_studio-0.1.0.dist-info/METADATA +116 -0
  53. flowmine_studio-0.1.0.dist-info/RECORD +57 -0
  54. flowmine_studio-0.1.0.dist-info/WHEEL +5 -0
  55. flowmine_studio-0.1.0.dist-info/entry_points.txt +3 -0
  56. flowmine_studio-0.1.0.dist-info/licenses/LICENSE +661 -0
  57. flowmine_studio-0.1.0.dist-info/top_level.txt +1 -0
File without changes
File without changes
@@ -0,0 +1,27 @@
1
+ from typing import Any
2
+ from uuid import UUID
3
+
4
+ from sqlalchemy.orm import Session
5
+
6
+ from flowmine_studio.api.models import AuditLog
7
+
8
+
9
+ def audit(
10
+ db: Session,
11
+ action: str,
12
+ user_id: UUID | None = None,
13
+ workspace_id: UUID | None = None,
14
+ object_type: str | None = None,
15
+ object_id: UUID | None = None,
16
+ detail: dict[str, Any] | None = None,
17
+ ) -> None:
18
+ db.add(
19
+ AuditLog(
20
+ user_id=user_id,
21
+ workspace_id=workspace_id,
22
+ action=action,
23
+ object_type=object_type,
24
+ object_id=object_id,
25
+ detail=detail,
26
+ )
27
+ )
File without changes
@@ -0,0 +1,36 @@
1
+ """Auth providers. CE ships local username/password auth with workspace RBAC."""
2
+ from typing import Protocol
3
+
4
+ from sqlalchemy import select
5
+ from sqlalchemy.orm import Session
6
+
7
+ from flowmine_studio.api.errors import UnauthorizedError
8
+ from flowmine_studio.api.models import User
9
+ from flowmine_studio.api.security import verify_password
10
+
11
+
12
+ class AuthProvider(Protocol):
13
+ name: str
14
+
15
+ def authenticate(self, db: Session, username: str, password: str) -> User: ...
16
+
17
+
18
+ class LocalProvider:
19
+ name = "local"
20
+
21
+ def authenticate(self, db: Session, username: str, password: str) -> User:
22
+ user = db.scalar(select(User).where(User.username == username))
23
+ if user is None or user.password_hash is None:
24
+ raise UnauthorizedError("Invalid username or password")
25
+ if not verify_password(password, user.password_hash):
26
+ raise UnauthorizedError("Invalid username or password")
27
+ if not user.is_active:
28
+ raise UnauthorizedError("Account is disabled")
29
+ return user
30
+
31
+
32
+ _PROVIDERS: dict[str, AuthProvider] = {"local": LocalProvider()}
33
+
34
+
35
+ def get_provider() -> AuthProvider:
36
+ return _PROVIDERS["local"]
@@ -0,0 +1,71 @@
1
+ """The `flowmine-studio` command: zero-config launch on http://localhost:8080.
2
+
3
+ First run: creates the SQLite database and artifact store in the user data
4
+ directory, seeds the admin account (admin/admin) and the demo dataset, runs the
5
+ demo analysis, then starts the server and opens the browser.
6
+ """
7
+ import argparse
8
+ import threading
9
+ import webbrowser
10
+
11
+ from flowmine_studio.api.config import data_dir, get_settings
12
+ from flowmine_studio.api.logging_setup import get_logger, setup_logging
13
+
14
+ log = get_logger("cli")
15
+
16
+
17
+ def _first_run_setup() -> None:
18
+ from flowmine_studio.api.db import init_db
19
+
20
+ init_db()
21
+ from flowmine_studio.scripts.seed import main as seed_main
22
+
23
+ seed_main()
24
+
25
+
26
+ def _open_browser(url: str) -> None:
27
+ try:
28
+ webbrowser.open(url)
29
+ except Exception: # noqa: BLE001 — headless environments have no browser
30
+ pass
31
+
32
+
33
+ def main() -> None:
34
+ parser = argparse.ArgumentParser(
35
+ prog="flowmine-studio",
36
+ description="FlowMine Studio — process mining. Starts the app on localhost.",
37
+ )
38
+ parser.add_argument("--host", default=None, help="bind host (default 127.0.0.1)")
39
+ parser.add_argument("--port", type=int, default=None, help="port (default 8080)")
40
+ parser.add_argument("--no-browser", action="store_true", help="do not open the browser")
41
+ parser.add_argument("--no-seed", action="store_true", help="skip demo data seeding")
42
+ args = parser.parse_args()
43
+
44
+ setup_logging()
45
+ settings = get_settings()
46
+ host = args.host or settings.host
47
+ port = args.port or settings.port
48
+
49
+ log.info("data_dir", path=str(data_dir()))
50
+ if args.no_seed:
51
+ from flowmine_studio.api.db import init_db
52
+
53
+ init_db()
54
+ else:
55
+ _first_run_setup()
56
+
57
+ url = f"http://{'localhost' if host in ('0.0.0.0', '127.0.0.1') else host}:{port}" # noqa: S104
58
+ print() # noqa: T201 — deliberate console UX
59
+ print(f" FlowMine Studio → {url} (admin / admin)") # noqa: T201
60
+ print(f" Данные: {data_dir()}") # noqa: T201
61
+ print() # noqa: T201
62
+ if not args.no_browser:
63
+ threading.Timer(1.5, _open_browser, args=(url,)).start()
64
+
65
+ import uvicorn
66
+
67
+ uvicorn.run("flowmine_studio.api.main:app", host=host, port=port, log_level="warning")
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()
@@ -0,0 +1,70 @@
1
+ """Settings. Zero-config by default: everything lives under the user data directory
2
+ (~/.flowmine-studio or the OS equivalent). Every knob can be overridden with a
3
+ STUDIO_* environment variable."""
4
+ import secrets
5
+ from functools import lru_cache
6
+ from pathlib import Path
7
+
8
+ from platformdirs import user_data_dir
9
+ from pydantic_settings import BaseSettings, SettingsConfigDict
10
+
11
+
12
+ def data_dir() -> Path:
13
+ d = Path(user_data_dir("flowmine-studio", appauthor=False))
14
+ d.mkdir(parents=True, exist_ok=True)
15
+ return d
16
+
17
+
18
+ def _default_secret_key() -> str:
19
+ """Persist a generated key on first run so encrypted secrets survive restarts."""
20
+ key_file = data_dir() / "secret_key"
21
+ if key_file.exists():
22
+ return key_file.read_text().strip()
23
+ key = secrets.token_urlsafe(48)
24
+ key_file.write_text(key)
25
+ try:
26
+ key_file.chmod(0o600)
27
+ except OSError: # Windows
28
+ pass
29
+ return key
30
+
31
+
32
+ class Settings(BaseSettings):
33
+ model_config = SettingsConfigDict(env_prefix="STUDIO_")
34
+
35
+ database_url: str = "" # default resolved to sqlite in data_dir (see resolved_database_url)
36
+ secret_key: str = "" # default resolved from persisted key file
37
+
38
+ access_token_ttl_seconds: int = 900
39
+ refresh_token_ttl_days: int = 7
40
+
41
+ artifact_local_path: str = "" # default resolved to data_dir/artifacts
42
+
43
+ host: str = "127.0.0.1"
44
+ port: int = 8080
45
+ upload_max_mb: int = 200
46
+ cors_origins: list[str] = []
47
+
48
+ job_workers: int = 2
49
+ jobs_sync: bool = False # inline job execution (tests, first-run seed)
50
+
51
+ environment: str = "dev"
52
+
53
+ @property
54
+ def resolved_database_url(self) -> str:
55
+ if self.database_url:
56
+ return self.database_url
57
+ return f"sqlite:///{data_dir() / 'studio.db'}"
58
+
59
+ @property
60
+ def resolved_secret_key(self) -> str:
61
+ return self.secret_key or _default_secret_key()
62
+
63
+ @property
64
+ def resolved_artifact_path(self) -> str:
65
+ return self.artifact_local_path or str(data_dir() / "artifacts")
66
+
67
+
68
+ @lru_cache
69
+ def get_settings() -> Settings:
70
+ return Settings()
@@ -0,0 +1,57 @@
1
+ from fastapi import Request
2
+ from sqlalchemy import create_engine
3
+ from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
4
+
5
+ from flowmine_studio.api.config import get_settings
6
+
7
+
8
+ class Base(DeclarativeBase):
9
+ pass
10
+
11
+
12
+ def _engine(): # type: ignore[no-untyped-def]
13
+ url = get_settings().resolved_database_url
14
+ if url.startswith("sqlite"):
15
+ engine = create_engine(url, connect_args={"check_same_thread": False})
16
+ from sqlalchemy import event
17
+
18
+ @event.listens_for(engine, "connect")
19
+ def _sqlite_pragmas(dbapi_conn, _record): # type: ignore[no-untyped-def]
20
+ cursor = dbapi_conn.cursor()
21
+ cursor.execute("PRAGMA foreign_keys=ON")
22
+ cursor.execute("PRAGMA journal_mode=WAL")
23
+ cursor.close()
24
+
25
+ return engine
26
+ return create_engine(url, pool_pre_ping=True)
27
+
28
+
29
+ _engine_instance = None
30
+ _session_factory: sessionmaker[Session] | None = None
31
+
32
+
33
+ def get_session_factory() -> sessionmaker[Session]:
34
+ global _engine_instance, _session_factory
35
+ if _session_factory is None:
36
+ _engine_instance = _engine()
37
+ _session_factory = sessionmaker(bind=_engine_instance, expire_on_commit=False)
38
+ return _session_factory
39
+
40
+
41
+ def get_db(request: Request) -> Session:
42
+ """Request-scoped session. Committed/rolled back and closed by the db middleware
43
+ in api.main BEFORE the response is sent — never in dependency teardown, which
44
+ FastAPI runs after the response and which therefore races follow-up requests."""
45
+ db = getattr(request.state, "db", None)
46
+ if db is None:
47
+ db = get_session_factory()()
48
+ request.state.db = db
49
+ return db # type: ignore[no-any-return]
50
+
51
+
52
+ def init_db() -> None:
53
+ """Create all tables (idempotent). CE bootstraps the schema at startup."""
54
+ from flowmine_studio.api import models # noqa: F401 — register tables
55
+
56
+ factory = get_session_factory()
57
+ Base.metadata.create_all(factory.kw["bind"])
@@ -0,0 +1,36 @@
1
+ """Dialect-agnostic column types: identical behavior on SQLite and PostgreSQL."""
2
+ from datetime import UTC, datetime
3
+ from typing import Any
4
+
5
+ import sqlalchemy as sa
6
+ from sqlalchemy.types import TypeDecorator
7
+
8
+
9
+ class TZDateTime(TypeDecorator):
10
+ """Stores UTC-naive, returns UTC-aware — safe on SQLite, correct on Postgres."""
11
+
12
+ impl = sa.DateTime
13
+ cache_ok = True
14
+
15
+ def process_bind_param(self, value: datetime | None, dialect: Any) -> datetime | None:
16
+ if value is None:
17
+ return None
18
+ if value.tzinfo is not None:
19
+ value = value.astimezone(UTC).replace(tzinfo=None)
20
+ return value
21
+
22
+ def process_result_value(self, value: datetime | None, dialect: Any) -> datetime | None:
23
+ if value is None:
24
+ return None
25
+ return value.replace(tzinfo=UTC)
26
+
27
+
28
+ def StrEnumType(py_enum: type, name: str) -> sa.Enum: # noqa: N802 — type factory
29
+ """Enum stored as VARCHAR + CHECK constraint: portable across SQLite/Postgres."""
30
+ return sa.Enum(
31
+ py_enum,
32
+ name=name,
33
+ native_enum=False,
34
+ length=32,
35
+ values_callable=lambda e: [m.value for m in e],
36
+ )
@@ -0,0 +1,65 @@
1
+ """FastAPI dependencies: current user, workspace resolution, RBAC guards."""
2
+ from collections.abc import Callable
3
+ from dataclasses import dataclass
4
+ from uuid import UUID
5
+
6
+ from fastapi import Depends, Header, Request
7
+ from sqlalchemy import select
8
+ from sqlalchemy.orm import Session
9
+
10
+ from flowmine_studio.api.db import get_db
11
+ from flowmine_studio.api.errors import ForbiddenError, UnauthorizedError
12
+ from flowmine_studio.api.models import Membership, User, Workspace
13
+ from flowmine_studio.api.security import decode_access_token
14
+ from flowmine_studio.shared.enums import ROLE_ORDER, Role
15
+
16
+
17
+ def get_current_user(request: Request, db: Session = Depends(get_db)) -> User:
18
+ auth = request.headers.get("Authorization", "")
19
+ if not auth.startswith("Bearer "):
20
+ raise UnauthorizedError("Missing bearer token")
21
+ user_id = decode_access_token(auth.removeprefix("Bearer ").strip())
22
+ user = db.get(User, user_id)
23
+ if user is None or not user.is_active:
24
+ raise UnauthorizedError("Unknown or disabled user")
25
+ return user
26
+
27
+
28
+ @dataclass
29
+ class WorkspaceContext:
30
+ user: User
31
+ workspace: Workspace
32
+ role: Role
33
+
34
+
35
+ def get_workspace_context(
36
+ x_workspace: str = Header(..., description="Workspace UUID"),
37
+ user: User = Depends(get_current_user),
38
+ db: Session = Depends(get_db),
39
+ ) -> WorkspaceContext:
40
+ try:
41
+ ws_id = UUID(x_workspace)
42
+ except ValueError as exc:
43
+ raise ForbiddenError("X-Workspace must be a UUID") from exc
44
+ ws = db.get(Workspace, ws_id)
45
+ if ws is None:
46
+ raise ForbiddenError("Workspace not found or no access")
47
+ if user.is_superuser:
48
+ return WorkspaceContext(user=user, workspace=ws, role=Role.admin)
49
+ membership = db.scalar(
50
+ select(Membership).where(
51
+ Membership.user_id == user.id, Membership.workspace_id == ws_id
52
+ )
53
+ )
54
+ if membership is None:
55
+ raise ForbiddenError("Workspace not found or no access")
56
+ return WorkspaceContext(user=user, workspace=ws, role=membership.role)
57
+
58
+
59
+ def require_role(minimum: Role) -> Callable[..., WorkspaceContext]:
60
+ def guard(ctx: WorkspaceContext = Depends(get_workspace_context)) -> WorkspaceContext:
61
+ if ROLE_ORDER[ctx.role] < ROLE_ORDER[minimum]:
62
+ raise ForbiddenError(f"Requires role '{minimum.value}' or higher")
63
+ return ctx
64
+
65
+ return guard
@@ -0,0 +1,53 @@
1
+ """Typed error hierarchy mapped to the openapi error envelope by api.main handlers."""
2
+ from typing import Any
3
+
4
+
5
+ class StudioError(Exception):
6
+ code = "internal_error"
7
+ status = 500
8
+
9
+ def __init__(self, message: str, detail: Any = None):
10
+ super().__init__(message)
11
+ self.message = message
12
+ self.detail = detail
13
+
14
+ def envelope(self) -> dict[str, Any]:
15
+ body: dict[str, Any] = {"code": self.code, "message": self.message}
16
+ if self.detail is not None:
17
+ body["detail"] = self.detail
18
+ return {"error": body}
19
+
20
+
21
+ class NotFoundError(StudioError):
22
+ code = "not_found"
23
+ status = 404
24
+
25
+
26
+ class ForbiddenError(StudioError):
27
+ code = "forbidden"
28
+ status = 403
29
+
30
+
31
+ class UnauthorizedError(StudioError):
32
+ code = "unauthorized"
33
+ status = 401
34
+
35
+
36
+ class ValidationFailed(StudioError):
37
+ code = "validation_error"
38
+ status = 422
39
+
40
+
41
+ class ConflictError(StudioError):
42
+ code = "conflict"
43
+ status = 409
44
+
45
+
46
+ class NotImplementedFeature(StudioError):
47
+ code = "not_implemented"
48
+ status = 501
49
+
50
+
51
+ class ConnectorFailure(StudioError):
52
+ code = "connector_error"
53
+ status = 502
@@ -0,0 +1,25 @@
1
+ """structlog JSON logging for api and worker processes."""
2
+ import logging
3
+ import sys
4
+
5
+ import structlog
6
+
7
+
8
+ def setup_logging(level: int = logging.INFO) -> None:
9
+ logging.basicConfig(stream=sys.stdout, level=level, format="%(message)s")
10
+ structlog.configure(
11
+ processors=[
12
+ structlog.contextvars.merge_contextvars,
13
+ structlog.processors.add_log_level,
14
+ structlog.processors.TimeStamper(fmt="iso", utc=True),
15
+ structlog.processors.StackInfoRenderer(),
16
+ structlog.processors.format_exc_info,
17
+ structlog.processors.JSONRenderer(),
18
+ ],
19
+ wrapper_class=structlog.make_filtering_bound_logger(level),
20
+ cache_logger_on_first_use=True,
21
+ )
22
+
23
+
24
+ def get_logger(name: str) -> structlog.stdlib.BoundLogger:
25
+ return structlog.get_logger(name)
@@ -0,0 +1,134 @@
1
+ from pathlib import Path
2
+
3
+ from fastapi import FastAPI, Request
4
+ from fastapi.exceptions import RequestValidationError
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+ from fastapi.responses import FileResponse, JSONResponse
7
+ from fastapi.staticfiles import StaticFiles
8
+
9
+ from flowmine_studio.api.config import get_settings
10
+ from flowmine_studio.api.errors import StudioError
11
+ from flowmine_studio.api.logging_setup import get_logger, setup_logging
12
+ from flowmine_studio.api.routers import (
13
+ admin_users,
14
+ artifacts,
15
+ auth,
16
+ connections,
17
+ datasets,
18
+ health,
19
+ jobs,
20
+ projects,
21
+ workspaces,
22
+ )
23
+
24
+ setup_logging()
25
+ log = get_logger("api")
26
+
27
+
28
+ STATIC_DIR = Path(__file__).parent / "static"
29
+
30
+
31
+ def create_app() -> FastAPI:
32
+ from contextlib import asynccontextmanager
33
+
34
+ @asynccontextmanager
35
+ async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
36
+ from flowmine_studio.api import runner
37
+ from flowmine_studio.api.db import init_db
38
+
39
+ init_db()
40
+ yield
41
+ runner.shutdown()
42
+
43
+ app = FastAPI(
44
+ lifespan=lifespan,
45
+ title="FlowMine Studio API",
46
+ version="0.1.0",
47
+ openapi_url="/api/v1/openapi.json",
48
+ docs_url="/api/v1/docs",
49
+ )
50
+ settings = get_settings()
51
+ if settings.cors_origins:
52
+ app.add_middleware(
53
+ CORSMiddleware,
54
+ allow_origins=settings.cors_origins,
55
+ allow_methods=["*"],
56
+ allow_headers=["*"],
57
+ )
58
+
59
+ @app.middleware("http")
60
+ async def db_lifecycle(request: Request, call_next): # type: ignore[no-untyped-def]
61
+ try:
62
+ response = await call_next(request)
63
+ except Exception:
64
+ db = getattr(request.state, "db", None)
65
+ if db is not None:
66
+ db.rollback()
67
+ db.close()
68
+ raise
69
+ db = getattr(request.state, "db", None)
70
+ if db is not None:
71
+ try:
72
+ if response.status_code < 400:
73
+ db.commit()
74
+ else:
75
+ db.rollback()
76
+ finally:
77
+ db.close()
78
+ return response
79
+
80
+ prefix = "/api/v1"
81
+ for router in (
82
+ health.router,
83
+ auth.router,
84
+ workspaces.router,
85
+ connections.router,
86
+ datasets.router,
87
+ projects.router,
88
+ jobs.router,
89
+ artifacts.router,
90
+ admin_users.router,
91
+ ):
92
+ app.include_router(router, prefix=prefix)
93
+
94
+ @app.exception_handler(StudioError)
95
+ async def studio_error_handler(request: Request, exc: StudioError) -> JSONResponse:
96
+ if exc.status >= 500:
97
+ log.error("request_failed", path=request.url.path, code=exc.code, msg=exc.message)
98
+ return JSONResponse(status_code=exc.status, content=exc.envelope())
99
+
100
+ # --- packaged UI: static SPA with /api passthrough ---
101
+ if (STATIC_DIR / "index.html").exists():
102
+ app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
103
+
104
+ @app.get("/{spa_path:path}", include_in_schema=False)
105
+ async def spa(spa_path: str): # type: ignore[no-untyped-def]
106
+ if spa_path.startswith(("api/", "api")) and spa_path.split("/")[0] == "api":
107
+ return JSONResponse(
108
+ status_code=404,
109
+ content={"error": {"code": "not_found", "message": "Unknown API path"}},
110
+ )
111
+ candidate = STATIC_DIR / spa_path
112
+ if spa_path and candidate.is_file():
113
+ return FileResponse(candidate)
114
+ return FileResponse(STATIC_DIR / "index.html")
115
+
116
+ @app.exception_handler(RequestValidationError)
117
+ async def validation_handler(
118
+ request: Request, exc: RequestValidationError
119
+ ) -> JSONResponse:
120
+ return JSONResponse(
121
+ status_code=422,
122
+ content={
123
+ "error": {
124
+ "code": "validation_error",
125
+ "message": "Request validation failed",
126
+ "detail": exc.errors(),
127
+ }
128
+ },
129
+ )
130
+
131
+ return app
132
+
133
+
134
+ app = create_app()