prop/docs/api/openapi.yaml
Graham McIntire c6d2c48264
feat: secure /api/v1 REST API for regular-user actions
Adds bearer-token authenticated REST API at /api/v1 covering every
action a non-admin user can perform on the website: contact + beacon
submission, beacon-monitor management, propagation queries, profile
read/update, and self-service API token issuance/revocation.

Security: SHA-256-hashed bearer tokens (mwp_ prefix, plaintext shown
once at creation), RFC 9457 problem+json error responses, RFC 9651
RateLimit-* headers backed by an ETS bucket (600/min per token,
60/min per anonymous IP, 30/min on /auth/tokens), private-contact
filtering by viewer.

Docs at docs/api/README.md (prose reference) and docs/api/openapi.yaml
(OpenAPI 3.1 spec covering every endpoint, response, and schema).

Tests: 124 new tests across schema, plug, error renderer, rate
limiter, fallback, and every controller. 16/17 API modules at 100%
line coverage; FallbackController at 87.5% (one defmodule line, an
Erlang-cover artifact for action_fallback-only modules).
2026-05-09 08:59:54 -05:00

699 lines
20 KiB
YAML

openapi: 3.1.0
info:
title: Microwaveprop Public API
version: "1.0.0"
summary: REST API for QSO submission, beacon management, and propagation queries.
description: |
Public REST API for the NTMS microwave propagation prediction service.
Implements every action a regular signed-in user can take on the
website (submit contacts and beacons, manage beacon monitors,
manage their own API tokens, query propagation scores), plus public
read-only endpoints for contacts, beacons, profiles, and scores.
Admin-only operations (user management, beacon approval, contact
moderation) are intentionally **not** exposed here.
See `docs/api/README.md` for prose, examples, and the stability
promise.
contact:
name: NTMS
url: https://w5hn.org/
servers:
- url: https://prop.w5isp.com/api/v1
description: Production
- url: http://localhost:4000/api/v1
description: Local development
security:
- bearerAuth: []
tags:
- name: auth
- name: me
- name: contacts
- name: beacons
- name: monitors
- name: scores
- name: profiles
paths:
/auth/tokens:
post:
tags: [auth]
summary: Issue a long-lived bearer token
security: []
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/AuthRequest" }
responses:
"201":
description: Token created
content:
application/json:
schema: { $ref: "#/components/schemas/AuthResponse" }
"400": { $ref: "#/components/responses/BadRequest" }
"401": { $ref: "#/components/responses/Unauthorized" }
"422": { $ref: "#/components/responses/ValidationFailed" }
"429": { $ref: "#/components/responses/RateLimited" }
/me:
get:
tags: [me]
summary: Current user profile
responses:
"200":
description: Profile
content:
application/json:
schema: { $ref: "#/components/schemas/MeResponse" }
"401": { $ref: "#/components/responses/Unauthorized" }
patch:
tags: [me]
summary: Update home QTH
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/HomeQthRequest" }
responses:
"200":
description: Updated profile
content:
application/json:
schema: { $ref: "#/components/schemas/MeResponse" }
"401": { $ref: "#/components/responses/Unauthorized" }
"422": { $ref: "#/components/responses/ValidationFailed" }
/me/contacts:
get:
tags: [me]
summary: My QSOs
responses:
"200":
description: List
content:
application/json:
schema: { $ref: "#/components/schemas/ContactList" }
"401": { $ref: "#/components/responses/Unauthorized" }
/me/beacons:
get:
tags: [me]
summary: My beacons
responses:
"200":
description: List
content:
application/json:
schema: { $ref: "#/components/schemas/BeaconList" }
"401": { $ref: "#/components/responses/Unauthorized" }
/me/api-tokens:
get:
tags: [me]
summary: List my API tokens
responses:
"200":
description: List
content:
application/json:
schema: { $ref: "#/components/schemas/TokenList" }
"401": { $ref: "#/components/responses/Unauthorized" }
/me/api-tokens/{id}:
delete:
tags: [me]
summary: Revoke an API token
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
responses:
"200":
description: Revoked record
content:
application/json:
schema: { $ref: "#/components/schemas/TokenResponse" }
"404": { $ref: "#/components/responses/NotFound" }
/me/beacon-monitors:
get:
tags: [monitors]
summary: List my beacon monitors
responses:
"200":
description: List
content:
application/json:
schema: { $ref: "#/components/schemas/BeaconMonitorList" }
post:
tags: [monitors]
summary: Create a beacon monitor
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/BeaconMonitorCreate" }
responses:
"201":
description: Created
content:
application/json:
schema: { $ref: "#/components/schemas/BeaconMonitorResponse" }
"422": { $ref: "#/components/responses/ValidationFailed" }
/me/beacon-monitors/{id}:
delete:
tags: [monitors]
summary: Delete a beacon monitor
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
responses:
"204":
description: No content
"404": { $ref: "#/components/responses/NotFound" }
/contacts:
get:
tags: [contacts]
summary: List public QSOs
security: []
parameters:
- in: query
name: page
schema: { type: integer, minimum: 1, default: 1 }
- in: query
name: per_page
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
- in: query
name: search
schema: { type: string }
responses:
"200":
description: Paginated list
content:
application/json:
schema: { $ref: "#/components/schemas/PaginatedContactList" }
post:
tags: [contacts]
summary: Submit a QSO
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ContactCreate" }
responses:
"201":
description: Created
content:
application/json:
schema: { $ref: "#/components/schemas/ContactResponse" }
"401": { $ref: "#/components/responses/Unauthorized" }
"409": { $ref: "#/components/responses/Conflict" }
"422": { $ref: "#/components/responses/ValidationFailed" }
/contacts/{id}:
get:
tags: [contacts]
summary: Show a single QSO
security: []
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
responses:
"200":
description: QSO
content:
application/json:
schema: { $ref: "#/components/schemas/ContactResponse" }
"404": { $ref: "#/components/responses/NotFound" }
/beacons:
get:
tags: [beacons]
summary: List approved beacons
security: []
responses:
"200":
description: List
content:
application/json:
schema: { $ref: "#/components/schemas/BeaconList" }
post:
tags: [beacons]
summary: Submit a beacon
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/BeaconCreate" }
responses:
"201":
description: Created (pending approval)
content:
application/json:
schema: { $ref: "#/components/schemas/BeaconResponse" }
"401": { $ref: "#/components/responses/Unauthorized" }
"422": { $ref: "#/components/responses/ValidationFailed" }
/beacons/{id}:
get:
tags: [beacons]
summary: Show a single beacon
security: []
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
responses:
"200":
description: Beacon
content:
application/json:
schema: { $ref: "#/components/schemas/BeaconResponse" }
"404": { $ref: "#/components/responses/NotFound" }
/profiles/{callsign}:
get:
tags: [profiles]
summary: Public per-callsign profile
security: []
parameters:
- in: path
name: callsign
required: true
schema: { type: string }
responses:
"200":
description: Profile + contacts + approved beacons
content:
application/json:
schema: { $ref: "#/components/schemas/ProfileResponse" }
"404": { $ref: "#/components/responses/NotFound" }
/scores/bands:
get:
tags: [scores]
summary: Bands with propagation scores
security: []
responses:
"200":
description: List of bands
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
mhz: { type: integer }
label: { type: string }
humidity_effect:
type: string
enum: [beneficial, harmful, neutral]
/scores:
get:
tags: [scores]
summary: Score + factor breakdown at a grid point
security: []
parameters:
- in: query
name: band
required: true
schema: { type: integer }
- in: query
name: lat
required: true
schema: { type: number, format: double }
- in: query
name: lon
required: true
schema: { type: number, format: double }
- in: query
name: valid_time
schema: { type: string, format: date-time }
responses:
"200":
description: Score
content:
application/json:
schema: { $ref: "#/components/schemas/ScoreResponse" }
"400": { $ref: "#/components/responses/BadRequest" }
"404": { $ref: "#/components/responses/NotFound" }
/forecast:
get:
tags: [scores]
summary: 18-hour score timeline at a grid point
security: []
parameters:
- in: query
name: band
required: true
schema: { type: integer }
- in: query
name: lat
required: true
schema: { type: number, format: double }
- in: query
name: lon
required: true
schema: { type: number, format: double }
responses:
"200":
description: Timeline
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
valid_time: { type: string, format: date-time }
score: { type: integer }
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: opaque
responses:
BadRequest:
description: Bad request
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
Unauthorized:
description: Authentication required
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
NotFound:
description: Resource not found
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
Conflict:
description: Duplicate
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
ValidationFailed:
description: Validation failed
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
RateLimited:
description: Rate limit exceeded
headers:
RateLimit-Limit: { schema: { type: integer } }
RateLimit-Remaining: { schema: { type: integer } }
RateLimit-Reset: { schema: { type: integer } }
Retry-After: { schema: { type: integer } }
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
schemas:
Problem:
type: object
properties:
type: { type: string, default: "about:blank" }
title: { type: string }
status: { type: integer }
detail: { type: string }
errors:
type: object
additionalProperties:
type: array
items: { type: string }
AuthRequest:
type: object
required: [email, password, name]
properties:
email: { type: string, format: email }
password: { type: string }
name: { type: string, maxLength: 100 }
expires_at: { type: string, format: date-time, nullable: true }
AuthResponse:
type: object
properties:
token: { type: string }
data: { $ref: "#/components/schemas/Token" }
Token:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
inserted_at: { type: string, format: date-time }
last_used_at: { type: string, format: date-time, nullable: true }
expires_at: { type: string, format: date-time, nullable: true }
revoked_at: { type: string, format: date-time, nullable: true }
TokenList:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Token" }
TokenResponse:
type: object
properties:
data: { $ref: "#/components/schemas/Token" }
Me:
type: object
properties:
id: { type: string, format: uuid }
callsign: { type: string }
name: { type: string }
email: { type: string, format: email }
is_admin: { type: boolean }
confirmed_at: { type: string, format: date-time, nullable: true }
home_grid: { type: string, nullable: true }
home_lat: { type: number, format: double, nullable: true }
home_lon: { type: number, format: double, nullable: true }
home_elevation_m: { type: integer, nullable: true }
MeResponse:
type: object
properties:
data: { $ref: "#/components/schemas/Me" }
HomeQthRequest:
type: object
properties:
home_grid: { type: string }
home_lat: { type: number, format: double }
home_lon: { type: number, format: double }
home_elevation_m: { type: integer }
Contact:
type: object
properties:
id: { type: string, format: uuid }
station1: { type: string }
station2: { type: string }
qso_timestamp: { type: string, format: date-time }
grid1: { type: string }
grid2: { type: string }
pos1:
type: object
properties:
lat: { type: number }
lon: { type: number }
pos2:
type: object
properties:
lat: { type: number }
lon: { type: number }
mode: { type: string }
band_mhz: { type: string }
distance_km: { type: string, nullable: true }
private: { type: boolean }
user_declared_prop_mode: { type: string, nullable: true }
propagation_mechanism: { type: string, nullable: true }
propagation_mechanism_confidence:
type: string
nullable: true
enum: [high, medium, low]
notes: { type: string, nullable: true }
"mine?": { type: boolean }
ContactCreate:
type: object
required: [station1, station2, qso_timestamp, band, grid1, grid2]
properties:
station1: { type: string }
station2: { type: string }
qso_timestamp: { type: string, format: date-time }
band: { type: string }
grid1: { type: string }
grid2: { type: string }
mode: { type: string, enum: [CW, SSB, FM, FT8, FT4, Q65] }
user_declared_prop_mode: { type: string }
height1_ft: { type: integer }
height2_ft: { type: integer }
private: { type: boolean }
notes: { type: string, maxLength: 2000 }
ContactResponse:
type: object
properties:
data: { $ref: "#/components/schemas/Contact" }
ContactList:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Contact" }
PaginatedContactList:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Contact" }
meta:
type: object
properties:
page: { type: integer }
per_page: { type: integer }
total_entries: { type: integer }
total_pages: { type: integer }
Beacon:
type: object
properties:
id: { type: string, format: uuid }
callsign: { type: string }
frequency_mhz: { type: number }
lat: { type: number }
lon: { type: number }
grid: { type: string }
power_mw: { type: number }
height_ft: { type: integer }
on_the_air: { type: boolean }
approved: { type: boolean }
keying: { type: string }
bearing: { type: string }
beamwidth_deg: { type: number, nullable: true }
notes: { type: string, nullable: true }
inserted_at: { type: string, format: date-time }
BeaconCreate:
type: object
required: [frequency_mhz, callsign, lat, lon, power_mw, height_ft, keying]
properties:
frequency_mhz: { type: number }
callsign: { type: string }
grid: { type: string }
lat: { type: number }
lon: { type: number }
power_mw: { type: number }
height_ft: { type: integer }
on_the_air: { type: boolean }
keying: { type: string }
bearing: { type: string }
beamwidth_deg: { type: number }
notes: { type: string }
BeaconResponse:
type: object
properties:
data: { $ref: "#/components/schemas/Beacon" }
BeaconList:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Beacon" }
BeaconMonitor:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
token: { type: string, nullable: true }
last_seen_at: { type: string, format: date-time, nullable: true }
inserted_at: { type: string, format: date-time }
BeaconMonitorCreate:
type: object
required: [name]
properties:
name: { type: string, maxLength: 100 }
BeaconMonitorResponse:
type: object
properties:
data: { $ref: "#/components/schemas/BeaconMonitor" }
BeaconMonitorList:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/BeaconMonitor" }
ProfileResponse:
type: object
properties:
user:
type: object
properties:
id: { type: string, format: uuid }
callsign: { type: string }
name: { type: string }
home_grid: { type: string, nullable: true }
home_lat: { type: number, nullable: true }
home_lon: { type: number, nullable: true }
contacts:
type: array
items: { $ref: "#/components/schemas/Contact" }
beacons:
type: array
items: { $ref: "#/components/schemas/Beacon" }
ScoreResponse:
type: object
properties:
data:
type: object
properties:
lat: { type: number }
lon: { type: number }
score: { type: integer }
factors:
type: object
additionalProperties: true
profile_source:
oneOf:
- { type: string, enum: [exact, unavailable] }
- { type: array }
valid_time: { type: string, format: date-time }