roborean-api-fastapi 0.1.2__tar.gz

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 (36) hide show
  1. roborean_api_fastapi-0.1.2/.gitignore +27 -0
  2. roborean_api_fastapi-0.1.2/CHANGELOG.md +19 -0
  3. roborean_api_fastapi-0.1.2/PKG-INFO +21 -0
  4. roborean_api_fastapi-0.1.2/pyproject.toml +33 -0
  5. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/__init__.py +7 -0
  6. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/app.py +48 -0
  7. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/deps.py +122 -0
  8. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/errors.py +81 -0
  9. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/openapi/customize.py +11 -0
  10. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/redaction.py +101 -0
  11. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/routers/__init__.py +5 -0
  12. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/routers/artifacts.py +45 -0
  13. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/routers/compile.py +32 -0
  14. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/routers/health.py +12 -0
  15. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/routers/previews.py +29 -0
  16. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/routers/projects.py +76 -0
  17. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/routers/runs.py +61 -0
  18. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/schemas/__init__.py +33 -0
  19. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/schemas/common.py +34 -0
  20. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/schemas/previews.py +25 -0
  21. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/schemas/projects.py +34 -0
  22. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/schemas/runs.py +55 -0
  23. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/security.py +30 -0
  24. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/services/__init__.py +25 -0
  25. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/services/compile_service.py +32 -0
  26. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/services/preview_service.py +71 -0
  27. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/services/project_service.py +67 -0
  28. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/services/run_service.py +75 -0
  29. roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/settings.py +24 -0
  30. roborean_api_fastapi-0.1.2/tests/conftest.py +50 -0
  31. roborean_api_fastapi-0.1.2/tests/health_test.py +12 -0
  32. roborean_api_fastapi-0.1.2/tests/idempotency_test.py +25 -0
  33. roborean_api_fastapi-0.1.2/tests/openapi_snapshot_test.py +25 -0
  34. roborean_api_fastapi-0.1.2/tests/projects_api_test.py +30 -0
  35. roborean_api_fastapi-0.1.2/tests/redaction_test.py +19 -0
  36. roborean_api_fastapi-0.1.2/tests/runs_api_test.py +18 -0
