diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fcbdd47..24c563d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,7 +88,7 @@ jobs: - name: Check formatting run: mix format --check-formatted - name: Run tests - run: source envrc.example && mix test --cover + run: mix test --cover - name: Report test coverage uses: mbta/github-actions-report-lcov@v1 with: diff --git a/.gitignore b/.gitignore index 3c05fbc7..92da6759 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ mobile_app_backend-*.tar npm-debug.log /assets/node_modules/ +# Local environment configuration +.envrc + diff --git a/config/config.exs b/config/config.exs index fc78e810..19b9cb48 100644 --- a/config/config.exs +++ b/config/config.exs @@ -21,6 +21,10 @@ config :mobile_app_backend, MobileAppBackendWeb.Endpoint, pubsub_server: MobileAppBackend.PubSub, live_view: [signing_salt: "EPNVgKKu"] +config :mobile_app_backend, MobileAppBackend.Search.Algolia, + route_index: "routes_test", + stop_index: "stops_test" + # Configure esbuild (the version is required) config :esbuild, version: "0.17.11", diff --git a/config/runtime.exs b/config/runtime.exs index db87e157..a824ca71 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -7,10 +7,17 @@ import Config # any compile-time configuration in here, as it won't be applied. # The block below contains prod specific runtime configuration. -# mbta_v3_api configuration in disguise -config :mobile_app_backend, - base_url: System.get_env("API_URL"), - api_key: System.get_env("API_KEY") +if config_env() != :test do + # mbta_v3_api configuration in disguise + config :mobile_app_backend, + base_url: System.get_env("API_URL"), + api_key: System.get_env("API_KEY") + + config :mobile_app_backend, MobileAppBackend.Search.Algolia, + app_id: System.get_env("ALGOLIA_APP_ID"), + search_key: System.get_env("ALGOLIA_SEARCH_KEY"), + base_url: System.get_env("ALGOLIA_READ_URL") +end # ## Using releases # diff --git a/config/test.exs b/config/test.exs index 72d96c8a..87381237 100644 --- a/config/test.exs +++ b/config/test.exs @@ -12,3 +12,11 @@ config :logger, level: :warning # Initialize plugs at runtime for faster test compilation config :phoenix, :plug_init_mode, :runtime + +config :mobile_app_backend, + base_url: "https://api-dev.mbtace.com/" + +config :mobile_app_backend, MobileAppBackend.Search.Algolia, + app_id: "fake_app", + search_key: "fake_key", + base_url: "fake_url" diff --git a/envrc.example b/envrc.example deleted file mode 100644 index 58b05124..00000000 --- a/envrc.example +++ /dev/null @@ -1,2 +0,0 @@ -#export API_KEY= -export API_URL=https://api-dev.mbtace.com/ diff --git a/envrc.template b/envrc.template new file mode 100644 index 00000000..c76f781c --- /dev/null +++ b/envrc.template @@ -0,0 +1,12 @@ +# Configuration for the [MBTA v3 API](https://github.com/mbta/api) +# Used for reading static and realtime GTFS data +export API_URL=https://api-dev.mbtace.com/ +export API_KEY= + + +# Configuration for [Alglolia](https://www.algolia.com/doc/rest-api/search/) +# Used to power search bar +export ALGOLIA_APP_ID= +export ALGOLIA_SEARCH_KEY= +export ALGOLIA_READ_URL= + diff --git a/lib/mobile_app_backend/search/algolia/api.ex b/lib/mobile_app_backend/search/algolia/api.ex new file mode 100644 index 00000000..09a77e49 --- /dev/null +++ b/lib/mobile_app_backend/search/algolia/api.ex @@ -0,0 +1,101 @@ +defmodule MobileAppBackend.Search.Algolia.Api do + @moduledoc """ + Client for the Algolia API. + See https://www.algolia.com/doc/rest-api/search/ + Relies on configuration from :mobile_app_backend, MobileAppBackend.Search.Algolia + """ + @algolia_api_version 1 + alias MobileAppBackend.Search.Algolia + require Logger + + @spec multi_index_search([Algolia.QueryPayload.t()]) :: + {:ok, [any()]} | {:error, any} + @doc """ + Perform the given index queries and return a flattened list of parsed results + """ + def multi_index_search(queries) do + perform_request_fn = + Application.get_env( + :mobile_app_backend, + :algolia_perform_request_fn, + &make_request/3 + ) + + body = multi_query_encoded_payload(queries) + + with {:ok, url} <- multi_index_url(), + {:ok, headers} <- headers(), + {:ok, response} <- perform_request_fn.(url, body, headers) do + parse_results(response.body) + else + error -> + Logger.error("#{__MODULE__} search_failed. reason=#{inspect(error)}") + {:error, :search_failed} + end + end + + defp make_request(url, body, headers) do + Req.post(url, body: body, headers: headers) + end + + defp parse_results(%{"results" => results_per_index}) do + {:ok, + Enum.flat_map(results_per_index, fn index_results -> + hits = index_results["hits"] + + cond do + index_results["index"] === Algolia.Index.index_name(:route) -> + Enum.map(hits, &Algolia.RouteResult.parse(&1)) + + index_results["index"] === Algolia.Index.index_name(:stop) -> + Enum.map(hits, &Algolia.StopResult.parse(&1)) + + true -> + [] + end + end)} + end + + defp parse_results(_bad_format) do + {:error, :malformed_results} + end + + defp multi_query_encoded_payload(queries) do + encoded_query_payloads = Enum.map(queries, &encode_query_payload/1) + + Jason.encode!(%{ + "requests" => encoded_query_payloads + }) + end + + defp encode_query_payload(query) do + %{ + "indexName" => query.index_name, + "params" => URI.encode_query(query.params) + } + end + + defp multi_index_url do + case Application.get_env(:mobile_app_backend, MobileAppBackend.Search.Algolia)[:base_url] do + nil -> {:error, :base_url_not_configured} + base_url -> {:ok, Path.join(base_url, "#{@algolia_api_version}/indexes/*/queries")} + end + end + + @spec headers :: {:ok, [{String.t(), String.t()}]} | {:error, any} + defp headers do + config = Application.get_env(:mobile_app_backend, MobileAppBackend.Search.Algolia) + app_id = config[:app_id] + search_key = config[:search_key] + + if is_nil(app_id) || is_nil(search_key) do + {:error, :missing_algolia_config} + else + {:ok, + [ + {"X-Algolia-API-Key", search_key}, + {"X-Algolia-Application-Id", app_id} + ]} + end + end +end diff --git a/lib/mobile_app_backend/search/algolia/index.ex b/lib/mobile_app_backend/search/algolia/index.ex new file mode 100644 index 00000000..6dd73cb9 --- /dev/null +++ b/lib/mobile_app_backend/search/algolia/index.ex @@ -0,0 +1,13 @@ +defmodule MobileAppBackend.Search.Algolia.Index do + @spec index_name(:stop | :route) :: String.t() | nil + @doc """ + Get the name of the Algolia index to query for the given data type + """ + def index_name(:stop) do + Application.get_env(:mobile_app_backend, MobileAppBackend.Search.Algolia)[:stop_index] + end + + def index_name(:route) do + Application.get_env(:mobile_app_backend, MobileAppBackend.Search.Algolia)[:route_index] + end +end diff --git a/lib/mobile_app_backend/search/algolia/query_payload.ex b/lib/mobile_app_backend/search/algolia/query_payload.ex new file mode 100644 index 00000000..23b2a248 --- /dev/null +++ b/lib/mobile_app_backend/search/algolia/query_payload.ex @@ -0,0 +1,51 @@ +defmodule MobileAppBackend.Search.Algolia.QueryPayload do + @moduledoc """ + The data expected by Algolia for search queries. + """ + alias MobileAppBackend.Search.Algolia + @type supported_index :: :stop | :route + + @type t :: %__MODULE__{ + index_name: String.t(), + params: map() + } + + @default_params %{"hitsPerPage" => 5, "clickAnalytics" => true} + + defstruct index_name: "", + params: %{} + + @spec for_index(supported_index, String.t()) :: t() + def for_index(:route, query) do + Algolia.Index.index_name(:route) + |> new(query) + |> with_hit_size(5) + end + + def for_index(:stop, query) do + Algolia.Index.index_name(:stop) + |> new(query) + |> with_hit_size(2) + end + + defp new(index_name, query) do + track_analytics? = + Application.get_env(:mobile_app_backend, MobileAppBackend.Search.Algolia)[:track_analytics?] || + false + + %__MODULE__{ + index_name: index_name, + params: + @default_params + |> Map.put("analytics", track_analytics?) + |> Map.put("query", query) + } + end + + defp with_hit_size(query_payload, hitSize) do + %__MODULE__{ + query_payload + | params: Map.put(query_payload.params, "hitsPerPage", hitSize) + } + end +end diff --git a/lib/mobile_app_backend/search/algolia/route_result.ex b/lib/mobile_app_backend/search/algolia/route_result.ex new file mode 100644 index 00000000..2fd496ac --- /dev/null +++ b/lib/mobile_app_backend/search/algolia/route_result.ex @@ -0,0 +1,29 @@ +defmodule MobileAppBackend.Search.Algolia.RouteResult do + @moduledoc """ + Data returned by Algolia for route search hits + """ + @derive Jason.Encoder + + @type t :: %__MODULE__{ + type: :route, + id: String.t(), + name: String.t(), + long_name: String.t(), + route_type: number(), + rank: number() + } + + defstruct [:type, :id, :name, :long_name, :route_type, :rank] + + @spec parse(map()) :: t() + def parse(result_response) do + %__MODULE__{ + type: :route, + id: result_response["route"]["id"], + name: result_response["route"]["name"], + long_name: result_response["route"]["long_name"], + route_type: result_response["route"]["type"], + rank: result_response["rank"] + } + end +end diff --git a/lib/mobile_app_backend/search/algolia/stop_result.ex b/lib/mobile_app_backend/search/algolia/stop_result.ex new file mode 100644 index 00000000..e49b67f3 --- /dev/null +++ b/lib/mobile_app_backend/search/algolia/stop_result.ex @@ -0,0 +1,31 @@ +defmodule MobileAppBackend.Search.Algolia.StopResult do + @moduledoc """ + Data returned by Algolia for stop search hits + """ + @derive Jason.Encoder + + @type t :: %__MODULE__{ + type: :stop, + id: String.t(), + name: String.t(), + rank: number(), + zone: String.t() | nil, + station?: boolean(), + routes: [%{type: number(), icon: String.t()}] + } + + defstruct [:type, :id, :name, :rank, :zone, :station?, :routes] + + @spec parse(map()) :: t() + def parse(result_response) do + %__MODULE__{ + type: :stop, + id: result_response["stop"]["id"], + name: result_response["stop"]["name"], + rank: result_response["rank"], + zone: result_response["stop"]["zone"], + station?: result_response["stop"]["station?"], + routes: Enum.map(result_response["routes"], &%{type: &1["type"], icon: &1["icon"]}) + } + end +end diff --git a/lib/mobile_app_backend_web/controllers/search_controller.ex b/lib/mobile_app_backend_web/controllers/search_controller.ex new file mode 100644 index 00000000..b1e1ff15 --- /dev/null +++ b/lib/mobile_app_backend_web/controllers/search_controller.ex @@ -0,0 +1,36 @@ +defmodule MobileAppBackendWeb.SearchController do + @moduledoc false + use MobileAppBackendWeb, :controller + alias MobileAppBackend.Search.Algolia + + alias Plug.Conn + + @spec query(Conn.t(), map) :: Conn.t() + def query(%Conn{} = conn, %{"query" => ""}) do + json(conn, %{data: []}) + end + + def query(%Conn{} = conn, %{"query" => query}) do + queries = [ + Algolia.QueryPayload.for_index(:route, query), + Algolia.QueryPayload.for_index(:stop, query) + ] + + algolia_query_fn = + Application.get_env( + :mobile_app_backend, + :algolia_multi_index_search_fn, + &Algolia.Api.multi_index_search/1 + ) + + case algolia_query_fn.(queries) do + {:ok, response} -> + json(conn, %{data: response}) + + {:error, _error} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "search_failed"}) + end + end +end diff --git a/lib/mobile_app_backend_web/router.ex b/lib/mobile_app_backend_web/router.ex index f4e7338d..899f2321 100644 --- a/lib/mobile_app_backend_web/router.ex +++ b/lib/mobile_app_backend_web/router.ex @@ -27,8 +27,8 @@ defmodule MobileAppBackendWeb.Router do # Other scopes may use custom stacks. scope "/api", MobileAppBackendWeb do pipe_through :api - get("/nearby", NearbyController, :show) + get("/search/query", SearchController, :query) end # Enable LiveDashboard in development diff --git a/test/mobile_app_backend/search/algolia/api_test.exs b/test/mobile_app_backend/search/algolia/api_test.exs new file mode 100644 index 00000000..99e38d52 --- /dev/null +++ b/test/mobile_app_backend/search/algolia/api_test.exs @@ -0,0 +1,166 @@ +defmodule MobileAppBackend.Search.Algolia.ApiTest do + use ExUnit.Case + alias MobileAppBackend.Search.Algolia.{Api, QueryPayload, RouteResult, StopResult} + + import Test.Support.Helpers + + describe "multi_index_search/2" do + test "when request is successful, returns flattened list of parsed results" do + reassign_env(:mobile_app_backend, :algolia_perform_request_fn, &mock_perform_request_fn/3) + + assert {:ok, results} = + Api.multi_index_search([ + QueryPayload.for_index(:stop, "1"), + QueryPayload.for_index(:route, "1") + ]) + + assert [ + %StopResult{ + type: :stop, + id: "place-FR-3338", + name: "Wachusett", + zone: "8", + station?: true, + rank: 3, + routes: [%{type: 2, icon: "commuter_rail"}] + }, + %RouteResult{ + type: :route, + id: "33", + name: "33Name", + long_name: "33 Long Name", + rank: 5, + route_type: 3 + } + ] == results + end + + test "makes requests to the algolia endpoint with expected headers and parameters properly encoded" do + pid = self() + + default_env = Application.get_env(:mobile_app_backend, MobileAppBackend.Search.Algolia) + + reassign_env( + :mobile_app_backend, + MobileAppBackend.Search.Algolia, + Keyword.merge(default_env, + search_key: "fake_search_key", + app_id: "fake_app_id" + ) + ) + + reassign_env(:mobile_app_backend, :algolia_perform_request_fn, fn url, body, headers -> + send(pid, %{url: url, body: body, headers: headers}) + {:ok, %{body: %{"results" => []}}} + end) + + Api.multi_index_search([ + QueryPayload.for_index(:stop, "1"), + QueryPayload.for_index(:route, "1") + ]) + + assert_received(%{url: url, body: body, headers: headers}) + + assert "fake_url/1/indexes/*/queries" == url + + assert ~s({"requests":[{"indexName":"stops_test","params":"analytics=false&clickAnalytics=true&hitsPerPage=2&query=1"},{"indexName":"routes_test","params":"analytics=false&clickAnalytics=true&hitsPerPage=5&query=1"}]}) == + body + + assert [ + {"X-Algolia-API-Key", "fake_search_key"}, + {"X-Algolia-Application-Id", "fake_app_id"} + ] == headers + end + + @tag capture_log: true + test "when config missing, returns error" do + default_env = Application.get_env(:mobile_app_backend, MobileAppBackend.Search.Algolia) + + reassign_env( + :mobile_app_backend, + MobileAppBackend.Search.Algolia, + Keyword.merge(default_env, search_key: nil) + ) + + reassign_env(:mobile_app_backend, :algolia_perform_request_fn, &mock_perform_request_fn/3) + + assert {:error, :search_failed} = + Api.multi_index_search([ + QueryPayload.for_index(:stop, "1"), + QueryPayload.for_index(:route, "1") + ]) + end + + @tag capture_log: true + test "when invalid json, returns error" do + reassign_env(:mobile_app_backend, :algolia_perform_request_fn, fn _url, _body, _headers -> + {:ok, %{body: "[123 this is not json]"}} + end) + + assert {:error, :malformed_results} = + Api.multi_index_search([ + QueryPayload.for_index(:stop, "1"), + QueryPayload.for_index(:route, "1") + ]) + end + + @tag capture_log: true + + test "when request is unsuccessful, returns error" do + reassign_env(:mobile_app_backend, :algolia_perform_request_fn, fn _url, _body, _headers -> + {:error, "oops"} + end) + + assert {:error, :search_failed} = + Api.multi_index_search([ + QueryPayload.for_index(:stop, "1"), + QueryPayload.for_index(:route, "1") + ]) + end + end + + def mock_perform_request_fn(_url, _body, _headers) do + {:ok, + %{ + body: %{ + "results" => [ + %{ + "index" => "stops_test", + "hits" => [ + %{ + "stop" => %{ + "zone" => "8", + "station?" => true, + "name" => "Wachusett", + "id" => "place-FR-3338" + }, + "routes" => [ + %{ + "type" => 2, + "icon" => "commuter_rail", + "display_name" => "Commuter Rail" + } + ], + "rank" => 3 + } + ] + }, + %{ + "index" => "routes_test", + "hits" => [ + %{ + "route" => %{ + "type" => 3, + "name" => "33Name", + "long_name" => "33 Long Name", + "id" => "33" + }, + "rank" => 5 + } + ] + } + ] + } + }} + end +end diff --git a/test/mobile_app_backend/search/algolia/index_test.exs b/test/mobile_app_backend/search/algolia/index_test.exs new file mode 100644 index 00000000..fcffa305 --- /dev/null +++ b/test/mobile_app_backend/search/algolia/index_test.exs @@ -0,0 +1,23 @@ +defmodule MobileAppBackend.Search.Algolia.IndexTest do + use ExUnit.Case + alias MobileAppBackend.Search.Algolia.Index + import Test.Support.Helpers + + describe "index_name/1" do + test "returns stop index name from config" do + reassign_env(:mobile_app_backend, MobileAppBackend.Search.Algolia, + stop_index: "fake_stop_index" + ) + + assert "fake_stop_index" == Index.index_name(:stop) + end + + test "returns route index name from config" do + reassign_env(:mobile_app_backend, MobileAppBackend.Search.Algolia, + route_index: "fake_route_index" + ) + + assert "fake_route_index" == Index.index_name(:route) + end + end +end diff --git a/test/mobile_app_backend/search/algolia/query_payload_test.exs b/test/mobile_app_backend/search/algolia/query_payload_test.exs new file mode 100644 index 00000000..99df73a0 --- /dev/null +++ b/test/mobile_app_backend/search/algolia/query_payload_test.exs @@ -0,0 +1,45 @@ +defmodule MobileAppBackend.Search.Algolia.QueryPayloadTest do + use ExUnit.Case, async: true + import Test.Support.Helpers + alias MobileAppBackend.Search.Algolia.QueryPayload + + describe "new/2" do + test "when for a route query, configures the index & params" do + reassign_env(:mobile_app_backend, MobileAppBackend.Search.Algolia, + route_index: "fake_route_index" + ) + + assert %QueryPayload{ + index_name: "fake_route_index", + params: %{ + "query" => "testString", + "hitsPerPage" => 5, + "clickAnalytics" => true, + "analytics" => false + } + } == QueryPayload.for_index(:route, "testString") + end + + test "when for a stop query, configures the index & params" do + reassign_env(:mobile_app_backend, MobileAppBackend.Search.Algolia, + stop_index: "fake_stop_index" + ) + + assert %QueryPayload{ + index_name: "fake_stop_index", + params: %{ + "query" => "testString", + "hitsPerPage" => 2, + "clickAnalytics" => true, + "analytics" => false + } + } == QueryPayload.for_index(:stop, "testString") + end + + test "when analytics is configured for the environment, then sets analytics param to true" do + reassign_env(:mobile_app_backend, MobileAppBackend.Search.Algolia, track_analytics?: true) + + assert %{params: %{"analytics" => true}} = QueryPayload.for_index(:route, "testString") + end + end +end diff --git a/test/mobile_app_backend/search/algolia/route_result_test.exs b/test/mobile_app_backend/search/algolia/route_result_test.exs new file mode 100644 index 00000000..d84e1161 --- /dev/null +++ b/test/mobile_app_backend/search/algolia/route_result_test.exs @@ -0,0 +1,27 @@ +defmodule MobileAppBackend.Search.Algolia.RouteResultTest do + use ExUnit.Case, async: true + alias MobileAppBackend.Search.Algolia.RouteResult + + describe "parse/1" do + test "parses relevant route data fields" do + response = %{ + "route" => %{ + "type" => 3, + "name" => "33Name", + "long_name" => "33 Long Name", + "id" => "33" + }, + "rank" => 5 + } + + assert %RouteResult{ + type: :route, + id: "33", + name: "33Name", + long_name: "33 Long Name", + rank: 5, + route_type: 3 + } == RouteResult.parse(response) + end + end +end diff --git a/test/mobile_app_backend/search/algolia/stop_result_test.exs b/test/mobile_app_backend/search/algolia/stop_result_test.exs new file mode 100644 index 00000000..ba5e5e65 --- /dev/null +++ b/test/mobile_app_backend/search/algolia/stop_result_test.exs @@ -0,0 +1,35 @@ +defmodule MobileAppBackend.Search.Algolia.StopResultTest do + use ExUnit.Case, async: true + alias MobileAppBackend.Search.Algolia.StopResult + + describe "parse/1" do + test "parses relevant stop data fields" do + response = %{ + "stop" => %{ + "zone" => "8", + "station?" => true, + "name" => "Wachusett", + "id" => "place-FR-3338" + }, + "routes" => [ + %{ + "type" => 2, + "icon" => "commuter_rail", + "display_name" => "Commuter Rail" + } + ], + "rank" => 3 + } + + assert %StopResult{ + type: :stop, + id: "place-FR-3338", + name: "Wachusett", + zone: "8", + station?: true, + rank: 3, + routes: [%{type: 2, icon: "commuter_rail"}] + } == StopResult.parse(response) + end + end +end diff --git a/test/mobile_app_backend_web/controllers/search_controller_test.exs b/test/mobile_app_backend_web/controllers/search_controller_test.exs new file mode 100644 index 00000000..2c3a3c30 --- /dev/null +++ b/test/mobile_app_backend_web/controllers/search_controller_test.exs @@ -0,0 +1,82 @@ +defmodule MobileAppBackendWeb.SearchControllerTest do + use MobileAppBackendWeb.ConnCase + import Test.Support.Helpers + alias MobileAppBackend.Search.Algolia.{RouteResult, StopResult} + + describe "/api/search/query" do + test "when query is an empty string, returns an empty list", %{conn: conn} do + conn = get(conn, "/api/search/query?query=") + + assert %{"data" => []} = + json_response(conn, 200) + end + + test "when valid query string, returns search results", %{conn: conn} do + stop = %StopResult{ + type: :stop, + id: "place-FR-3338", + name: "Wachusett", + zone: "8", + station?: true, + rank: 3, + routes: [%{type: 2, icon: "commuter_rail"}] + } + + route = %RouteResult{ + type: :route, + id: "33", + name: "33Name", + long_name: "33 Long Name", + rank: 5, + route_type: 3 + } + + reassign_env( + :mobile_app_backend, + :algolia_multi_index_search_fn, + fn _queries -> {:ok, [stop, route]} end + ) + + conn = get(conn, "/api/search/query?query=1") + + assert %{ + "data" => [ + %{ + "type" => "stop", + "id" => stop.id, + "name" => stop.name, + "zone" => stop.zone, + "station?" => stop.station?, + "rank" => stop.rank, + "routes" => [%{"type" => 2, "icon" => "commuter_rail"}] + }, + %{ + "type" => "route", + "id" => route.id, + "name" => route.name, + "long_name" => route.long_name, + "rank" => route.rank, + "route_type" => route.route_type + } + ] + } == + json_response(conn, 200) + end + + @tag capture_log: true + test "when there is an error performing algolia search, returns an error", %{conn: conn} do + reassign_env( + :mobile_app_backend, + :algolia_multi_index_search_fn, + fn _queries -> {:error, "something_went_wrong"} end + ) + + conn = get(conn, "/api/search/query?query=1") + + assert %{ + "error" => "search_failed" + } == + json_response(conn, 500) + end + end +end