|
from devplacepy.constants import TOPICS, REACTION_EMOJI
|
|
|
|
VOTE_TARGETS = ["post", "comment", "gist", "project"]
|
|
REACTION_TARGETS = ["post", "comment", "gist", "project"]
|
|
BOOKMARK_TARGETS = ["post", "gist", "project", "news"]
|
|
COMMENT_TARGETS = ["post", "project", "news", "bug", "gist"]
|
|
PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
|
|
GIST_LANGUAGES = [
|
|
"python", "javascript", "typescript", "html", "css", "c", "cpp", "java",
|
|
"go", "rust", "sql", "bash", "json", "markdown", "plaintext",
|
|
]
|
|
SERVICE_ACTIONS = [
|
|
("start", "Start the service"),
|
|
("stop", "Stop the service"),
|
|
("run", "Trigger a single run now"),
|
|
("clear-logs", "Clear the in-memory log buffer"),
|
|
]
|
|
|
|
|
|
def field(name, location, type="string", required=False, example="", description="", options=None):
|
|
spec = {
|
|
"name": name,
|
|
"location": location,
|
|
"type": type,
|
|
"required": required,
|
|
"example": example,
|
|
"description": description,
|
|
}
|
|
if options:
|
|
spec["options"] = list(options)
|
|
return spec
|
|
|
|
|
|
ROLE_LABELS = {"public": "Public", "user": "Member", "admin": "Admin"}
|
|
|
|
|
|
def endpoint(id, method, path, title, summary, auth="user", ajax=False,
|
|
encoding="none", interactive=True, destructive=False,
|
|
params=None, notes=None, sample_response=None, negotiation=None):
|
|
return {
|
|
"id": id,
|
|
"method": method,
|
|
"path": path,
|
|
"title": title,
|
|
"summary": summary,
|
|
"auth": auth,
|
|
"min_role": ROLE_LABELS.get(auth, auth.title()),
|
|
"ajax": ajax,
|
|
"encoding": encoding,
|
|
"interactive": interactive,
|
|
"destructive": destructive,
|
|
"params": params or [],
|
|
"notes": notes or [],
|
|
"sample_response": sample_response,
|
|
"negotiation": negotiation,
|
|
}
|
|
|
|
|
|
NON_BODY_ENDPOINTS = {"avatar", "gateway-passthrough", "notifications-open"}
|
|
|
|
|
|
def _classify(ep):
|
|
if ep["id"] in _PAGE_RESPONSES or ep["id"] in _ACTION_RESPONSES:
|
|
return "negotiable"
|
|
if ep.get("ajax"):
|
|
return "ajax"
|
|
if ep["id"] in NON_BODY_ENDPOINTS:
|
|
return "none"
|
|
return "json"
|
|
|
|
|
|
def _service_control_endpoints():
|
|
endpoints = []
|
|
for action, summary in SERVICE_ACTIONS:
|
|
endpoints.append(endpoint(
|
|
id=f"services-{action.replace('-', '')}",
|
|
method="POST",
|
|
path=f"/admin/services/{{name}}/{action}",
|
|
title=f"Service: {action}",
|
|
summary=summary + ".",
|
|
auth="admin",
|
|
interactive=True,
|
|
destructive=True,
|
|
params=[field("name", "path", required=True, example="news", description="Registered service name.")],
|
|
sample_response={"ok": True},
|
|
))
|
|
return endpoints
|
|
|
|
|
|
API_GROUPS = [
|
|
{
|
|
"slug": "conventions",
|
|
"title": "Conventions & Errors",
|
|
"intro": """
|
|
# Conventions & Errors
|
|
|
|
Shared rules that apply to every endpoint in this reference.
|
|
|
|
## Base URL
|
|
|
|
Every example uses your current host:
|
|
|
|
```
|
|
{{ base }}
|
|
```
|
|
|
|
## Authentication
|
|
|
|
Each endpoint is tagged **public**, **user**, or **admin**. Authenticate user and admin
|
|
endpoints with any of the four methods in [Authentication](/docs/authentication.html): the
|
|
`session` cookie, an `X-API-KEY` header, a `Bearer` token, or HTTP Basic credentials. The
|
|
interactive panels on this site pre-fill your own API key, so you can run user-level calls
|
|
immediately.
|
|
|
|
## Request bodies
|
|
|
|
POST endpoints accept `application/x-www-form-urlencoded` form fields (the same fields the
|
|
website submits). File uploads use `multipart/form-data`. A small number of endpoints accept
|
|
a JSON body; those are noted explicitly.
|
|
|
|
## HTML or JSON (content negotiation)
|
|
|
|
**Every** endpoint that renders a page or returns a redirect can also answer in JSON - the
|
|
website keeps working exactly as before, and automation gets structured data from the same
|
|
URLs. A request is served JSON when it sends either of:
|
|
|
|
- `Accept: application/json`
|
|
- `Content-Type: application/json`
|
|
|
|
A normal browser navigation (`Accept: text/html`) always receives HTML, so nothing existing
|
|
changes. Responses are defined by Pydantic models, so each page returns the same data the
|
|
template renders.
|
|
|
|
**Page reads** (GET) return the page payload as a JSON object (lists include a `next_cursor`
|
|
for pagination; detail pages embed author, comments, reactions, poll, and attachments).
|
|
|
|
**Actions** (the form POSTs: create / edit / delete / follow / send / mark-read …) return a
|
|
uniform envelope instead of a `302` redirect:
|
|
|
|
```json
|
|
{ "ok": true, "redirect": "/posts/abc-my-post", "data": { "uid": "…", "slug": "…", "url": "…" } }
|
|
```
|
|
|
|
`data` carries the created/affected resource where applicable, or `null`. Cookies (e.g. the
|
|
session set on login/signup) are still set on JSON responses.
|
|
|
|
**Errors** are JSON too when JSON is requested:
|
|
|
|
```json
|
|
{ "error": { "status": 404, "message": "Not found" } }
|
|
```
|
|
|
|
Validation failures return `422` with `{ "error": "validation", "fields": { "field": ["msg"] } }`.
|
|
Unauthenticated JSON requests to a protected endpoint return `401` (browsers are redirected to
|
|
the login page instead); non-admins calling an admin endpoint get `403`.
|
|
|
|
## Trying it here
|
|
|
|
Every endpoint below has a live panel. Pick the response format (**JSON** by default, or
|
|
**HTML** where the endpoint negotiates) and the panel sets the matching `Accept` header on the
|
|
request and the generated cURL/JavaScript/Python snippets. The **Expected** tab always shows the
|
|
modeled response shape; the **Live response** tab shows the real result after you press
|
|
**Send request**.
|
|
|
|
## AJAX responses (legacy shape)
|
|
|
|
The [Votes, Reactions, Bookmarks & Polls](/docs/social-actions.html) endpoints predate the
|
|
envelope and keep their original flat JSON shapes (e.g. `{ "saved": true }`). They return JSON
|
|
when the request carries an `X-Requested-With: fetch` header (a subset of the rule above);
|
|
without it they `302` redirect, mirroring the browser flow. The interactive panels send the
|
|
header for you.
|
|
|
|
## Pagination
|
|
|
|
Most list endpoints page with an opaque cursor. Pass the `before` query parameter set to the
|
|
`created_at` (or `synced_at`) value of the last item you received to fetch the next page; the
|
|
JSON payload returns a `next_cursor` to use as the next `before`. This covers the feed, the
|
|
post/project/gist/news lists, notifications, and saved bookmarks.
|
|
|
|
The follower and following lists are the exception: `GET /profile/{username}/followers` and
|
|
`GET /profile/{username}/following` use classic page-based pagination via the `page` query
|
|
parameter (25 per page), not a cursor.
|
|
|
|
## Identifiers
|
|
|
|
Posts, projects, gists, and news articles accept either their slug or their bare UUID in the
|
|
path. Slugs embed the first eight characters of the UUID.
|
|
|
|
## Dates
|
|
|
|
All dates rendered to users are `DD/MM/YYYY`. Timestamps in stored records are ISO-8601 UTC.
|
|
|
|
## Status codes
|
|
|
|
| Code | Meaning |
|
|
|------|---------|
|
|
| `200` | Success (JSON or HTML) |
|
|
| `201` | Resource created (uploads) |
|
|
| `302` | Redirect (browser-style success for form posts) |
|
|
| `400` | Invalid request body or parameters |
|
|
| `401` | Credentials supplied but invalid |
|
|
| `403` | Authenticated but not allowed |
|
|
| `404` | Resource not found |
|
|
| `413` | Upload exceeds the configured size limit (see [Uploads](/docs/uploads.html)) |
|
|
| `415` | Upload file type not allowed (see [Uploads](/docs/uploads.html)) |
|
|
| `422` | Form/body validation failed (JSON clients) |
|
|
| `429` | Rate limit exceeded (see Rate limiting) |
|
|
| `503` | Maintenance mode |
|
|
|
|
## Rate limiting
|
|
|
|
Mutating requests (`POST`/`PUT`/`DELETE`/`PATCH`) are rate limited per client IP over a
|
|
rolling window; reads are not limited. The limit and window are configurable by an
|
|
administrator (defaults: 60 requests per 60 seconds). When you exceed the limit you receive a
|
|
`429` whose `Retry-After` header gives the number of seconds to wait before retrying. The
|
|
OpenAI gateway (`/openai/...`) is exempt.
|
|
|
|
## Troubleshooting
|
|
|
|
**I get HTML back instead of JSON.** Send `Accept: application/json` (or
|
|
`Content-Type: application/json` on a body). A request is only served JSON when it asks for it
|
|
and does not also accept `text/html`; a normal browser navigation always gets HTML.
|
|
`X-Requested-With: fetch` is **not** a general JSON switch - it only applies to the legacy
|
|
engagement actions (votes, reactions, bookmarks, polls).
|
|
|
|
**An action returns `302` instead of the JSON envelope.** Same cause: the request did not ask
|
|
for JSON. Add the `Accept: application/json` header and the action returns
|
|
`{ "ok": true, "redirect": "...", "data": {...} }` instead of redirecting.
|
|
|
|
**A protected endpoint redirects me to the login page.** Browser-style (HTML) requests to a
|
|
`user`/`admin` endpoint without valid credentials are redirected to login; the same request
|
|
with `Accept: application/json` returns `401` instead. Non-admins calling an `admin` endpoint
|
|
get `403` (JSON) or a redirect to the feed (HTML).
|
|
|
|
**An upload is rejected with `413` or `415`.** `413` means the file exceeds the configured
|
|
size limit; `415` means the file type is not in the allowed list. Both limits are set by an
|
|
administrator.
|
|
""",
|
|
"endpoints": [],
|
|
},
|
|
{
|
|
"slug": "lookups",
|
|
"title": "Search & Lookups",
|
|
"intro": """
|
|
# Search & Lookups
|
|
|
|
Type-ahead lookups that power mentions and the message composer. Both return JSON and accept
|
|
a single `q` query parameter. These feed [Messaging](/docs/messaging.html) (the recipient
|
|
composer) and [Profiles & Social Graph](/docs/profiles.html) (mentions and user pages).
|
|
|
|
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
|
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
|
four ways to sign requests.
|
|
""",
|
|
"endpoints": [
|
|
endpoint(
|
|
id="search-users",
|
|
method="GET",
|
|
path="/profile/search",
|
|
title="Search users",
|
|
summary="Find up to ten users whose username matches a query.",
|
|
auth="user",
|
|
params=[field("q", "query", required=True, example="al", description="Partial username to match.")],
|
|
sample_response={"results": [{"uid": "8f14e45f-...", "username": "alice_test"}]},
|
|
),
|
|
endpoint(
|
|
id="search-message-recipients",
|
|
method="GET",
|
|
path="/messages/search",
|
|
title="Search message recipients",
|
|
summary="Like user search, but excludes yourself; used by the message composer.",
|
|
auth="user",
|
|
params=[field("q", "query", required=True, example="bo", description="Partial username to match.")],
|
|
sample_response={"results": [{"uid": "0cc175b9-...", "username": "bob_test"}]},
|
|
),
|
|
],
|
|
},
|
|
{
|
|
"slug": "social-actions",
|
|
"title": "Votes, Reactions, Bookmarks & Polls",
|
|
"intro": """
|
|
# Votes, Reactions, Bookmarks & Polls
|
|
|
|
Lightweight engagement actions. The POST endpoints here are **toggles** - sending the same
|
|
action again removes it. They return JSON when called with `X-Requested-With: fetch` (sent
|
|
automatically by the panels below); the [Conventions & Errors](/docs/conventions.html) page
|
|
explains that header rule and the response envelope.
|
|
|
|
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
|
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
|
four ways to sign requests.
|
|
""",
|
|
"endpoints": [
|
|
endpoint(
|
|
id="votes-cast",
|
|
method="POST",
|
|
path="/votes/{target_type}/{target_uid}",
|
|
title="Cast or toggle a vote",
|
|
summary="Upvote or downvote a target. Re-sending the same value removes the vote.",
|
|
auth="user",
|
|
ajax=True,
|
|
encoding="form",
|
|
params=[
|
|
field("target_type", "path", "enum", True, "post", "Type of content being voted on.", VOTE_TARGETS),
|
|
field("target_uid", "path", "string", True, "POST_UID", "UID of the target."),
|
|
field("value", "form", "enum", True, "1", "1 to upvote, -1 to downvote.", ["1", "-1"]),
|
|
],
|
|
sample_response={"net": 3, "up": 4, "down": 1, "value": 1},
|
|
),
|
|
endpoint(
|
|
id="reactions-toggle",
|
|
method="POST",
|
|
path="/reactions/{target_type}/{target_uid}",
|
|
title="Toggle an emoji reaction",
|
|
summary="Add or remove an emoji reaction on a target.",
|
|
auth="user",
|
|
ajax=True,
|
|
encoding="form",
|
|
params=[
|
|
field("target_type", "path", "enum", True, "post", "Type of content being reacted to.", REACTION_TARGETS),
|
|
field("target_uid", "path", "string", True, "POST_UID", "UID of the target."),
|
|
field("emoji", "form", "enum", True, REACTION_EMOJI[0], "One of the allowed reaction emoji.", REACTION_EMOJI),
|
|
],
|
|
sample_response={"counts": {REACTION_EMOJI[0]: 2}, "mine": [REACTION_EMOJI[0]]},
|
|
),
|
|
endpoint(
|
|
id="bookmarks-toggle",
|
|
method="POST",
|
|
path="/bookmarks/{target_type}/{target_uid}",
|
|
title="Toggle a bookmark",
|
|
summary="Save or unsave a target to your bookmarks.",
|
|
auth="user",
|
|
ajax=True,
|
|
encoding="none",
|
|
params=[
|
|
field("target_type", "path", "enum", True, "post", "Type of content to bookmark.", BOOKMARK_TARGETS),
|
|
field("target_uid", "path", "string", True, "POST_UID", "UID of the target."),
|
|
],
|
|
sample_response={"saved": True},
|
|
),
|
|
endpoint(
|
|
id="bookmarks-saved",
|
|
method="GET",
|
|
path="/bookmarks/saved",
|
|
title="View saved bookmarks",
|
|
summary="Render your saved content. Returns an HTML page.",
|
|
auth="user",
|
|
interactive=True,
|
|
params=[field("before", "query", "string", False, "", "Pagination cursor (created_at of the last item).")],
|
|
notes=["Bookmarks target posts, projects, gists, and news; see [Posts, Comments, Projects, Gists & News](/docs/content.html)."],
|
|
),
|
|
endpoint(
|
|
id="polls-vote",
|
|
method="POST",
|
|
path="/polls/{poll_uid}/vote",
|
|
title="Vote in a poll",
|
|
summary="Cast, change, or clear your vote on a poll option.",
|
|
auth="user",
|
|
ajax=True,
|
|
encoding="form",
|
|
params=[
|
|
field("poll_uid", "path", "string", True, "POLL_UID", "UID of the poll."),
|
|
field("option_uid", "form", "string", True, "OPTION_UID", "UID of the chosen option."),
|
|
],
|
|
notes=[
|
|
"You hold at most one vote per poll, and only your latest vote counts. "
|
|
"Voting a different option replaces your previous choice; voting your current "
|
|
"option again removes the vote.",
|
|
],
|
|
sample_response={"question": "Best editor?", "options": [{"uid": "OPTION_UID", "label": "Vim", "votes": 5}], "total": 5, "voted": "OPTION_UID"},
|
|
),
|
|
],
|
|
},
|
|
{
|
|
"slug": "content",
|
|
"title": "Posts, Comments, Projects, Gists & News",
|
|
"intro": """
|
|
# Posts, Comments, Projects, Gists & News
|
|
|
|
The core content types. Read endpoints render HTML pages; write endpoints accept form fields
|
|
and redirect to the new or updated resource. List fields such as `attachment_uids` are
|
|
repeated form keys - upload files first via [Uploads](/docs/uploads.html) and pass the returned
|
|
uids here. Engage with this content through [Votes, Reactions, Bookmarks & Polls](/docs/social-actions.html).
|
|
|
|
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
|
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
|
four ways to sign requests.
|
|
""",
|
|
"endpoints": [
|
|
endpoint(
|
|
id="feed-list",
|
|
method="GET",
|
|
path="/feed",
|
|
title="Browse the feed",
|
|
summary="The main post feed. Returns an HTML page.",
|
|
auth="public",
|
|
interactive=True,
|
|
params=[
|
|
field("tab", "query", "enum", False, "all", "Feed selector.", ["all", "trending", "following"]),
|
|
field("topic", "query", "enum", False, "", "Filter by topic.", TOPICS),
|
|
field("before", "query", "string", False, "", "Pagination cursor."),
|
|
],
|
|
),
|
|
endpoint(
|
|
id="posts-create",
|
|
method="POST",
|
|
path="/posts/create",
|
|
title="Create a post",
|
|
summary="Publish a post, optionally with a poll. Redirects to the new post.",
|
|
auth="user",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("content", "form", "textarea", True, "Posted from a script.", "Body, 10-2000 characters."),
|
|
field("title", "form", "string", False, "Hello", "Optional title, up to 500 characters."),
|
|
field("topic", "form", "enum", False, "random", "Post topic.", TOPICS),
|
|
field("project_uid", "form", "string", False, "", "Attach to a project."),
|
|
field("poll_question", "form", "string", False, "", "Optional poll question."),
|
|
field("poll_options", "form", "string", False, "", "Repeat the field for each poll option, or send a single newline- or comma-separated string (2-6 options)."),
|
|
],
|
|
notes=["Returns a `302` redirect to `/posts/{slug}` on success."],
|
|
),
|
|
endpoint(
|
|
id="posts-detail",
|
|
method="GET",
|
|
path="/posts/{post_slug}",
|
|
title="View a post",
|
|
summary="Render a post with comments. Returns an HTML page.",
|
|
auth="public",
|
|
interactive=True,
|
|
params=[field("post_slug", "path", "string", True, "POST_SLUG", "Slug or UID of the post.")],
|
|
),
|
|
endpoint(
|
|
id="posts-edit",
|
|
method="POST",
|
|
path="/posts/edit/{post_slug}",
|
|
title="Edit a post",
|
|
summary="Update a post you own, optionally adding a poll if it has none.",
|
|
auth="user",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("post_slug", "path", "string", True, "POST_SLUG", "Slug or UID of the post."),
|
|
field("content", "form", "textarea", True, "Updated body.", "Body, 10-2000 characters."),
|
|
field("title", "form", "string", False, "Updated title", "Optional title."),
|
|
field("topic", "form", "enum", False, "random", "Post topic.", TOPICS),
|
|
field("poll_question", "form", "string", False, "", "Optional poll question. Adds a poll only when the post has none."),
|
|
field("poll_options", "form", "string", False, "", "Repeat the field for each poll option, or send a single newline- or comma-separated string (2-6 options)."),
|
|
],
|
|
),
|
|
endpoint(
|
|
id="posts-delete",
|
|
method="POST",
|
|
path="/posts/delete/{post_slug}",
|
|
title="Delete a post",
|
|
summary="Delete a post you own, cascading its comments and votes.",
|
|
auth="user",
|
|
destructive=True,
|
|
params=[field("post_slug", "path", "string", True, "POST_SLUG", "Slug or UID of the post.")],
|
|
),
|
|
endpoint(
|
|
id="comments-create",
|
|
method="POST",
|
|
path="/comments/create",
|
|
title="Create a comment",
|
|
summary="Comment on any commentable target. Supports nested replies.",
|
|
auth="user",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("content", "form", "textarea", True, "Nice work.", "Body, 3-1000 characters."),
|
|
field("target_uid", "form", "string", False, "POST_UID", "UID of the target (or use post_uid)."),
|
|
field("post_uid", "form", "string", False, "", "Convenience alias for a post target."),
|
|
field("target_type", "form", "enum", False, "post", "Type of the target.", COMMENT_TARGETS),
|
|
field("parent_uid", "form", "string", False, "", "Parent comment UID for a reply."),
|
|
],
|
|
notes=["Either `target_uid` or `post_uid` is required."],
|
|
),
|
|
endpoint(
|
|
id="comments-delete",
|
|
method="POST",
|
|
path="/comments/delete/{comment_uid}",
|
|
title="Delete a comment",
|
|
summary="Delete a comment you own.",
|
|
auth="user",
|
|
destructive=True,
|
|
params=[field("comment_uid", "path", "string", True, "COMMENT_UID", "UID of the comment.")],
|
|
),
|
|
endpoint(
|
|
id="projects-list",
|
|
method="GET",
|
|
path="/projects",
|
|
title="Browse projects",
|
|
summary="List projects. Returns an HTML page.",
|
|
auth="public",
|
|
interactive=True,
|
|
params=[
|
|
field("tab", "query", "enum", False, "recent", "Sort selector.", ["recent", "popular", "released"]),
|
|
field("search", "query", "string", False, "", "Search title and description."),
|
|
field("project_type", "query", "enum", False, "", "Filter by type.", PROJECT_TYPES),
|
|
field("before", "query", "string", False, "", "Pagination cursor."),
|
|
],
|
|
),
|
|
endpoint(
|
|
id="projects-detail",
|
|
method="GET",
|
|
path="/projects/{project_slug}",
|
|
title="View a project",
|
|
summary="Render a project with comments. Returns an HTML page.",
|
|
auth="public",
|
|
interactive=True,
|
|
params=[field("project_slug", "path", "string", True, "PROJECT_SLUG", "Slug or UID of the project.")],
|
|
),
|
|
endpoint(
|
|
id="projects-create",
|
|
method="POST",
|
|
path="/projects/create",
|
|
title="Create a project",
|
|
summary="Publish a project. Redirects to the new project.",
|
|
auth="user",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("title", "form", "string", True, "My Project", "Title, 1-200 characters."),
|
|
field("description", "form", "textarea", True, "What it does.", "Description, 1-5000 characters."),
|
|
field("project_type", "form", "enum", False, "software", "Project type.", PROJECT_TYPES),
|
|
field("status", "form", "string", False, "In Development", "Free-form status label."),
|
|
field("platforms", "form", "string", False, "Linux, Web", "Comma-separated platforms."),
|
|
field("release_date", "form", "string", False, "31/12/2026", "Optional release date in DD/MM/YYYY format."),
|
|
field("demo_date", "form", "string", False, "31/12/2026", "Optional demo date in DD/MM/YYYY format."),
|
|
],
|
|
),
|
|
endpoint(
|
|
id="projects-delete",
|
|
method="POST",
|
|
path="/projects/delete/{project_slug}",
|
|
title="Delete a project",
|
|
summary="Delete a project you own.",
|
|
auth="user",
|
|
destructive=True,
|
|
params=[field("project_slug", "path", "string", True, "PROJECT_SLUG", "Slug or UID of the project.")],
|
|
),
|
|
endpoint(
|
|
id="projects-private",
|
|
method="POST",
|
|
path="/projects/{project_slug}/private",
|
|
title="Set project visibility",
|
|
summary="Mark a project you own private (only you and administrators can see it) or public. Send value=1 for private, value=0 for public.",
|
|
auth="user",
|
|
encoding="form",
|
|
params=[
|
|
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Slug or UID of the project."),
|
|
field("value", "form", "boolean", True, "1", "1 to make the project private, 0 to make it public."),
|
|
],
|
|
),
|
|
endpoint(
|
|
id="projects-readonly",
|
|
method="POST",
|
|
path="/projects/{project_slug}/readonly",
|
|
title="Set project read-only",
|
|
summary="Mark a project you own read-only so all of its files become immutable (no writes, edits, moves, deletes, or uploads succeed), or writable again. Send value=1 for read-only, value=0 for writable.",
|
|
auth="user",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Slug or UID of the project."),
|
|
field("value", "form", "boolean", True, "1", "1 to make the project read-only, 0 to make it writable."),
|
|
],
|
|
),
|
|
endpoint(
|
|
id="projects-zip",
|
|
method="POST",
|
|
path="/projects/{project_slug}/zip",
|
|
title="Queue a project zip",
|
|
summary="Start a background job that archives the whole project. Returns the job uid and status URL to poll.",
|
|
auth="public",
|
|
params=[field("project_slug", "path", "string", True, "PROJECT_SLUG", "Slug or UID of the project.")],
|
|
sample_response={"uid": "ZIP_JOB_UID", "status_url": "/zips/ZIP_JOB_UID"},
|
|
),
|
|
endpoint(
|
|
id="zips-status",
|
|
method="GET",
|
|
path="/zips/{uid}",
|
|
title="Zip job status",
|
|
summary="Poll a zip job. While pending or running download_url is null; once done it points at the archive.",
|
|
auth="public",
|
|
params=[field("uid", "path", "string", True, "ZIP_JOB_UID", "Zip job uid returned when the job was queued.")],
|
|
sample_response={
|
|
"uid": "ZIP_JOB_UID",
|
|
"kind": "zip",
|
|
"status": "done",
|
|
"preferred_name": "my-project",
|
|
"download_url": "/zips/ZIP_JOB_UID/download",
|
|
"error": None,
|
|
"bytes_in": 20480,
|
|
"bytes_out": 8192,
|
|
"item_count": 12,
|
|
"file_count": 10,
|
|
"dir_count": 2,
|
|
"created_at": "2026-06-09T10:00:00+00:00",
|
|
"completed_at": "2026-06-09T10:00:03+00:00",
|
|
},
|
|
),
|
|
endpoint(
|
|
id="zips-download",
|
|
method="GET",
|
|
path="/zips/{uid}/download",
|
|
title="Download a zip archive",
|
|
summary="Stream the finished archive as application/zip. Each access extends the retention window.",
|
|
auth="public",
|
|
interactive=True,
|
|
params=[field("uid", "path", "string", True, "ZIP_JOB_UID", "Zip job uid of a finished job.")],
|
|
),
|
|
endpoint(
|
|
id="gists-list",
|
|
method="GET",
|
|
path="/gists",
|
|
title="Browse gists",
|
|
summary="List code gists. Returns an HTML page.",
|
|
auth="public",
|
|
interactive=True,
|
|
params=[
|
|
field("language", "query", "enum", False, "", "Filter by language.", GIST_LANGUAGES),
|
|
field("user_uid", "query", "string", False, "", "Filter by author UID."),
|
|
field("before", "query", "string", False, "", "Pagination cursor."),
|
|
],
|
|
),
|
|
endpoint(
|
|
id="gists-detail",
|
|
method="GET",
|
|
path="/gists/{gist_slug}",
|
|
title="View a gist",
|
|
summary="Render a gist with comments. Returns an HTML page.",
|
|
auth="public",
|
|
interactive=True,
|
|
params=[field("gist_slug", "path", "string", True, "GIST_SLUG", "Slug or UID of the gist.")],
|
|
),
|
|
endpoint(
|
|
id="gists-create",
|
|
method="POST",
|
|
path="/gists/create",
|
|
title="Create a gist",
|
|
summary="Publish a code snippet. Redirects to the new gist.",
|
|
auth="user",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("title", "form", "string", True, "Quick sort", "Title, 1-200 characters."),
|
|
field("source_code", "form", "textarea", True, "print('hello')", "Source, 1-400000 characters."),
|
|
field("language", "form", "enum", False, "python", "Syntax language.", GIST_LANGUAGES),
|
|
field("description", "form", "string", False, "", "Optional description."),
|
|
],
|
|
),
|
|
endpoint(
|
|
id="gists-edit",
|
|
method="POST",
|
|
path="/gists/edit/{gist_slug}",
|
|
title="Edit a gist",
|
|
summary="Update a gist you own.",
|
|
auth="user",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("gist_slug", "path", "string", True, "GIST_SLUG", "Slug or UID of the gist."),
|
|
field("title", "form", "string", True, "Quick sort", "Title, 1-200 characters."),
|
|
field("source_code", "form", "textarea", True, "print('hi')", "Source, 1-400000 characters."),
|
|
field("language", "form", "enum", False, "python", "Syntax language.", GIST_LANGUAGES),
|
|
field("description", "form", "string", False, "", "Optional description."),
|
|
],
|
|
),
|
|
endpoint(
|
|
id="gists-delete",
|
|
method="POST",
|
|
path="/gists/delete/{gist_slug}",
|
|
title="Delete a gist",
|
|
summary="Delete a gist you own.",
|
|
auth="user",
|
|
destructive=True,
|
|
params=[field("gist_slug", "path", "string", True, "GIST_SLUG", "Slug or UID of the gist.")],
|
|
),
|
|
endpoint(
|
|
id="news-list",
|
|
method="GET",
|
|
path="/news",
|
|
title="Browse news",
|
|
summary="Curated developer news. Returns an HTML page.",
|
|
auth="public",
|
|
interactive=True,
|
|
params=[field("before", "query", "string", False, "", "Pagination cursor (synced_at).")],
|
|
),
|
|
endpoint(
|
|
id="news-detail",
|
|
method="GET",
|
|
path="/news/{news_slug}",
|
|
title="View a news article",
|
|
summary="Render a news article with comments. Returns an HTML page.",
|
|
auth="public",
|
|
interactive=True,
|
|
params=[field("news_slug", "path", "string", True, "NEWS_SLUG", "Slug or UID of the article.")],
|
|
),
|
|
],
|
|
},
|
|
{
|
|
"slug": "profiles",
|
|
"title": "Profiles & Social Graph",
|
|
"intro": """
|
|
# Profiles & Social Graph
|
|
|
|
Profile data, the follow graph, the leaderboard, and avatar generation. Find users with
|
|
[Search & Lookups](/docs/lookups.html); follows generate entries in [Notifications](/docs/notifications.html).
|
|
|
|
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
|
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
|
four ways to sign requests.
|
|
""",
|
|
"endpoints": [
|
|
endpoint(
|
|
id="profile-detail",
|
|
method="GET",
|
|
path="/profile/{username}",
|
|
title="View a profile",
|
|
summary="Render a user profile. Returns an HTML page.",
|
|
auth="public",
|
|
interactive=True,
|
|
params=[
|
|
field("username", "path", "string", True, "{{ username }}", "Target username."),
|
|
field("tab", "query", "enum", False, "posts", "Profile tab.", ["posts", "activity", "followers", "following"]),
|
|
],
|
|
),
|
|
endpoint(
|
|
id="profile-update",
|
|
method="POST",
|
|
path="/profile/update",
|
|
title="Update your profile",
|
|
summary="Update your own bio and links.",
|
|
auth="user",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("bio", "form", "textarea", False, "Building things.", "Bio, up to 500 characters."),
|
|
field("location", "form", "string", False, "Earth", "Location, up to 200 characters."),
|
|
field("git_link", "form", "string", False, "", "Git profile URL."),
|
|
field("website", "form", "string", False, "", "Personal website URL."),
|
|
],
|
|
),
|
|
endpoint(
|
|
id="profile-regenerate-key",
|
|
method="POST",
|
|
path="/profile/regenerate-api-key",
|
|
title="Regenerate your API key",
|
|
summary="Issue a new API key and invalidate the current one.",
|
|
auth="user",
|
|
interactive=False,
|
|
destructive=True,
|
|
notes=[
|
|
"> Running this invalidates the key these documentation panels use. Do it from your "
|
|
"[profile page](/profile/{{ username }}) instead, then reload these docs.",
|
|
],
|
|
sample_response={"api_key": "NEW_UUID"},
|
|
),
|
|
endpoint(
|
|
id="follow-user",
|
|
method="POST",
|
|
path="/follow/{username}",
|
|
title="Follow a user",
|
|
summary="Follow another user. Idempotent.",
|
|
auth="user",
|
|
destructive=True,
|
|
params=[field("username", "path", "string", True, "bob_test", "Username to follow.")],
|
|
),
|
|
endpoint(
|
|
id="unfollow-user",
|
|
method="POST",
|
|
path="/follow/unfollow/{username}",
|
|
title="Unfollow a user",
|
|
summary="Stop following a user.",
|
|
auth="user",
|
|
destructive=True,
|
|
params=[field("username", "path", "string", True, "bob_test", "Username to unfollow.")],
|
|
),
|
|
endpoint(
|
|
id="list-followers",
|
|
method="GET",
|
|
path="/profile/{username}/followers",
|
|
title="List followers",
|
|
summary="List the users who follow a profile, 25 per page. Returns JSON.",
|
|
auth="public",
|
|
interactive=True,
|
|
params=[
|
|
field("username", "path", "string", True, "{{ username }}", "Target username."),
|
|
field("page", "query", "integer", False, "1", "Page number, 25 per page."),
|
|
],
|
|
sample_response={
|
|
"username": "{{ username }}",
|
|
"mode": "followers",
|
|
"count": 2,
|
|
"page": 1,
|
|
"total_pages": 1,
|
|
"followers": [
|
|
{"uid": "UUID", "username": "bob_test", "bio": "Building things.", "is_following": False},
|
|
],
|
|
},
|
|
),
|
|
endpoint(
|
|
id="list-following",
|
|
method="GET",
|
|
path="/profile/{username}/following",
|
|
title="List following",
|
|
summary="List the users a profile follows, 25 per page. Returns JSON.",
|
|
auth="public",
|
|
interactive=True,
|
|
params=[
|
|
field("username", "path", "string", True, "{{ username }}", "Target username."),
|
|
field("page", "query", "integer", False, "1", "Page number, 25 per page."),
|
|
],
|
|
sample_response={
|
|
"username": "{{ username }}",
|
|
"mode": "following",
|
|
"count": 1,
|
|
"page": 1,
|
|
"total_pages": 1,
|
|
"following": [
|
|
{"uid": "UUID", "username": "alice_test", "bio": "", "is_following": True},
|
|
],
|
|
},
|
|
),
|
|
endpoint(
|
|
id="leaderboard",
|
|
method="GET",
|
|
path="/leaderboard",
|
|
title="View the leaderboard",
|
|
summary="Top contributors by stars. Returns an HTML page.",
|
|
auth="public",
|
|
interactive=True,
|
|
),
|
|
endpoint(
|
|
id="avatar",
|
|
method="GET",
|
|
path="/avatar/{style}/{seed}",
|
|
title="Generate an avatar",
|
|
summary="Deterministic SVG avatar for a seed. Returns an image.",
|
|
auth="public",
|
|
interactive=False,
|
|
params=[
|
|
field("style", "path", "enum", True, "multiavatar", "Avatar style.", ["multiavatar"]),
|
|
field("seed", "path", "string", True, "{{ username }}", "Seed string, usually a username."),
|
|
field("size", "query", "int", False, "128", "Pixel size."),
|
|
],
|
|
),
|
|
],
|
|
},
|
|
{
|
|
"slug": "messaging",
|
|
"title": "Messaging",
|
|
"intro": """
|
|
# Messaging
|
|
|
|
Direct messages between users. The inbox renders HTML; sending uses form fields. Look up
|
|
recipients with [Search & Lookups](/docs/lookups.html) and attach files via [Uploads](/docs/uploads.html).
|
|
|
|
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
|
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
|
four ways to sign requests.
|
|
""",
|
|
"endpoints": [
|
|
endpoint(
|
|
id="messages-inbox",
|
|
method="GET",
|
|
path="/messages",
|
|
title="Open the inbox",
|
|
summary="Render conversations. Returns an HTML page.",
|
|
auth="user",
|
|
interactive=True,
|
|
params=[
|
|
field("with_uid", "query", "string", False, "", "Open a specific conversation by user UID."),
|
|
field("search", "query", "string", False, "", "Jump to a conversation by username."),
|
|
],
|
|
),
|
|
endpoint(
|
|
id="messages-send",
|
|
method="POST",
|
|
path="/messages/send",
|
|
title="Send a message",
|
|
summary="Send a direct message to a user.",
|
|
auth="user",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("content", "form", "textarea", True, "Hello there.", "Body, 1-2000 characters."),
|
|
field("receiver_uid", "form", "string", True, "RECEIVER_UID", "Recipient user UID."),
|
|
],
|
|
),
|
|
],
|
|
},
|
|
{
|
|
"slug": "notifications",
|
|
"title": "Notifications",
|
|
"intro": """
|
|
# Notifications
|
|
|
|
Read your notification feed and mark items read. The unread counts endpoint backs the badges
|
|
in the navigation bar. Deliver these to the browser with [Web Push](/docs/push.html).
|
|
|
|
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
|
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
|
four ways to sign requests.
|
|
""",
|
|
"endpoints": [
|
|
endpoint(
|
|
id="notifications-list",
|
|
method="GET",
|
|
path="/notifications",
|
|
title="View notifications",
|
|
summary="Render your notifications. Returns an HTML page.",
|
|
auth="user",
|
|
interactive=True,
|
|
params=[field("before", "query", "string", False, "", "Pagination cursor.")],
|
|
),
|
|
endpoint(
|
|
id="notifications-counts",
|
|
method="GET",
|
|
path="/notifications/counts",
|
|
title="Unread counts",
|
|
summary="Unread notification and message counts.",
|
|
auth="public",
|
|
notes=["Guests receive `{ \"notifications\": 0, \"messages\": 0 }` instead of an error, so the navigation badge works before login."],
|
|
sample_response={"notifications": 2, "messages": 1},
|
|
),
|
|
endpoint(
|
|
id="notifications-open",
|
|
method="GET",
|
|
path="/notifications/open/{notification_uid}",
|
|
title="Open a notification",
|
|
summary="Mark a notification read and redirect to its target.",
|
|
auth="user",
|
|
interactive=False,
|
|
params=[field("notification_uid", "path", "string", True, "NOTIFICATION_UID", "UID of the notification.")],
|
|
),
|
|
endpoint(
|
|
id="notifications-mark-read",
|
|
method="POST",
|
|
path="/notifications/mark-read/{notification_uid}",
|
|
title="Mark one read",
|
|
summary="Mark a single notification as read.",
|
|
auth="user",
|
|
params=[field("notification_uid", "path", "string", True, "NOTIFICATION_UID", "UID of the notification.")],
|
|
),
|
|
endpoint(
|
|
id="notifications-mark-all-read",
|
|
method="POST",
|
|
path="/notifications/mark-all-read",
|
|
title="Mark all read",
|
|
summary="Mark every notification as read.",
|
|
auth="user",
|
|
),
|
|
],
|
|
},
|
|
{
|
|
"slug": "uploads",
|
|
"title": "Uploads",
|
|
"intro": """
|
|
# Uploads
|
|
|
|
Attachment storage. Upload a file to receive an attachment record, then reference its `uid`
|
|
in an `attachment_uids` field when creating a post, comment, project, gist, message, or bug -
|
|
see [Posts, Comments, Projects, Gists & News](/docs/content.html). Images and videos embed and
|
|
play inline once posted; other types render as download links. The record's `is_image` and
|
|
`is_video` flags indicate how the file is displayed.
|
|
|
|
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
|
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
|
four ways to sign requests.
|
|
""",
|
|
"endpoints": [
|
|
endpoint(
|
|
id="uploads-upload",
|
|
method="POST",
|
|
path="/uploads/upload",
|
|
title="Upload a file",
|
|
summary="Store a file and return its attachment record.",
|
|
auth="user",
|
|
encoding="multipart",
|
|
params=[field("file", "form", "file", True, "", "The file to upload.")],
|
|
notes=[
|
|
"Allowed file types and the size limit are configured by administrators. Images and common video formats (mp4, webm, ogv, mov, m4v) are accepted by default.",
|
|
"Returns `201` on success, `413` if too large, `415` if the type is not allowed.",
|
|
],
|
|
sample_response={"uid": "ATTACHMENT_UID", "filename": "clip.mp4", "url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.mp4", "size": 20480, "is_image": False, "is_video": True, "mime_type": "video/mp4"},
|
|
),
|
|
endpoint(
|
|
id="uploads-delete",
|
|
method="DELETE",
|
|
path="/uploads/delete/{attachment_uid}",
|
|
title="Delete an attachment",
|
|
summary="Delete an attachment you own.",
|
|
auth="user",
|
|
destructive=True,
|
|
params=[field("attachment_uid", "path", "string", True, "ATTACHMENT_UID", "UID of the attachment.")],
|
|
sample_response={"status": "deleted"},
|
|
),
|
|
],
|
|
},
|
|
{
|
|
"slug": "project-files",
|
|
"title": "Project Filesystem",
|
|
"intro": """
|
|
# Project Filesystem
|
|
|
|
Each project carries a full virtual filesystem - directories and files - so a project can hold a
|
|
complete software project. Reading is public (anyone can browse a project's tree); creating,
|
|
editing, uploading, moving and deleting require the project owner. Text files are editable inline;
|
|
binary files are uploaded and served from `/static/uploads/project_files/...`.
|
|
|
|
Paths are relative POSIX paths inside the project (for example `src/main.py`). Parent directories
|
|
are created automatically on write, upload and mkdir. Paths containing `..`, null bytes or empty
|
|
segments are rejected.
|
|
|
|
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
|
negotiation, status codes); see [Authentication](/docs/authentication.html) for the four ways to
|
|
sign requests.
|
|
""",
|
|
"endpoints": [
|
|
endpoint(
|
|
id="project-files-list",
|
|
method="GET",
|
|
path="/projects/{project_slug}/files",
|
|
title="List a project's files",
|
|
summary="Return the flat list of files and directories in a project.",
|
|
auth="public",
|
|
params=[field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid.")],
|
|
sample_response={"project": {"uid": "PROJECT_UID", "slug": "PROJECT_SLUG"}, "files": [{"path": "src/main.py", "name": "main.py", "type": "file", "is_binary": False, "size": 42}], "is_owner": False},
|
|
),
|
|
endpoint(
|
|
id="project-files-raw",
|
|
method="GET",
|
|
path="/projects/{project_slug}/files/raw",
|
|
title="Read a project file",
|
|
summary="Return one file's metadata and (for text files) its content.",
|
|
auth="public",
|
|
params=[
|
|
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
|
field("path", "query", "string", True, "src/main.py", "Relative file path inside the project."),
|
|
],
|
|
sample_response={"path": "src/main.py", "name": "main.py", "type": "file", "is_binary": False, "mime_type": "text/plain", "size": 42, "url": None, "content": "print('hello')\n"},
|
|
),
|
|
endpoint(
|
|
id="project-files-write",
|
|
method="POST",
|
|
path="/projects/{project_slug}/files/write",
|
|
title="Write a text file",
|
|
summary="Create or overwrite a text file; parent directories are created automatically.",
|
|
auth="user",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
|
field("path", "form", "string", True, "src/main.py", "Relative file path."),
|
|
field("content", "form", "textarea", True, "print('hello')", "Full file content (max 400000 chars)."),
|
|
],
|
|
notes=["Owner only; non-owners get `403`. Invalid paths return `400`."],
|
|
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/main.py", "type": "file"}},
|
|
),
|
|
endpoint(
|
|
id="project-files-lines",
|
|
method="GET",
|
|
path="/projects/{project_slug}/files/lines",
|
|
title="Read a line range",
|
|
summary="Read a 1-indexed inclusive line range of a text file. Returns lines plus total_lines for targeting edits.",
|
|
auth="public",
|
|
params=[
|
|
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
|
field("path", "query", "string", True, "src/main.py", "Relative file path."),
|
|
field("start", "query", "integer", False, "1", "First line, 1-indexed (default 1)."),
|
|
field("end", "query", "integer", False, "50", "Last line inclusive; omit or -1 for end of file."),
|
|
],
|
|
notes=["Text files only; binary, directory, or missing paths return `404`."],
|
|
sample_response={"path": "src/main.py", "start": 1, "end": 2, "total_lines": 2, "lines": ["import os", "print(os.getcwd())"], "content": "import os\nprint(os.getcwd())"},
|
|
),
|
|
endpoint(
|
|
id="project-files-replace-lines",
|
|
method="POST",
|
|
path="/projects/{project_slug}/files/replace-lines",
|
|
title="Replace a line range",
|
|
summary="Replace lines start..end (inclusive) with new content; empty content deletes the range. Leaves the rest of the file untouched.",
|
|
auth="user",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
|
field("path", "form", "string", True, "src/main.py", "Relative file path."),
|
|
field("start", "form", "integer", True, "10", "First line to replace (1-indexed)."),
|
|
field("end", "form", "integer", True, "12", "Last line to replace (inclusive)."),
|
|
field("content", "form", "textarea", False, "new code", "Replacement text (empty deletes the range)."),
|
|
],
|
|
notes=["Owner only. The preferred way to edit a large file; avoids rewriting the whole file."],
|
|
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/main.py", "type": "file"}},
|
|
),
|
|
endpoint(
|
|
id="project-files-insert-lines",
|
|
method="POST",
|
|
path="/projects/{project_slug}/files/insert-lines",
|
|
title="Insert lines",
|
|
summary="Insert content before a 1-indexed line. Use at=1 to prepend and at=total_lines+1 to append.",
|
|
auth="user",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
|
field("path", "form", "string", True, "src/main.py", "Relative file path."),
|
|
field("at", "form", "integer", True, "1", "Insert before this 1-indexed line."),
|
|
field("content", "form", "textarea", True, "# header", "Text to insert."),
|
|
],
|
|
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/main.py", "type": "file"}},
|
|
),
|
|
endpoint(
|
|
id="project-files-delete-lines",
|
|
method="POST",
|
|
path="/projects/{project_slug}/files/delete-lines",
|
|
title="Delete a line range",
|
|
summary="Delete lines start..end (inclusive) from a text file.",
|
|
auth="user",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
|
field("path", "form", "string", True, "src/main.py", "Relative file path."),
|
|
field("start", "form", "integer", True, "5", "First line to delete (1-indexed)."),
|
|
field("end", "form", "integer", True, "7", "Last line to delete (inclusive)."),
|
|
],
|
|
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/main.py", "type": "file"}},
|
|
),
|
|
endpoint(
|
|
id="project-files-append",
|
|
method="POST",
|
|
path="/projects/{project_slug}/files/append",
|
|
title="Append to a file",
|
|
summary="Append content as new lines at the end of a text file; grow a large file across calls without resending it.",
|
|
auth="user",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
|
field("path", "form", "string", True, "log.txt", "Relative file path."),
|
|
field("content", "form", "textarea", True, "next chunk", "Text to append."),
|
|
],
|
|
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "log.txt", "type": "file"}},
|
|
),
|
|
endpoint(
|
|
id="project-files-upload",
|
|
method="POST",
|
|
path="/projects/{project_slug}/files/upload",
|
|
title="Upload a file into a project",
|
|
summary="Upload a file into a directory (parents created); text decodes to an editable file, otherwise stored as binary.",
|
|
auth="user",
|
|
encoding="multipart",
|
|
destructive=True,
|
|
params=[
|
|
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
|
field("file", "form", "file", True, "", "The file to upload."),
|
|
field("path", "form", "string", False, "assets", "Target directory, empty for the root."),
|
|
],
|
|
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "assets/logo.png", "type": "file", "is_binary": True}},
|
|
),
|
|
endpoint(
|
|
id="project-files-mkdir",
|
|
method="POST",
|
|
path="/projects/{project_slug}/files/mkdir",
|
|
title="Create a directory",
|
|
summary="Create a directory and any missing parents.",
|
|
auth="user",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
|
field("path", "form", "string", True, "src/components", "Relative directory path."),
|
|
],
|
|
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/components", "type": "dir"}},
|
|
),
|
|
endpoint(
|
|
id="project-files-move",
|
|
method="POST",
|
|
path="/projects/{project_slug}/files/move",
|
|
title="Move or rename",
|
|
summary="Move or rename a file or directory (and its descendants).",
|
|
auth="user",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
|
field("from_path", "form", "string", True, "src/old.py", "Existing path."),
|
|
field("to_path", "form", "string", True, "src/new.py", "New path."),
|
|
],
|
|
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/new.py"}},
|
|
),
|
|
endpoint(
|
|
id="project-files-delete",
|
|
method="POST",
|
|
path="/projects/{project_slug}/files/delete",
|
|
title="Delete a file or directory",
|
|
summary="Delete a file, or a directory and everything under it.",
|
|
auth="user",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
|
field("path", "form", "string", True, "src/old.py", "Relative path to delete."),
|
|
],
|
|
sample_response={"ok": True, "redirect": "/projects/PROJECT_SLUG/files", "data": {"path": "src/old.py"}},
|
|
),
|
|
endpoint(
|
|
id="project-files-zip",
|
|
method="POST",
|
|
path="/projects/{project_slug}/files/zip",
|
|
title="Queue a zip of files",
|
|
summary="Archive the whole tree, or a subtree via the path query. Returns the job uid and status URL to poll with /zips/{uid}.",
|
|
auth="public",
|
|
params=[
|
|
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
|
field("path", "query", "string", False, "src", "Relative file or directory to archive; empty for the whole project."),
|
|
],
|
|
sample_response={"uid": "ZIP_JOB_UID", "status_url": "/zips/ZIP_JOB_UID"},
|
|
),
|
|
],
|
|
},
|
|
{
|
|
"slug": "containers",
|
|
"title": "Container Manager",
|
|
"intro": """
|
|
# Container Manager
|
|
|
|
Build versioned Docker images for a project and run supervised container instances. Every endpoint is
|
|
**administrator only** (running arbitrary Dockerfiles with docker socket access is root-equivalent).
|
|
Mutations flip desired state; a single reconciler converges containers to it.
|
|
""",
|
|
"endpoints": [
|
|
endpoint(
|
|
id="containers-page", method="GET", path="/projects/{project_slug}/containers",
|
|
title="Container manager page", summary="The admin container manager UI for a project.",
|
|
auth="admin", interactive=True,
|
|
params=[field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid.")],
|
|
),
|
|
endpoint(
|
|
id="containers-create-dockerfile", method="POST",
|
|
path="/projects/{project_slug}/containers/dockerfiles",
|
|
title="Create a Dockerfile", summary="Create a Dockerfile and queue its first build (default template if content is empty).",
|
|
auth="admin", encoding="form", destructive=True,
|
|
params=[
|
|
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
|
field("name", "form", "string", True, "myapp", "Image name (lowercase)."),
|
|
field("description", "form", "string", False, "", "Optional description."),
|
|
field("tags", "form", "string", False, "", "Optional comma separated tags."),
|
|
field("content", "form", "textarea", False, "", "Optional Dockerfile content."),
|
|
],
|
|
sample_response={"ok": True, "data": {"dockerfile": {"uid": "DF_UID", "name": "myapp"}, "build_uid": "BUILD_UID"}},
|
|
),
|
|
endpoint(
|
|
id="containers-save-version", method="POST",
|
|
path="/projects/{project_slug}/containers/dockerfiles/{df_ref}/version",
|
|
title="Save a new version", summary="Create a new immutable version and auto-build if content changed.",
|
|
auth="admin", encoding="form", destructive=True,
|
|
params=[
|
|
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
|
field("df_ref", "path", "string", True, "DF_REF", "Dockerfile name, slug, or uid."),
|
|
field("content", "form", "textarea", True, "FROM python:3.13-slim", "New content."),
|
|
],
|
|
),
|
|
endpoint(
|
|
id="containers-build-status", method="GET",
|
|
path="/projects/{project_slug}/containers/builds/{uid}",
|
|
title="Build status", summary="Status, image tag, and stored logs for a build.",
|
|
auth="admin",
|
|
params=[
|
|
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
|
field("uid", "path", "string", True, "BUILD_UID", "Build uid."),
|
|
],
|
|
sample_response={"uid": "BUILD_UID", "image_tag": "myapp:1", "status": "success", "logs": "..."},
|
|
),
|
|
endpoint(
|
|
id="containers-create-instance", method="POST",
|
|
path="/projects/{project_slug}/containers/instances",
|
|
title="Create an instance", summary="Create and (by default) start an instance from a build; /app is mounted to the project workspace.",
|
|
auth="admin", encoding="form", destructive=True,
|
|
params=[
|
|
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
|
field("dockerfile_uid", "form", "string", True, "DF_UID", "Dockerfile uid."),
|
|
field("build_uid", "form", "string", False, "", "Specific build uid (defaults to latest success)."),
|
|
field("name", "form", "string", True, "staging", "Instance name."),
|
|
field("boot_command", "form", "string", False, "python app.py", "Optional boot command."),
|
|
field("env", "form", "textarea", False, "KEY=VALUE", "Env vars, one KEY=VALUE per line."),
|
|
field("ports", "form", "string", False, "80", "Port maps. Bare container port auto-assigns a unique host port above 20000; host:container pins one."),
|
|
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
|
|
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
|
|
field("restart_policy", "form", "enum", False, "never", "Restart policy.",
|
|
["never", "always", "on-failure", "unless-stopped"]),
|
|
field("ingress_slug", "form", "string", False, "my-service", "Publish at /p/<slug> (optional)."),
|
|
field("ingress_port", "form", "integer", False, "8899", "Container port to publish (must be a mapped port)."),
|
|
],
|
|
),
|
|
endpoint(
|
|
id="containers-ingress",
|
|
method="GET",
|
|
path="/p/{slug}",
|
|
title="Container ingress proxy",
|
|
summary="Public reverse proxy (HTTP and WebSocket) to a running instance published via ingress_slug. The /p/<slug> prefix is stripped before forwarding.",
|
|
auth="public",
|
|
interactive=True,
|
|
params=[field("slug", "path", "string", True, "my-service", "The instance's ingress_slug.")],
|
|
),
|
|
endpoint(
|
|
id="containers-instance-action", method="POST",
|
|
path="/projects/{project_slug}/containers/instances/{uid}/{action}",
|
|
title="Instance lifecycle", summary="start, stop, restart, pause, or resume an instance (flips desired state).",
|
|
auth="admin", destructive=True,
|
|
params=[
|
|
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
|
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
|
field("action", "path", "enum", True, "start", "Lifecycle action.",
|
|
["start", "stop", "restart", "pause", "resume"]),
|
|
],
|
|
),
|
|
endpoint(
|
|
id="containers-instance-logs", method="GET",
|
|
path="/projects/{project_slug}/containers/instances/{uid}/logs",
|
|
title="Instance logs", summary="Recent docker logs of a running instance.",
|
|
auth="admin",
|
|
params=[
|
|
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
|
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
|
field("tail", "query", "integer", False, "200", "Number of lines."),
|
|
],
|
|
sample_response={"logs": "..."},
|
|
),
|
|
endpoint(
|
|
id="containers-instance-sync", method="POST",
|
|
path="/projects/{project_slug}/containers/instances/{uid}/sync",
|
|
title="Sync workspace", summary="Import the container /app workspace back into the project files.",
|
|
auth="admin", destructive=True,
|
|
params=[
|
|
field("project_slug", "path", "string", True, "PROJECT_SLUG", "Project slug or uid."),
|
|
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
|
],
|
|
),
|
|
],
|
|
},
|
|
{
|
|
"slug": "push",
|
|
"title": "Web Push",
|
|
"intro": """
|
|
# Web Push
|
|
|
|
Browser push notifications via the Web Push protocol. Fetch the public VAPID key, then
|
|
register a `PushSubscription` obtained from the browser's `PushManager`.
|
|
|
|
There is no server-side unsubscribe endpoint: unsubscription is handled entirely in the
|
|
browser by calling `PushManager.unsubscribe()` on the subscription. The server stops delivering
|
|
to a subscription once its push endpoint reports it as gone. These mirror the in-app
|
|
[Notifications](/docs/notifications.html) feed.
|
|
|
|
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
|
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
|
four ways to sign requests.
|
|
""",
|
|
"endpoints": [
|
|
endpoint(
|
|
id="push-key",
|
|
method="GET",
|
|
path="/push.json",
|
|
title="Get the public key",
|
|
summary="Return the VAPID public key for subscribing.",
|
|
auth="public",
|
|
sample_response={"publicKey": "BASE64_VAPID_KEY"},
|
|
),
|
|
endpoint(
|
|
id="push-register",
|
|
method="POST",
|
|
path="/push.json",
|
|
title="Register a subscription",
|
|
summary="Register a browser push subscription. Sends a welcome notification.",
|
|
auth="user",
|
|
encoding="json",
|
|
interactive=False,
|
|
params=[
|
|
field("endpoint", "json", "string", True, "https://fcm.googleapis.com/...", "Subscription endpoint URL."),
|
|
field("keys", "json", "string", True, '{"p256dh":"...","auth":"..."}', "Subscription keys object."),
|
|
],
|
|
notes=["The body must be JSON: `{\"endpoint\": \"...\", \"keys\": {\"p256dh\": \"...\", \"auth\": \"...\"}}`."],
|
|
sample_response={"registered": True},
|
|
),
|
|
],
|
|
},
|
|
{
|
|
"slug": "bugs",
|
|
"title": "Bug Reports",
|
|
"intro": """
|
|
# Bug Reports
|
|
|
|
A public board for reporting issues. The list renders HTML; reporting uses form fields, and a
|
|
report can carry files uploaded via [Uploads](/docs/uploads.html).
|
|
|
|
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
|
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
|
four ways to sign requests.
|
|
""",
|
|
"endpoints": [
|
|
endpoint(
|
|
id="bugs-list",
|
|
method="GET",
|
|
path="/bugs",
|
|
title="View bug reports",
|
|
summary="Render the bug board. Returns an HTML page.",
|
|
auth="public",
|
|
interactive=True,
|
|
),
|
|
endpoint(
|
|
id="bugs-create",
|
|
method="POST",
|
|
path="/bugs/create",
|
|
title="Report a bug",
|
|
summary="File a bug report.",
|
|
auth="user",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("title", "form", "string", True, "Login button misaligned", "Title, 1-200 characters."),
|
|
field("description", "form", "textarea", True, "Steps to reproduce...", "Description, 1-5000 characters."),
|
|
],
|
|
),
|
|
],
|
|
},
|
|
{
|
|
"slug": "gateway",
|
|
"title": "OpenAI Gateway",
|
|
"admin": True,
|
|
"intro": """
|
|
# OpenAI Gateway
|
|
|
|
An OpenAI-compatible proxy mounted at `/openai/v1`. It forwards requests to a configured
|
|
upstream using the gateway's own credentials, so no DevPlace key is required - but an
|
|
administrator must enable the `openai` service first. Point any OpenAI-compatible client at
|
|
`{{ base }}/openai/v1`.
|
|
|
|
This gateway is the **single point of truth for AI** on the platform. Every other DevPlace
|
|
service (news, bots, Devii) calls it by default instead of an external provider, sends the
|
|
generic model name `molodetz`, and authenticates with an internal key that is auto-generated
|
|
on first boot. The real provider URLs, models, and keys (DeepSeek, OpenRouter) live only here,
|
|
so an operator switches providers or backends in one place. `DEEPSEEK_API_KEY` and
|
|
`OPENROUTER_API_KEY` are migrated into the editable settings on boot and the value in use is
|
|
shown. Because `Force model` is on by default, the upstream always receives the configured
|
|
model regardless of what a client (or `molodetz`) requests.
|
|
|
|
The gateway also performs **vision** augmentation: when a request includes an image, the gateway
|
|
describes the image with a configured vision model and rewrites it to text, so a vision-less
|
|
upstream still works. The vision model, URL, and key are configured alongside the other gateway
|
|
settings.
|
|
|
|
Administrators enable and configure this gateway under [Background Services](/docs/services.html)
|
|
(the `openai` service).
|
|
|
|
The gateway is exempt from rate limiting, but every other endpoint follows the shared
|
|
[Conventions & Errors](/docs/conventions.html); see [Authentication](/docs/authentication.html)
|
|
for signing DevPlace's own requests.
|
|
""",
|
|
"endpoints": [
|
|
endpoint(
|
|
id="gateway-chat",
|
|
method="POST",
|
|
path="/openai/v1/chat/completions",
|
|
title="Chat completions",
|
|
summary="OpenAI-compatible chat completion. Supports streaming.",
|
|
auth="user",
|
|
encoding="json",
|
|
params=[
|
|
field("model", "json", "string", False, "gpt-4o-mini", "Model id; the gateway may override it."),
|
|
field("messages", "json", "string", True, '[{"role":"user","content":"Hello"}]', "Chat messages array."),
|
|
field("stream", "json", "string", False, "false", "Set true for a streamed SSE response."),
|
|
],
|
|
notes=["Returns `503` when the gateway service is not running."],
|
|
),
|
|
endpoint(
|
|
id="gateway-passthrough",
|
|
method="POST",
|
|
path="/openai/v1/{path}",
|
|
title="Passthrough",
|
|
summary="Any other /v1 path is forwarded to the upstream as-is.",
|
|
auth="user",
|
|
interactive=False,
|
|
params=[field("path", "path", "string", True, "models", "Upstream API path after /v1/.")],
|
|
),
|
|
],
|
|
},
|
|
{
|
|
"slug": "services",
|
|
"title": "Background Services",
|
|
"admin": True,
|
|
"dynamic": True,
|
|
"intro": "",
|
|
"endpoints": [],
|
|
},
|
|
{
|
|
"slug": "admin",
|
|
"title": "Admin API",
|
|
"admin": True,
|
|
"intro": """
|
|
# Admin API
|
|
|
|
Site administration endpoints for users, news curation, and settings. Every call requires an
|
|
**admin** account. Background service management lives on the
|
|
[Background Services](/docs/services.html) page.
|
|
|
|
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
|
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
|
four ways to sign requests.
|
|
""",
|
|
"endpoints": [
|
|
endpoint(
|
|
id="admin-users",
|
|
method="GET",
|
|
path="/admin/users",
|
|
title="List users",
|
|
summary="Paginated user management page. Returns HTML.",
|
|
auth="admin",
|
|
interactive=True,
|
|
params=[field("page", "query", "int", False, "1", "Page number.")],
|
|
),
|
|
endpoint(
|
|
id="admin-analytics",
|
|
method="GET",
|
|
path="/admin/analytics",
|
|
title="Site analytics",
|
|
summary=(
|
|
"One-call aggregate analytics, returned as JSON: total members, active users "
|
|
"in the last 24h/7d/30d, users signed in now, new signups (24h/7d/30d), content "
|
|
"totals (posts, comments, gists, projects, news), and top authors. Use this "
|
|
"instead of paging the user list to count or measure activity."
|
|
),
|
|
auth="admin",
|
|
interactive=True,
|
|
params=[field("top_n", "query", "int", False, "10", "How many top authors to include (1-50).")],
|
|
notes=["This is the endpoint the Devii assistant calls as `site_analytics`; see [Devii internals](/docs/devii-internals.html)."],
|
|
),
|
|
endpoint(
|
|
id="admin-ai-usage",
|
|
method="GET",
|
|
path="/admin/ai-usage/data",
|
|
title="AI gateway usage analytics",
|
|
summary=(
|
|
"One-call AI gateway metrics, returned as JSON for a bounded time window: request "
|
|
"volume and throughput, token usage with averages and percentiles (p50/p90/p95/p99), "
|
|
"latency (upstream, gateway overhead, queue wait, connection establishment), error "
|
|
"rates by category, cost in USD (per model, per caller, input vs output, projected "
|
|
"monthly burn, caching savings), caller behavior, and an hourly breakdown. Cost is "
|
|
"taken from the upstream native cost when present (OpenRouter) and computed from the "
|
|
"configured per-million pricing otherwise (DeepSeek)."
|
|
),
|
|
auth="admin",
|
|
interactive=True,
|
|
params=[
|
|
field("hours", "query", "int", False, "48", "Lookback window in hours (1-168)."),
|
|
field("top_n", "query", "int", False, "10", "How many rows in each top-N breakdown."),
|
|
],
|
|
notes=["TTFT and inter-token latency are not reported: the gateway forwards non-streaming to the upstream."],
|
|
),
|
|
endpoint(
|
|
id="admin-user-ai-usage",
|
|
method="GET",
|
|
path="/admin/users/{uid}/ai-usage",
|
|
title="Per-user AI usage",
|
|
summary=(
|
|
"One user's AI gateway usage over the last 24 hours, returned as JSON: request "
|
|
"volume, success and error rates, token totals, cost (window, per hour, per request, "
|
|
"and a 30-day projection from the full 24h spend), average latency and throughput, a "
|
|
"per-model breakdown, and an hourly cost series. Because Devii operates a signed-in "
|
|
"user's account with that user's own API key, this is the user's complete gateway "
|
|
"spend, whether driven through Devii or direct API calls. Shown admin-only on the "
|
|
"user's profile page."
|
|
),
|
|
auth="admin",
|
|
interactive=True,
|
|
params=[
|
|
field("uid", "path", "string", True, "USER_UID", "Target user UID."),
|
|
field("hours", "query", "int", False, "24", "Lookback window in hours (1-168)."),
|
|
],
|
|
sample_response={
|
|
"owner_id": "USER_UID", "window_hours": 24, "requests": 42, "success": 41,
|
|
"failed": 1, "success_pct": 97.6, "error_pct": 2.4,
|
|
"tokens": {"prompt": 120000, "completion": 38000, "total": 158000},
|
|
"cost": {"window_usd": 0.214, "per_hour_usd": 0.0089, "per_request_usd": 0.0051, "projected_30d_usd": 6.42},
|
|
"latency": {"avg_ms": 1830.0, "avg_tps": 41.2},
|
|
"by_model": [{"key": "molodetz", "requests": 42, "total_tokens": 158000, "cost_usd": 0.214}],
|
|
"hourly": [{"hour": "2026-06-08T16", "requests": 6, "cost_usd": 0.031, "total_tokens": 22000}],
|
|
},
|
|
),
|
|
endpoint(
|
|
id="admin-user-role",
|
|
method="POST",
|
|
path="/admin/users/{uid}/role",
|
|
title="Set a user role",
|
|
summary="Promote or demote a user. You cannot change your own role.",
|
|
auth="admin",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("uid", "path", "string", True, "USER_UID", "Target user UID."),
|
|
field("role", "form", "enum", True, "member", "New role.", ["member", "admin"]),
|
|
],
|
|
),
|
|
endpoint(
|
|
id="admin-user-password",
|
|
method="POST",
|
|
path="/admin/users/{uid}/password",
|
|
title="Reset a user password",
|
|
summary="Set a new password for a user.",
|
|
auth="admin",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("uid", "path", "string", True, "USER_UID", "Target user UID."),
|
|
field("password", "form", "string", True, "newpassword", "New password, 6+ characters."),
|
|
],
|
|
),
|
|
endpoint(
|
|
id="admin-user-toggle",
|
|
method="POST",
|
|
path="/admin/users/{uid}/toggle",
|
|
title="Enable or disable a user",
|
|
summary="Toggle a user's active state. You cannot disable yourself.",
|
|
auth="admin",
|
|
destructive=True,
|
|
params=[field("uid", "path", "string", True, "USER_UID", "Target user UID.")],
|
|
),
|
|
endpoint(
|
|
id="admin-news-list",
|
|
method="GET",
|
|
path="/admin/news",
|
|
title="List news articles",
|
|
summary="Paginated news management page. Returns HTML.",
|
|
auth="admin",
|
|
interactive=True,
|
|
params=[field("page", "query", "int", False, "1", "Page number.")],
|
|
),
|
|
endpoint(
|
|
id="admin-news-toggle",
|
|
method="POST",
|
|
path="/admin/news/{uid}/toggle",
|
|
title="Toggle featured",
|
|
summary="Toggle an article's featured flag.",
|
|
auth="admin",
|
|
destructive=True,
|
|
params=[field("uid", "path", "string", True, "NEWS_UID", "Article UID.")],
|
|
),
|
|
endpoint(
|
|
id="admin-news-publish",
|
|
method="POST",
|
|
path="/admin/news/{uid}/publish",
|
|
title="Toggle published",
|
|
summary="Switch an article between draft and published.",
|
|
auth="admin",
|
|
destructive=True,
|
|
params=[field("uid", "path", "string", True, "NEWS_UID", "Article UID.")],
|
|
),
|
|
endpoint(
|
|
id="admin-news-landing",
|
|
method="POST",
|
|
path="/admin/news/{uid}/landing",
|
|
title="Toggle landing",
|
|
summary="Toggle whether an article shows on the landing page.",
|
|
auth="admin",
|
|
destructive=True,
|
|
params=[field("uid", "path", "string", True, "NEWS_UID", "Article UID.")],
|
|
),
|
|
endpoint(
|
|
id="admin-news-delete",
|
|
method="POST",
|
|
path="/admin/news/{uid}/delete",
|
|
title="Delete a news article",
|
|
summary="Delete an article and its images.",
|
|
auth="admin",
|
|
destructive=True,
|
|
params=[field("uid", "path", "string", True, "NEWS_UID", "Article UID.")],
|
|
),
|
|
endpoint(
|
|
id="admin-settings-get",
|
|
method="GET",
|
|
path="/admin/settings",
|
|
title="Read site settings",
|
|
summary="Return the current site and operational settings. Negotiates HTML or JSON.",
|
|
auth="admin",
|
|
interactive=True,
|
|
),
|
|
endpoint(
|
|
id="admin-settings",
|
|
method="POST",
|
|
path="/admin/settings",
|
|
title="Save site settings",
|
|
summary="Update operational and site settings. Empty fields are skipped.",
|
|
auth="admin",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("site_name", "form", "string", False, "DevPlace", "Site name."),
|
|
field("rate_limit_per_minute", "form", "string", False, "60", "Requests per window."),
|
|
field("registration_open", "form", "enum", False, "1", "Allow signups.", ["1", "0"]),
|
|
field("maintenance_mode", "form", "enum", False, "0", "Maintenance gate.", ["1", "0"]),
|
|
],
|
|
notes=["Accepts every field on the admin settings form; only non-empty values are written."],
|
|
),
|
|
],
|
|
},
|
|
]
|
|
|
|
from devplacepy import schemas
|
|
from devplacepy.docs_examples import schema_example, action_example
|
|
|
|
_PAGE_RESPONSES = {
|
|
"feed-list": schemas.FeedOut,
|
|
"posts-detail": schemas.PostDetailOut,
|
|
"projects-list": schemas.ProjectsOut,
|
|
"projects-detail": schemas.ProjectDetailOut,
|
|
"gists-list": schemas.GistsOut,
|
|
"gists-detail": schemas.GistDetailOut,
|
|
"news-list": schemas.NewsListOut,
|
|
"news-detail": schemas.NewsDetailOut,
|
|
"profile-detail": schemas.ProfileOut,
|
|
"leaderboard": schemas.LeaderboardOut,
|
|
"messages-inbox": schemas.MessagesOut,
|
|
"notifications-list": schemas.NotificationsOut,
|
|
"bugs-list": schemas.BugsOut,
|
|
"bookmarks-saved": schemas.SavedOut,
|
|
"admin-users": schemas.AdminUsersOut,
|
|
"admin-news-list": schemas.AdminNewsOut,
|
|
"admin-settings-get": schemas.AdminSettingsOut,
|
|
}
|
|
|
|
_ACTION_RESPONSES = {
|
|
"posts-create": ("/posts/POST_SLUG", {"uid": "POST_UID", "slug": "POST_SLUG", "url": "/posts/POST_SLUG"}),
|
|
"posts-edit": ("/posts/POST_SLUG", {"uid": "POST_UID", "slug": "POST_SLUG", "url": "/posts/POST_SLUG"}),
|
|
"posts-delete": ("/feed", None),
|
|
"comments-create": ("/posts/POST_SLUG#comment-COMMENT_UID", {"uid": "COMMENT_UID", "url": "/posts/POST_SLUG#comment-COMMENT_UID"}),
|
|
"comments-delete": ("/posts/POST_SLUG", None),
|
|
"projects-create": ("/projects/PROJECT_SLUG", {"uid": "PROJECT_UID", "slug": "PROJECT_SLUG", "url": "/projects/PROJECT_SLUG"}),
|
|
"projects-delete": ("/projects", None),
|
|
"gists-create": ("/gists/GIST_SLUG", {"uid": "GIST_UID", "slug": "GIST_SLUG", "url": "/gists/GIST_SLUG"}),
|
|
"gists-edit": ("/gists/GIST_SLUG", {"uid": "GIST_UID", "slug": "GIST_SLUG", "url": "/gists/GIST_SLUG"}),
|
|
"gists-delete": ("/gists", None),
|
|
"profile-update": ("/profile/YOUR_USERNAME", None),
|
|
"follow-user": ("/profile/bob_test", None),
|
|
"unfollow-user": ("/profile/bob_test", None),
|
|
"messages-send": ("/messages?with_uid=RECEIVER_UID", {"uid": "MESSAGE_UID"}),
|
|
"notifications-open": ("/posts/POST_SLUG#comment-COMMENT_UID", None),
|
|
"notifications-mark-read": ("/notifications", None),
|
|
"notifications-mark-all-read": ("/notifications", None),
|
|
"bugs-create": ("/bugs", {"uid": "BUG_UID"}),
|
|
"admin-user-role": ("/admin/users", None),
|
|
"admin-user-password": ("/admin/users", None),
|
|
"admin-user-toggle": ("/admin/users", None),
|
|
"admin-news-toggle": ("/admin/news", None),
|
|
"admin-news-publish": ("/admin/news", None),
|
|
"admin-news-landing": ("/admin/news", None),
|
|
"admin-news-delete": ("/admin/news", None),
|
|
"admin-settings": ("/admin/settings", None),
|
|
}
|
|
|
|
|
|
def _apply_negotiated_responses():
|
|
for group in API_GROUPS:
|
|
for ep in group.get("endpoints", []) or []:
|
|
if ep.get("sample_response") is None:
|
|
if ep["id"] in _PAGE_RESPONSES:
|
|
ep["sample_response"] = schema_example(_PAGE_RESPONSES[ep["id"]])
|
|
elif ep["id"] in _ACTION_RESPONSES:
|
|
redirect, data = _ACTION_RESPONSES[ep["id"]]
|
|
ep["sample_response"] = action_example(redirect, data)
|
|
ep["negotiation"] = _classify(ep)
|
|
|
|
|
|
_apply_negotiated_responses()
|
|
|
|
_GROUPS_BY_SLUG = {group["slug"]: group for group in API_GROUPS}
|
|
|
|
|
|
def get_group(slug):
|
|
return _GROUPS_BY_SLUG.get(slug)
|
|
|
|
|
|
def api_doc_pages():
|
|
return [
|
|
{
|
|
"slug": group["slug"],
|
|
"title": group["title"],
|
|
"kind": "api",
|
|
"admin": group.get("admin", False),
|
|
"dynamic": group.get("dynamic", False),
|
|
}
|
|
for group in API_GROUPS
|
|
]
|
|
|
|
|
|
def _field_to_param(spec):
|
|
description = spec["label"]
|
|
if spec.get("help"):
|
|
description = f"{spec['label']} - {spec['help']}"
|
|
value = "" if spec.get("secret") else str(spec.get("value", ""))
|
|
if spec.get("secret"):
|
|
description += " Leave blank to keep the current value."
|
|
if spec["type"] == "bool":
|
|
return field(spec["key"], "form", "enum", False, value or "0", description, ["1", "0"])
|
|
if spec["type"] == "select":
|
|
options = [option["value"] for option in spec.get("options") or []]
|
|
return field(spec["key"], "form", "enum", False, value or (options[0] if options else ""), description, options)
|
|
if spec["type"] in ("int", "float"):
|
|
return field(spec["key"], "form", "int", False, value, description)
|
|
return field(spec["key"], "form", "string", False, value, description)
|
|
|
|
|
|
def _service_section(service):
|
|
enabled = "yes" if service.get("enabled") else "no"
|
|
groups = ", ".join(group["name"] for group in service.get("field_groups", []))
|
|
lines = [
|
|
f"## {service.get('title') or service['name']}",
|
|
"",
|
|
service.get("description", ""),
|
|
"",
|
|
f"- Service name: `{service['name']}`",
|
|
f"- Enabled: {enabled}",
|
|
f"- Run interval: {service.get('interval_seconds', 0)}s",
|
|
]
|
|
if groups:
|
|
lines.append(f"- Configuration groups: {groups}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def build_services_group(services, base):
|
|
intro_parts = [
|
|
"# Background Services",
|
|
"",
|
|
"DevPlace runs background services supervised by a single manager. Each service has its "
|
|
"own enable flag, run interval, live status, metrics, and a rolling log buffer. Manage "
|
|
"them from the admin panel at `/admin/services` or with the endpoints below.",
|
|
"",
|
|
"Control a service by its `name` (shown per service): start or stop it, trigger a single "
|
|
"run, clear its logs, or save its configuration. `GET /admin/services/data` returns the "
|
|
"live status, metrics, and log tail for every service.",
|
|
"",
|
|
"See the [Admin API](/docs/admin.html) for the rest of administration; the Devii assistant "
|
|
"is itself a service, configured in [Configuration and CLI](/docs/devii-config.html).",
|
|
]
|
|
config_endpoints = []
|
|
for service in services:
|
|
intro_parts.append("")
|
|
intro_parts.append(_service_section(service))
|
|
params = [
|
|
_field_to_param(spec)
|
|
for spec in service.get("fields", [])
|
|
if not spec["key"].endswith("_enabled")
|
|
]
|
|
config_endpoints.append(endpoint(
|
|
id=f"services-config-{service['name']}",
|
|
method="POST",
|
|
path=f"/admin/services/{service['name']}/config",
|
|
title=f"Configure {service.get('title') or service['name']}",
|
|
summary=f"Save configuration for the {service['name']} service. Empty values keep the current setting.",
|
|
auth="admin",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=params,
|
|
notes=["Use start/stop to enable or disable the service; this saves the remaining settings."],
|
|
))
|
|
control_endpoints = [
|
|
endpoint(
|
|
id="services-data",
|
|
method="GET",
|
|
path="/admin/services/data",
|
|
title="Service status",
|
|
summary="Live status, metrics, and log tail for every background service.",
|
|
auth="admin",
|
|
sample_response={"services": [{"name": "news", "status": "running", "enabled": True}]},
|
|
),
|
|
endpoint(
|
|
id="services-detail-data",
|
|
method="GET",
|
|
path="/admin/services/{name}/data",
|
|
title="One service status",
|
|
summary="Live status, metrics, and log tail for a single background service.",
|
|
auth="admin",
|
|
params=[field("name", "path", "string", True, "news", "Service name.")],
|
|
sample_response={"service": {"name": "news", "status": "running", "enabled": True}},
|
|
),
|
|
*_service_control_endpoints(),
|
|
]
|
|
endpoints = control_endpoints + config_endpoints
|
|
for ep in endpoints:
|
|
ep["negotiation"] = _classify(ep)
|
|
return {
|
|
"slug": "services",
|
|
"title": "Background Services",
|
|
"admin": True,
|
|
"intro": "\n".join(intro_parts),
|
|
"endpoints": endpoints,
|
|
}
|
|
|
|
|
|
def _substitute(value, replacements):
|
|
if isinstance(value, str):
|
|
for token, replacement in replacements.items():
|
|
value = value.replace(token, replacement)
|
|
return value
|
|
if isinstance(value, list):
|
|
return [_substitute(item, replacements) for item in value]
|
|
if isinstance(value, dict):
|
|
return {key: _substitute(item, replacements) for key, item in value.items()}
|
|
return value
|
|
|
|
|
|
def render_group(slug, base, username, api_key):
|
|
group = get_group(slug)
|
|
if not group:
|
|
return None
|
|
replacements = {
|
|
"{{ base }}": base,
|
|
"{{ username }}": username or "YOUR_USERNAME",
|
|
"{{ api_key }}": api_key or "YOUR_API_KEY",
|
|
}
|
|
return _substitute(group, replacements)
|