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.
This commit is contained in:
parent
e539dc5a3e
commit
a796f8a3a9
5 changed files with 462 additions and 3 deletions
|
|
@ -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 =
|
||||
|
|
|
|||
156
lib/aprsme_web/controllers/api/v1/weather_controller.ex
Normal file
156
lib/aprsme_web/controllers/api/v1/weather_controller.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
287
test/aprsme_web/controllers/api/v1/weather_controller_test.exs
Normal file
287
test/aprsme_web/controllers/api/v1/weather_controller_test.exs
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue