Skip to content

Commit

Permalink
Geocoding service backend (#2127)
Browse files Browse the repository at this point in the history
* chore: pull in place index name from environment variable

* chore: pull AWS credentials from awscli configuration in dev

* chore: add AWS_PLACE_INDEX to envrc template

* feat: request and parse search results

* feat: expose search results via API endpoint

* fix: correct FilterBBox

* fix: make map component bounds match tileset bounds

* feat: request and parse suggestions

* feat: API endpoint for suggestions

* refactor: have one source of truth for map bounds

* refactor: make search/1 and suggest/1 return parsed value
  • Loading branch information
lemald authored Jul 18, 2023
1 parent 65cc36f commit 6c5aa19
Show file tree
Hide file tree
Showing 20 changed files with 524 additions and 15 deletions.
3 changes: 3 additions & 0 deletions .envrc.template
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export BUSLOC_URL=
## Source of GTFS-realtime enhanced TripUpdates json data file (optional)
export TRIP_UPDATES_URL=

## Amazon Location Service place index to use for location search
export AWS_PLACE_INDEX=

## Erlang/OTP settings, pass "+MIscs 2048" to allocate enough memory for literals in your local dev environment
export ERL_FLAGS="+MIscs 2048"

Expand Down
15 changes: 11 additions & 4 deletions assets/src/components/map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { StreetViewControl } from "./map/controls/StreetViewSwitch"
import StreetViewModeEnabledContext from "../contexts/streetViewModeEnabledContext"
import { TileType, tilesetUrlForType } from "../tilesetUrls"
import { TileTypeContext } from "../contexts/tileTypeContext"
import getMapLimits from "../mapLimits"

export interface Props {
reactLeafletRef?: MutableRefObject<LeafletMap | null>
Expand Down Expand Up @@ -312,6 +313,8 @@ const Map = (props: Props): ReactElement<HTMLDivElement> => {
const [streetViewEnabled, setStreetViewEnabled] = useState<boolean>(
props.streetViewInitiallyEnabled || false
)

const mapLimits = getMapLimits()
const { allowFullscreen = true } = props

const stateClasses = joinClasses([
Expand All @@ -329,10 +332,14 @@ const Map = (props: Props): ReactElement<HTMLDivElement> => {
<MapContainer
className="c-vehicle-map"
id="id-vehicle-map"
maxBounds={[
[41.2, -72],
[43, -69.8],
]}
maxBounds={
mapLimits
? [
[mapLimits.south, mapLimits.west],
[mapLimits.north, mapLimits.east],
]
: undefined
}
zoomControl={false}
center={defaultCenter}
zoom={defaultZoom}
Expand Down
24 changes: 24 additions & 0 deletions assets/src/mapLimits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { create, Infer, number, type } from "superstruct"
import appData from "./appData"

const MapLimits = type({
north: number(),
south: number(),
east: number(),
west: number(),
})
type MapLimits = Infer<typeof MapLimits>

const getMapLimits = (): MapLimits | null => {
const mapLimitsJson = appData()?.mapLimits

if (mapLimitsJson === undefined) {
return null
}

const mapLimits = create(JSON.parse(mapLimitsJson) as unknown, MapLimits)

return mapLimits
}

export default getMapLimits
20 changes: 10 additions & 10 deletions assets/tests/components/map.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ const animationFramePromise = (): Promise<null> => {

describe("auto centering", () => {
test("auto centers on a vehicle", async () => {
const location = { lat: 42, lng: -71 }
const location = { lat: 42.25, lng: -71 }
const vehicle: VehicleInScheduledService = vehicleFactory.build({
latitude: location.lat,
longitude: location.lng,
Expand All @@ -517,7 +517,7 @@ describe("auto centering", () => {
test("tracks a vehicle when it moves", async () => {
const vehicle = vehicleFactory.build({})
const mapRef: MutableRefObject<LeafletMap | null> = { current: null }
const oldLatLng = { lat: 42, lng: -71 }
const oldLatLng = { lat: 42.25, lng: -71 }
const oldVehicle = {
...vehicle,
latitude: oldLatLng.lat,
Expand All @@ -530,7 +530,7 @@ describe("auto centering", () => {
/>
)
await animationFramePromise()
const newLatLng = { lat: 42.1, lng: -71.1 }
const newLatLng = { lat: 42.35, lng: -71.1 }
const newVehicle = {
...vehicle,
latitude: newLatLng.lat,
Expand Down Expand Up @@ -559,15 +559,15 @@ describe("auto centering", () => {
expect(container.firstChild).toHaveClass(
"c-vehicle-map-state--auto-centering"
)
const manualLatLng = { lat: 41.9, lng: -70.9 }
const manualLatLng = { lat: 42.25, lng: -70.9 }

act(() => {
mapRef.current!.fire("dragstart")
mapRef.current!.panTo(manualLatLng)
})

await animationFramePromise()
const newLatLng = { lat: 42.1, lng: -71.1 }
const newLatLng = { lat: 42.35, lng: -71.1 }
const newVehicle = {
...vehicle,
latitude: newLatLng.lat,
Expand All @@ -589,9 +589,9 @@ describe("auto centering", () => {
test("auto recentering does not disable auto centering", async () => {
const vehicle = vehicleFactory.build({})
const mapRef: MutableRefObject<LeafletMap | null> = { current: null }
const latLng1 = { lat: 42, lng: -71 }
const latLng2 = { lat: 42.1, lng: -71.1 }
const latLng3 = { lat: 42.2, lng: -71.2 }
const latLng1 = { lat: 42.1, lng: -71 }
const latLng2 = { lat: 42.2, lng: -71.1 }
const latLng3 = { lat: 42.3, lng: -71.2 }
const vehicle1 = {
...vehicle,
latitude: latLng1.lat,
Expand Down Expand Up @@ -642,7 +642,7 @@ describe("auto centering", () => {
await animationFramePromise()

// Manual move to turn off auto centering
const manualLatLng = { lat: 41.9, lng: -70.9 }
const manualLatLng = { lat: 42.25, lng: -70.9 }
act(() => {
mapRef.current!.fire("dragstart")
mapRef.current!.panTo(manualLatLng)
Expand Down Expand Up @@ -677,7 +677,7 @@ describe("auto centering", () => {
await animationFramePromise()

// Manual move to turn off auto centering
const manualLatLng = { lat: 41.9, lng: -70.9 }
const manualLatLng = { lat: 42.35, lng: -70.9 }
act(() => {
mapRef.current!.fire("dragstart")
mapRef.current!.panTo(manualLatLng)
Expand Down
45 changes: 45 additions & 0 deletions assets/tests/mapLimits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { StructError } from "superstruct"
import appData from "../src/appData"
import getMapLimits from "../src/mapLimits"

jest.mock("appData")

describe("getMapLimits", () => {
test("returns map limits when properly formatted", () => {
const limits = { north: 1, south: 2, east: 3, west: 4 }
;(appData as jest.Mock).mockImplementation(() => ({
mapLimits: JSON.stringify(limits),
}))

expect(getMapLimits()).toEqual(limits)
})

test.each([
// JSON.parse should throw when given invalid json
{ expectedError: SyntaxError, mockTestGroupData: "" },
{ expectedError: SyntaxError, mockTestGroupData: "test 1234 !@#$ []{}" },
{ expectedError: SyntaxError, mockTestGroupData: [1, 2, 3] },

// Superstruct catches valid json but invalid types
{ expectedError: StructError, mockTestGroupData: null },
{ expectedError: StructError, mockTestGroupData: JSON.stringify(null) },
{ expectedError: StructError, mockTestGroupData: true },
{ expectedError: StructError, mockTestGroupData: JSON.stringify(true) },
{
expectedError: StructError,
mockTestGroupData: JSON.stringify([1, 2, 3]),
},
{
expectedError: StructError,
mockTestGroupData: JSON.stringify({ irrelevant: "keys" }),
},
])(
"raise error when test group data is not the correct type: case %#",
({ mockTestGroupData, expectedError: expectedError }) => {
;(appData as jest.Mock).mockImplementationOnce(() => ({
mapLimits: mockTestGroupData,
}))
expect(() => getMapLimits()).toThrowError(expectedError)
}
)
})
6 changes: 6 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ config :skate,
sentry_frontend_dsn: {:system, "SENTRY_FRONTEND_DSN"},
sentry_environment: {:system, "SENTRY_ENV"},
log_duration_timing: true,
map_limits: %{
north: 42.65,
south: 42.05,
east: -70.6,
west: -71.55
},
redirect_http?: false,
static_href: {SkateWeb.Router.Helpers, :static_path},
timezone: "America/New_York",
Expand Down
4 changes: 4 additions & 0 deletions config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ config :skate, SkateWeb.Endpoint,

config :skate, SkateWeb.AuthManager, secret_key: "dev key"

config :ex_aws,
access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, {:awscli, "default", 30}],
secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, {:awscli, "default", 30}]

config :ueberauth, Ueberauth,
providers: [
cognito: {Skate.Ueberauth.Strategy.Fake, [groups: ["skate-dispatcher", "skate-admin"]]}
Expand Down
3 changes: 2 additions & 1 deletion config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ config :skate,
secret_key_base: System.get_env("SECRET_KEY_BASE"),
restrict_environment_access?: System.get_env("RESTRICT_ENVIRONMENT_ACCESS") == "true",
base_tileset_url: System.get_env("BASE_TILESET_URL"),
satellite_tileset_url: System.get_env("SATELLITE_TILESET_URL")
satellite_tileset_url: System.get_env("SATELLITE_TILESET_URL"),
aws_place_index: System.get_env("AWS_PLACE_INDEX")

config :ueberauth, Ueberauth.Strategy.Cognito,
client_secret: System.get_env("COGNITO_CLIENT_SECRET")
Expand Down
99 changes: 99 additions & 0 deletions lib/skate/location_search/aws_location_request.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
defmodule Skate.LocationSearch.AwsLocationRequest do
alias Skate.LocationSearch.SearchResult

@spec search(String.t()) :: {:ok, map()} | {:error, term()}
def search(text) do
request_fn = Application.get_env(:skate, :aws_request_fn, &ExAws.request/1)

path =
"/places/v0/indexes/" <> Application.get_env(:skate, :aws_place_index) <> "/search/text"

case %ExAws.Operation.RestQuery{
http_method: :post,
path: path,
body: Map.merge(base_arguments(), %{Text: text}),
service: :places
}
|> request_fn.() do
{:ok, response} -> parse_search_response(response)
{:error, error} -> {:error, error}
end
end

@spec suggest(String.t()) :: {:ok, map()} | {:error, term()}
def suggest(text) do
request_fn = Application.get_env(:skate, :aws_request_fn, &ExAws.request/1)

path =
"/places/v0/indexes/" <>
Application.get_env(:skate, :aws_place_index) <> "/search/suggestions"

case %ExAws.Operation.RestQuery{
http_method: :post,
path: path,
body: Map.merge(base_arguments(), %{Text: text}),
service: :places
}
|> request_fn.() do
{:ok, response} -> parse_suggest_response(response)
{:error, error} -> {:error, error}
end
end

defp parse_search_response(%{status_code: 200, body: body}) do
%{"Results" => results} = Jason.decode!(body)

Enum.map(results, fn result ->
%{"PlaceId" => id, "Place" => place} = result

%{"Label" => label, "Geometry" => %{"Point" => [longitude, latitude]}} = place

{name, address} =
separate_label_text(label, Map.get(place, "AddressNumber"), Map.get(place, "Street"))

%SearchResult{
id: id,
name: name,
address: address,
latitude: latitude,
longitude: longitude
}
end)
end

defp parse_suggest_response(%{status_code: 200, body: body}) do
%{"Results" => results} = Jason.decode!(body)

Enum.map(results, fn result -> Map.get(result, "Text") end)
end

@spec separate_label_text(String.t(), String.t() | nil, String.t() | nil) ::
{String.t() | nil, String.t()}
defp separate_label_text(label, nil, nil), do: {nil, label}

defp separate_label_text(label, address_number, street) do
address_prefix = address_number || street

case address_prefix
|> Regex.escape()
|> Regex.compile!()
|> Regex.split(label, parts: 2, include_captures: true) do
["", prefix, address] ->
{nil, prefix <> address}

[name, prefix, address] ->
{Regex.replace(~r/, $/, name, ""), prefix <> address}

[prefix, address] ->
{nil, prefix <> address}
end
end

defp base_arguments do
map_limits = Application.get_env(:skate, :map_limits)

%{
FilterBBox: [map_limits[:west], map_limits[:south], map_limits[:east], map_limits[:north]]
}
end
end
25 changes: 25 additions & 0 deletions lib/skate/location_search/search_result.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
defmodule Skate.LocationSearch.SearchResult do
@type t :: %__MODULE__{
id: String.t(),
name: String.t() | nil,
address: String.t(),
latitude: float(),
longitude: float()
}

@enforce_keys [
:id,
:address,
:latitude,
:longitude
]

@derive {Jason.Encoder, only: [:id, :name, :address, :latitude, :longitude]}
defstruct [
:id,
:name,
:address,
:latitude,
:longitude
]
end
23 changes: 23 additions & 0 deletions lib/skate_web/controllers/location_search_controller.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
defmodule SkateWeb.LocationSearchController do
use SkateWeb, :controller

alias Skate.LocationSearch.AwsLocationRequest

@spec search(Plug.Conn.t(), map()) :: Plug.Conn.t()
def search(conn, %{"query" => query}) do
seach_fn = Application.get_env(:skate, :location_search_fn, &AwsLocationRequest.search/1)

{:ok, result} = seach_fn.(query)

json(conn, %{data: result})
end

@spec suggest(Plug.Conn.t(), map()) :: Plug.Conn.t()
def suggest(conn, %{"query" => query}) do
suggest_fn = Application.get_env(:skate, :location_suggest_fn, &AwsLocationRequest.suggest/1)

{:ok, result} = suggest_fn.(query)

json(conn, %{data: result})
end
end
3 changes: 3 additions & 0 deletions lib/skate_web/controllers/page_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ defmodule SkateWeb.PageController do
dispatcher_flag =
conn |> Guardian.Plug.current_claims() |> AuthManager.claims_grant_dispatcher_access?()

map_limits = Application.get_env(:skate, :map_limits)

conn
|> assign(:username, username)
|> assign(:user_uuid, user.uuid)
Expand All @@ -34,6 +36,7 @@ defmodule SkateWeb.PageController do
satellite: Application.get_env(:skate, :satellite_tileset_url)
})
|> assign(:user_test_groups, user.test_groups |> Enum.map(& &1.name))
|> assign(:map_limits, map_limits)
|> render("index.html")
end

Expand Down
Loading

0 comments on commit 6c5aa19

Please sign in to comment.