Reviewed-on: #9
Service: Royalroad Chapters
Overview
service-royalroad-chapters is a FastAPI-based webserver that monitors RSS feeds for new entries and sends a notification to every subscribed user through backend-api-internal. It supports two feed types:
- Royal Road: new chapters of a story
- Gitea: new releases of a repository on
gitea.com/gitea/
Feeds are shared between users. A feed is stored once and polled once, and each user has their own subscription with an optional custom name.
Prerequisites
- Python 3.11+
- MySQL database with the tables described under Database, including the
userstable backend-api-internal(receives the notifications)- Royal Road story IDs
Installation
pip install -r requirements.txt
Internal libraries (lib-db-module, lib-secret-manager, lib-service-auth) come from the Gitea package registry. Point pip at it first:
export PIP_EXTRA_INDEX_URL=https://git.gansejunge.com/api/packages/notifier/pypi/simple/
Configuration
Secrets are read from files under /etc/secrets/ (Kubernetes secrets) and fall back to environment variables for development. The files are deleted after startup.
| Secret | File | Dev environment variable |
|---|---|---|
| Gateway key | /etc/secrets/gateway_key |
GATEWAY_KEY |
| Scheduler key | /etc/secrets/scheduler_key |
SCHEDULER_KEY |
| Gitea token | /etc/secrets/gitea_token |
GITEA_TOKEN |
| Notification API key | /etc/secrets/api_key |
(see lib-secret-manager) |
| Database user | /etc/secrets/db_username |
(see lib-db-module) |
| Database password | /etc/secrets/db_password |
(see lib-db-module) |
Caller keys must be at least 32 characters and must differ from each other. Generate them with:
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
Running the Service
python main.py
This starts two servers:
- Main API:
http://0.0.0.0:5000- Handles chapter check requests - Metrics Server:
http://0.0.0.0:9000/metrics- Prometheus metrics endpoint
Authentication
Every route on the main API requires a bearer key, and each key belongs to exactly one caller:
Authorization: Bearer <key>
| Caller | Key | May call |
|---|---|---|
Gateway (backend-api) |
gateway_key |
/add/feed, /delete/feed |
| Scheduler (CronJob) | scheduler_key |
/royalroad, /gitea |
A missing or unknown key returns 401. A valid key used on a route it is not allowed to call returns 403.
User routes also require the user the gateway is acting for:
X-User-Id: 1
The value must be a positive integer.
API
Subscribe to a feed
POST /add/feed
Headers: Authorization: Bearer <gateway_key>, X-User-Id: <user id>
Body:
{
"type": "royalroad",
"rss_feed_url": "royalroad.com/fiction/118891",
"custom_name": "My favourite story"
}
| Field | Required | Description |
|---|---|---|
type |
yes | royalroad or gitea |
rss_feed_url |
yes | Feed or page URL, see Accepted feed URLs |
custom_name |
no | Name used as the notification title. If empty, the title from the feed is used |
Response:
{
"status": "ok",
"detail": "Subscribed to feed",
"feed_url": "https://www.royalroad.com/fiction/syndication/65058"
}
Behavior:
- Validates the URL and converts it to the canonical feed URL
- If the feed is not stored yet, checks that it exists and records its latest entry, so only entries published after subscribing trigger a notification
- Stores the feed, or reactivates it if it was inactive
- Creates the user's subscription
Sending the same request again renames the subscription and reactivates it if the user had unsubscribed.
Unsubscribe from a feed
POST /delete/feed
Headers: Authorization: Bearer <gateway_key>, X-User-Id: <user id>
Body: same as /add/feed. custom_name is ignored.
Response:
{
"status": "ok",
"detail": "Unsubscribed from feed",
"feed_url": "https://www.royalroad.com/fiction/syndication/118891"
}
Behavior: deactivates the user's subscription. If no active subscribers are left, the feed itself is deactivated and is no longer polled. An unknown feed also returns ok.
Check for new chapters
POST /royalroad
Headers: Authorization: Bearer <scheduler_key>
Check for new Gitea releases
POST /gitea
Headers: Authorization: Bearer <scheduler_key>
Response (both):
{
"checked": 12,
"new": 2,
"errors": 0
}
If any feed failed, the same counts are returned as the detail of a 500 response.
Notification failures:
- If every notification for a feed fails,
last_entryis not updated, the feed counts as an error and it is retried on the next run - If only some fail,
last_entryis updated, so the users who were notified are not notified again - If the notification backend is unavailable, the remaining users of that feed are skipped
When a new release of gitea/runner is detected, the service also triggers the runner build workflow on the Gitea instance, using gitea_token.
Accepted feed URLs
The scheme and www. are optional. Whatever is submitted is converted to the canonical feed URL.
| Type | Accepted input | Stored as |
|---|---|---|
royalroad |
royalroad.com/fiction/118891https://www.royalroad.com/fiction/118891/story-title |
https://www.royalroad.com/fiction/syndication/118891 |
gitea |
gitea.com/gitea/runnerhttps://gitea.com/gitea/runner/releaseshttps://gitea.com/gitea/runner/releases.rss |
https://gitea.com/gitea/runner/releases.rss |
Only repositories under gitea.com/gitea/ are supported.
Error Responses
Errors are returned as {"detail": "<message>"}.
| Status | Cause | Example detail |
|---|---|---|
401 |
Missing or invalid bearer key | Unauthorized |
403 |
Key is valid but not allowed on this route | Forbidden |
422 |
Invalid X-User-Id or body |
type: Input should be 'royalroad' or 'gitea' |
422 |
URL does not match the feed type | A gitea feed must be on gitea.com |
422 |
URL has the wrong path | Unrecognised feed path (expected e.g. royalroad.com/fiction/118891) |
422 |
The feed does not exist | The feed does not exist: <url> |
502 |
The feed could not be checked right now | Could not verify the feed right now, try again later: <url> |
500 |
Internal error, or a check run with failed feeds |
Notification Format
One message is sent to backend-api-internal per subscriber:
{
"user_id": 1,
"message": {
"title": "New Life As A Max Level Archmage",
"body": "Chapter 68 has been released",
"link": "https://www.royalroad.com/fiction/118891/new-life-as-a-max-level-archmage/chapter/2683797/68-carve",
"category": "royal-road",
"timestamp": 1729339200
}
}
Gitea releases use the category utility. When a subscription has no custom name, the title is the feed title for Royal Road and the repository path (for example gitea.com/gitea/runner) for Gitea.
Monitoring
Prometheus Metrics
Available at http://localhost:9000/metrics:
http_requests_total{method, path, status}: Request counter with labels