roborean-api-fastapi 0.1.2__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.
- roborean_api_fastapi/__init__.py +7 -0
- roborean_api_fastapi/app.py +48 -0
- roborean_api_fastapi/deps.py +122 -0
- roborean_api_fastapi/errors.py +81 -0
- roborean_api_fastapi/openapi/customize.py +11 -0
- roborean_api_fastapi/redaction.py +101 -0
- roborean_api_fastapi/routers/__init__.py +5 -0
- roborean_api_fastapi/routers/artifacts.py +45 -0
- roborean_api_fastapi/routers/compile.py +32 -0
- roborean_api_fastapi/routers/health.py +12 -0
- roborean_api_fastapi/routers/previews.py +29 -0
- roborean_api_fastapi/routers/projects.py +76 -0
- roborean_api_fastapi/routers/runs.py +61 -0
- roborean_api_fastapi/schemas/__init__.py +33 -0
- roborean_api_fastapi/schemas/common.py +34 -0
- roborean_api_fastapi/schemas/previews.py +25 -0
- roborean_api_fastapi/schemas/projects.py +34 -0
- roborean_api_fastapi/schemas/runs.py +55 -0
- roborean_api_fastapi/security.py +30 -0
- roborean_api_fastapi/services/__init__.py +25 -0
- roborean_api_fastapi/services/compile_service.py +32 -0
- roborean_api_fastapi/services/preview_service.py +71 -0
- roborean_api_fastapi/services/project_service.py +67 -0
- roborean_api_fastapi/services/run_service.py +75 -0
- roborean_api_fastapi/settings.py +24 -0
- roborean_api_fastapi-0.1.2.dist-info/METADATA +21 -0
- roborean_api_fastapi-0.1.2.dist-info/RECORD +28 -0
- roborean_api_fastapi-0.1.2.dist-info/WHEEL +4 -0
|
@@ -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)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Run routes."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, Header
|
|
4
|
+
|
|
5
|
+
from ..deps import AppState, get_principal, get_state
|
|
6
|
+
from ..errors import ApiError
|
|
7
|
+
from ..schemas.runs import RunCreate, RunDetail, RunSummary
|
|
8
|
+
from ..security import Principal
|
|
9
|
+
from ..services import run_service
|
|
10
|
+
|
|
11
|
+
router = APIRouter(tags=["runs"])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@router.post(
|
|
15
|
+
"/v1/projects/{project_id}/runs",
|
|
16
|
+
response_model=RunDetail,
|
|
17
|
+
status_code=201,
|
|
18
|
+
)
|
|
19
|
+
def create_run(
|
|
20
|
+
project_id: str,
|
|
21
|
+
body: RunCreate,
|
|
22
|
+
state: AppState = Depends(get_state),
|
|
23
|
+
_principal: Principal = Depends(get_principal),
|
|
24
|
+
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
|
25
|
+
) -> RunDetail:
|
|
26
|
+
"""Execute a durable run."""
|
|
27
|
+
if not idempotency_key:
|
|
28
|
+
raise ApiError(
|
|
29
|
+
status_code=400,
|
|
30
|
+
code="E_IDEMPOTENCY",
|
|
31
|
+
message="Idempotency-Key header is required",
|
|
32
|
+
)
|
|
33
|
+
return run_service.create_run(
|
|
34
|
+
state.run_service,
|
|
35
|
+
project_id,
|
|
36
|
+
body,
|
|
37
|
+
idempotency_key=idempotency_key,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@router.get(
|
|
42
|
+
"/v1/projects/{project_id}/runs",
|
|
43
|
+
response_model=list[RunSummary],
|
|
44
|
+
)
|
|
45
|
+
def list_runs(
|
|
46
|
+
project_id: str,
|
|
47
|
+
state: AppState = Depends(get_state),
|
|
48
|
+
_principal: Principal = Depends(get_principal),
|
|
49
|
+
) -> list[RunSummary]:
|
|
50
|
+
"""List runs for a project."""
|
|
51
|
+
return run_service.list_runs(state.run_service, project_id)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@router.get("/v1/runs/{run_id}", response_model=RunDetail)
|
|
55
|
+
def get_run(
|
|
56
|
+
run_id: str,
|
|
57
|
+
state: AppState = Depends(get_state),
|
|
58
|
+
_principal: Principal = Depends(get_principal),
|
|
59
|
+
) -> RunDetail:
|
|
60
|
+
"""Fetch one run."""
|
|
61
|
+
return run_service.get_run(state.run_service, run_id)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Schema package exports."""
|
|
2
|
+
|
|
3
|
+
from .common import DiagnosticDto, ErrorBody
|
|
4
|
+
from .previews import PreviewRequest, PreviewResponse
|
|
5
|
+
from .projects import (
|
|
6
|
+
ProjectCreate,
|
|
7
|
+
ProjectDetail,
|
|
8
|
+
ProjectSummary,
|
|
9
|
+
ProjectUpdate,
|
|
10
|
+
)
|
|
11
|
+
from .runs import (
|
|
12
|
+
CompileRequest,
|
|
13
|
+
CompileResponse,
|
|
14
|
+
RunCreate,
|
|
15
|
+
RunDetail,
|
|
16
|
+
RunSummary,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"CompileRequest",
|
|
21
|
+
"CompileResponse",
|
|
22
|
+
"DiagnosticDto",
|
|
23
|
+
"ErrorBody",
|
|
24
|
+
"PreviewRequest",
|
|
25
|
+
"PreviewResponse",
|
|
26
|
+
"ProjectCreate",
|
|
27
|
+
"ProjectDetail",
|
|
28
|
+
"ProjectSummary",
|
|
29
|
+
"ProjectUpdate",
|
|
30
|
+
"RunCreate",
|
|
31
|
+
"RunDetail",
|
|
32
|
+
"RunSummary",
|
|
33
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Shared API DTOs."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ApiModel(BaseModel):
|
|
9
|
+
"""Base model with alias support."""
|
|
10
|
+
|
|
11
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DiagnosticDto(ApiModel):
|
|
15
|
+
"""One compile or validation diagnostic."""
|
|
16
|
+
|
|
17
|
+
severity: Literal["error", "warning", "info"]
|
|
18
|
+
code: str
|
|
19
|
+
message: str
|
|
20
|
+
path: str | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ErrorBody(ApiModel):
|
|
24
|
+
"""Standard error envelope."""
|
|
25
|
+
|
|
26
|
+
code: str
|
|
27
|
+
message: str
|
|
28
|
+
diagnostics: list[DiagnosticDto] = Field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class IdempotencyKey(ApiModel):
|
|
32
|
+
"""Document idempotency header usage."""
|
|
33
|
+
|
|
34
|
+
key: str = Field(alias="idempotencyKey")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Preview API models."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
from .common import ApiModel, DiagnosticDto
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PreviewRequest(ApiModel):
|
|
11
|
+
"""Preview one document definition."""
|
|
12
|
+
|
|
13
|
+
document_id: str = Field(alias="documentId")
|
|
14
|
+
workspace_overrides: dict[str, Any] = Field(
|
|
15
|
+
default_factory=dict, alias="workspaceOverrides"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PreviewResponse(ApiModel):
|
|
20
|
+
"""Browser-safe preview payload."""
|
|
21
|
+
|
|
22
|
+
document_id: str = Field(alias="documentId")
|
|
23
|
+
kind: Literal["html", "text", "sheet-json", "drawing-json", "unsupported"]
|
|
24
|
+
body: Any
|
|
25
|
+
warnings: list[DiagnosticDto] = Field(default_factory=list)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Project API models."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
from roborean_spec import Project
|
|
7
|
+
|
|
8
|
+
from .common import ApiModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ProjectCreate(ApiModel):
|
|
12
|
+
"""Create one stored project."""
|
|
13
|
+
|
|
14
|
+
project: Project
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProjectUpdate(ApiModel):
|
|
18
|
+
"""Replace a stored project."""
|
|
19
|
+
|
|
20
|
+
project: Project
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProjectSummary(ApiModel):
|
|
24
|
+
"""List row for projects."""
|
|
25
|
+
|
|
26
|
+
id: str
|
|
27
|
+
name: str
|
|
28
|
+
schema_version: str = Field(alias="schemaVersion")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ProjectDetail(ApiModel):
|
|
32
|
+
"""Redacted project payload."""
|
|
33
|
+
|
|
34
|
+
project: dict[str, Any]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Run and compile API models."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
from .common import ApiModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CompileRequest(ApiModel):
|
|
11
|
+
"""Optional compile flags."""
|
|
12
|
+
|
|
13
|
+
strict_undeclared_access: bool = Field(
|
|
14
|
+
default=True, alias="strictUndeclaredAccess"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CompileResponse(ApiModel):
|
|
19
|
+
"""Compiled project snapshot."""
|
|
20
|
+
|
|
21
|
+
compiled: dict[str, Any]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RunCreate(ApiModel):
|
|
25
|
+
"""Body for POST /runs."""
|
|
26
|
+
|
|
27
|
+
dry_run: bool = Field(default=False, alias="dryRun")
|
|
28
|
+
stop_on_bit_error: bool = Field(default=True, alias="stopOnBitError")
|
|
29
|
+
workspace_overrides: dict[str, Any] = Field(
|
|
30
|
+
default_factory=dict, alias="workspaceOverrides"
|
|
31
|
+
)
|
|
32
|
+
strict_workspace_access: bool = Field(
|
|
33
|
+
default=True, alias="strictWorkspaceAccess"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RunSummary(ApiModel):
|
|
38
|
+
"""Run list item."""
|
|
39
|
+
|
|
40
|
+
run_id: str = Field(alias="runId")
|
|
41
|
+
project_id: str = Field(alias="projectId")
|
|
42
|
+
status: str
|
|
43
|
+
created_at: str = Field(alias="createdAt")
|
|
44
|
+
finished_at: str | None = Field(default=None, alias="finishedAt")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RunDetail(ApiModel):
|
|
48
|
+
"""Redacted durable run."""
|
|
49
|
+
|
|
50
|
+
run_id: str = Field(alias="runId")
|
|
51
|
+
project_id: str = Field(alias="projectId")
|
|
52
|
+
status: str
|
|
53
|
+
results: dict[str, Any] | None = None
|
|
54
|
+
diff: dict[str, Any] | None = None
|
|
55
|
+
error: dict[str, Any] | None = None
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Auth stub for Phase 4."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from fastapi import Request
|
|
6
|
+
|
|
7
|
+
from .errors import ApiError
|
|
8
|
+
from .settings import Settings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Principal:
|
|
13
|
+
"""Caller identity (stub)."""
|
|
14
|
+
|
|
15
|
+
subject: str
|
|
16
|
+
roles: frozenset[str]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_principal(request: Request, settings: Settings) -> Principal:
|
|
20
|
+
"""Resolve the caller from headers or allow anonymous local use."""
|
|
21
|
+
if not settings.require_auth:
|
|
22
|
+
return Principal(subject="anonymous", roles=frozenset({"local"}))
|
|
23
|
+
raw = request.headers.get("X-Roborean-Principal")
|
|
24
|
+
if not raw:
|
|
25
|
+
raise ApiError(
|
|
26
|
+
status_code=401,
|
|
27
|
+
code="E_AUTH",
|
|
28
|
+
message="missing principal",
|
|
29
|
+
)
|
|
30
|
+
return Principal(subject=raw, roles=frozenset({"user"}))
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Service exports."""
|
|
2
|
+
|
|
3
|
+
from .compile_service import compile_stored_project
|
|
4
|
+
from .preview_service import build_preview
|
|
5
|
+
from .project_service import (
|
|
6
|
+
create_project,
|
|
7
|
+
delete_project,
|
|
8
|
+
get_project,
|
|
9
|
+
list_projects,
|
|
10
|
+
update_project,
|
|
11
|
+
)
|
|
12
|
+
from .run_service import create_run, get_run, list_runs
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"build_preview",
|
|
16
|
+
"compile_stored_project",
|
|
17
|
+
"create_project",
|
|
18
|
+
"create_run",
|
|
19
|
+
"delete_project",
|
|
20
|
+
"get_project",
|
|
21
|
+
"get_run",
|
|
22
|
+
"list_projects",
|
|
23
|
+
"list_runs",
|
|
24
|
+
"update_project",
|
|
25
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Compile service."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from roborean_engine.bits.registry import BitTypeRegistry
|
|
6
|
+
from roborean_engine.compiler import CompileOptions, compile_project
|
|
7
|
+
from roborean_storage_base import ProjectRepository
|
|
8
|
+
|
|
9
|
+
from ..schemas.runs import CompileRequest, CompileResponse
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def compile_stored_project(
|
|
13
|
+
repo: ProjectRepository,
|
|
14
|
+
project_id: str,
|
|
15
|
+
*,
|
|
16
|
+
body: CompileRequest,
|
|
17
|
+
registry: BitTypeRegistry,
|
|
18
|
+
package_dir: Path | None,
|
|
19
|
+
) -> CompileResponse:
|
|
20
|
+
"""Compile a stored project package."""
|
|
21
|
+
project = repo.get(project_id)
|
|
22
|
+
compiled = compile_project(
|
|
23
|
+
project,
|
|
24
|
+
bit_registry=registry,
|
|
25
|
+
options=CompileOptions(
|
|
26
|
+
strict_undeclared_access=body.strict_undeclared_access,
|
|
27
|
+
package_dir=package_dir,
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
return CompileResponse(
|
|
31
|
+
compiled=compiled.model_dump(mode="json", by_alias=True)
|
|
32
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Document preview service."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import TypeAdapter
|
|
6
|
+
from roborean_engine.bits.registry import builtin_registry
|
|
7
|
+
from roborean_engine.compiler import CompileOptions, compile_project
|
|
8
|
+
from roborean_engine.runner import RunOptions, run_project_detailed
|
|
9
|
+
from roborean_spec import WorkspaceValue
|
|
10
|
+
from roborean_storage_base import ProjectRepository
|
|
11
|
+
|
|
12
|
+
from ..schemas.common import DiagnosticDto
|
|
13
|
+
from ..schemas.previews import PreviewRequest, PreviewResponse
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_preview(
|
|
17
|
+
repo: ProjectRepository,
|
|
18
|
+
project_id: str,
|
|
19
|
+
body: PreviewRequest,
|
|
20
|
+
*,
|
|
21
|
+
package_dir: Path | None,
|
|
22
|
+
) -> PreviewResponse:
|
|
23
|
+
"""Run the project and return a browser-safe preview."""
|
|
24
|
+
project = repo.get(project_id)
|
|
25
|
+
registry = builtin_registry()
|
|
26
|
+
compiled = compile_project(
|
|
27
|
+
project,
|
|
28
|
+
bit_registry=registry,
|
|
29
|
+
options=CompileOptions(package_dir=package_dir),
|
|
30
|
+
)
|
|
31
|
+
adapter = TypeAdapter(WorkspaceValue)
|
|
32
|
+
overrides = {
|
|
33
|
+
key: adapter.validate_python(value)
|
|
34
|
+
for key, value in body.workspace_overrides.items()
|
|
35
|
+
}
|
|
36
|
+
outcome = run_project_detailed(
|
|
37
|
+
compiled,
|
|
38
|
+
project,
|
|
39
|
+
registry=registry,
|
|
40
|
+
options=RunOptions(
|
|
41
|
+
workspace_overrides=overrides,
|
|
42
|
+
package_dir=package_dir,
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
preview = outcome.previews.get(body.document_id)
|
|
46
|
+
if preview is None:
|
|
47
|
+
return PreviewResponse(
|
|
48
|
+
documentId=body.document_id,
|
|
49
|
+
kind="unsupported",
|
|
50
|
+
body="",
|
|
51
|
+
warnings=[
|
|
52
|
+
DiagnosticDto(
|
|
53
|
+
severity="warning",
|
|
54
|
+
code="W_PREVIEW_MISSING",
|
|
55
|
+
message="No preview produced for document",
|
|
56
|
+
)
|
|
57
|
+
],
|
|
58
|
+
)
|
|
59
|
+
mode = str(preview.get("mode", "unsupported"))
|
|
60
|
+
kind = "unsupported"
|
|
61
|
+
if mode in ("text", "html", "drawing-json"):
|
|
62
|
+
kind = "html" if mode == "html" else mode
|
|
63
|
+
if mode == "html":
|
|
64
|
+
kind = "html"
|
|
65
|
+
body_value = preview.get("body", "")
|
|
66
|
+
return PreviewResponse(
|
|
67
|
+
documentId=body.document_id,
|
|
68
|
+
kind=kind,
|
|
69
|
+
body=body_value,
|
|
70
|
+
warnings=[],
|
|
71
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Project persistence service."""
|
|
2
|
+
|
|
3
|
+
from roborean_spec import Project
|
|
4
|
+
from roborean_storage_base import ProjectRepository
|
|
5
|
+
|
|
6
|
+
from ..redaction import redact_project_for_client
|
|
7
|
+
from ..schemas.projects import (
|
|
8
|
+
ProjectCreate,
|
|
9
|
+
ProjectDetail,
|
|
10
|
+
ProjectSummary,
|
|
11
|
+
ProjectUpdate,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def list_projects(repo: ProjectRepository) -> list[ProjectSummary]:
|
|
16
|
+
"""List stored project ids."""
|
|
17
|
+
rows = []
|
|
18
|
+
for project_id in repo.list_ids():
|
|
19
|
+
project = repo.get(project_id)
|
|
20
|
+
rows.append(
|
|
21
|
+
ProjectSummary(
|
|
22
|
+
id=project.id,
|
|
23
|
+
name=project.name,
|
|
24
|
+
schemaVersion=project.schema_version,
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
return rows
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _coerce_project(value: Project | dict) -> Project:
|
|
31
|
+
"""Ensure a project model instance."""
|
|
32
|
+
if isinstance(value, Project):
|
|
33
|
+
return value
|
|
34
|
+
return Project.model_validate(value)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_project(
|
|
38
|
+
repo: ProjectRepository, body: ProjectCreate
|
|
39
|
+
) -> ProjectDetail:
|
|
40
|
+
"""Validate and store a new project."""
|
|
41
|
+
project = _coerce_project(body.project)
|
|
42
|
+
repo.save(project, revision="1")
|
|
43
|
+
return ProjectDetail(project=redact_project_for_client(project))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_project(repo: ProjectRepository, project_id: str) -> ProjectDetail:
|
|
47
|
+
"""Load one redacted project."""
|
|
48
|
+
project = repo.get(project_id)
|
|
49
|
+
return ProjectDetail(project=redact_project_for_client(project))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def update_project(
|
|
53
|
+
repo: ProjectRepository,
|
|
54
|
+
project_id: str,
|
|
55
|
+
body: ProjectUpdate,
|
|
56
|
+
) -> ProjectDetail:
|
|
57
|
+
"""Replace a stored project."""
|
|
58
|
+
project = _coerce_project(body.project)
|
|
59
|
+
if project.id != project_id:
|
|
60
|
+
raise ValueError("project.id must match URL project_id")
|
|
61
|
+
repo.save(project, revision="1")
|
|
62
|
+
return ProjectDetail(project=redact_project_for_client(project))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def delete_project(repo: ProjectRepository, project_id: str) -> None:
|
|
66
|
+
"""Remove a project package."""
|
|
67
|
+
repo.delete(project_id)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Run orchestration for the API."""
|
|
2
|
+
|
|
3
|
+
from pydantic import TypeAdapter
|
|
4
|
+
from roborean_engine import RunService
|
|
5
|
+
from roborean_spec import RunRequest, RunTrigger, WorkspaceValue
|
|
6
|
+
|
|
7
|
+
from ..redaction import redact_run_results_for_client
|
|
8
|
+
from ..schemas.runs import RunCreate, RunDetail, RunSummary
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _to_detail(record) -> RunDetail:
|
|
12
|
+
"""Map a durable record to a client DTO."""
|
|
13
|
+
results = None
|
|
14
|
+
if record.results is not None:
|
|
15
|
+
results = redact_run_results_for_client(record.results)
|
|
16
|
+
diff = None
|
|
17
|
+
if record.diff is not None:
|
|
18
|
+
diff = record.diff.model_dump(mode="json", by_alias=True)
|
|
19
|
+
error = None
|
|
20
|
+
if record.error is not None:
|
|
21
|
+
error = record.error.model_dump(mode="json", by_alias=True)
|
|
22
|
+
return RunDetail(
|
|
23
|
+
runId=record.run_id,
|
|
24
|
+
projectId=record.project_id,
|
|
25
|
+
status=record.status.value,
|
|
26
|
+
results=results,
|
|
27
|
+
diff=diff,
|
|
28
|
+
error=error,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_run(
|
|
33
|
+
service: RunService,
|
|
34
|
+
project_id: str,
|
|
35
|
+
body: RunCreate,
|
|
36
|
+
*,
|
|
37
|
+
idempotency_key: str,
|
|
38
|
+
) -> RunDetail:
|
|
39
|
+
"""Create an idempotent durable run."""
|
|
40
|
+
adapter = TypeAdapter(WorkspaceValue)
|
|
41
|
+
overrides = {
|
|
42
|
+
key: adapter.validate_python(value)
|
|
43
|
+
for key, value in body.workspace_overrides.items()
|
|
44
|
+
}
|
|
45
|
+
request = RunRequest(
|
|
46
|
+
projectId=project_id,
|
|
47
|
+
projectRevision="1",
|
|
48
|
+
idempotencyKey=idempotency_key,
|
|
49
|
+
trigger=RunTrigger.API,
|
|
50
|
+
workspaceOverrides=overrides,
|
|
51
|
+
strictWorkspaceAccess=body.strict_workspace_access,
|
|
52
|
+
)
|
|
53
|
+
record = service.create_and_execute(request)
|
|
54
|
+
return _to_detail(record)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_run(service: RunService, run_id: str) -> RunDetail:
|
|
58
|
+
"""Load one run."""
|
|
59
|
+
return _to_detail(service.get(run_id))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def list_runs(service: RunService, project_id: str) -> list[RunSummary]:
|
|
63
|
+
"""List runs for a project."""
|
|
64
|
+
rows = []
|
|
65
|
+
for record in service.list_for_project(project_id):
|
|
66
|
+
rows.append(
|
|
67
|
+
RunSummary(
|
|
68
|
+
runId=record.run_id,
|
|
69
|
+
projectId=record.project_id,
|
|
70
|
+
status=record.status.value,
|
|
71
|
+
createdAt=record.created_at,
|
|
72
|
+
finishedAt=record.finished_at,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
return rows
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Application settings."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Settings(BaseSettings):
|
|
10
|
+
"""Runtime configuration for the Roborean API."""
|
|
11
|
+
|
|
12
|
+
model_config = SettingsConfigDict(env_prefix="ROBOREAN_")
|
|
13
|
+
|
|
14
|
+
storage_backend: Literal["dict", "sqlalchemy"] = "dict"
|
|
15
|
+
store_path: Path = Path("playground/api-store")
|
|
16
|
+
database_url: str | None = None
|
|
17
|
+
artifact_root: Path = Path("playground/api-artifacts")
|
|
18
|
+
cors_origins: list[str] = ["http://localhost:5173"]
|
|
19
|
+
require_auth: bool = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_settings() -> Settings:
|
|
23
|
+
"""Load settings from environment."""
|
|
24
|
+
return Settings()
|
|
@@ -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,28 @@
|
|
|
1
|
+
roborean_api_fastapi/__init__.py,sha256=-pxfaVsLV7M5ZEUtqNc2h5eAUmn-9oo5fSKdBBGDwuM,126
|
|
2
|
+
roborean_api_fastapi/app.py,sha256=l002xHIBqCr7leW-Czfn3FV0nbBmoxeNQ22WTw4Ygfg,1457
|
|
3
|
+
roborean_api_fastapi/deps.py,sha256=83Nug4hbu2LmdHEd0AW4sAKvekVFqhyHslC4StmrElM,3573
|
|
4
|
+
roborean_api_fastapi/errors.py,sha256=lygczjriEF2zm2BC-0xkJHoFsULQjDSbc9fkD7G0E6Q,2493
|
|
5
|
+
roborean_api_fastapi/redaction.py,sha256=enNbnty3icIwkXG1z9EE_wypxTkRwnP_-pvxoJ2m6Co,3534
|
|
6
|
+
roborean_api_fastapi/security.py,sha256=CCv1e6YgZ6zyQDqxPO6UKa-2lAaQCm9fj5e-Q9DmheQ,793
|
|
7
|
+
roborean_api_fastapi/settings.py,sha256=7kZmo-6A7CxqlsQfbPYH2U56GUhOs4-JiGD0yzI7lfU,688
|
|
8
|
+
roborean_api_fastapi/openapi/customize.py,sha256=HcrMxGpUPuWS5ulnOFbUYT83ObBvQGHWfhwkS1EwFwE,345
|
|
9
|
+
roborean_api_fastapi/routers/__init__.py,sha256=wQxFhCu-JM5hwFlhzN1G7-m-or1ZqPAm07Uh6YUxzgE,168
|
|
10
|
+
roborean_api_fastapi/routers/artifacts.py,sha256=SdD56t3SMvr3UR_zUPKT5po1Cu41ymz4Kw9XX2_Rg5U,1459
|
|
11
|
+
roborean_api_fastapi/routers/compile.py,sha256=eggA9ECpyLBUZEII-67z-2sOeLcEKBMbE4ypCIMukEc,1043
|
|
12
|
+
roborean_api_fastapi/routers/health.py,sha256=FZIvuLwD9KcY3eu5wXEaS5hZ7T7HNWupn5as0z8tkb0,303
|
|
13
|
+
roborean_api_fastapi/routers/previews.py,sha256=6YFNbVMacphZxThSD8nx6oCPkB-PxUMuedlOPG1qmUM,908
|
|
14
|
+
roborean_api_fastapi/routers/projects.py,sha256=tu7VPuz4g6pVWGm0h9cEspnl4de2OdnsdQfkBrYyvc8,2262
|
|
15
|
+
roborean_api_fastapi/routers/runs.py,sha256=2TKAA8Bi-pP4yn0hf2n-2KRI9aW69EvpsjIG3_BjDv4,1657
|
|
16
|
+
roborean_api_fastapi/schemas/__init__.py,sha256=7skI91nUqyXfThn1xfhG-y38tjW4EbQ0COr3VnXIYUs,622
|
|
17
|
+
roborean_api_fastapi/schemas/common.py,sha256=UTkRtt4Lm8W_aHG4pzUztQWjcO9_6uTRg7MpQBDHU3U,721
|
|
18
|
+
roborean_api_fastapi/schemas/previews.py,sha256=Zn18pbJgx2IZbe5FL35A-nqmvimwxpeMT6kOK3Nu5II,653
|
|
19
|
+
roborean_api_fastapi/schemas/projects.py,sha256=tJL79T3rFkxoINWi_nrnYMbFDS6h3G8yildOvrsQqPo,572
|
|
20
|
+
roborean_api_fastapi/schemas/runs.py,sha256=tz_na0NwxJvRYVHh2fYJ2ZYqAKzbtxFgYMof8CNTRhU,1355
|
|
21
|
+
roborean_api_fastapi/services/__init__.py,sha256=nD4ziTuAdNUFBJFd5jcXLezL3e7ejDaKd0BtJ2AQKMU,526
|
|
22
|
+
roborean_api_fastapi/services/compile_service.py,sha256=aJJQw5eRo3rDaF-vb1rpH8jgQ3NP88jJkW8pkvz65gU,897
|
|
23
|
+
roborean_api_fastapi/services/preview_service.py,sha256=a60_5b74PSowbz5Fk0MuZuXC-a9I9zo_XNSWBGYdHHc,2156
|
|
24
|
+
roborean_api_fastapi/services/project_service.py,sha256=cpOya_PmqBnBCGDBRpIwry6zidXY6OB7Xi1C8glymKc,1929
|
|
25
|
+
roborean_api_fastapi/services/run_service.py,sha256=iBENGAbwyByOKvZMYa6XJvcan9tFq3wlFvXZIvM-0Lw,2221
|
|
26
|
+
roborean_api_fastapi-0.1.2.dist-info/METADATA,sha256=xcGAnjGYAzKcTL1mkjMx0rS9XyaEK6ErSIYC8qmaxjo,838
|
|
27
|
+
roborean_api_fastapi-0.1.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
28
|
+
roborean_api_fastapi-0.1.2.dist-info/RECORD,,
|