docs: add weather nearby API implementation plan

Detailed TDD implementation plan for GET /api/v1/weather/nearby endpoint.
Includes database queries, JSON views, controller logic, and comprehensive
testing strategy.
This commit is contained in:
Graham McIntire 2026-03-22 10:58:03 -05:00
parent d87ef4dac5
commit 6ed10d11f4
No known key found for this signature in database

View file

@ -0,0 +1,969 @@
# Weather Nearby API Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add GET /api/v1/weather/nearby endpoint to find weather stations within a radius of a lat/lon point
**Architecture:** Phoenix controller → PreparedQueries context function → PostGIS ST_DWithin query → JSON view. Uses existing spatial indexes and follows established API patterns.
**Tech Stack:** Phoenix, Ecto, PostGIS, ExUnit
---
## Task 1: Add Database Query Function
**Files:**
- Modify: `lib/aprsme/packets/prepared_queries.ex:211` (add new function at end)
- Test: `test/aprsme/packets/prepared_queries_test.exs` (new file)
**Step 1: Write the failing test**
Create `test/aprsme/packets/prepared_queries_test.exs`:
```elixir
defmodule Aprsme.Packets.PreparedQueriesTest do
use Aprsme.DataCase, async: false
alias Aprsme.Packets.PreparedQueries
alias Aprsme.Repo
describe "get_nearby_weather_stations/4" do
setup do
# San Francisco center point
center_lat = 37.7749
center_lon = -122.4194
# Station A: ~0.5 miles north, weather 1 hour ago ✓
{:ok, station_a} =
insert_weather_station("STATION-A", 37.7821, -122.4194, hours_ago: 1)
# Station B: ~10 miles east, weather 2 hours ago ✓
{:ok, station_b} =
insert_weather_station("STATION-B", 37.7749, -122.2694, hours_ago: 2)
# Station C: ~50 miles south, weather 1 hour ago ✗ (outside 25 mile radius)
{:ok, _station_c} =
insert_weather_station("STATION-C", 37.0749, -122.4194, hours_ago: 1)
# Station D: ~5 miles south, weather 8 hours ago ✗ (outside 6 hour window)
{:ok, _station_d} =
insert_weather_station("STATION-D", 37.7200, -122.4194, hours_ago: 8)
# Station E: ~5 miles north, no weather data ✗
{:ok, _station_e} =
insert_packet("STATION-E", 37.8300, -122.4194, has_weather: false, hours_ago: 1)
%{
center_lat: center_lat,
center_lon: center_lon,
station_a: station_a,
station_b: station_b
}
end
test "returns weather stations within radius", %{
center_lat: lat,
center_lon: lon,
station_a: station_a,
station_b: station_b
} do
results = PreparedQueries.get_nearby_weather_stations(lat, lon, 25.0, %{})
callsigns = Enum.map(results, & &1.callsign)
assert station_a.sender in callsigns
assert station_b.sender in callsigns
assert length(results) == 2
end
test "excludes stations outside radius", %{center_lat: lat, center_lon: lon} do
results = PreparedQueries.get_nearby_weather_stations(lat, lon, 25.0, %{})
callsigns = Enum.map(results, & &1.callsign)
refute "STATION-C" in callsigns
end
test "excludes stations outside time window", %{center_lat: lat, center_lon: lon} do
results = PreparedQueries.get_nearby_weather_stations(lat, lon, 25.0, %{hours: 6})
callsigns = Enum.map(results, & &1.callsign)
refute "STATION-D" in callsigns
end
test "excludes stations without weather data", %{center_lat: lat, center_lon: lon} do
results = PreparedQueries.get_nearby_weather_stations(lat, lon, 25.0, %{})
callsigns = Enum.map(results, & &1.callsign)
refute "STATION-E" in callsigns
end
test "orders results by distance (closest first)", %{
center_lat: lat,
center_lon: lon,
station_a: station_a,
station_b: station_b
} do
results = PreparedQueries.get_nearby_weather_stations(lat, lon, 25.0, %{})
assert length(results) == 2
# Station A is ~0.5 miles, Station B is ~10 miles
assert List.first(results).callsign == station_a.sender
assert List.last(results).callsign == station_b.sender
assert List.first(results).distance_miles < List.last(results).distance_miles
end
test "respects limit option", %{center_lat: lat, center_lon: lon} do
results = PreparedQueries.get_nearby_weather_stations(lat, lon, 25.0, %{limit: 1})
assert length(results) == 1
end
test "uses default hours of 6", %{center_lat: lat, center_lon: lon} do
# Station D is 8 hours old, should be excluded with default
results = PreparedQueries.get_nearby_weather_stations(lat, lon, 25.0, %{})
callsigns = Enum.map(results, & &1.callsign)
refute "STATION-D" in callsigns
end
test "returns distance in miles", %{center_lat: lat, center_lon: lon, station_a: station_a} do
results = PreparedQueries.get_nearby_weather_stations(lat, lon, 25.0, %{})
station_a_result = Enum.find(results, &(&1.callsign == station_a.sender))
assert station_a_result.distance_miles > 0
assert station_a_result.distance_miles < 1.0
end
test "includes weather data in results", %{center_lat: lat, center_lon: lon} do
results = PreparedQueries.get_nearby_weather_stations(lat, lon, 25.0, %{})
assert length(results) > 0
first = List.first(results)
assert is_float(first.temperature)
assert is_float(first.humidity)
assert is_float(first.wind_speed)
end
test "returns empty list when no stations in radius", _context do
# Middle of Pacific Ocean
results = PreparedQueries.get_nearby_weather_stations(0.0, -180.0, 10.0, %{})
assert results == []
end
end
defp insert_weather_station(callsign, lat, lon, opts) do
hours_ago = Keyword.get(opts, :hours_ago, 1)
attrs = %{
sender: callsign,
base_callsign: callsign,
destination: "APRS",
path: "WIDE1-1",
received_at: DateTime.add(DateTime.utc_now(), -hours_ago * 3600, :second),
has_position: true,
has_weather: true,
lat: Decimal.from_float(lat),
lon: Decimal.from_float(lon),
location: %Geo.Point{coordinates: {lon, lat}, srid: 4326},
temperature: 72.5,
humidity: 65.0,
pressure: 1013.2,
wind_speed: 8.5,
wind_direction: 270,
wind_gust: 12.0,
symbol_table_id: "/",
symbol_code: "_",
comment: "Test weather station"
}
%Aprsme.Packet{}
|> Aprsme.Packet.changeset(attrs)
|> Repo.insert()
end
defp insert_packet(callsign, lat, lon, opts) do
hours_ago = Keyword.get(opts, :hours_ago, 1)
has_weather = Keyword.get(opts, :has_weather, false)
attrs = %{
sender: callsign,
base_callsign: callsign,
destination: "APRS",
path: "WIDE1-1",
received_at: DateTime.add(DateTime.utc_now(), -hours_ago * 3600, :second),
has_position: true,
has_weather: has_weather,
lat: Decimal.from_float(lat),
lon: Decimal.from_float(lon),
location: %Geo.Point{coordinates: {lon, lat}, srid: 4326},
symbol_table_id: "/",
symbol_code: "/"
}
%Aprsme.Packet{}
|> Aprsme.Packet.changeset(attrs)
|> Repo.insert()
end
end
```
**Step 2: Run test to verify it fails**
Run: `mix test test/aprsme/packets/prepared_queries_test.exs`
Expected: FAIL with "function PreparedQueries.get_nearby_weather_stations/4 is undefined"
**Step 3: Write minimal implementation**
Add to `lib/aprsme/packets/prepared_queries.ex` at line 211:
```elixir
@doc """
Get nearby weather stations within a radius (in miles) from a lat/lon point.
Returns stations with recent weather data, ordered by distance.
## Options
* `:hours` - Time window in hours (default: 6)
* `:limit` - Maximum results (default: 50)
## Returns
List of maps with station info, weather data, and distance in miles.
"""
@spec get_nearby_weather_stations(float(), float(), float(), map()) :: [map()]
def get_nearby_weather_stations(lat, lon, radius_miles, opts \\ %{}) do
hours = Map.get(opts, :hours, 6)
limit = Map.get(opts, :limit, 50)
# Convert miles to meters for PostGIS (1 mile = 1609.34 meters)
radius_meters = radius_miles * 1609.34
cutoff_time = DateTime.add(DateTime.utc_now(), -hours * 3600, :second)
# Build point for distance calculation
point = %Geo.Point{coordinates: {lon, lat}, srid: 4326}
# Subquery to get most recent weather packet per base_callsign
recent_weather =
from(p in Packet,
where: p.has_weather == true,
where: p.has_position == true,
where: p.received_at >= ^cutoff_time,
where: not is_nil(p.location),
where:
fragment(
"ST_DWithin(?::geography, ?::geography, ?)",
p.location,
^point,
^radius_meters
),
distinct: p.base_callsign,
order_by: [asc: p.base_callsign, desc: p.received_at]
)
# Order by distance and limit results
from(p in subquery(recent_weather),
order_by: fragment("? <-> ?", p.location, ^point),
limit: ^limit,
select: %{
callsign: p.sender,
base_callsign: p.base_callsign,
lat: fragment("ST_Y(?)", p.location),
lon: fragment("ST_X(?)", p.location),
distance_miles: fragment("ST_Distance(?::geography, ?::geography) / 1609.34", p.location, ^point),
temperature: p.temperature,
humidity: p.humidity,
pressure: p.pressure,
wind_speed: p.wind_speed,
wind_direction: p.wind_direction,
wind_gust: p.wind_gust,
rain_1h: p.rain_1h,
rain_24h: p.rain_24h,
rain_since_midnight: p.rain_since_midnight,
symbol_table_id: p.symbol_table_id,
symbol_code: p.symbol_code,
comment: p.comment,
received_at: p.received_at
}
)
|> Repo.all()
end
```
**Step 4: Run test to verify it passes**
Run: `mix test test/aprsme/packets/prepared_queries_test.exs`
Expected: All tests PASS
**Step 5: Run mix format**
Run: `mix format`
**Step 6: Commit**
```bash
git add lib/aprsme/packets/prepared_queries.ex test/aprsme/packets/prepared_queries_test.exs
git commit -m "feat: add get_nearby_weather_stations query function
Adds PreparedQueries.get_nearby_weather_stations/4 to find weather
stations within a radius using PostGIS ST_DWithin. Supports configurable
time window and result limit. Returns stations ordered by distance with
weather data included."
```
---
## Task 2: Add JSON View Module
**Files:**
- Create: `lib/aprsme_web/controllers/api/v1/weather_json.ex`
- Test: `test/aprsme_web/controllers/api/v1/weather_json_test.exs`
**Step 1: Write the failing test**
Create `test/aprsme_web/controllers/api/v1/weather_json_test.exs`:
```elixir
defmodule AprsmeWeb.Api.V1.WeatherJSONTest do
use ExUnit.Case, async: true
alias AprsmeWeb.Api.V1.WeatherJSON
describe "nearby/1" do
test "renders list of weather stations with all fields" do
stations = [
%{
callsign: "N0CALL-13",
base_callsign: "N0CALL",
lat: 37.7849,
lon: -122.4094,
distance_miles: 0.87,
temperature: 72.5,
humidity: 65.0,
pressure: 1013.2,
wind_speed: 8.5,
wind_direction: 270,
wind_gust: 12.0,
rain_1h: 0.0,
rain_24h: 0.5,
rain_since_midnight: 0.3,
symbol_table_id: "/",
symbol_code: "_",
comment: "Davis Vantage Pro2",
received_at: ~U[2026-03-22 15:30:00Z]
}
]
params = %{lat: 37.7749, lon: -122.4194, radius_miles: 25, hours: 6, limit: 50}
result = WeatherJSON.nearby(%{stations: stations, params: params})
assert %{data: [station], meta: meta} = result
assert station.callsign == "N0CALL-13"
assert station.base_callsign == "N0CALL"
assert station.position.lat == 37.7849
assert station.position.lon == -122.4094
assert station.distance_miles == 0.87
assert station.weather.temperature == 72.5
assert station.weather.humidity == 65.0
assert station.weather.pressure == 1013.2
assert station.weather.wind_speed == 8.5
assert station.weather.wind_direction == 270
assert station.weather.wind_gust == 12.0
assert station.weather.rain_1h == 0.0
assert station.weather.rain_24h == 0.5
assert station.weather.rain_since_midnight == 0.3
assert station.symbol.table_id == "/"
assert station.symbol.code == "_"
assert station.comment == "Davis Vantage Pro2"
assert station.last_report == "2026-03-22T15:30:00Z"
assert meta.count == 1
assert meta.params.lat == 37.7749
assert meta.params.lon == -122.4194
assert meta.params.radius_miles == 25
assert meta.params.hours == 6
assert meta.params.limit == 50
end
test "handles null weather fields" do
stations = [
%{
callsign: "TEST-1",
base_callsign: "TEST",
lat: 37.7849,
lon: -122.4094,
distance_miles: 1.5,
temperature: 70.0,
humidity: nil,
pressure: nil,
wind_speed: nil,
wind_direction: nil,
wind_gust: nil,
rain_1h: nil,
rain_24h: nil,
rain_since_midnight: nil,
symbol_table_id: "/",
symbol_code: "_",
comment: nil,
received_at: ~U[2026-03-22 15:30:00Z]
}
]
params = %{lat: 37.7749, lon: -122.4194, radius_miles: 25, hours: 6, limit: 50}
result = WeatherJSON.nearby(%{stations: stations, params: params})
assert %{data: [station]} = result
assert station.weather.temperature == 70.0
assert station.weather.humidity == nil
assert station.weather.pressure == nil
assert station.comment == nil
end
test "renders empty list when no stations found" do
params = %{lat: 0.0, lon: -180.0, radius_miles: 10, hours: 6, limit: 50}
result = WeatherJSON.nearby(%{stations: [], params: params})
assert %{data: [], meta: meta} = result
assert meta.count == 0
end
end
end
```
**Step 2: Run test to verify it fails**
Run: `mix test test/aprsme_web/controllers/api/v1/weather_json_test.exs`
Expected: FAIL with "module AprsmeWeb.Api.V1.WeatherJSON is not available"
**Step 3: Write minimal implementation**
Create `lib/aprsme_web/controllers/api/v1/weather_json.ex`:
```elixir
defmodule AprsmeWeb.Api.V1.WeatherJSON do
@moduledoc """
JSON rendering for weather API endpoints.
"""
@doc """
Renders a list of nearby weather stations.
"""
def nearby(%{stations: stations, params: params}) do
%{
data: Enum.map(stations, &station_json/1),
meta: %{
count: length(stations),
params: params
}
}
end
defp station_json(station) do
%{
callsign: station.callsign,
base_callsign: station.base_callsign,
position: %{
lat: station.lat,
lon: station.lon
},
distance_miles: station.distance_miles,
weather: %{
temperature: station.temperature,
humidity: station.humidity,
pressure: station.pressure,
wind_speed: station.wind_speed,
wind_direction: station.wind_direction,
wind_gust: station.wind_gust,
rain_1h: station.rain_1h,
rain_24h: station.rain_24h,
rain_since_midnight: station.rain_since_midnight
},
symbol: %{
table_id: station.symbol_table_id,
code: station.symbol_code
},
comment: station.comment,
last_report: format_datetime(station.received_at)
}
end
defp format_datetime(%DateTime{} = dt) do
DateTime.to_iso8601(dt)
end
defp format_datetime(nil), do: nil
end
```
**Step 4: Run test to verify it passes**
Run: `mix test test/aprsme_web/controllers/api/v1/weather_json_test.exs`
Expected: All tests PASS
**Step 5: Run mix format**
Run: `mix format`
**Step 6: Commit**
```bash
git add lib/aprsme_web/controllers/api/v1/weather_json.ex test/aprsme_web/controllers/api/v1/weather_json_test.exs
git commit -m "feat: add WeatherJSON view for API responses
Adds JSON rendering for nearby weather stations endpoint. Formats
station data with position, weather metrics, symbols, and metadata."
```
---
## Task 3: Add Weather Controller
**Files:**
- Create: `lib/aprsme_web/controllers/api/v1/weather_controller.ex`
- Test: `test/aprsme_web/controllers/api/v1/weather_controller_test.exs`
**Step 1: Write the failing test**
Create `test/aprsme_web/controllers/api/v1/weather_controller_test.exs`:
```elixir
defmodule AprsmeWeb.Api.V1.WeatherControllerTest do
use AprsmeWeb.ConnCase, async: false
alias Aprsme.Repo
describe "GET /api/v1/weather/nearby" do
setup do
# Insert test weather stations
{:ok, station_a} =
insert_weather_station("TESTA-1", 37.7821, -122.4194, hours_ago: 1)
{:ok, station_b} =
insert_weather_station("TESTB-1", 37.7749, -122.2694, hours_ago: 2)
%{station_a: station_a, station_b: station_b}
end
test "returns 200 with weather stations when valid params provided", %{conn: conn} do
conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25")
assert %{"data" => data, "meta" => meta} = json_response(conn, 200)
assert is_list(data)
assert length(data) >= 2
assert meta["count"] >= 2
assert meta["params"]["lat"] == 37.7749
assert meta["params"]["lon"] == -122.4194
assert meta["params"]["radius_miles"] == 25
end
test "returns weather station data with correct structure", %{conn: conn} do
conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25")
assert %{"data" => [station | _]} = json_response(conn, 200)
assert is_binary(station["callsign"])
assert is_binary(station["base_callsign"])
assert is_map(station["position"])
assert is_float(station["position"]["lat"])
assert is_float(station["position"]["lon"])
assert is_float(station["distance_miles"])
assert is_map(station["weather"])
assert is_map(station["symbol"])
assert is_binary(station["last_report"])
end
test "returns 400 when lat is missing", %{conn: conn} do
conn = get(conn, ~p"/api/v1/weather/nearby?lon=-122.4194&radius=25")
assert %{"error" => error} = json_response(conn, 400)
assert error =~ "lat"
end
test "returns 400 when lon is missing", %{conn: conn} do
conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&radius=25")
assert %{"error" => error} = json_response(conn, 400)
assert error =~ "lon"
end
test "returns 400 when radius is missing", %{conn: conn} do
conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194")
assert %{"error" => error} = json_response(conn, 400)
assert error =~ "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=-122.4194&radius=25")
assert %{"error" => error} = json_response(conn, 422)
assert error =~ "latitude"
end
test "returns 422 when lon is out of range", %{conn: conn} do
conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=181&radius=25")
assert %{"error" => error} = json_response(conn, 422)
assert error =~ "longitude"
end
test "returns 422 when radius is zero or negative", %{conn: conn} do
conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=0")
assert %{"error" => error} = json_response(conn, 422)
assert error =~ "radius"
end
test "returns 422 when radius exceeds maximum", %{conn: conn} do
conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=1001")
assert %{"error" => error} = json_response(conn, 422)
assert error =~ "radius"
end
test "returns 422 when hours is out of range", %{conn: conn} do
conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25&hours=0")
assert %{"error" => error} = json_response(conn, 422)
assert error =~ "hours"
end
test "returns 422 when limit is out of range", %{conn: conn} do
conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25&limit=0")
assert %{"error" => error} = json_response(conn, 422)
assert error =~ "limit"
end
test "accepts optional hours parameter", %{conn: conn} do
conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25&hours=12")
assert %{"meta" => meta} = json_response(conn, 200)
assert meta["params"]["hours"] == 12
end
test "accepts optional limit parameter", %{conn: conn} do
conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25&limit=10")
assert %{"meta" => meta} = json_response(conn, 200)
assert meta["params"]["limit"] == 10
end
test "uses default hours when not provided", %{conn: conn} do
conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25")
assert %{"meta" => meta} = json_response(conn, 200)
assert meta["params"]["hours"] == 6
end
test "uses default limit when not provided", %{conn: conn} do
conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25")
assert %{"meta" => meta} = json_response(conn, 200)
assert meta["params"]["limit"] == 50
end
test "returns empty array when no stations in radius", %{conn: conn} do
# Middle of Pacific Ocean
conn = get(conn, ~p"/api/v1/weather/nearby?lat=0.0&lon=-180.0&radius=10")
assert %{"data" => data, "meta" => meta} = json_response(conn, 200)
assert data == []
assert meta["count"] == 0
end
test "coerces string params to numbers", %{conn: conn} do
conn = get(conn, ~p"/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25")
assert json_response(conn, 200)
end
end
defp insert_weather_station(callsign, lat, lon, opts) do
hours_ago = Keyword.get(opts, :hours_ago, 1)
attrs = %{
sender: callsign,
base_callsign: String.split(callsign, "-") |> List.first(),
destination: "APRS",
path: "WIDE1-1",
received_at: DateTime.add(DateTime.utc_now(), -hours_ago * 3600, :second),
has_position: true,
has_weather: true,
lat: Decimal.from_float(lat),
lon: Decimal.from_float(lon),
location: %Geo.Point{coordinates: {lon, lat}, srid: 4326},
temperature: 72.5,
humidity: 65.0,
pressure: 1013.2,
wind_speed: 8.5,
wind_direction: 270,
wind_gust: 12.0,
rain_1h: 0.0,
rain_24h: 0.5,
rain_since_midnight: 0.3,
symbol_table_id: "/",
symbol_code: "_",
comment: "Test weather station"
}
%Aprsme.Packet{}
|> Aprsme.Packet.changeset(attrs)
|> Repo.insert()
end
end
```
**Step 2: Run test to verify it fails**
Run: `mix test test/aprsme_web/controllers/api/v1/weather_controller_test.exs`
Expected: FAIL with route not found or controller not available
**Step 3: Write minimal implementation**
Create `lib/aprsme_web/controllers/api/v1/weather_controller.ex`:
```elixir
defmodule AprsmeWeb.Api.V1.WeatherController do
@moduledoc """
API controller for weather-related endpoints.
"""
use AprsmeWeb, :controller
alias Aprsme.Packets.PreparedQueries
alias AprsmeWeb.Api.V1.WeatherJSON
action_fallback AprsmeWeb.Api.V1.FallbackController
@doc """
Get weather stations within a radius of a lat/lon point.
## Parameters
* `lat` - Latitude (-90 to 90)
* `lon` - Longitude (-180 to 180)
* `radius` - Search radius in miles (> 0, max 1000)
* `hours` - Time window in hours (optional, default: 6, max: 168)
* `limit` - Maximum results (optional, default: 50, max: 100)
## Returns
* 200 - Success with list of weather stations
* 400 - Missing required parameters
* 422 - Invalid parameter values
"""
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
}
)
conn
|> put_status(:ok)
|> put_view(json: WeatherJSON)
|> render(:nearby, %{
stations: stations,
params: validated_params
})
end
end
defp validate_params(params) do
with {:ok, lat} <- parse_required_float(params, "lat"),
{:ok, lon} <- parse_required_float(params, "lon"),
{:ok, radius} <- parse_required_float(params, "radius"),
{:ok, hours} <- parse_optional_int(params, "hours", 6),
{:ok, limit} <- parse_optional_int(params, "limit", 50),
:ok <- validate_lat(lat),
:ok <- validate_lon(lon),
:ok <- validate_radius(radius),
:ok <- validate_hours(hours),
:ok <- validate_limit(limit) do
{:ok,
%{
lat: lat,
lon: lon,
radius: radius,
radius_miles: radius,
hours: hours,
limit: limit
}}
end
end
defp parse_required_float(params, key) do
case Map.get(params, key) do
nil ->
{:error, "Missing required parameter: #{key}"}
value when is_binary(value) ->
case Float.parse(value) do
{float, _} -> {:ok, float}
:error -> {:error, "Invalid #{key}: must be a number"}
end
value when is_number(value) ->
{:ok, value * 1.0}
end
end
defp parse_optional_int(params, key, default) do
case Map.get(params, key) do
nil ->
{:ok, default}
value when is_binary(value) ->
case Integer.parse(value) do
{int, _} -> {:ok, int}
:error -> {:error, "Invalid #{key}: must be an integer"}
end
value when is_integer(value) ->
{:ok, value}
value when is_float(value) ->
{:ok, trunc(value)}
end
end
defp validate_lat(lat) when lat >= -90 and lat <= 90, do: :ok
defp validate_lat(_),
do: {:error, "Invalid latitude: must be a number between -90 and 90"}
defp validate_lon(lon) when lon >= -180 and lon <= 180, do: :ok
defp validate_lon(_),
do: {:error, "Invalid longitude: must be a number between -180 and 180"}
defp validate_radius(radius) when radius > 0 and radius <= 1000, do: :ok
defp validate_radius(_),
do: {:error, "Radius must be between 0 and 1000 miles"}
defp validate_hours(hours) when hours >= 1 and hours <= 168, do: :ok
defp validate_hours(_),
do: {:error, "Hours must be between 1 and 168 (7 days)"}
defp validate_limit(limit) when limit >= 1 and limit <= 100, do: :ok
defp validate_limit(_),
do: {:error, "Limit must be between 1 and 100"}
end
```
**Step 4: Run test to verify it still fails (no route)**
Run: `mix test test/aprsme_web/controllers/api/v1/weather_controller_test.exs`
Expected: FAIL with route not found
**Step 5: Add route to router**
Modify `lib/aprsme_web/router.ex` at line 118 (in the `/api/v1` scope):
```elixir
# API v1 routes
scope "/api/v1", AprsmeWeb.Api.V1, as: :api_v1 do
pipe_through :api
get "/callsign/:callsign", CallsignController, :show
get "/weather/nearby", WeatherController, :nearby
end
```
**Step 6: Run test to verify it passes**
Run: `mix test test/aprsme_web/controllers/api/v1/weather_controller_test.exs`
Expected: All tests PASS
**Step 7: Run mix format**
Run: `mix format`
**Step 8: Commit**
```bash
git add lib/aprsme_web/controllers/api/v1/weather_controller.ex test/aprsme_web/controllers/api/v1/weather_controller_test.exs lib/aprsme_web/router.ex
git commit -m "feat: add weather nearby API endpoint
Adds GET /api/v1/weather/nearby endpoint to find weather stations
within a radius. Validates parameters (lat, lon, radius, hours, limit)
and returns formatted JSON response with station data and weather
metrics."
```
---
## Task 4: Run Full Test Suite
**Step 1: Run all tests**
Run: `mix test`
Expected: All tests PASS
**Step 2: If any tests fail, fix them**
If tests fail, investigate and fix issues. Commit fixes separately.
**Step 3: Final commit if any fixes were needed**
```bash
git add <files>
git commit -m "fix: resolve test failures"
```
---
## Task 5: Manual Verification
**Step 1: Start the development server**
Run: `mix phx.server`
**Step 2: Test the endpoint with curl**
```bash
# Valid request
curl "http://localhost:4000/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25"
# With optional parameters
curl "http://localhost:4000/api/v1/weather/nearby?lat=37.7749&lon=-122.4194&radius=25&hours=12&limit=10"
# Invalid parameters
curl "http://localhost:4000/api/v1/weather/nearby?lat=91&lon=-122.4194&radius=25"
```
**Step 3: Verify responses match expected format**
Check that:
- Valid requests return 200 with data and meta
- Invalid parameters return 400/422 with error messages
- Empty results return 200 with empty array
---
## Summary
This plan implements the weather nearby API endpoint following TDD principles:
1. **Database query function** - PostGIS spatial query with ST_DWithin
2. **JSON view** - Response formatting with weather data
3. **Controller** - Parameter validation and request handling
4. **Router** - Endpoint registration
5. **Testing** - Comprehensive unit and integration tests
All code follows existing patterns in the codebase (PreparedQueries, API controllers, JSON views).