feat: add plug-and-play devRant API client examples with Python and JavaScript

Add complete set of example scripts for the devRant REST protocol under
examples/devrant/, including reusable client libraries, a rant poster,
a live feed watcher with keyword-based auto-upvote, and an end-to-end
smoke test that exercises every endpoint. Both Python (stdlib-only) and
JavaScript (Node 18+ global fetch) implementations are provided.
This commit is contained in:
2026-06-14 07:48:37 +00:00
parent f2b910ea75
commit c151325916
23 changed files with 1451 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
# DevPlace devRant API examples
Plug-and-play clients and scripts for the devRant-compatible REST protocol served at
`/api`. The Python scripts use only the standard library (`urllib`); the JavaScript
scripts use Node 18+ (global `fetch`, ES modules). No dependencies to install. Full
reference: `/docs/devrant.html`.
## Files
| File | Language | What it does |
|------|----------|--------------|
| `client.py` / `client.mjs` | Python / JS | Reusable `DevRant` client: login, token injection, every endpoint. Imported by the others. |
| `post_rant.py` / `post_rant.mjs` | Python / JS | Log in and post one rant from the command line. |
| `feed_watch.py` / `feed_watch.mjs` | Python / JS | Live feed ticker; optionally auto-upvotes rants matching keywords. |
| `smoke_test.py` / `smoke_test.mjs` | Python / JS | End-to-end conformance test: registers a throwaway user and exercises every endpoint, printing PASS/FAIL. |
## Configuration
The scripts read their settings from environment variables:
| Variable | Default | Purpose |
|----------|---------|---------|
| `DEVRANT_BASE` | `http://localhost:10500` | Base URL of the DevPlace server (no `/api` suffix) |
| `DEVRANT_USERNAME` | (none) | Username for write operations |
| `DEVRANT_PASSWORD` | (none) | Password for write operations |
| `DEVRANT_POLL_SECONDS` | `30` | `feed_watch` poll interval |
| `DEVRANT_UPVOTE_KEYWORDS` | (empty) | Comma-separated keywords `feed_watch` auto-upvotes |
## Run
```bash
# post a rant
DEVRANT_USERNAME=you DEVRANT_PASSWORD=secret6 \
python post_rant.py "Posted from a script" "python,automation"
# same in JavaScript
DEVRANT_USERNAME=you DEVRANT_PASSWORD=secret6 \
node post_rant.mjs "Posted from a script" "python,automation"
# watch the feed and auto-upvote anything mentioning rust
DEVRANT_USERNAME=you DEVRANT_PASSWORD=secret6 DEVRANT_UPVOTE_KEYWORDS=rust \
python feed_watch.py
# run the full conformance test against a running server
DEVRANT_BASE=http://localhost:10500 python smoke_test.py
DEVRANT_BASE=http://localhost:10500 node smoke_test.mjs
```
## Notes
- Authentication follows the devRant model: `login()` calls `POST /api/users/auth-token`
and stores the `(user_id, token_id, token_key)` triple, which is then sent with every
call (query params for `GET`/`DELETE`, form body for `POST`).
- Read endpoints (feed, single rant, search, profiles) work without logging in.
- These scripts target a DevPlace server. A legacy devRant client hard-coded to
`devrant.com` is reached only through host routing (DNS/reverse-proxy), which is an
infrastructure concern outside these examples.
+126
View File
@@ -0,0 +1,126 @@
// retoor <retoor@molodetz.nl>
export class DevRant {
constructor(baseUrl, username, password) {
this.baseUrl = baseUrl.replace(/\/$/, "");
this.username = username;
this.password = password;
this.auth = {};
}
_url(path, params) {
const url = new URL(`${this.baseUrl}/api/${path.replace(/^\//, "")}`);
for (const [key, value] of Object.entries(params || {})) {
url.searchParams.set(key, value);
}
return url;
}
async _request(method, path, params = {}, body = null) {
const merged = { ...params, ...this.auth };
const headers = { Accept: "application/json" };
let url;
const init = { method, headers };
if (method === "GET" || method === "DELETE") {
url = this._url(path, merged);
} else {
url = this._url(path);
headers["Content-Type"] = "application/x-www-form-urlencoded";
init.body = new URLSearchParams({ ...merged, ...(body || {}) }).toString();
}
const response = await fetch(url, init);
return response.json();
}
async login() {
const result = await this._request("POST", "users/auth-token", {}, {
username: this.username,
password: this.password,
});
if (!result.success) throw new Error(result.error || "login failed");
const token = result.auth_token;
this.auth = { user_id: token.user_id, token_id: token.id, token_key: token.key };
return this.auth;
}
register(email) {
return this._request("POST", "users", {}, {
username: this.username,
email,
password: this.password,
});
}
async rants(sort = "recent", limit = 20, skip = 0) {
const result = await this._request("GET", "devrant/rants", { sort, limit, skip });
return result.rants || [];
}
rant(rantId) {
return this._request("GET", `devrant/rants/${rantId}`);
}
async search(term) {
return (await this._request("GET", "devrant/search", { term })).results || [];
}
async userId(username) {
return (await this._request("GET", "get-user-id", { username })).user_id;
}
async profile(userId) {
return (await this._request("GET", `users/${userId}`)).profile;
}
async notifs() {
return (await this._request("GET", "users/me/notif-feed")).data || {};
}
clearNotifs() {
return this._request("DELETE", "users/me/notif-feed");
}
editProfile(fields) {
return this._request("POST", "users/me/edit-profile", {}, fields);
}
postRant(text, tags = "") {
return this._request("POST", "devrant/rants", {}, { rant: text, tags });
}
editRant(rantId, text, tags = "") {
return this._request("POST", `devrant/rants/${rantId}`, {}, { rant: text, tags });
}
deleteRant(rantId) {
return this._request("DELETE", `devrant/rants/${rantId}`);
}
voteRant(rantId, vote) {
return this._request("POST", `devrant/rants/${rantId}/vote`, {}, { vote });
}
favorite(rantId) {
return this._request("POST", `devrant/rants/${rantId}/favorite`);
}
unfavorite(rantId) {
return this._request("POST", `devrant/rants/${rantId}/unfavorite`);
}
comment(rantId, text) {
return this._request("POST", `devrant/rants/${rantId}/comments`, {}, { comment: text });
}
voteComment(commentId, vote) {
return this._request("POST", `comments/${commentId}/vote`, {}, { vote });
}
}
export function fromEnv() {
return new DevRant(
process.env.DEVRANT_BASE || "http://localhost:10500",
process.env.DEVRANT_USERNAME,
process.env.DEVRANT_PASSWORD,
);
}
+141
View File
@@ -0,0 +1,141 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import json
import os
import urllib.parse
import urllib.request
from typing import Any, Optional
class DevRant:
def __init__(
self,
base_url: str,
username: Optional[str] = None,
password: Optional[str] = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.username = username
self.password = password
self.auth: dict[str, Any] = {}
def _url(self, path: str) -> str:
return f"{self.base_url}/api/{path.lstrip('/')}"
def _request(
self,
method: str,
path: str,
params: Optional[dict] = None,
body: Optional[dict] = None,
) -> dict:
merged = dict(params or {})
merged.update(self.auth)
url = self._url(path)
data = None
headers = {"Accept": "application/json"}
if method in ("GET", "DELETE"):
if merged:
url += "?" + urllib.parse.urlencode(merged)
else:
payload = dict(merged)
payload.update(body or {})
data = urllib.parse.urlencode(payload).encode("utf-8")
headers["Content-Type"] = "application/x-www-form-urlencoded"
request = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(request) as response:
return json.loads(response.read().decode("utf-8"))
def login(self) -> dict:
result = self._request(
"POST",
"users/auth-token",
body={"username": self.username, "password": self.password},
)
if not result.get("success"):
raise RuntimeError(result.get("error", "login failed"))
token = result["auth_token"]
self.auth = {
"user_id": token["user_id"],
"token_id": token["id"],
"token_key": token["key"],
}
return self.auth
def register(self, email: str) -> dict:
return self._request(
"POST",
"users",
body={
"username": self.username,
"email": email,
"password": self.password,
},
)
def rants(self, sort: str = "recent", limit: int = 20, skip: int = 0) -> list:
result = self._request(
"GET", "devrant/rants", {"sort": sort, "limit": limit, "skip": skip}
)
return result.get("rants", [])
def rant(self, rant_id: int) -> dict:
return self._request("GET", f"devrant/rants/{rant_id}")
def search(self, term: str) -> list:
return self._request("GET", "devrant/search", {"term": term}).get("results", [])
def user_id(self, username: str) -> Optional[int]:
return self._request("GET", "get-user-id", {"username": username}).get("user_id")
def profile(self, user_id: int) -> Optional[dict]:
return self._request("GET", f"users/{user_id}").get("profile")
def notifs(self) -> dict:
return self._request("GET", "users/me/notif-feed").get("data", {})
def clear_notifs(self) -> dict:
return self._request("DELETE", "users/me/notif-feed")
def edit_profile(self, **fields: str) -> dict:
return self._request("POST", "users/me/edit-profile", body=fields)
def post_rant(self, text: str, tags: str = "") -> dict:
return self._request("POST", "devrant/rants", body={"rant": text, "tags": tags})
def edit_rant(self, rant_id: int, text: str, tags: str = "") -> dict:
return self._request(
"POST", f"devrant/rants/{rant_id}", body={"rant": text, "tags": tags}
)
def delete_rant(self, rant_id: int) -> dict:
return self._request("DELETE", f"devrant/rants/{rant_id}")
def vote_rant(self, rant_id: int, vote: int) -> dict:
return self._request(
"POST", f"devrant/rants/{rant_id}/vote", body={"vote": vote}
)
def favorite(self, rant_id: int) -> dict:
return self._request("POST", f"devrant/rants/{rant_id}/favorite")
def unfavorite(self, rant_id: int) -> dict:
return self._request("POST", f"devrant/rants/{rant_id}/unfavorite")
def comment(self, rant_id: int, text: str) -> dict:
return self._request(
"POST", f"devrant/rants/{rant_id}/comments", body={"comment": text}
)
def vote_comment(self, comment_id: int, vote: int) -> dict:
return self._request("POST", f"comments/{comment_id}/vote", body={"vote": vote})
def from_env() -> DevRant:
return DevRant(
os.environ.get("DEVRANT_BASE", "http://localhost:10500"),
os.environ.get("DEVRANT_USERNAME"),
os.environ.get("DEVRANT_PASSWORD"),
)
+35
View File
@@ -0,0 +1,35 @@
// retoor <retoor@molodetz.nl>
import { fromEnv } from "./client.mjs";
const pollSeconds = Number(process.env.DEVRANT_POLL_SECONDS || "30");
const keywords = (process.env.DEVRANT_UPVOTE_KEYWORDS || "")
.split(",")
.map((word) => word.trim().toLowerCase())
.filter(Boolean);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const api = fromEnv();
if (api.username && api.password) await api.login();
const seen = new Set();
console.log(`watching feed every ${pollSeconds}s (ctrl-c to stop)`);
while (true) {
const rants = await api.rants("recent", 20);
for (const rant of rants.reverse()) {
if (seen.has(rant.id)) continue;
seen.add(rant.id);
const text = rant.text.replace(/\n/g, " ").slice(0, 80);
console.log(`[${rant.id}] ${rant.user_username}: ${text}`);
if (keywords.length && api.auth.token_key) {
const lowered = rant.text.toLowerCase();
if (keywords.some((word) => lowered.includes(word))) {
await api.voteRant(rant.id, 1);
console.log(` upvoted ${rant.id}`);
}
}
}
await sleep(pollSeconds * 1000);
}
+39
View File
@@ -0,0 +1,39 @@
# retoor <retoor@molodetz.nl>
import os
import time
from client import from_env
POLL_SECONDS = int(os.environ.get("DEVRANT_POLL_SECONDS", "30"))
UPVOTE_KEYWORDS = [
word.strip().lower()
for word in os.environ.get("DEVRANT_UPVOTE_KEYWORDS", "").split(",")
if word.strip()
]
def main() -> None:
api = from_env()
if api.username and api.password:
api.login()
seen: set[int] = set()
print(f"watching feed every {POLL_SECONDS}s (ctrl-c to stop)")
while True:
for rant in reversed(api.rants(sort="recent", limit=20)):
rant_id = rant["id"]
if rant_id in seen:
continue
seen.add(rant_id)
text = rant["text"].replace("\n", " ")[:80]
print(f"[{rant_id}] {rant['user_username']}: {text}")
if UPVOTE_KEYWORDS and api.auth:
lowered = rant["text"].lower()
if any(word in lowered for word in UPVOTE_KEYWORDS):
api.vote_rant(rant_id, 1)
print(f" upvoted {rant_id}")
time.sleep(POLL_SECONDS)
if __name__ == "__main__":
main()
+20
View File
@@ -0,0 +1,20 @@
// retoor <retoor@molodetz.nl>
import { fromEnv } from "./client.mjs";
const text = process.argv[2];
const tags = process.argv[3] || "";
if (!text) {
console.log("usage: DEVRANT_USERNAME=.. DEVRANT_PASSWORD=.. node post_rant.mjs <text> [tags]");
process.exit(1);
}
const api = fromEnv();
await api.login();
const result = await api.postRant(text, tags);
if (result.success) {
console.log(`posted rant ${result.rant_id}`);
} else {
console.log(`failed: ${result.error}`);
}
+24
View File
@@ -0,0 +1,24 @@
# retoor <retoor@molodetz.nl>
import sys
from client import from_env
def main() -> None:
if len(sys.argv) < 2:
print("usage: DEVRANT_USERNAME=.. DEVRANT_PASSWORD=.. python post_rant.py <text> [tags]")
raise SystemExit(1)
text = sys.argv[1]
tags = sys.argv[2] if len(sys.argv) > 2 else ""
api = from_env()
api.login()
result = api.post_rant(text, tags)
if result.get("success"):
print(f"posted rant {result['rant_id']}")
else:
print(f"failed: {result.get('error')}")
if __name__ == "__main__":
main()
+77
View File
@@ -0,0 +1,77 @@
// retoor <retoor@molodetz.nl>
import { DevRant } from "./client.mjs";
const base = process.env.DEVRANT_BASE || "http://localhost:10500";
let passed = 0;
let failed = 0;
function check(label, condition, detail = "") {
if (condition) {
passed += 1;
console.log(` PASS ${label}`);
} else {
failed += 1;
console.log(` FAIL ${label} ${detail}`);
}
}
const suffix = String(Math.floor(Date.now() / 1000));
const username = `dr_smoke_${suffix}`;
const api = new DevRant(base, username, "secret6");
const reg = await api.register(`${username}@example.test`);
check("register", reg.success, JSON.stringify(reg));
const login = await new DevRant(base, username, "secret6").login();
check("login returns token", Boolean(login.token_key));
await api.login();
const posted = await api.postRant("Smoke test rant about Python", "python,smoke,devrant");
check("post rant", posted.success);
const rantId = posted.rant_id;
const feed = await api.rants("recent", 5);
check("feed returns rants", feed.some((r) => r.id === rantId));
const detail = await api.rant(rantId);
check("get rant tags round-trip", JSON.stringify(detail.rant.tags) === JSON.stringify(["python", "smoke", "devrant"]));
check("get rant editable", detail.rant.editable === true);
check("comment", (await api.comment(rantId, "Nice rant!")).success);
const voted = await api.voteRant(rantId, 1);
check("vote score", voted.rant.score === 1);
check("vote state", voted.rant.vote_state === 1);
check("favorite", (await api.favorite(rantId)).success);
check("unfavorite", (await api.unfavorite(rantId)).success);
const uid = await api.userId(username);
check("get-user-id", Boolean(uid));
let profile = await api.profile(uid);
check("profile score", profile.score === 1);
check("profile counts", profile.content.counts.rants === 1);
await api.editProfile({ profile_about: "I build with Python", profile_github: "smoke" });
profile = await api.profile(uid);
check("edit-profile bio", profile.about === "I build with Python");
check("skills derived from bio", profile.skills === "I build with Python");
const notifs = await api.notifs();
check("notif-feed shape", "unread" in notifs && "items" in notifs);
check("delete rant", (await api.deleteRant(rantId)).success);
let badRejected = false;
try {
await new DevRant(base, username, "wrongpw").login();
} catch {
badRejected = true;
}
check("bad login rejected", badRejected);
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
+87
View File
@@ -0,0 +1,87 @@
# retoor <retoor@molodetz.nl>
import os
import time
from client import DevRant
BASE = os.environ.get("DEVRANT_BASE", "http://localhost:10500")
passed = 0
failed = 0
def check(label: str, condition: bool, detail: str = "") -> None:
global passed, failed
if condition:
passed += 1
print(f" PASS {label}")
else:
failed += 1
print(f" FAIL {label} {detail}")
def main() -> None:
suffix = str(int(time.time()))
username = f"dr_smoke_{suffix}"
api = DevRant(BASE, username, "secret6")
reg = api.register(f"{username}@example.test")
check("register", reg.get("success"), str(reg))
login = DevRant(BASE, username, "secret6").login()
check("login returns token", bool(login.get("token_key")))
api.login()
posted = api.post_rant("Smoke test rant about Python", "python,smoke,devrant")
check("post rant", posted.get("success"))
rant_id = posted.get("rant_id")
feed = api.rants(limit=5)
check("feed returns rants", any(r["id"] == rant_id for r in feed))
detail = api.rant(rant_id)
check("get rant tags round-trip", detail.get("rant", {}).get("tags") == ["python", "smoke", "devrant"])
check("get rant editable", detail.get("rant", {}).get("editable") is True)
commented = api.comment(rant_id, "Nice rant!")
check("comment", commented.get("success"))
voted = api.vote_rant(rant_id, 1)
check("vote score", voted.get("rant", {}).get("score") == 1)
check("vote state", voted.get("rant", {}).get("vote_state") == 1)
check("favorite", api.favorite(rant_id).get("success"))
check("unfavorite", api.unfavorite(rant_id).get("success"))
uid = api.user_id(username)
check("get-user-id", bool(uid))
profile = api.profile(uid)
check("profile score", profile.get("score") == 1)
check("profile counts", profile.get("content", {}).get("counts", {}).get("rants") == 1)
api.edit_profile(profile_about="I build with Python", profile_github="smoke")
profile = api.profile(uid)
check("edit-profile bio", profile.get("about") == "I build with Python")
check("skills derived from bio", profile.get("skills") == "I build with Python")
notifs = api.notifs()
check("notif-feed shape", "unread" in notifs and "items" in notifs)
deleted = api.delete_rant(rant_id)
check("delete rant", deleted.get("success"))
bad = DevRant(BASE, username, "wrongpw")
try:
bad.login()
check("bad login rejected", False)
except RuntimeError:
check("bad login rejected", True)
print(f"\n{passed} passed, {failed} failed")
raise SystemExit(1 if failed else 0)
if __name__ == "__main__":
main()