From 16209269e797d96708a365be8abf52cf29eda0b4 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 22 Jun 2025 16:02:59 -0500 Subject: [PATCH] add api --- lib/aprs_web/components/core_components.ex | 3 + .../controllers/api/v1/callsign_controller.ex | 100 ++++ .../controllers/api/v1/fallback_controller.ex | 72 +++ .../controllers/api/v1/json/callsign_json.ex | 127 +++++ .../controllers/api/v1/json/changeset_json.ex | 30 ++ .../controllers/api/v1/json/error_json.ex | 86 ++++ lib/aprs_web/live/api_docs_live.ex | 466 ++++++++++++++++++ lib/aprs_web/router.ex | 21 +- 8 files changed, 896 insertions(+), 9 deletions(-) create mode 100644 lib/aprs_web/controllers/api/v1/callsign_controller.ex create mode 100644 lib/aprs_web/controllers/api/v1/fallback_controller.ex create mode 100644 lib/aprs_web/controllers/api/v1/json/callsign_json.ex create mode 100644 lib/aprs_web/controllers/api/v1/json/changeset_json.ex create mode 100644 lib/aprs_web/controllers/api/v1/json/error_json.ex create mode 100644 lib/aprs_web/live/api_docs_live.ex diff --git a/lib/aprs_web/components/core_components.ex b/lib/aprs_web/components/core_components.ex index 644cea3..3f19e97 100644 --- a/lib/aprs_web/components/core_components.ex +++ b/lib/aprs_web/components/core_components.ex @@ -411,6 +411,9 @@ defmodule AprsWeb.CoreComponents do
Home + + API + About diff --git a/lib/aprs_web/controllers/api/v1/callsign_controller.ex b/lib/aprs_web/controllers/api/v1/callsign_controller.ex new file mode 100644 index 0000000..1d16b3e --- /dev/null +++ b/lib/aprs_web/controllers/api/v1/callsign_controller.ex @@ -0,0 +1,100 @@ +defmodule AprsWeb.Api.V1.CallsignController do + @moduledoc """ + API v1 controller for callsign-related endpoints. + """ + use AprsWeb, :controller + + alias Aprs.Packets + alias AprsWeb.Api.V1.CallsignJSON + + action_fallback AprsWeb.Api.V1.FallbackController + + @doc """ + Get the most recent packet for a given callsign. + + ## Parameters + * `callsign` - The callsign to search for, with optional SSID (e.g., "N0CALL" or "N0CALL-9") + + ## Returns + * 200 - Success with packet data + * 404 - No packets found for the callsign + * 400 - Invalid callsign format + """ + def show(conn, %{"callsign" => callsign}) do + with {:ok, normalized_callsign} <- validate_callsign(callsign), + {:ok, packet} <- get_latest_packet(normalized_callsign) do + conn + |> put_status(:ok) + |> put_view(json: CallsignJSON) + |> render(:show, packet: packet) + else + {:error, :invalid_callsign} -> + {:error, "Invalid callsign format"} + + {:error, :not_found} -> + conn + |> put_status(:ok) + |> put_view(json: CallsignJSON) + |> render(:not_found, callsign: callsign) + + {:error, reason} -> + {:error, reason} + end + end + + defp validate_callsign(callsign) when is_binary(callsign) do + # Remove any whitespace and convert to uppercase + normalized = callsign |> String.trim() |> String.upcase() + + if validate_callsign_format(normalized) do + {:ok, normalized} + else + {:error, :invalid_callsign} + end + end + + defp validate_callsign(nil), do: {:error, :invalid_callsign} + defp validate_callsign(_), do: {:error, :invalid_callsign} + + defp validate_callsign_format(callsign) do + # Basic callsign validation + # Matches patterns like: N0CALL, N0CALL-9, W1ABC-15, etc. + # This is a simplified regex - a full implementation might be more comprehensive + callsign_regex = ~r/^[A-Z0-9]{1,3}[0-9][A-Z]{1,4}(-[0-9]{1,2})?$/ + + String.match?(callsign, callsign_regex) and String.length(callsign) <= 12 + end + + defp get_latest_packet(callsign) do + # Try to get the most recent packet for this callsign + # We'll limit to packets from the last 30 days to keep queries efficient + thirty_days_ago = DateTime.add(DateTime.utc_now(), -30, :day) + + # Use get_recent_packets which orders by desc received_at + opts = %{ + callsign: callsign, + start_time: thirty_days_ago, + limit: 1 + } + + case Packets.get_recent_packets(opts) do + [] -> + {:error, :not_found} + + [packet | _] -> + {:ok, packet} + + {:error, reason} -> + {:error, reason} + end + rescue + Ecto.QueryError -> + {:error, :database_error} + + error -> + require Logger + + Logger.error("Error fetching packet for callsign #{callsign}: #{inspect(error)}") + {:error, :internal_error} + end +end diff --git a/lib/aprs_web/controllers/api/v1/fallback_controller.ex b/lib/aprs_web/controllers/api/v1/fallback_controller.ex new file mode 100644 index 0000000..08cbc6b --- /dev/null +++ b/lib/aprs_web/controllers/api/v1/fallback_controller.ex @@ -0,0 +1,72 @@ +defmodule AprsWeb.Api.V1.FallbackController do + @moduledoc """ + Translates controller action results into valid `Plug.Conn` responses. + + See `Phoenix.Controller.action_fallback/1` for more details. + """ + use AprsWeb, :controller + + alias AprsWeb.Api.V1.ErrorJSON + + # This clause handles errors returned by Ecto's insert/update/delete. + def call(conn, {:error, %Ecto.Changeset{} = changeset}) do + conn + |> put_status(:unprocessable_entity) + |> put_view(json: AprsWeb.Api.V1.ChangesetJSON) + |> render(:error, changeset: changeset) + end + + # This clause is an example of how to handle resources that cannot be found. + def call(conn, {:error, :not_found}) do + conn + |> put_status(:not_found) + |> put_view(json: ErrorJSON) + |> render(:"404") + end + + # Handle generic errors + def call(conn, {:error, reason}) when is_atom(reason) do + status = + case reason do + :unauthorized -> :unauthorized + :forbidden -> :forbidden + :bad_request -> :bad_request + _ -> :internal_server_error + end + + conn + |> put_status(status) + |> put_view(json: ErrorJSON) + |> render(:error, message: format_error_message(reason)) + end + + # Handle string error messages + def call(conn, {:error, message}) when is_binary(message) do + conn + |> put_status(:bad_request) + |> put_view(json: ErrorJSON) + |> render(:error, message: message) + end + + # Handle timeout errors + def call(conn, :timeout) do + conn + |> put_status(:request_timeout) + |> put_view(json: ErrorJSON) + |> render(:error, message: "Request timeout") + end + + # Default fallback for unexpected errors + def call(conn, _error) do + conn + |> put_status(:internal_server_error) + |> put_view(json: ErrorJSON) + |> render(:"500") + end + + defp format_error_message(:not_found), do: "Resource not found" + defp format_error_message(:unauthorized), do: "Unauthorized access" + defp format_error_message(:forbidden), do: "Access forbidden" + defp format_error_message(:bad_request), do: "Bad request" + defp format_error_message(reason), do: "An error occurred: #{reason}" +end diff --git a/lib/aprs_web/controllers/api/v1/json/callsign_json.ex b/lib/aprs_web/controllers/api/v1/json/callsign_json.ex new file mode 100644 index 0000000..e6afba7 --- /dev/null +++ b/lib/aprs_web/controllers/api/v1/json/callsign_json.ex @@ -0,0 +1,127 @@ +defmodule AprsWeb.Api.V1.CallsignJSON do + @moduledoc """ + Renders callsign and packet data for API v1. + """ + + alias Aprs.Packet + + def render("show.json", %{packet: packet}) do + %{data: packet_json(packet)} + end + + def render("index.json", %{packets: packets}) do + %{data: Enum.map(packets, &packet_json/1)} + end + + def render("not_found.json", %{callsign: callsign}) do + %{ + data: nil, + message: "No packets found for callsign #{callsign}" + } + end + + defp packet_json(%Packet{} = packet) do + %{ + id: packet.id, + callsign: format_callsign(packet.base_callsign, packet.ssid), + base_callsign: packet.base_callsign, + ssid: packet.ssid, + sender: packet.sender, + destination: packet.destination, + path: packet.path, + data_type: packet.data_type, + information_field: packet.information_field, + raw_packet: packet.raw_packet, + received_at: packet.received_at, + region: packet.region, + position: position_json(packet), + symbol: symbol_json(packet), + comment: packet.comment, + timestamp: packet.timestamp, + aprs_messaging: packet.aprs_messaging, + weather: weather_json(packet), + equipment: equipment_json(packet), + message: message_json(packet), + has_position: packet.has_position, + inserted_at: packet.inserted_at, + updated_at: packet.updated_at + } + end + + defp format_callsign(base_callsign, nil), do: base_callsign + defp format_callsign(base_callsign, "0"), do: base_callsign + defp format_callsign(base_callsign, ssid), do: "#{base_callsign}-#{ssid}" + + defp position_json(%Packet{has_position: false}), do: nil + defp position_json(%Packet{lat: nil, lon: nil}), do: nil + + defp position_json(%Packet{} = packet) do + base_position = %{ + latitude: to_float(packet.lat), + longitude: to_float(packet.lon) + } + + # Add course, speed, and altitude if available + base_position + |> maybe_add(:course, packet.course) + |> maybe_add(:speed, packet.speed) + |> maybe_add(:altitude, packet.altitude) + end + + defp symbol_json(%Packet{symbol_code: nil, symbol_table_id: nil}), do: nil + + defp symbol_json(%Packet{} = packet) do + %{ + code: packet.symbol_code, + table_id: packet.symbol_table_id + } + end + + defp weather_json(%Packet{} = packet) do + weather_data = + %{} + |> maybe_add(:temperature, packet.temperature) + |> maybe_add(:humidity, packet.humidity) + |> maybe_add(:wind_speed, packet.wind_speed) + |> maybe_add(:wind_direction, packet.wind_direction) + |> maybe_add(:wind_gust, packet.wind_gust) + |> maybe_add(:pressure, packet.pressure) + |> maybe_add(:rain_1h, packet.rain_1h) + |> maybe_add(:rain_24h, packet.rain_24h) + |> maybe_add(:rain_since_midnight, packet.rain_since_midnight) + + case weather_data do + empty when empty == %{} -> nil + data -> data + end + end + + defp equipment_json(%Packet{} = packet) do + equipment_data = + %{} + |> maybe_add(:manufacturer, packet.manufacturer) + |> maybe_add(:equipment_type, packet.equipment_type) + + case equipment_data do + empty when empty == %{} -> nil + data -> data + end + end + + defp message_json(%Packet{addressee: nil, message_text: nil, message_number: nil}), do: nil + + defp message_json(%Packet{} = packet) do + %{} + |> maybe_add(:addressee, packet.addressee) + |> maybe_add(:text, packet.message_text) + |> maybe_add(:number, packet.message_number) + end + + defp maybe_add(map, _key, nil), do: map + defp maybe_add(map, _key, ""), do: map + defp maybe_add(map, key, value), do: Map.put(map, key, value) + + defp to_float(%Decimal{} = decimal), do: Decimal.to_float(decimal) + defp to_float(value) when is_number(value), do: value + defp to_float(_), do: nil +end diff --git a/lib/aprs_web/controllers/api/v1/json/changeset_json.ex b/lib/aprs_web/controllers/api/v1/json/changeset_json.ex new file mode 100644 index 0000000..09ad47d --- /dev/null +++ b/lib/aprs_web/controllers/api/v1/json/changeset_json.ex @@ -0,0 +1,30 @@ +defmodule AprsWeb.Api.V1.ChangesetJSON do + @moduledoc """ + Renders changeset errors for API v1. + """ + + @doc """ + Renders changeset errors. + """ + def render("error.json", %{changeset: changeset}) do + %{ + error: %{ + message: "Validation failed", + code: "validation_error", + details: translate_errors(changeset) + } + } + end + + defp translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, &translate_error/1) + end + + defp translate_error({msg, opts}) do + # Because the error messages we show in our forms and APIs + # are defined inside Ecto, we need to translate them dynamically. + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end +end diff --git a/lib/aprs_web/controllers/api/v1/json/error_json.ex b/lib/aprs_web/controllers/api/v1/json/error_json.ex new file mode 100644 index 0000000..194406d --- /dev/null +++ b/lib/aprs_web/controllers/api/v1/json/error_json.ex @@ -0,0 +1,86 @@ +defmodule AprsWeb.Api.V1.ErrorJSON do + @moduledoc """ + Renders error responses for API v1. + """ + + def render("error.json", %{message: message}) do + %{ + error: %{ + message: message, + code: "generic_error" + } + } + end + + def render("404.json", _assigns) do + %{ + error: %{ + message: "Resource not found", + code: "not_found" + } + } + end + + def render("500.json", _assigns) do + %{ + error: %{ + message: "Internal server error", + code: "internal_server_error" + } + } + end + + def render("422.json", _assigns) do + %{ + error: %{ + message: "Unprocessable entity", + code: "unprocessable_entity" + } + } + end + + def render("400.json", _assigns) do + %{ + error: %{ + message: "Bad request", + code: "bad_request" + } + } + end + + def render("401.json", _assigns) do + %{ + error: %{ + message: "Unauthorized", + code: "unauthorized" + } + } + end + + def render("403.json", _assigns) do + %{ + error: %{ + message: "Forbidden", + code: "forbidden" + } + } + end + + def render("408.json", _assigns) do + %{ + error: %{ + message: "Request timeout", + code: "request_timeout" + } + } + end + + def render(template, _assigns) do + %{ + error: %{ + message: Phoenix.Controller.status_message_from_template(template), + code: "unknown_error" + } + } + end +end diff --git a/lib/aprs_web/live/api_docs_live.ex b/lib/aprs_web/live/api_docs_live.ex new file mode 100644 index 0000000..81c3cf1 --- /dev/null +++ b/lib/aprs_web/live/api_docs_live.ex @@ -0,0 +1,466 @@ +defmodule AprsWeb.ApiDocsLive do + @moduledoc """ + LiveView for API documentation page. + """ + use AprsWeb, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, page_title: "API Documentation")} + end + + @impl true + def render(assigns) do + ~H""" +
+
+

APRS.me API Documentation

+

+ RESTful JSON API for accessing APRS packet data and station information. +

+
+ + +
+
+

Overview

+
+
+
+

+ The APRS.me API provides programmatic access to Amateur Radio APRS (Automatic Packet Reporting System) + data. All API endpoints return JSON data and follow RESTful conventions. +

+ +
+
+

Base URL

+ + https://aprs.me/api/v1 + +
+ +
+

Content Type

+ + application/json + +
+
+ +
+

Rate Limiting

+

+ Currently no rate limiting is enforced, but please be respectful and avoid excessive requests. + Rate limiting may be implemented in the future. +

+
+
+
+
+ + +
+ +
+
+
+

Get Latest Packet by Callsign

+ + GET + +
+
+ +
+
+

Endpoint

+
+ GET /api/v1/callsign/{"{callsign}"} +
+
+ +
+

Description

+

+ Retrieves the most recent APRS packet for the specified callsign. The callsign can include + an SSID (e.g., N0CALL-9) or just the base callsign (e.g., N0CALL). +

+
+ +
+

Parameters

+
+ + + + + + + + + + + + + + + + + +
+ Parameter + + Type + + Required + + Description +
+ callsign + + string + + Yes + + Amateur radio callsign with optional SSID (e.g., N0CALL or N0CALL-9) +
+
+
+ +
+

