diff --git a/.gitignore b/.gitignore index 482562b..dec3038 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,22 @@ - -logs -project/project +**/.bloop/ +**/.metals/ +**/.vscode/ **/target/ -lib_managed -tmp -.history -dist -/.idea +/.bsp/ +/.idea/ +logs/ +project/project/ /*.iml /*.ipr -/out -/.idea_modules /.classpath +/.idea_modules /.project -/RUNNING_PID /.settings -*.iws -/.bsp /null -**/.bloop/ -**/.metals/ -**/.vscode/ +/out +/RUNNING_PID +*.iws +.history +dist +lib_managed +tmp diff --git a/app/controllers/AddressController.scala b/app/controllers/AddressController.scala deleted file mode 100644 index 52405dd..0000000 --- a/app/controllers/AddressController.scala +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2023 HM Revenue & Customs - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package controllers - -import play.api.Logger -import play.api.mvc.{ControllerComponents, Result} -import uk.gov.hmrc.play.bootstrap.backend.controller.BackendController - -abstract class AddressController(cc: ControllerComponents) extends BackendController(cc) { - - private val logger = Logger(this.getClass.getSimpleName) - - protected final def badRequest(tag: String, data: (String, String)*): Result = { - logEvent(tag, data: _*) - BadRequest(keyVal(data.last)) - } - - protected final def notFound(tag: String, data: (String, String)*): Result = { - logEvent(tag, data: _*) - NotFound(keyVal(data.last)) - } - - protected final def logEvent(tag: String, data: (String, String)*): Unit = { - val formatted = data.map(keyVal).mkString(" ") - logger.info(s"$tag $formatted") - } - - protected final def logEvent(tag: String, matches: Int, data: List[(String, String)]): Unit = { - logEvent(tag, ("matches" -> matches.toString) :: data: _*) - } - - private def keyVal(item: (String, String)) = item._1 + "=" + item._2 -} diff --git a/app/controllers/AddressSearchController.scala b/app/controllers/AddressSearchController.scala index 39992c2..dc1255e 100644 --- a/app/controllers/AddressSearchController.scala +++ b/app/controllers/AddressSearchController.scala @@ -18,29 +18,25 @@ package controllers import access.AccessChecker import config.ConfigHelper -import model._ -import model.address.{AddressRecord, Postcode} -import model.internal.NonUKAddress import model.request.{LookupByCountryRequest, LookupByPostTownRequest, LookupByPostcodeRequest, LookupByUprnRequest} import model.response.{ErrorResponse, SupportedCountryCodes} +import play.api.Logging import play.api.libs.json.{JsError, JsSuccess, Json} import play.api.mvc._ -import repositories.{ABPAddressRepository, NonABPAddressRepository} -import services.{CheckAddressDataScheduler, ResponseProcessor} +import services.{AddressSearchService, CheckAddressDataScheduler} import uk.gov.hmrc.http.HeaderCarrier -import uk.gov.hmrc.play.audit.http.connector.AuditConnector import uk.gov.hmrc.play.http.HeaderCarrierConverter import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success, Try} -class AddressSearchController @Inject()(addressSearch: ABPAddressRepository, nonABPAddressSearcher: NonABPAddressRepository, - responseProcessor: ResponseProcessor, auditConnector: AuditConnector, - cc: ControllerComponents, supportedCountryCodes: SupportedCountryCodes, - scheduler: CheckAddressDataScheduler, val configHelper: ConfigHelper)( - implicit ec: ExecutionContext) - extends AddressController(cc) with AccessChecker { +class AddressSearchController @Inject()(addressSearchService: AddressSearchService, + val controllerComponents: ControllerComponents, + supportedCountryCodes: SupportedCountryCodes, + scheduler: CheckAddressDataScheduler, + val configHelper: ConfigHelper)(ec: ExecutionContext) + extends BaseController with AccessChecker with Logging { import ErrorResponse.Implicits._ scheduler.enable() @@ -52,7 +48,7 @@ class AddressSearchController @Inject()(addressSearch: ABPAddressRepository, non case Success(json) => json.validate[LookupByPostcodeRequest](LookupByPostcodeRequest.reads) match { case JsSuccess(lookupByPostcodeRequest, _) => - searchByPostcode(request, lookupByPostcodeRequest.postcode, lookupByPostcodeRequest.filter) + addressSearchService.searchByPostcode(request, lookupByPostcodeRequest.postcode, lookupByPostcodeRequest.filter) case JsError(errors) => Future.successful(BadRequest(JsError.toJson(errors))) } @@ -66,7 +62,7 @@ class AddressSearchController @Inject()(addressSearch: ABPAddressRepository, non maybeJson match { case Success(json) => json.validate[LookupByUprnRequest] match { case JsSuccess(lookupByUprnRequest, _) => - searchByUprn(request, lookupByUprnRequest.uprn) + addressSearchService.searchByUprn(request, lookupByUprnRequest.uprn) case JsError(errors) => Future.successful(BadRequest(JsError.toJson(errors))) } @@ -80,7 +76,7 @@ class AddressSearchController @Inject()(addressSearch: ABPAddressRepository, non maybeJson match { case Success(json) => json.validate[LookupByPostTownRequest] match { case JsSuccess(lookupByTownRequest, _) => - searchByTown(request, lookupByTownRequest.posttown, lookupByTownRequest.filter) + addressSearchService.searchByTown(request, lookupByTownRequest.posttown, lookupByTownRequest.filter) case JsError(errors) => Future.successful(BadRequest(JsError.toJson(errors))) } @@ -102,153 +98,11 @@ class AddressSearchController @Inject()(addressSearch: ABPAddressRepository, non case Success(json) => json.validate[LookupByCountryRequest] match { case JsSuccess(lookupByCountryRequest, _) => val userAgent = request.headers.get("User-Agent") - searchByCountry(userAgent, countryCode.toLowerCase(), lookupByCountryRequest.filter) + addressSearchService.searchByCountry(userAgent, countryCode.toLowerCase(), lookupByCountryRequest.filter) case JsError(errors) => Future.successful(BadRequest(JsError.toJson(errors))) } case Failure(_) => Future.successful(BadRequest(Json.toJson(ErrorResponse.invalidJson))) } } - - private[controllers] def searchByUprn[A](request: Request[A], uprn: String): Future[Result] = { - if (Try(uprn.toLong).isFailure) { - Future.successful { - badRequest("BAD-UPRN", "uprn" -> uprn, "error" -> s"uprn must only consist of digits") - } - } else { - import model.address.AddressRecord.formats._ - - addressSearch.findUprn(uprn).map { - a => - val a2 = responseProcessor.convertAddressList(a) - logEvent("LOOKUP", "uprn" -> uprn, "matches" -> a2.size.toString) - Ok(Json.toJson(a2)) - } - } - } - - private[controllers] def searchByPostcode[A](request: Request[A], postcode: Postcode, filter: Option[String]): Future[Result] = { - implicit val hc: HeaderCarrier = HeaderCarrierConverter.fromRequest(request) - - if (postcode.toString.isEmpty) { - Future.successful { - badRequest("BAD-POSTCODE", "error" -> s"missing or badly-formed $postcode parameter") - } - } else { - import model.address.AddressRecord.formats._ - addressSearch.findPostcode(postcode, filter).map { - a => - val userAgent = request.headers.get("User-Agent") - val a2 = responseProcessor.convertAddressList(a) - - if (a2.nonEmpty) { - auditAddressSearch(userAgent, a2, postcode = Some(postcode), filter = filter) - } - - logEvent("LOOKUP", a2.size, List(Some("postcode" -> postcode.toString), filter.map(f => "filter" -> f)).flatten) - Ok(Json.toJson(a2)) - } - } - } - - private[controllers] def searchByTown[A](request: Request[A], posttown: String, filter: Option[String]): Future[Result] = { - implicit val hc: HeaderCarrier = HeaderCarrierConverter.fromRequest(request) - - if (posttown.isEmpty) { - Future.successful { - badRequest("BAD-POSTTOWN", "error" -> s"missing or badly-formed $posttown parameter") - } - } else { - import model.address.AddressRecord.formats._ - val casedPosttown = posttown.toUpperCase - - addressSearch.findTown(casedPosttown, filter).map { - a => - val userAgent = request.headers.get("User-Agent") - val a2 = responseProcessor.convertAddressList(a) - - if (a2.nonEmpty) { - auditAddressSearch(userAgent, a2, postTown = Some(casedPosttown), filter = filter) - } - - logEvent("LOOKUP", a2.size, List(Some("posttown" -> posttown), filter.map(f => "filter" -> f)).flatten) - Ok(Json.toJson(a2)) - } - } - } - - private[controllers] def searchByCountry[A](userAgent: Option[String], countryCode: String, filter: String)(implicit hc: HeaderCarrier): Future[Result] = { - - if (countryCode.isEmpty || "[a-zA-Z]{2}".r.unapplySeq(countryCode).isEmpty) { - Future.successful { - badRequest("BAD-COUNTRYCODE", "error" -> s"missing or badly-formed country code") - } - } else if (supportedCountryCodes.abp.contains(countryCode)) { - Future.successful { - badRequest("ABP-COUNTRYCODE", "error" -> s"country code is abp.") - } - } else if (!supportedCountryCodes.nonAbp.contains(countryCode)) { - Future.successful { - notFound("UNSUPPORTED-COUNTRYCODE", "error" -> s"country code unsupported") - } - } else { - import model.internal.NonUKAddress._ - - nonABPAddressSearcher.findInCountry(countryCode, filter).map { - a => - auditNonUKAddressSearch(userAgent, a, countryCode, Option(filter)) - logEvent("LOOKUP", a.size, List("countryCode" -> countryCode, "filter" -> filter)) - Ok(Json.toJson(a)) - }.recover { - case e: Throwable => logEvent("LOOKUP-NONUK-ERROR", "errorMessage" -> e.getMessage) - ExpectationFailed - } - } - } - - private def auditAddressSearch[A](userAgent: Option[String], a2: List[AddressRecord], postcode: Option[Postcode] = None, - postTown: Option[String] = None, filter: Option[String] = None)(implicit hc: HeaderCarrier): Unit = { - - auditConnector.sendExplicitAudit("AddressSearch", - AddressSearchAuditEvent(userAgent, - AddressSearchAuditEventRequestDetails(postcode.map(_.toString), postTown, filter), - a2.length, - a2.map { ma => - AddressSearchAuditEventMatchedAddress( - ma.uprn.map(_.toString).getOrElse("").toString, - ma.parentUprn, - ma.usrn, - ma.organisation, - ma.address.lines, - ma.address.town, - ma.localCustodian, - ma.location, - ma.administrativeArea, - ma.poBox, - ma.address.postcode, - ma.address.subdivision, - ma.address.country) - })) - } - - private def auditNonUKAddressSearch[A](userAgent: Option[String], a2: List[NonUKAddress], country: String, - filter: Option[String] = None)(implicit hc: HeaderCarrier): Unit = { - - auditConnector.sendExplicitAudit("NonUKAddressSearch", - NonUKAddressSearchAuditEvent(userAgent, - NonUKAddressSearchAuditEventRequestDetails(filter), - a2.length, - a2.map { ma => - NonUKAddressSearchAuditEventMatchedAddress( - ma.id, - ma.number, - ma.street, - ma.unit, - ma.city, - ma.district, - ma.region, - ma.postcode, - country) - })) - } } diff --git a/app/services/AddressSearchService.scala b/app/services/AddressSearchService.scala new file mode 100644 index 0000000..7bdf542 --- /dev/null +++ b/app/services/AddressSearchService.scala @@ -0,0 +1,202 @@ +/* + * Copyright 2024 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package services + +import model.address.{AddressRecord, Postcode} +import model.internal.NonUKAddress +import model.response.SupportedCountryCodes +import model._ +import play.api.Logging +import play.api.libs.json.Json +import play.api.mvc.Results._ +import play.api.mvc.{Request, Result} +import repositories.{ABPAddressRepository, NonABPAddressRepository} +import uk.gov.hmrc.http.HeaderCarrier +import uk.gov.hmrc.play.audit.http.connector.AuditConnector +import uk.gov.hmrc.play.http.HeaderCarrierConverter + +import javax.inject.Inject +import scala.concurrent.{ExecutionContext, Future} +import scala.util.Try + +class AddressSearchService@Inject()(addressSearch: ABPAddressRepository, nonABPAddressSearcher: NonABPAddressRepository, + responseProcessor: ResponseProcessor, auditConnector: AuditConnector, supportedCountryCodes: SupportedCountryCodes + )(implicit ec: ExecutionContext) extends Logging { + def searchByUprn[A](request: Request[A], uprn: String): Future[Result] = { + if (Try(uprn.toLong).isFailure) { + Future.successful { + badRequest("BAD-UPRN", "uprn" -> uprn, "error" -> s"uprn must only consist of digits") + } + } else { + import model.address.AddressRecord.formats._ + + addressSearch.findUprn(uprn).map { + a => + val a2 = responseProcessor.convertAddressList(a) + logEvent("LOOKUP", "uprn" -> uprn, "matches" -> a2.size.toString) + Ok(Json.toJson(a2)) + } + } + } + + def searchByPostcode[A](request: Request[A], postcode: Postcode, filter: Option[String]): Future[Result] = { + implicit val hc: HeaderCarrier = HeaderCarrierConverter.fromRequest(request) + + if (postcode.toString.isEmpty) { + Future.successful { + badRequest("BAD-POSTCODE", "error" -> s"missing or badly-formed $postcode parameter") + } + } else { + import model.address.AddressRecord.formats._ + addressSearch.findPostcode(postcode, filter).map { + a => + val userAgent = request.headers.get("User-Agent") + val a2 = responseProcessor.convertAddressList(a) + + if (a2.nonEmpty) { + auditAddressSearch(userAgent, a2, postcode = Some(postcode), filter = filter) + } + + logEvent("LOOKUP", a2.size, List(Some("postcode" -> postcode.toString), filter.map(f => "filter" -> f)).flatten) + Ok(Json.toJson(a2)) + } + } + } + + def searchByTown[A](request: Request[A], posttown: String, filter: Option[String]): Future[Result] = { + implicit val hc: HeaderCarrier = HeaderCarrierConverter.fromRequest(request) + + if (posttown.isEmpty) { + Future.successful { + badRequest("BAD-POSTTOWN", "error" -> s"missing or badly-formed $posttown parameter") + } + } else { + import model.address.AddressRecord.formats._ + val casedPosttown = posttown.toUpperCase + + addressSearch.findTown(casedPosttown, filter).map { + a => + val userAgent = request.headers.get("User-Agent") + val a2 = responseProcessor.convertAddressList(a) + + if (a2.nonEmpty) { + auditAddressSearch(userAgent, a2, postTown = Some(casedPosttown), filter = filter) + } + + logEvent("LOOKUP", a2.size, List(Some("posttown" -> posttown), filter.map(f => "filter" -> f)).flatten) + Ok(Json.toJson(a2)) + } + } + } + + def searchByCountry[A](userAgent: Option[String], countryCode: String, filter: String)(implicit hc: HeaderCarrier): Future[Result] = { + + if (countryCode.isEmpty || "[a-zA-Z]{2}".r.unapplySeq(countryCode).isEmpty) { + Future.successful { + badRequest("BAD-COUNTRYCODE", "error" -> s"missing or badly-formed country code") + } + } else if (supportedCountryCodes.abp.contains(countryCode)) { + Future.successful { + badRequest("ABP-COUNTRYCODE", "error" -> s"country code is abp.") + } + } else if (!supportedCountryCodes.nonAbp.contains(countryCode)) { + Future.successful { + notFound("UNSUPPORTED-COUNTRYCODE", "error" -> s"country code unsupported") + } + } else { + import model.internal.NonUKAddress._ + + nonABPAddressSearcher.findInCountry(countryCode, filter).map { + a => + auditNonUKAddressSearch(userAgent, a, countryCode, Option(filter)) + logEvent("LOOKUP", a.size, List("countryCode" -> countryCode, "filter" -> filter)) + Ok(Json.toJson(a)) + }.recover { + case e: Throwable => logEvent("LOOKUP-NONUK-ERROR", "errorMessage" -> e.getMessage) + ExpectationFailed + } + } + } + + private def auditAddressSearch[A](userAgent: Option[String], a2: List[AddressRecord], postcode: Option[Postcode] = None, + postTown: Option[String] = None, filter: Option[String] = None)(implicit hc: HeaderCarrier): Unit = { + + auditConnector.sendExplicitAudit("AddressSearch", + AddressSearchAuditEvent(userAgent, + AddressSearchAuditEventRequestDetails(postcode.map(_.toString), postTown, filter), + a2.length, + a2.map { ma => + AddressSearchAuditEventMatchedAddress( + ma.uprn.map(_.toString).getOrElse("").toString, + ma.parentUprn, + ma.usrn, + ma.organisation, + ma.address.lines, + ma.address.town, + ma.localCustodian, + ma.location, + ma.administrativeArea, + ma.poBox, + ma.address.postcode, + ma.address.subdivision, + ma.address.country) + })) + } + + private def auditNonUKAddressSearch[A](userAgent: Option[String], a2: List[NonUKAddress], country: String, + filter: Option[String] = None)(implicit hc: HeaderCarrier): Unit = { + + auditConnector.sendExplicitAudit("NonUKAddressSearch", + NonUKAddressSearchAuditEvent(userAgent, + NonUKAddressSearchAuditEventRequestDetails(filter), + a2.length, + a2.map { ma => + NonUKAddressSearchAuditEventMatchedAddress( + ma.id, + ma.number, + ma.street, + ma.unit, + ma.city, + ma.district, + ma.region, + ma.postcode, + country) + })) + } + + private final def badRequest(tag: String, data: (String, String)*): Result = { + logEvent(tag, data: _*) + BadRequest(keyVal(data.last)) + } + + private final def notFound(tag: String, data: (String, String)*): Result = { + logEvent(tag, data: _*) + NotFound(keyVal(data.last)) + } + + private final def logEvent(tag: String, data: (String, String)*): Unit = { + val formatted = data.map(keyVal).mkString(" ") + logger.info(s"$tag $formatted") + } + + private final def logEvent(tag: String, matches: Int, data: List[(String, String)]): Unit = { + logEvent(tag, ("matches" -> matches.toString) :: data: _*) + } + + + private def keyVal(item: (String, String)) = item._1 + "=" + item._2 +} diff --git a/project/AppDependencies.scala b/project/AppDependencies.scala index b3066d2..26f3a3b 100644 --- a/project/AppDependencies.scala +++ b/project/AppDependencies.scala @@ -25,5 +25,6 @@ object AppDependencies { "uk.gov.hmrc" %% "bootstrap-test-play-30" % bootstrapPlayVersion % Test, "org.pegdown" % "pegdown" % pegdownVersion % Test, "org.jsoup" % "jsoup" % "1.14.3" % Test, + "org.scalatestplus" %% "selenium-4-17" % "3.2.18.0" % Test ) } diff --git a/test/controllers/AddressSearchControllerTest.scala b/test/controllers/AddressSearchControllerTest.scala index c641b44..c328cd3 100644 --- a/test/controllers/AddressSearchControllerTest.scala +++ b/test/controllers/AddressSearchControllerTest.scala @@ -37,7 +37,7 @@ import play.api.test.FakeRequest import play.api.test.Helpers._ import play.api.{Application, inject} import repositories.{ABPAddressRepository, NonABPAddressRepository, PostgresABPAddressRepository, PostgresNonABPAddressRepository} -import services.{CheckAddressDataScheduler, ReferenceData, ResponseProcessor} +import services.{AddressSearchService, CheckAddressDataScheduler, ReferenceData, ResponseProcessor} import uk.gov.hmrc.play.audit.http.connector.AuditConnector import util.Utils._ @@ -305,7 +305,8 @@ class AddressSearchControllerTest extends AnyWordSpec with Matchers with GuiceOn val scheduler = app.injector.instanceOf[CheckAddressDataScheduler] val configHelper = app.injector.instanceOf[ConfigHelper] - val controller = new AddressSearchController(abpSearcher, nonAbpSearcher, new ResponseStub(Nil), mockAuditConnector, cc, SupportedCountryCodes(List(), List()), scheduler, configHelper) + val addressSearchService = app.injector.instanceOf[AddressSearchService] + val controller = new AddressSearchController(addressSearchService, cc, SupportedCountryCodes(List(), List()), scheduler, configHelper)(ec) val jsonPayload = Json.toJson(LookupByUprnRequest("GB0123456789")) val request = FakeRequest("POST", "/lookup/by-uprn") .withBody(jsonPayload.toString)