HRRR publishes f21-f48 at 3-hourly intervals in addition to the hourly f01-f18 we already fetch. This extends the grid_tasks seed from 19 rows (1 analysis + 18 forecast) to 29 rows (1 analysis + 18 hourly + 10 3-hourly), and bumps the UI forecast window constant from 18h to 48h so the map timeline and path calculator forecast chart automatically show the extended horizon. No Rust logic changes needed — the pipeline is data-driven and u8 forecast_hour already handles f48.
1085 lines
39 KiB
Elixir
1085 lines
39 KiB
Elixir
defmodule MicrowavepropWeb.ApiDocsLive do
|
|
@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
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# 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,
|
|
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"""
|
|
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-none">
|
|
<div class="api-docs-shell">
|
|
<aside class="api-docs-sidebar">
|
|
<nav aria-label="API documentation">
|
|
<.nav_group title="Guides">
|
|
<.nav_link href="#introduction">Introduction</.nav_link>
|
|
<.nav_link href="#quickstart">Quickstart</.nav_link>
|
|
<.nav_link href="#authentication">Authentication</.nav_link>
|
|
<.nav_link href="#errors">Errors</.nav_link>
|
|
<.nav_link href="#rate-limiting">Rate limiting</.nav_link>
|
|
<.nav_link href="#conventions">Conventions</.nav_link>
|
|
<.nav_link href="#stability">Stability promise</.nav_link>
|
|
</.nav_group>
|
|
|
|
<.nav_group title="Endpoints">
|
|
<.nav_link href="#auth-tokens">Auth tokens</.nav_link>
|
|
<.nav_link href="#me">Current user</.nav_link>
|
|
<.nav_link href="#me-collections">User collections</.nav_link>
|
|
<.nav_link href="#me-tokens">API tokens</.nav_link>
|
|
<.nav_link href="#me-monitors">Beacon monitors</.nav_link>
|
|
<.nav_link href="#contacts">Contacts</.nav_link>
|
|
<.nav_link href="#beacons">Beacons</.nav_link>
|
|
<.nav_link href="#scores">Scores</.nav_link>
|
|
<.nav_link href="#forecast">Forecast</.nav_link>
|
|
<.nav_link href="#profiles">Profiles</.nav_link>
|
|
</.nav_group>
|
|
|
|
<.nav_group title="Reference">
|
|
<.nav_link href="/docs/api/openapi.yaml">OpenAPI spec</.nav_link>
|
|
<.nav_link href="/docs/api/README.md">Markdown source</.nav_link>
|
|
<.nav_link href="/.well-known/api-catalog">Service catalog</.nav_link>
|
|
</.nav_group>
|
|
</nav>
|
|
</aside>
|
|
|
|
<main class="api-docs-main">
|
|
<article class="api-docs-prose">
|
|
<header id="introduction" class="api-docs-hero">
|
|
<p class="api-docs-eyebrow">REST API · v1</p>
|
|
<h1>Microwaveprop API</h1>
|
|
<p class="api-docs-lead">
|
|
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.
|
|
</p>
|
|
|
|
<dl class="api-docs-meta">
|
|
<div>
|
|
<dt>Base URL</dt>
|
|
<dd><code>https://prop.w5isp.com/api/v1</code></dd>
|
|
</div>
|
|
<div>
|
|
<dt>Versioning</dt>
|
|
<dd>
|
|
Path-based (<code>/api/v1</code>). Breaking changes ship as <code>/api/v2</code>.
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Auth</dt>
|
|
<dd>
|
|
Opaque bearer tokens (<code>Authorization: Bearer mwp_…</code>).
|
|
Mint one from your
|
|
<.link navigate="/users/settings#api-tokens" class="api-docs-link">
|
|
account settings
|
|
</.link>
|
|
or programmatically at <code>POST /api/v1/auth/tokens</code>.
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Format</dt>
|
|
<dd>
|
|
<code>application/json</code>
|
|
requests and responses. Errors use <a
|
|
href="https://www.rfc-editor.org/rfc/rfc9457"
|
|
class="api-docs-link"
|
|
>
|
|
RFC 9457 problem+json
|
|
</a>.
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Spec</dt>
|
|
<dd>
|
|
<.link href="/docs/api/openapi.yaml" class="api-docs-link">
|
|
openapi.yaml
|
|
</.link>
|
|
(OpenAPI 3.1)
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</header>
|
|
|
|
<.row id="quickstart">
|
|
<:col>
|
|
<h2>Quickstart</h2>
|
|
<p>
|
|
Mint a long-lived bearer token once, then use it for every
|
|
subsequent call. Tokens are 32 random bytes, URL-base64 encoded
|
|
with an <code>mwp_</code> prefix so leak scanners can spot them.
|
|
</p>
|
|
<p class="api-docs-callout">
|
|
The interactive way to mint a token is the
|
|
<.link navigate="/users/settings#api-tokens" class="api-docs-link">
|
|
account settings page
|
|
</.link>
|
|
— the value is shown once. Keep it like a password.
|
|
</p>
|
|
</:col>
|
|
<:code>
|
|
<.code_block
|
|
title="Mint a token"
|
|
tag="POST"
|
|
label="/auth/tokens"
|
|
code={@sample_quickstart}
|
|
/>
|
|
</:code>
|
|
</.row>
|
|
|
|
<.row id="authentication">
|
|
<:col>
|
|
<h2>Authentication</h2>
|
|
<p>
|
|
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.
|
|
</p>
|
|
|
|
<h3>Endpoint auth matrix</h3>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Endpoint</th>
|
|
<th>Auth</th>
|
|
<th>Notes</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>POST /auth/tokens</code></td>
|
|
<td>password</td>
|
|
<td>The only endpoint that accepts a password.</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>GET / PATCH /me</code></td>
|
|
<td>bearer</td>
|
|
<td>The user behind the bearer token.</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>POST /contacts</code></td>
|
|
<td>bearer</td>
|
|
<td>Regular user QSO submission.</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>GET /contacts</code></td>
|
|
<td>optional</td>
|
|
<td>Public; bearer reveals viewer-private rows.</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>GET /beacons</code></td>
|
|
<td>none</td>
|
|
<td>Approved beacons only.</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>POST /beacons</code></td>
|
|
<td>bearer</td>
|
|
<td>New beacons start unapproved.</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>GET /scores</code></td>
|
|
<td>none</td>
|
|
<td>Public read of propagation scores.</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>GET /profiles/:call</code></td>
|
|
<td>none</td>
|
|
<td>Public per-callsign profile.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<h3>Token lifecycle</h3>
|
|
<ul>
|
|
<li>
|
|
Tokens may carry an optional <code>expires_at</code>
|
|
(ISO 8601 UTC). Without one they live until revoked.
|
|
</li>
|
|
<li>
|
|
<code>GET /me/api-tokens</code> lists every non-revoked token belonging to the
|
|
authenticated user (without plaintext).
|
|
</li>
|
|
<li>
|
|
<code>DELETE /me/api-tokens/:id</code>
|
|
revokes a token (soft delete — the row remains for
|
|
audit).
|
|
</li>
|
|
<li>
|
|
Revoking the token used by the current request immediately
|
|
invalidates every subsequent request from that token.
|
|
</li>
|
|
</ul>
|
|
</:col>
|
|
<:code>
|
|
<.code_block title="Authorization header" code={@sample_auth_header} />
|
|
<.code_block title="Token format" code={@sample_token_format} />
|
|
</:code>
|
|
</.row>
|
|
|
|
<.row id="errors">
|
|
<:col>
|
|
<h2>Errors</h2>
|
|
<p>
|
|
All error responses follow
|
|
<a href="https://www.rfc-editor.org/rfc/rfc9457" class="api-docs-link">
|
|
RFC 9457
|
|
</a>
|
|
— a structured <code>application/problem+json</code>
|
|
body
|
|
with a stable <code>title</code>
|
|
field and per-field <code>errors</code>
|
|
on validation failures.
|
|
</p>
|
|
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Status</th>
|
|
<th>When</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>400</td>
|
|
<td>Missing or malformed query / body parameter.</td>
|
|
</tr>
|
|
<tr>
|
|
<td>401</td>
|
|
<td>No / invalid / revoked / expired bearer token.</td>
|
|
</tr>
|
|
<tr>
|
|
<td>403</td>
|
|
<td>Authenticated, but the action is forbidden for the caller.</td>
|
|
</tr>
|
|
<tr>
|
|
<td>404</td>
|
|
<td>Resource not found, or hidden by privacy.</td>
|
|
</tr>
|
|
<tr>
|
|
<td>409</td>
|
|
<td>Duplicate — an equivalent resource already exists.</td>
|
|
</tr>
|
|
<tr>
|
|
<td>422</td>
|
|
<td>Validation failed; <code>errors</code> carries per-field messages.</td>
|
|
</tr>
|
|
<tr>
|
|
<td>429</td>
|
|
<td>
|
|
Rate limit exceeded; check <code>RateLimit-*</code>
|
|
+ <code>Retry-After</code>.
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>5xx</td>
|
|
<td>Bug. Please open an issue.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</:col>
|
|
<:code>
|
|
<.code_block title="422 validation failure" lang="json" code={@sample_error_422} />
|
|
</:code>
|
|
</.row>
|
|
|
|
<.row id="rate-limiting">
|
|
<:col>
|
|
<h2>Rate limiting</h2>
|
|
<p>
|
|
Every response carries the
|
|
<a href="https://datatracker.ietf.org/doc/rfc9651/" class="api-docs-link">
|
|
RFC 9651
|
|
</a>
|
|
<code>RateLimit-*</code>
|
|
headers so clients can self-throttle
|
|
before hitting a 429.
|
|
</p>
|
|
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Header</th>
|
|
<th>Meaning</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>RateLimit-Limit</code></td>
|
|
<td>Requests permitted in the current window.</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>RateLimit-Remaining</code></td>
|
|
<td>Requests remaining in the current window.</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>RateLimit-Reset</code></td>
|
|
<td>Seconds until the window resets.</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>Retry-After</code></td>
|
|
<td>Sent on 429s; retry no sooner than this many seconds.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<h3>Defaults</h3>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Caller</th>
|
|
<th>Limit</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>Anonymous (per IP)</td>
|
|
<td>60 req / minute</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Authenticated (per token)</td>
|
|
<td>600 req / minute</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>POST /auth/tokens</code> (per IP)</td>
|
|
<td>30 req / minute</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</:col>
|
|
<:code>
|
|
<.code_block title="Response headers" code={@sample_rate_headers} />
|
|
</:code>
|
|
</.row>
|
|
|
|
<hr class="api-docs-divider" />
|
|
|
|
<h2 class="api-docs-section-heading">Endpoint reference</h2>
|
|
|
|
<.endpoint id="auth-tokens" method="POST" path="/auth/tokens">
|
|
<:summary>Issue an API token</:summary>
|
|
<:body>
|
|
<p>
|
|
Prefer the
|
|
<.link navigate="/users/settings#api-tokens" class="api-docs-link">
|
|
account settings page
|
|
</.link>
|
|
for interactive use; this endpoint is here for scripts that need
|
|
to mint a token without leaving the terminal.
|
|
</p>
|
|
|
|
<h4>Request body</h4>
|
|
<.properties>
|
|
<:property name="email" type="string" required>
|
|
Account email address.
|
|
</:property>
|
|
<:property name="password" type="string" required>
|
|
Account password.
|
|
</:property>
|
|
<:property name="name" type="string" required>
|
|
Human-readable label so you can identify the token later.
|
|
</:property>
|
|
<:property name="expires_at" type="datetime">
|
|
ISO 8601 UTC. Omit for a token that lives until revoked.
|
|
</:property>
|
|
</.properties>
|
|
</:body>
|
|
<: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} />
|
|
</:code>
|
|
</.endpoint>
|
|
|
|
<.endpoint id="me" method="GET" path="/me">
|
|
<:summary>The authenticated user</:summary>
|
|
<:body>
|
|
<p>
|
|
Returns the user behind the bearer token: callsign, name,
|
|
email, home QTH, and admin flag.
|
|
</p>
|
|
<p>
|
|
<code>PATCH /me</code>
|
|
accepts any subset of <code>home_grid</code>, <code>home_lat</code>, <code>home_lon</code>, <code>home_elevation_m</code>. Grid is auto-derived from
|
|
lat/lon and vice versa.
|
|
</p>
|
|
</:body>
|
|
<: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} />
|
|
</:code>
|
|
</.endpoint>
|
|
|
|
<.endpoint id="me-collections" method="GET" path="/me/contacts">
|
|
<:summary>User-scoped collections</:summary>
|
|
<:body>
|
|
<p>
|
|
Two sibling endpoints return everything submitted under the
|
|
authenticated user's account, newest first:
|
|
</p>
|
|
<ul>
|
|
<li><code>GET /me/contacts</code> — every QSO submitted by the user.</li>
|
|
<li>
|
|
<code>GET /me/beacons</code>
|
|
— every beacon (approved or pending) submitted by the user.
|
|
</li>
|
|
</ul>
|
|
<p>
|
|
Both honour standard <code>page</code> + <code>per_page</code> pagination.
|
|
</p>
|
|
</:body>
|
|
<:code>
|
|
<.code_block
|
|
title="List my contacts"
|
|
tag="GET"
|
|
label="/me/contacts"
|
|
code={@sample_me_contacts}
|
|
/>
|
|
</:code>
|
|
</.endpoint>
|
|
|
|
<.endpoint id="me-tokens" method="GET" path="/me/api-tokens">
|
|
<:summary>Manage your API tokens</:summary>
|
|
<:body>
|
|
<p>
|
|
<code>GET /me/api-tokens</code>
|
|
lists every non-revoked token belonging to the authenticated
|
|
user. The plaintext value is never returned — only the
|
|
metadata (id, name, timestamps).
|
|
</p>
|
|
<p>
|
|
<code>DELETE /me/api-tokens/:id</code>
|
|
revokes a token. The row is soft-deleted (its <code>revoked_at</code>
|
|
is set) and any in-flight request using it is invalidated on
|
|
the next call.
|
|
</p>
|
|
</:body>
|
|
<: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}
|
|
/>
|
|
</:code>
|
|
</.endpoint>
|
|
|
|
<.endpoint id="me-monitors" method="GET" path="/me/beacon-monitors">
|
|
<:summary>Beacon monitor stations</:summary>
|
|
<:body>
|
|
<p>
|
|
Distributed beacon monitor stations that report SNR samples
|
|
back to Microwaveprop. Each monitor row has a <code>token</code>
|
|
field — the credential the monitor program uses to
|
|
identify itself when reporting.
|
|
</p>
|
|
<h4>Operations</h4>
|
|
<ul>
|
|
<li><code>GET /me/beacon-monitors</code> — list.</li>
|
|
<li>
|
|
<code>POST /me/beacon-monitors</code>
|
|
— create. Returns the new monitor's plaintext token (shown once).
|
|
</li>
|
|
<li><code>DELETE /me/beacon-monitors/:id</code> — revoke and remove.</li>
|
|
</ul>
|
|
</:body>
|
|
<:code>
|
|
<.code_block
|
|
title="Create a monitor"
|
|
tag="POST"
|
|
label="/me/beacon-monitors"
|
|
code={@sample_monitor_create}
|
|
/>
|
|
</:code>
|
|
</.endpoint>
|
|
|
|
<.endpoint id="contacts" method="GET" path="/contacts">
|
|
<:summary>Public contact (QSO) feed</:summary>
|
|
<:body>
|
|
<p>Paginated list of QSOs.</p>
|
|
|
|
<h4>Query parameters</h4>
|
|
<.properties>
|
|
<:property name="page" type="integer">
|
|
1-based. Defaults to <code>1</code>.
|
|
</:property>
|
|
<:property name="per_page" type="integer">
|
|
Defaults to <code>50</code>; capped at <code>200</code>.
|
|
</:property>
|
|
<:property name="search" type="string">
|
|
One or two callsigns; matches station1 or station2.
|
|
</:property>
|
|
</.properties>
|
|
|
|
<p>
|
|
When called with a bearer token, the user's own private
|
|
contacts are included in addition to the public set.
|
|
</p>
|
|
|
|
<p>
|
|
<code>GET /contacts/:id</code>
|
|
returns a single QSO. Private QSOs return 404 to non-owners.
|
|
</p>
|
|
</:body>
|
|
<:code>
|
|
<.code_block
|
|
title="Search by callsign"
|
|
tag="GET"
|
|
label="/contacts"
|
|
code={@sample_contacts_search}
|
|
/>
|
|
</:code>
|
|
</.endpoint>
|
|
|
|
<.endpoint method="POST" path="/contacts">
|
|
<:summary>Submit a QSO</:summary>
|
|
<:body>
|
|
<p>
|
|
Create a new QSO under the authenticated user's account; their
|
|
email is recorded as <code>submitter_email</code>.
|
|
</p>
|
|
|
|
<h4>Required fields</h4>
|
|
<.properties>
|
|
<:property name="station1" type="string" required>Submitter callsign.</:property>
|
|
<:property name="station2" type="string" required>
|
|
Other operator's callsign.
|
|
</:property>
|
|
<:property name="qso_timestamp" type="datetime" required>ISO 8601 UTC.</:property>
|
|
<:property name="band" type="string" required>
|
|
MHz integer string (<code>"10000"</code>, <code>"24000"</code>).
|
|
</:property>
|
|
<:property name="grid1" type="string" required>
|
|
4- or 6-character Maidenhead.
|
|
</:property>
|
|
<:property name="grid2" type="string" required>Other end's grid.</:property>
|
|
<:property name="mode" type="string" required>
|
|
Mode (e.g. <code>CW</code>, <code>SSB</code>).
|
|
</:property>
|
|
</.properties>
|
|
|
|
<h4>Optional fields</h4>
|
|
<.properties>
|
|
<:property name="user_declared_prop_mode" type="string">
|
|
e.g. <code>tropo</code>, <code>aircraft_scatter</code>.
|
|
</:property>
|
|
<:property name="height1_ft" type="number">Antenna height above ground.</:property>
|
|
<:property name="height2_ft" type="number">Other end's antenna height.</:property>
|
|
<:property name="private" type="boolean">Hide from the public feed.</:property>
|
|
<:property name="notes" type="string">Free-form text.</:property>
|
|
</.properties>
|
|
|
|
<p>
|
|
A duplicate (same stations + same hour + same band) returns
|
|
<code>409 Conflict</code>
|
|
with the existing record in the <code>existing</code>
|
|
field.
|
|
</p>
|
|
</:body>
|
|
<:code>
|
|
<.code_block
|
|
title="Submit a QSO"
|
|
tag="POST"
|
|
label="/contacts"
|
|
code={@sample_contact_submit}
|
|
/>
|
|
</:code>
|
|
</.endpoint>
|
|
|
|
<.endpoint id="beacons" method="GET" path="/beacons">
|
|
<:summary>Beacons</:summary>
|
|
<:body>
|
|
<p>
|
|
<code>GET /beacons</code>
|
|
lists approved beacons; <code>GET /beacons/:id</code>
|
|
reads a
|
|
single one.
|
|
</p>
|
|
<p>
|
|
<code>POST /beacons</code>
|
|
is unauthenticated — the new beacon is created in <code>approved=false</code>
|
|
state until an admin approves it via the website.
|
|
</p>
|
|
</:body>
|
|
<:code>
|
|
<.code_block
|
|
title="Submit a beacon"
|
|
tag="POST"
|
|
label="/beacons"
|
|
code={@sample_beacon_submit}
|
|
/>
|
|
</:code>
|
|
</.endpoint>
|
|
|
|
<.endpoint id="scores" method="GET" path="/scores">
|
|
<:summary>Propagation score at a grid point</:summary>
|
|
<:body>
|
|
<p>
|
|
Returns the composite propagation score plus the per-factor
|
|
breakdown (rain, humidity, refractivity, …) for one
|
|
grid point at one band.
|
|
</p>
|
|
|
|
<h4>Query parameters</h4>
|
|
<.properties>
|
|
<:property name="band" type="integer" required>MHz.</:property>
|
|
<:property name="lat" type="number" required>Decimal degrees.</:property>
|
|
<:property name="lon" type="number" required>Decimal degrees.</:property>
|
|
<:property name="valid_time" type="datetime">
|
|
ISO 8601 UTC. Defaults to the latest scored hour.
|
|
</:property>
|
|
</.properties>
|
|
|
|
<p>
|
|
<code>GET /scores/bands</code>
|
|
lists every band the engine scores for, with each band's
|
|
humidity-effect classification (beneficial / harmful).
|
|
</p>
|
|
</:body>
|
|
<:code>
|
|
<.code_block
|
|
title="Score for Dallas, 10 GHz"
|
|
tag="GET"
|
|
label="/scores"
|
|
code={@sample_scores}
|
|
/>
|
|
</:code>
|
|
</.endpoint>
|
|
|
|
<.endpoint id="forecast" method="GET" path="/forecast">
|
|
<:summary>48-hour score timeline</:summary>
|
|
<:body>
|
|
<p>
|
|
Returns the score at one grid point across the available
|
|
forecast horizon (currently 48 hours). Same <code>band</code>
|
|
+ <code>lat</code>
|
|
+ <code>lon</code>
|
|
parameters as <code>/scores</code>.
|
|
</p>
|
|
</:body>
|
|
<:code>
|
|
<.code_block
|
|
title="18 h timeline"
|
|
tag="GET"
|
|
label="/forecast"
|
|
code={@sample_forecast}
|
|
/>
|
|
</:code>
|
|
</.endpoint>
|
|
|
|
<.endpoint id="profiles" method="GET" path="/profiles/:callsign">
|
|
<:summary>Public per-user profile</:summary>
|
|
<:body>
|
|
<p>
|
|
Public per-user profile: callsign, name, home QTH, all public
|
|
contacts involving the callsign, plus all approved beacons
|
|
submitted by the user. Email is <strong>not</strong> exposed.
|
|
</p>
|
|
</:body>
|
|
<:code>
|
|
<.code_block
|
|
title="Read a profile"
|
|
tag="GET"
|
|
label="/profiles/W5XD"
|
|
code={@sample_profile}
|
|
/>
|
|
</:code>
|
|
</.endpoint>
|
|
|
|
<hr class="api-docs-divider" />
|
|
|
|
<section id="conventions">
|
|
<h2>Conventions</h2>
|
|
<ul>
|
|
<li>All timestamps are ISO 8601 UTC (<code>...Z</code>).</li>
|
|
<li>All identifiers are UUIDv7 (binary_id) strings.</li>
|
|
<li>Bands are integer MHz strings (<code>"10000"</code>, <code>"24000"</code>).</li>
|
|
<li>Latitude / longitude are decimal degrees.</li>
|
|
<li>
|
|
Maidenhead grids are 4- or 6-character (<code>EM12</code>, <code>EM12kx</code>).
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<section id="stability">
|
|
<h2>Stability promise</h2>
|
|
<ul>
|
|
<li>Adding new fields to existing responses is non-breaking.</li>
|
|
<li>Removing or renaming fields will only happen in a new <code>/api/vN</code>.</li>
|
|
<li>Adding new endpoints to <code>/api/v1</code> is non-breaking.</li>
|
|
<li>Tightening validation may produce new 422s; called out in the changelog.</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<section id="see-also">
|
|
<h2>See also</h2>
|
|
<ul>
|
|
<li>
|
|
<.link href="/docs/api/openapi.yaml" class="api-docs-link">
|
|
openapi.yaml
|
|
</.link>
|
|
— the machine-readable spec.
|
|
</li>
|
|
<li>
|
|
<code>/.well-known/api-catalog</code> — RFC 9727 service descriptor (public).
|
|
</li>
|
|
<li>
|
|
<.link navigate="/algo" class="api-docs-link">/algo</.link>
|
|
— the scoring algorithm in detail.
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
</article>
|
|
</main>
|
|
</div>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
|
|
# ---------------------------------------------------------------- components
|
|
|
|
attr :title, :string, required: true
|
|
slot :inner_block, required: true
|
|
|
|
defp nav_group(assigns) do
|
|
~H"""
|
|
<div class="api-docs-nav-group">
|
|
<h3>{@title}</h3>
|
|
<ul>{render_slot(@inner_block)}</ul>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
attr :href, :string, required: true
|
|
slot :inner_block, required: true
|
|
|
|
defp nav_link(assigns) do
|
|
~H"""
|
|
<li>
|
|
<a href={@href}>{render_slot(@inner_block)}</a>
|
|
</li>
|
|
"""
|
|
end
|
|
|
|
attr :id, :string, default: nil
|
|
slot :col, required: true
|
|
slot :code, required: true
|
|
|
|
defp row(assigns) do
|
|
~H"""
|
|
<section id={@id} class="api-docs-row">
|
|
<div class="api-docs-row-prose">{render_slot(@col)}</div>
|
|
<div class="api-docs-row-code">{render_slot(@code)}</div>
|
|
</section>
|
|
"""
|
|
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"""
|
|
<section id={@id} class="api-docs-endpoint">
|
|
<div class="api-docs-endpoint-prose">
|
|
<div class="api-docs-eyebrow-row">
|
|
<.method_tag method={@method} />
|
|
<span class="api-docs-eyebrow-dot"></span>
|
|
<code class="api-docs-eyebrow-path">{@path}</code>
|
|
</div>
|
|
<h3 class="api-docs-endpoint-title">{render_slot(@summary)}</h3>
|
|
{render_slot(@body)}
|
|
</div>
|
|
<div class="api-docs-endpoint-code">{render_slot(@code)}</div>
|
|
</section>
|
|
"""
|
|
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"""
|
|
<span class={"api-docs-method " <> @color}>{String.upcase(@method)}</span>
|
|
"""
|
|
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"""
|
|
<figure class="api-docs-code">
|
|
<figcaption :if={@title || @tag || @label} class="api-docs-code-caption">
|
|
<span :if={@title} class="api-docs-code-title">{@title}</span>
|
|
<span :if={@tag || @label} class="api-docs-code-meta">
|
|
<.method_tag :if={@tag} method={@tag} />
|
|
<code :if={@label}>{@label}</code>
|
|
</span>
|
|
</figcaption>
|
|
<pre class={"api-docs-pre lang-" <> @lang}><code>{@code}</code></pre>
|
|
</figure>
|
|
"""
|
|
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"""
|
|
<dl class="api-docs-properties">
|
|
<div :for={prop <- @property} class="api-docs-property">
|
|
<dt>
|
|
<code class="api-docs-property-name">{prop.name}</code>
|
|
<span class="api-docs-property-type">{prop.type}</span>
|
|
<span :if={Map.get(prop, :required)} class="api-docs-property-required">required</span>
|
|
</dt>
|
|
<dd>{render_slot(prop)}</dd>
|
|
</div>
|
|
</dl>
|
|
"""
|
|
end
|
|
end
|