add api
This commit is contained in:
parent
77276eb2a1
commit
16209269e7
8 changed files with 896 additions and 9 deletions
|
|
@ -411,6 +411,9 @@ defmodule AprsWeb.CoreComponents do
|
|||
</div>
|
||||
<div class="flex items-center space-x-6">
|
||||
<a href="/" class="text-gray-700 hover:text-blue-700 font-medium transition-colors">Home</a>
|
||||
<a href="/api" class="text-gray-700 hover:text-blue-700 font-medium transition-colors">
|
||||
API
|
||||
</a>
|
||||
<a href="/about" class="text-gray-700 hover:text-blue-700 font-medium transition-colors">
|
||||
About
|
||||
</a>
|
||||
|
|
|
|||
100
lib/aprs_web/controllers/api/v1/callsign_controller.ex
Normal file
100
lib/aprs_web/controllers/api/v1/callsign_controller.ex
Normal file
|
|
@ -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
|
||||
72
lib/aprs_web/controllers/api/v1/fallback_controller.ex
Normal file
72
lib/aprs_web/controllers/api/v1/fallback_controller.ex
Normal file
|
|
@ -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
|
||||
127
lib/aprs_web/controllers/api/v1/json/callsign_json.ex
Normal file
127
lib/aprs_web/controllers/api/v1/json/callsign_json.ex
Normal file
|
|
@ -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
|
||||
30
lib/aprs_web/controllers/api/v1/json/changeset_json.ex
Normal file
30
lib/aprs_web/controllers/api/v1/json/changeset_json.ex
Normal file
|
|
@ -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
|
||||
86
lib/aprs_web/controllers/api/v1/json/error_json.ex
Normal file
86
lib/aprs_web/controllers/api/v1/json/error_json.ex
Normal file
|
|
@ -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
|
||||
466
lib/aprs_web/live/api_docs_live.ex
Normal file
466
lib/aprs_web/live/api_docs_live.ex
Normal file
|
|
@ -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"""
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">APRS.me API Documentation</h1>
|
||||
<p class="text-lg text-gray-600">
|
||||
RESTful JSON API for accessing APRS packet data and station information.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- API Overview -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-8">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-2xl font-semibold text-gray-900">Overview</h2>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="prose max-w-none">
|
||||
<p class="text-gray-700 mb-4">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6 mt-6">
|
||||
<div class="bg-blue-50 p-4 rounded-lg">
|
||||
<h3 class="font-semibold text-blue-900 mb-2">Base URL</h3>
|
||||
<code class="text-sm bg-white px-2 py-1 rounded border">
|
||||
https://aprs.me/api/v1
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div class="bg-green-50 p-4 rounded-lg">
|
||||
<h3 class="font-semibold text-green-900 mb-2">Content Type</h3>
|
||||
<code class="text-sm bg-white px-2 py-1 rounded border">
|
||||
application/json
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 p-4 bg-yellow-50 rounded-lg">
|
||||
<h3 class="font-semibold text-yellow-900 mb-2">Rate Limiting</h3>
|
||||
<p class="text-yellow-800">
|
||||
Currently no rate limiting is enforced, but please be respectful and avoid excessive requests.
|
||||
Rate limiting may be implemented in the future.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Endpoints -->
|
||||
<div class="space-y-8">
|
||||
<!-- Callsign Endpoint -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Get Latest Packet by Callsign</h2>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
GET
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Endpoint</h3>
|
||||
<div class="bg-gray-50 p-3 rounded-lg">
|
||||
<code class="text-sm">GET /api/v1/callsign/{"{callsign}"}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Description</h3>
|
||||
<p class="text-gray-700">
|
||||
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).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Parameters</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Parameter
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Required
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Description
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
|
||||
callsign
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
string
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
Yes
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">
|
||||
Amateur radio callsign with optional SSID (e.g., N0CALL or N0CALL-9)
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Example Request</h3>
|
||||
<div class="bg-gray-900 text-white p-4 rounded-lg overflow-x-auto">
|
||||
<pre><code>curl -X GET "https://aprs.me/api/v1/callsign/N0CALL-9" \
|
||||
-H "Accept: application/json"</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Response Format</h3>
|
||||
|
||||
<h4 class="text-md font-medium text-gray-800 mb-2">Success Response (200 OK)</h4>
|
||||
<div class="bg-gray-900 text-white p-4 rounded-lg overflow-x-auto mb-4">
|
||||
<pre><code><%= 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"
|
||||
}
|
||||
}| %></code></pre>
|
||||
</div>
|
||||
|
||||
<h4 class="text-md font-medium text-gray-800 mb-2">Not Found Response (404)</h4>
|
||||
<div class="bg-gray-900 text-white p-4 rounded-lg overflow-x-auto mb-4">
|
||||
<pre><code><%= raw ~s|{
|
||||
"data": null,
|
||||
"message": "No packets found for callsign N0CALL-9"
|
||||
}| %></code></pre>
|
||||
</div>
|
||||
|
||||
<h4 class="text-md font-medium text-gray-800 mb-2">Error Response (400)</h4>
|
||||
<div class="bg-gray-900 text-white p-4 rounded-lg overflow-x-auto">
|
||||
<pre><code><%= raw ~s|{
|
||||
"error": {
|
||||
"message": "Invalid callsign format",
|
||||
"code": "bad_request"
|
||||
}
|
||||
}| %></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Response Fields Documentation -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Response Fields</h2>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Field
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Description
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">id</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">integer</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">Unique packet identifier</td>
|
||||
</tr>
|
||||
<tr class="bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
|
||||
callsign
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">string</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">
|
||||
Full callsign with SSID (e.g., "N0CALL-9")
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
|
||||
base_callsign
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">string</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">Base callsign without SSID</td>
|
||||
</tr>
|
||||
<tr class="bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">ssid</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">string</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">
|
||||
SSID (Secondary Station Identifier), null if not present
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
|
||||
received_at
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">datetime</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">
|
||||
When the packet was received (ISO 8601 format)
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
|
||||
position
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">object</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">
|
||||
Position data (latitude, longitude, course, speed, altitude)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
|
||||
symbol
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">object</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">
|
||||
APRS symbol information (code and table_id)
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
|
||||
weather
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">object</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">
|
||||
Weather data if present (temperature, humidity, wind, etc.)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
|
||||
equipment
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">object</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">
|
||||
Equipment information (manufacturer, type)
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
|
||||
message
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">object</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">
|
||||
Message data if the packet is a message
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
|
||||
raw_packet
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">string</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">
|
||||
Original raw APRS packet as received
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HTTP Status Codes -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-900">HTTP Status Codes</h2>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status Code
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Description
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-green-600">
|
||||
200 OK
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">
|
||||
Request successful, packet data returned
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-yellow-600">
|
||||
400 Bad Request
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">
|
||||
Invalid callsign format or malformed request
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-red-600">
|
||||
404 Not Found
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">
|
||||
No packets found for the specified callsign
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-red-600">
|
||||
408 Request Timeout
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">Request took too long to process</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-red-600">
|
||||
500 Internal Server Error
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">
|
||||
Server error occurred while processing the request
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Future Endpoints -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Planned Endpoints</h2>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4">
|
||||
<p class="text-gray-700 mb-4">
|
||||
The following endpoints are planned for future releases:
|
||||
</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
|
||||
<div>
|
||||
<code class="text-sm font-mono">GET /api/v1/callsign/{"{callsign}"}/history</code>
|
||||
<p class="text-sm text-gray-600 mt-1">Get historical packets for a callsign</p>
|
||||
</div>
|
||||
<span class="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">Planned</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
|
||||
<div>
|
||||
<code class="text-sm font-mono">GET /api/v1/packets/recent</code>
|
||||
<p class="text-sm text-gray-600 mt-1">Get recent packets with filtering options</p>
|
||||
</div>
|
||||
<span class="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">Planned</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
|
||||
<div>
|
||||
<code class="text-sm font-mono">GET /api/v1/packets/area</code>
|
||||
<p class="text-sm text-gray-600 mt-1">Get packets within a geographic area</p>
|
||||
</div>
|
||||
<span class="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">Planned</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
|
||||
<div>
|
||||
<code class="text-sm font-mono">GET /api/v1/weather/{"{callsign}"}</code>
|
||||
<p class="text-sm text-gray-600 mt-1">Get weather data from weather stations</p>
|
||||
</div>
|
||||
<span class="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">Planned</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact and Support -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Support</h2>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4">
|
||||
<div class="prose max-w-none">
|
||||
<p class="text-gray-700 mb-4">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 class="font-semibold text-gray-900 mb-2">Guidelines</h3>
|
||||
<ul class="text-gray-700 space-y-1">
|
||||
<li>• Use reasonable request rates to avoid overwhelming the service</li>
|
||||
<li>• Cache responses when appropriate to reduce server load</li>
|
||||
<li>• Include a User-Agent header identifying your application</li>
|
||||
<li>• This service is for amateur radio and educational use</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue