Skip to content

Commit 73aff82

Browse files
authored
feat: optionally use OpenTripPlanner for nearby transit (#41)
* feat: optionally use OpenTripPlanner for nearby transit * fix: don't use OTP's wacky route pattern data * fix Nearby.parse/1 typespec * refactor: don't parse source as a single-use atom * feat: ignore massport stops * test: add OTP parsing parent stations
1 parent fd5bedf commit 73aff82

File tree

17 files changed

+640
-38
lines changed

17 files changed

+640
-38
lines changed

config/runtime.exs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ if config_env() != :test do
1717
app_id: System.get_env("ALGOLIA_APP_ID"),
1818
search_key: System.get_env("ALGOLIA_SEARCH_KEY"),
1919
base_url: System.get_env("ALGOLIA_READ_URL")
20+
21+
# open_trip_planner_client configuration in disguise
22+
config :mobile_app_backend,
23+
otp_url: System.get_env("OTP_URL")
2024
end
2125

2226
# ## Using releases

config/test.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ config :logger, level: :warning
1414
config :phoenix, :plug_init_mode, :runtime
1515

1616
config :mobile_app_backend,
17-
base_url: "https://api-dev.mbtace.com/"
17+
base_url: "https://api-dev.mbtace.com/",
18+
otp_url: "http://otp.example.com/"
1819

1920
config :mobile_app_backend, MobileAppBackend.Search.Algolia,
2021
app_id: "fake_app",

lib/mobile_app_backend_web/controllers/nearby_controller.ex

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,28 @@ defmodule MobileAppBackendWeb.NearbyController do
22
use MobileAppBackendWeb, :controller
33

44
def show(conn, params) do
5+
params = Map.merge(%{"source" => "otp", "radius" => "0.5"}, params)
6+
latitude = String.to_float(Map.fetch!(params, "latitude"))
7+
longitude = String.to_float(Map.fetch!(params, "longitude"))
8+
radius = String.to_float(Map.fetch!(params, "radius"))
9+
510
{:ok, stops} =
6-
MBTAV3API.Stop.get_all(
7-
filter: [
8-
latitude: String.to_float(params["latitude"]),
9-
longitude: String.to_float(params["longitude"]),
10-
location_type: [0, 1],
11-
radius: miles_to_degrees(0.5)
12-
],
13-
include: :parent_station,
14-
sort: {:distance, :asc}
15-
)
11+
case Map.fetch!(params, "source") do
12+
"v3" ->
13+
MBTAV3API.Stop.get_all(
14+
filter: [
15+
latitude: latitude,
16+
longitude: longitude,
17+
location_type: [0, 1],
18+
radius: miles_to_degrees(radius)
19+
],
20+
include: :parent_station,
21+
sort: {:distance, :asc}
22+
)
23+
24+
"otp" ->
25+
OpenTripPlannerClient.nearby(latitude, longitude, miles_to_meters(radius))
26+
end
1627

1728
stop_ids = MapSet.new(stops, & &1.id)
1829

@@ -46,6 +57,8 @@ defmodule MobileAppBackendWeb.NearbyController do
4657
})
4758
end
4859

60+
defp miles_to_meters(miles), do: round(miles * 1_609.344)
61+
4962
# The V3 API does not actually calculate distance,
5063
# and it just pretends latitude degrees and longitude degrees are equally sized.
5164
# See https://github.com/mbta/api/blob/1671ba02d4669827fb2a58966d8c3ab39c939b0e/apps/api_web/lib/api_web/controllers/stop_controller.ex#L27-L31.

