Skip to content

Commit

Permalink
feat: add OpenTripPlanner client based on dotcom code
Browse files Browse the repository at this point in the history
  • Loading branch information
boringcactus committed Jan 23, 2024
1 parent f19e785 commit f70d280
Show file tree
Hide file tree
Showing 30 changed files with 2,209 additions and 4 deletions.
2 changes: 2 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ config :logger, :console,
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason

config :mobile_app_backend, timezone: "America/New_York"

# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"
207 changes: 207 additions & 0 deletions lib/open_trip_planner_client.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
defmodule OpenTripPlannerClient do
@moduledoc """
Fetches data from the OpenTripPlanner API.
## Configuration
```elixir
config :mobile_app_backend,
otp_url: "http://localhost:8080",
timezone: "America/New_York"
```
"""

require Logger

alias OpenTripPlannerClient.{Itinerary, ItineraryTag, NamedPosition, ParamsBuilder, Parser}

@behaviour OpenTripPlannerClient.Behaviour

@type error :: OpenTripPlannerClient.Behaviour.error()
@type plan_opt :: OpenTripPlannerClient.Behaviour.plan_opt()

@impl true
@doc """
Generate a trip plan with the given endpoints and options.
"""
@spec plan(NamedPosition.t(), NamedPosition.t(), [plan_opt()]) ::
{:ok, Itinerary.t()} | {:error, error()}
def plan(from, to, opts) do
accessible? = Keyword.get(opts, :wheelchair_accessible?, false)

{postprocess_opts, opts} = Keyword.split(opts, [:tags])

