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.
@@ -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)
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any