Example Request

+
+
curl -X GET "https://aprs.me/api/v1/callsign/N0CALL-9" \
+     -H "Accept: application/json"
+
+
+ +
+

Response Format

+ +

Success Response (200 OK)

+
+
<%= raw ~s|{
+    "data": {
+    "id": 12345,
+    "callsign": "N0CALL-9",
+    "base_callsign": "N0CALL",
+    "ssid": "9",
+    "sender": "N0CALL-9",
+    "destination": "APRS",
+    "path": "WIDE1-1,WIDE2-1",
+    "data_type": "position",
+    "information_field": "!4740.00N/12200.00W>Mobile Station",
+    "raw_packet": "N0CALL-9>APRS,WIDE1-1,WIDE2-1:!4740.00N/12200.00W>Mobile Station",
+    "received_at": "2024-01-15T10:30:45.123456Z",
+    "region": "US-West",
+    "position": {
+      "latitude": 47.666667,
+      "longitude": -122.0,
+      "course": 90,
+      "speed": 35.5,
+      "altitude": 152.4
+    },
+    "symbol": {
+      "code": ">",
+      "table_id": "/"
+    },
+    "comment": "Mobile Station",
+    "timestamp": null,
+    "aprs_messaging": false,
+    "weather": null,
+    "equipment": {
+      "manufacturer": "Kenwood",
+      "equipment_type": "TM-D710"
+    },
+    "message": null,
+    "has_position": true,
+    "inserted_at": "2024-01-15T10:30:45.123456Z",
+    "updated_at": "2024-01-15T10:30:45.123456Z"
+    }
+    }| %>
+
+ +