lib/open_trip_planner_client.ex

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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+
```
11+
"""
12+
13+
require Logger
14+
15+
alias OpenTripPlannerClient.Nearby
16+
17+
@doc """
18+
Fetches stops within the given number of meters of the given position.
19+
"""
20+
@spec nearby(float(), float(), integer(), Keyword.t()) ::
21+
{:ok, [MBTAV3API.Stop.t()]} | {:error, term()}
22+
def nearby(latitude, longitude, radius, opts \\ []) do
23+
root_url =
24+
Keyword.get(opts, :root_url, Application.fetch_env!(:mobile_app_backend, :otp_url))
25+
26+
request =
27+
Nearby.request(latitude, longitude, radius)
28+
|> Req.update(base_url: root_url, url: "/otp/routers/default/index/graphql")
29+
30+
case send_request(request) do
31+
{:ok, body} -> Nearby.parse(body)
32+
{:error, error} -> {:error, error}
33+
end
34+
end
35+
36+
@spec send_request(Req.Request.t()) :: {:ok, term()} | {:error, term()}
37+
defp send_request(request) do
38+
with {:ok, response} <- log_response(request),
39+
%{status: 200, body: body} <- response do
40+
{:ok, body}
41+
else
42+
%{status: _} = response ->
43+
{:error, response}
44+
45+
error ->
46+
error
47+
end
48+
end
49+
50+
@spec log_response(Req.Request.t()) :: {:ok, Req.Response.t()} | {:error, term()}
51+
defp log_response(request) do
52+
{duration, response} =
53+
:timer.tc(
54+
MobileAppBackend.HTTP,
55+
:request,
56+
[request]
57+
)
58+
59+
_ =
60+
Logger.info(fn ->
61+
"#{__MODULE__}.otp_response query=#{inspect(request.options[:graphql])} #{status_text(response)} duration=#{duration / :timer.seconds(1)}"
62+
end)
63+
64+
response
65+
end
66+
67+
defp status_text({:ok, %{status: code}}) do
68+
"status=#{code}"
69+
end
70+
71+
defp status_text({:error, error}) do
72+
"status=error error=#{inspect(error)}"
73+
end
74+
end
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
defmodule OpenTripPlannerClient.Nearby do
2+
@spec request(float(), float(), integer()) :: Req.Request.t()
3+
def request(latitude, longitude, radius) do
4+
Req.new(method: :post)
5+
|> AbsintheClient.attach(
6+
graphql: {graphql_query(), %{latitude: latitude, longitude: longitude, radius: radius}}
7+
)
8+
end
9+
10+
@spec parse(map()) :: {:ok, [MBTAV3API.Stop.t()]} | {:error, term()}
11+
def parse(data) do
12+
with {:ok, edges} <- get_edges(data),
13+
{:ok, stops} <- parse_edges(edges) do
14+
{:ok, stops}
15+
else
16+
{:error, error} -> {:error, error}
17+
end
18+
end
19+
20+
defp graphql_query do
21+
"""
22+
query NearbyQuery($latitude: Float!, $longitude: Float!, $radius: Int!) {
23+
nearest(lat: $latitude, lon: $longitude, maxDistance: $radius, filterByPlaceTypes: [STOP]) {
24+
edges {
25+
node {
26+
place {
27+
... on Stop {
28+
...stopDetails
29+
parentStation {
30+
...stopDetails
31+
}
32+
}
33+
}
34+
distance
35+
}
36+
}
37+
}
38+
}
39+
40+
fragment stopDetails on Stop {
41+
gtfsId
42+
lat
43+
lon
44+
name
45+
}
46+
"""
47+
end
48+
49+
@spec get_edges(map()) :: {:ok, list(map())} | {:error, term()}
50+
defp get_edges(data) do
51+
case data do
52+
%{"data" => %{"nearest" => %{"edges" => edges}}} -> {:ok, edges}
53+
_ -> {:error, :bad_format}
54+
end
55+
end
56+
57+
@spec parse_edges(list(map())) :: {:ok, list(MBTAV3API.Stop.t())} | {:error, term()}
58+
defp parse_edges(edges) do
59+
edges
60+
|> Enum.reduce({:ok, []}, fn
61+
edge, {:ok, reversed_stops} ->
62+
with {:ok, stop} <- get_stop(edge),
63+
{:ok, stop} <- parse_stop(stop) do
64+
{:ok, [stop | reversed_stops]}
65+
else
66+
:ignore -> {:ok, reversed_stops}
67+
{:error, error} -> {:error, error}
68+
end
69+
70+
_edge, {:error, error} ->
71+
{:error, error}
72+
end)
73+
|> case do
74+
{:ok, reversed_stops} -> {:ok, Enum.reverse(reversed_stops)}
75+
{:error, error} -> {:error, error}
76+
end
77+
end
78+
79+
@spec get_stop(map()) :: {:ok, map()} | {:error, term()}
80+
defp get_stop(edge) do
81+
case edge do
82+
%{"node" => %{"place" => stop}} -> {:ok, stop}
83+
_ -> {:error, :bad_format}
84+
end
85+
end
86+
87+
@spec parse_stop(map()) :: {:ok, MBTAV3API.Stop.t()} | :ignore | {:error, term()}
88+
defp parse_stop(place) do
89+
case place do
90+
%{"lat" => latitude, "lon" => longitude, "gtfsId" => "mbta-ma-us:" <> id, "name" => name} ->
91+
{:ok,
92+
%MBTAV3API.Stop{
93+
id: id,
94+
latitude: latitude,
95+
longitude: longitude,
96+
name: name,
97+
parent_station:
98+
with {:ok, parent_station} when not is_nil(parent_station) <-
99+
Map.fetch(place, "parentStation"),
100+
{:ok, parent_station} <- parse_stop(parent_station) do
101+
parent_station
102+
else
103+
_ -> nil
104+
end
105+
}}
106+
107+
%{"gtfsId" => "2272_2274:" <> _} ->
108+
:ignore
109+
end
110+
end
111+
end

mix.exs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,11 @@ defmodule MobileAppBackend.MixProject do
6868
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
6969
{:mox, "~> 1.0", only: :test},
7070
{:uniq, "~> 0.6", only: :test},
71-
{:req, "~> 0.4.8"},
71+
{:req, "~> 0.3"},
7272
{:sentry, "~> 10.0"},
7373
{:timex, "~> 3.7"},
74-
{:lcov_ex, "~> 0.3", only: [:test], runtime: false}
74+
{:lcov_ex, "~> 0.3", only: [:test], runtime: false},
75+
{:absinthe_client, "~> 0.1.0"}
7576
]
7677
end
7778

mix.lock

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
%{
2+
"absinthe_client": {:hex, :absinthe_client, "0.1.0", "a3bafc1dff141073a2a7fd926942fb10afb4d45295f0b6df46f6f1955ececaac", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:req, "~> 0.3.0", [hex: :req, repo: "hexpm", optional: false]}, {:slipstream, "~> 1.0", [hex: :slipstream, repo: "hexpm", optional: false]}], "hexpm", "a7ec3e13da9b463cb024dba4733c2fa31a0690a3bfa897b9df6bdd544a4d6f91"},
23
"bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"},
34
"castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"},
45
"certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"},
@@ -28,6 +29,7 @@
2829
"mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"},
2930
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
3031
"mint": {:hex, :mint, "1.5.2", "4805e059f96028948870d23d7783613b7e6b0e2fb4e98d720383852a760067fd", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "d77d9e9ce4eb35941907f1d3df38d8f750c357865353e21d335bdcdf6d892a02"},
32+
"mint_web_socket": {:hex, :mint_web_socket, "1.0.3", "aab42fff792a74649916236d0b01f560a0b3f03ca5dea693c230d1c44736b50e", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "ca3810ca44cc8532e3dce499cc17f958596695d226bb578b2fbb88c09b5954b0"},
3133
"mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"},
3234
"nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"},
3335
"nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"},
@@ -43,8 +45,9 @@
4345
"plug_cowboy": {:hex, :plug_cowboy, "2.6.2", "753611b23b29231fb916b0cdd96028084b12aff57bfd7b71781bd04b1dbeb5c9", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "951ed2433df22f4c97b85fdb145d4cee561f36b74854d64c06d896d7cd2921a7"},
4446
"plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"},
4547
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
46-
"req": {:hex, :req, "0.4.8", "2b754a3925ddbf4ad78c56f30208ced6aefe111a7ea07fb56c23dccc13eb87ae", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "7146e51d52593bb7f20d00b5308a5d7d17d663d6e85cd071452b613a8277100c"},
48+
"req": {:hex, :req, "0.3.12", "f84c2f9e7cc71c81d7cbeacf7c61e763e53ab5f3065703792a4ab264b4f22672", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "c91103d4d1c8edeba90c84e0ba223a59865b673eaab217bfd17da3aa54ab136c"},
4749
"sentry": {:hex, :sentry, "10.1.0", "5d73c23deb5d95f3027fbb09801bd8e787065be61f0065418aed3961becbbe9f", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.3", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "f4319e7491133046912b4cf7cbe6f5226b309275d1a6d05386cce2ac7f97b2d2"},
50+
"slipstream": {:hex, :slipstream, "1.1.0", "e3581e9bc73036e4283b33447475499d18c813c7662aa6b86e131633a7e912f3", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mint_web_socket, "~> 0.2 or ~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.1 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "66eb1ac7c43573511b5bad90c24c128bb4e69f588bff65d0c409adf4c7eb02e6"},
4851
"sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"},
4952
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
5053
"tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"},

priv/test_data/018d3879-36f2-709a-b94f-1c2cba8612fb.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"data":{"nearest":{"edges":[{"node":{"distance":354,"place":{"gtfsId":"mbta-ma-us:67120","lat":42.28101,"lon":-71.177035,"name":"Millennium Park","parentStation":null}}},{"node":{"distance":618,"place":{"gtfsId":"mbta-ma-us:129","lat":42.278959,"lon":-71.175667,"name":"Rivermoor St @ Charles Park Rd","parentStation":null}}},{"node":{"distance":627,"place":{"gtfsId":"mbta-ma-us:137","lat":42.278907,"lon":-71.175492,"name":"Charles Park Rd @ Rivermoor St","parentStation":null}}}]}}}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"data":{"nearest":{"edges":[{"node":{"distance":44,"place":{"gtfsId":"mbta-ma-us:7097","lat":42.374518,"lon":-71.029532,"name":"Airport","parentStation":{"gtfsId":"mbta-ma-us:place-aport","lat":42.374262,"lon":-71.030395,"name":"Airport"}}}},{"node":{"distance":44,"place":{"gtfsId":"mbta-ma-us:7096","lat":42.374693,"lon":-71.029333,"name":"Airport","parentStation":{"gtfsId":"mbta-ma-us:place-aport","lat":42.374262,"lon":-71.030395,"name":"Airport"}}}},{"node":{"distance":44,"place":{"gtfsId":"mbta-ma-us:70048","lat":42.374262,"lon":-71.030395,"name":"Airport","parentStation":{"gtfsId":"mbta-ma-us:place-aport","lat":42.374262,"lon":-71.030395,"name":"Airport"}}}},{"node":{"distance":44,"place":{"gtfsId":"mbta-ma-us:70047","lat":42.374262,"lon":-71.030395,"name":"Airport","parentStation":{"gtfsId":"mbta-ma-us:place-aport","lat":42.374262,"lon":-71.030395,"name":"Airport"}}}},{"node":{"distance":69,"place":{"gtfsId":"2272_2274:52","lat":42.373908,"lon":-71.030087,"name":"Blue Line - MBTA","parentStation":null}}},{"node":{"distance":80,"place":{"gtfsId":"2272_2274:41","lat":42.37374,"lon":-71.03005,"name":"Blue Line - MBTA","parentStation":null}}}]}}}

0 commit comments

Comments
 (0)