Skip to content

Commit f70d280

Browse files
committed
feat: add OpenTripPlanner client based on dotcom code
1 parent f19e785 commit f70d280

30 files changed

+2209
-4
lines changed

config/config.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ config :logger, :console,
5151
# Use Jason for JSON parsing in Phoenix
5252
config :phoenix, :json_library, Jason
5353

54+
config :mobile_app_backend, timezone: "America/New_York"
55+
5456
# Import environment specific config. This must remain at the bottom
5557
# of this file so it overrides the configuration defined above.
5658
import_config "#{config_env()}.exs"

lib/open_trip_planner_client.ex

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
defmodule OpenTripPlannerClient do
2+
@moduledoc """
3+
Fetches data from the OpenTripPlanner API.
4+
5+
## Configuration
6+
7+
```elixir
8+
config :mobile_app_backend,
9+
otp_url: "http://localhost:8080",
10+
timezone: "America/New_York"
11+
```
12+
"""
13+
14+
require Logger
15+
16+
alias OpenTripPlannerClient.{Itinerary, ItineraryTag, NamedPosition, ParamsBuilder, Parser}
17+
18+
@behaviour OpenTripPlannerClient.Behaviour
19+
20+
@type error :: OpenTripPlannerClient.Behaviour.error()
21+
@type plan_opt :: OpenTripPlannerClient.Behaviour.plan_opt()
22+
23+
@impl true
24+
@doc """
25+
Generate a trip plan with the given endpoints and options.
26+
"""
27+
@spec plan(NamedPosition.t(), NamedPosition.t(), [plan_opt()]) ::
28+
{:ok, Itinerary.t()} | {:error, error()}
29+
def plan(from, to, opts) do
30+
accessible? = Keyword.get(opts, :wheelchair_accessible?, false)
31+
32+
{postprocess_opts, opts} = Keyword.split(opts, [:tags])
33+
34+
with {:ok, params} <- ParamsBuilder.build_params(from, to, opts) do
35+
param_string = Enum.map_join(params, "\n", fn {key, val} -> ~s{#{key}: #{val}} end)
36+
37+
graphql_query = """
38+
{
39+
plan(
40+
#{param_string}
41+
)
42+
#{itinerary_shape()}
43+
}
44+
"""
45+
46+
root_url =
47+
Keyword.get(opts, :root_url, Application.fetch_env!(:mobile_app_backend, :otp_url))
48+
49+
graphql_url = "#{root_url}/otp/routers/default/index/"
50+
51+
with {:ok, body} <- send_request(graphql_url, graphql_query),
52+
{:ok, itineraries} <- Parser.parse_ql(body, accessible?) do
53+
tags = Keyword.get(postprocess_opts, :tags, [])
54+
55+
result =
56+
Enum.reduce(tags, itineraries, fn tag, itineraries ->
57+
ItineraryTag.apply_tag(tag, itineraries)
58+
end)
59+
60+
{:ok, result}
61+
end
62+
end
63+
end
64+
65+
defp send_request(url, query) do
66+
with {:ok, response} <- log_response(url, query),
67+
%{status: 200, body: body} <- response do
68+
{:ok, body}
69+
else
70+
%{status: _} = response ->
71+
{:error, response}
72+
73+
error ->
74+
error
75+
end
76+
end
77+
78+
defp log_response(url, query) do
79+
graphql_req =
80+
Req.new(base_url: url)
81+
|> AbsintheClient.attach()
82+
83+
{duration, response} =
84+
:timer.tc(
85+
Req,
86+
:post,
87+
[graphql_req, [graphql: query]]
88+
)
89+
90+
_ =
91+
Logger.info(fn ->
92+
"#{__MODULE__}.plan_response url=#{url} query=#{inspect(query)} #{status_text(response)} duration=#{duration / :timer.seconds(1)}"
93+
end)
94+
95+
response
96+
end
97+
98+
defp status_text({:ok, %{status: code}}) do
99+
"status=#{code}"
100+
end
101+
102+
defp status_text({:error, error}) do
103+
"status=error error=#{inspect(error)}"
104+
end
105+
106+
defp itinerary_shape do
107+
"""
108+
{
109+
routingErrors {
110+
code
111+
description
112+
}
113+
itineraries {
114+
accessibilityScore
115+
startTime
116+
endTime
117+
duration
118+
legs {
119+
mode
120+
startTime
121+
endTime
122+
distance
123+
duration
124+
intermediateStops {
125+
id
126+
gtfsId
127+
name
128+
desc
129+
lat
130+
lon
131+
code
132+
locationType
133+
}
134+
transitLeg
135+
headsign
136+
realTime
137+
realtimeState
138+
agency {
139+
id
140+
gtfsId
141+
name
142+
}
143+
alerts {
144+
id
145+
alertHeaderText
146+
alertDescriptionText
147+
}
148+
fareProducts {
149+
id
150+
product {
151+
id
152+
name
153+
riderCategory {
154+
id
155+
name
156+
157+
}
158+
}
159+
}
160+
from {
161+
name
162+
lat
163+
lon
164+
departureTime
165+
arrivalTime
166+
stop {
167+
gtfsId
168+
}
169+
}
170+
to {
171+
name
172+
lat
173+
lon
174+
departureTime
175+
arrivalTime
176+
stop {
177+
gtfsId
178+
}
179+
}
180+
route {
181+
gtfsId
182+
longName
183+
shortName
184+
desc
185+
color
186+
textColor
187+
}
188+
trip {
189+
gtfsId
190+
}
191+
steps {
192+
distance
193+
streetName
194+
lat
195+
lon
196+
relativeDirection
197+
stayOn
198+
}
199+
legGeometry {
200+
points
201+
}
202+
}
203+
}
204+
}
205+
"""
206+
end
207+
end
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
defmodule OpenTripPlannerClient.Behaviour do
2+
@moduledoc """
3+
A behaviour that specifies the API for the `OpenTripPlannerClient`.
4+
5+
May be useful for testing with libraries like [Mox](https://hex.pm/packages/mox).
6+
"""
7+
8+
alias OpenTripPlannerClient.{Itinerary, ItineraryTag, NamedPosition}
9+
10+
@type plan_opt ::
11+
{:arrive_by, DateTime.t()}
12+
| {:depart_at, DateTime.t()}
13+
| {:wheelchair_accessible?, boolean}
14+
| {:optimize_for, :less_walking | :fewest_transfers}
15+
| {:tags, [ItineraryTag.t()]}
16+
17+
@type error ::
18+
:outside_bounds
19+
| :timeout
20+
| :no_transit_times
21+
| :too_close
22+
| :location_not_accessible
23+
| :path_not_found
24+
| :unknown
25+
26+
@callback plan(from :: NamedPosition.t(), to :: NamedPosition.t(), opts :: [plan_opt()]) ::
27+
{:ok, Itinerary.t()} | {:error, error()}
28+
end
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
defmodule OpenTripPlannerClient.Itinerary do
2+
@moduledoc """
3+
A trip at a particular time.
4+
5+
An Itinerary is a single trip, with the legs being the different types of
6+
travel. Itineraries are separate even if they use the same modes but happen
7+
at different times of day.
8+
"""
9+
10+
alias OpenTripPlannerClient.Leg
11+
12+
@enforce_keys [:start, :stop]
13+
defstruct [
14+
:start,
15+
:stop,
16+
legs: [],
17+
accessible?: false,
18+
tags: MapSet.new()
19+
]
20+
21+
@type t :: %__MODULE__{
22+
start: DateTime.t(),
23+
stop: DateTime.t(),
24+
legs: [Leg.t()],
25+
accessible?: boolean,
26+
tags: MapSet.t(atom())
27+
}
28+
29+
@doc "Gets the time in seconds between the start and stop of the itinerary."
30+
@spec duration(t()) :: integer()
31+
def duration(%__MODULE__{start: start, stop: stop}) do
32+
DateTime.diff(stop, start, :second)
33+
end
34+
35+
@doc "Total walking distance over all legs, in meters"
36+
@spec walking_distance(t) :: float
37+
def walking_distance(itinerary) do
38+
itinerary
39+
|> Enum.map(&Leg.walking_distance/1)
40+
|> Enum.sum()
41+
end
42+
43+
@doc "Determines if two itineraries represent the same sequence of legs at the same time"
44+
@spec same_itinerary?(t, t) :: boolean
45+
def same_itinerary?(itinerary_1, itinerary_2) do
46+
itinerary_1.start == itinerary_2.start && itinerary_1.stop == itinerary_2.stop &&
47+
same_legs?(itinerary_2, itinerary_2)
48+
end
49+
50+
@spec same_legs?(t, t) :: boolean
51+
defp same_legs?(%__MODULE__{legs: legs_1}, %__MODULE__{legs: legs_2}) do
52+
Enum.count(legs_1) == Enum.count(legs_2) &&
53+
legs_1 |> Enum.zip(legs_2) |> Enum.all?(fn {l1, l2} -> Leg.same_leg?(l1, l2) end)
54+
end
55+
56+
defimpl Enumerable do
57+
alias OpenTripPlannerClient.Leg
58+
59+
def count(%@for{legs: legs}) do
60+
Enumerable.count(legs)
61+
end
62+
63+
def member?(%@for{legs: legs}, element) do
64+
Enumerable.member?(legs, element)
65+
end
66+
67+
def reduce(%@for{legs: legs}, acc, fun) do
68+
Enumerable.reduce(legs, acc, fun)
69+
end
70+
71+
def slice(%@for{legs: legs}) do
72+
Enumerable.slice(legs)
73+
end
74+
end
75+
end
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
defmodule OpenTripPlannerClient.ItineraryTag do
2+
@moduledoc """
3+
Logic for a tag which can be applied to itineraries which are the best by some criterion.
4+
"""
5+
alias OpenTripPlannerClient.Itinerary
6+
7+
@callback optimal :: :max | :min
8+
@callback score(Itinerary.t()) :: number() | nil
9+
@callback tag :: atom()
10+
11+
@type t :: module()
12+
13+
@doc """
14+
Applies the tag defined by the given module to the itinerary with the optimal score.
15+
16+
If multiple itineraries are optimal, they will each get the tag.
17+
If all itineraries have a score of nil, nothing gets the tag.
18+
"""
19+
@spec apply_tag(t(), [Itinerary.t()]) :: [Itinerary.t()]
20+
def apply_tag(tag_module, itineraries) do
21+
scores = itineraries |> Enum.map(&tag_module.score/1)
22+
{min_score, max_score} = Enum.min_max(scores |> Enum.reject(&is_nil/1), fn -> {nil, nil} end)
23+
24+
best_score =
25+
case tag_module.optimal() do
26+
:max -> max_score
27+
:min -> min_score
28+
end
29+
30+
Enum.zip(itineraries, scores)
31+
|> Enum.map(fn {itinerary, score} ->
32+
if not is_nil(score) and score == best_score do
33+
update_in(itinerary.tags, &MapSet.put(&1, tag_module.tag()))
34+
else
35+
itinerary
36+
end
37+
end)
38+
end
39+
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
defmodule OpenTripPlannerClient.ItineraryTag.EarliestArrival do
2+
@moduledoc false
3+
@behaviour OpenTripPlannerClient.ItineraryTag
4+
5+
alias OpenTripPlannerClient.Itinerary
6+
7+
@impl OpenTripPlannerClient.ItineraryTag
8+
def optimal, do: :min
9+
10+
@impl OpenTripPlannerClient.ItineraryTag
11+
def score(%Itinerary{} = itinerary) do
12+
itinerary.stop |> DateTime.to_unix()
13+
end
14+
15+
@impl OpenTripPlannerClient.ItineraryTag
16+
def tag, do: :earliest_arrival
17+
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
defmodule OpenTripPlannerClient.ItineraryTag.LeastWalking do
2+
@moduledoc false
3+
@behaviour OpenTripPlannerClient.ItineraryTag
4+
5+
alias OpenTripPlannerClient.Itinerary
6+
7+
@impl OpenTripPlannerClient.ItineraryTag
8+
def optimal, do: :min
9+
10+
@impl OpenTripPlannerClient.ItineraryTag
11+
def score(%Itinerary{} = itinerary) do
12+
Itinerary.walking_distance(itinerary)
13+
end
14+
15+
@impl OpenTripPlannerClient.ItineraryTag
16+
def tag, do: :least_walking
17+
end

0 commit comments

Comments
 (0)