Not Found Response (404)

+
+
<%= raw ~s|{
+    "data": null,
+    "message": "No packets found for callsign N0CALL-9"
+    }| %>
+
+ +

Error Response (400)

+
+
<%= raw ~s|{
+    "error": {
+    "message": "Invalid callsign format",
+    "code": "bad_request"
+    }
+    }| %>
+
+
+
+
+ + +
+
+

Response Fields

+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Field + + Type + + Description +
idintegerUnique packet identifier
+ callsign + string + Full callsign with SSID (e.g., "N0CALL-9") +
+ base_callsign + stringBase callsign without SSID
ssidstring + SSID (Secondary Station Identifier), null if not present +
+ received_at + datetime + When the packet was received (ISO 8601 format) +
+ position + object + Position data (latitude, longitude, course, speed, altitude) +
+ symbol + object + APRS symbol information (code and table_id) +
+ weather + object + Weather data if present (temperature, humidity, wind, etc.) +
+ equipment + object + Equipment information (manufacturer, type) +
+ message + object + Message data if the packet is a message +
+ raw_packet + string + Original raw APRS packet as received +
+
+
+
+ + +
+
+

HTTP Status Codes

+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Status Code + + Description +
+ 200 OK + + Request successful, packet data returned +
+ 400 Bad Request + + Invalid callsign format or malformed request +
+ 404 Not Found + + No packets found for the specified callsign +
+ 408 Request Timeout + Request took too long to process
+ 500 Internal Server Error + + Server error occurred while processing the request +
+
+
+
+ + +
+
+

