Detailed TDD implementation plan for GET /api/v1/weather/nearby endpoint. Includes database queries, JSON views, controller logic, and comprehensive testing strategy.
969 lines
29 KiB
Markdown
969 lines
29 KiB
Markdown
# 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).
|