Skip to content

Commit 1c29260

Browse files
committed
Added REST and WebSocket API endpoints to support peer management, messaging, identity handling, and service control.
1 parent b1e3f95 commit 1c29260

File tree

16 files changed

+2997
-5
lines changed

16 files changed

+2997
-5
lines changed

py-peer/API_REFERENCE.md

Lines changed: 1563 additions & 0 deletions
Large diffs are not rendered by default.

py-peer/api/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""
2+
Tornado REST + WebSocket API package for py-peer Universal Connectivity DApp.
3+
"""

py-peer/api/base.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""
2+
Base handler for all Tornado API endpoints.
3+
4+
Provides:
5+
- JSON response helpers
6+
- CORS headers
7+
- Service readiness check
8+
- Uniform error envelope
9+
"""
10+
11+
import json
12+
import time
13+
import logging
14+
import traceback
15+
16+
import tornado.web
17+
18+
logger = logging.getLogger("api.base")
19+
20+
21+
class BaseHandler(tornado.web.RequestHandler):
22+
"""Base class for all REST API handlers."""
23+
24+
def initialize(self, service):
25+
"""Inject the HeadlessService instance."""
26+
self.service = service
27+
28+
# ------------------------------------------------------------------ #
29+
# CORS #
30+
# ------------------------------------------------------------------ #
31+
def set_default_headers(self):
32+
self.set_header("Access-Control-Allow-Origin", "*")
33+
self.set_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
34+
self.set_header("Access-Control-Allow-Headers", "Content-Type, X-API-Key")
35+
self.set_header("Content-Type", "application/json")
36+
37+
def options(self, *args, **kwargs):
38+
"""Handle CORS preflight."""
39+
self.set_status(204)
40+
self.finish()
41+
42+
# ------------------------------------------------------------------ #
43+
# JSON helpers #
44+
# ------------------------------------------------------------------ #
45+
def send_success(self, data=None, status=200):
46+
self.set_status(status)
47+
self.finish(json.dumps({
48+
"success": True,
49+
"data": data,
50+
"error": None,
51+
"timestamp": time.time(),
52+
}))
53+
54+
def send_error_response(self, message, status=400, detail=None):
55+
self.set_status(status)
56+
self.finish(json.dumps({
57+
"success": False,
58+
"data": None,
59+
"error": {
60+
"code": status,
61+
"message": message,
62+
"detail": detail,
63+
},
64+
"timestamp": time.time(),
65+
}))
66+
67+
# ------------------------------------------------------------------ #
68+
# Request body helpers #
69+
# ------------------------------------------------------------------ #
70+
def get_json_body(self):
71+
try:
72+
return json.loads(self.request.body)
73+
except (json.JSONDecodeError, Exception):
74+
return {}
75+
76+
# ------------------------------------------------------------------ #
77+
# Service readiness guard #
78+
# ------------------------------------------------------------------ #
79+
def require_ready(self):
80+
"""Return False and send 503 if the service is not ready yet."""
81+
if not self.service or not self.service.ready:
82+
self.send_error_response(
83+
"Service not ready yet — HeadlessService is still initialising.",
84+
status=503,
85+
)
86+
return False
87+
return True
88+
89+
# ------------------------------------------------------------------ #
90+
# Global exception handler #
91+
# ------------------------------------------------------------------ #
92+
def write_error(self, status_code, **kwargs):
93+
exc_info = kwargs.get("exc_info")
94+
detail = None
95+
if exc_info:
96+
detail = traceback.format_exception(*exc_info)[-1].strip()
97+
self.set_header("Content-Type", "application/json")
98+
self.finish(json.dumps({
99+
"success": False,
100+
"data": None,
101+
"error": {
102+
"code": status_code,
103+
"message": self._reason,
104+
"detail": detail,
105+
},
106+
"timestamp": time.time(),
107+
}))

