lljz66 commited on
Commit
5bf6ce1
·
verified ·
1 Parent(s): c2de51b

devnet: FastAPI app, routes, health

Browse files
Files changed (1) hide show
  1. 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