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).
699 lines
20 KiB
YAML
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 }
|