From a796f8a3a9059e72f7b99b7ab03b274386dde401 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 22 Mar 2026 11:14:22 -0500 Subject: [PATCH] Add Weather API controller with parameter validation Implements GET /api/v1/weather/nearby endpoint with comprehensive parameter validation and error handling. The controller validates required parameters (lat, lon, radius) and optional parameters (hours, limit) with appropriate range checking. Integrates with PreparedQueries for efficient database access and WeatherJSON for response serialization. Key features: - Required params: lat (-90 to 90), lon (-180 to 180), radius (0-1000 miles) - Optional params: hours (1-168, default 6), limit (1-100, default 50) - Proper error responses via FallbackController (400 for bad requests, 422 for validation) - Full test coverage (16 tests) including edge cases and error scenarios Also updates FallbackController to handle {:error, status, message} tuples and fixes WeatherJSON to support both field name formats (lat/lon and latitude/longitude) for compatibility with existing tests and actual query results. --- .../controllers/api/v1/fallback_controller.ex | 8 + .../controllers/api/v1/weather_controller.ex | 156 ++++++++++ .../controllers/api/v1/weather_json.ex | 13 +- lib/aprsme_web/router.ex | 1 + .../api/v1/weather_controller_test.exs | 287 ++++++++++++++++++ 5 files changed, 462 insertions(+), 3 deletions(-) create mode 100644 lib/aprsme_web/controllers/api/v1/weather_controller.ex create mode 100644 test/aprsme_web/controllers/api/v1/weather_controller_test.exs 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