diff --git a/assets/css/app.css b/assets/css/app.css index c22e3dad..b23e9f87 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -327,61 +327,449 @@ select.select { text-decoration: underline; } -/* API docs page — readable prose. Override the project's monospace - default with a system sans stack for the body, keep mono for code - spans and pre blocks. Without this the bulleted prose wraps to - 2-3 lines per item and is a chore to scan. */ -.markdown-content.api-docs { - max-width: 64rem; - font-family: +/* ───────────────────────────────────────────────────────────────────────── + /docs/api — Tailwind UI "Protocol"-style API reference layout. + Sidebar on the left, two-column body where prose sits next to a + sticky code sample. Themed via daisyUI base-* tokens for auto + light/dark. + ───────────────────────────────────────────────────────────────────────── */ + +.api-docs-shell { + --api-prose-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; - font-size: 1rem; + --api-mono-font: + "Cascadia Code", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + display: grid; + grid-template-columns: 1fr; + gap: 0; + min-height: calc(100vh - 4rem); + margin: -3rem -1rem 0; + font-family: var(--api-prose-font); + color: var(--color-base-content); +} + +@media (min-width: 1024px) { + .api-docs-shell { + grid-template-columns: 17rem minmax(0, 1fr); + margin: -3rem 0 0; + } +} + +.api-docs-sidebar { + display: none; + border-right: 1px solid var(--color-base-200); + padding: 1.5rem 1.25rem 2rem 1.5rem; + background: color-mix(in oklab, var(--color-base-100) 65%, transparent); +} + +@media (min-width: 1024px) { + .api-docs-sidebar { + display: block; + position: sticky; + top: 4rem; + height: calc(100vh - 4rem); + overflow-y: auto; + } +} + +.api-docs-nav-group { + margin-top: 1.5rem; +} +.api-docs-nav-group:first-child { margin-top: 0; } + +.api-docs-nav-group h3 { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-base-content); + opacity: 0.7; + margin: 0 0 0.5rem; +} + +.api-docs-nav-group ul { + display: flex; + flex-direction: column; + border-left: 1px solid var(--color-base-200); + padding-left: 0; + margin: 0; + list-style: none; +} + +.api-docs-nav-group li { margin: 0; } + +.api-docs-nav-group a { + display: block; + padding: 0.3rem 0 0.3rem 0.875rem; + margin-left: -1px; + font-size: 0.85rem; + border-left: 1px solid transparent; + color: var(--color-base-content); + opacity: 0.7; + text-decoration: none; + transition: color 120ms, border-color 120ms, opacity 120ms; +} +.api-docs-nav-group a:hover { + opacity: 1; + color: var(--color-primary); +} +.api-docs-nav-group a:target, +.api-docs-nav-group a:focus-visible { + outline: none; + opacity: 1; + border-left-color: var(--color-primary); + color: var(--color-primary); +} + +.api-docs-main { + min-width: 0; + padding: 2rem 1rem 4rem; +} + +@media (min-width: 768px) { + .api-docs-main { padding: 2.5rem 2rem 5rem; } +} + +.api-docs-prose { + max-width: 76rem; + margin: 0 auto; line-height: 1.7; } -.markdown-content.api-docs code, -.markdown-content.api-docs pre, -.markdown-content.api-docs pre code { - font-family: "Cascadia Code", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +.api-docs-prose h1 { + font-size: 2.25rem; + font-weight: 700; + letter-spacing: -0.02em; + margin: 0 0 0.75rem; + scroll-margin-top: 6rem; } - -.markdown-content.api-docs code { - word-break: break-word; - font-size: 0.875em; -} - -.markdown-content.api-docs h1 { - font-size: 2rem; - letter-spacing: -0.01em; -} - -.markdown-content.api-docs h2 { +.api-docs-prose h2 { font-size: 1.5rem; + font-weight: 700; letter-spacing: -0.01em; - margin-top: 2.25rem; + margin: 0 0 0.75rem; + scroll-margin-top: 6rem; +} +.api-docs-prose h3 { + font-size: 1.125rem; + font-weight: 600; + margin: 1.5rem 0 0.5rem; +} +.api-docs-prose h4 { + font-size: 0.95rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-base-content); + opacity: 0.75; + margin: 1.25rem 0 0.5rem; } -.markdown-content.api-docs h3 { +.api-docs-prose p { margin: 0.75rem 0; } +.api-docs-prose ul { + list-style: disc; + padding-left: 1.25rem; + margin: 0.75rem 0; +} +.api-docs-prose ul li { margin: 0.35rem 0; } + +.api-docs-prose code { + font-family: var(--api-mono-font); + font-size: 0.85em; + padding: 0.1em 0.35em; + border-radius: 0.3rem; + background: var(--color-base-200); + color: var(--color-base-content); + word-break: break-word; +} + +.api-docs-prose table { + width: 100%; + border-collapse: collapse; + margin: 0.75rem 0 1.25rem; + font-size: 0.9rem; + border: 1px solid var(--color-base-200); + border-radius: 0.5rem; + overflow: hidden; +} +.api-docs-prose th, +.api-docs-prose td { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--color-base-200); +} +.api-docs-prose th { + background: var(--color-base-200); + font-weight: 600; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.api-docs-prose tbody tr:last-child td { border-bottom: 0; } + +.api-docs-link { + color: var(--color-primary); + text-decoration: underline; + text-underline-offset: 2px; +} +.api-docs-link:hover { text-decoration-thickness: 2px; } + +/* ── Hero ─────────────────────────────────────────────────────────── */ +.api-docs-hero { + border-bottom: 1px solid var(--color-base-200); + padding-bottom: 2rem; + margin-bottom: 2.5rem; +} + +.api-docs-eyebrow { + font-family: var(--api-mono-font); + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--color-primary); + margin: 0 0 0.5rem; +} + +.api-docs-lead { + font-size: 1.1rem; + line-height: 1.6; + opacity: 0.85; + max-width: 52rem; +} + +.api-docs-meta { + display: grid; + grid-template-columns: 1fr; + gap: 0.75rem 2rem; + margin: 1.5rem 0 0; + padding: 1rem 1.25rem; + border: 1px solid var(--color-base-200); + border-radius: 0.75rem; + background: color-mix(in oklab, var(--color-base-100) 60%, transparent); +} +@media (min-width: 768px) { + .api-docs-meta { grid-template-columns: max-content 1fr; column-gap: 1.5rem; } + .api-docs-meta > div { display: contents; } +} +.api-docs-meta dt { + font-weight: 600; + font-size: 0.85rem; + opacity: 0.75; +} +.api-docs-meta dd { + margin: 0; + font-size: 0.9rem; +} + +/* ── Two-column row (guide sections) + endpoint blocks ────────────── */ +.api-docs-row, +.api-docs-endpoint { + display: grid; + grid-template-columns: 1fr; + gap: 1.25rem; + scroll-margin-top: 6rem; + padding-block: 2rem; + border-top: 1px solid var(--color-base-200); +} +.api-docs-row:first-of-type, +.api-docs-endpoint:first-of-type { border-top: 0; padding-top: 1rem; } + +@media (min-width: 1100px) { + .api-docs-row, + .api-docs-endpoint { + grid-template-columns: minmax(0, 1fr) minmax(0, 28rem); + gap: 2.5rem; + } + .api-docs-row-code, + .api-docs-endpoint-code { + position: sticky; + top: 5rem; + align-self: start; + } +} + +.api-docs-row-prose, +.api-docs-endpoint-prose { + min-width: 0; +} + +.api-docs-row-code, +.api-docs-endpoint-code { + display: flex; + flex-direction: column; + gap: 1rem; + min-width: 0; +} + +.api-docs-section-heading { + font-size: 1.75rem; + font-weight: 700; + letter-spacing: -0.01em; + margin: 1.5rem 0 0.5rem; +} + +.api-docs-divider { + border: 0; + height: 1px; + background: var(--color-base-200); + margin: 2.5rem 0 1rem; +} + +.api-docs-callout { + border-left: 3px solid var(--color-primary); + background: color-mix(in oklab, var(--color-primary) 7%, transparent); + padding: 0.6rem 0.9rem; + border-radius: 0 0.4rem 0.4rem 0; + font-size: 0.9rem; +} + +/* ── Endpoint header ───────────────────────────────────────────────── */ +.api-docs-eyebrow-row { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.4rem; +} +.api-docs-eyebrow-dot { + width: 0.2rem; + height: 0.2rem; + border-radius: 9999px; + background: var(--color-base-300); +} +.api-docs-eyebrow-path { + font-family: var(--api-mono-font); + font-size: 0.78rem; + background: transparent; + padding: 0; + color: var(--color-base-content); + opacity: 0.7; +} + +.api-docs-endpoint-title { font-size: 1.2rem; - margin-top: 1.75rem; + font-weight: 600; + margin: 0 0 0.5rem; } -.markdown-content.api-docs h4 { - font-size: 1rem; +/* ── HTTP method tags ──────────────────────────────────────────────── */ +.api-docs-method { + display: inline-flex; + align-items: center; + font-family: var(--api-mono-font); + font-size: 0.625rem; + font-weight: 700; + letter-spacing: 0.05em; + padding: 0.05rem 0.45rem; + border-radius: 0.4rem; + border: 1px solid currentColor; + background: color-mix(in oklab, currentColor 10%, transparent); +} +.api-docs-method.is-get { color: oklch(72% 0.15 160); } +.api-docs-method.is-post { color: oklch(70% 0.13 230); } +.api-docs-method.is-put { color: oklch(78% 0.13 75); } +.api-docs-method.is-patch { color: oklch(78% 0.13 75); } +.api-docs-method.is-delete { color: oklch(70% 0.18 25); } +.api-docs-method.is-zinc { color: var(--color-base-content); opacity: 0.6; } + +/* ── Code blocks ───────────────────────────────────────────────────── */ +.api-docs-code { + margin: 0; + border: 1px solid color-mix(in oklab, var(--color-neutral) 50%, transparent); + border-radius: 0.7rem; + overflow: hidden; + background: var(--color-neutral); + color: var(--color-neutral-content); } -.markdown-content.api-docs li { - margin: 0.4rem 0; +.api-docs-code-caption { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.5rem 0.85rem; + background: color-mix(in oklab, var(--color-neutral) 80%, black 20%); + border-bottom: 1px solid color-mix(in oklab, var(--color-neutral-content) 15%, transparent); + font-size: 0.78rem; } -.markdown-content.api-docs table { - font-size: 0.95em; +.api-docs-code-title { + color: var(--color-neutral-content); + font-weight: 600; } -.markdown-content.api-docs th, -.markdown-content.api-docs td { - white-space: normal; +.api-docs-code-meta { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} +.api-docs-code-meta code { + font-family: var(--api-mono-font); + font-size: 0.72rem; + background: transparent; + color: color-mix(in oklab, var(--color-neutral-content) 70%, transparent); + padding: 0; +} + +.api-docs-pre { + margin: 0; + padding: 0.95rem 1rem; + font-family: var(--api-mono-font); + font-size: 0.78rem; + line-height: 1.55; + overflow-x: auto; + color: var(--color-neutral-content); + background: transparent; +} +.api-docs-pre code { + background: transparent; + padding: 0; + color: inherit; + font-size: inherit; + white-space: pre; +} + +/* ── Properties list ──────────────────────────────────────────────── */ +.api-docs-properties { + margin: 0.5rem 0 1rem; + border-top: 1px solid var(--color-base-200); +} +.api-docs-property { + padding: 0.65rem 0; + border-bottom: 1px solid var(--color-base-200); +} +.api-docs-property dt { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.15rem; + font-size: 0.85rem; +} +.api-docs-property dd { + margin: 0; + font-size: 0.9rem; + opacity: 0.85; +} +.api-docs-property-name { + font-family: var(--api-mono-font); + font-weight: 600; + background: transparent; + padding: 0; + color: var(--color-base-content); +} +.api-docs-property-type { + font-family: var(--api-mono-font); + font-size: 0.72rem; + color: var(--color-base-content); + opacity: 0.6; +} +.api-docs-property-required { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: oklch(70% 0.18 25); } /* Allow map interaction through non-content parts of Leaflet popups */ diff --git a/lib/microwaveprop_web/live/api_docs_live.ex b/lib/microwaveprop_web/live/api_docs_live.ex index f0fea24a..62a597f8 100644 --- a/lib/microwaveprop_web/live/api_docs_live.ex +++ b/lib/microwaveprop_web/live/api_docs_live.ex @@ -1,23 +1,1085 @@ defmodule MicrowavepropWeb.ApiDocsLive do - @moduledoc "Renders `docs/api/README.md` as HTML at `/docs/api`." + @moduledoc """ + Renders the Microwaveprop REST API documentation at `/docs/api`. + + Layout is modelled on the Tailwind UI "Protocol" template — a fixed + left sidebar with section navigation and a two-column main column + where prose sits next to a sticky code sample. + """ use MicrowavepropWeb, :live_view - @external_resource "docs/api/README.md" - @docs_html "docs/api/README.md" |> File.read!() |> Microwaveprop.Markdown.to_html!() + # ───────────────────────────────────────────────────────────────────── + # Code samples are kept as module attributes so the HEEx template + # stays readable. HEEx's attribute parser doesn't accept multi-line + # heredoc sigils inline, so we bind once at compile time and reference + # by name from the template. + # ───────────────────────────────────────────────────────────────────── + + @sample_quickstart """ + curl -sS -X POST https://prop.w5isp.com/api/v1/auth/tokens \\ + -H "Content-Type: application/json" \\ + -d '{"email":"you@example.com","password":"...","name":"laptop"}' + # => {"data": {...}, "token": "mwp_..."} + + TOKEN=mwp_... + curl -sS -H "Authorization: Bearer $TOKEN" \\ + https://prop.w5isp.com/api/v1/me + """ + + @sample_auth_header "Authorization: Bearer mwp_AbCdEf123…" + + @sample_token_format """ + # 32 random bytes, URL-base64 + mwp_<43 chars> + + # Server stores SHA-256(token) only. + """ + + @sample_error_422 """ + { + "type": "about:blank", + "title": "validation_failed", + "status": 422, + "detail": "One or more fields are invalid.", + "errors": { + "callsign": ["must be 3-10 letters and digits"] + } + } + """ + + @sample_rate_headers """ + RateLimit-Limit: 600 + RateLimit-Remaining: 597 + RateLimit-Reset: 42 + """ + + @sample_auth_tokens_request """ + curl -X POST https://prop.w5isp.com/api/v1/auth/tokens \\ + -H "Content-Type: application/json" \\ + -d '{ + "email": "you@example.com", + "password": "your password", + "name": "iPad", + "expires_at": "2027-01-01T00:00:00Z" + }' + """ + + @sample_auth_tokens_response """ + { + "data": { + "id": "01HX...", + "name": "iPad", + "inserted_at": "2026-05-09T12:34:00Z", + "last_used_at": null, + "expires_at": "2027-01-01T00:00:00Z", + "revoked_at": null + }, + "token": "mwp_AbCdEf..." + } + """ + + @sample_me_get """ + curl -H "Authorization: Bearer $TOKEN" \\ + https://prop.w5isp.com/api/v1/me + """ + + @sample_me_patch """ + curl -X PATCH \\ + -H "Authorization: Bearer $TOKEN" \\ + -H "Content-Type: application/json" \\ + -d '{"home_grid":"EM12kx"}' \\ + https://prop.w5isp.com/api/v1/me + """ + + @sample_me_contacts """ + curl -H "Authorization: Bearer $TOKEN" \\ + "https://prop.w5isp.com/api/v1/me/contacts?per_page=20" + """ + + @sample_me_tokens_list """ + curl -H "Authorization: Bearer $TOKEN" \\ + https://prop.w5isp.com/api/v1/me/api-tokens + """ + + @sample_me_tokens_revoke """ + curl -X DELETE \\ + -H "Authorization: Bearer $TOKEN" \\ + https://prop.w5isp.com/api/v1/me/api-tokens/01HX... + """ + + @sample_monitor_create """ + curl -X POST \\ + -H "Authorization: Bearer $TOKEN" \\ + -H "Content-Type: application/json" \\ + -d '{"name":"shack-pi","grid":"EM12kx"}' \\ + https://prop.w5isp.com/api/v1/me/beacon-monitors + """ + + @sample_contacts_search """ + curl "https://prop.w5isp.com/api/v1/contacts?search=W5XD&per_page=25" + """ + + @sample_contact_submit """ + curl -X POST \\ + -H "Authorization: Bearer $TOKEN" \\ + -H "Content-Type: application/json" \\ + -d '{ + "station1": "W5XD", + "station2": "K5XD", + "qso_timestamp": "2026-05-08T12:34:00Z", + "band": "10000", + "grid1": "EM12", + "grid2": "EM13", + "mode": "CW" + }' \\ + https://prop.w5isp.com/api/v1/contacts + """ + + @sample_beacon_submit """ + curl -X POST \\ + -H "Content-Type: application/json" \\ + -d '{ + "callsign": "WB5LUA/B", + "frequency_mhz": 10368.025, + "grid": "EM13qe", + "power_w": 5 + }' \\ + https://prop.w5isp.com/api/v1/beacons + """ + + @sample_scores """ + curl "https://prop.w5isp.com/api/v1/scores?band=10000&lat=32.78&lon=-96.80" + """ + + @sample_forecast """ + curl "https://prop.w5isp.com/api/v1/forecast?band=10000&lat=32.78&lon=-96.80" + """ + + @sample_profile """ + curl https://prop.w5isp.com/api/v1/profiles/W5XD + """ @impl true def mount(_params, _session, socket) do - {:ok, assign(socket, page_title: "API Documentation", content: @docs_html)} + {:ok, + socket + |> assign(:page_title, "API Documentation") + |> assign(:sample_quickstart, @sample_quickstart) + |> assign(:sample_auth_header, @sample_auth_header) + |> assign(:sample_token_format, @sample_token_format) + |> assign(:sample_error_422, @sample_error_422) + |> assign(:sample_rate_headers, @sample_rate_headers) + |> assign(:sample_auth_tokens_request, @sample_auth_tokens_request) + |> assign(:sample_auth_tokens_response, @sample_auth_tokens_response) + |> assign(:sample_me_get, @sample_me_get) + |> assign(:sample_me_patch, @sample_me_patch) + |> assign(:sample_me_contacts, @sample_me_contacts) + |> assign(:sample_me_tokens_list, @sample_me_tokens_list) + |> assign(:sample_me_tokens_revoke, @sample_me_tokens_revoke) + |> assign(:sample_monitor_create, @sample_monitor_create) + |> assign(:sample_contacts_search, @sample_contacts_search) + |> assign(:sample_contact_submit, @sample_contact_submit) + |> assign(:sample_beacon_submit, @sample_beacon_submit) + |> assign(:sample_scores, @sample_scores) + |> assign(:sample_forecast, @sample_forecast) + |> assign(:sample_profile, @sample_profile)} end @impl true def render(assigns) do ~H""" - -
- {raw(@content)} + +
+ + +
+
+
+

REST API · v1

+

Microwaveprop API

+

+ The Microwaveprop public REST API exposes the read-and-write surface a + regular user of the website has access to: contact (QSO) submission, + beacon submission, beacon-monitor management, propagation queries, + and profile management. Admin-only operations are deliberately + excluded. +

+ +
+
+
Base URL
+
https://prop.w5isp.com/api/v1
+
+
+
Versioning
+
+ Path-based (/api/v1). Breaking changes ship as /api/v2. +
+
+
+
Auth
+
+ Opaque bearer tokens (Authorization: Bearer mwp_…). + Mint one from your + <.link navigate="/users/settings#api-tokens" class="api-docs-link"> + account settings + + or programmatically at POST /api/v1/auth/tokens. +
+
+
+
Format
+
+ application/json + requests and responses. Errors use + RFC 9457 problem+json + . +
+
+
+
Spec
+
+ <.link href="/docs/api/openapi.yaml" class="api-docs-link"> + openapi.yaml + + (OpenAPI 3.1) +
+
+
+
+ + <.row id="quickstart"> + <:col> +

Quickstart

+

+ Mint a long-lived bearer token once, then use it for every + subsequent call. Tokens are 32 random bytes, URL-base64 encoded + with an mwp_ prefix so leak scanners can spot them. +

+

+ The interactive way to mint a token is the + <.link navigate="/users/settings#api-tokens" class="api-docs-link"> + account settings page + + — the value is shown once. Keep it like a password. +

+ + <:code> + <.code_block + title="Mint a token" + tag="POST" + label="/auth/tokens" + code={@sample_quickstart} + /> + + + + <.row id="authentication"> + <:col> +

Authentication

+

+ Every endpoint that mutates state, or returns the + authenticated user's own data, requires a bearer token. Public + read endpoints accept an optional bearer that unlocks the + caller's private rows. +

+ +

Endpoint auth matrix

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EndpointAuthNotes
POST /auth/tokenspasswordThe only endpoint that accepts a password.
GET / PATCH /mebearerThe user behind the bearer token.
POST /contactsbearerRegular user QSO submission.
GET /contactsoptionalPublic; bearer reveals viewer-private rows.
GET /beaconsnoneApproved beacons only.
POST /beaconsbearerNew beacons start unapproved.
GET /scoresnonePublic read of propagation scores.
GET /profiles/:callnonePublic per-callsign profile.
+ +

Token lifecycle

+
    +
  • + Tokens may carry an optional expires_at + (ISO 8601 UTC). Without one they live until revoked. +
  • +
  • + GET /me/api-tokens lists every non-revoked token belonging to the + authenticated user (without plaintext). +
  • +
  • + DELETE /me/api-tokens/:id + revokes a token (soft delete — the row remains for + audit). +
  • +
  • + Revoking the token used by the current request immediately + invalidates every subsequent request from that token. +
  • +
+ + <:code> + <.code_block title="Authorization header" code={@sample_auth_header} /> + <.code_block title="Token format" code={@sample_token_format} /> + + + + <.row id="errors"> + <:col> +

Errors

+

+ All error responses follow + + RFC 9457 + + — a structured application/problem+json + body + with a stable title + field and per-field errors + on validation failures. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StatusWhen
400Missing or malformed query / body parameter.
401No / invalid / revoked / expired bearer token.
403Authenticated, but the action is forbidden for the caller.
404Resource not found, or hidden by privacy.
409Duplicate — an equivalent resource already exists.
422Validation failed; errors carries per-field messages.
429 + Rate limit exceeded; check RateLimit-* + + Retry-After. +
5xxBug. Please open an issue.
+ + <:code> + <.code_block title="422 validation failure" lang="json" code={@sample_error_422} /> + + + + <.row id="rate-limiting"> + <:col> +

Rate limiting

+

+ Every response carries the + + RFC 9651 + + RateLimit-* + headers so clients can self-throttle + before hitting a 429. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
HeaderMeaning
RateLimit-LimitRequests permitted in the current window.
RateLimit-RemainingRequests remaining in the current window.
RateLimit-ResetSeconds until the window resets.
Retry-AfterSent on 429s; retry no sooner than this many seconds.
+ +

Defaults

+ + + + + + + + + + + + + + + + + + + + + +
CallerLimit
Anonymous (per IP)60 req / minute
Authenticated (per token)600 req / minute
POST /auth/tokens (per IP)30 req / minute
+ + <:code> + <.code_block title="Response headers" code={@sample_rate_headers} /> + + + +
+ +

Endpoint reference

+ + <.endpoint id="auth-tokens" method="POST" path="/auth/tokens"> + <:summary>Issue an API token + <:body> +

+ Prefer the + <.link navigate="/users/settings#api-tokens" class="api-docs-link"> + account settings page + + for interactive use; this endpoint is here for scripts that need + to mint a token without leaving the terminal. +

+ +

Request body

+ <.properties> + <:property name="email" type="string" required> + Account email address. + + <:property name="password" type="string" required> + Account password. + + <:property name="name" type="string" required> + Human-readable label so you can identify the token later. + + <:property name="expires_at" type="datetime"> + ISO 8601 UTC. Omit for a token that lives until revoked. + + + + <:code> + <.code_block + title="Request" + tag="POST" + label="/auth/tokens" + code={@sample_auth_tokens_request} + /> + <.code_block title="Response · 201" lang="json" code={@sample_auth_tokens_response} /> + + + + <.endpoint id="me" method="GET" path="/me"> + <:summary>The authenticated user + <:body> +

+ Returns the user behind the bearer token: callsign, name, + email, home QTH, and admin flag. +

+

+ PATCH /me + accepts any subset of home_grid, home_lat, home_lon, home_elevation_m. Grid is auto-derived from + lat/lon and vice versa. +

+ + <:code> + <.code_block title="Request" tag="GET" label="/me" code={@sample_me_get} /> + <.code_block title="Update home QTH" tag="PATCH" label="/me" code={@sample_me_patch} /> + + + + <.endpoint id="me-collections" method="GET" path="/me/contacts"> + <:summary>User-scoped collections + <:body> +

+ Two sibling endpoints return everything submitted under the + authenticated user's account, newest first: +

+
    +
  • GET /me/contacts — every QSO submitted by the user.
  • +
  • + GET /me/beacons + — every beacon (approved or pending) submitted by the user. +
  • +
+

+ Both honour standard page + per_page pagination. +

+ + <:code> + <.code_block + title="List my contacts" + tag="GET" + label="/me/contacts" + code={@sample_me_contacts} + /> + + + + <.endpoint id="me-tokens" method="GET" path="/me/api-tokens"> + <:summary>Manage your API tokens + <:body> +

+ GET /me/api-tokens + lists every non-revoked token belonging to the authenticated + user. The plaintext value is never returned — only the + metadata (id, name, timestamps). +

+

+ DELETE /me/api-tokens/:id + revokes a token. The row is soft-deleted (its revoked_at + is set) and any in-flight request using it is invalidated on + the next call. +

+ + <:code> + <.code_block + title="List tokens" + tag="GET" + label="/me/api-tokens" + code={@sample_me_tokens_list} + /> + <.code_block + title="Revoke a token" + tag="DELETE" + label="/me/api-tokens/:id" + code={@sample_me_tokens_revoke} + /> + + + + <.endpoint id="me-monitors" method="GET" path="/me/beacon-monitors"> + <:summary>Beacon monitor stations + <:body> +

+ Distributed beacon monitor stations that report SNR samples + back to Microwaveprop. Each monitor row has a token + field — the credential the monitor program uses to + identify itself when reporting. +

+

Operations

+
    +
  • GET /me/beacon-monitors — list.
  • +
  • + POST /me/beacon-monitors + — create. Returns the new monitor's plaintext token (shown once). +
  • +
  • DELETE /me/beacon-monitors/:id — revoke and remove.
  • +
+ + <:code> + <.code_block + title="Create a monitor" + tag="POST" + label="/me/beacon-monitors" + code={@sample_monitor_create} + /> + + + + <.endpoint id="contacts" method="GET" path="/contacts"> + <:summary>Public contact (QSO) feed + <:body> +

Paginated list of QSOs.

+ +

Query parameters

+ <.properties> + <:property name="page" type="integer"> + 1-based. Defaults to 1. + + <:property name="per_page" type="integer"> + Defaults to 50; capped at 200. + + <:property name="search" type="string"> + One or two callsigns; matches station1 or station2. + + + +

+ When called with a bearer token, the user's own private + contacts are included in addition to the public set. +

+ +

+ GET /contacts/:id + returns a single QSO. Private QSOs return 404 to non-owners. +

+ + <:code> + <.code_block + title="Search by callsign" + tag="GET" + label="/contacts" + code={@sample_contacts_search} + /> + + + + <.endpoint method="POST" path="/contacts"> + <:summary>Submit a QSO + <:body> +

+ Create a new QSO under the authenticated user's account; their + email is recorded as submitter_email. +

+ +

Required fields

+ <.properties> + <:property name="station1" type="string" required>Submitter callsign. + <:property name="station2" type="string" required> + Other operator's callsign. + + <:property name="qso_timestamp" type="datetime" required>ISO 8601 UTC. + <:property name="band" type="string" required> + MHz integer string ("10000", "24000"). + + <:property name="grid1" type="string" required> + 4- or 6-character Maidenhead. + + <:property name="grid2" type="string" required>Other end's grid. + <:property name="mode" type="string" required> + Mode (e.g. CW, SSB). + + + +

Optional fields

+ <.properties> + <:property name="user_declared_prop_mode" type="string"> + e.g. tropo, aircraft_scatter. + + <:property name="height1_ft" type="number">Antenna height above ground. + <:property name="height2_ft" type="number">Other end's antenna height. + <:property name="private" type="boolean">Hide from the public feed. + <:property name="notes" type="string">Free-form text. + + +

+ A duplicate (same stations + same hour + same band) returns + 409 Conflict + with the existing record in the existing + field. +

+ + <:code> + <.code_block + title="Submit a QSO" + tag="POST" + label="/contacts" + code={@sample_contact_submit} + /> + + + + <.endpoint id="beacons" method="GET" path="/beacons"> + <:summary>Beacons + <:body> +

+ GET /beacons + lists approved beacons; GET /beacons/:id + reads a + single one. +

+

+ POST /beacons + is unauthenticated — the new beacon is created in approved=false + state until an admin approves it via the website. +

+ + <:code> + <.code_block + title="Submit a beacon" + tag="POST" + label="/beacons" + code={@sample_beacon_submit} + /> + + + + <.endpoint id="scores" method="GET" path="/scores"> + <:summary>Propagation score at a grid point + <:body> +

+ Returns the composite propagation score plus the per-factor + breakdown (rain, humidity, refractivity, …) for one + grid point at one band. +

+ +

Query parameters

+ <.properties> + <:property name="band" type="integer" required>MHz. + <:property name="lat" type="number" required>Decimal degrees. + <:property name="lon" type="number" required>Decimal degrees. + <:property name="valid_time" type="datetime"> + ISO 8601 UTC. Defaults to the latest scored hour. + + + +

+ GET /scores/bands + lists every band the engine scores for, with each band's + humidity-effect classification (beneficial / harmful). +

+ + <:code> + <.code_block + title="Score for Dallas, 10 GHz" + tag="GET" + label="/scores" + code={@sample_scores} + /> + + + + <.endpoint id="forecast" method="GET" path="/forecast"> + <:summary>18-hour score timeline + <:body> +

+ Returns the score at one grid point across the available + forecast horizon (currently 18 hours). Same band + + lat + + lon + parameters as /scores. +

+ + <:code> + <.code_block + title="18 h timeline" + tag="GET" + label="/forecast" + code={@sample_forecast} + /> + + + + <.endpoint id="profiles" method="GET" path="/profiles/:callsign"> + <:summary>Public per-user profile + <:body> +

+ Public per-user profile: callsign, name, home QTH, all public + contacts involving the callsign, plus all approved beacons + submitted by the user. Email is not exposed. +

+ + <:code> + <.code_block + title="Read a profile" + tag="GET" + label="/profiles/W5XD" + code={@sample_profile} + /> + + + +
+ +
+

Conventions

+
    +
  • All timestamps are ISO 8601 UTC (...Z).
  • +
  • All identifiers are UUIDv7 (binary_id) strings.
  • +
  • Bands are integer MHz strings ("10000", "24000").
  • +
  • Latitude / longitude are decimal degrees.
  • +
  • + Maidenhead grids are 4- or 6-character (EM12, EM12kx). +
  • +
+
+ +
+

Stability promise

+
    +
  • Adding new fields to existing responses is non-breaking.
  • +
  • Removing or renaming fields will only happen in a new /api/vN.
  • +
  • Adding new endpoints to /api/v1 is non-breaking.
  • +
  • Tightening validation may produce new 422s; called out in the changelog.
  • +
+
+ +
+

See also

+
    +
  • + <.link href="/docs/api/openapi.yaml" class="api-docs-link"> + openapi.yaml + + — the machine-readable spec. +
  • +
  • + /.well-known/api-catalog — RFC 9727 service descriptor (public). +
  • +
  • + <.link navigate="/algo" class="api-docs-link">/algo + — the scoring algorithm in detail. +
  • +
+
+
+
""" end + + # ---------------------------------------------------------------- components + + attr :title, :string, required: true + slot :inner_block, required: true + + defp nav_group(assigns) do + ~H""" +
+

{@title}

+
    {render_slot(@inner_block)}
+
+ """ + end + + attr :href, :string, required: true + slot :inner_block, required: true + + defp nav_link(assigns) do + ~H""" +
  • + {render_slot(@inner_block)} +
  • + """ + end + + attr :id, :string, default: nil + slot :col, required: true + slot :code, required: true + + defp row(assigns) do + ~H""" +
    +
    {render_slot(@col)}
    +
    {render_slot(@code)}
    +
    + """ + end + + attr :id, :string, default: nil + attr :method, :string, required: true + attr :path, :string, required: true + slot :summary, required: true + slot :body, required: true + slot :code, required: true + + defp endpoint(assigns) do + ~H""" +
    +
    +
    + <.method_tag method={@method} /> + + {@path} +
    +

    {render_slot(@summary)}

    + {render_slot(@body)} +
    +
    {render_slot(@code)}
    +
    + """ + end + + attr :method, :string, required: true + + defp method_tag(assigns) do + color = + case String.upcase(assigns.method) do + "GET" -> "is-get" + "POST" -> "is-post" + "PUT" -> "is-put" + "PATCH" -> "is-patch" + "DELETE" -> "is-delete" + _ -> "is-zinc" + end + + assigns = assign(assigns, :color, color) + + ~H""" + @color}>{String.upcase(@method)} + """ + end + + attr :code, :string, required: true + attr :title, :string, default: nil + attr :tag, :string, default: nil + attr :label, :string, default: nil + attr :lang, :string, default: "bash" + + defp code_block(assigns) do + assigns = assign(assigns, :code, dedent(assigns.code)) + + ~H""" +
    +
    + {@title} + + <.method_tag :if={@tag} method={@tag} /> + {@label} + +
    +
     @lang}>{@code}
    +
    + """ + end + + # Strip uniform leading whitespace so source-side indentation is dropped. + defp dedent(text) when is_binary(text) do + text = String.trim_trailing(text) + lines = String.split(text, "\n") + + indents = + lines + |> Enum.reject(&(String.trim(&1) == "")) + |> Enum.map(&leading_spaces/1) + + case indents do + [] -> text + _ -> Enum.map_join(lines, "\n", &drop_prefix(&1, Enum.min(indents))) + end + end + + defp leading_spaces(line) do + line + |> String.graphemes() + |> Enum.take_while(&(&1 == " ")) + |> length() + end + + defp drop_prefix(line, n) do + case line do + "" -> "" + _ -> String.slice(line, min(n, String.length(line))..-1//1) + end + end + + slot :property do + attr :name, :string, required: true + attr :type, :string, required: true + attr :required, :boolean + end + + defp properties(assigns) do + ~H""" +
    +
    +
    + {prop.name} + {prop.type} + required +
    +
    {render_slot(prop)}
    +
    +
    + """ + end end diff --git a/test/microwaveprop_web/live/api_docs_live_test.exs b/test/microwaveprop_web/live/api_docs_live_test.exs index 84c246bd..71407af5 100644 --- a/test/microwaveprop_web/live/api_docs_live_test.exs +++ b/test/microwaveprop_web/live/api_docs_live_test.exs @@ -6,21 +6,44 @@ defmodule MicrowavepropWeb.ApiDocsLiveTest do test "renders the API reference at /docs/api", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/docs/api") - assert html =~ "Microwaveprop REST API" + assert html =~ "Microwaveprop API" assert html =~ "/api/v1" - assert html =~ "POST /auth/tokens" + assert html =~ "/auth/tokens" end - test "renders bulleted lists rather than literal asterisks", %{conn: conn} do + test "renders the protocol-style two-pane layout shell", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/docs/api") - assert html =~ "