-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add OpenTripPlanner client based on dotcom code
- Loading branch information
1 parent
f19e785
commit f70d280
Showing
30 changed files
with
2,209 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
17
lib/open_trip_planner_client/itinerary_tag/earliest_arrival.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
17
lib/open_trip_planner_client/itinerary_tag/least_walking.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.