Files
nuntius/docs/api-reference.md
T

272 lines
8.3 KiB
Markdown
Raw Normal View History

# HTTP API Reference
Every form defined in `config.toml` exposes the same three HTTP verbs on its configured `path`. The server also exposes one global endpoint (`GET /health`).
## Per-Form Endpoints
For each form in the config:
| Method | Behaviour |
|---|---|
| `POST` | Validate → honeypot → email send → (newsletter only) persist to disk |
| `OPTIONS` | CORS preflight. Returns `204 No Content` if the origin is allowed; `403` otherwise. |
| _other_ | `405 Method Not Allowed` (Go 1.22+ `http.ServeMux` semantics) |
### Request shape
All four form types accept a single JSON object as the request body. Required fields depend on the form's `type`:
| Type | Required | Optional |
|---|---|---|
| `contact` | `name`, `email`, `message` | `service`, `honeypot` |
| `feedback` | `name`, `email`, `message` | `honeypot` |
| `newsletter` | `email` | `honeypot` |
| `generic` | `name`, `email`, `message` | `honeypot` |
The `honeypot` field name in the request body is configured per form (`honeypot_field` in `config.toml`, default `"website"`). The handler reads it into `contactform.Request.Honeypot` (which is `json:"-"`, so it does not appear in the public Go API) and drops the request silently if it is non-empty.
#### Example: contact
```http
POST /api/nuntius/contact HTTP/1.1
Host: example.com
Content-Type: application/json
Origin: https://example.com
{
"name": "Jane Doe",
"email": "jane@example.com",
"service": "architecture",
"message": "Hello, I would like to discuss an engagement."
}
```
#### Example: feedback
```http
POST /api/nuntius/feedback HTTP/1.1
Content-Type: application/json
Origin: https://example.com
{
"name": "Jane Doe",
"email": "jane@example.com",
"message": "I love how the site uses Vue.js with zero runtime deps."
}
```
#### Example: newsletter
```http
POST /api/nuntius/newsletter HTTP/1.1
Content-Type: application/json
Origin: https://example.com
{ "email": "jane@example.com" }
```
## Response Status Codes
| Status | Body | When |
|---|---|---|
| `200` | `{"ok": true}` | Submission accepted; mail sent (and subscriber persisted for `newsletter`) |
| `200` | `{"ok": true}` | Honeypot triggered (silent accept, no mail, no log) |
| `204` | _empty_ | CORS preflight succeeded |
| `400` | `{"error": "invalid_json", "message": "..."}` | Body is not valid JSON |
| `400` | `{"error": "validation", "details": [...]}` | One or more fields are invalid |
| `403` | `{"error": "origin_not_allowed"}` | `Origin` header is not in the form's `allowed_origins` |
| `405` | _empty_ | Method not allowed (e.g. `GET` on a form path) |
| `429` | `{"error": "rate_limited", "message": "..."}` | Token bucket empty for this IP / form |
| `500` | `{"error": "send_failed", "message": "..."}` | SMTP round-trip failed |
| `500` | `{"error": "storage_failed", "message": "..."}` | `newsletter` only — could not write the JSONL line |
## Response Bodies
### Success
```json
{ "ok": true }
```
### Validation error
```json
{
"error": "validation",
"details": [
{ "field": "name", "message": "name must be at least 2 characters" },
{ "field": "email", "message": "email is invalid" },
{ "field": "message", "message": "message must be at least 10 characters" }
]
}
```
`details[]` always has at least one entry when `error == "validation"`. Each entry has `field` and `message` strings. The `message` is human-readable and safe to surface in the UI.
### JSON parse error
```json
{ "error": "invalid_json", "message": "Could not parse JSON body." }
```
### CORS rejection
```json
{ "error": "origin_not_allowed" }
```
### Rate limit
```json
{ "error": "rate_limited", "message": "Too many requests, please try again later." }
```
### SMTP failure
```json
{ "error": "send_failed", "message": "Could not send email." }
```
### Storage failure (newsletter only)
```json
{ "error": "storage_failed", "message": "Could not record subscription." }
```
## CORS
CORS is enforced **per form**. The flow:
1. Browser sends a preflight `OPTIONS` request with `Origin` and `Access-Control-Request-Method`.
2. nuntius checks `Origin` against the form's `allowed_origins`.
3. If allowed, the response carries:
```http
Access-Control-Allow-Origin: <echoed origin>
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type
Vary: Origin
```
4. If not allowed, the response is `403` with `{"error": "origin_not_allowed"}`.
For the actual `POST`:
- An absent `Origin` header is allowed (the form is treated as a non-browser client). This makes `curl` work out of the box.
- A present `Origin` that is **not** in `allowed_origins` returns `403`.
- A present `Origin` that **is** in `allowed_origins` gets the same CORS headers as the preflight.
Wildcard `*` is not supported. List every origin explicitly.
## Rate Limiting
Each form has its own in-memory token bucket. The bucket size equals `rate_limit_per_hour` and refills at `perHour / 3600` tokens per second. The first request from a new IP creates a full bucket.
To disable rate limiting on a form, set `rate_limit_per_hour: 0` explicitly. There is no global safety net — use with care.
Rate limit state is **per-process** and resets on restart. If you scale nuntius horizontally, each replica has its own bucket; the effective limit is `replicas × per_form_limit`.
The IP used for rate limiting is taken from the same precedence chain as the log line:
1. `X-Forwarded-For` (first hop, before any comma)
2. `X-Real-IP`
3. `RemoteAddr` (with the port stripped)
Behind nginx, set `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` so the bucket sees the real client IP.
## Health Check
### `GET /health`
```http
GET /health HTTP/1.1
```
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{ "status": "ok", "forms": 3 }
```
| Field | Type | Description |
|---|---|---|
| `status` | string | Always `"ok"` while the process is up. There is no deep health check; the endpoint is suitable for a load balancer that wants to know "is the listener alive". |
| `forms` | int | Number of forms loaded from `config.toml` at startup. |
This endpoint is not rate-limited and is not CORS-checked.
## Curl Recipes
### POST a contact form
```bash
curl -X POST http://localhost:8080/api/nuntius/contact \
-H "Content-Type: application/json" \
-H "Origin: https://petrbalvin.org" \
-d '{
"name": "Jane Doe",
"email": "jane@example.com",
"service": "architecture",
"message": "Hello, I would like to discuss an engagement."
}'
```
### POST a feedback form
```bash
curl -X POST http://localhost:8080/api/nuntius/feedback \
-H "Content-Type: application/json" \
-H "Origin: https://petrbalvin.org" \
-d '{
"name": "Jane Doe",
"email": "jane@example.com",
"message": "I love how the site uses Vue.js with zero runtime deps."
}'
```
### POST a newsletter signup
```bash
curl -X POST http://localhost:8080/api/nuntius/newsletter \
-H "Content-Type: application/json" \
-H "Origin: https://petrbalvin.org" \
-d '{ "email": "jane@example.com" }'
```
### Trigger the honeypot (bot)
```bash
curl -X POST http://localhost:8080/api/nuntius/contact \
-H "Content-Type: application/json" \
-d '{
"name": "Bot",
"email": "bot@spam.example",
"message": "buy cheap viagra click here",
"website": "https://spam.example"
}'
# → 200 {"ok": true}, no email sent, no log line about message sent
```
### CORS preflight
```bash
curl -i -X OPTIONS http://localhost:8080/api/nuntius/contact \
-H "Origin: https://petrbalvin.org" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type"
```
### Health check
```bash
curl -s http://localhost:8080/health | jq .
# → { "status": "ok", "forms": 3 }
```
## Notes
- nuntius does not currently expose per-form metrics, request counters, or Prometheus endpoints. A future release may add `GET /metrics`; for now, count rows in the JSONL log and grep `journalctl -u nuntius` for request lines.
- All request bodies are bounded by `ReadTimeout` (15 s) and `WriteTimeout` (30 s). The full request body is read into memory by `json.NewDecoder(r.Body).Decode(&req)`; for a 5,000-rune message this is negligible.
- Logs are emitted to stdout in JSON form via `log/slog`. One line per request (`method`, `path`, `status`, `duration_ms`, `ip`) plus one line per sent / failed message and one line per honeypot hit. There is no PII in the log line beyond the client IP.