Files
railiance-fabric/railiance_fabric/server.py

216 lines
8.8 KiB
Python

from __future__ import annotations
import argparse
import json
import sys
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
from .registry import (
RegistryError,
RegistryStore,
backstage_projection,
blast_radius,
consumers,
dependency_path_lines,
library_xregistry_projection,
providers,
unresolved_dependencies,
xregistry_projection,
)
class RegistryHandler(BaseHTTPRequestHandler):
store: RegistryStore
def do_GET(self) -> None:
self._handle(self._get)
def do_POST(self) -> None:
self._handle(self._post)
def log_message(self, format: str, *args: object) -> None:
return
def _get(self, path: str, query: dict[str, list[str]]) -> tuple[int, Any]:
parts = _parts(path)
if path == "/health":
return HTTPStatus.OK, {"status": "ok"}
if path == "/status":
return HTTPStatus.OK, self.store.status()
if parts == ["repositories"]:
return HTTPStatus.OK, self.store.list_repositories()
if len(parts) == 3 and parts[0] == "repositories" and parts[2] == "inventory":
return HTTPStatus.OK, self.store.repository_inventory(parts[1])
if len(parts) == 2 and parts[0] == "repositories":
return HTTPStatus.OK, self.store.get_repository(parts[1])
if len(parts) == 3 and parts[0] == "repositories" and parts[2] == "snapshots":
return HTTPStatus.OK, self.store.list_snapshots(parts[1])
if len(parts) == 4 and parts[0] == "repositories" and parts[2] == "snapshots" and parts[3] == "latest":
return HTTPStatus.OK, self.store.latest_snapshot(parts[1])
if len(parts) == 4 and parts[0] == "repositories" and parts[2] == "snapshots" and parts[3] == "diff":
return HTTPStatus.OK, self.store.snapshot_diff(
parts[1],
from_id=_query_optional_int(query, "from_id"),
to_id=_query_optional_int(query, "to_id"),
)
if parts == ["search"]:
return HTTPStatus.OK, self.store.search(_query_one(query, "q"))
if parts == ["graph", "nodes"]:
return HTTPStatus.OK, self.store.combined_graph()["nodes"]
if len(parts) == 3 and parts[0] == "graph" and parts[1] == "nodes":
return HTTPStatus.OK, self.store.graph_node_detail(parts[2])
if parts == ["graph", "providers"]:
return HTTPStatus.OK, providers(self.store.combined_graph(), _query_one(query, "capability_type"))
if parts == ["graph", "consumers"]:
return HTTPStatus.OK, consumers(self.store.combined_graph(), _query_one(query, "target"))
if parts == ["graph", "unresolved"]:
return HTTPStatus.OK, unresolved_dependencies(self.store.combined_graph())
if parts == ["graph", "blast-radius"]:
return HTTPStatus.OK, blast_radius(self.store.combined_graph(), _query_one(query, "interface_id"))
if parts == ["graph", "dependency-path"]:
return HTTPStatus.OK, {"lines": dependency_path_lines(self.store.combined_graph(), _query_one(query, "service_id"))}
if parts == ["exports", "state-hub"]:
return HTTPStatus.OK, self.store.combined_graph()
if parts == ["exports", "backstage"]:
return HTTPStatus.OK, backstage_projection(self.store.combined_graph())
if parts == ["exports", "xregistry"]:
return HTTPStatus.OK, xregistry_projection(self.store.combined_graph())
if parts == ["artifacts"]:
return HTTPStatus.OK, self.store.list_artifacts(
repo_slug=_query_optional(query, "repo_slug"),
target_id=_query_optional(query, "target_id"),
artifact_type=_query_optional(query, "artifact_type") or _query_optional(query, "type"),
)
if len(parts) == 2 and parts[0] == "artifacts":
return HTTPStatus.OK, self.store.get_artifact(_int_id(parts[1], "artifact_id"))
if parts == ["libraries"]:
return HTTPStatus.OK, self.store.list_libraries(
repo_slug=_query_optional(query, "repo_slug"),
name=_query_optional(query, "name"),
purl=_query_optional(query, "purl"),
component_type=_query_optional(query, "component_type") or _query_optional(query, "type"),
)
if len(parts) == 2 and parts[0] == "libraries":
return HTTPStatus.OK, self.store.get_library(_int_id(parts[1], "library_id"))
if len(parts) == 3 and parts[0] == "repositories" and parts[2] == "libraries":
return HTTPStatus.OK, self.store.list_libraries(repo_slug=parts[1])
if parts == ["exports", "libraries", "xregistry"]:
return HTTPStatus.OK, library_xregistry_projection(self.store.list_libraries())
raise RegistryError(f"route not found: {path}", 404)
def _post(self, path: str, _query: dict[str, list[str]]) -> tuple[int, Any]:
parts = _parts(path)
body = self._read_json()
if parts == ["repositories"]:
return HTTPStatus.CREATED, self.store.upsert_repository(body)
if len(parts) == 3 and parts[0] == "repositories" and parts[2] == "snapshots":
return HTTPStatus.CREATED, self.store.add_snapshot(parts[1], body)
if len(parts) == 4 and parts[0] == "repositories" and parts[2] == "libraries" and parts[3] == "cyclonedx":
return HTTPStatus.CREATED, self.store.ingest_cyclonedx(parts[1], body)
if parts == ["artifacts"]:
return HTTPStatus.CREATED, self.store.add_artifact(body)
raise RegistryError(f"route not found: {path}", 404)
def _handle(self, action: Any) -> None:
parsed = urlparse(self.path)
query = parse_qs(parsed.query)
try:
status, body = action(parsed.path, query)
self._send_json(int(status), body)
except RegistryError as exc:
self._send_json(exc.status_code, {"error": exc.message})
except json.JSONDecodeError as exc:
self._send_json(400, {"error": f"invalid JSON request body: {exc}"})
except Exception as exc:
self._send_json(500, {"error": str(exc)})
def _read_json(self) -> dict[str, Any]:
length = int(self.headers.get("Content-Length", "0"))
if length == 0:
return {}
raw = self.rfile.read(length)
body = json.loads(raw.decode("utf-8"))
if not isinstance(body, dict):
raise RegistryError("request body must be a JSON object")
return body
def _send_json(self, status: int, body: Any) -> None:
payload = json.dumps(body, indent=2, sort_keys=True).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="railiance-fabric-registry",
description="Run the Railiance Fabric ecosystem registry service.",
)
parser.add_argument("--db", type=Path, default=Path(".railiance-fabric/registry.sqlite3"))
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
store = RegistryStore(args.db)
store.init_schema()
class Handler(RegistryHandler):
pass
Handler.store = store
server = ThreadingHTTPServer((args.host, args.port), Handler)
print(f"Railiance Fabric Registry listening on http://{args.host}:{args.port}")
try:
server.serve_forever()
except KeyboardInterrupt:
print()
return 0
finally:
server.server_close()
return 0
def _parts(path: str) -> list[str]:
return [part for part in path.strip("/").split("/") if part]
def _query_one(query: dict[str, list[str]], key: str) -> str:
values = query.get(key)
if not values or not values[0]:
raise RegistryError(f"missing query parameter: {key}")
return values[0]
def _query_optional(query: dict[str, list[str]], key: str) -> str | None:
values = query.get(key)
if not values or not values[0]:
return None
return values[0]
def _int_id(value: str, label: str) -> int:
try:
return int(value)
except ValueError as exc:
raise RegistryError(f"invalid {label}: {value}", 400) from exc
def _query_optional_int(query: dict[str, list[str]], key: str) -> int | None:
value = _query_optional(query, key)
if value is None:
return None
return _int_id(value, key)
if __name__ == "__main__":
sys.exit(main())