diff --git a/lib/aprsme_web/controllers/api/v1/fallback_controller.ex b/lib/aprsme_web/controllers/api/v1/fallback_controller.ex index c28e42c..af014a6 100644 --- a/lib/aprsme_web/controllers/api/v1/fallback_controller.ex +++ b/lib/aprsme_web/controllers/api/v1/fallback_controller.ex @@ -25,6 +25,14 @@ defmodule AprsmeWeb.Api.V1.FallbackController do |> render(:"404") end + # Handle errors with status and message (e.g., {:error, :bad_request, "message"}) + def call(conn, {:error, status, message}) when is_atom(status) and is_binary(message) do + conn + |> put_status(status) + |> put_view(json: ErrorJSON) + |> render(:error, message: message) + end + # Handle generic errors def call(conn, {:error, reason}) when is_atom(reason) do status = diff --git a/lib/aprsme_web/controllers/api/v1/weather_controller.ex b/lib/aprsme_web/controllers/api/v1/weather_controller.ex new file mode 100644 index 0000000..d163ace --- /dev/null +++ b/lib/aprsme_web/controllers/api/v1/weather_controller.ex @@ -0,0 +1,156 @@ +defmodule AprsmeWeb.Api.V1.WeatherController do + @moduledoc """ + Controller for weather-related API endpoints. + """ + use AprsmeWeb, :controller + + alias Aprsme.Packets.PreparedQueries + + action_fallback AprsmeWeb.Api.V1.FallbackController + + @doc """ + Returns nearby weather stations within a specified radius. + + ## Parameters + + * `lat` - Latitude (-90 to 90) - required + * `lon` - Longitude (-180 to 180) - required + * `radius` - Search radius in miles (> 0, max 1000) - required + * `hours` - Hours of data to retrieve (1-168, default: 6) - optional + * `limit` - Maximum number of results (1-100, default: 50) - optional + + ## Examples + + GET /api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=10 + GET /api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=10&hours=24&limit=25 + + """ + def nearby(conn, params) do + with {:ok, validated_params} <- validate_params(params) do + stations = + PreparedQueries.get_nearby_weather_stations( + validated_params.lat, + validated_params.lon, + validated_params.radius, + hours: validated_params.hours, + limit: validated_params.limit + ) + + # Transform params for JSON response + response_params = %{ + latitude: validated_params.lat, + longitude: validated_params.lon, + radius_miles: validated_params.radius, + hours: validated_params.hours, + limit: validated_params.limit + } + + render(conn, :nearby, + stations: stations, + params: response_params + ) + end + end + + # Private Functions + + @spec validate_params(map()) :: + {:ok, + %{ + lat: float(), + lon: float(), + radius: float(), + hours: integer(), + limit: integer() + }} + | {:error, :bad_request, String.t()} + | {:error, :unprocessable_entity, String.t()} + defp validate_params(params) do + with {:ok, lat} <- validate_required_float(params, "lat"), + {:ok, lon} <- validate_required_float(params, "lon"), + {:ok, radius} <- validate_required_float(params, "radius"), + {:ok, hours} <- validate_optional_integer(params, "hours", 6), + {:ok, limit} <- validate_optional_integer(params, "limit", 50), + :ok <- validate_latitude(lat), + :ok <- validate_longitude(lon), + :ok <- validate_radius(radius), + :ok <- validate_hours(hours), + :ok <- validate_limit(limit) do + {:ok, + %{ + lat: lat, + lon: lon, + radius: radius, + hours: hours, + limit: limit + }} + end + end + + @spec validate_required_float(map(), String.t()) :: + {:ok, float()} | {:error, :bad_request, String.t()} + defp validate_required_float(params, key) do + case Map.get(params, key) do + nil -> + {:error, :bad_request, "Missing required parameter: #{key}"} + + value when is_binary(value) -> + case Float.parse(value) do + {float_value, ""} -> {:ok, float_value} + _ -> {:error, :bad_request, "Invalid numeric value for parameter: #{key}"} + end + + value when is_number(value) -> + {:ok, value / 1} + end + end + + @spec validate_optional_integer(map(), String.t(), integer()) :: + {:ok, integer()} | {:error, :bad_request, String.t()} + defp validate_optional_integer(params, key, default) do + case Map.get(params, key) do + nil -> + {:ok, default} + + value when is_binary(value) -> + case Integer.parse(value) do + {int_value, ""} -> {:ok, int_value} + _ -> {:error, :bad_request, "Invalid integer value for parameter: #{key}"} + end + + value when is_integer(value) -> + {:ok, value} + + value when is_float(value) -> + {:ok, trunc(value)} + end + end + + @spec validate_latitude(float()) :: :ok | {:error, :unprocessable_entity, String.t()} + defp validate_latitude(lat) when lat >= -90 and lat <= 90, do: :ok + + defp validate_latitude(_lat), do: {:error, :unprocessable_entity, "Invalid latitude: must be between -90 and 90"} + + @spec validate_longitude(float()) :: :ok | {:error, :unprocessable_entity, String.t()} + defp validate_longitude(lon) when lon >= -180 and lon <= 180, do: :ok + + defp validate_longitude(_lon), do: {:error, :unprocessable_entity, "Invalid longitude: must be between -180 and 180"} + + @spec validate_radius(float()) :: :ok | {:error, :unprocessable_entity, String.t()} + defp validate_radius(radius) when radius > 0 and radius <= 1000, do: :ok + + defp validate_radius(radius) when radius <= 0, + do: {:error, :unprocessable_entity, "Invalid radius: must be greater than 0"} + + defp validate_radius(_radius), do: {:error, :unprocessable_entity, "Invalid radius: must not exceed 1000 miles"} + + @spec validate_hours(integer()) :: :ok | {:error, :unprocessable_entity, String.t()} + defp validate_hours(hours) when hours >= 1 and hours <= 168, do: :ok + + defp validate_hours(_hours), do: {:error, :unprocessable_entity, "Invalid hours: must be between 1 and 168"} + + @spec validate_limit(integer()) :: :ok | {:error, :unprocessable_entity, String.t()} + defp validate_limit(limit) when limit >= 1 and limit <= 100, do: :ok + + defp validate_limit(_limit), do: {:error, :unprocessable_entity, "Invalid limit: must be between 1 and 100"} +end diff --git a/lib/aprsme_web/controllers/api/v1/weather_json.ex b/lib/aprsme_web/controllers/api/v1/weather_json.ex index cbc7be0..6e1e5df 100644 --- a/lib/aprsme_web/controllers/api/v1/weather_json.ex +++ b/lib/aprsme_web/controllers/api/v1/weather_json.ex @@ -17,12 +17,19 @@ defmodule AprsmeWeb.Api.V1.WeatherJSON do end defp station_json(station) do + # Support both :lat/:lon (from query) and :latitude/:longitude (from tests) + lat = Map.get(station, :lat) || Map.get(station, :latitude) + lon = Map.get(station, :lon) || Map.get(station, :longitude) + + # Support both :received_at (from query) and :last_report (from tests) + last_report = Map.get(station, :received_at) || Map.get(station, :last_report) + %{ callsign: station.callsign, base_callsign: station.base_callsign, position: %{ - lat: station.latitude, - lon: station.longitude + lat: lat, + lon: lon }, distance_miles: station.distance_miles, weather: %{ @@ -41,7 +48,7 @@ defmodule AprsmeWeb.Api.V1.WeatherJSON do code: station.symbol_code }, comment: station.comment, - last_report: format_datetime(station.last_report) + last_report: format_datetime(last_report) } end diff --git a/lib/aprsme_web/router.ex b/lib/aprsme_web/router.ex index 7b0d92e..c33cc1f 100644 --- a/lib/aprsme_web/router.ex +++ b/lib/aprsme_web/router.ex @@ -115,6 +115,7 @@ defmodule AprsmeWeb.Router do pipe_through :api get "/callsign/:callsign", CallsignController, :show + get "/weather/nearby", WeatherController, :nearby end # Enable LiveDashboard and Swoosh mailbox preview in development diff --git a/test/aprsme_web/controllers/api/v1/weather_controller_test.exs b/test/aprsme_web/controllers/api/v1/weather_controller_test.exs new file mode 100644 index 0000000..0161e2f --- /dev/null +++ b/test/aprsme_web/controllers/api/v1/weather_controller_test.exs @@ -0,0 +1,287 @@ +defmodule AprsmeWeb.Api.V1.WeatherControllerTest do + use AprsmeWeb.ConnCase, async: false + + import Aprsme.PacketsFixtures + + describe "GET /api/v1/weather/nearby" do + setup do + # Create a weather station packet + station = + packet_fixture(%{ + sender: "TEST-1", + raw_packet: "TEST-1>APRS,TCPIP*:@121045z3745.50N/12159.75W_090/000g000t068r000p000P000h50b10120", + data_type: "weather", + lat: Decimal.new("37.7583"), + lon: Decimal.new("-121.9958"), + has_weather: true, + temperature: 68.0, + humidity: 50.0, + pressure: 1012.0, + wind_speed: 0.0, + wind_direction: 90, + rain_1h: 0.0, + rain_24h: 0.0, + rain_since_midnight: 0.0 + }) + + # Create a non-weather packet (should not appear) + packet_fixture(%{ + sender: "TEST-2", + data_type: "position", + lat: Decimal.new("37.7583"), + lon: Decimal.new("-121.9958"), + has_weather: false + }) + + # Create a weather station outside radius + packet_fixture(%{ + sender: "TEST-3", + data_type: "weather", + lat: Decimal.new("40.0"), + lon: Decimal.new("-120.0"), + has_weather: true, + temperature: 75.0 + }) + + %{station: station} + end + + test "returns nearby weather stations with valid params", %{conn: conn, station: station} do + conn = + get(conn, ~p"/api/v1/weather/nearby", %{ + "lat" => "37.7583", + "lon" => "-121.9958", + "radius" => "10" + }) + + assert %{ + "data" => [weather_station], + "meta" => %{ + "count" => 1, + "params" => %{ + "latitude" => 37.7583, + "longitude" => -121.9958, + "radius_miles" => 10.0, + "hours" => 6, + "limit" => 50 + } + } + } = json_response(conn, 200) + + assert weather_station["callsign"] == station.sender + assert weather_station["distance_miles"] < 10.0 + assert weather_station["weather"]["temperature"] == 68.0 + end + + test "returns 400 when lat is missing", %{conn: conn} do + conn = + get(conn, ~p"/api/v1/weather/nearby", %{ + "lon" => "-121.9958", + "radius" => "10" + }) + + assert %{"error" => %{"message" => message}} = json_response(conn, 400) + assert message =~ "lat" + end + + test "returns 400 when lon is missing", %{conn: conn} do + conn = + get(conn, ~p"/api/v1/weather/nearby", %{ + "lat" => "37.7583", + "radius" => "10" + }) + + assert %{"error" => %{"message" => message}} = json_response(conn, 400) + assert message =~ "lon" + end + + test "returns 400 when radius is missing", %{conn: conn} do + conn = + get(conn, ~p"/api/v1/weather/nearby", %{ + "lat" => "37.7583", + "lon" => "-121.9958" + }) + + assert %{"error" => %{"message" => message}} = json_response(conn, 400) + assert message =~ "radius" + end + + test "returns 422 when lat is out of range", %{conn: conn} do + conn = + get(conn, ~p"/api/v1/weather/nearby", %{ + "lat" => "91", + "lon" => "-121.9958", + "radius" => "10" + }) + + assert %{"error" => %{"message" => message}} = json_response(conn, 422) + assert message =~ "latitude" + assert message =~ "-90" + assert message =~ "90" + end + + test "returns 422 when lon is out of range", %{conn: conn} do + conn = + get(conn, ~p"/api/v1/weather/nearby", %{ + "lat" => "37.7583", + "lon" => "-181", + "radius" => "10" + }) + + assert %{"error" => %{"message" => message}} = json_response(conn, 422) + assert message =~ "longitude" + assert message =~ "-180" + assert message =~ "180" + end + + test "returns 422 when radius is zero", %{conn: conn} do + conn = + get(conn, ~p"/api/v1/weather/nearby", %{ + "lat" => "37.7583", + "lon" => "-121.9958", + "radius" => "0" + }) + + assert %{"error" => %{"message" => message}} = json_response(conn, 422) + assert message =~ "radius" + assert message =~ "greater than 0" + end + + test "returns 422 when radius is negative", %{conn: conn} do + conn = + get(conn, ~p"/api/v1/weather/nearby", %{ + "lat" => "37.7583", + "lon" => "-121.9958", + "radius" => "-1" + }) + + assert %{"error" => %{"message" => message}} = json_response(conn, 422) + assert message =~ "radius" + assert message =~ "greater than 0" + end + + test "returns 422 when radius exceeds maximum", %{conn: conn} do + conn = + get(conn, ~p"/api/v1/weather/nearby", %{ + "lat" => "37.7583", + "lon" => "-121.9958", + "radius" => "1001" + }) + + assert %{"error" => %{"message" => message}} = json_response(conn, 422) + assert message =~ "radius" + assert message =~ "1000" + end + + test "accepts optional hours parameter", %{conn: conn} do + conn = + get(conn, ~p"/api/v1/weather/nearby", %{ + "lat" => "37.7583", + "lon" => "-121.9958", + "radius" => "10", + "hours" => "24" + }) + + assert %{"meta" => %{"params" => %{"hours" => 24}}} = json_response(conn, 200) + end + + test "returns 422 when hours is out of range", %{conn: conn} do + conn = + get(conn, ~p"/api/v1/weather/nearby", %{ + "lat" => "37.7583", + "lon" => "-121.9958", + "radius" => "10", + "hours" => "169" + }) + + assert %{"error" => %{"message" => message}} = json_response(conn, 422) + assert message =~ "hours" + assert message =~ "1" + assert message =~ "168" + end + + test "accepts optional limit parameter", %{conn: conn} do + conn = + get(conn, ~p"/api/v1/weather/nearby", %{ + "lat" => "37.7583", + "lon" => "-121.9958", + "radius" => "10", + "limit" => "25" + }) + + assert %{"meta" => %{"params" => %{"limit" => 25}}} = json_response(conn, 200) + end + + test "returns 422 when limit is out of range", %{conn: conn} do + conn = + get(conn, ~p"/api/v1/weather/nearby", %{ + "lat" => "37.7583", + "lon" => "-121.9958", + "radius" => "10", + "limit" => "101" + }) + + assert %{"error" => %{"message" => message}} = json_response(conn, 422) + assert message =~ "limit" + assert message =~ "1" + assert message =~ "100" + end + + test "uses default values when optional params not provided", %{conn: conn} do + conn = + get(conn, ~p"/api/v1/weather/nearby", %{ + "lat" => "37.7583", + "lon" => "-121.9958", + "radius" => "10" + }) + + assert %{"meta" => %{"params" => params}} = json_response(conn, 200) + assert params["hours"] == 6 + assert params["limit"] == 50 + end + + test "returns empty list when no weather stations in range", %{conn: conn} do + conn = + get(conn, ~p"/api/v1/weather/nearby", %{ + "lat" => "0.0", + "lon" => "0.0", + "radius" => "1" + }) + + assert %{ + "data" => [], + "meta" => %{"count" => 0} + } = json_response(conn, 200) + end + + test "coerces string parameters to correct types", %{conn: conn} do + conn = + get(conn, ~p"/api/v1/weather/nearby", %{ + "lat" => "37.7583", + "lon" => "-121.9958", + "radius" => "10", + "hours" => "12", + "limit" => "25" + }) + + assert %{"meta" => %{"params" => params}} = json_response(conn, 200) + assert is_float(params["latitude"]) + assert is_float(params["longitude"]) + assert is_float(params["radius_miles"]) + assert is_integer(params["hours"]) + assert is_integer(params["limit"]) + end + + test "handles invalid numeric strings", %{conn: conn} do + conn = + get(conn, ~p"/api/v1/weather/nearby", %{ + "lat" => "not_a_number", + "lon" => "-121.9958", + "radius" => "10" + }) + + assert %{"error" => %{"message" => message}} = json_response(conn, 400) + assert message =~ "lat" + end + end +end