py-peer/api/dht.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""
2+
DHT endpoints.
3+
4+
GET /api/v1/dht/status - mode, routing table size, random walk
5+
GET /api/v1/dht/peers - peer IDs in DHT routing table
6+
GET /api/v1/dht/routing-table - concise routing table dump
7+
"""
8+
9+
from .base import BaseHandler
10+
11+
12+
class DHTStatusHandler(BaseHandler):
13+
"""GET /api/v1/dht/status"""
14+
15+
def get(self):
16+
if not self.require_ready():
17+
return
18+
dht = self.service.dht
19+
if not dht:
20+
self.send_error_response("DHT is not initialised.", status=503)
21+
return
22+
try:
23+
rt_size = len(list(dht.routing_table.get_peer_ids()))
24+
except Exception:
25+
rt_size = -1
26+
27+
# DHTMode enum → string
28+
mode_val = getattr(dht, "mode", None)
29+
mode_str = mode_val.name if hasattr(mode_val, "name") else str(mode_val)
30+
31+
self.send_success({
32+
"mode": mode_str,
33+
"random_walk_enabled": getattr(dht, "enable_random_walk", False),
34+
"routing_table_size": rt_size,
35+
})
36+
37+
38+
class DHTPeersHandler(BaseHandler):
39+
"""GET /api/v1/dht/peers"""
40+
41+
def get(self):
42+
if not self.require_ready():
43+
return
44+
dht = self.service.dht
45+
if not dht:
46+
self.send_error_response("DHT is not initialised.", status=503)
47+
return
48+
try:
49+
peers = [str(p) for p in dht.routing_table.get_peer_ids()]
50+
except Exception as e:
51+
self.send_error_response(f"Could not read routing table: {e}", status=500)
52+
return
53+
self.send_success({"peers": peers, "count": len(peers)})
54+
55+
56+
class DHTRoutingTableHandler(BaseHandler):
57+
"""GET /api/v1/dht/routing-table"""
58+
59+
def get(self):
60+
if not self.require_ready():
61+
return
62+
dht = self.service.dht
63+
if not dht:
64+
self.send_error_response("DHT is not initialised.", status=503)
65+
return
66+
try:
67+
peers = [str(p) for p in dht.routing_table.get_peer_ids()]
68+
except Exception as e:
69+
self.send_error_response(f"Could not read routing table: {e}", status=500)
70+
return
71+
self.send_success({
72+
"routing_table": peers,
73+
"total_peers": len(peers),
74+
})

py-peer/api/files.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""
2+
File sharing endpoints (Bitswap / MerkleDag).
3+
4+
GET /api/v1/files/shared - list files this node has shared
5+
GET /api/v1/files/shared/{cid} - metadata for a specific shared file
6+
POST /api/v1/files/share - share a local file to a topic
7+
POST /api/v1/files/download - download a file by CID hex
8+
POST /api/v1/files/upload - upload via multipart and share to a topic
9+
"""
10+
11+
import os
12+
from .base import BaseHandler
13+
14+
15+
class SharedFilesHandler(BaseHandler):
16+
"""GET /api/v1/files/shared"""
17+
18+
def get(self):
19+
if not self.require_ready():
20+
return
21+
files = [
22+
{"cid": cid, **meta}
23+
for cid, meta in self.service.shared_files.items()
24+
]
25+
self.send_success({"shared_files": files, "count": len(files)})
26+
27+
28+
class SharedFileDetailHandler(BaseHandler):
29+
"""GET /api/v1/files/shared/{cid}"""
30+
31+
def get(self, cid):
32+
if not self.require_ready():
33+
return
34+
meta = self.service.shared_files.get(cid)
35+
if not meta:
36+
self.send_error_response(f"No shared file with CID '{cid}'.", status=404)
37+
return
38+
self.send_success({"cid": cid, **meta})
39+
40+
41+
class ShareFileHandler(BaseHandler):
42+
"""POST /api/v1/files/share — share a file that already exists on disk"""
43+
44+
def post(self):
45+
if not self.require_ready():
46+
return
47+
body = self.get_json_body()
48+
file_path = body.get("file_path", "").strip()
49+
topic = body.get("topic", "").strip()
50+
51+
if not file_path:
52+
self.send_error_response("'file_path' is required.")
53+
return
54+
if not topic:
55+
self.send_error_response("'topic' is required.")
56+
return
57+
if not os.path.exists(file_path):
58+
self.send_error_response(f"File not found: {file_path}", status=400)
59+
return
60+
61+
subscribed = self.service.get_subscribed_topics()
62+
if topic not in subscribed:
63+
self.send_error_response(
64+
f"Not subscribed to topic '{topic}'. Subscribe first via POST /api/v1/topics.",
65+
status=400,
66+
)
67+
return
68+
69+
queued = self.service.share_file(file_path, topic)
70+
if queued:
71+
filename = os.path.basename(file_path)
72+
self.send_success(
73+
{"message": "File share request queued", "filename": filename, "topic": topic},
74+
status=202,
75+
)
76+
else:
77+
self.send_error_response("Failed to queue file share — service not ready.", status=503)
78+
79+
80+
class DownloadFileHandler(BaseHandler):
81+
"""POST /api/v1/files/download — download a file by CID hex"""
82+
83+
def post(self):
84+
if not self.require_ready():
85+
return
86+
body = self.get_json_body()
87+
cid = body.get("file_cid", "").strip()
88+
name = body.get("file_name", "unknown").strip()
89+
90+
if not cid:
91+
self.send_error_response("'file_cid' is required.")
92+
return
93+
94+
queued = self.service.download_file(cid, name)
95+
if queued:
96+
self.send_success(
97+
{"message": "Download request queued", "file_cid": cid, "file_name": name},
98+
status=202,
99+
)
100+
else:
101+
self.send_error_response("Failed to queue download — service not ready.", status=503)
102+
103+
104+
class UploadAndShareHandler(BaseHandler):
105+
"""
106+
POST /api/v1/files/upload
107+
Accepts multipart/form-data with fields:
108+
- file : the file bytes
109+
- topic : the topic to share to
110+
Saves the file to the service's download_dir and queues a share.
111+
"""
112+
113+
def post(self):
114+
if not self.require_ready():
115+
return
116+
117+
topic = self.get_argument("topic", "").strip()
118+
if not topic:
119+
self.send_error_response("'topic' form field is required.")
120+
return
121+
122+
if "file" not in self.request.files:
123+
self.send_error_response("'file' form-data field is required.")
124+
return
125+
126+
file_info = self.request.files["file"][0]
127+
filename = file_info["filename"] or "upload"
128+
file_data = file_info["body"]
129+
130+
# Save to download_dir
131+
save_path = os.path.join(self.service.download_dir, filename)
132+
# Handle name collisions
133+
counter = 1
134+
base, ext = os.path.splitext(filename)
135+
while os.path.exists(save_path):
136+
save_path = os.path.join(self.service.download_dir, f"{base}_{counter}{ext}")
137+
counter += 1
138+
139+
with open(save_path, "wb") as f:
140+
f.write(file_data)
141+
142+
subscribed = self.service.get_subscribed_topics()
143+
if topic not in subscribed:
144+
self.send_error_response(
145+
f"Not subscribed to topic '{topic}'. Subscribe first via POST /api/v1/topics.",
146+
status=400,
147+
)
148+
return
149+
150+
queued = self.service.share_file(save_path, topic)
151+
if queued:
152+
self.send_success(
153+
{
154+
"message": "File uploaded and share request queued",
155+
"filename": os.path.basename(save_path),
156+
"size": len(file_data),
157+
"topic": topic,
158+
"saved_path": save_path,
159+
},
160+
status=202,
161+
)
162+
else:
163+
self.send_error_response("Failed to queue file share — service not ready.", status=503)

0 commit comments

Comments
 (0)