with {:ok, params} <- ParamsBuilder.build_params(from, to, opts) do
param_string = Enum.map_join(params, "\n", fn {key, val} -> ~s{#{key}: #{val}} end)

graphql_query = """
{
plan(
#{param_string}
)
#{itinerary_shape()}
}
"""

root_url =
Keyword.get(opts, :root_url, Application.fetch_env!(:mobile_app_backend, :otp_url))

graphql_url = "#{root_url}/otp/routers/default/index/"

with {:ok, body} <- send_request(graphql_url, graphql_query),
{:ok, itineraries} <- Parser.parse_ql(body, accessible?) do
tags = Keyword.get(postprocess_opts, :tags, [])

result =
Enum.reduce(tags, itineraries, fn tag, itineraries ->
ItineraryTag.apply_tag(tag, itineraries)
end)

{:ok, result}
end
end
end

defp send_request(url, query) do
with {:ok, response} <- log_response(url, query),
%{status: 200, body: body} <- response do
{:ok, body}
else
%{status: _} = response ->
{:error, response}

error ->
error
end
end

defp log_response(url, query) do
graphql_req =
Req.new(base_url: url)
|> AbsintheClient.attach()

{duration, response} =
:timer.tc(
Req,
:post,
[graphql_req, [graphql: query]]
)

_ =
Logger.info(fn ->
"#{__MODULE__}.plan_response url=#{url} query=#{inspect(query)} #{status_text(response)} duration=#{duration / :timer.seconds(1)}"
end)

response
end

defp status_text({:ok, %{status: code}}) do
"status=#{code}"
end

defp status_text({:error, error}) do
"status=error error=#{inspect(error)}"
end

defp itinerary_shape do
"""
{
routingErrors {
code
description
}
itineraries {
accessibilityScore
startTime
endTime
duration
legs {
mode
startTime
endTime
distance
duration
intermediateStops {
id
gtfsId
name
desc
lat
lon
code
locationType
}
transitLeg
headsign
realTime
realtimeState
agency {
id
gtfsId
name
}
alerts {
id
alertHeaderText
alertDescriptionText
}
fareProducts {
id
product {
id
name
riderCategory {
id
name
}
}
}
from {
name
lat
lon
departureTime
arrivalTime
stop {
gtfsId
}
}
to {
name
lat
lon
departureTime
arrivalTime
stop {
gtfsId
}
}
route {
gtfsId
longName
shortName
desc
color
textColor
}
trip {
gtfsId
}
steps {
distance
streetName
lat
lon
relativeDirection
stayOn
}
legGeometry {
points
}
}
}
}
"""
end
end
28 changes: 28 additions & 0 deletions lib/open_trip_planner_client/behaviour.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
defmodule OpenTripPlannerClient.Behaviour do
@moduledoc """
A behaviour that specifies the API for the `OpenTripPlannerClient`.
May be useful for testing with libraries like [Mox](https://hex.pm/packages/mox).
"""

alias OpenTripPlannerClient.{Itinerary, ItineraryTag, NamedPosition}

@type plan_opt ::
{:arrive_by, DateTime.t()}
| {:depart_at, DateTime.t()}
| {:wheelchair_accessible?, boolean}
| {:optimize_for, :less_walking | :fewest_transfers}
| {:tags, [ItineraryTag.t()]}

@type error ::
:outside_bounds
| :timeout
| :no_transit_times
| :too_close
| :location_not_accessible
| :path_not_found
| :unknown

@callback plan(from :: NamedPosition.t(), to :: NamedPosition.t(), opts :: [plan_opt()]) ::
{:ok, Itinerary.t()} | {:error, error()}
end
75 changes: 75 additions & 0 deletions lib/open_trip_planner_client/itinerary.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
defmodule OpenTripPlannerClient.Itinerary do
@moduledoc """
A trip at a particular time.
An Itinerary is a single trip, with the legs being the different types of
travel. Itineraries are separate even if they use the same modes but happen
at different times of day.
"""

alias OpenTripPlannerClient.Leg

@enforce_keys [:start, :stop]
defstruct [
:start,
:stop,
legs: [],
accessible?: false,
tags: MapSet.new()
]

@type t :: %__MODULE__{
start: DateTime.t(),
stop: DateTime.t(),
legs: [Leg.t()],
accessible?: boolean,
tags: MapSet.t(atom())
}

@doc "Gets the time in seconds between the start and stop of the itinerary."
@spec duration(t()) :: integer()
def duration(%__MODULE__{start: start, stop: stop}) do
DateTime.diff(stop, start, :second)
end

@doc "Total walking distance over all legs, in meters"
@spec walking_distance(t) :: float
def walking_distance(itinerary) do
itinerary
|> Enum.map(&Leg.walking_distance/1)
|> Enum.sum()
end

@doc "Determines if two itineraries represent the same sequence of legs at the same time"
@spec same_itinerary?(t, t) :: boolean
def same_itinerary?(itinerary_1, itinerary_2) do
itinerary_1.start == itinerary_2.start && itinerary_1.stop == itinerary_2.stop &&
same_legs?(itinerary_2, itinerary_2)
end

@spec same_legs?(t, t) :: boolean
defp same_legs?(%__MODULE__{legs: legs_1}, %__MODULE__{legs: legs_2}) do
Enum.count(legs_1) == Enum.count(legs_2) &&
legs_1 |> Enum.zip(legs_2) |> Enum.all?(fn {l1, l2} -> Leg.same_leg?(l1, l2) end)
end

defimpl Enumerable do
alias OpenTripPlannerClient.Leg

def count(%@for{legs: legs}) do
Enumerable.count(legs)
end

def member?(%@for{legs: legs}, element) do
Enumerable.member?(legs, element)
end

def reduce(%@for{legs: legs}, acc, fun) do
Enumerable.reduce(legs, acc, fun)
end

def slice(%@for{legs: legs}) do
Enumerable.slice(legs)
end
end
end
39 changes: 39 additions & 0 deletions lib/open_trip_planner_client/itinerary_tag.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
defmodule OpenTripPlannerClient.ItineraryTag do
@moduledoc """
Logic for a tag which can be applied to itineraries which are the best by some criterion.
"""
alias OpenTripPlannerClient.Itinerary

@callback optimal :: :max | :min
@callback score(Itinerary.t()) :: number() | nil
@callback tag :: atom()

@type t :: module()

@doc """
Applies the tag defined by the given module to the itinerary with the optimal score.
If multiple itineraries are optimal, they will each get the tag.
If all itineraries have a score of nil, nothing gets the tag.
"""
@spec apply_tag(t(), [Itinerary.t()]) :: [Itinerary.t()]
def apply_tag(tag_module, itineraries) do
scores = itineraries |> Enum.map(&tag_module.score/1)
{min_score, max_score} = Enum.min_max(scores |> Enum.reject(&is_nil/1), fn -> {nil, nil} end)

best_score =
case tag_module.optimal() do
:max -> max_score
:min -> min_score
end

Enum.zip(itineraries, scores)
|> Enum.map(fn {itinerary, score} ->
if not is_nil(score) and score == best_score do
update_in(itinerary.tags, &MapSet.put(&1, tag_module.tag()))
else
itinerary
end
end)
end
end
17 changes: 17 additions & 0 deletions lib/open_trip_planner_client/itinerary_tag/earliest_arrival.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
defmodule OpenTripPlannerClient.ItineraryTag.EarliestArrival do
@moduledoc false
@behaviour OpenTripPlannerClient.ItineraryTag

alias OpenTripPlannerClient.Itinerary

@impl OpenTripPlannerClient.ItineraryTag
def optimal, do: :min

@impl OpenTripPlannerClient.ItineraryTag
def score(%Itinerary{} = itinerary) do
itinerary.stop |> DateTime.to_unix()
end

@impl OpenTripPlannerClient.ItineraryTag
def tag, do: :earliest_arrival
end
17 changes: 17 additions & 0 deletions lib/open_trip_planner_client/itinerary_tag/least_walking.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
defmodule OpenTripPlannerClient.ItineraryTag.LeastWalking do
@moduledoc false
@behaviour OpenTripPlannerClient.ItineraryTag

alias OpenTripPlannerClient.Itinerary

@impl OpenTripPlannerClient.ItineraryTag
def optimal, do: :min

@impl OpenTripPlannerClient.ItineraryTag
def score(%Itinerary{} = itinerary) do
Itinerary.walking_distance(itinerary)
end

@impl OpenTripPlannerClient.ItineraryTag
def tag, do: :least_walking
end
Loading

0 comments on commit f70d280

Please sign in to comment.