devnet: FastAPI app, routes, health
Browse files- src/devnet/main.py +120 -0
src/devnet/main.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""devnet - one interface to the places builders use.
|
| 2 |
+
|
| 3 |
+
REST under /v1, MCP at /mcp, health at /health.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import secrets
|
| 9 |
+
|
| 10 |
+
from fastapi import Depends, FastAPI, Header, HTTPException
|
| 11 |
+
|
| 12 |
+
from .core import AuthRequired, RateLimited, UpstreamError
|
| 13 |
+
from .sources import github, hackernews, pypi, rss
|
| 14 |
+
|
| 15 |
+
app = FastAPI(title="devnet", version="0.1.0", summary="Developer internet for AI agents.")
|
| 16 |
+
|
| 17 |
+
# ponytail: one shared key from env, no accounts and no database. Ceiling: cannot
|
| 18 |
+
# rotate per customer or attribute usage to one. Upgrade: DB-backed keys at billing.
|
| 19 |
+
_KEYS = tuple(k for k in os.environ.get("DEVNET_KEYS", "").replace(",", " ").split() if k)
|
| 20 |
+
_OPEN = os.environ.get("DEVNET_OPEN") == "1"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def require_key(x_key: str = Header(default="")) -> None:
|
| 24 |
+
if _OPEN or not _KEYS:
|
| 25 |
+
return
|
| 26 |
+
if not any(secrets.compare_digest(x_key, k) for k in _KEYS):
|
| 27 |
+
raise HTTPException(401, "invalid or missing X-Key header")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
key = Depends(require_key)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _run(fn, *args):
|
| 34 |
+
"""Map the four upstream states onto HTTP status codes."""
|
| 35 |
+
try:
|
| 36 |
+
return fn(*args)
|
| 37 |
+
except RateLimited as exc:
|
| 38 |
+
raise HTTPException(429, str(exc)) from exc
|
| 39 |
+
except AuthRequired as exc:
|
| 40 |
+
raise HTTPException(401, str(exc)) from exc
|
| 41 |
+
except UpstreamError as exc:
|
| 42 |
+
raise HTTPException(502, str(exc)) from exc
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@app.get("/", include_in_schema=False)
|
| 46 |
+
def root() -> dict:
|
| 47 |
+
return {
|
| 48 |
+
"service": "devnet",
|
| 49 |
+
"mcp": "/mcp",
|
| 50 |
+
"rest": [
|
| 51 |
+
"/v1/pypi/package",
|
| 52 |
+
"/v1/github/repo",
|
| 53 |
+
"/v1/github/issues",
|
| 54 |
+
"/v1/hackernews/search",
|
| 55 |
+
"/v1/rss/read",
|
| 56 |
+
],
|
| 57 |
+
"health": "/health",
|
| 58 |
+
"docs": "/docs",
|
| 59 |
+
"auth": "open" if (_OPEN or not _KEYS) else "X-Key header",
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@app.get("/health")
|
| 64 |
+
def health() -> dict:
|
| 65 |
+
"""One cheap cached probe per source. Four states: ok/error/rate_limited/auth_required."""
|
| 66 |
+
probes = {
|
| 67 |
+
"pypi": lambda: pypi.package("requests"),
|
| 68 |
+
"github": lambda: github.repository("psf", "requests"),
|
| 69 |
+
"hackernews": lambda: hackernews.search("python", limit=1),
|
| 70 |
+
"rss": lambda: rss.read("https://huggingface.co/blog/feed.xml", limit=1),
|
| 71 |
+
}
|
| 72 |
+
checks: dict[str, dict] = {}
|
| 73 |
+
for name, probe in probes.items():
|
| 74 |
+
try:
|
| 75 |
+
probe()
|
| 76 |
+
checks[name] = {"status": "ok"}
|
| 77 |
+
except UpstreamError as exc:
|
| 78 |
+
checks[name] = {"status": exc.kind, "detail": str(exc)[:200]}
|
| 79 |
+
except Exception as exc: # health must never crash
|
| 80 |
+
checks[name] = {"status": "error", "detail": f"{type(exc).__name__}: {exc}"[:200]}
|
| 81 |
+
|
| 82 |
+
worst = "ok"
|
| 83 |
+
for candidate in ("error", "rate_limited", "auth_required"):
|
| 84 |
+
if any(c["status"] == candidate for c in checks.values()):
|
| 85 |
+
worst = candidate
|
| 86 |
+
break
|
| 87 |
+
return {"status": worst, "checks": checks}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@app.get("/v1/pypi/package", dependencies=[key])
|
| 91 |
+
def pypi_package(name: str) -> dict:
|
| 92 |
+
return _run(pypi.package, name)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@app.get("/v1/github/repo", dependencies=[key])
|
| 96 |
+
def github_repo(owner: str, name: str) -> dict:
|
| 97 |
+
return _run(github.repository, owner, name)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@app.get("/v1/github/issues", dependencies=[key])
|
| 101 |
+
def github_issues(owner: str, name: str, state: str = "open", limit: int = 20) -> dict:
|
| 102 |
+
return _run(github.issues, owner, name, state, limit)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@app.get("/v1/hackernews/search", dependencies=[key])
|
| 106 |
+
def hn_search(query: str, limit: int = 20, kind: str = "all") -> dict:
|
| 107 |
+
return _run(hackernews.search, query, limit, kind)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@app.get("/v1/rss/read", dependencies=[key])
|
| 111 |
+
def rss_read(url: str, limit: int = 20) -> dict:
|
| 112 |
+
return _run(rss.read, url, limit)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
try:
|
| 116 |
+
from .mcp_server import mcp_app
|
| 117 |
+
|
| 118 |
+
app.mount("/mcp", mcp_app)
|
| 119 |
+
except ImportError: # REST still serves if the mcp package is unavailable
|
| 120 |
+
pass
|