aprs.me/lib/aprsme/encoding_utils.ex
Graham McIntire ee02769d38
refactor: pattern-match in HealthCheck, EncodingUtils, and StatusLive helpers
- HealthCheck.check_health(:readiness): collapse the 3-branch cond into
  readiness_status/2 dispatched on (health_status, shutting_down?).
- HealthCheck.shutting_down?: the whereis + alive? + try/catch nest
  becomes a small pipeline of function heads (ask_shutting_down?/1 +
  ask_if_alive/2), with the catch isolated to the single place it fires.
- EncodingUtils.sanitize_string_fields and sanitize_nested_map shared a
  three-branch atom-or-string key lookup. Extract update_existing_key/3
  + first_present_key/3 + nil_aware/1 helpers; the original functions
  become two-liners.
- StatusLive.Index.format_uptime: splits the cond into
  format_uptime_parts/4 clauses with numeric guards.
- StatusLive.Index.calculate_health_score: struct-shape pattern match
  on %{connected: false} / %{uptime_seconds: s} guards replaces the cond.
- StatusLive.Index.format_time_ago: pipe the diff through
  format_seconds_ago/1 with four clauses.

Adds tests for the four public StatusLive.Index helpers
(format_uptime, format_time_ago, get_health_description, format_number).
Coverage 67.29 → 67.43%.
2026-04-23 14:02:50 -05:00

404 lines
11 KiB
Elixir
Raw Permalink Blame History

