Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Detour serializer #2899

Merged
merged 15 commits into from
Nov 19, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lib/skate/detours/detours.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ defmodule Skate.Detours.Detours do
import Ecto.Query, warn: false
alias Skate.Repo
alias Skate.Detours.Db.Detour
alias Skate.Detours.SnapshotSerde
alias Skate.Detours.Detour.Detailed, as: DetailedDetour
alias Skate.Detours.Detour.WithState, as: DetourWithState
alias Skate.Settings.User
Expand Down Expand Up @@ -155,7 +156,7 @@ defmodule Skate.Detours.Detours do
|> Repo.one!()

%DetourWithState{
state: detour.state,
state: SnapshotSerde.serialize(detour),
updated_at: timestamp_to_unix(detour.updated_at),
author: detour.author.email
}
Expand Down
265 changes: 265 additions & 0 deletions lib/skate/detours/snapshot_serde.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ defmodule Skate.Detours.SnapshotSerde do
Serializes Ecto Database Structs into XState JSON Snapshots
"""

require Logger

alias Skate.Detours.Db.Detour

@doc """
Converts a XState JSON Snapshot to Detours Database Changeset
"""
Expand All @@ -27,4 +31,265 @@ defmodule Skate.Detours.SnapshotSerde do
"""
def id_from_snapshot(%{"context" => %{"uuid" => id}}), do: id
def id_from_snapshot(%{"context" => %{}}), do: nil

@doc """
Builds XState Snapshot from Detours Database object
"""
def serialize(%Detour{} = detour) do
validate_serialized_snapshot(
%{
"value" => state_from_detour(detour),
"status" => "active",
"context" => context_from_detour(detour),
"children" => snapshot_children_from_detour(detour),
"historyValue" => %{}
},
detour
)
end

defp validate_serialized_snapshot(
serialized_snapshot,
%Detour{id: id, state: state}
) do
if serialized_snapshot === state do
serialized_snapshot
else
Logger.error(
"Serialized detour doesn't match saved snapshot. Falling back to snapshot for detour_id=#{id} diff=#{inspect(MapDiff.diff(state, serialized_snapshot))}"
)

state
end
end

# For each of these retrieve functions, the first function is the one
# that will look for the correct field on the db object, once the detour state
# is properly distributed across db tables / columns.
#
# The second function is the fallback that uses the existing state snapshot.
#
# Note that the first function is only stubbed out and will be significantly updated
# when the new fields are added to the db. For example, `get_route_from_db_detour`
# suggests that the field `route` will be surfaced at the top level of the detour
# but actually, it may be nested under `route_pattern` or however else we organize
# it in the db.

defp context_from_detour(%Detour{} = detour) do
:maps.filter(fn _, v -> v != nil end, %{
"uuid" => uuid_from_detour(detour),
"route" => route_from_detour(detour),
"routePattern" => routepattern_from_detour(detour),
"routePatterns" => routepatterns_from_detour(detour),
"startPoint" => startpoint_from_detour(detour),
"endPoint" => endpoint_from_detour(detour),
"waypoints" => waypoints_from_detour(detour),
"nearestIntersection" => nearestintersection_from_detour(detour),
"detourShape" => detourshape_from_detour(detour),
"finishedDetour" => finisheddetour_from_detour(detour),
"editedDirections" => editeddirections_from_detour(detour),
"selectedDuration" => selectedduration_from_detour(detour),
"selectedReason" => selectedreason_from_detour(detour)
})
end

defmacrop log_fallback(field) do
quote do
Logger.warning("Unexpected detour structure. Using snapshot for field: #{unquote(field)}")
end
end

# defp state_from_detour(%Detour{detour_state: state}), do: state
defp state_from_detour(%Detour{
state: %{
"value" => state
}
}) do
log_fallback("state")
state
end

defp state_from_detour(_), do: nil

defp uuid_from_detour(%Detour{id: id}), do: id

# defp route_from_detour(%Detour{route: route}), do: route
defp route_from_detour(%Detour{
state: %{
"context" => %{
"route" => route
}
}
}) do
log_fallback("route")
route
end

defp route_from_detour(_), do: nil

# defp routepattern_from_detour(%Detour{route_pattern: route_pattern}), do: route_pattern
defp routepattern_from_detour(%Detour{
state: %{
"context" => %{
"routePattern" => route_pattern
}
}
}) do
log_fallback("routePattern")
route_pattern
end

defp routepattern_from_detour(_), do: nil

# defp routepatterns_from_detour(%Detour{route_patterns: route_patterns}), do: route_patterns
defp routepatterns_from_detour(%Detour{
state: %{
"context" => %{
"routePatterns" => route_patterns
}
}
}) do
log_fallback("route_patterns")
route_patterns
end

defp routepatterns_from_detour(_), do: nil

# defp startpoint_from_detour(%Detour{start_point: start_point}), do: start_point
defp startpoint_from_detour(%Detour{
state: %{
"context" => %{
"startPoint" => start_point
}
}
}) do
log_fallback("startPoint")
start_point
end

defp startpoint_from_detour(_), do: nil

# defp endpoint_from_detour(%Detour{end_point: end_point}), do: end_point
defp endpoint_from_detour(%Detour{
state: %{
"context" => %{
"endPoint" => end_point
}
}
}) do
log_fallback("endPoint")
end_point
end

defp endpoint_from_detour(_), do: nil

# defp waypoints_from_detour(%Detour{waypoints: waypoints}), do: waypoints
defp waypoints_from_detour(%Detour{
state: %{
"context" => %{
"waypoints" => waypoints
}
}
}) do
log_fallback("waypoints")
waypoints
end

defp waypoints_from_detour(_), do: nil

# defp nearestintersection_from_detour(%Detour{nearest_intersection: nearest_intersection}), do: nearest_intersection
defp nearestintersection_from_detour(%Detour{
state: %{
"context" => %{
"nearestIntersection" => nearest_intersection
}
}
}) do
log_fallback("nearestIntersection")
nearest_intersection
end

defp nearestintersection_from_detour(_), do: nil

# defp detourshape_from_detour(%Detour{detour_shape: detour_shape}), do: detour_shape
defp detourshape_from_detour(%Detour{
state: %{
"context" => %{
"detourShape" => detour_shape
}
}
}) do
log_fallback("detourShape")
detour_shape
end

defp detourshape_from_detour(_), do: nil

# defp finisheddetour_from_detour(%Detour{finished_detour: finished_detour}), do: finished_detour
defp finisheddetour_from_detour(%Detour{
state: %{
"context" => %{
"finishedDetour" => finished_detour
}
}
}) do
log_fallback("finishedDetour")
finished_detour
end

defp finisheddetour_from_detour(_), do: nil

# defp editeddirections_from_detour(%Detour{finished_detour: finished_detour}), do: finished_detour
defp editeddirections_from_detour(%Detour{
state: %{
"context" => %{
"editedDirections" => edited_directions
}
}
}) do
log_fallback("editedDirections")
edited_directions
end

defp editeddirections_from_detour(_), do: nil

# defp selectedduration_from_detour(%Detour{snapshot_children: snapshot_children}), do: snapshot_children
defp selectedduration_from_detour(%Detour{
state: %{
"context" => %{
"selectedDuration" => selected_duration
}
}
}) do
log_fallback("selectedDuration")
selected_duration
end

defp selectedduration_from_detour(_), do: nil

# defp selectedreason_from_detour(%Detour{snapshot_children: snapshot_children}), do: snapshot_children
defp selectedreason_from_detour(%Detour{
state: %{
"context" => %{
"selectedReason" => selected_reason
}
}
}) do
log_fallback("selectedReason")
selected_reason
end

defp selectedreason_from_detour(_), do: nil

# defp snapshot_children_from_detour(%Detour{snapshot_children: snapshot_children}), do: snapshot_children
defp snapshot_children_from_detour(%Detour{
state: %{
"children" => snapshot_children
}
}) do
log_fallback("children")
snapshot_children
end

