Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Search endpoint #35

Merged
merged 10 commits into from
Jan 23, 2024
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ mobile_app_backend-*.tar
npm-debug.log
/assets/node_modules/

# Local environment configuration
.envrc

4 changes: 4 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 11 additions & 4 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down
8 changes: 8 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 0 additions & 2 deletions envrc.example

This file was deleted.

12 changes: 12 additions & 0 deletions envrc.template
Original file line number Diff line number Diff line change
@@ -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=

101 changes: 101 additions & 0 deletions lib/mobile_app_backend/search/algolia/api.ex
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions lib/mobile_app_backend/search/algolia/index.ex
Original file line number Diff line number Diff line change
@@ -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
51 changes: 51 additions & 0 deletions lib/mobile_app_backend/search/algolia/query_payload.ex
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions lib/mobile_app_backend/search/algolia/route_result.ex
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions lib/mobile_app_backend/search/algolia/stop_result.ex
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions lib/mobile_app_backend_web/controllers/search_controller.ex
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion lib/mobile_app_backend_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading