refactor: apply functional programming patterns across codebase

- accounts.ex: replace validate_user_password (if-based) with with-chain;
  drop unnecessary try/rescue around Repo.get_by. Extract transaction_unwrap
  helper to eliminate 3 copies of the Multi result-unwrap case block.

- packets.ex: deduplicate store_bad_packet by extracting raw_packet_string,
  extract_error_type, and extract_error_message into composable helpers.
  Normalize find_coordinate_value — collapse 12 clauses into a generic
  deep_get/direct_get that handles atom/string keys and nested maps uniformly.

- data_builder.ex: split build_simple_popup into separate data-construction
  (build_simple_popup_data) and rendering (render_popup) phases, eliminating
  duplicated PopupComponent/Safe/IO pipelines.

- param_utils.ex: unify parse_float_in_range and parse_int_in_range via a
  shared parse_in_range that accepts Float.parse/Integer.parse as a function
  parameter — functions-as-values pattern.
This commit is contained in:
Graham McIntire 2026-07-01 08:45:19 -05:00
parent bc60fec98c
commit 01ecf3d6bc
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 89 additions and 123 deletions

View file

@ -41,19 +41,11 @@ defmodule Aprsme.Accounts do
""" """
def get_user_by_email_and_password(email, password) when is_binary(email) and is_binary(password) do def get_user_by_email_and_password(email, password) when is_binary(email) and is_binary(password) do
user = with %User{} = user <- Repo.get_by(User, email: email),
try do true <- User.valid_password?(user, password) do
Repo.get_by(User, email: email)
rescue
_ -> nil
end
validate_user_password(user, password)
end
defp validate_user_password(user, password) do
if User.valid_password?(user, password) do
user user
else
_ -> nil
end end
end end
@ -229,10 +221,7 @@ defmodule Aprsme.Accounts do
Ecto.Multi.new() Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset) |> Ecto.Multi.update(:user, changeset)
|> Repo.transaction() |> Repo.transaction()
|> case do |> transaction_unwrap()
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
end end
@doc """ @doc """
@ -273,10 +262,7 @@ defmodule Aprsme.Accounts do
|> Ecto.Multi.update(:user, changeset) |> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
|> Repo.transaction() |> Repo.transaction()
|> case do |> transaction_unwrap()
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
end end
## Session ## Session
@ -415,7 +401,16 @@ defmodule Aprsme.Accounts do
|> Ecto.Multi.update(:user, User.password_changeset(user, attrs)) |> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
|> Repo.transaction() |> Repo.transaction()
|> case do |> transaction_unwrap()
end
# --- Private helpers ---
# Unwraps an Ecto.Multi transaction result, converting the verbose
# {:ok, %{key: value}} / {:error, key, changeset, _} tuple into the
# standard {:ok, value} / {:error, changeset} shape used by callers.
defp transaction_unwrap(multi_result) do
case multi_result do
{:ok, %{user: user}} -> {:ok, user} {:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset} {:error, :user, changeset, _} -> {:error, changeset}
end end

View file

@ -162,23 +162,37 @@ defmodule Aprsme.Packets do
find_coordinate_value(map, coord_type) || find_coordinate_value(map, coord_string) find_coordinate_value(map, coord_type) || find_coordinate_value(map, coord_string)
end end
# Pattern matching for direct coordinate access # Pattern matching for coordinate access — normalizes atom/string keys and
defp find_coordinate_value(%{latitude: lat}, :latitude) when not is_nil(lat), do: lat # nested/flat maps into a uniform lookup before extracting the value.
defp find_coordinate_value(%{longitude: lon}, :longitude) when not is_nil(lon), do: lon defp find_coordinate_value(map, coord_type) do
defp find_coordinate_value(%{"latitude" => lat}, "latitude") when not is_nil(lat), do: lat key = coord_key(coord_type)
defp find_coordinate_value(%{"longitude" => lon}, "longitude") when not is_nil(lon), do: lon direct_get(map, key) || deep_get(map, [:position, key])
end
# Pattern matching for nested position access defp coord_key(k) when is_atom(k), do: Atom.to_string(k)
defp find_coordinate_value(%{position: %{latitude: lat}}, :latitude) when not is_nil(lat), do: lat defp coord_key(k), do: k
defp find_coordinate_value(%{position: %{longitude: lon}}, :longitude) when not is_nil(lon), do: lon
defp find_coordinate_value(%{position: %{"latitude" => lat}}, :latitude) when not is_nil(lat), do: lat
defp find_coordinate_value(%{position: %{"longitude" => lon}}, :longitude) when not is_nil(lon), do: lon
defp find_coordinate_value(%{"position" => %{latitude: lat}}, "latitude") when not is_nil(lat), do: lat
defp find_coordinate_value(%{"position" => %{longitude: lon}}, "longitude") when not is_nil(lon), do: lon
defp find_coordinate_value(%{"position" => %{"latitude" => lat}}, "latitude") when not is_nil(lat), do: lat
defp find_coordinate_value(%{"position" => %{"longitude" => lon}}, "longitude") when not is_nil(lon), do: lon
defp find_coordinate_value(_, _), do: nil defp direct_get(map, key) when is_map(map) do
Map.get(map, key) || Map.get(map, String.to_existing_atom(key))
rescue
ArgumentError -> nil
end
defp direct_get(_, _), do: nil
defp deep_get(map, []) when is_map(map), do: map
defp deep_get(map, [head | tail]) when is_map(map) do
case Map.get(map, head) || Map.get(map, String.to_existing_atom(head)) do
nil -> nil
inner when is_map(inner) -> deep_get(inner, tail)
_ -> nil
end
rescue
ArgumentError -> nil
end
defp deep_get(_, _), do: nil
defp set_lat_lon(attrs, lat, lon) do defp set_lat_lon(attrs, lat, lon) do
# Optimize: avoid creating anonymous function on each call # Optimize: avoid creating anonymous function on each call
@ -247,55 +261,29 @@ defmodule Aprsme.Packets do
""" """
@spec store_bad_packet(map() | String.t(), any()) :: @spec store_bad_packet(map() | String.t(), any()) ::
{:ok, struct()} | {:error, Ecto.Changeset.t()} {:ok, struct()} | {:error, Ecto.Changeset.t()}
def store_bad_packet(packet_data, error) when is_binary(packet_data) do def store_bad_packet(packet_data, error) do
error_type =
case error do
%{type: type} -> type
%{__struct__: struct} -> struct |> to_string() |> String.replace("Elixir.", "")
_ -> "UnknownError"
end
error_message =
case error do
%{message: message} -> message
%{__struct__: _} -> Exception.message(error)
_ -> inspect(error)
end
%BadPacket{} %BadPacket{}
|> BadPacket.changeset(%{ |> BadPacket.changeset(%{
raw_packet: Aprsme.EncodingUtils.sanitize_string(packet_data), raw_packet: raw_packet_string(packet_data),
error_message: error_message, error_message: extract_error_message(error),
error_type: error_type, error_type: extract_error_type(error),
attempted_at: DateTime.utc_now() attempted_at: DateTime.utc_now()
}) })
|> Repo.insert() |> Repo.insert()
end end
def store_bad_packet(packet_data, error) when is_map(packet_data) do defp raw_packet_string(data) when is_binary(data), do: Aprsme.EncodingUtils.sanitize_string(data)
error_type = defp raw_packet_string(%{raw_packet: rp}), do: rp
case error do defp raw_packet_string(%{"raw_packet" => rp}), do: rp
%{type: type} -> type defp raw_packet_string(data), do: inspect(data)
%{__struct__: struct} -> struct |> to_string() |> String.replace("Elixir.", "")
_ -> "UnknownError"
end
error_message = defp extract_error_type(%{type: type}), do: type
case error do defp extract_error_type(%{__struct__: struct}), do: struct |> to_string() |> String.replace("Elixir.", "")
%{message: message} -> message defp extract_error_type(_), do: "UnknownError"
%{__struct__: _} -> Exception.message(error)
_ -> inspect(error)
end
%BadPacket{} defp extract_error_message(%{message: message}), do: message
|> BadPacket.changeset(%{ defp extract_error_message(%{__struct__: _} = error), do: Exception.message(error)
raw_packet: packet_data[:raw_packet] || packet_data["raw_packet"] || inspect(packet_data), defp extract_error_message(error), do: inspect(error)
error_message: error_message,
error_type: error_type,
attempted_at: DateTime.utc_now()
})
|> Repo.insert()
end
# Extracts position data from packet, checking various possible locations # Extracts position data from packet, checking various possible locations
defp extract_position(packet_data) do defp extract_position(packet_data) do

View file

@ -215,23 +215,22 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
""" """
@spec build_simple_popup(map(), boolean()) :: String.t() @spec build_simple_popup(map(), boolean()) :: String.t()
def build_simple_popup(packet, has_weather) do def build_simple_popup(packet, has_weather) do
# Build popup HTML directly without database queries packet
callsign = map_label(packet) |> build_simple_popup_data()
timestamp_dt = get_packet_received_at(packet) |> Map.put(:weather_link, has_weather || weather_packet?(packet))
cache_buster = System.system_time(:millisecond) |> render_popup()
end
# Check if this packet itself is a weather packet defp build_simple_popup_data(packet) do
is_weather = weather_packet?(packet) is_weather = weather_packet?(packet)
if is_weather do if is_weather do
# Build weather popup
%{ %{
callsign: callsign, callsign: map_label(packet),
comment: nil, comment: nil,
timestamp_dt: timestamp_dt, timestamp_dt: get_packet_received_at(packet),
cache_buster: cache_buster, cache_buster: System.system_time(:millisecond),
weather: true, weather: true,
weather_link: true,
temperature: get_weather_field(packet, :temperature), temperature: get_weather_field(packet, :temperature),
temp_unit: "°F", temp_unit: "°F",
humidity: get_weather_field(packet, :humidity), humidity: get_weather_field(packet, :humidity),
@ -248,29 +247,27 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
rain_24h_unit: "in", rain_24h_unit: "in",
rain_since_midnight_unit: "in" rain_since_midnight_unit: "in"
} }
|> PopupComponent.popup()
|> Safe.to_iodata()
|> IO.iodata_to_binary()
else else
# Build standard popup
raw_comment = get_packet_field(packet, :comment, "") raw_comment = get_packet_field(packet, :comment, "")
clean_comment = Aprsme.EncodingUtils.sanitize_comment(raw_comment) clean_comment = Aprsme.EncodingUtils.sanitize_comment(raw_comment)
%{ %{
callsign: callsign, callsign: map_label(packet),
comment: clean_comment, comment: clean_comment,
timestamp_dt: timestamp_dt, timestamp_dt: get_packet_received_at(packet),
cache_buster: cache_buster, cache_buster: System.system_time(:millisecond),
weather: false, weather: false
# Use pre-fetched weather info
weather_link: has_weather
} }
|> PopupComponent.popup()
|> Safe.to_iodata()
|> IO.iodata_to_binary()
end end
end end
defp render_popup(data) do
data
|> PopupComponent.popup()
|> Safe.to_iodata()
|> IO.iodata_to_binary()
end
@doc """ @doc """
Select the best packet to display for a callsign. Select the best packet to display for a callsign.
Moved from historical_loader.ex. Moved from historical_loader.ex.

View file

@ -9,20 +9,7 @@ defmodule AprsmeWeb.Live.Shared.ParamUtils do
""" """
@spec parse_float_in_range(binary() | any(), float(), float(), float()) :: float() @spec parse_float_in_range(binary() | any(), float(), float(), float()) :: float()
def parse_float_in_range(str, default, min, max) when is_binary(str) do def parse_float_in_range(str, default, min, max) when is_binary(str) do
# Sanitize input first parse_in_range(str, default, min, max, &Float.parse/1, fn v -> finite?(v) && v end)
sanitized = sanitize_numeric_string(str)
case Float.parse(sanitized) do
{val, ""} when val >= min and val <= max ->
if finite?(val), do: val, else: default
{val, _remainder} when val >= min and val <= max ->
# Accept even with trailing characters, but validate the number
if finite?(val), do: val, else: default
_ ->
default
end
end end
def parse_float_in_range(_, default, _, _), do: default def parse_float_in_range(_, default, _, _), do: default
@ -32,24 +19,23 @@ defmodule AprsmeWeb.Live.Shared.ParamUtils do
""" """
@spec parse_int_in_range(binary() | any(), integer(), integer(), integer()) :: integer() @spec parse_int_in_range(binary() | any(), integer(), integer(), integer()) :: integer()
def parse_int_in_range(str, default, min, max) when is_binary(str) do def parse_int_in_range(str, default, min, max) when is_binary(str) do
# Sanitize input first parse_in_range(str, default, min, max, &Integer.parse/1)
end
def parse_int_in_range(_, default, _, _), do: default
defp parse_in_range(str, default, min, max, parser, validator \\ fn v -> v end) do
sanitized = sanitize_numeric_string(str) sanitized = sanitize_numeric_string(str)
case Integer.parse(sanitized) do case parser.(sanitized) do
{val, ""} when val >= min and val <= max -> {val, _} when val >= min and val <= max ->
val validator.(val) || default
{val, _remainder} when val >= min and val <= max ->
# Accept even with trailing characters
val
_ -> _ ->
default default
end end
end end
def parse_int_in_range(_, default, _, _), do: default
@doc """ @doc """
Sanitize numeric strings to prevent injection attacks. Sanitize numeric strings to prevent injection attacks.
""" """