defp snapshot_children_from_detour(_), do: nil
end
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ defmodule Skate.MixProject do
{:jason, "~> 1.0"},
{:lcov_ex, "~> 0.2", only: [:dev, :test], runtime: false},
{:logster, "~> 1.0"},
{:map_diff, "~> 1.3.4"},
{:mox, "~> 1.1.0", only: :test},
{:oban, "~> 2.15"},
{:phoenix, "~> 1.7.0"},
Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"},
"makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"},
"makeup_erlang": {:hex, :makeup_erlang, "0.1.4", "29563475afa9b8a2add1b7a9c8fb68d06ca7737648f28398e04461f008b69521", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f4ed47ecda66de70dd817698a703f8816daa91272e7e45812469498614ae8b29"},
"map_diff": {:hex, :map_diff, "1.3.4", "4fa013ad4fff7b21694f3aa5890dad5a0679f11812fdd89a0ad06276a431faf8", [:mix], [], "hexpm", "32fc0b8fc158683a00a58298440b8cb884e7e779f9459e598df61d022b5412e9"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"},
"mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"},
Expand Down
66 changes: 64 additions & 2 deletions test/skate_web/controllers/detours_controller_test.exs
firestack marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ defmodule SkateWeb.DetoursControllerTest do
import Mox
import Skate.Factory

alias ExUnit.CaptureLog

alias Skate.Detours.Detours
alias Skate.Detours.MissedStops

Expand Down Expand Up @@ -205,8 +207,6 @@ defmodule SkateWeb.DetoursControllerTest do

conn = get(conn, "/api/detours/1")

json_response(conn, 200)

assert %{
"data" => %{
"author" => ^email,
Expand All @@ -226,6 +226,68 @@ defmodule SkateWeb.DetoursControllerTest do
}
} = json_response(conn, 200)
end

@tag :authenticated
test "input snapshot matches the retrieved serialized detour", %{conn: conn} do
detour_id = 4

detour_snapshot =
:detour_snapshot
|> build()
|> with_id(detour_id)

put(conn, "/api/detours/update_snapshot", %{"snapshot" => detour_snapshot})

conn = get(conn, "/api/detours/#{detour_id}")

assert detour_snapshot == json_response(conn, 200)["data"]["state"]
end

@tag :authenticated
test "log an error if the serialized detour does not match db state", %{conn: conn} do
detour_id = 4

detour_snapshot =
:detour_snapshot
|> build()
|> with_id(detour_id)

put(conn, "/api/detours/update_snapshot", %{"snapshot" => detour_snapshot})

retrieved_detour = Detours.get_detour!(detour_id)
# Changing the status is a sure way to force a fallback, as it should always be "active"
edited_snapshot = put_in(detour_snapshot["status"], nil)
edited_detour = %{retrieved_detour | state: edited_snapshot}

log =
CaptureLog.capture_log(fn ->
Skate.Detours.SnapshotSerde.serialize(edited_detour)
end)

assert log =~
"Serialized detour doesn't match saved snapshot. Falling back to snapshot for detour_id=#{detour_id}"
end

@tag :authenticated
test "fallback to snapshot if the serialized detour does not match db state", %{conn: conn} do
detour_id = 5

detour_snapshot =
:detour_snapshot
|> build()
|> with_id(detour_id)

put(conn, "/api/detours/update_snapshot", %{"snapshot" => detour_snapshot})

retrieved_detour = Detours.get_detour!(detour_id)
# Changing the status is a sure way to force a fallback, as it should always be "active"
edited_snapshot = put_in(detour_snapshot["status"], nil)
edited_detour = %{retrieved_detour | state: edited_snapshot}

# Serializer returns the fallback original, instead of `"status" => "active"`,
# which it sets for all successful serializations
assert edited_snapshot == Skate.Detours.SnapshotSerde.serialize(edited_detour)
end
end

describe "detours/2" do
Expand Down
Loading
Loading