Skip to content

Commit

Permalink
Implement the repository geocoder using AWS Location service
Browse files Browse the repository at this point in the history
The old Bing one seems sadly defunct
  • Loading branch information
mikesname committed Mar 6, 2024
1 parent 415d31a commit 38e2ebd
Show file tree
Hide file tree
Showing 10 changed files with 126 additions and 5 deletions.
3 changes: 3 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ val portalDependencies = Seq(

// S3 sdk
"software.amazon.awssdk" % "s3" % "2.15.63",

// AWS Location sdk
"software.amazon.awssdk" % "location" % "2.15.63",
)

val adminDependencies = Seq(
Expand Down
5 changes: 5 additions & 0 deletions conf/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ services {

geocoding {
parallelism: 3

config = {
# This should be configured for the individual Geocoding service instance
# e.g. for AWS Location or Bing
}
}
}

Expand Down
1 change: 1 addition & 0 deletions conf/logback-play-dev.xml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
<!-- Uncomment to see how indexed entities are converted to Solr docs.
Don't leave this on in production. -->
<!-- <logger name="services.search" level="TRACE" /> -->
<logger name="services.geocoding" level="DEBUG" />

<!-- Uncomment to see Solr requests -->
<!-- <logger name="eu.ehri.project.search.solr" level="DEBUG" /> -->
Expand Down
5 changes: 5 additions & 0 deletions conf/test.conf
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,8 @@ storage.portal.classifier = portal

storage.test.config = ${minio}
storage.test.classifier = test

# The full AWS configuration is missing but this
# is enough to run tests that instantiate the client
services.geocoding.config = ${minio}
services.geocoding.config.aws.index-name = ehri
20 changes: 19 additions & 1 deletion modules/core/src/main/scala/models/Address.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package models
import models.json._
import play.api.libs.json._

import java.util.Locale


object AddressF {
val UNNAMED_ADDRESS = "Unnamed Address"
Expand Down Expand Up @@ -48,9 +50,25 @@ case class AddressF(
fax: Seq[String] = Nil,
url: Seq[String] = Nil
) extends ModelData {

/**
* Returns a concise representation of the address, suitable for display in a list.
*/
def concise: String =
Seq(streetAddress, city, region).flatten.filterNot(_.trim.isEmpty).mkString(", ")

/**
* Returns a postal address, suitable for display in a single block.
*/
def postalAddress: String =
Seq(streetAddress, city, region, postalCode, countryCode).flatten.filterNot(_.trim.isEmpty).mkString("\n")

/**
* Returns the ISO 3166-1 alpha-3 code for the country, if available.
*/
def countryCode3: Option[String] =
countryCode.flatMap(c => Locale.getAvailableLocales.find(_.getCountry == c).map(_.getISO3Country))

override def toString: String =
Seq(name, contactPerson, streetAddress, city, region, countryCode).filter(_.isDefined).mkString(", ")
}
Expand All @@ -60,7 +78,7 @@ object Address {
import play.api.data.Form
import play.api.data.Forms._

val form = Form(
val form: Form[AddressF] = Form(
mapping(
Entity.ISA -> ignored(EntityType.Address),
Entity.ID -> optional(nonEmptyText),
Expand Down
8 changes: 7 additions & 1 deletion modules/portal/app/guice/AppModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import services.RateLimitChecker
import services.cypher.{CypherQueryService, CypherService, SqlCypherQueryService, WsCypherService}
import services.data._
import services.feedback.{FeedbackService, SqlFeedbackService}
import services.geocoding.{AwsGeocodingService, GeocodingService}
import services.htmlpages.{GoogleDocsHtmlPages, HtmlPages}
import services.redirects.{MovedPageLookup, SqlMovedPageLookup}
import services.search._
Expand All @@ -31,12 +32,15 @@ private class DamStorageProvider @Inject()(config: play.api.Configuration)(impli
S3CompatibleFileStorage(config.get[com.typesafe.config.Config]("storage.dam"))
}

private class AwsGeocodingServiceProvider @Inject()(config: play.api.Configuration, ec: ExecutionContext) extends Provider[GeocodingService] {
override def get(): GeocodingService = AwsGeocodingService(config.get[com.typesafe.config.Config]("services.geocoding"))(ec)
}

class AppModule extends AbstractModule with AkkaGuiceSupport {
override def configure(): Unit = {
bind(classOf[AppConfig])
bind(classOf[RateLimitChecker])
bind(classOf[EventHandler]).to(classOf[IndexingEventHandler])
bind(classOf[ItemLifecycle]).to(classOf[GeocodingItemLifecycle])
bind(classOf[DataServiceBuilder]).to(classOf[WsDataServiceBuilder])
bind(classOf[FeedbackService]).to(classOf[SqlFeedbackService])
bind(classOf[CypherQueryService]).to(classOf[SqlCypherQueryService])
Expand All @@ -48,6 +52,8 @@ class AppModule extends AbstractModule with AkkaGuiceSupport {
bind(classOf[RawMarkdownRenderer]).to(classOf[CommonmarkMarkdownRenderer])
bind(classOf[MarkdownRenderer]).to(classOf[SanitisingMarkdownRenderer])
bind(classOf[CypherService]).to(classOf[WsCypherService])
bind(classOf[ItemLifecycle]).to(classOf[GeocodingItemLifecycle])
bind(classOf[GeocodingService]).toProvider(classOf[AwsGeocodingServiceProvider])
bindActor[EventForwarder]("event-forwarder")
}
}
68 changes: 68 additions & 0 deletions modules/portal/app/services/geocoding/AwsGeocodingService.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package services.geocoding

import models.AddressF
import software.amazon.awssdk.auth.credentials.{AwsCredentials, AwsCredentialsProvider, StaticCredentialsProvider}
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.regions.providers.AwsRegionProvider
import software.amazon.awssdk.services.location.LocationClient
import software.amazon.awssdk.services.location.model.SearchPlaceIndexForTextRequest

import javax.inject.Inject
import scala.collection.JavaConverters._
import scala.concurrent.{ExecutionContext, Future}


object AwsGeocodingService {
def apply(config: com.typesafe.config.Config)(implicit ec: ExecutionContext): AwsGeocodingService = {

val credentials = StaticCredentialsProvider.create(new AwsCredentials {
override def accessKeyId(): String = config.getString("config.aws.credentials.access-key-id")

override def secretAccessKey(): String = config.getString("config.aws.credentials.secret-access-key")
})
val region: AwsRegionProvider = new AwsRegionProvider {
override def getRegion: Region = Region.of(config.getString("config.aws.region.default-region"))
}

new AwsGeocodingService(
credentials,
region,
config.getString("config.aws.index-name")
)
}
}

case class AwsGeocodingService @Inject()(credentials: AwsCredentialsProvider, region: AwsRegionProvider, indexName: String)(implicit executionContext: ExecutionContext) extends GeocodingService {

private val logger = play.api.Logger(classOf[AwsGeocodingService])

override def geocode(address: AddressF): Future[Option[Point]] = {
val client: LocationClient = LocationClient
.builder()
.credentialsProvider(credentials)
.region(region.getRegion)
.build()

val request = SearchPlaceIndexForTextRequest.builder()
.indexName(indexName)
.text(address.postalAddress)
.filterCountries(address.countryCode3.toList: _*)
.maxResults(1)
.build()

Future {
client.searchPlaceIndexForText(request)
.results()
.asScala
.headOption
.map { result =>
logger.debug(s"Geocoding result: $result, point: ${result.place().geometry().point()}")
val lonLat = result.place().geometry().point()
Point(
BigDecimal(lonLat.get(1)),
BigDecimal(lonLat.get(0))
)
}
}
}
}
2 changes: 0 additions & 2 deletions modules/portal/app/services/geocoding/GeocodingService.scala
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
package services.geocoding

import com.google.inject.ImplementedBy
import models.AddressF

import scala.concurrent.Future

case class Point(latitude: BigDecimal, longitude: BigDecimal)

@ImplementedBy(classOf[BingGeocodingService])
trait GeocodingService {
def geocode(address: AddressF): Future[Option[Point]]
}
3 changes: 2 additions & 1 deletion test/helpers/TestConfiguration.scala
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import services.accounts.{AccountManager, MockAccountManager}
import services.cypher.{CypherQueryService, MockCypherQueryService}
import services.data._
import services.feedback.{FeedbackService, MockFeedbackService}
import services.geocoding.{GeocodingService, NoopGeocodingService}
import services.harvesting.{MockResourceSyncClient, ResourceSyncClient}
import services.htmlpages.{HtmlPages, MockHtmlPages}
import services.ingest.{EadValidator, MockEadValidatorService}
Expand Down Expand Up @@ -118,7 +119,7 @@ trait TestConfiguration {

bind[EventHandler].toInstance(testEventHandler),
bind[HtmlPages].toInstance(mockHtmlPages),
bind[ItemLifecycle].to[NoopItemLifecycle],
bind[GeocodingService].to[NoopGeocodingService],
bind[EadValidator].to[MockEadValidatorService],
bind[ResourceSyncClient].to[MockResourceSyncClient],
// NB: Graph IDs are not stable during testing due to
Expand Down
16 changes: 16 additions & 0 deletions test/services/geocoding/NoopGeocodingService.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package services.geocoding

import models.AddressF
import play.api.Logger

import javax.inject.{Inject, Singleton}
import scala.concurrent.Future

@Singleton
case class NoopGeocodingService @Inject()() extends GeocodingService {
private val logger = Logger(classOf[NoopGeocodingService])
override def geocode(address: AddressF): Future[Option[Point]] = {
logger.info(s"Geocoding disabled: $address")
Future.successful(None)
}
}

0 comments on commit 38e2ebd

Please sign in to comment.