prop/lib/microwaveprop_web/live/api_docs_live.ex
Graham McIntire 91c9d98a99
feat(docs/api): protocol-style two-pane API reference layout
Replace the markdown-rendered /docs/api page with a structured
LiveView modelled on the Tailwind UI Protocol template — fixed
left sidebar with grouped section nav, hero with metadata dl,
two-column rows where prose sits next to a sticky code sample,
and endpoint cards with color-coded HTTP method tags.
2026-05-10 11:06:29 -05:00

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>
&mdash; 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 &mdash; 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>
&mdash; 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 &mdash; 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> &mdash; every QSO submitted by the user.</li>
<li>
<code>GET /me/beacons</code>
&mdash; 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 &mdash; 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 &mdash; the credential the monitor program uses to
identify itself when reporting.
</p>
<h4>Operations</h4>
<ul>
<li><code>GET /me/beacon-monitors</code> &mdash; list.</li>
<li>
<code>POST /me/beacon-monitors</code>
&mdash; create. Returns the new monitor's plaintext token (shown once).
</li>
<li><code>DELETE /me/beacon-monitors/:id</code> &mdash; 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 &mdash; 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, &hellip;) 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>18-hour score timeline</:summary>
<:body>
<p>
Returns the score at one grid point across the available
forecast horizon (currently 18 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>
&mdash; the machine-readable spec.
</li>
<li>
<code>/.well-known/api-catalog</code> &mdash; RFC 9727 service descriptor (public).
</li>
<li>
<.link navigate="/algo" class="api-docs-link">/algo</.link>
&mdash; 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