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.
- roborean_api_fastapi-0.1.2/.gitignore +27 -0
- roborean_api_fastapi-0.1.2/CHANGELOG.md +19 -0
- roborean_api_fastapi-0.1.2/PKG-INFO +21 -0
- roborean_api_fastapi-0.1.2/pyproject.toml +33 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/__init__.py +7 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/app.py +48 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/deps.py +122 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/errors.py +81 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/openapi/customize.py +11 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/redaction.py +101 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/routers/__init__.py +5 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/routers/artifacts.py +45 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/routers/compile.py +32 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/routers/health.py +12 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/routers/previews.py +29 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/routers/projects.py +76 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/routers/runs.py +61 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/schemas/__init__.py +33 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/schemas/common.py +34 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/schemas/previews.py +25 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/schemas/projects.py +34 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/schemas/runs.py +55 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/security.py +30 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/services/__init__.py +25 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/services/compile_service.py +32 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/services/preview_service.py +71 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/services/project_service.py +67 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/services/run_service.py +75 -0
- roborean_api_fastapi-0.1.2/src/roborean_api_fastapi/settings.py +24 -0
- roborean_api_fastapi-0.1.2/tests/conftest.py +50 -0
- roborean_api_fastapi-0.1.2/tests/health_test.py +12 -0
- roborean_api_fastapi-0.1.2/tests/idempotency_test.py +25 -0
- roborean_api_fastapi-0.1.2/tests/openapi_snapshot_test.py +25 -0
- roborean_api_fastapi-0.1.2/tests/projects_api_test.py +30 -0
- roborean_api_fastapi-0.1.2/tests/redaction_test.py +19 -0
- 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,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,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)
|