defmodule Aprsme.EncodingUtils do
@moduledoc """
Encoding utilities for handling APRS packet data.
Provides functions for sanitizing strings, converting encodings,
and validating data.
"""
alias Aprs.Types.MicE
alias Aprsme.Encoding
@doc """
Sanitizes a binary to ensure it can be safely JSON encoded.
If the binary is valid UTF-8, returns it as-is.
If it contains invalid UTF-8 sequences, replaces them with the Unicode
replacement character (<28>) or removes them entirely.
## Examples
iex> Aprsme.EncodingUtils.sanitize_string("Hello World")
"Hello World"
iex> Aprsme.EncodingUtils.sanitize_string(<<72, 101, 108, 108, 111, 211, 87, 111, 114, 108, 100>>)
"HelloÓWorld"
"""
@spec sanitize_string(binary() | nil | any()) :: binary() | nil | any()
def sanitize_string(binary) when is_binary(binary) do
Encoding.sanitize_string(binary)
end
def sanitize_string(nil), do: nil
def sanitize_string(other), do: other
@doc """
Sanitizes a comment string by removing Mic-E telemetry and control characters.
## Examples
iex> Aprsme.EncodingUtils.sanitize_comment("!w>`!Clb=6.4m/s t=-66.7C")
"Clb=6.4m/s t=-66.7C"
iex> Aprsme.EncodingUtils.sanitize_comment("Normal comment")
"Normal comment"
"""
@spec sanitize_comment(binary() | nil | any()) :: binary() | nil | any()
def sanitize_comment(comment) when is_binary(comment) do
comment
|> Encoding.strip_mice_telemetry()
|> Encoding.sanitize_string()
end
def sanitize_comment(nil), do: nil
def sanitize_comment(other), do: other
@doc """
Converts various types to float with validation for safety.
## Examples
iex> Aprsme.EncodingUtils.to_float(1)
1.0
iex> Aprsme.EncodingUtils.to_float(1.5)
1.5
iex> Aprsme.EncodingUtils.to_float("2.3")
2.3
iex> Aprsme.EncodingUtils.to_float("bad")
nil
iex> Aprsme.EncodingUtils.to_float(nil)
nil
"""
@spec to_float(any()) :: float() | nil
def to_float(value) when is_float(value) do
# In Elixir, floats are always finite (no infinity or NaN)
value
end
def to_float(value) when is_integer(value) do
if value >= -9.0e15 and value <= 9.0e15 do
value * 1.0
end
end
def to_float(value) when is_binary(value) do
# Sanitize and use Gleam's safe conversion
sanitized = value |> sanitize_string() |> to_string()
case Encoding.to_float_safe(sanitized) do
{:ok, f} -> f
nil -> nil
end
end
def to_float(%Decimal{} = value) do
Decimal.to_float(value)
end
def to_float(_), do: nil
@doc """
Converts various types to Decimal for database storage.
## Examples
iex> is_struct(Aprsme.EncodingUtils.to_decimal(1), Decimal)
true
iex> is_struct(Aprsme.EncodingUtils.to_decimal(1.5), Decimal)
true
iex> is_struct(Aprsme.EncodingUtils.to_decimal("2.3"), Decimal)
true
iex> Aprsme.EncodingUtils.to_decimal("bad")
nil
iex> Aprsme.EncodingUtils.to_decimal(nil)
nil
"""
@spec to_decimal(any()) :: Decimal.t() | nil
def to_decimal(%Decimal{} = d), do: d
def to_decimal(f) when is_float(f), do: Decimal.from_float(f)
def to_decimal(i) when is_integer(i), do: Decimal.new(i)
def to_decimal(s) when is_binary(s) do
case Decimal.parse(s) do
{d, _} ->
d
:error ->
case Float.parse(s) do
{f, _} -> Decimal.from_float(f)
:error -> nil
end
end
end
def to_decimal(_), do: nil
@doc """
Sanitizes all string fields in packet data before database storage.
## Examples
iex> Aprsme.EncodingUtils.sanitize_packet_strings(["abc", <<255>>])
["abc", "ÿ"]
iex> Aprsme.EncodingUtils.sanitize_packet_strings(%{"foo" => <<0, 65, 66, 67>>})
%{"foo" => "ABC"}
iex> Aprsme.EncodingUtils.sanitize_packet_strings(nil)
nil
"""
@spec sanitize_packet_strings(any()) :: any()
def sanitize_packet_strings(%DateTime{} = dt), do: dt
def sanitize_packet_strings(%NaiveDateTime{} = ndt), do: ndt
def sanitize_packet_strings(%_struct{} = struct), do: struct |> Map.from_struct() |> sanitize_packet_strings()
def sanitize_packet_strings(list) when is_list(list), do: Enum.map(list, &sanitize_packet_strings/1)
def sanitize_packet_strings(map) when is_map(map) do
Enum.reduce(map, %{}, fn {key, value}, acc ->
Map.put(acc, key, sanitize_packet_strings(value))
end)
end
def sanitize_packet_strings(binary) when is_binary(binary) do
s = sanitize_string(binary)
if is_binary(s), do: s, else: ""
end
def sanitize_packet_strings(other), do: other
@doc """
Normalizes data_type field to ensure it's always a string.
## Examples
iex> Aprsme.EncodingUtils.normalize_data_type(%{data_type: :weather})
%{data_type: "weather"}
iex> Aprsme.EncodingUtils.normalize_data_type(%{"data_type" => :foo})
%{"data_type" => "foo"}
iex> Aprsme.EncodingUtils.normalize_data_type(%{data_type: "bar"})
%{data_type: "bar"}
iex> Aprsme.EncodingUtils.normalize_data_type(%{"data_type" => "baz"})
%{"data_type" => "baz"}
iex> Aprsme.EncodingUtils.normalize_data_type(%{foo: 1})
%{foo: 1}
"""
@spec normalize_data_type(map()) :: map()
def normalize_data_type(%{data_type: data_type} = attrs) when is_atom(data_type) do
%{attrs | data_type: to_string(data_type)}
end
def normalize_data_type(%{"data_type" => data_type} = attrs) when is_atom(data_type) do
%{attrs | "data_type" => to_string(data_type)}
end
def normalize_data_type(attrs) when is_map(attrs) do
case {Map.has_key?(attrs, :data_type), Map.get(attrs, :data_type)} do
{true, data_type} when is_atom(data_type) ->
%{attrs | data_type: to_string(data_type)}
_ ->
attrs
end
end
def normalize_data_type(attrs), do: attrs
@doc """
List of weather-related fields used for packet classification.
## Examples
iex> Aprsme.EncodingUtils.weather_fields() |> Enum.member?(:temperature)
true
iex> :foo in Aprsme.EncodingUtils.weather_fields()
false
"""
@spec weather_fields() :: [
:temperature
| :humidity
| :wind_speed
| :wind_direction
| :wind_gust
| :pressure
| :rain_1h
| :rain_24h
| :rain_since_midnight
| :snow,
...
]
def weather_fields do
[
:temperature,
:humidity,
:wind_speed,
:wind_direction,
:wind_gust,
:pressure,
:rain_1h,
:rain_24h,
:rain_since_midnight,
:snow
]
end
@doc """
Sanitizes all string fields in an APRS packet to ensure safe JSON encoding.
## Examples
iex> result = Aprsme.EncodingUtils.sanitize_packet(%{"comment" => <<0, 65, 66, 67>>, "data_extended" => %{"comment" => <<0, 68, 69, 70>>}})
iex> result["comment"] == "ABC"
true
iex> result["data_extended"]["comment"] == "DEF"
true
"""
@spec sanitize_packet(struct() | map()) :: struct() | map()
def sanitize_packet(%Aprsme.Packet{} = packet) do
sanitized_data =
case packet.data do
nil -> nil
data when is_map(data) -> sanitize_packet_strings(data)
other -> other
end
%{
packet
| data: sanitized_data,
data_extended: sanitize_data_extended(packet.data_extended)
}
end
def sanitize_packet(packet) when is_map(packet) do
packet
|> sanitize_string_fields()
|> sanitize_nested_map(:data, &sanitize_packet_strings/1)
|> sanitize_nested_map(:data_extended, &sanitize_data_extended/1)
end
@string_fields [
:comment,
:path,
:raw_packet,
:destination,
:sender,
:base_callsign,
:ssid,
:manufacturer,
:equipment_type,
:message_text,
:addressee,
:symbol_code,
:symbol_table_id,
:dao,
:timestamp,
:device_identifier
]
defp sanitize_string_fields(packet) do
Enum.reduce(@string_fields, packet, fn field, acc ->
# sanitize_comment strips Mic-E telemetry; everything else uses the
# generic sanitizer.
sanitizer = field_sanitizer(field)
update_existing_key(acc, field, sanitizer)
end)
end
defp field_sanitizer(:comment), do: &sanitize_comment/1
defp field_sanitizer(_other), do: &sanitize_string/1
defp sanitize_nested_map(packet, key, sanitizer) do
update_existing_key(packet, key, nil_aware(sanitizer))
end
# Apply `sanitizer` to whichever key (atom or string) actually exists in the
# map. If neither is present, return the map unchanged.
defp update_existing_key(map, atom_key, sanitizer) do
string_key = to_string(atom_key)
case first_present_key(map, atom_key, string_key) do
nil -> map
key -> Map.update(map, key, nil, sanitizer)
end
end
defp first_present_key(map, atom_key, string_key) do
cond do
Map.has_key?(map, atom_key) -> atom_key
Map.has_key?(map, string_key) -> string_key
true -> nil
end
end
# Wraps a sanitizer so it passes `nil` values through unchanged.
defp nil_aware(sanitizer) do
fn
nil -> nil
value -> sanitizer.(value)
end
end
@doc """
Sanitizes string fields in the data_extended structure.
## Examples
iex> Aprsme.EncodingUtils.sanitize_data_extended(%{"comment" => <<0, 65, 66, 67>>})
%{"comment" => "ABC"}
iex> Aprsme.EncodingUtils.sanitize_data_extended(nil)
nil
"""
@spec sanitize_data_extended(nil | map() | MicE.t() | any()) :: nil | map() | MicE.t() | any()
def sanitize_data_extended(nil), do: nil
def sanitize_data_extended(%{comment: comment} = data_extended) when is_map(data_extended) do
%{data_extended | comment: sanitize_string(comment)}
end
def sanitize_data_extended(%MicE{message: message} = mic_e) do
%{mic_e | message: sanitize_string(message)}
end
def sanitize_data_extended(data_extended) when is_map(data_extended) and not is_struct(data_extended) do
# Handle generic maps by sanitizing all string values
Enum.reduce(data_extended, %{}, fn {key, value}, acc ->
sanitized_value = sanitize_map_value(value)
Map.put(acc, key, sanitized_value)
end)
end
def sanitize_data_extended(data_extended), do: data_extended
@spec sanitize_map_value(any()) :: any()
defp sanitize_map_value(val) when is_binary(val), do: sanitize_string(val)
defp sanitize_map_value(val), do: val
@doc """
Converts a binary to a hex string representation for debugging.
## Examples
iex> Aprsme.EncodingUtils.to_hex(<<72, 101, 108, 108, 111>>)
"48656C6C6F"
"""
@spec to_hex(binary()) :: String.t()
def to_hex(binary) when is_binary(binary) do
Encoding.to_hex(binary)
end
@doc """
Returns information about a binary's encoding validity.
## Examples
iex> Aprsme.EncodingUtils.encoding_info("Hello")
%{valid_utf8: true, byte_count: 5, char_count: 5, invalid_at: nil}
iex> Aprsme.EncodingUtils.encoding_info(<<72, 101, 211, 108, 111>>)
%{valid_utf8: false, byte_count: 5, char_count: nil, invalid_at: 2}
"""
@spec encoding_info(binary()) :: %{
valid_utf8: boolean(),
byte_count: non_neg_integer(),
char_count: non_neg_integer() | nil,
invalid_at: non_neg_integer() | nil
}
def encoding_info(binary) when is_binary(binary) do
Encoding.encoding_info(binary)
end
end