Planned Endpoints

+
+ +
+

+ The following endpoints are planned for future releases: +

+ +
+
+
+ GET /api/v1/callsign/{"{callsign}"}/history +

Get historical packets for a callsign

+
+ Planned +
+ +
+
+ GET /api/v1/packets/recent +

Get recent packets with filtering options

+
+ Planned +
+ +
+
+ GET /api/v1/packets/area +

Get packets within a geographic area

+
+ Planned +
+ +
+
+ GET /api/v1/weather/{"{callsign}"} +

Get weather data from weather stations

+
+ Planned +
+
+
+
+ + +
+
+

Support

+
+ +
+
+

+ This API is provided free of charge for amateur radio and educational purposes. + If you encounter issues or have suggestions for improvements, please reach out. +

+ +
+

Guidelines

+
    +
  • • Use reasonable request rates to avoid overwhelming the service
  • +
  • • Cache responses when appropriate to reduce server load
  • +
  • • Include a User-Agent header identifying your application
  • +
  • • This service is for amateur radio and educational use
  • +
+
+
+
+
+
+
+ """ + end +end diff --git a/lib/aprs_web/router.ex b/lib/aprs_web/router.ex index 46a4b14..fb27511 100644 --- a/lib/aprs_web/router.ex +++ b/lib/aprs_web/router.ex @@ -30,11 +30,6 @@ defmodule AprsWeb.Router do live_dashboard "/dashboard", metrics: AprsWeb.Telemetry - live_session :map_pages, layout: {AprsWeb.Layouts, :map} do - live "/", MapLive.Index, :index - live "/:callsign", MapLive.CallsignView, :index - end - live_session :regular_pages do live "/status", StatusLive.Index, :index live "/packets", PacketsLive.Index, :index @@ -42,13 +37,21 @@ defmodule AprsWeb.Router do live "/badpackets", BadPacketsLive.Index, :index live "/weather/:callsign", WeatherLive.CallsignView, :index live "/about", AboutLive, :index + live "/api", ApiDocsLive, :index + end + + live_session :map_pages, layout: {AprsWeb.Layouts, :map} do + live "/", MapLive.Index, :index + live "/:callsign", MapLive.CallsignView, :index end end - # Other scopes may use custom stacks. - # scope "/api", AprsWeb do - # pipe_through :api - # end + # API v1 routes + scope "/api/v1", AprsWeb.Api.V1, as: :api_v1 do + pipe_through :api + + get "/callsign/:callsign", CallsignController, :show + end # Enable LiveDashboard and Swoosh mailbox preview in development if Application.compile_env(:aprs, :dev_routes) do