@@ -0,0 +1,27 @@
1
+ venv/
2
+ venv-qt5/
3
+ venv-qt6/
4
+ .venv/
5
+ __pycache__/
6
+ *.py[cod]
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .coverage
10
+ htmlcov/
11
+ dist/
12
+ build/
13
+ *.egg-info/
14
+ *.tsbuildinfo
15
+ node_modules/
16
+ .pnpm-store/
17
+ .DS_Store
18
+ .idea/
19
+ .vscode/*.local
20
+ playground/
21
+ .e2e-ai/
22
+ **/test-results/
23
+ **/playwright-report/
24
+ research/
25
+ .roborean/
26
+ .roborean-sql-artifacts/
27
+ *.db
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ### Added
6
+
7
+ - FastAPI application factory with project CRUD, compile, durable runs,
8
+ artifact download, and document preview endpoints.
9
+ - Client redaction for workspace secrets and run results.
10
+ - Optional auth stub via `X-Roborean-Principal`.
11
+
12
+ ### Changed
13
+
14
+ ### Fixed
15
+
16
+ - Lower inter-package dependency pins to `>=0.1.1` to match lockstep
17
+ release versions.
18
+ - Omit null optional fields when redacting projects for clients so
19
+ browser compile/validate matches JSON Schema string optionals.
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: roborean-api-fastapi
3
+ Version: 0.1.2
4
+ Summary: FastAPI HTTP surface for Roborean projects, runs, and previews
5
+ Project-URL: Homepage, https://github.com/TNick/roborean
6
+ Project-URL: Repository, https://github.com/TNick/roborean
7
+ License-Expression: MIT
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: fastapi>=0.115.0
10
+ Requires-Dist: pydantic-settings>=2.0.0
11
+ Requires-Dist: roborean-engine>=0.1.1
12
+ Requires-Dist: roborean-storage-base>=0.1.1
13
+ Requires-Dist: roborean-storage-dict>=0.1.1
14
+ Requires-Dist: roborean-storage-sqlalchemy>=0.1.1
15
+ Provides-Extra: dev
16
+ Requires-Dist: black>=24.0; extra == 'dev'
17
+ Requires-Dist: flake8>=7.0; extra == 'dev'
18
+ Requires-Dist: httpx>=0.27.0; extra == 'dev'
19
+ Requires-Dist: isort>=5.0; extra == 'dev'
20
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
21
+ Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "roborean-api-fastapi"
7
+ version = "0.1.2"
8
+ description = "FastAPI HTTP surface for Roborean projects, runs, and previews"
9
+ requires-python = ">=3.11"
10
+ license = "MIT"
11
+ dependencies = [
12
+ "fastapi>=0.115.0",
13
+ "pydantic-settings>=2.0.0",
14
+ "roborean-engine>=0.1.1",
15
+ "roborean-storage-base>=0.1.1",
16
+ "roborean-storage-dict>=0.1.1",
17
+ "roborean-storage-sqlalchemy>=0.1.1",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.24.0", "httpx>=0.27.0", "black>=24.0", "isort>=5.0", "flake8>=7.0"]
22
+
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/TNick/roborean"
26
+ Repository = "https://github.com/TNick/roborean"
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
30
+ asyncio_mode = "auto"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/roborean_api_fastapi"]
@@ -0,0 +1,7 @@
1
+ """Roborean FastAPI HTTP API."""
2
+
3
+ from .app import create_app
4
+
5
+ __version__ = "0.4.0"
6
+
7
+ __all__ = ["create_app", "__version__"]
@@ -0,0 +1,48 @@
1
+ """FastAPI application factory."""
2
+
3
+ from fastapi import FastAPI
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ from fastapi.openapi.utils import get_openapi
6
+
7
+ from .deps import attach_app_state
8
+ from .errors import install_exception_handlers
9
+ from .openapi.customize import customize_openapi
10
+ from .routers import artifacts, compile, health, previews, projects, runs
11
+ from .settings import Settings
12
+
13
+
14
+ def create_app(settings: Settings | None = None) -> FastAPI:
15
+ """Build the Roborean HTTP API."""
16
+ app = FastAPI(title="Roborean API", version="0.4.0")
17
+ resolved = attach_app_state(app, settings)
18
+
19
+ app.add_middleware(
20
+ CORSMiddleware,
21
+ allow_origins=resolved.cors_origins,
22
+ allow_credentials=True,
23
+ allow_methods=["*"],
24
+ allow_headers=["*"],
25
+ )
26
+ install_exception_handlers(app)
27
+
28
+ app.include_router(health.router)
29
+ app.include_router(projects.router)
30
+ app.include_router(compile.router)
31
+ app.include_router(runs.router)
32
+ app.include_router(artifacts.router)
33
+ app.include_router(previews.router)
34
+
35
+ def custom_openapi() -> dict:
36
+ if app.openapi_schema:
37
+ return app.openapi_schema
38
+ schema = get_openapi(
39
+ title=app.title,
40
+ version=app.version,
41
+ routes=app.routes,
42
+ )
43
+ app.openapi_schema = customize_openapi(schema)
44
+ return app.openapi_schema
45
+
46
+ app.openapi = custom_openapi
47
+ _ = resolved
48
+ return app
@@ -0,0 +1,122 @@
1
+ """FastAPI dependency wiring."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from fastapi import Depends, Request
7
+ from roborean_engine import RunService
8
+ from roborean_engine.bits.registry import BitTypeRegistry, builtin_registry
9
+ from roborean_engine.documents import default_driver_registry
10
+ from roborean_storage_base import (
11
+ ArtifactStore,
12
+ ProjectRepository,
13
+ RunRepository,
14
+ )
15
+ from roborean_storage_dict import (
16
+ DictArtifactStore,
17
+ DictProjectRepository,
18
+ DictRunRepository,
19
+ )
20
+
21
+ from .security import Principal, resolve_principal
22
+ from .settings import Settings, load_settings
23
+
24
+
25
+ @dataclass
26
+ class AppState:
27
+ """Shared repositories and services."""
28
+
29
+ settings: Settings
30
+ projects: ProjectRepository
31
+ runs: RunRepository
32
+ artifacts: ArtifactStore
33
+ run_service: RunService
34
+
35
+
36
+ def build_app_state(settings: Settings) -> AppState:
37
+ """Construct repositories from settings."""
38
+ if settings.storage_backend == "dict":
39
+ root = settings.store_path.expanduser().resolve()
40
+ root.mkdir(parents=True, exist_ok=True)
41
+ projects = DictProjectRepository(root)
42
+ runs = DictRunRepository(root)
43
+ artifacts = DictArtifactStore(settings.artifact_root.resolve())
44
+ else:
45
+ from roborean_storage_sqlalchemy import (
46
+ SqlAlchemyProjectRepository,
47
+ SqlAlchemyRunRepository,
48
+ make_engine,
49
+ make_session_factory,
50
+ upgrade,
51
+ )
52
+
53
+ if not settings.database_url:
54
+ raise ValueError("database_url required for sqlalchemy backend")
55
+ engine = make_engine(settings.database_url)
56
+ upgrade(engine)
57
+ factory = make_session_factory(engine)
58
+ projects = SqlAlchemyProjectRepository(factory)
59
+ runs = SqlAlchemyRunRepository(factory)
60
+ artifacts = DictArtifactStore(settings.artifact_root.resolve())
61
+
62
+ def package_dir(project_id: str) -> Path | None:
63
+ if isinstance(projects, DictProjectRepository):
64
+ path = projects._project_dir(project_id)
65
+ return path if path.is_dir() else None
66
+ return None
67
+
68
+ run_service = RunService(
69
+ projects=projects,
70
+ runs=runs,
71
+ artifacts=artifacts,
72
+ registry=builtin_registry(),
73
+ package_dir_for_project=package_dir,
74
+ )
75
+ return AppState(
76
+ settings=settings,
77
+ projects=projects,
78
+ runs=runs,
79
+ artifacts=artifacts,
80
+ run_service=run_service,
81
+ )
82
+
83
+
84
+ def get_settings(request: Request) -> Settings:
85
+ """Settings attached to the app instance."""
86
+ return request.app.state.roborean_settings
87
+
88
+
89
+ def get_state(request: Request) -> AppState:
90
+ """Repositories attached to the app instance."""
91
+ return request.app.state.roborean_app_state
92
+
93
+
94
+ def get_principal(
95
+ request: Request,
96
+ settings: Settings = Depends(get_settings),
97
+ ) -> Principal:
98
+ """Resolve caller identity."""
99
+ return resolve_principal(request, settings)
100
+
101
+
102
+ def get_bit_registry() -> BitTypeRegistry:
103
+ """Built-in bit registry."""
104
+ return builtin_registry()
105
+
106
+
107
+ def get_document_registry():
108
+ """Installed document drivers."""
109
+ return default_driver_registry()
110
+
111
+
112
+ def attach_app_state(app, settings: Settings | None = None) -> Settings:
113
+ """Bind settings and repositories to a FastAPI app."""
114
+ resolved = settings or load_settings()
115
+ app.state.roborean_settings = resolved
116
+ app.state.roborean_app_state = build_app_state(resolved)
117
+ return resolved
118
+
119
+
120
+ def reset_state() -> None:
121
+ """No-op placeholder for tests (state lives on app)."""
122
+ return None
@@ -0,0 +1,81 @@
1
+ """HTTP error mapping."""
2
+
3
+ from fastapi import FastAPI, Request
4
+ from fastapi.responses import JSONResponse
5
+ from roborean_engine.compiler import CompileError
6
+ from roborean_storage_base import ConflictError, NotFoundError
7
+
8
+ from .schemas.common import DiagnosticDto, ErrorBody
9
+
10
+
11
+ class ApiError(Exception):
12
+ """Structured API failure."""
13
+
14
+ def __init__(
15
+ self,
16
+ *,
17
+ status_code: int,
18
+ code: str,
19
+ message: str,
20
+ diagnostics: list[DiagnosticDto] | None = None,
21
+ ) -> None:
22
+ """Store HTTP metadata for handlers."""
23
+ self.status_code = status_code
24
+ self.code = code
25
+ self.message = message
26
+ self.diagnostics = diagnostics or []
27
+ super().__init__(message)
28
+
29
+
30
+ def install_exception_handlers(app: FastAPI) -> None:
31
+ """Register domain → HTTP mappings."""
32
+
33
+ @app.exception_handler(ApiError)
34
+ async def _api_error(_request: Request, error: ApiError) -> JSONResponse:
35
+ body = ErrorBody(
36
+ code=error.code,
37
+ message=error.message,
38
+ diagnostics=error.diagnostics,
39
+ )
40
+ return JSONResponse(
41
+ status_code=error.status_code,
42
+ content=body.model_dump(mode="json", by_alias=True),
43
+ )
44
+
45
+ @app.exception_handler(NotFoundError)
46
+ async def _not_found(
47
+ _request: Request, error: NotFoundError
48
+ ) -> JSONResponse:
49
+ body = ErrorBody(code="E_NOT_FOUND", message=str(error))
50
+ return JSONResponse(
51
+ status_code=404, content=body.model_dump(by_alias=True)
52
+ )
53
+
54
+ @app.exception_handler(ConflictError)
55
+ async def _conflict(
56
+ _request: Request, error: ConflictError
57
+ ) -> JSONResponse:
58
+ body = ErrorBody(code="E_CONFLICT", message=str(error))
59
+ return JSONResponse(
60
+ status_code=409, content=body.model_dump(by_alias=True)
61
+ )
62
+
63
+ @app.exception_handler(CompileError)
64
+ async def _compile(_request: Request, error: CompileError) -> JSONResponse:
65
+ diagnostics = [
66
+ DiagnosticDto(
67
+ severity=item.severity,
68
+ code=item.code,
69
+ message=item.message,
70
+ path=item.path,
71
+ )
72
+ for item in error.diagnostics
73
+ ]
74
+ body = ErrorBody(
75
+ code="E_COMPILE",
76
+ message=str(error),
77
+ diagnostics=diagnostics,
78
+ )
79
+ return JSONResponse(
80
+ status_code=400, content=body.model_dump(by_alias=True)
81
+ )
@@ -0,0 +1,11 @@
1
+ """OpenAPI post-processing."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ def customize_openapi(schema: dict[str, Any]) -> dict[str, Any]:
7
+ """Apply stable metadata for generated clients."""
8
+ info = schema.setdefault("info", {})
9
+ info["title"] = info.get("title") or "Roborean API"
10
+ info["version"] = info.get("version") or "0.4.0"
11
+ return schema
@@ -0,0 +1,101 @@
1
+ """Redact workspace values for client responses."""
2
+
3
+ from typing import Any
4
+
5
+ from roborean_spec import (
6
+ Exposure,
7
+ Project,
8
+ PublicLiteral,
9
+ Redacted,
10
+ RunResults,
11
+ SecretRefValue,
12
+ Variable,
13
+ WorkspaceValue,
14
+ )
15
+
16
+
17
+ def redact_workspace_value(
18
+ value: WorkspaceValue,
19
+ *,
20
+ exposure: Exposure | None = None,
21
+ ) -> WorkspaceValue:
22
+ """Replace secret-bearing values according to exposure."""
23
+ if exposure in (Exposure.BACKEND_ONLY, Exposure.REDACTED_TO_CLIENT):
24
+ return Redacted(kind="redacted", reason="secret")
25
+ if isinstance(value, SecretRefValue):
26
+ return Redacted(kind="redacted", reason="secret")
27
+ if isinstance(value, PublicLiteral):
28
+ return value
29
+ return Redacted(kind="redacted", reason="policy")
30
+
31
+
32
+ def redact_project_for_client(project: Project) -> dict[str, Any]:
33
+ """Return a JSON-serializable project safe for browsers."""
34
+ if isinstance(project, dict):
35
+ project = Project.model_validate(project)
36
+
37
+ # Omit null optional fields so Draft 2020-12 schemas stay valid.
38
+ data = project.model_dump(mode="json", by_alias=True, exclude_none=True)
39
+ variables = []
40
+ for variable in project.workspace.get("variables", []):
41
+ if isinstance(variable, dict):
42
+ variable = Variable.model_validate(variable)
43
+ item = variable.model_dump(
44
+ mode="json", by_alias=True, exclude_none=True
45
+ )
46
+ item["defaultValue"] = redact_workspace_value(
47
+ variable.default_value,
48
+ exposure=variable.exposure,
49
+ ).model_dump(mode="json", by_alias=True, exclude_none=True)
50
+ variables.append(item)
51
+ data["workspace"]["variables"] = variables
52
+ return data
53
+
54
+
55
+ def _redact_patch_ops(payload: dict[str, Any]) -> dict[str, Any]:
56
+ """Redact workspace patch operations in run results."""
57
+ bit_results = []
58
+ for item in payload.get("bitResults", []):
59
+ patch = item.get("workspacePatch") or {}
60
+ ops = []
61
+ for op in patch.get("ops", []):
62
+ if op.get("op") == "set" and "value" in op:
63
+ value = op["value"]
64
+ if (
65
+ isinstance(value, dict)
66
+ and value.get("kind") == "secret_ref"
67
+ ):
68
+ op = {
69
+ **op,
70
+ "value": {"kind": "redacted", "reason": "secret"},
71
+ }
72
+ ops.append(op)
73
+ item = {**item, "workspacePatch": {**patch, "ops": ops}}
74
+ bit_results.append(item)
75
+ payload["bitResults"] = bit_results
76
+ return payload
77
+
78
+
79
+ def redact_run_results_for_client(results: RunResults) -> dict[str, Any]:
80
+ """Return run results without resolved secret literals."""
81
+ payload = results.model_dump(mode="json", by_alias=True, exclude_none=True)
82
+ return _redact_patch_ops(payload)
83
+
84
+
85
+ def assert_no_backend_only_literals(payload: dict[str, Any]) -> None:
86
+ """Fail tests when raw secret literals leak to clients."""
87
+ stack = [payload]
88
+ while stack:
89
+ current = stack.pop()
90
+ if isinstance(current, dict):
91
+ kind = current.get("kind")
92
+ if kind == "public_literal" and current.get("value") not in (
93
+ None,
94
+ "",
95
+ ):
96
+ exposure = current.get("exposure")
97
+ if exposure == "backendOnly":
98
+ raise AssertionError("backendOnly literal leaked")
99
+ stack.extend(current.values())
100
+ elif isinstance(current, list):
101
+ stack.extend(current)
@@ -0,0 +1,5 @@
1
+ """Router exports."""
2
+
3
+ from . import artifacts, compile, health, previews, projects, runs
4
+
5
+ __all__ = ["artifacts", "compile", "health", "previews", "projects", "runs"]
@@ -0,0 +1,45 @@
1
+ """Artifact download."""
2
+
3
+ from fastapi import APIRouter, Depends, Response
4
+ from roborean_storage_base import NotFoundError
5
+
6
+ from ..deps import AppState, get_principal, get_state
7
+ from ..errors import ApiError
8
+ from ..security import Principal
9
+
10
+ router = APIRouter(prefix="/v1/runs", tags=["artifacts"])
11
+
12
+
13
+ @router.get("/{run_id}/artifacts/{artifact_id}")
14
+ def download_artifact(
15
+ run_id: str,
16
+ artifact_id: str,
17
+ state: AppState = Depends(get_state),
18
+ _principal: Principal = Depends(get_principal),
19
+ ) -> Response:
20
+ """Stream one stored artifact (document id)."""
21
+ record = state.run_service.get(run_id)
22
+ media_type = "application/octet-stream"
23
+ for item in record.results.artifacts if record.results else []:
24
+ doc_id = (
25
+ item.get("documentId")
26
+ if isinstance(item, dict)
27
+ else item.document_id
28
+ )
29
+ if doc_id == artifact_id:
30
+ media_type = (
31
+ item.get("mediaType")
32
+ if isinstance(item, dict)
33
+ else item.media_type
34
+ )
35
+ break
36
+ key = f"{run_id}/{artifact_id}"
37
+ try:
38
+ payload = state.artifacts.get_bytes(key)
39
+ except (NotFoundError, FileNotFoundError, KeyError) as error:
40
+ raise ApiError(
41
+ status_code=404,
42
+ code="E_NOT_FOUND",
43
+ message=f"artifact not found: {artifact_id}",
44
+ ) from error
45
+ return Response(content=payload, media_type=media_type)
@@ -0,0 +1,32 @@
1
+ """Compile route."""
2
+
3
+ from fastapi import APIRouter, Depends
4
+
5
+ from ..deps import AppState, get_bit_registry, get_principal, get_state
6
+ from ..schemas.runs import CompileRequest, CompileResponse
7
+ from ..security import Principal
8
+ from ..services import compile_service
9
+
10
+ router = APIRouter(prefix="/v1/projects", tags=["compile"])
11
+
12
+
13
+ @router.post("/{project_id}/compile", response_model=CompileResponse)
14
+ def compile_project(
15
+ project_id: str,
16
+ body: CompileRequest | None = None,
17
+ state: AppState = Depends(get_state),
18
+ registry=Depends(get_bit_registry),
19
+ _principal: Principal = Depends(get_principal),
20
+ ) -> CompileResponse:
21
+ """Compile a stored project."""
22
+ request = body or CompileRequest()
23
+ package_dir = None
24
+ if state.run_service.package_dir_for_project:
25
+ package_dir = state.run_service.package_dir_for_project(project_id)
26
+ return compile_service.compile_stored_project(
27
+ state.projects,
28
+ project_id,
29
+ body=request,
30
+ registry=registry,
31
+ package_dir=package_dir,
32
+ )
@@ -0,0 +1,12 @@
1
+ """Health probe."""
2
+
3
+ from fastapi import APIRouter
4
+ from roborean_engine.version import ENGINE_VERSION
5
+
6
+ router = APIRouter(tags=["health"])
7
+
8
+
9
+ @router.get("/health")
10
+ def health() -> dict[str, str]:
11
+ """Return liveness and engine version."""
12
+ return {"status": "ok", "engineVersion": ENGINE_VERSION}
@@ -0,0 +1,29 @@
1
+ """Preview route."""
2
+
3
+ from fastapi import APIRouter, Depends
4
+
5
+ from ..deps import AppState, get_principal, get_state
6
+ from ..schemas.previews import PreviewRequest, PreviewResponse
7
+ from ..security import Principal
8
+ from ..services import preview_service
9
+
10
+ router = APIRouter(prefix="/v1/projects", tags=["previews"])
11
+
12
+
13
+ @router.post("/{project_id}/preview", response_model=PreviewResponse)
14
+ def preview_document(
15
+ project_id: str,
16
+ body: PreviewRequest,
17
+ state: AppState = Depends(get_state),
18
+ _principal: Principal = Depends(get_principal),
19
+ ) -> PreviewResponse:
20
+ """Return a browser-safe document preview."""
21
+ package_dir = None
22
+ if state.run_service.package_dir_for_project:
23
+ package_dir = state.run_service.package_dir_for_project(project_id)
24
+ return preview_service.build_preview(
25
+ state.projects,
26
+ project_id,
27
+ body,
28
+ package_dir=package_dir,
29
+ )
@@ -0,0 +1,76 @@
1
+ """Project CRUD routes."""
2
+
3
+ from fastapi import APIRouter, Depends, Response, status
4
+
5
+ from ..deps import AppState, get_principal, get_state
6
+ from ..errors import ApiError
7
+ from ..schemas.projects import (
8
+ ProjectCreate,
9
+ ProjectDetail,
10
+ ProjectSummary,
11
+ ProjectUpdate,
12
+ )
13
+ from ..security import Principal
14
+ from ..services import project_service
15
+
16
+ router = APIRouter(prefix="/v1/projects", tags=["projects"])
17
+
18
+
19
+ @router.get("", response_model=list[ProjectSummary])
20
+ def list_projects(
21
+ state: AppState = Depends(get_state),
22
+ _principal: Principal = Depends(get_principal),
23
+ ) -> list[ProjectSummary]:
24
+ """List project summaries."""
25
+ return project_service.list_projects(state.projects)
26
+
27
+
28
+ @router.post(
29
+ "", response_model=ProjectDetail, status_code=status.HTTP_201_CREATED
30
+ )
31
+ def create_project(
32
+ body: ProjectCreate,
33
+ state: AppState = Depends(get_state),
34
+ _principal: Principal = Depends(get_principal),
35
+ ) -> ProjectDetail:
36
+ """Create a stored project."""
37
+ return project_service.create_project(state.projects, body)
38
+
39
+
40
+ @router.get("/{project_id}", response_model=ProjectDetail)
41
+ def get_project(
42
+ project_id: str,
43
+ state: AppState = Depends(get_state),
44
+ _principal: Principal = Depends(get_principal),
45
+ ) -> ProjectDetail:
46
+ """Fetch one project."""
47
+ return project_service.get_project(state.projects, project_id)
48
+
49
+
50
+ @router.put("/{project_id}", response_model=ProjectDetail)
51
+ def update_project(
52
+ project_id: str,
53
+ body: ProjectUpdate,
54
+ state: AppState = Depends(get_state),
55
+ _principal: Principal = Depends(get_principal),
56
+ ) -> ProjectDetail:
57
+ """Replace a project."""
58
+ try:
59
+ return project_service.update_project(state.projects, project_id, body)
60
+ except ValueError as error:
61
+ raise ApiError(
62
+ status_code=400,
63
+ code="E_SCHEMA",
64
+ message=str(error),
65
+ ) from error
66
+
67
+
68
+ @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
69
+ def delete_project(
70
+ project_id: str,
71
+ state: AppState = Depends(get_state),
72
+ _principal: Principal = Depends(get_principal),
73
+ ) -> Response:
74
+ """Delete a project."""
75
+ project_service.delete_project(state.projects, project_id)
76
+ return Response(status_code=status.HTTP_204_NO_CONTENT)