From 0063753a70ae02482c7248cc0d6362537d88b95f Mon Sep 17 00:00:00 2001 From: Rich Birch Date: Thu, 28 Nov 2024 12:12:34 +0000 Subject: [PATCH 1/7] DRTII-1691 Update end point to source data from agg db. Update tests --- build.sbt | 2 +- .../scala/uk/gov/homeoffice/drt/Server.scala | 28 ++- .../drt/routes/api/v1/ApiV1Routes.scala | 60 ----- .../drt/routes/api/v1/AuthApiV1Routes.scala | 11 +- .../drt/routes/api/v1/FlightApiV1Routes.scala | 179 +++++++++++++-- .../drt/routes/api/v1/QueueApiV1Routes.scala | 215 ++++++++++++++++-- .../routes/api/v1/FlightApiV1RoutesTest.scala | 52 +++-- .../routes/api/v1/QueueApiV1RoutesTest.scala | 55 +++-- 8 files changed, 450 insertions(+), 152 deletions(-) delete mode 100644 src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/ApiV1Routes.scala diff --git a/build.sbt b/build.sbt index 9c31971c..c465a283 100644 --- a/build.sbt +++ b/build.sbt @@ -1,6 +1,6 @@ import sbt.Keys.resolvers -lazy val drtLibVersion = "v972" +lazy val drtLibVersion = "v975" lazy val drtCiriumVersion = "203" lazy val akkaHttpVersion = "10.5.3" lazy val akkaVersion = "2.8.5" diff --git a/src/main/scala/uk/gov/homeoffice/drt/Server.scala b/src/main/scala/uk/gov/homeoffice/drt/Server.scala index 94a0cb13..a4e5ddd3 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/Server.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/Server.scala @@ -20,9 +20,9 @@ import uk.gov.homeoffice.drt.keycloak.KeyCloakAuth import uk.gov.homeoffice.drt.notifications._ import uk.gov.homeoffice.drt.persistence.{ExportPersistenceImpl, ScheduledHealthCheckPausePersistenceImpl} import uk.gov.homeoffice.drt.ports.Terminals.Terminal -import uk.gov.homeoffice.drt.ports.{PortCode, PortRegion} +import uk.gov.homeoffice.drt.ports._ import uk.gov.homeoffice.drt.routes._ -import uk.gov.homeoffice.drt.routes.api.v1.{AuthApiV1Routes, FlightApiV1Routes, QueueApiV1Routes} +import uk.gov.homeoffice.drt.routes.api.v1.{AuthApiV1Routes, FlightApiV1Routes, FlightExport, QueueApiV1Routes, QueueExport} import uk.gov.homeoffice.drt.services.s3.S3Service import uk.gov.homeoffice.drt.services.{PassengerSummaryStreams, UserRequestService, UserService} import uk.gov.homeoffice.drt.time.SDate @@ -88,6 +88,26 @@ object Server { ArrivalLandingTimesHealthCheck(windowLength = 2.hours, buffer = 20, minimumFlights = 3, passThresholdPercentage = 50, SDate.now), ) + private val nonMlPaxPorts = Set("ABZ", "EXT", "HUY", "INV", "LHR", "MME", "NQY", "NWI", "PIK", "SEN") + + val paxFeedSourceOrder: PortCode => List[FeedSource] = + portCode => if (!nonMlPaxPorts.contains(portCode.iata)) List( + ScenarioSimulationSource, + LiveFeedSource, + ApiFeedSource, + MlFeedSource, + ForecastFeedSource, + HistoricApiFeedSource, + AclFeedSource, + ) else List( + ScenarioSimulationSource, + LiveFeedSource, + ApiFeedSource, + ForecastFeedSource, + HistoricApiFeedSource, + AclFeedSource, + ) + def apply(config: ServerConfig, notifications: EmailNotifications, emailClient: EmailClient, @@ -133,8 +153,8 @@ object Server { concat( pathPrefix("v1") { concat( - QueueApiV1Routes(httpClient, config.enabledPorts), - FlightApiV1Routes(httpClient, config.enabledPorts), + QueueApiV1Routes(config.enabledPorts, QueueExport.queues(db)), + FlightApiV1Routes(config.enabledPorts, FlightExport.flights(db)), AuthApiV1Routes(keyCloakAuth.getToken), ) }, diff --git a/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/ApiV1Routes.scala b/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/ApiV1Routes.scala deleted file mode 100644 index c44626f3..00000000 --- a/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/ApiV1Routes.scala +++ /dev/null @@ -1,60 +0,0 @@ -package uk.gov.homeoffice.drt.routes.api.v1 - -import akka.http.scaladsl.model.HttpRequest -import akka.http.scaladsl.model.StatusCodes.InternalServerError -import akka.http.scaladsl.model.headers.RawHeader -import akka.http.scaladsl.server.Directives.{complete, onComplete} -import akka.http.scaladsl.server.Route -import akka.stream.Materializer -import akka.stream.scaladsl.{Sink, Source} -import akka.util.ByteString -import org.slf4j.LoggerFactory -import spray.json._ -import uk.gov.homeoffice.drt.HttpClient -import uk.gov.homeoffice.drt.authentication.User -import uk.gov.homeoffice.drt.ports.PortCode -import uk.gov.homeoffice.drt.routes.api.v1.AuthApiV1Routes.JsonResponse - -import scala.concurrent.ExecutionContext - -trait ApiV1Routes { - private val log = LoggerFactory.getLogger(getClass) - - - def multiPortResponse(httpClient: HttpClient, - enabledPorts: Iterable[PortCode], - email: String, - groups: String, - portUri: PortCode => String, - jsonResponse: (String, String, Seq[String]) => JsonResponse, - startStr: String, - endStr: String, - ) - (implicit ec: ExecutionContext, mat: Materializer, jsonWriter: JsonWriter[JsonResponse]): Route = { - val parallelism = 10 - - val user = User.fromRoles(email, groups) - val ports = enabledPorts.filter(user.accessiblePorts.contains(_)).toList - - val eventualContent = Source(ports) - .mapAsync(parallelism) { portCode => - val request = HttpRequest(uri = portUri(portCode), headers = Seq(RawHeader("X-Forwarded-Email", email), RawHeader("X-Forwarded-Groups", groups))) - httpClient.send(request) - } - .mapAsync(1) { response => - response.entity.dataBytes - .runFold(ByteString.empty)(_ ++ _) - .map(_.utf8String) - } - .runWith(Sink.seq) - .map(ports => jsonResponse(startStr, endStr, ports).toJson.compactPrint) - - onComplete(eventualContent) { - case scala.util.Success(content) => complete(content) - case scala.util.Failure(e) => - log.error(s"Failed to get export: ${e.getMessage}") - complete(InternalServerError) - } - } - -} diff --git a/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/AuthApiV1Routes.scala b/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/AuthApiV1Routes.scala index 2fd0077b..7d424162 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/AuthApiV1Routes.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/AuthApiV1Routes.scala @@ -4,9 +4,10 @@ import akka.http.scaladsl.model.StatusCodes.InternalServerError import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Route import org.slf4j.{Logger, LoggerFactory} -import spray.json.RootJsonFormat +import spray.json.{JsObject, RootJsonFormat} import uk.gov.homeoffice.drt.db import uk.gov.homeoffice.drt.keycloak._ +import uk.gov.homeoffice.drt.routes.api.v1.QueueExport.PortQueuesJson import scala.concurrent.{ExecutionContextExecutor, Future} import scala.util.{Failure, Success} @@ -14,14 +15,6 @@ import scala.util.{Failure, Success} object AuthApiV1Routes extends db.UserAccessRequestJsonSupport with KeyCloakAuthTokenParserProtocol { val log: Logger = LoggerFactory.getLogger(getClass) - trait JsonResponse { - def startTime: String - - def endTime: String - - def ports: Seq[String] - } - case class Credentials(username: String, password: String) implicit val credentialsJsonFormat: RootJsonFormat[Credentials] = jsonFormat2(Credentials) diff --git a/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1Routes.scala b/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1Routes.scala index 907a9d53..d027e2e9 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1Routes.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1Routes.scala @@ -1,49 +1,128 @@ package uk.gov.homeoffice.drt.routes.api.v1 +import akka.http.scaladsl.model.StatusCodes.InternalServerError import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Route import akka.stream.Materializer +import akka.stream.scaladsl.{Sink, Source} +import org.slf4j.LoggerFactory import spray.json._ +import uk.gov.homeoffice.drt.Server.paxFeedSourceOrder +import uk.gov.homeoffice.drt.arrivals.Arrival import uk.gov.homeoffice.drt.auth.Roles.ApiFlightAccess -import uk.gov.homeoffice.drt.ports.PortCode -import uk.gov.homeoffice.drt.routes.api.v1.AuthApiV1Routes.JsonResponse +import uk.gov.homeoffice.drt.authentication.User +import uk.gov.homeoffice.drt.db.AppDatabase +import uk.gov.homeoffice.drt.db.dao.FlightDao +import uk.gov.homeoffice.drt.ports.Terminals.Terminal +import uk.gov.homeoffice.drt.ports.config.AirportConfigs +import uk.gov.homeoffice.drt.ports.{FeedSource, PortCode} +import uk.gov.homeoffice.drt.routes.api.v1.FlightApiV1Routes.FlightJsonResponse +import uk.gov.homeoffice.drt.routes.api.v1.FlightExport.{FlightJson, PortFlightsJson, TerminalFlightsJson} import uk.gov.homeoffice.drt.routes.services.AuthByRole -import uk.gov.homeoffice.drt.{Dashboard, HttpClient} +import uk.gov.homeoffice.drt.services.AirportInfoService +import uk.gov.homeoffice.drt.time.{SDate, SDateLike} -import scala.concurrent.ExecutionContext +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Failure, Success, Try} trait FlightApiV1JsonFormats extends DefaultJsonProtocol { - implicit object jsonResponseFormat extends RootJsonFormat[JsonResponse] { + implicit object FlightJsonJsonFormat extends RootJsonFormat[FlightJson] { + override def write(obj: FlightJson): JsValue = { + val maybePax = obj.estimatedPaxCount.filter(_ > 0) + JsObject( + "code" -> obj.code.toJson, + "originPortIata" -> obj.originPortIata.toJson, + "originPortName" -> obj.originPortName.toJson, + "scheduledTime" -> SDate(obj.scheduledTime).toISOString.toJson, + "estimatedLandingTime" -> obj.estimatedLandingTime.map(SDate(_).toISOString).toJson, + "actualChocksTime" -> obj.actualChocksTime.map(SDate(_).toISOString).toJson, + "estimatedPcpStartTime" -> maybePax.flatMap(_ => obj.estimatedPcpStartTime.map(SDate(_).toISOString)).toJson, + "estimatedPcpEndTime" -> maybePax.flatMap(_ => obj.estimatedPcpEndTime.map(SDate(_).toISOString)).toJson, + "estimatedPcpPaxCount" -> obj.estimatedPaxCount.toJson, + "status" -> obj.status.toJson + ) + } + + override def read(json: JsValue): FlightJson = json match { + case JsObject(fields) => FlightJson( + fields.get("code").map(_.convertTo[String]).getOrElse(""), + fields.get("originPortIata").map(_.convertTo[String]).getOrElse(""), + fields.get("originPortName").map(_.convertTo[String]).getOrElse(""), + fields.get("scheduledTime").map(_.convertTo[Long]).getOrElse(0L), + fields.get("estimatedLandingTime").map(_.convertTo[Long]), + fields.get("actualChocksTime").map(_.convertTo[Long]), + fields.get("estimatedPcpStartTime").map(_.convertTo[Long]), + fields.get("estimatedPcpEndTime").map(_.convertTo[Long]), + fields.get("estimatedPcpPaxCount").map(_.convertTo[Int]), + fields.get("status").map(_.convertTo[String]).getOrElse(""), + ) + case unexpected => throw new Exception(s"Failed to parse FlightJson. Expected JsString. Got ${unexpected.getClass}") + } + } + + implicit val flightJsonFormat: RootJsonFormat[FlightJson] = jsonFormat10(FlightJson.apply) + + implicit object TerminalJsonFormat extends RootJsonFormat[Terminal] { + override def write(obj: Terminal): JsValue = obj.toString.toJson + + override def read(json: JsValue): Terminal = json match { + case JsString(value) => Terminal(value) + case unexpected => throw new Exception(s"Failed to parse Terminal. Expected JsString. Got ${unexpected.getClass}") + } + } + + implicit val terminalFlightsJsonFormat: RootJsonFormat[TerminalFlightsJson] = jsonFormat2(TerminalFlightsJson.apply) + + implicit object PortCodeJsonFormat extends RootJsonFormat[PortCode] { + override def write(obj: PortCode): JsValue = obj.iata.toJson + + override def read(json: JsValue): PortCode = json match { + case JsString(value) => PortCode(value) + case unexpected => throw new Exception(s"Failed to parse Terminal. Expected JsString. Got ${unexpected.getClass}") + } + } + - override def write(obj: JsonResponse): JsValue = JsObject(Map( + implicit val portFlightsJsonFormat: RootJsonFormat[PortFlightsJson] = jsonFormat2(PortFlightsJson.apply) + implicit object jsonResponseFormat extends RootJsonFormat[FlightJsonResponse] { + + override def write(obj: FlightJsonResponse): JsValue = JsObject(Map( "startTime" -> obj.startTime.toJson, "endTime" -> obj.endTime.toJson, - "ports" -> JsArray(obj.ports.map(_.parseJson).toVector), + "ports" -> obj.ports.toJson, )) - override def read(json: JsValue): JsonResponse = throw new Exception("Not implemented") + override def read(json: JsValue): FlightJsonResponse = throw new Exception("Not implemented") } } -object FlightApiV1Routes extends DefaultJsonProtocol with ApiV1Routes with FlightApiV1JsonFormats { +object FlightApiV1Routes extends DefaultJsonProtocol with FlightApiV1JsonFormats { + private val log = LoggerFactory.getLogger(getClass) - case class FlightJsonResponse(startTime: String, endTime: String, ports: Seq[String]) extends JsonResponse + case class FlightJsonResponse(startTime: String, endTime: String, ports: Seq[PortFlightsJson]) - def apply(httpClient: HttpClient, enabledPorts: Iterable[PortCode]) - (implicit ec: ExecutionContext, mat: Materializer): Route = + def apply(enabledPorts: Iterable[PortCode], + arrivalSource: Seq[PortCode] => (SDateLike, SDateLike) => Future[FlightJsonResponse]): Route = AuthByRole(ApiFlightAccess) { (get & path("flights")) { pathEnd( headerValueByName("X-Forwarded-Email") { email => headerValueByName("X-Forwarded-Groups") { groups => parameters("start", "end") { (startStr, endStr) => - val portUri: PortCode => String = - portCode => s"${Dashboard.drtInternalUriForPortCode(portCode)}/api/v1/flights?start=$startStr&end=$endStr" - val jsonResponse: (String, String, Seq[String]) => JsonResponse = - (startTime, endTime, ports) => FlightJsonResponse(startTime, endTime, ports) + val user = User.fromRoles(email, groups) + val ports = enabledPorts.filter(user.accessiblePorts.contains(_)).toList + val flights = arrivalSource(ports) + + val start = SDate(startStr) + val end = SDate(endStr) - multiPortResponse(httpClient, enabledPorts, email, groups, portUri, jsonResponse, startStr, endStr) + onComplete(flights(start, end)) { + case Success(value) => complete(value.toJson.compactPrint) + case Failure(t) => + log.error(s"Failed to get export: ${t.getMessage}") + complete(InternalServerError) + } } } } @@ -51,3 +130,69 @@ object FlightApiV1Routes extends DefaultJsonProtocol with ApiV1Routes with Fligh } } } + +object FlightExport { + case class FlightJson(code: String, + originPortIata: String, + originPortName: String, + scheduledTime: Long, + estimatedLandingTime: Option[Long], + actualChocksTime: Option[Long], + estimatedPcpStartTime: Option[Long], + estimatedPcpEndTime: Option[Long], + estimatedPaxCount: Option[Int], + status: String, + ) + + object FlightJson { + def apply(ar: Arrival) + (implicit sourceOrderPreference: List[FeedSource]): FlightJson = FlightJson( + code = ar.flightCodeString, + originPortIata = ar.Origin.iata, + originPortName = AirportInfoService.airportInfo(ar.Origin).map(_.airportName).getOrElse("n/a"), + scheduledTime = ar.Scheduled, + estimatedLandingTime = ar.Estimated, + actualChocksTime = ar.ActualChox, + estimatedPcpStartTime = Try(ar.pcpRange(sourceOrderPreference).min).toOption, + estimatedPcpEndTime = Try(ar.pcpRange(sourceOrderPreference).max).toOption, + estimatedPaxCount = ar.bestPcpPaxEstimate(sourceOrderPreference), + status = ar.displayStatus.description, + ) + } + + case class TerminalFlightsJson(terminal: Terminal, flights: Iterable[FlightJson]) + + case class PortFlightsJson(portCode: PortCode, terminals: Iterable[TerminalFlightsJson]) + + def flights(db: AppDatabase) + (implicit ec: ExecutionContext, mat: Materializer): Seq[PortCode] => (SDateLike, SDateLike) => Future[FlightJsonResponse] = + portCodes => (start, end) => { + val dates = Set(start.toLocalDate, end.toLocalDate) + val flightDao = FlightDao() + + Source(portCodes) + .mapAsync(1) { portCode => + val eventualPortFlights = AirportConfigs.confByPort(portCode).terminals.map { terminal => + implicit val sourceOrder: List[FeedSource] = paxFeedSourceOrder(portCode) + val flightsForDatesAndTerminals = flightDao.flightsForPcpDateRange(portCode, sourceOrder, db.run) + + flightsForDatesAndTerminals(dates.min, dates.max, Seq(terminal)) + .runWith(Sink.seq) + .map { r => + val relevantFlights = r.flatMap { case (_, flights) => + flights + .filter(_.apiFlight.hasPcpDuring(start, end, sourceOrder)) + .map(f => FlightJson(f.apiFlight)) + } + TerminalFlightsJson(terminal, relevantFlights) + } + } + + Future + .sequence(eventualPortFlights) + .map(PortFlightsJson(portCode, _)) + } + .runWith(Sink.seq) + .map(FlightJsonResponse(start.toISOString, end.toISOString, _)) + } +} diff --git a/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1Routes.scala b/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1Routes.scala index d782951a..6cb21bad 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1Routes.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1Routes.scala @@ -1,58 +1,227 @@ package uk.gov.homeoffice.drt.routes.api.v1 +import akka.http.scaladsl.model.StatusCodes.InternalServerError import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Route import akka.stream.Materializer +import akka.stream.scaladsl.{Sink, Source} +import org.slf4j.LoggerFactory import spray.json._ -import uk.gov.homeoffice.drt.auth.Roles.ApiQueueAccess -import uk.gov.homeoffice.drt.ports.PortCode -import uk.gov.homeoffice.drt.routes.api.v1.AuthApiV1Routes.JsonResponse +import uk.gov.homeoffice.drt.auth.Roles.{ApiFlightAccess, ApiQueueAccess} +import uk.gov.homeoffice.drt.authentication.User +import uk.gov.homeoffice.drt.db.AppDatabase +import uk.gov.homeoffice.drt.db.dao.QueueSlotDao +import uk.gov.homeoffice.drt.model.{CrunchMinute, MinuteLike} +import uk.gov.homeoffice.drt.ports.Queues.Queue +import uk.gov.homeoffice.drt.ports.Terminals.Terminal +import uk.gov.homeoffice.drt.ports.config.AirportConfigs +import uk.gov.homeoffice.drt.ports.{PortCode, Queues} import uk.gov.homeoffice.drt.routes.api.v1.QueueApiV1Routes.QueueJsonResponse +import uk.gov.homeoffice.drt.routes.api.v1.QueueExport.{PeriodJson, PortQueuesJson, QueueJson, TerminalQueuesJson} import uk.gov.homeoffice.drt.routes.services.AuthByRole -import uk.gov.homeoffice.drt.{Dashboard, HttpClient} +import uk.gov.homeoffice.drt.time.MilliDate.MillisSinceEpoch +import uk.gov.homeoffice.drt.time.{SDate, SDateLike, UtcDate} -import scala.concurrent.ExecutionContext +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Failure, Success} trait QueueApiV1JsonFormats extends DefaultJsonProtocol { - implicit object jsonResponseFormat extends RootJsonFormat[JsonResponse] { - override def write(obj: JsonResponse): JsValue = obj match { + implicit object QueueJsonFormat extends RootJsonFormat[Queue] { + override def write(obj: Queue): JsValue = obj.stringValue.toJson + + override def read(json: JsValue): Queue = json match { + case JsString(value) => Queue(value) + case unexpected => throw new Exception(s"Failed to parse Queue. Expected JsString. Got ${unexpected.getClass}") + } + } + + implicit val queueJsonFormat: RootJsonFormat[QueueJson] = jsonFormat3(QueueJson.apply) + + implicit object SDateJsonFormat extends RootJsonFormat[SDateLike] { + override def write(obj: SDateLike): JsValue = obj.toISOString.toJson + + override def read(json: JsValue): SDateLike = json match { + case JsString(value) => SDate(value) + case unexpected => throw new Exception(s"Failed to parse SDate. Expected JsNumber. Got ${unexpected.getClass}") + } + } + + implicit val periodJsonFormat: RootJsonFormat[PeriodJson] = jsonFormat2(PeriodJson.apply) + + implicit object TerminalJsonFormat extends RootJsonFormat[Terminal] { + override def write(obj: Terminal): JsValue = obj.toString.toJson + + override def read(json: JsValue): Terminal = json match { + case JsString(value) => Terminal(value) + case unexpected => throw new Exception(s"Failed to parse Terminal. Expected JsString. Got ${unexpected.getClass}") + } + } + + implicit val terminalQueuesJsonFormat: RootJsonFormat[TerminalQueuesJson] = jsonFormat2(TerminalQueuesJson.apply) + + implicit object PortCodeJsonFormat extends RootJsonFormat[PortCode] { + override def write(obj: PortCode): JsValue = obj.iata.toJson + + override def read(json: JsValue): PortCode = json match { + case JsString(value) => PortCode(value) + case unexpected => throw new Exception(s"Failed to parse Terminal. Expected JsString. Got ${unexpected.getClass}") + } + } + + implicit val portQueuesJsonFormat: RootJsonFormat[PortQueuesJson] = jsonFormat2(PortQueuesJson.apply) + + implicit object jsonResponseFormat extends RootJsonFormat[QueueJsonResponse] { + + override def write(obj: QueueJsonResponse): JsValue = obj match { case obj: QueueJsonResponse => JsObject(Map( "startTime" -> obj.startTime.toJson, "endTime" -> obj.endTime.toJson, "periodLengthMinutes" -> obj.slotSizeMinutes.toJson, - "ports" -> JsArray(obj.ports.map(_.parseJson).toVector), + "ports" -> obj.ports.toJson, )) } - override def read(json: JsValue): JsonResponse = throw new Exception("Not implemented") + override def read(json: JsValue): QueueJsonResponse = throw new Exception("Not implemented") } } -object QueueApiV1Routes extends DefaultJsonProtocol with ApiV1Routes with QueueApiV1JsonFormats { - case class QueueJsonResponse(startTime: String, endTime: String, slotSizeMinutes: Int, ports: Seq[String]) extends JsonResponse - def apply(httpClient: HttpClient, enabledPorts: Iterable[PortCode]) - (implicit ec: ExecutionContext, mat: Materializer): Route = + +object QueueApiV1Routes extends DefaultJsonProtocol with QueueApiV1JsonFormats { + private val log = LoggerFactory.getLogger(getClass) + + case class QueueJsonResponse(startTime: String, endTime: String, slotSizeMinutes: Int, ports: Seq[PortQueuesJson]) + + def apply(enabledPorts: Iterable[PortCode], + arrivalSource: (Seq[PortCode], Int) => (SDateLike, SDateLike) => Future[QueueJsonResponse]): Route = AuthByRole(ApiQueueAccess) { (get & path("queues")) { - pathEnd { + pathEnd( headerValueByName("X-Forwarded-Email") { email => headerValueByName("X-Forwarded-Groups") { groups => - val defaultSlotSizeMinutes = 15 - - parameters("start", "end", "slot-size-minutes".as[Int].withDefault(defaultSlotSizeMinutes)) { (startStr, endStr, slotSizeMinutes) => - val portUri: PortCode => String = - portCode => s"${Dashboard.drtInternalUriForPortCode(portCode)}/api/v1/queues?start=$startStr&end=$endStr&period-minutes=$slotSizeMinutes" + parameters("start", "end", "period-minutes".optional) { (startStr, endStr, maybePeriodMinutes) => + val defaultSlotSizeMinutes = 15 + val slotSize = maybePeriodMinutes.map(_.toInt).getOrElse(defaultSlotSizeMinutes) + val user = User.fromRoles(email, groups) + val ports = enabledPorts.filter(user.accessiblePorts.contains(_)).toList + val queuesJson = arrivalSource(ports, slotSize) - val jsonResponse: (String, String, Seq[String]) => QueueJsonResponse = - (startTime, endTime, ports) => QueueJsonResponse(startTime, endTime, slotSizeMinutes, ports) + val start = SDate(startStr) + val end = SDate(endStr) - multiPortResponse(httpClient, enabledPorts, email, groups, portUri, jsonResponse, startStr, endStr) + onComplete(queuesJson(start, end)) { + case Success(value) => complete(value.toJson.compactPrint) + case Failure(t) => + log.error(s"Failed to get export: ${t.getMessage}") + complete(InternalServerError) + } } } } - } + ) } } } + +object QueueExport { + + case class QueueJson(queue: Queue, incomingPax: Int, maxWaitMinutes: Int) + + object QueueJson { + def apply(cm: CrunchMinute): QueueJson = QueueJson(cm.queue, cm.paxLoad.toInt, cm.waitTime) + } + + case class PeriodJson(startTime: SDateLike, queues: Iterable[QueueJson]) + + case class TerminalQueuesJson(terminal: Terminal, periods: Iterable[PeriodJson]) + + case class PortQueuesJson(portCode: PortCode, terminals: Iterable[TerminalQueuesJson]) + + def queues(db: AppDatabase) + (implicit ec: ExecutionContext, mat: Materializer): (Seq[PortCode], Int) => (SDateLike, SDateLike) => Future[QueueJsonResponse] = + (portCodes, slotSize) => (start, end) => { + if (slotSize % 15 != 0) throw new IllegalArgumentException(s"Slot size must be a multiple of 15 minutes. Got $slotSize") + val groupSize = slotSize / 15 + + val dates = Set(start.toLocalDate, end.toLocalDate) + val dao = QueueSlotDao() + + Source(portCodes) + .mapAsync(1) { portCode => + val eventualPortQueueSlots = AirportConfigs.confByPort(portCode).terminals.map { terminal => + val queueSlotsForDatesAndTerminals = dao.queueSlotsForDateRange(portCode, db.run) + + queueSlotsForDatesAndTerminals(dates.min, dates.max, Seq(terminal)) + .runWith(Sink.seq) + .map { r: Seq[(UtcDate, Seq[CrunchMinute])] => + val periodJsons = r.flatMap { + case (_, mins) => + val byMinute = terminalMinutesByMinute(mins, terminal) + val grouped = groupCrunchMinutesBy(groupSize)(byMinute, terminal, Queues.queueOrder) + grouped.map { + case (minute, queueMinutes) => + val queues = queueMinutes.map(QueueJson.apply) + PeriodJson(SDate(minute), queues) + } + } + TerminalQueuesJson(terminal, periodJsons) + } + } + + Future + .sequence(eventualPortQueueSlots) + .map(PortQueuesJson(portCode, _)) + } + .runWith(Sink.seq) + .map(QueueJsonResponse(start.toString(), end.toString(), slotSize, _)) + } + + def terminalMinutesByMinute[T <: MinuteLike[_, _]](minutes: Seq[T], + terminalName: Terminal): Seq[(MillisSinceEpoch, Seq[T])] = + minutes + .filter(_.terminal == terminalName) + .groupBy(_.minute) + .toList + .sortBy(_._1) + + + def groupCrunchMinutesBy(groupSize: Int) + (crunchMinutes: Seq[(MillisSinceEpoch, Seq[CrunchMinute])], + terminalName: Terminal, + queueOrder: Seq[Queue], + ): Seq[(MillisSinceEpoch, Seq[CrunchMinute])] = + crunchMinutes.grouped(groupSize).toList.map(group => { + val byQueueName = group.flatMap(_._2).groupBy(_.queue) + val startMinute = group.map(_._1).min + val queueCrunchMinutes = queueOrder.collect { + case qn if byQueueName.contains(qn) => + val queueMinutes: Seq[CrunchMinute] = byQueueName(qn) + val allActDesks = queueMinutes.collect { + case cm: CrunchMinute if cm.actDesks.isDefined => cm.actDesks.getOrElse(0) + } + val actDesks = if (allActDesks.isEmpty) None else Option(allActDesks.max) + val allActWaits = queueMinutes.collect { + case cm: CrunchMinute if cm.actWait.isDefined => cm.actWait.getOrElse(0) + } + val actWaits = if (allActWaits.isEmpty) None else Option(allActWaits.max) + CrunchMinute( + terminal = terminalName, + queue = qn, + minute = startMinute, + paxLoad = queueMinutes.map(_.paxLoad).sum, + workLoad = queueMinutes.map(_.workLoad).sum, + deskRec = queueMinutes.map(_.deskRec).max, + waitTime = queueMinutes.map(_.waitTime).max, + maybePaxInQueue = queueMinutes.map(_.maybePaxInQueue).max, + deployedDesks = Option(queueMinutes.map(_.deployedDesks.getOrElse(0)).max), + deployedWait = Option(queueMinutes.map(_.deployedWait.getOrElse(0)).max), + maybeDeployedPaxInQueue = Option(queueMinutes.map(_.maybeDeployedPaxInQueue.getOrElse(0)).max), + actDesks = actDesks, + actWait = actWaits + ) + } + (startMinute, queueCrunchMinutes) + }) + +} diff --git a/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1RoutesTest.scala b/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1RoutesTest.scala index 4cd52824..5b2afb3a 100644 --- a/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1RoutesTest.scala +++ b/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1RoutesTest.scala @@ -8,11 +8,10 @@ import akka.stream.Materializer import akka.testkit.TestProbe import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec -import spray.json.enrichAny import uk.gov.homeoffice.drt.ports.PortCode -import uk.gov.homeoffice.drt.routes.api.v1.AuthApiV1Routes.JsonResponse -import uk.gov.homeoffice.drt.routes.api.v1.RouteTestHelper.requestPortAndUriExist -import uk.gov.homeoffice.drt.{MockHttpClient, ProdHttpClient} +import uk.gov.homeoffice.drt.ports.Terminals.T2 +import uk.gov.homeoffice.drt.routes.api.v1.FlightApiV1Routes.FlightJsonResponse +import uk.gov.homeoffice.drt.routes.api.v1.FlightExport.{FlightJson, PortFlightsJson, TerminalFlightsJson} import scala.concurrent.{ExecutionContextExecutor, Future} @@ -24,11 +23,26 @@ class FlightApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRout val start = "2024-10-20T10:00" val end = "2024-10-20T12:00" + val flightJson: FlightJson = FlightJson( + "BA0001", + "LHR", + "Heathrow", + 1600000000000L, + Some(1600000000000L), + Some(1600000000000L), + Some(1600000000000L), + Some(1600000000000L), + Option(100), + "scheduled" + ) + val terminalFlightJson: TerminalFlightsJson = TerminalFlightsJson(T2, Seq(flightJson)) + val portFlightJsonLhr: PortFlightsJson = PortFlightsJson(PortCode("LHR"), Seq(terminalFlightJson)) + val portFlightJsonStn: PortFlightsJson = PortFlightsJson(PortCode("STN"), Seq(terminalFlightJson)) + "Given a request for the flight status, I should see a JSON response containing the flight status" in { - val portContent = """["some content"]""" val routes = FlightApiV1Routes( - httpClient = MockHttpClient(() => portContent), enabledPorts = Seq(PortCode("LHR"), PortCode("LGW")), + arrivalSource = _ => (_, _) => Future.successful(FlightJsonResponse(start, end, Seq(portFlightJsonLhr, portFlightJsonLhr))), ) Get("/flights?start=" + start + "&end=" + end) ~> @@ -36,15 +50,15 @@ class FlightApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRout RawHeader("X-Forwarded-Email", "my@email.com") ~> routes ~> check { - val expected: JsonResponse = FlightApiV1Routes.FlightJsonResponse(start, end, Seq(portContent, portContent)) + val expected = FlightApiV1Routes.FlightJsonResponse(start, end, Seq(portFlightJsonLhr, portFlightJsonLhr)) responseAs[String] shouldEqual expected.toJson.compactPrint } } "Given a failed response from a port the response status should be 500" in { val routes = FlightApiV1Routes( - httpClient = ProdHttpClient(_ => Future.failed(new RuntimeException("Failed to connect"))), enabledPorts = Seq(PortCode("LHR"), PortCode("LGW")), + arrivalSource = _ => (_, _) => Future.failed(new Exception("Failed to get flights")), ) Get("/flights?start=" + start + "&end=" + end) ~> @@ -56,23 +70,29 @@ class FlightApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRout } } - "Given a request from a user with access to some ports that are not enabled, the response should only contain the enabled ports" in { - val probe = TestProbe("flightApiV1Routes") - val portContent = """["some content"]""" - val routes = FlightApiV1Routes(httpClient = MockHttpClient(() => portContent, maybeProbe = Option(probe)), enabledPorts = Seq(PortCode("LHR"))) + "Given a request from a user with access to some ports that are not enabled, only the enabled ports should be passed to the source function" in { + val probe = TestProbe("flight-source") + val routes = FlightApiV1Routes( + enabledPorts = Seq(PortCode("LHR")), + arrivalSource = portCodes => (_, _) => { + probe.ref ! portCodes + Future.successful(FlightJsonResponse(start, end, Seq.empty)) + }, + ) Get("/flights?start=" + start + "&end=" + end) ~> RawHeader("X-Forwarded-Groups", "LHR,LGW,STN,api-flight-access") ~> RawHeader("X-Forwarded-Email", "my@email.com") ~> routes ~> check { - - requestPortAndUriExist(probe, "lhr", s"start=$start&end=$end") + probe.expectMsg(Seq(PortCode("LHR"))) } } "Given a request from a user without access to the flight api, the response should be 403" in { - val portContent = """["some content"]""" - val routes = FlightApiV1Routes(httpClient = MockHttpClient(() => portContent), enabledPorts = Seq(PortCode("LHR"))) + val routes = FlightApiV1Routes( + enabledPorts = Seq(PortCode("LHR")), + arrivalSource = _ => (_, _) => Future.successful(FlightJsonResponse(start, end, Seq.empty)), + ) Get("/flights?start=" + start + "&end=" + end) ~> RawHeader("X-Forwarded-Groups", "LHR") ~> diff --git a/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1RoutesTest.scala b/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1RoutesTest.scala index 68f7bc7f..025f5731 100644 --- a/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1RoutesTest.scala +++ b/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1RoutesTest.scala @@ -9,10 +9,11 @@ import akka.testkit.TestProbe import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec import spray.json.enrichAny -import uk.gov.homeoffice.drt.ports.PortCode -import uk.gov.homeoffice.drt.routes.api.v1.AuthApiV1Routes.JsonResponse -import uk.gov.homeoffice.drt.routes.api.v1.RouteTestHelper.requestPortAndUriExist -import uk.gov.homeoffice.drt.{MockHttpClient, ProdHttpClient} +import uk.gov.homeoffice.drt.ports.Terminals.T2 +import uk.gov.homeoffice.drt.ports.{PortCode, Queues} +import uk.gov.homeoffice.drt.routes.api.v1.QueueApiV1Routes.QueueJsonResponse +import uk.gov.homeoffice.drt.routes.api.v1.QueueExport.{PeriodJson, PortQueuesJson, QueueJson, TerminalQueuesJson} +import uk.gov.homeoffice.drt.time.SDate import scala.concurrent.{ExecutionContextExecutor, Future} @@ -24,19 +25,24 @@ class QueueApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRoute val start = "2024-10-20T10:00" val end = "2024-10-20T12:00" + val queueJson: QueueJson = QueueJson(Queues.EeaDesk, 100, 10) + val periodJson: PeriodJson = PeriodJson(SDate(start), Seq(queueJson)) + val terminalQueueJson: TerminalQueuesJson = TerminalQueuesJson(T2, Seq(periodJson)) + val portQueueJsonLhr: PortQueuesJson = PortQueuesJson(PortCode("LHR"), Seq(terminalQueueJson)) + val portQueueJsonStn: PortQueuesJson = PortQueuesJson(PortCode("STN"), Seq(terminalQueueJson)) + val defaultSlotSizeMinutes = 15 + "Given a request for the queue status, I should see a JSON response containing the queue status" in { - val portContent = """["some content"]""" val routes = QueueApiV1Routes( - httpClient = MockHttpClient(() => portContent), enabledPorts = Seq(PortCode("LHR"), PortCode("LGW")), + arrivalSource = (_, _) => (_, _) => Future.successful(QueueJsonResponse(start, end, defaultSlotSizeMinutes, Seq(portQueueJsonLhr, portQueueJsonLhr))), ) Get("/queues?start=" + start + "&end=" + end) ~> RawHeader("X-Forwarded-Groups", "LHR,LGW,api-queue-access") ~> RawHeader("X-Forwarded-Email", "my@email.com") ~> routes ~> check { - val defaultSlotSizeMinutes = 15 - val expected: JsonResponse = QueueApiV1Routes.QueueJsonResponse(start, end, defaultSlotSizeMinutes, Seq(portContent, portContent)) + val expected = QueueApiV1Routes.QueueJsonResponse(start, end, defaultSlotSizeMinutes, Seq(portQueueJsonLhr, portQueueJsonLhr)) responseAs[String] shouldEqual expected.toJson.compactPrint } @@ -44,27 +50,26 @@ class QueueApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRoute "Given a request without the optional slot-size-minutes parameter, the default slot size should be 15 minutes" in { val probe = TestProbe("queueApiV1Routes") - val portContent = """["some content"]""" val routes = QueueApiV1Routes( - httpClient = MockHttpClient(() => portContent, maybeProbe = Option(probe)), enabledPorts = Seq(PortCode("LHR"), PortCode("LGW")), + arrivalSource = (_, slotSize) => (_, _) => { + probe.ref ! slotSize + Future.successful(QueueJsonResponse(start, end, defaultSlotSizeMinutes, Seq(portQueueJsonLhr, portQueueJsonLhr))) + }, ) Get("/queues?start=" + start + "&end=" + end) ~> RawHeader("X-Forwarded-Groups", "LHR,LGW,api-queue-access") ~> RawHeader("X-Forwarded-Email", "my@email.com") ~> routes ~> check { - val defaultSlotSizeMinutes = "15" - - requestPortAndUriExist(probe, "lhr", s"start=$start&end=$end&period-minutes=$defaultSlotSizeMinutes") - requestPortAndUriExist(probe, "lgw", s"start=$start&end=$end&period-minutes=$defaultSlotSizeMinutes") + probe.expectMsg(defaultSlotSizeMinutes) } } "Given a failed response from a port the response status should be 500" in { val routes = QueueApiV1Routes( - httpClient = ProdHttpClient(_ => Future.failed(new RuntimeException("Failed to connect"))), enabledPorts = Seq(PortCode("LHR"), PortCode("LGW")), + arrivalSource = (_, _) => (_, _) => Future.failed(new Exception("Failed to get flights")), ) Get("/queues?start=" + start + "&end=" + end) ~> @@ -76,23 +81,29 @@ class QueueApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRoute } } - "Given a request from a user with access to some ports that are not enabled, the response should only contain the enabled ports" in { + "Given a request from a user with access to some ports that are not enabled, only the enabled ports should be passed to the source function" in { val probe = TestProbe("queueApiV1Routes") - val portContent = """["some content"]""" - val routes = QueueApiV1Routes(httpClient = MockHttpClient(() => portContent, maybeProbe = Option(probe)), enabledPorts = Seq(PortCode("LHR"))) + val routes = QueueApiV1Routes( + enabledPorts = Seq(PortCode("LHR")), + arrivalSource = (portCodes, _) => (_, _) => { + probe.ref ! portCodes + Future.successful(QueueJsonResponse(start, end, defaultSlotSizeMinutes, Seq.empty)) + }, + ) Get("/queues?start=" + start + "&end=" + end) ~> RawHeader("X-Forwarded-Groups", "LHR,LGW,STN,api-queue-access") ~> RawHeader("X-Forwarded-Email", "my@email.com") ~> routes ~> check { - - requestPortAndUriExist(probe, "lhr", s"start=$start&end=$end") + probe.expectMsg(Seq(PortCode("LHR"))) } } "Given a request from a user without access to the queue api, the response should be 403" in { - val portContent = """["some content"]""" - val routes = QueueApiV1Routes(httpClient = MockHttpClient(() => portContent), enabledPorts = Seq(PortCode("LHR"))) + val routes = QueueApiV1Routes( + enabledPorts = Seq(PortCode("LHR")), + arrivalSource = (_, _) => (_, _) => Future.successful(QueueJsonResponse(start, end, defaultSlotSizeMinutes, Seq(portQueueJsonLhr, portQueueJsonLhr))), + ) Get("/queues?start=" + start + "&end=" + end) ~> RawHeader("X-Forwarded-Groups", "LHR") ~> From 7355f53d05e31f30fd359f23a7fe5c19fa76e73d Mon Sep 17 00:00:00 2001 From: Rich Birch Date: Fri, 29 Nov 2024 13:07:09 +0000 Subject: [PATCH 2/7] DRTII-1691 Tests around queue export function --- build.sbt | 2 +- .../scala/uk/gov/homeoffice/drt/Server.scala | 18 +- .../drt/routes/api/v1/AuthApiV1Routes.scala | 3 +- .../drt/routes/api/v1/FlightApiV1Routes.scala | 162 +-------------- .../drt/routes/api/v1/QueueApiV1Routes.scala | 191 +----------------- .../drt/services/api/v1/FlightExport.scala | 83 ++++++++ .../drt/services/api/v1/QueueExport.scala | 119 +++++++++++ .../serialiser/FlightApiV1JsonFormats.scala | 79 ++++++++ .../v1/serialiser/QueueApiV1JsonFormats.scala | 70 +++++++ .../routes/api/v1/FlightApiV1RoutesTest.scala | 11 +- .../routes/api/v1/QueueApiV1RoutesTest.scala | 13 +- .../drt/services/api/v1/QueueExportTest.scala | 48 +++++ 12 files changed, 445 insertions(+), 354 deletions(-) create mode 100644 src/main/scala/uk/gov/homeoffice/drt/services/api/v1/FlightExport.scala create mode 100644 src/main/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExport.scala create mode 100644 src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/FlightApiV1JsonFormats.scala create mode 100644 src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/QueueApiV1JsonFormats.scala create mode 100644 src/test/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExportTest.scala diff --git a/build.sbt b/build.sbt index c465a283..5a315a92 100644 --- a/build.sbt +++ b/build.sbt @@ -1,6 +1,6 @@ import sbt.Keys.resolvers -lazy val drtLibVersion = "v975" +lazy val drtLibVersion = "v976" lazy val drtCiriumVersion = "203" lazy val akkaHttpVersion = "10.5.3" lazy val akkaVersion = "2.8.5" diff --git a/src/main/scala/uk/gov/homeoffice/drt/Server.scala b/src/main/scala/uk/gov/homeoffice/drt/Server.scala index a4e5ddd3..879279d6 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/Server.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/Server.scala @@ -1,5 +1,6 @@ package uk.gov.homeoffice.drt +import akka.NotUsed import akka.actor.Cancellable import akka.actor.typed.scaladsl.AskPattern.{Askable, schedulerFromActorSystem} import akka.actor.typed.scaladsl.{ActorContext, Behaviors} @@ -11,21 +12,25 @@ import akka.http.scaladsl.server.Directives.{concat, getFromResource, pathPrefix import akka.http.scaladsl.server.Route import akka.http.scaladsl.settings.ConnectionPoolSettings import akka.stream.Materializer +import akka.stream.scaladsl.Source import akka.util.Timeout import org.slf4j.LoggerFactory import uk.gov.homeoffice.drt.db._ -import uk.gov.homeoffice.drt.db.dao.UserFeedbackDao +import uk.gov.homeoffice.drt.db.dao.{QueueSlotDao, UserFeedbackDao} import uk.gov.homeoffice.drt.healthchecks._ import uk.gov.homeoffice.drt.keycloak.KeyCloakAuth +import uk.gov.homeoffice.drt.model.CrunchMinute import uk.gov.homeoffice.drt.notifications._ import uk.gov.homeoffice.drt.persistence.{ExportPersistenceImpl, ScheduledHealthCheckPausePersistenceImpl} import uk.gov.homeoffice.drt.ports.Terminals.Terminal import uk.gov.homeoffice.drt.ports._ import uk.gov.homeoffice.drt.routes._ -import uk.gov.homeoffice.drt.routes.api.v1.{AuthApiV1Routes, FlightApiV1Routes, FlightExport, QueueApiV1Routes, QueueExport} +import uk.gov.homeoffice.drt.routes.api.v1.QueueApiV1Routes.QueueJsonResponse +import uk.gov.homeoffice.drt.routes.api.v1.{AuthApiV1Routes, FlightApiV1Routes, QueueApiV1Routes} +import uk.gov.homeoffice.drt.services.api.v1.{FlightExport, QueueExport} import uk.gov.homeoffice.drt.services.s3.S3Service import uk.gov.homeoffice.drt.services.{PassengerSummaryStreams, UserRequestService, UserService} -import uk.gov.homeoffice.drt.time.SDate +import uk.gov.homeoffice.drt.time.{LocalDate, SDate, SDateLike, UtcDate} import uk.gov.homeoffice.drt.uploadTraining.FeatureGuideService import scala.concurrent.duration.DurationInt @@ -148,12 +153,17 @@ object Server { val keyCloakAuth = KeyCloakAuth(config.keycloakTokenUrl, config.keycloakClientId, config.keycloakClientSecret, sendHttpRequest) + val queuesForPortAndDatesAndSlotSize: (PortCode, Terminal, Int, LocalDate, LocalDate) => Source[(UtcDate, Seq[CrunchMinute]), NotUsed] = { + (port, terminal, slotSize, start, end) => + QueueSlotDao().queueSlotsForDateRange(port, slotSize, db.run)(start, end, Seq(terminal)) + } + val routes: Route = concat( pathPrefix("api") { concat( pathPrefix("v1") { concat( - QueueApiV1Routes(config.enabledPorts, QueueExport.queues(db)), + QueueApiV1Routes(config.enabledPorts, QueueExport.queues(queuesForPortAndDatesAndSlotSize)), FlightApiV1Routes(config.enabledPorts, FlightExport.flights(db)), AuthApiV1Routes(keyCloakAuth.getToken), ) diff --git a/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/AuthApiV1Routes.scala b/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/AuthApiV1Routes.scala index 7d424162..09929540 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/AuthApiV1Routes.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/AuthApiV1Routes.scala @@ -4,10 +4,9 @@ import akka.http.scaladsl.model.StatusCodes.InternalServerError import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Route import org.slf4j.{Logger, LoggerFactory} -import spray.json.{JsObject, RootJsonFormat} +import spray.json.RootJsonFormat import uk.gov.homeoffice.drt.db import uk.gov.homeoffice.drt.keycloak._ -import uk.gov.homeoffice.drt.routes.api.v1.QueueExport.PortQueuesJson import scala.concurrent.{ExecutionContextExecutor, Future} import scala.util.{Failure, Success} diff --git a/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1Routes.scala b/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1Routes.scala index d027e2e9..d51470a6 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1Routes.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1Routes.scala @@ -3,107 +3,27 @@ package uk.gov.homeoffice.drt.routes.api.v1 import akka.http.scaladsl.model.StatusCodes.InternalServerError import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Route -import akka.stream.Materializer -import akka.stream.scaladsl.{Sink, Source} import org.slf4j.LoggerFactory import spray.json._ -import uk.gov.homeoffice.drt.Server.paxFeedSourceOrder -import uk.gov.homeoffice.drt.arrivals.Arrival import uk.gov.homeoffice.drt.auth.Roles.ApiFlightAccess import uk.gov.homeoffice.drt.authentication.User -import uk.gov.homeoffice.drt.db.AppDatabase -import uk.gov.homeoffice.drt.db.dao.FlightDao -import uk.gov.homeoffice.drt.ports.Terminals.Terminal -import uk.gov.homeoffice.drt.ports.config.AirportConfigs -import uk.gov.homeoffice.drt.ports.{FeedSource, PortCode} -import uk.gov.homeoffice.drt.routes.api.v1.FlightApiV1Routes.FlightJsonResponse -import uk.gov.homeoffice.drt.routes.api.v1.FlightExport.{FlightJson, PortFlightsJson, TerminalFlightsJson} +import uk.gov.homeoffice.drt.ports.PortCode import uk.gov.homeoffice.drt.routes.services.AuthByRole -import uk.gov.homeoffice.drt.services.AirportInfoService +import uk.gov.homeoffice.drt.services.api.v1.FlightExport.PortFlightsJson +import uk.gov.homeoffice.drt.services.api.v1.serialiser.FlightApiV1JsonFormats import uk.gov.homeoffice.drt.time.{SDate, SDateLike} -import scala.concurrent.{ExecutionContext, Future} -import scala.util.{Failure, Success, Try} +import scala.concurrent.Future +import scala.util.{Failure, Success} -trait FlightApiV1JsonFormats extends DefaultJsonProtocol { - implicit object FlightJsonJsonFormat extends RootJsonFormat[FlightJson] { - override def write(obj: FlightJson): JsValue = { - val maybePax = obj.estimatedPaxCount.filter(_ > 0) - JsObject( - "code" -> obj.code.toJson, - "originPortIata" -> obj.originPortIata.toJson, - "originPortName" -> obj.originPortName.toJson, - "scheduledTime" -> SDate(obj.scheduledTime).toISOString.toJson, - "estimatedLandingTime" -> obj.estimatedLandingTime.map(SDate(_).toISOString).toJson, - "actualChocksTime" -> obj.actualChocksTime.map(SDate(_).toISOString).toJson, - "estimatedPcpStartTime" -> maybePax.flatMap(_ => obj.estimatedPcpStartTime.map(SDate(_).toISOString)).toJson, - "estimatedPcpEndTime" -> maybePax.flatMap(_ => obj.estimatedPcpEndTime.map(SDate(_).toISOString)).toJson, - "estimatedPcpPaxCount" -> obj.estimatedPaxCount.toJson, - "status" -> obj.status.toJson - ) - } - - override def read(json: JsValue): FlightJson = json match { - case JsObject(fields) => FlightJson( - fields.get("code").map(_.convertTo[String]).getOrElse(""), - fields.get("originPortIata").map(_.convertTo[String]).getOrElse(""), - fields.get("originPortName").map(_.convertTo[String]).getOrElse(""), - fields.get("scheduledTime").map(_.convertTo[Long]).getOrElse(0L), - fields.get("estimatedLandingTime").map(_.convertTo[Long]), - fields.get("actualChocksTime").map(_.convertTo[Long]), - fields.get("estimatedPcpStartTime").map(_.convertTo[Long]), - fields.get("estimatedPcpEndTime").map(_.convertTo[Long]), - fields.get("estimatedPcpPaxCount").map(_.convertTo[Int]), - fields.get("status").map(_.convertTo[String]).getOrElse(""), - ) - case unexpected => throw new Exception(s"Failed to parse FlightJson. Expected JsString. Got ${unexpected.getClass}") - } - } - - implicit val flightJsonFormat: RootJsonFormat[FlightJson] = jsonFormat10(FlightJson.apply) - - implicit object TerminalJsonFormat extends RootJsonFormat[Terminal] { - override def write(obj: Terminal): JsValue = obj.toString.toJson - - override def read(json: JsValue): Terminal = json match { - case JsString(value) => Terminal(value) - case unexpected => throw new Exception(s"Failed to parse Terminal. Expected JsString. Got ${unexpected.getClass}") - } - } - - implicit val terminalFlightsJsonFormat: RootJsonFormat[TerminalFlightsJson] = jsonFormat2(TerminalFlightsJson.apply) - - implicit object PortCodeJsonFormat extends RootJsonFormat[PortCode] { - override def write(obj: PortCode): JsValue = obj.iata.toJson - - override def read(json: JsValue): PortCode = json match { - case JsString(value) => PortCode(value) - case unexpected => throw new Exception(s"Failed to parse Terminal. Expected JsString. Got ${unexpected.getClass}") - } - } - - - implicit val portFlightsJsonFormat: RootJsonFormat[PortFlightsJson] = jsonFormat2(PortFlightsJson.apply) - implicit object jsonResponseFormat extends RootJsonFormat[FlightJsonResponse] { - - override def write(obj: FlightJsonResponse): JsValue = JsObject(Map( - "startTime" -> obj.startTime.toJson, - "endTime" -> obj.endTime.toJson, - "ports" -> obj.ports.toJson, - )) - - override def read(json: JsValue): FlightJsonResponse = throw new Exception("Not implemented") - } -} - object FlightApiV1Routes extends DefaultJsonProtocol with FlightApiV1JsonFormats { private val log = LoggerFactory.getLogger(getClass) case class FlightJsonResponse(startTime: String, endTime: String, ports: Seq[PortFlightsJson]) def apply(enabledPorts: Iterable[PortCode], - arrivalSource: Seq[PortCode] => (SDateLike, SDateLike) => Future[FlightJsonResponse]): Route = + dateRangeJsonForPorts: Seq[PortCode] => (SDateLike, SDateLike) => Future[FlightJsonResponse]): Route = AuthByRole(ApiFlightAccess) { (get & path("flights")) { pathEnd( @@ -112,12 +32,12 @@ object FlightApiV1Routes extends DefaultJsonProtocol with FlightApiV1JsonFormats parameters("start", "end") { (startStr, endStr) => val user = User.fromRoles(email, groups) val ports = enabledPorts.filter(user.accessiblePorts.contains(_)).toList - val flights = arrivalSource(ports) + val dateRangeJson = dateRangeJsonForPorts(ports) val start = SDate(startStr) val end = SDate(endStr) - onComplete(flights(start, end)) { + onComplete(dateRangeJson(start, end)) { case Success(value) => complete(value.toJson.compactPrint) case Failure(t) => log.error(s"Failed to get export: ${t.getMessage}") @@ -130,69 +50,3 @@ object FlightApiV1Routes extends DefaultJsonProtocol with FlightApiV1JsonFormats } } } - -object FlightExport { - case class FlightJson(code: String, - originPortIata: String, - originPortName: String, - scheduledTime: Long, - estimatedLandingTime: Option[Long], - actualChocksTime: Option[Long], - estimatedPcpStartTime: Option[Long], - estimatedPcpEndTime: Option[Long], - estimatedPaxCount: Option[Int], - status: String, - ) - - object FlightJson { - def apply(ar: Arrival) - (implicit sourceOrderPreference: List[FeedSource]): FlightJson = FlightJson( - code = ar.flightCodeString, - originPortIata = ar.Origin.iata, - originPortName = AirportInfoService.airportInfo(ar.Origin).map(_.airportName).getOrElse("n/a"), - scheduledTime = ar.Scheduled, - estimatedLandingTime = ar.Estimated, - actualChocksTime = ar.ActualChox, - estimatedPcpStartTime = Try(ar.pcpRange(sourceOrderPreference).min).toOption, - estimatedPcpEndTime = Try(ar.pcpRange(sourceOrderPreference).max).toOption, - estimatedPaxCount = ar.bestPcpPaxEstimate(sourceOrderPreference), - status = ar.displayStatus.description, - ) - } - - case class TerminalFlightsJson(terminal: Terminal, flights: Iterable[FlightJson]) - - case class PortFlightsJson(portCode: PortCode, terminals: Iterable[TerminalFlightsJson]) - - def flights(db: AppDatabase) - (implicit ec: ExecutionContext, mat: Materializer): Seq[PortCode] => (SDateLike, SDateLike) => Future[FlightJsonResponse] = - portCodes => (start, end) => { - val dates = Set(start.toLocalDate, end.toLocalDate) - val flightDao = FlightDao() - - Source(portCodes) - .mapAsync(1) { portCode => - val eventualPortFlights = AirportConfigs.confByPort(portCode).terminals.map { terminal => - implicit val sourceOrder: List[FeedSource] = paxFeedSourceOrder(portCode) - val flightsForDatesAndTerminals = flightDao.flightsForPcpDateRange(portCode, sourceOrder, db.run) - - flightsForDatesAndTerminals(dates.min, dates.max, Seq(terminal)) - .runWith(Sink.seq) - .map { r => - val relevantFlights = r.flatMap { case (_, flights) => - flights - .filter(_.apiFlight.hasPcpDuring(start, end, sourceOrder)) - .map(f => FlightJson(f.apiFlight)) - } - TerminalFlightsJson(terminal, relevantFlights) - } - } - - Future - .sequence(eventualPortFlights) - .map(PortFlightsJson(portCode, _)) - } - .runWith(Sink.seq) - .map(FlightJsonResponse(start.toISOString, end.toISOString, _)) - } -} diff --git a/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1Routes.scala b/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1Routes.scala index 6cb21bad..9fae6ede 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1Routes.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1Routes.scala @@ -3,90 +3,19 @@ package uk.gov.homeoffice.drt.routes.api.v1 import akka.http.scaladsl.model.StatusCodes.InternalServerError import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Route -import akka.stream.Materializer -import akka.stream.scaladsl.{Sink, Source} import org.slf4j.LoggerFactory import spray.json._ -import uk.gov.homeoffice.drt.auth.Roles.{ApiFlightAccess, ApiQueueAccess} +import uk.gov.homeoffice.drt.auth.Roles.ApiQueueAccess import uk.gov.homeoffice.drt.authentication.User -import uk.gov.homeoffice.drt.db.AppDatabase -import uk.gov.homeoffice.drt.db.dao.QueueSlotDao -import uk.gov.homeoffice.drt.model.{CrunchMinute, MinuteLike} -import uk.gov.homeoffice.drt.ports.Queues.Queue -import uk.gov.homeoffice.drt.ports.Terminals.Terminal -import uk.gov.homeoffice.drt.ports.config.AirportConfigs -import uk.gov.homeoffice.drt.ports.{PortCode, Queues} -import uk.gov.homeoffice.drt.routes.api.v1.QueueApiV1Routes.QueueJsonResponse -import uk.gov.homeoffice.drt.routes.api.v1.QueueExport.{PeriodJson, PortQueuesJson, QueueJson, TerminalQueuesJson} +import uk.gov.homeoffice.drt.ports.PortCode import uk.gov.homeoffice.drt.routes.services.AuthByRole -import uk.gov.homeoffice.drt.time.MilliDate.MillisSinceEpoch -import uk.gov.homeoffice.drt.time.{SDate, SDateLike, UtcDate} +import uk.gov.homeoffice.drt.services.api.v1.QueueExport.PortQueuesJson +import uk.gov.homeoffice.drt.services.api.v1.serialiser.QueueApiV1JsonFormats +import uk.gov.homeoffice.drt.time.{SDate, SDateLike} -import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.Future import scala.util.{Failure, Success} -trait QueueApiV1JsonFormats extends DefaultJsonProtocol { - - implicit object QueueJsonFormat extends RootJsonFormat[Queue] { - override def write(obj: Queue): JsValue = obj.stringValue.toJson - - override def read(json: JsValue): Queue = json match { - case JsString(value) => Queue(value) - case unexpected => throw new Exception(s"Failed to parse Queue. Expected JsString. Got ${unexpected.getClass}") - } - } - - implicit val queueJsonFormat: RootJsonFormat[QueueJson] = jsonFormat3(QueueJson.apply) - - implicit object SDateJsonFormat extends RootJsonFormat[SDateLike] { - override def write(obj: SDateLike): JsValue = obj.toISOString.toJson - - override def read(json: JsValue): SDateLike = json match { - case JsString(value) => SDate(value) - case unexpected => throw new Exception(s"Failed to parse SDate. Expected JsNumber. Got ${unexpected.getClass}") - } - } - - implicit val periodJsonFormat: RootJsonFormat[PeriodJson] = jsonFormat2(PeriodJson.apply) - - implicit object TerminalJsonFormat extends RootJsonFormat[Terminal] { - override def write(obj: Terminal): JsValue = obj.toString.toJson - - override def read(json: JsValue): Terminal = json match { - case JsString(value) => Terminal(value) - case unexpected => throw new Exception(s"Failed to parse Terminal. Expected JsString. Got ${unexpected.getClass}") - } - } - - implicit val terminalQueuesJsonFormat: RootJsonFormat[TerminalQueuesJson] = jsonFormat2(TerminalQueuesJson.apply) - - implicit object PortCodeJsonFormat extends RootJsonFormat[PortCode] { - override def write(obj: PortCode): JsValue = obj.iata.toJson - - override def read(json: JsValue): PortCode = json match { - case JsString(value) => PortCode(value) - case unexpected => throw new Exception(s"Failed to parse Terminal. Expected JsString. Got ${unexpected.getClass}") - } - } - - implicit val portQueuesJsonFormat: RootJsonFormat[PortQueuesJson] = jsonFormat2(PortQueuesJson.apply) - - implicit object jsonResponseFormat extends RootJsonFormat[QueueJsonResponse] { - - override def write(obj: QueueJsonResponse): JsValue = obj match { - case obj: QueueJsonResponse => JsObject(Map( - "startTime" -> obj.startTime.toJson, - "endTime" -> obj.endTime.toJson, - "periodLengthMinutes" -> obj.slotSizeMinutes.toJson, - "ports" -> obj.ports.toJson, - )) - } - - override def read(json: JsValue): QueueJsonResponse = throw new Exception("Not implemented") - } -} - - object QueueApiV1Routes extends DefaultJsonProtocol with QueueApiV1JsonFormats { private val log = LoggerFactory.getLogger(getClass) @@ -94,7 +23,7 @@ object QueueApiV1Routes extends DefaultJsonProtocol with QueueApiV1JsonFormats { case class QueueJsonResponse(startTime: String, endTime: String, slotSizeMinutes: Int, ports: Seq[PortQueuesJson]) def apply(enabledPorts: Iterable[PortCode], - arrivalSource: (Seq[PortCode], Int) => (SDateLike, SDateLike) => Future[QueueJsonResponse]): Route = + dateRangeJsonForPortsAndSlotSize: (Seq[PortCode], Int) => (SDateLike, SDateLike) => Future[QueueJsonResponse]): Route = AuthByRole(ApiQueueAccess) { (get & path("queues")) { pathEnd( @@ -105,12 +34,12 @@ object QueueApiV1Routes extends DefaultJsonProtocol with QueueApiV1JsonFormats { val slotSize = maybePeriodMinutes.map(_.toInt).getOrElse(defaultSlotSizeMinutes) val user = User.fromRoles(email, groups) val ports = enabledPorts.filter(user.accessiblePorts.contains(_)).toList - val queuesJson = arrivalSource(ports, slotSize) + val dateRangeJson = dateRangeJsonForPortsAndSlotSize(ports, slotSize) val start = SDate(startStr) val end = SDate(endStr) - onComplete(queuesJson(start, end)) { + onComplete(dateRangeJson(start, end)) { case Success(value) => complete(value.toJson.compactPrint) case Failure(t) => log.error(s"Failed to get export: ${t.getMessage}") @@ -123,105 +52,3 @@ object QueueApiV1Routes extends DefaultJsonProtocol with QueueApiV1JsonFormats { } } } - -object QueueExport { - - case class QueueJson(queue: Queue, incomingPax: Int, maxWaitMinutes: Int) - - object QueueJson { - def apply(cm: CrunchMinute): QueueJson = QueueJson(cm.queue, cm.paxLoad.toInt, cm.waitTime) - } - - case class PeriodJson(startTime: SDateLike, queues: Iterable[QueueJson]) - - case class TerminalQueuesJson(terminal: Terminal, periods: Iterable[PeriodJson]) - - case class PortQueuesJson(portCode: PortCode, terminals: Iterable[TerminalQueuesJson]) - - def queues(db: AppDatabase) - (implicit ec: ExecutionContext, mat: Materializer): (Seq[PortCode], Int) => (SDateLike, SDateLike) => Future[QueueJsonResponse] = - (portCodes, slotSize) => (start, end) => { - if (slotSize % 15 != 0) throw new IllegalArgumentException(s"Slot size must be a multiple of 15 minutes. Got $slotSize") - val groupSize = slotSize / 15 - - val dates = Set(start.toLocalDate, end.toLocalDate) - val dao = QueueSlotDao() - - Source(portCodes) - .mapAsync(1) { portCode => - val eventualPortQueueSlots = AirportConfigs.confByPort(portCode).terminals.map { terminal => - val queueSlotsForDatesAndTerminals = dao.queueSlotsForDateRange(portCode, db.run) - - queueSlotsForDatesAndTerminals(dates.min, dates.max, Seq(terminal)) - .runWith(Sink.seq) - .map { r: Seq[(UtcDate, Seq[CrunchMinute])] => - val periodJsons = r.flatMap { - case (_, mins) => - val byMinute = terminalMinutesByMinute(mins, terminal) - val grouped = groupCrunchMinutesBy(groupSize)(byMinute, terminal, Queues.queueOrder) - grouped.map { - case (minute, queueMinutes) => - val queues = queueMinutes.map(QueueJson.apply) - PeriodJson(SDate(minute), queues) - } - } - TerminalQueuesJson(terminal, periodJsons) - } - } - - Future - .sequence(eventualPortQueueSlots) - .map(PortQueuesJson(portCode, _)) - } - .runWith(Sink.seq) - .map(QueueJsonResponse(start.toString(), end.toString(), slotSize, _)) - } - - def terminalMinutesByMinute[T <: MinuteLike[_, _]](minutes: Seq[T], - terminalName: Terminal): Seq[(MillisSinceEpoch, Seq[T])] = - minutes - .filter(_.terminal == terminalName) - .groupBy(_.minute) - .toList - .sortBy(_._1) - - - def groupCrunchMinutesBy(groupSize: Int) - (crunchMinutes: Seq[(MillisSinceEpoch, Seq[CrunchMinute])], - terminalName: Terminal, - queueOrder: Seq[Queue], - ): Seq[(MillisSinceEpoch, Seq[CrunchMinute])] = - crunchMinutes.grouped(groupSize).toList.map(group => { - val byQueueName = group.flatMap(_._2).groupBy(_.queue) - val startMinute = group.map(_._1).min - val queueCrunchMinutes = queueOrder.collect { - case qn if byQueueName.contains(qn) => - val queueMinutes: Seq[CrunchMinute] = byQueueName(qn) - val allActDesks = queueMinutes.collect { - case cm: CrunchMinute if cm.actDesks.isDefined => cm.actDesks.getOrElse(0) - } - val actDesks = if (allActDesks.isEmpty) None else Option(allActDesks.max) - val allActWaits = queueMinutes.collect { - case cm: CrunchMinute if cm.actWait.isDefined => cm.actWait.getOrElse(0) - } - val actWaits = if (allActWaits.isEmpty) None else Option(allActWaits.max) - CrunchMinute( - terminal = terminalName, - queue = qn, - minute = startMinute, - paxLoad = queueMinutes.map(_.paxLoad).sum, - workLoad = queueMinutes.map(_.workLoad).sum, - deskRec = queueMinutes.map(_.deskRec).max, - waitTime = queueMinutes.map(_.waitTime).max, - maybePaxInQueue = queueMinutes.map(_.maybePaxInQueue).max, - deployedDesks = Option(queueMinutes.map(_.deployedDesks.getOrElse(0)).max), - deployedWait = Option(queueMinutes.map(_.deployedWait.getOrElse(0)).max), - maybeDeployedPaxInQueue = Option(queueMinutes.map(_.maybeDeployedPaxInQueue.getOrElse(0)).max), - actDesks = actDesks, - actWait = actWaits - ) - } - (startMinute, queueCrunchMinutes) - }) - -} diff --git a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/FlightExport.scala b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/FlightExport.scala new file mode 100644 index 00000000..31b326a8 --- /dev/null +++ b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/FlightExport.scala @@ -0,0 +1,83 @@ +package uk.gov.homeoffice.drt.services.api.v1 + +import akka.stream.Materializer +import akka.stream.scaladsl.{Sink, Source} +import uk.gov.homeoffice.drt.Server.paxFeedSourceOrder +import uk.gov.homeoffice.drt.arrivals.Arrival +import uk.gov.homeoffice.drt.db.AppDatabase +import uk.gov.homeoffice.drt.db.dao.FlightDao +import uk.gov.homeoffice.drt.ports.{FeedSource, PortCode} +import uk.gov.homeoffice.drt.ports.Terminals.Terminal +import uk.gov.homeoffice.drt.ports.config.AirportConfigs +import uk.gov.homeoffice.drt.routes.api.v1.FlightApiV1Routes.FlightJsonResponse +import uk.gov.homeoffice.drt.services.AirportInfoService +import uk.gov.homeoffice.drt.time.SDateLike + +import scala.concurrent.{ExecutionContext, Future} +import scala.util.Try + +object FlightExport { + case class FlightJson(code: String, + originPortIata: String, + originPortName: String, + scheduledTime: Long, + estimatedLandingTime: Option[Long], + actualChocksTime: Option[Long], + estimatedPcpStartTime: Option[Long], + estimatedPcpEndTime: Option[Long], + estimatedPaxCount: Option[Int], + status: String, + ) + + object FlightJson { + def apply(ar: Arrival) + (implicit sourceOrderPreference: List[FeedSource]): FlightJson = FlightJson( + code = ar.flightCodeString, + originPortIata = ar.Origin.iata, + originPortName = AirportInfoService.airportInfo(ar.Origin).map(_.airportName).getOrElse("n/a"), + scheduledTime = ar.Scheduled, + estimatedLandingTime = ar.Estimated, + actualChocksTime = ar.ActualChox, + estimatedPcpStartTime = Try(ar.pcpRange(sourceOrderPreference).min).toOption, + estimatedPcpEndTime = Try(ar.pcpRange(sourceOrderPreference).max).toOption, + estimatedPaxCount = ar.bestPcpPaxEstimate(sourceOrderPreference), + status = ar.displayStatus.description, + ) + } + + case class TerminalFlightsJson(terminal: Terminal, flights: Iterable[FlightJson]) + + case class PortFlightsJson(portCode: PortCode, terminals: Iterable[TerminalFlightsJson]) + + def flights(db: AppDatabase) + (implicit ec: ExecutionContext, mat: Materializer): Seq[PortCode] => (SDateLike, SDateLike) => Future[FlightJsonResponse] = + portCodes => (start, end) => { + val dates = Set(start.toLocalDate, end.toLocalDate) + val flightDao = FlightDao() + + Source(portCodes) + .mapAsync(1) { portCode => + val eventualPortFlights = AirportConfigs.confByPort(portCode).terminals.map { terminal => + implicit val sourceOrder: List[FeedSource] = paxFeedSourceOrder(portCode) + val flightsForDatesAndTerminals = flightDao.flightsForPcpDateRange(portCode, sourceOrder, db.run) + + flightsForDatesAndTerminals(dates.min, dates.max, Seq(terminal)) + .runWith(Sink.seq) + .map { r => + val relevantFlights = r.flatMap { case (_, flights) => + flights + .filter(_.apiFlight.hasPcpDuring(start, end, sourceOrder)) + .map(f => FlightJson(f.apiFlight)) + } + TerminalFlightsJson(terminal, relevantFlights) + } + } + + Future + .sequence(eventualPortFlights) + .map(PortFlightsJson(portCode, _)) + } + .runWith(Sink.seq) + .map(FlightJsonResponse(start.toISOString, end.toISOString, _)) + } +} diff --git a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExport.scala b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExport.scala new file mode 100644 index 00000000..4095db54 --- /dev/null +++ b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExport.scala @@ -0,0 +1,119 @@ +package uk.gov.homeoffice.drt.services.api.v1 + +import akka.NotUsed +import akka.stream.Materializer +import akka.stream.scaladsl.{Sink, Source} +import uk.gov.homeoffice.drt.model.{CrunchMinute, MinuteLike} +import uk.gov.homeoffice.drt.ports.Queues.Queue +import uk.gov.homeoffice.drt.ports.Terminals.Terminal +import uk.gov.homeoffice.drt.ports.config.AirportConfigs +import uk.gov.homeoffice.drt.ports.{PortCode, Queues} +import uk.gov.homeoffice.drt.routes.api.v1.QueueApiV1Routes.QueueJsonResponse +import uk.gov.homeoffice.drt.time.MilliDate.MillisSinceEpoch +import uk.gov.homeoffice.drt.time.{LocalDate, SDate, SDateLike, UtcDate} + +import scala.concurrent.{ExecutionContext, Future} + +object QueueExport { + + case class QueueJson(queue: Queue, incomingPax: Int, maxWaitMinutes: Int) + + object QueueJson { + def apply(cm: CrunchMinute): QueueJson = QueueJson(cm.queue, cm.paxLoad.toInt, cm.waitTime) + } + + case class PeriodJson(startTime: SDateLike, queues: Iterable[QueueJson]) + + case class TerminalQueuesJson(terminal: Terminal, periods: Iterable[PeriodJson]) + + case class PortQueuesJson(portCode: PortCode, terminals: Iterable[TerminalQueuesJson]) + + def queues(queuesForPortAndDatesAndSlotSize: (PortCode, Terminal, Int, LocalDate, LocalDate) => Source[(UtcDate, Seq[CrunchMinute]), NotUsed]) + (implicit ec: ExecutionContext, mat: Materializer): (Seq[PortCode], Int) => (SDateLike, SDateLike) => Future[QueueJsonResponse] = + (portCodes, slotSize) => (start, end) => { + if (slotSize % 15 != 0) throw new IllegalArgumentException(s"Slot size must be a multiple of 15 minutes. Got $slotSize") + val groupSize = slotSize / 15 + + val dates = Set(start.toLocalDate, end.toLocalDate) + + Source(portCodes) + .mapAsync(1) { portCode => + val eventualPortQueueSlots = AirportConfigs.confByPort(portCode).terminals.map { terminal => + + queuesForPortAndDatesAndSlotSize(portCode, terminal, slotSize, dates.min, dates.max) + .runWith(Sink.seq) + .map { r: Seq[(UtcDate, Seq[CrunchMinute])] => + val periodJsons = r + // .map(_._2) + .map(_._2.filter(m => start.millisSinceEpoch <= m.minute && m.minute < end.millisSinceEpoch)) + .flatMap { mins => + val byMinute = terminalMinutesByMinute(mins, terminal) + val grouped = groupCrunchMinutesBy(groupSize)(byMinute, terminal, Queues.queueOrder) + grouped.map { + case (minute, queueMinutes) => + val queues = queueMinutes.map(QueueJson.apply) + PeriodJson(SDate(minute), queues) + } + } + TerminalQueuesJson(terminal, periodJsons) + } + } + + Future + .sequence(eventualPortQueueSlots) + .map(PortQueuesJson(portCode, _)) + } + .runWith(Sink.seq) + .map(QueueJsonResponse(start.toString(), end.toString(), slotSize, _)) + } + + def terminalMinutesByMinute[T <: MinuteLike[_, _]](minutes: Seq[T], + terminalName: Terminal): Seq[(MillisSinceEpoch, Seq[T])] = + minutes + .filter(_.terminal == terminalName) + .groupBy(_.minute) + .toList + .sortBy(_._1) + + def groupCrunchMinutesBy(groupSize: Int) + (crunchMinutes: Seq[(MillisSinceEpoch, Seq[CrunchMinute])], + terminalName: Terminal, + queueOrder: Seq[Queue], + ): Seq[(MillisSinceEpoch, Seq[CrunchMinute])] = + crunchMinutes + .sortBy(_._1) + .grouped(groupSize).toList + .map { group => + val byQueueName = group.flatMap(_._2).groupBy(_.queue) + val startMinute = group.map(_._1).min + val queueCrunchMinutes = queueOrder.collect { + case qn if byQueueName.contains(qn) => + val queueMinutes: Seq[CrunchMinute] = byQueueName(qn) + val allActDesks = queueMinutes.collect { + case cm: CrunchMinute if cm.actDesks.isDefined => cm.actDesks.getOrElse(0) + } + val actDesks = if (allActDesks.isEmpty) None else Option(allActDesks.max) + val allActWaits = queueMinutes.collect { + case cm: CrunchMinute if cm.actWait.isDefined => cm.actWait.getOrElse(0) + } + val actWaits = if (allActWaits.isEmpty) None else Option(allActWaits.max) + CrunchMinute( + terminal = terminalName, + queue = qn, + minute = startMinute, + paxLoad = queueMinutes.map(_.paxLoad).sum, + workLoad = queueMinutes.map(_.workLoad).sum, + deskRec = queueMinutes.map(_.deskRec).max, + waitTime = queueMinutes.map(_.waitTime).max, + maybePaxInQueue = queueMinutes.map(_.maybePaxInQueue).max, + deployedDesks = Option(queueMinutes.map(_.deployedDesks.getOrElse(0)).max), + deployedWait = Option(queueMinutes.map(_.deployedWait.getOrElse(0)).max), + maybeDeployedPaxInQueue = Option(queueMinutes.map(_.maybeDeployedPaxInQueue.getOrElse(0)).max), + actDesks = actDesks, + actWait = actWaits + ) + } + (startMinute, queueCrunchMinutes) + } + +} diff --git a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/FlightApiV1JsonFormats.scala b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/FlightApiV1JsonFormats.scala new file mode 100644 index 00000000..7a3ffa5c --- /dev/null +++ b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/FlightApiV1JsonFormats.scala @@ -0,0 +1,79 @@ +package uk.gov.homeoffice.drt.services.api.v1.serialiser + +import spray.json.{DefaultJsonProtocol, JsObject, JsString, JsValue, RootJsonFormat, enrichAny} +import uk.gov.homeoffice.drt.ports.PortCode +import uk.gov.homeoffice.drt.ports.Terminals.Terminal +import uk.gov.homeoffice.drt.routes.api.v1.FlightApiV1Routes.FlightJsonResponse +import uk.gov.homeoffice.drt.services.api.v1.FlightExport.{FlightJson, PortFlightsJson, TerminalFlightsJson} +import uk.gov.homeoffice.drt.time.SDate + +trait FlightApiV1JsonFormats extends DefaultJsonProtocol { + implicit object FlightJsonJsonFormat extends RootJsonFormat[FlightJson] { + override def write(obj: FlightJson): JsValue = { + val maybePax = obj.estimatedPaxCount.filter(_ > 0) + JsObject( + "code" -> obj.code.toJson, + "originPortIata" -> obj.originPortIata.toJson, + "originPortName" -> obj.originPortName.toJson, + "scheduledTime" -> SDate(obj.scheduledTime).toISOString.toJson, + "estimatedLandingTime" -> obj.estimatedLandingTime.map(SDate(_).toISOString).toJson, + "actualChocksTime" -> obj.actualChocksTime.map(SDate(_).toISOString).toJson, + "estimatedPcpStartTime" -> maybePax.flatMap(_ => obj.estimatedPcpStartTime.map(SDate(_).toISOString)).toJson, + "estimatedPcpEndTime" -> maybePax.flatMap(_ => obj.estimatedPcpEndTime.map(SDate(_).toISOString)).toJson, + "estimatedPcpPaxCount" -> obj.estimatedPaxCount.toJson, + "status" -> obj.status.toJson + ) + } + + override def read(json: JsValue): FlightJson = json match { + case JsObject(fields) => FlightJson( + fields.get("code").map(_.convertTo[String]).getOrElse(""), + fields.get("originPortIata").map(_.convertTo[String]).getOrElse(""), + fields.get("originPortName").map(_.convertTo[String]).getOrElse(""), + fields.get("scheduledTime").map(_.convertTo[Long]).getOrElse(0L), + fields.get("estimatedLandingTime").map(_.convertTo[Long]), + fields.get("actualChocksTime").map(_.convertTo[Long]), + fields.get("estimatedPcpStartTime").map(_.convertTo[Long]), + fields.get("estimatedPcpEndTime").map(_.convertTo[Long]), + fields.get("estimatedPcpPaxCount").map(_.convertTo[Int]), + fields.get("status").map(_.convertTo[String]).getOrElse(""), + ) + case unexpected => throw new Exception(s"Failed to parse FlightJson. Expected JsString. Got ${unexpected.getClass}") + } + } + + implicit val flightJsonFormat: RootJsonFormat[FlightJson] = jsonFormat10(FlightJson.apply) + + implicit object TerminalJsonFormat extends RootJsonFormat[Terminal] { + override def write(obj: Terminal): JsValue = obj.toString.toJson + + override def read(json: JsValue): Terminal = json match { + case JsString(value) => Terminal(value) + case unexpected => throw new Exception(s"Failed to parse Terminal. Expected JsString. Got ${unexpected.getClass}") + } + } + + implicit val terminalFlightsJsonFormat: RootJsonFormat[TerminalFlightsJson] = jsonFormat2(TerminalFlightsJson.apply) + + implicit object PortCodeJsonFormat extends RootJsonFormat[PortCode] { + override def write(obj: PortCode): JsValue = obj.iata.toJson + + override def read(json: JsValue): PortCode = json match { + case JsString(value) => PortCode(value) + case unexpected => throw new Exception(s"Failed to parse Terminal. Expected JsString. Got ${unexpected.getClass}") + } + } + + + implicit val portFlightsJsonFormat: RootJsonFormat[PortFlightsJson] = jsonFormat2(PortFlightsJson.apply) + implicit object jsonResponseFormat extends RootJsonFormat[FlightJsonResponse] { + + override def write(obj: FlightJsonResponse): JsValue = JsObject(Map( + "startTime" -> obj.startTime.toJson, + "endTime" -> obj.endTime.toJson, + "ports" -> obj.ports.toJson, + )) + + override def read(json: JsValue): FlightJsonResponse = throw new Exception("Not implemented") + } +} diff --git a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/QueueApiV1JsonFormats.scala b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/QueueApiV1JsonFormats.scala new file mode 100644 index 00000000..15ed9178 --- /dev/null +++ b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/QueueApiV1JsonFormats.scala @@ -0,0 +1,70 @@ +package uk.gov.homeoffice.drt.services.api.v1.serialiser + +import spray.json._ +import uk.gov.homeoffice.drt.ports.PortCode +import uk.gov.homeoffice.drt.ports.Queues.Queue +import uk.gov.homeoffice.drt.ports.Terminals.Terminal +import uk.gov.homeoffice.drt.routes.api.v1.QueueApiV1Routes.QueueJsonResponse +import uk.gov.homeoffice.drt.services.api.v1.QueueExport.{PeriodJson, PortQueuesJson, QueueJson, TerminalQueuesJson} +import uk.gov.homeoffice.drt.time.{SDate, SDateLike} + +trait QueueApiV1JsonFormats extends DefaultJsonProtocol { + + implicit object QueueJsonFormat extends RootJsonFormat[Queue] { + override def write(obj: Queue): JsValue = obj.stringValue.toJson + + override def read(json: JsValue): Queue = json match { + case JsString(value) => Queue(value) + case unexpected => throw new Exception(s"Failed to parse Queue. Expected JsString. Got ${unexpected.getClass}") + } + } + + implicit val queueJsonFormat: RootJsonFormat[QueueJson] = jsonFormat3(QueueJson.apply) + + implicit object SDateJsonFormat extends RootJsonFormat[SDateLike] { + override def write(obj: SDateLike): JsValue = obj.toISOString.toJson + + override def read(json: JsValue): SDateLike = json match { + case JsString(value) => SDate(value) + case unexpected => throw new Exception(s"Failed to parse SDate. Expected JsNumber. Got ${unexpected.getClass}") + } + } + + implicit val periodJsonFormat: RootJsonFormat[PeriodJson] = jsonFormat2(PeriodJson.apply) + + implicit object TerminalJsonFormat extends RootJsonFormat[Terminal] { + override def write(obj: Terminal): JsValue = obj.toString.toJson + + override def read(json: JsValue): Terminal = json match { + case JsString(value) => Terminal(value) + case unexpected => throw new Exception(s"Failed to parse Terminal. Expected JsString. Got ${unexpected.getClass}") + } + } + + implicit val terminalQueuesJsonFormat: RootJsonFormat[TerminalQueuesJson] = jsonFormat2(TerminalQueuesJson.apply) + + implicit object PortCodeJsonFormat extends RootJsonFormat[PortCode] { + override def write(obj: PortCode): JsValue = obj.iata.toJson + + override def read(json: JsValue): PortCode = json match { + case JsString(value) => PortCode(value) + case unexpected => throw new Exception(s"Failed to parse Terminal. Expected JsString. Got ${unexpected.getClass}") + } + } + + implicit val portQueuesJsonFormat: RootJsonFormat[PortQueuesJson] = jsonFormat2(PortQueuesJson.apply) + + implicit object jsonResponseFormat extends RootJsonFormat[QueueJsonResponse] { + + override def write(obj: QueueJsonResponse): JsValue = obj match { + case obj: QueueJsonResponse => JsObject(Map( + "startTime" -> obj.startTime.toJson, + "endTime" -> obj.endTime.toJson, + "periodLengthMinutes" -> obj.slotSizeMinutes.toJson, + "ports" -> obj.ports.toJson, + )) + } + + override def read(json: JsValue): QueueJsonResponse = throw new Exception("Not implemented") + } +} diff --git a/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1RoutesTest.scala b/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1RoutesTest.scala index 5b2afb3a..aa9db275 100644 --- a/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1RoutesTest.scala +++ b/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1RoutesTest.scala @@ -11,7 +11,8 @@ import org.scalatest.wordspec.AnyWordSpec import uk.gov.homeoffice.drt.ports.PortCode import uk.gov.homeoffice.drt.ports.Terminals.T2 import uk.gov.homeoffice.drt.routes.api.v1.FlightApiV1Routes.FlightJsonResponse -import uk.gov.homeoffice.drt.routes.api.v1.FlightExport.{FlightJson, PortFlightsJson, TerminalFlightsJson} +import uk.gov.homeoffice.drt.services.api.v1.FlightExport.{FlightJson, PortFlightsJson, TerminalFlightsJson} +import uk.gov.homeoffice.drt.services.api.v1.serialiser.FlightApiV1JsonFormats import scala.concurrent.{ExecutionContextExecutor, Future} @@ -42,7 +43,7 @@ class FlightApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRout "Given a request for the flight status, I should see a JSON response containing the flight status" in { val routes = FlightApiV1Routes( enabledPorts = Seq(PortCode("LHR"), PortCode("LGW")), - arrivalSource = _ => (_, _) => Future.successful(FlightJsonResponse(start, end, Seq(portFlightJsonLhr, portFlightJsonLhr))), + dateRangeJsonForPorts = _ => (_, _) => Future.successful(FlightJsonResponse(start, end, Seq(portFlightJsonLhr, portFlightJsonLhr))), ) Get("/flights?start=" + start + "&end=" + end) ~> @@ -58,7 +59,7 @@ class FlightApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRout "Given a failed response from a port the response status should be 500" in { val routes = FlightApiV1Routes( enabledPorts = Seq(PortCode("LHR"), PortCode("LGW")), - arrivalSource = _ => (_, _) => Future.failed(new Exception("Failed to get flights")), + dateRangeJsonForPorts = _ => (_, _) => Future.failed(new Exception("Failed to get flights")), ) Get("/flights?start=" + start + "&end=" + end) ~> @@ -74,7 +75,7 @@ class FlightApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRout val probe = TestProbe("flight-source") val routes = FlightApiV1Routes( enabledPorts = Seq(PortCode("LHR")), - arrivalSource = portCodes => (_, _) => { + dateRangeJsonForPorts = portCodes => (_, _) => { probe.ref ! portCodes Future.successful(FlightJsonResponse(start, end, Seq.empty)) }, @@ -91,7 +92,7 @@ class FlightApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRout "Given a request from a user without access to the flight api, the response should be 403" in { val routes = FlightApiV1Routes( enabledPorts = Seq(PortCode("LHR")), - arrivalSource = _ => (_, _) => Future.successful(FlightJsonResponse(start, end, Seq.empty)), + dateRangeJsonForPorts = _ => (_, _) => Future.successful(FlightJsonResponse(start, end, Seq.empty)), ) Get("/flights?start=" + start + "&end=" + end) ~> diff --git a/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1RoutesTest.scala b/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1RoutesTest.scala index 025f5731..08f76626 100644 --- a/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1RoutesTest.scala +++ b/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1RoutesTest.scala @@ -12,7 +12,8 @@ import spray.json.enrichAny import uk.gov.homeoffice.drt.ports.Terminals.T2 import uk.gov.homeoffice.drt.ports.{PortCode, Queues} import uk.gov.homeoffice.drt.routes.api.v1.QueueApiV1Routes.QueueJsonResponse -import uk.gov.homeoffice.drt.routes.api.v1.QueueExport.{PeriodJson, PortQueuesJson, QueueJson, TerminalQueuesJson} +import uk.gov.homeoffice.drt.services.api.v1.QueueExport.{PeriodJson, PortQueuesJson, QueueJson, TerminalQueuesJson} +import uk.gov.homeoffice.drt.services.api.v1.serialiser.QueueApiV1JsonFormats import uk.gov.homeoffice.drt.time.SDate import scala.concurrent.{ExecutionContextExecutor, Future} @@ -35,7 +36,7 @@ class QueueApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRoute "Given a request for the queue status, I should see a JSON response containing the queue status" in { val routes = QueueApiV1Routes( enabledPorts = Seq(PortCode("LHR"), PortCode("LGW")), - arrivalSource = (_, _) => (_, _) => Future.successful(QueueJsonResponse(start, end, defaultSlotSizeMinutes, Seq(portQueueJsonLhr, portQueueJsonLhr))), + dateRangeJsonForPortsAndSlotSize = (_, _) => (_, _) => Future.successful(QueueJsonResponse(start, end, defaultSlotSizeMinutes, Seq(portQueueJsonLhr, portQueueJsonLhr))), ) Get("/queues?start=" + start + "&end=" + end) ~> RawHeader("X-Forwarded-Groups", "LHR,LGW,api-queue-access") ~> @@ -52,7 +53,7 @@ class QueueApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRoute val probe = TestProbe("queueApiV1Routes") val routes = QueueApiV1Routes( enabledPorts = Seq(PortCode("LHR"), PortCode("LGW")), - arrivalSource = (_, slotSize) => (_, _) => { + dateRangeJsonForPortsAndSlotSize = (_, slotSize) => (_, _) => { probe.ref ! slotSize Future.successful(QueueJsonResponse(start, end, defaultSlotSizeMinutes, Seq(portQueueJsonLhr, portQueueJsonLhr))) }, @@ -69,7 +70,7 @@ class QueueApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRoute "Given a failed response from a port the response status should be 500" in { val routes = QueueApiV1Routes( enabledPorts = Seq(PortCode("LHR"), PortCode("LGW")), - arrivalSource = (_, _) => (_, _) => Future.failed(new Exception("Failed to get flights")), + dateRangeJsonForPortsAndSlotSize = (_, _) => (_, _) => Future.failed(new Exception("Failed to get flights")), ) Get("/queues?start=" + start + "&end=" + end) ~> @@ -85,7 +86,7 @@ class QueueApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRoute val probe = TestProbe("queueApiV1Routes") val routes = QueueApiV1Routes( enabledPorts = Seq(PortCode("LHR")), - arrivalSource = (portCodes, _) => (_, _) => { + dateRangeJsonForPortsAndSlotSize = (portCodes, _) => (_, _) => { probe.ref ! portCodes Future.successful(QueueJsonResponse(start, end, defaultSlotSizeMinutes, Seq.empty)) }, @@ -102,7 +103,7 @@ class QueueApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRoute "Given a request from a user without access to the queue api, the response should be 403" in { val routes = QueueApiV1Routes( enabledPorts = Seq(PortCode("LHR")), - arrivalSource = (_, _) => (_, _) => Future.successful(QueueJsonResponse(start, end, defaultSlotSizeMinutes, Seq(portQueueJsonLhr, portQueueJsonLhr))), + dateRangeJsonForPortsAndSlotSize = (_, _) => (_, _) => Future.successful(QueueJsonResponse(start, end, defaultSlotSizeMinutes, Seq(portQueueJsonLhr, portQueueJsonLhr))), ) Get("/queues?start=" + start + "&end=" + end) ~> diff --git a/src/test/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExportTest.scala b/src/test/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExportTest.scala new file mode 100644 index 00000000..1f04ae56 --- /dev/null +++ b/src/test/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExportTest.scala @@ -0,0 +1,48 @@ +package uk.gov.homeoffice.drt.services.api.v1 + +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import uk.gov.homeoffice.drt.model.CrunchMinute +import uk.gov.homeoffice.drt.ports.Queues.{EeaDesk, NonEeaDesk} +import uk.gov.homeoffice.drt.ports.Terminals.T1 +import uk.gov.homeoffice.drt.time.SDate + +class QueueExportTest extends AnyWordSpec with Matchers { + "Given some 15 minutely queue slot crunch minutes, when I ask for a group size of 2 I should get 30 minutes slots" in { + val min1 = SDate("2024-11-29T12:00") + val min2 = SDate("2024-11-29T12:15") + val min3 = SDate("2024-11-29T12:30") + val min4 = SDate("2024-11-29T12:45") + val crunchMinutes = Seq( + min1.millisSinceEpoch -> Seq( + CrunchMinute(T1, EeaDesk, min1.millisSinceEpoch, 5, 1, 1, 1, None, None, None, None, None, None, None), + CrunchMinute(T1, NonEeaDesk, min1.millisSinceEpoch, 5, 1, 1, 1, None, None, None, None, None, None, None), + ), + min2.millisSinceEpoch -> Seq( + CrunchMinute(T1, EeaDesk, min2.millisSinceEpoch, 5, 1, 2, 3, None, None, None, None, None, None, None), + CrunchMinute(T1, NonEeaDesk, min2.millisSinceEpoch, 5, 1, 2, 3, None, None, None, None, None, None, None), + ), + min3.millisSinceEpoch -> Seq( + CrunchMinute(T1, EeaDesk, min3.millisSinceEpoch, 5, 1, 3, 6, None, None, None, None, None, None, None), + CrunchMinute(T1, NonEeaDesk, min3.millisSinceEpoch, 5, 1, 3, 6, None, None, None, None, None, None, None), + ), + min4.millisSinceEpoch -> Seq( + CrunchMinute(T1, EeaDesk, min4.millisSinceEpoch, 5, 1, 4, 10, None, None, None, None, None, None, None), + CrunchMinute(T1, NonEeaDesk, min4.millisSinceEpoch, 5, 1, 4, 10, None, None, None, None, None, None, None), + ), + ) + + val grouped = QueueExport.groupCrunchMinutesBy(2)(crunchMinutes, T1, Seq(EeaDesk, NonEeaDesk)) + + grouped should ===(Seq( + min1.millisSinceEpoch -> Seq( + CrunchMinute(T1, EeaDesk, min1.millisSinceEpoch, 10, 2, 2, 3, None, Some(0), Some(0), Some(0), None, None, None), + CrunchMinute(T1, NonEeaDesk, min1.millisSinceEpoch, 10, 2, 2, 3, None, Some(0), Some(0), Some(0), None, None, None), + ), + min3.millisSinceEpoch -> Seq( + CrunchMinute(T1, EeaDesk, min3.millisSinceEpoch, 10, 2, 4, 10, None, Some(0), Some(0), Some(0), None, None, None), + CrunchMinute(T1, NonEeaDesk, min3.millisSinceEpoch, 10, 2, 4, 10, None, Some(0), Some(0), Some(0), None, None, None), + ), + )) + } +} From 2741c3c89d051bd7f42ada7556b09a0288b62837 Mon Sep 17 00:00:00 2001 From: Rich Birch Date: Mon, 2 Dec 2024 13:14:51 +0000 Subject: [PATCH 3/7] DRTII-1691 Fix slot size db query --- src/main/scala/uk/gov/homeoffice/drt/Server.scala | 10 +++++----- .../drt/routes/api/v1/QueueApiV1Routes.scala | 2 +- .../homeoffice/drt/services/api/v1/QueueExport.scala | 11 ++++++----- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/main/scala/uk/gov/homeoffice/drt/Server.scala b/src/main/scala/uk/gov/homeoffice/drt/Server.scala index 879279d6..6a94a6da 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/Server.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/Server.scala @@ -25,12 +25,11 @@ import uk.gov.homeoffice.drt.persistence.{ExportPersistenceImpl, ScheduledHealth import uk.gov.homeoffice.drt.ports.Terminals.Terminal import uk.gov.homeoffice.drt.ports._ import uk.gov.homeoffice.drt.routes._ -import uk.gov.homeoffice.drt.routes.api.v1.QueueApiV1Routes.QueueJsonResponse import uk.gov.homeoffice.drt.routes.api.v1.{AuthApiV1Routes, FlightApiV1Routes, QueueApiV1Routes} import uk.gov.homeoffice.drt.services.api.v1.{FlightExport, QueueExport} import uk.gov.homeoffice.drt.services.s3.S3Service import uk.gov.homeoffice.drt.services.{PassengerSummaryStreams, UserRequestService, UserService} -import uk.gov.homeoffice.drt.time.{LocalDate, SDate, SDateLike, UtcDate} +import uk.gov.homeoffice.drt.time.{LocalDate, SDate, UtcDate} import uk.gov.homeoffice.drt.uploadTraining.FeatureGuideService import scala.concurrent.duration.DurationInt @@ -126,6 +125,7 @@ object Server { val now = () => SDate.now() + val defaultQueueSlotMinutes = 15 val urls = Urls(config.rootDomain, config.useHttps) val userRequestService = UserRequestService(UserAccessRequestDao(ProdDatabase.db)) val userService = UserService(UserDao(ProdDatabase.db)) @@ -153,9 +153,9 @@ object Server { val keyCloakAuth = KeyCloakAuth(config.keycloakTokenUrl, config.keycloakClientId, config.keycloakClientSecret, sendHttpRequest) - val queuesForPortAndDatesAndSlotSize: (PortCode, Terminal, Int, LocalDate, LocalDate) => Source[(UtcDate, Seq[CrunchMinute]), NotUsed] = { - (port, terminal, slotSize, start, end) => - QueueSlotDao().queueSlotsForDateRange(port, slotSize, db.run)(start, end, Seq(terminal)) + val queuesForPortAndDatesAndSlotSize: (PortCode, Terminal, LocalDate, LocalDate) => Source[(UtcDate, Seq[CrunchMinute]), NotUsed] = { + (port, terminal, start, end) => + QueueSlotDao().queueSlotsForDateRange(port, defaultQueueSlotMinutes, db.run)(start, end, Seq(terminal)) } val routes: Route = concat( diff --git a/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1Routes.scala b/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1Routes.scala index 9fae6ede..d2c427f0 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1Routes.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1Routes.scala @@ -42,7 +42,7 @@ object QueueApiV1Routes extends DefaultJsonProtocol with QueueApiV1JsonFormats { onComplete(dateRangeJson(start, end)) { case Success(value) => complete(value.toJson.compactPrint) case Failure(t) => - log.error(s"Failed to get export: ${t.getMessage}") + log.error(s"Failed to get export: ${t.getMessage}", t) complete(InternalServerError) } } diff --git a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExport.scala b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExport.scala index 4095db54..b54c5d91 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExport.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExport.scala @@ -16,6 +16,8 @@ import scala.concurrent.{ExecutionContext, Future} object QueueExport { + private val defaultSlotSize = 15 + case class QueueJson(queue: Queue, incomingPax: Int, maxWaitMinutes: Int) object QueueJson { @@ -28,11 +30,11 @@ object QueueExport { case class PortQueuesJson(portCode: PortCode, terminals: Iterable[TerminalQueuesJson]) - def queues(queuesForPortAndDatesAndSlotSize: (PortCode, Terminal, Int, LocalDate, LocalDate) => Source[(UtcDate, Seq[CrunchMinute]), NotUsed]) + def queues(queuesForPortAndDatesAndSlotSize: (PortCode, Terminal, LocalDate, LocalDate) => Source[(UtcDate, Seq[CrunchMinute]), NotUsed]) (implicit ec: ExecutionContext, mat: Materializer): (Seq[PortCode], Int) => (SDateLike, SDateLike) => Future[QueueJsonResponse] = (portCodes, slotSize) => (start, end) => { - if (slotSize % 15 != 0) throw new IllegalArgumentException(s"Slot size must be a multiple of 15 minutes. Got $slotSize") - val groupSize = slotSize / 15 + if (slotSize % defaultSlotSize != 0) throw new IllegalArgumentException(s"Slot size must be a multiple of $defaultSlotSize minutes. Got $slotSize") + val groupSize = slotSize / defaultSlotSize val dates = Set(start.toLocalDate, end.toLocalDate) @@ -40,11 +42,10 @@ object QueueExport { .mapAsync(1) { portCode => val eventualPortQueueSlots = AirportConfigs.confByPort(portCode).terminals.map { terminal => - queuesForPortAndDatesAndSlotSize(portCode, terminal, slotSize, dates.min, dates.max) + queuesForPortAndDatesAndSlotSize(portCode, terminal, dates.min, dates.max) .runWith(Sink.seq) .map { r: Seq[(UtcDate, Seq[CrunchMinute])] => val periodJsons = r - // .map(_._2) .map(_._2.filter(m => start.millisSinceEpoch <= m.minute && m.minute < end.millisSinceEpoch)) .flatMap { mins => val byMinute = terminalMinutesByMinute(mins, terminal) From a630188963ded47035802d29adf87ab4e29fa7a2 Mon Sep 17 00:00:00 2001 From: Rich Birch Date: Mon, 2 Dec 2024 17:01:59 +0000 Subject: [PATCH 4/7] DRTII-1691 Add tests around export code --- .../scala/uk/gov/homeoffice/drt/Server.scala | 9 +- .../drt/routes/api/v1/QueueApiV1Routes.scala | 2 +- .../drt/services/api/v1/FlightExport.scala | 9 +- .../drt/services/api/v1/QueueExport.scala | 2 +- .../gov/homeoffice/drt/ArrivalGenerator.scala | 85 +++++++++++++++++++ .../routes/api/v1/QueueApiV1RoutesTest.scala | 10 +-- .../services/api/v1/FlightExportTest.scala | 58 +++++++++++++ .../drt/services/api/v1/QueueExportTest.scala | 79 ++++++++++++++++- 8 files changed, 237 insertions(+), 17 deletions(-) create mode 100644 src/test/scala/uk/gov/homeoffice/drt/ArrivalGenerator.scala create mode 100644 src/test/scala/uk/gov/homeoffice/drt/services/api/v1/FlightExportTest.scala diff --git a/src/main/scala/uk/gov/homeoffice/drt/Server.scala b/src/main/scala/uk/gov/homeoffice/drt/Server.scala index 6a94a6da..d40a3b76 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/Server.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/Server.scala @@ -15,8 +15,9 @@ import akka.stream.Materializer import akka.stream.scaladsl.Source import akka.util.Timeout import org.slf4j.LoggerFactory +import uk.gov.homeoffice.drt.arrivals.ApiFlightWithSplits import uk.gov.homeoffice.drt.db._ -import uk.gov.homeoffice.drt.db.dao.{QueueSlotDao, UserFeedbackDao} +import uk.gov.homeoffice.drt.db.dao.{FlightDao, QueueSlotDao, UserFeedbackDao} import uk.gov.homeoffice.drt.healthchecks._ import uk.gov.homeoffice.drt.keycloak.KeyCloakAuth import uk.gov.homeoffice.drt.model.CrunchMinute @@ -158,13 +159,17 @@ object Server { QueueSlotDao().queueSlotsForDateRange(port, defaultQueueSlotMinutes, db.run)(start, end, Seq(terminal)) } + val flightsForDatesAndTerminals: (PortCode, List[FeedSource], LocalDate, LocalDate, Seq[Terminal]) => Source[(UtcDate, Seq[ApiFlightWithSplits]), NotUsed] = + (portCode, sourceOrder, start, end, terminals) => + FlightDao().flightsForPcpDateRange(portCode, sourceOrder, db.run)(start, end, terminals) + val routes: Route = concat( pathPrefix("api") { concat( pathPrefix("v1") { concat( QueueApiV1Routes(config.enabledPorts, QueueExport.queues(queuesForPortAndDatesAndSlotSize)), - FlightApiV1Routes(config.enabledPorts, FlightExport.flights(db)), + FlightApiV1Routes(config.enabledPorts, FlightExport.flights(flightsForDatesAndTerminals)), AuthApiV1Routes(keyCloakAuth.getToken), ) }, diff --git a/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1Routes.scala b/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1Routes.scala index d2c427f0..1468ae11 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1Routes.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1Routes.scala @@ -20,7 +20,7 @@ import scala.util.{Failure, Success} object QueueApiV1Routes extends DefaultJsonProtocol with QueueApiV1JsonFormats { private val log = LoggerFactory.getLogger(getClass) - case class QueueJsonResponse(startTime: String, endTime: String, slotSizeMinutes: Int, ports: Seq[PortQueuesJson]) + case class QueueJsonResponse(startTime: SDateLike, endTime: SDateLike, slotSizeMinutes: Int, ports: Seq[PortQueuesJson]) def apply(enabledPorts: Iterable[PortCode], dateRangeJsonForPortsAndSlotSize: (Seq[PortCode], Int) => (SDateLike, SDateLike) => Future[QueueJsonResponse]): Route = diff --git a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/FlightExport.scala b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/FlightExport.scala index 31b326a8..e0acc83f 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/FlightExport.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/FlightExport.scala @@ -1,9 +1,10 @@ package uk.gov.homeoffice.drt.services.api.v1 +import akka.NotUsed import akka.stream.Materializer import akka.stream.scaladsl.{Sink, Source} import uk.gov.homeoffice.drt.Server.paxFeedSourceOrder -import uk.gov.homeoffice.drt.arrivals.Arrival +import uk.gov.homeoffice.drt.arrivals.{ApiFlightWithSplits, Arrival} import uk.gov.homeoffice.drt.db.AppDatabase import uk.gov.homeoffice.drt.db.dao.FlightDao import uk.gov.homeoffice.drt.ports.{FeedSource, PortCode} @@ -11,7 +12,7 @@ import uk.gov.homeoffice.drt.ports.Terminals.Terminal import uk.gov.homeoffice.drt.ports.config.AirportConfigs import uk.gov.homeoffice.drt.routes.api.v1.FlightApiV1Routes.FlightJsonResponse import uk.gov.homeoffice.drt.services.AirportInfoService -import uk.gov.homeoffice.drt.time.SDateLike +import uk.gov.homeoffice.drt.time.{LocalDate, SDateLike, UtcDate} import scala.concurrent.{ExecutionContext, Future} import scala.util.Try @@ -49,17 +50,15 @@ object FlightExport { case class PortFlightsJson(portCode: PortCode, terminals: Iterable[TerminalFlightsJson]) - def flights(db: AppDatabase) + def flights(flightsForDatesAndTerminals: (PortCode, List[FeedSource], LocalDate, LocalDate, Seq[Terminal]) => Source[(UtcDate, Seq[ApiFlightWithSplits]), NotUsed]) (implicit ec: ExecutionContext, mat: Materializer): Seq[PortCode] => (SDateLike, SDateLike) => Future[FlightJsonResponse] = portCodes => (start, end) => { val dates = Set(start.toLocalDate, end.toLocalDate) - val flightDao = FlightDao() Source(portCodes) .mapAsync(1) { portCode => val eventualPortFlights = AirportConfigs.confByPort(portCode).terminals.map { terminal => implicit val sourceOrder: List[FeedSource] = paxFeedSourceOrder(portCode) - val flightsForDatesAndTerminals = flightDao.flightsForPcpDateRange(portCode, sourceOrder, db.run) flightsForDatesAndTerminals(dates.min, dates.max, Seq(terminal)) .runWith(Sink.seq) diff --git a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExport.scala b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExport.scala index b54c5d91..8e95d412 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExport.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExport.scala @@ -65,7 +65,7 @@ object QueueExport { .map(PortQueuesJson(portCode, _)) } .runWith(Sink.seq) - .map(QueueJsonResponse(start.toString(), end.toString(), slotSize, _)) + .map(QueueJsonResponse(start, end, slotSize, _)) } def terminalMinutesByMinute[T <: MinuteLike[_, _]](minutes: Seq[T], diff --git a/src/test/scala/uk/gov/homeoffice/drt/ArrivalGenerator.scala b/src/test/scala/uk/gov/homeoffice/drt/ArrivalGenerator.scala new file mode 100644 index 00000000..e0a4c552 --- /dev/null +++ b/src/test/scala/uk/gov/homeoffice/drt/ArrivalGenerator.scala @@ -0,0 +1,85 @@ +package uk.gov.homeoffice.drt + +import uk.gov.homeoffice.drt.arrivals._ +import uk.gov.homeoffice.drt.ports.Terminals.{T1, Terminal} +import uk.gov.homeoffice.drt.ports.{FeedSource, PortCode} +import uk.gov.homeoffice.drt.time.{SDate, SDateLike} + +object ArrivalGenerator { + def arrival(iata: String = "", + schDt: String = "", + maxPax: Option[Int] = None, + terminal: Terminal = T1, + origin: PortCode = PortCode("JFK"), + operator: Option[Operator] = None, + status: ArrivalStatus = ArrivalStatus(""), + estDt: String = "", + actDt: String = "", + estChoxDt: String = "", + actChoxDt: String = "", + gate: Option[String] = None, + stand: Option[String] = None, + runwayId: Option[String] = None, + baggageReclaimId: Option[String] = None, + totalPax: Option[Int] = None, + transPax: Option[Int] = None, + feedSource: FeedSource, + ): Arrival = { + val actualArrival = live( + iata, schDt, maxPax, terminal, origin, operator, status, estDt, actDt, estChoxDt, + actChoxDt, gate, stand, runwayId, baggageReclaimId, totalPax, transPax + ) + .toArrival(feedSource) + actualArrival.copy(PcpTime = Option(actualArrival.bestArrivalTime(true))) + } + + def live(iata: String = "BA0001", + schDt: String = "", + maxPax: Option[Int] = None, + terminal: Terminal = T1, + origin: PortCode = PortCode("JFK"), + operator: Option[Operator] = None, + status: ArrivalStatus = ArrivalStatus(""), + estDt: String = "", + actDt: String = "", + estChoxDt: String = "", + actChoxDt: String = "", + gate: Option[String] = None, + stand: Option[String] = None, + runwayId: Option[String] = None, + baggageReclaimId: Option[String] = None, + totalPax: Option[Int] = None, + transPax: Option[Int] = None, + ): LiveArrival = { + val (carrierCode, voyageNumber, suffix) = FlightCode.flightCodeToParts(iata) + + LiveArrival( + operator = operator.map(_.code), + maxPax = maxPax, + totalPax = totalPax, + transPax = transPax, + terminal = terminal, + voyageNumber = voyageNumber.numeric, + carrierCode = carrierCode.code, + flightCodeSuffix = suffix.map(_.suffix), + origin = origin.iata, + scheduled = if (schDt.nonEmpty) SDate(schDt).millisSinceEpoch else 0, + estimated = if (estDt.nonEmpty) Option(SDate(estDt).millisSinceEpoch) else None, + touchdown = if (actDt.nonEmpty) Option(SDate(actDt).millisSinceEpoch) else None, + estimatedChox = if (estChoxDt.nonEmpty) Option(SDate(estChoxDt).millisSinceEpoch) else None, + actualChox = if (actChoxDt.nonEmpty) Option(SDate(actChoxDt).millisSinceEpoch) else None, + status = status.description, + gate = gate, + stand = stand, + runway = runwayId, + baggageReclaim = baggageReclaimId, + ) + } + + def flightWithSplitsForDayAndTerminal(date: SDateLike, terminal: Terminal = T1, feedSource: FeedSource): ApiFlightWithSplits = ApiFlightWithSplits( + ArrivalGenerator.live(schDt = date.toISOString, terminal = terminal).toArrival(feedSource), Set(), Option(date.millisSinceEpoch) + ) + + def arrivalForDayAndTerminal(date: SDateLike, terminal: Terminal = T1): LiveArrival = + ArrivalGenerator.live(schDt = date.toISOString, terminal = terminal) +} diff --git a/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1RoutesTest.scala b/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1RoutesTest.scala index 08f76626..821be716 100644 --- a/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1RoutesTest.scala +++ b/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1RoutesTest.scala @@ -14,7 +14,7 @@ import uk.gov.homeoffice.drt.ports.{PortCode, Queues} import uk.gov.homeoffice.drt.routes.api.v1.QueueApiV1Routes.QueueJsonResponse import uk.gov.homeoffice.drt.services.api.v1.QueueExport.{PeriodJson, PortQueuesJson, QueueJson, TerminalQueuesJson} import uk.gov.homeoffice.drt.services.api.v1.serialiser.QueueApiV1JsonFormats -import uk.gov.homeoffice.drt.time.SDate +import uk.gov.homeoffice.drt.time.{SDate, SDateLike} import scala.concurrent.{ExecutionContextExecutor, Future} @@ -23,11 +23,11 @@ class QueueApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRoute implicit val mat: Materializer = Materializer(system) implicit val ec: ExecutionContextExecutor = mat.executionContext - val start = "2024-10-20T10:00" - val end = "2024-10-20T12:00" + val start: SDateLike = SDate("2024-10-20T10:00") + val end: SDateLike = SDate("2024-10-20T12:00") val queueJson: QueueJson = QueueJson(Queues.EeaDesk, 100, 10) - val periodJson: PeriodJson = PeriodJson(SDate(start), Seq(queueJson)) + val periodJson: PeriodJson = PeriodJson(start, Seq(queueJson)) val terminalQueueJson: TerminalQueuesJson = TerminalQueuesJson(T2, Seq(periodJson)) val portQueueJsonLhr: PortQueuesJson = PortQueuesJson(PortCode("LHR"), Seq(terminalQueueJson)) val portQueueJsonStn: PortQueuesJson = PortQueuesJson(PortCode("STN"), Seq(terminalQueueJson)) @@ -38,7 +38,7 @@ class QueueApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRoute enabledPorts = Seq(PortCode("LHR"), PortCode("LGW")), dateRangeJsonForPortsAndSlotSize = (_, _) => (_, _) => Future.successful(QueueJsonResponse(start, end, defaultSlotSizeMinutes, Seq(portQueueJsonLhr, portQueueJsonLhr))), ) - Get("/queues?start=" + start + "&end=" + end) ~> + Get("/queues?start=" + start.toISOString + "&end=" + end.toISOString) ~> RawHeader("X-Forwarded-Groups", "LHR,LGW,api-queue-access") ~> RawHeader("X-Forwarded-Email", "my@email.com") ~> routes ~> check { diff --git a/src/test/scala/uk/gov/homeoffice/drt/services/api/v1/FlightExportTest.scala b/src/test/scala/uk/gov/homeoffice/drt/services/api/v1/FlightExportTest.scala new file mode 100644 index 00000000..1f5dc290 --- /dev/null +++ b/src/test/scala/uk/gov/homeoffice/drt/services/api/v1/FlightExportTest.scala @@ -0,0 +1,58 @@ +package services.exports + +import akka.actor.ActorSystem +import akka.stream.Materializer +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import uk.gov.homeoffice.drt.ArrivalGenerator +import uk.gov.homeoffice.drt.ports.Terminals.{T1, Terminal} +import uk.gov.homeoffice.drt.ports.{FeedSource, LiveFeedSource, PortCode} +import uk.gov.homeoffice.drt.services.api.v1.FlightExport +import uk.gov.homeoffice.drt.time.{SDate, SDateLike} + +import scala.concurrent.duration.DurationInt +import scala.concurrent.{Await, ExecutionContextExecutor, Future} + + +class FlightExportSpec extends AnyWordSpec with Matchers { + implicit val system: ActorSystem = ActorSystem("FlightExportSpec") + implicit val mat: Materializer = Materializer.matFromSystem + implicit val ec: ExecutionContextExecutor = system.dispatcher + implicit val sourceOrderPreference: List[FeedSource] = List(LiveFeedSource) + + val startMinute: SDateLike = SDate("2024-10-15T12:00") + val endMinute: SDateLike = SDate("2024-10-15T14:00") + + "FlightExport" should { + "return a PortFlightsJson with the correct structure and only the flight with passengers in the requested time range" in { + val sched1 = SDate("2024-10-15T12:00") + val sched2 = SDate("2024-10-15T13:55") + val source = (_: SDateLike, _: SDateLike, _: Terminal) => { + Future.successful(Seq( + ArrivalGenerator.arrival(iata = "BA0001", schDt = "2024-10-15T11:00", totalPax = Option(100), transPax = Option(10), feedSource = LiveFeedSource), + ArrivalGenerator.arrival(iata = "BA0002", schDt = sched1.toISOString, estDt = sched1.addMinutes(1).toISOString, + actChoxDt = sched1.addMinutes(5).toISOString, totalPax = Option(100), transPax = Option(10), feedSource = LiveFeedSource), + ArrivalGenerator.arrival(iata = "BA0003", schDt = sched2.toISOString, totalPax = Option(200), transPax = Option(10), feedSource = LiveFeedSource), + ArrivalGenerator.arrival(iata = "BA0004", schDt = "2024-10-15T15:00", totalPax = Option(200), transPax = Option(10), feedSource = LiveFeedSource), + )) + } + val export = FlightExport.flights(source, Seq(T1), PortCode("LHR")) + Await.result(export(startMinute, endMinute), 1.second) shouldEqual + PortFlightsJson( + PortCode("LHR"), + List(TerminalFlightsJson( + T1, + List( + FlightJson("BA0002", "JFK", "John F Kennedy Intl", sched1.millisSinceEpoch, + Option(sched1.addMinutes(1).millisSinceEpoch), Option(sched1.addMinutes(5).millisSinceEpoch), + Some(sched1.addMinutes(5).millisSinceEpoch), Some(sched1.addMinutes(9).millisSinceEpoch), Some(90), "On Chocks"), + FlightJson("BA0003", "JFK", "John F Kennedy Intl", sched2.millisSinceEpoch, + None, None, + Some(sched2.addMinutes(5).millisSinceEpoch), Some(sched2.addMinutes(14).millisSinceEpoch), Some(190), "Scheduled"), + ) + ) + ) + ) + } + } +} diff --git a/src/test/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExportTest.scala b/src/test/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExportTest.scala index 1f04ae56..5ea8c9e7 100644 --- a/src/test/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExportTest.scala +++ b/src/test/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExportTest.scala @@ -1,11 +1,23 @@ package uk.gov.homeoffice.drt.services.api.v1 +import akka.NotUsed +import akka.actor.ActorSystem +import akka.stream.Materializer +import akka.stream.scaladsl.Source import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec import uk.gov.homeoffice.drt.model.CrunchMinute -import uk.gov.homeoffice.drt.ports.Queues.{EeaDesk, NonEeaDesk} -import uk.gov.homeoffice.drt.ports.Terminals.T1 -import uk.gov.homeoffice.drt.time.SDate +import uk.gov.homeoffice.drt.ports.PortCode +import uk.gov.homeoffice.drt.ports.Queues.{EGate, EeaDesk, NonEeaDesk} +import uk.gov.homeoffice.drt.ports.Terminals.{T1, Terminal} +import uk.gov.homeoffice.drt.routes.api.v1.QueueApiV1Routes +import uk.gov.homeoffice.drt.routes.api.v1.QueueApiV1Routes.QueueJsonResponse +import uk.gov.homeoffice.drt.services.api.v1.QueueExport.{PeriodJson, PortQueuesJson, QueueJson, TerminalQueuesJson} +import uk.gov.homeoffice.drt.time.{LocalDate, SDate, SDateLike, UtcDate} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration.DurationInt +import scala.concurrent.{Await, Future} class QueueExportTest extends AnyWordSpec with Matchers { "Given some 15 minutely queue slot crunch minutes, when I ask for a group size of 2 I should get 30 minutes slots" in { @@ -45,4 +57,65 @@ class QueueExportTest extends AnyWordSpec with Matchers { ), )) } + + + val start: SDateLike = SDate("2024-10-15T12:00") + val end: SDateLike = SDate("2024-10-15T12:30") + val utcDate = start.toUtcDate + + "QueueExport" should { + "return a PortQueuesJson with the correct structure and only the values in the requested time range" in { + val system = ActorSystem("QueueExportSpec") + implicit val mat: Materializer = Materializer(system) + + val source: (PortCode, Terminal, LocalDate, LocalDate) => Source[(UtcDate, Seq[CrunchMinute]), NotUsed] = (_: PortCode, _: Terminal, _: LocalDate, _: LocalDate) => { + Source(List( + utcDate -> Seq( + CrunchMinute(T1, EeaDesk, start.addMinutes(-15).millisSinceEpoch, 10d, 0d, 0, 0, None, None, None, None, None, None, None), + CrunchMinute(T1, NonEeaDesk, start.addMinutes(-15).millisSinceEpoch, 12d, 0d, 0, 0, None, None, None, None, None, None, None), + CrunchMinute(T1, EGate, start.addMinutes(-15).millisSinceEpoch, 14d, 0d, 0, 0, None, None, None, None, None, None, None), + CrunchMinute(T1, EeaDesk, start.millisSinceEpoch, 10d, 0d, 0, 0, None, None, None, None, None, None, None), + CrunchMinute(T1, NonEeaDesk, start.millisSinceEpoch, 12d, 0d, 0, 0, None, None, None, None, None, None, None), + CrunchMinute(T1, EGate, start.millisSinceEpoch, 14d, 0d, 0, 0, None, None, None, None, None, None, None), + CrunchMinute(T1, EeaDesk, start.addMinutes(15).millisSinceEpoch, 10d, 0d, 0, 0, None, None, None, None, None, None, None), + CrunchMinute(T1, NonEeaDesk, start.addMinutes(15).millisSinceEpoch, 12d, 0d, 0, 0, None, None, None, None, None, None, None), + CrunchMinute(T1, EGate, start.addMinutes(15).millisSinceEpoch, 14d, 0d, 0, 0, None, None, None, None, None, None, None), + CrunchMinute(T1, EeaDesk, start.addMinutes(30).millisSinceEpoch, 10d, 0d, 0, 0, None, None, None, None, None, None, None), + CrunchMinute(T1, NonEeaDesk, start.addMinutes(30).millisSinceEpoch, 12d, 0d, 0, 0, None, None, None, None, None, None, None), + CrunchMinute(T1, EGate, start.addMinutes(30).millisSinceEpoch, 14d, 0d, 0, 0, None, None, None, None, None, None, None), + ), + )) + } + val export = QueueExport.queues(source) + Await.result(export(Seq(PortCode("STN")), 15)(start, end), 1.second) should ===( + QueueJsonResponse( + start, + end, + 15, + Seq( + PortQueuesJson( + PortCode("STN"), + Set( + TerminalQueuesJson( + T1, + Seq( + PeriodJson(start, Seq( + QueueJson(EGate, 14, 0), + QueueJson(EeaDesk, 10, 0), + QueueJson(NonEeaDesk, 12, 0), + )), + PeriodJson(start.addMinutes(15), Seq( + QueueJson(EGate, 14, 0), + QueueJson(EeaDesk, 10, 0), + QueueJson(NonEeaDesk, 12, 0), + )), + ) + ) + ) + ) + ) + ) + ) + } + } } From a82f9b27c5e74db9c6095838d96dd5dad1cff116 Mon Sep 17 00:00:00 2001 From: Rich Birch Date: Tue, 3 Dec 2024 09:27:18 +0000 Subject: [PATCH 5/7] DRTII-1691 Tests around queue export function --- .../scala/uk/gov/homeoffice/drt/Server.scala | 14 ++-- .../drt/routes/api/v1/FlightApiV1Routes.scala | 2 +- .../drt/services/api/v1/FlightExport.scala | 21 +++--- .../drt/services/api/v1/QueueExport.scala | 36 +++++----- .../serialiser/FlightApiV1JsonFormats.scala | 1 + .../v1/serialiser/QueueApiV1JsonFormats.scala | 11 +--- .../routes/api/v1/FlightApiV1RoutesTest.scala | 9 +-- .../routes/api/v1/QueueApiV1RoutesTest.scala | 8 +-- .../services/api/v1/FlightExportTest.scala | 66 +++++++++++-------- .../drt/services/api/v1/QueueExportTest.scala | 6 +- 10 files changed, 89 insertions(+), 85 deletions(-) diff --git a/src/main/scala/uk/gov/homeoffice/drt/Server.scala b/src/main/scala/uk/gov/homeoffice/drt/Server.scala index d40a3b76..e9164bae 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/Server.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/Server.scala @@ -154,14 +154,20 @@ object Server { val keyCloakAuth = KeyCloakAuth(config.keycloakTokenUrl, config.keycloakClientId, config.keycloakClientSecret, sendHttpRequest) - val queuesForPortAndDatesAndSlotSize: (PortCode, Terminal, LocalDate, LocalDate) => Source[(UtcDate, Seq[CrunchMinute]), NotUsed] = { + val queuesForPortAndDatesAndSlotSize: (PortCode, Terminal, LocalDate, LocalDate) => Source[CrunchMinute, NotUsed] = { (port, terminal, start, end) => - QueueSlotDao().queueSlotsForDateRange(port, defaultQueueSlotMinutes, db.run)(start, end, Seq(terminal)) + QueueSlotDao() + .queueSlotsForDateRange(port, defaultQueueSlotMinutes, db.run)(start, end, Seq(terminal)) + .map(_._2) + .mapConcat(identity) } - val flightsForDatesAndTerminals: (PortCode, List[FeedSource], LocalDate, LocalDate, Seq[Terminal]) => Source[(UtcDate, Seq[ApiFlightWithSplits]), NotUsed] = + val flightsForDatesAndTerminals: (PortCode, List[FeedSource], LocalDate, LocalDate, Seq[Terminal]) => Source[ApiFlightWithSplits, NotUsed] = (portCode, sourceOrder, start, end, terminals) => - FlightDao().flightsForPcpDateRange(portCode, sourceOrder, db.run)(start, end, terminals) + FlightDao() + .flightsForPcpDateRange(portCode, sourceOrder, db.run)(start, end, terminals) + .map(_._2) + .mapConcat(identity) val routes: Route = concat( pathPrefix("api") { diff --git a/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1Routes.scala b/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1Routes.scala index d51470a6..96252e3a 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1Routes.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1Routes.scala @@ -20,7 +20,7 @@ import scala.util.{Failure, Success} object FlightApiV1Routes extends DefaultJsonProtocol with FlightApiV1JsonFormats { private val log = LoggerFactory.getLogger(getClass) - case class FlightJsonResponse(startTime: String, endTime: String, ports: Seq[PortFlightsJson]) + case class FlightJsonResponse(startTime: SDateLike, endTime: SDateLike, ports: Seq[PortFlightsJson]) def apply(enabledPorts: Iterable[PortCode], dateRangeJsonForPorts: Seq[PortCode] => (SDateLike, SDateLike) => Future[FlightJsonResponse]): Route = diff --git a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/FlightExport.scala b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/FlightExport.scala index e0acc83f..0b04a62c 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/FlightExport.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/FlightExport.scala @@ -5,14 +5,12 @@ import akka.stream.Materializer import akka.stream.scaladsl.{Sink, Source} import uk.gov.homeoffice.drt.Server.paxFeedSourceOrder import uk.gov.homeoffice.drt.arrivals.{ApiFlightWithSplits, Arrival} -import uk.gov.homeoffice.drt.db.AppDatabase -import uk.gov.homeoffice.drt.db.dao.FlightDao -import uk.gov.homeoffice.drt.ports.{FeedSource, PortCode} import uk.gov.homeoffice.drt.ports.Terminals.Terminal import uk.gov.homeoffice.drt.ports.config.AirportConfigs +import uk.gov.homeoffice.drt.ports.{FeedSource, PortCode} import uk.gov.homeoffice.drt.routes.api.v1.FlightApiV1Routes.FlightJsonResponse import uk.gov.homeoffice.drt.services.AirportInfoService -import uk.gov.homeoffice.drt.time.{LocalDate, SDateLike, UtcDate} +import uk.gov.homeoffice.drt.time.{LocalDate, SDateLike} import scala.concurrent.{ExecutionContext, Future} import scala.util.Try @@ -50,7 +48,7 @@ object FlightExport { case class PortFlightsJson(portCode: PortCode, terminals: Iterable[TerminalFlightsJson]) - def flights(flightsForDatesAndTerminals: (PortCode, List[FeedSource], LocalDate, LocalDate, Seq[Terminal]) => Source[(UtcDate, Seq[ApiFlightWithSplits]), NotUsed]) + def flights(flightsForDatesAndTerminals: (PortCode, List[FeedSource], LocalDate, LocalDate, Seq[Terminal]) => Source[ApiFlightWithSplits, NotUsed]) (implicit ec: ExecutionContext, mat: Materializer): Seq[PortCode] => (SDateLike, SDateLike) => Future[FlightJsonResponse] = portCodes => (start, end) => { val dates = Set(start.toLocalDate, end.toLocalDate) @@ -60,14 +58,13 @@ object FlightExport { val eventualPortFlights = AirportConfigs.confByPort(portCode).terminals.map { terminal => implicit val sourceOrder: List[FeedSource] = paxFeedSourceOrder(portCode) - flightsForDatesAndTerminals(dates.min, dates.max, Seq(terminal)) + flightsForDatesAndTerminals(portCode, sourceOrder, dates.min, dates.max, Seq(terminal)) .runWith(Sink.seq) .map { r => - val relevantFlights = r.flatMap { case (_, flights) => - flights - .filter(_.apiFlight.hasPcpDuring(start, end, sourceOrder)) - .map(f => FlightJson(f.apiFlight)) - } + val relevantFlights = r + .filter(_.apiFlight.hasPcpDuring(start, end, sourceOrder)) + .map(f => FlightJson(f.apiFlight)) + TerminalFlightsJson(terminal, relevantFlights) } } @@ -77,6 +74,6 @@ object FlightExport { .map(PortFlightsJson(portCode, _)) } .runWith(Sink.seq) - .map(FlightJsonResponse(start.toISOString, end.toISOString, _)) + .map(FlightJsonResponse(start, end, _)) } } diff --git a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExport.scala b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExport.scala index 8e95d412..8dbe964a 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExport.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExport.scala @@ -30,7 +30,7 @@ object QueueExport { case class PortQueuesJson(portCode: PortCode, terminals: Iterable[TerminalQueuesJson]) - def queues(queuesForPortAndDatesAndSlotSize: (PortCode, Terminal, LocalDate, LocalDate) => Source[(UtcDate, Seq[CrunchMinute]), NotUsed]) + def queues(queuesForPortAndDatesAndSlotSize: (PortCode, Terminal, LocalDate, LocalDate) => Source[CrunchMinute, NotUsed]) (implicit ec: ExecutionContext, mat: Materializer): (Seq[PortCode], Int) => (SDateLike, SDateLike) => Future[QueueJsonResponse] = (portCodes, slotSize) => (start, end) => { if (slotSize % defaultSlotSize != 0) throw new IllegalArgumentException(s"Slot size must be a multiple of $defaultSlotSize minutes. Got $slotSize") @@ -40,25 +40,23 @@ object QueueExport { Source(portCodes) .mapAsync(1) { portCode => - val eventualPortQueueSlots = AirportConfigs.confByPort(portCode).terminals.map { terminal => - - queuesForPortAndDatesAndSlotSize(portCode, terminal, dates.min, dates.max) - .runWith(Sink.seq) - .map { r: Seq[(UtcDate, Seq[CrunchMinute])] => - val periodJsons = r - .map(_._2.filter(m => start.millisSinceEpoch <= m.minute && m.minute < end.millisSinceEpoch)) - .flatMap { mins => - val byMinute = terminalMinutesByMinute(mins, terminal) - val grouped = groupCrunchMinutesBy(groupSize)(byMinute, terminal, Queues.queueOrder) - grouped.map { - case (minute, queueMinutes) => - val queues = queueMinutes.map(QueueJson.apply) - PeriodJson(SDate(minute), queues) - } + val eventualPortQueueSlots = AirportConfigs.confByPort(portCode) + .terminals.map { terminal => + queuesForPortAndDatesAndSlotSize(portCode, terminal, dates.min, dates.max) + .runWith(Sink.seq) + .map { mins: Seq[CrunchMinute] => + val minsInRange = mins.filter(m => start.millisSinceEpoch <= m.minute && m.minute < end.millisSinceEpoch) + + val byMinute = terminalMinutesByMinute(minsInRange, terminal) + val grouped = groupCrunchMinutesBy(groupSize)(byMinute, terminal, Queues.queueOrder) + val periodJsons = grouped.map { + case (minute, queueMinutes) => + val queues = queueMinutes.map(QueueJson.apply) + PeriodJson(SDate(minute), queues) } - TerminalQueuesJson(terminal, periodJsons) - } - } + TerminalQueuesJson(terminal, periodJsons) + } + } Future .sequence(eventualPortQueueSlots) diff --git a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/FlightApiV1JsonFormats.scala b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/FlightApiV1JsonFormats.scala index 7a3ffa5c..3bd9d507 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/FlightApiV1JsonFormats.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/FlightApiV1JsonFormats.scala @@ -1,6 +1,7 @@ package uk.gov.homeoffice.drt.services.api.v1.serialiser import spray.json.{DefaultJsonProtocol, JsObject, JsString, JsValue, RootJsonFormat, enrichAny} +import uk.gov.homeoffice.drt.json.SDateLikeJsonFormats.SDateLikeJsonFormat import uk.gov.homeoffice.drt.ports.PortCode import uk.gov.homeoffice.drt.ports.Terminals.Terminal import uk.gov.homeoffice.drt.routes.api.v1.FlightApiV1Routes.FlightJsonResponse diff --git a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/QueueApiV1JsonFormats.scala b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/QueueApiV1JsonFormats.scala index 15ed9178..52f6db02 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/QueueApiV1JsonFormats.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/QueueApiV1JsonFormats.scala @@ -1,12 +1,12 @@ package uk.gov.homeoffice.drt.services.api.v1.serialiser import spray.json._ +import uk.gov.homeoffice.drt.json.SDateLikeJsonFormats.SDateLikeJsonFormat import uk.gov.homeoffice.drt.ports.PortCode import uk.gov.homeoffice.drt.ports.Queues.Queue import uk.gov.homeoffice.drt.ports.Terminals.Terminal import uk.gov.homeoffice.drt.routes.api.v1.QueueApiV1Routes.QueueJsonResponse import uk.gov.homeoffice.drt.services.api.v1.QueueExport.{PeriodJson, PortQueuesJson, QueueJson, TerminalQueuesJson} -import uk.gov.homeoffice.drt.time.{SDate, SDateLike} trait QueueApiV1JsonFormats extends DefaultJsonProtocol { @@ -21,15 +21,6 @@ trait QueueApiV1JsonFormats extends DefaultJsonProtocol { implicit val queueJsonFormat: RootJsonFormat[QueueJson] = jsonFormat3(QueueJson.apply) - implicit object SDateJsonFormat extends RootJsonFormat[SDateLike] { - override def write(obj: SDateLike): JsValue = obj.toISOString.toJson - - override def read(json: JsValue): SDateLike = json match { - case JsString(value) => SDate(value) - case unexpected => throw new Exception(s"Failed to parse SDate. Expected JsNumber. Got ${unexpected.getClass}") - } - } - implicit val periodJsonFormat: RootJsonFormat[PeriodJson] = jsonFormat2(PeriodJson.apply) implicit object TerminalJsonFormat extends RootJsonFormat[Terminal] { diff --git a/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1RoutesTest.scala b/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1RoutesTest.scala index aa9db275..661a2b40 100644 --- a/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1RoutesTest.scala +++ b/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/FlightApiV1RoutesTest.scala @@ -13,6 +13,7 @@ import uk.gov.homeoffice.drt.ports.Terminals.T2 import uk.gov.homeoffice.drt.routes.api.v1.FlightApiV1Routes.FlightJsonResponse import uk.gov.homeoffice.drt.services.api.v1.FlightExport.{FlightJson, PortFlightsJson, TerminalFlightsJson} import uk.gov.homeoffice.drt.services.api.v1.serialiser.FlightApiV1JsonFormats +import uk.gov.homeoffice.drt.time.SDate import scala.concurrent.{ExecutionContextExecutor, Future} @@ -43,7 +44,7 @@ class FlightApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRout "Given a request for the flight status, I should see a JSON response containing the flight status" in { val routes = FlightApiV1Routes( enabledPorts = Seq(PortCode("LHR"), PortCode("LGW")), - dateRangeJsonForPorts = _ => (_, _) => Future.successful(FlightJsonResponse(start, end, Seq(portFlightJsonLhr, portFlightJsonLhr))), + dateRangeJsonForPorts = _ => (_, _) => Future.successful(FlightJsonResponse(SDate(start), SDate(end), Seq(portFlightJsonLhr, portFlightJsonLhr))), ) Get("/flights?start=" + start + "&end=" + end) ~> @@ -51,7 +52,7 @@ class FlightApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRout RawHeader("X-Forwarded-Email", "my@email.com") ~> routes ~> check { - val expected = FlightApiV1Routes.FlightJsonResponse(start, end, Seq(portFlightJsonLhr, portFlightJsonLhr)) + val expected = FlightApiV1Routes.FlightJsonResponse(SDate(start), SDate(end), Seq(portFlightJsonLhr, portFlightJsonLhr)) responseAs[String] shouldEqual expected.toJson.compactPrint } } @@ -77,7 +78,7 @@ class FlightApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRout enabledPorts = Seq(PortCode("LHR")), dateRangeJsonForPorts = portCodes => (_, _) => { probe.ref ! portCodes - Future.successful(FlightJsonResponse(start, end, Seq.empty)) + Future.successful(FlightJsonResponse(SDate(start), SDate(end), Seq.empty)) }, ) @@ -92,7 +93,7 @@ class FlightApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRout "Given a request from a user without access to the flight api, the response should be 403" in { val routes = FlightApiV1Routes( enabledPorts = Seq(PortCode("LHR")), - dateRangeJsonForPorts = _ => (_, _) => Future.successful(FlightJsonResponse(start, end, Seq.empty)), + dateRangeJsonForPorts = _ => (_, _) => Future.successful(FlightJsonResponse(SDate(start), SDate(end), Seq.empty)), ) Get("/flights?start=" + start + "&end=" + end) ~> diff --git a/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1RoutesTest.scala b/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1RoutesTest.scala index 821be716..3610478b 100644 --- a/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1RoutesTest.scala +++ b/src/test/scala/uk/gov/homeoffice/drt/routes/api/v1/QueueApiV1RoutesTest.scala @@ -59,7 +59,7 @@ class QueueApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRoute }, ) - Get("/queues?start=" + start + "&end=" + end) ~> + Get("/queues?start=" + start.toISOString + "&end=" + end.toISOString) ~> RawHeader("X-Forwarded-Groups", "LHR,LGW,api-queue-access") ~> RawHeader("X-Forwarded-Email", "my@email.com") ~> routes ~> check { @@ -73,7 +73,7 @@ class QueueApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRoute dateRangeJsonForPortsAndSlotSize = (_, _) => (_, _) => Future.failed(new Exception("Failed to get flights")), ) - Get("/queues?start=" + start + "&end=" + end) ~> + Get("/queues?start=" + start.toISOString + "&end=" + end.toISOString) ~> RawHeader("X-Forwarded-Groups", "LHR,LGW,api-queue-access") ~> RawHeader("X-Forwarded-Email", "my@email.com") ~> routes ~> check { @@ -92,7 +92,7 @@ class QueueApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRoute }, ) - Get("/queues?start=" + start + "&end=" + end) ~> + Get("/queues?start=" + start.toISOString + "&end=" + end.toISOString) ~> RawHeader("X-Forwarded-Groups", "LHR,LGW,STN,api-queue-access") ~> RawHeader("X-Forwarded-Email", "my@email.com") ~> routes ~> check { @@ -106,7 +106,7 @@ class QueueApiV1RoutesTest extends AnyWordSpec with Matchers with ScalatestRoute dateRangeJsonForPortsAndSlotSize = (_, _) => (_, _) => Future.successful(QueueJsonResponse(start, end, defaultSlotSizeMinutes, Seq(portQueueJsonLhr, portQueueJsonLhr))), ) - Get("/queues?start=" + start + "&end=" + end) ~> + Get("/queues?start=" + start.toISOString + "&end=" + end.toISOString) ~> RawHeader("X-Forwarded-Groups", "LHR") ~> RawHeader("X-Forwarded-Email", "my@email.com") ~> routes ~> check { diff --git a/src/test/scala/uk/gov/homeoffice/drt/services/api/v1/FlightExportTest.scala b/src/test/scala/uk/gov/homeoffice/drt/services/api/v1/FlightExportTest.scala index 1f5dc290..d4af2ef5 100644 --- a/src/test/scala/uk/gov/homeoffice/drt/services/api/v1/FlightExportTest.scala +++ b/src/test/scala/uk/gov/homeoffice/drt/services/api/v1/FlightExportTest.scala @@ -1,20 +1,23 @@ -package services.exports +package uk.gov.homeoffice.drt.services.api.v1 import akka.actor.ActorSystem import akka.stream.Materializer +import akka.stream.scaladsl.Source import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec import uk.gov.homeoffice.drt.ArrivalGenerator +import uk.gov.homeoffice.drt.arrivals.ApiFlightWithSplits import uk.gov.homeoffice.drt.ports.Terminals.{T1, Terminal} import uk.gov.homeoffice.drt.ports.{FeedSource, LiveFeedSource, PortCode} -import uk.gov.homeoffice.drt.services.api.v1.FlightExport -import uk.gov.homeoffice.drt.time.{SDate, SDateLike} +import uk.gov.homeoffice.drt.routes.api.v1.FlightApiV1Routes.FlightJsonResponse +import uk.gov.homeoffice.drt.services.api.v1.FlightExport.{FlightJson, PortFlightsJson, TerminalFlightsJson} +import uk.gov.homeoffice.drt.time.{LocalDate, SDate, SDateLike} import scala.concurrent.duration.DurationInt -import scala.concurrent.{Await, ExecutionContextExecutor, Future} +import scala.concurrent.{Await, ExecutionContextExecutor} -class FlightExportSpec extends AnyWordSpec with Matchers { +class FlightExportTest extends AnyWordSpec with Matchers { implicit val system: ActorSystem = ActorSystem("FlightExportSpec") implicit val mat: Materializer = Materializer.matFromSystem implicit val ec: ExecutionContextExecutor = system.dispatcher @@ -27,32 +30,41 @@ class FlightExportSpec extends AnyWordSpec with Matchers { "return a PortFlightsJson with the correct structure and only the flight with passengers in the requested time range" in { val sched1 = SDate("2024-10-15T12:00") val sched2 = SDate("2024-10-15T13:55") - val source = (_: SDateLike, _: SDateLike, _: Terminal) => { - Future.successful(Seq( - ArrivalGenerator.arrival(iata = "BA0001", schDt = "2024-10-15T11:00", totalPax = Option(100), transPax = Option(10), feedSource = LiveFeedSource), - ArrivalGenerator.arrival(iata = "BA0002", schDt = sched1.toISOString, estDt = sched1.addMinutes(1).toISOString, - actChoxDt = sched1.addMinutes(5).toISOString, totalPax = Option(100), transPax = Option(10), feedSource = LiveFeedSource), - ArrivalGenerator.arrival(iata = "BA0003", schDt = sched2.toISOString, totalPax = Option(200), transPax = Option(10), feedSource = LiveFeedSource), - ArrivalGenerator.arrival(iata = "BA0004", schDt = "2024-10-15T15:00", totalPax = Option(200), transPax = Option(10), feedSource = LiveFeedSource), - )) + val source = (_: PortCode, _: List[FeedSource], _: LocalDate, _: LocalDate, _: Seq[Terminal]) => { + Source( + Seq( + ArrivalGenerator.arrival(iata = "BA0001", schDt = "2024-10-15T11:00", totalPax = Option(100), transPax = Option(10), feedSource = LiveFeedSource), + ArrivalGenerator.arrival(iata = "BA0002", schDt = sched1.toISOString, estDt = sched1.addMinutes(1).toISOString, + actChoxDt = sched1.addMinutes(5).toISOString, totalPax = Option(100), transPax = Option(10), feedSource = LiveFeedSource), + ArrivalGenerator.arrival(iata = "BA0003", schDt = sched2.toISOString, totalPax = Option(200), transPax = Option(10), feedSource = LiveFeedSource), + ArrivalGenerator.arrival(iata = "BA0004", schDt = "2024-10-15T15:00", totalPax = Option(200), transPax = Option(10), feedSource = LiveFeedSource), + ).map(a => ApiFlightWithSplits(a, Set.empty)) + ) } - val export = FlightExport.flights(source, Seq(T1), PortCode("LHR")) - Await.result(export(startMinute, endMinute), 1.second) shouldEqual - PortFlightsJson( - PortCode("LHR"), - List(TerminalFlightsJson( - T1, - List( - FlightJson("BA0002", "JFK", "John F Kennedy Intl", sched1.millisSinceEpoch, - Option(sched1.addMinutes(1).millisSinceEpoch), Option(sched1.addMinutes(5).millisSinceEpoch), - Some(sched1.addMinutes(5).millisSinceEpoch), Some(sched1.addMinutes(9).millisSinceEpoch), Some(90), "On Chocks"), - FlightJson("BA0003", "JFK", "John F Kennedy Intl", sched2.millisSinceEpoch, - None, None, - Some(sched2.addMinutes(5).millisSinceEpoch), Some(sched2.addMinutes(14).millisSinceEpoch), Some(190), "Scheduled"), + val export = FlightExport.flights(source) + Await.result(export(Seq(PortCode("STN")))(startMinute, endMinute), 1.second) should ===( + FlightJsonResponse( + startMinute, + endMinute, + Seq( + PortFlightsJson( + PortCode("STN"), + Set(TerminalFlightsJson( + T1, + Vector( + FlightJson("BA0002", "JFK", "John F Kennedy Intl", sched1.millisSinceEpoch, + Option(sched1.addMinutes(1).millisSinceEpoch), Option(sched1.addMinutes(5).millisSinceEpoch), + Some(sched1.addMinutes(5).millisSinceEpoch), Some(sched1.addMinutes(9).millisSinceEpoch), Some(90), "On Chocks"), + FlightJson("BA0003", "JFK", "John F Kennedy Intl", sched2.millisSinceEpoch, + None, None, + Some(sched2.addMinutes(5).millisSinceEpoch), Some(sched2.addMinutes(14).millisSinceEpoch), Some(190), "Scheduled"), + ) + ) + ) ) ) - ) ) + ) } } } diff --git a/src/test/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExportTest.scala b/src/test/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExportTest.scala index 5ea8c9e7..d0858a45 100644 --- a/src/test/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExportTest.scala +++ b/src/test/scala/uk/gov/homeoffice/drt/services/api/v1/QueueExportTest.scala @@ -68,9 +68,8 @@ class QueueExportTest extends AnyWordSpec with Matchers { val system = ActorSystem("QueueExportSpec") implicit val mat: Materializer = Materializer(system) - val source: (PortCode, Terminal, LocalDate, LocalDate) => Source[(UtcDate, Seq[CrunchMinute]), NotUsed] = (_: PortCode, _: Terminal, _: LocalDate, _: LocalDate) => { + val source: (PortCode, Terminal, LocalDate, LocalDate) => Source[CrunchMinute, NotUsed] = (_: PortCode, _: Terminal, _: LocalDate, _: LocalDate) => { Source(List( - utcDate -> Seq( CrunchMinute(T1, EeaDesk, start.addMinutes(-15).millisSinceEpoch, 10d, 0d, 0, 0, None, None, None, None, None, None, None), CrunchMinute(T1, NonEeaDesk, start.addMinutes(-15).millisSinceEpoch, 12d, 0d, 0, 0, None, None, None, None, None, None, None), CrunchMinute(T1, EGate, start.addMinutes(-15).millisSinceEpoch, 14d, 0d, 0, 0, None, None, None, None, None, None, None), @@ -83,7 +82,6 @@ class QueueExportTest extends AnyWordSpec with Matchers { CrunchMinute(T1, EeaDesk, start.addMinutes(30).millisSinceEpoch, 10d, 0d, 0, 0, None, None, None, None, None, None, None), CrunchMinute(T1, NonEeaDesk, start.addMinutes(30).millisSinceEpoch, 12d, 0d, 0, 0, None, None, None, None, None, None, None), CrunchMinute(T1, EGate, start.addMinutes(30).millisSinceEpoch, 14d, 0d, 0, 0, None, None, None, None, None, None, None), - ), )) } val export = QueueExport.queues(source) @@ -98,7 +96,7 @@ class QueueExportTest extends AnyWordSpec with Matchers { Set( TerminalQueuesJson( T1, - Seq( + Vector( PeriodJson(start, Seq( QueueJson(EGate, 14, 0), QueueJson(EeaDesk, 10, 0), From e97615fe0672314fc8809fbadde0db9ccd75e43f Mon Sep 17 00:00:00 2001 From: Rich Birch Date: Tue, 3 Dec 2024 13:50:04 +0000 Subject: [PATCH 6/7] DRTII-1691 Fix sdate json format. Refactor common code --- .../drt/json/ExportJsonFormats.scala | 2 +- .../drt/json/SDateLikeJsonFormats.scala | 4 +-- .../api/v1/serialiser/CommonJsonFormats.scala | 35 +++++++++++++++++++ .../serialiser/FlightApiV1JsonFormats.scala | 27 ++------------ .../v1/serialiser/QueueApiV1JsonFormats.scala | 24 +------------ 5 files changed, 42 insertions(+), 50 deletions(-) create mode 100644 src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/CommonJsonFormats.scala diff --git a/src/main/scala/uk/gov/homeoffice/drt/json/ExportJsonFormats.scala b/src/main/scala/uk/gov/homeoffice/drt/json/ExportJsonFormats.scala index 82e7731d..833165e5 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/json/ExportJsonFormats.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/json/ExportJsonFormats.scala @@ -3,7 +3,7 @@ package uk.gov.homeoffice.drt.json import spray.json.{DefaultJsonProtocol, JsString, JsValue, RootJsonFormat} import uk.gov.homeoffice.drt.exports._ import uk.gov.homeoffice.drt.json.LocalDateJsonFormats.LocalDateJsonFormat -import uk.gov.homeoffice.drt.json.SDateLikeJsonFormats.SDateLikeJsonFormat +import uk.gov.homeoffice.drt.json.SDateLikeJsonFormats.SDateLikeTimestampJsonFormat import uk.gov.homeoffice.drt.models.Export import uk.gov.homeoffice.drt.routes.ExportRoutes.ExportRequest diff --git a/src/main/scala/uk/gov/homeoffice/drt/json/SDateLikeJsonFormats.scala b/src/main/scala/uk/gov/homeoffice/drt/json/SDateLikeJsonFormats.scala index 719b3360..f61d4c8d 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/json/SDateLikeJsonFormats.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/json/SDateLikeJsonFormats.scala @@ -1,11 +1,11 @@ package uk.gov.homeoffice.drt.json -import spray.json.{DefaultJsonProtocol, JsNumber, JsValue, RootJsonFormat, enrichAny} +import spray.json.{DefaultJsonProtocol, JsNumber, JsString, JsValue, RootJsonFormat, enrichAny} import uk.gov.homeoffice.drt.time.{SDate, SDateLike} object SDateLikeJsonFormats extends DefaultJsonProtocol { - implicit object SDateLikeJsonFormat extends RootJsonFormat[SDateLike] { + implicit object SDateLikeTimestampJsonFormat extends RootJsonFormat[SDateLike] { override def read(json: JsValue): SDateLike = json match { case JsNumber(ts) => SDate(ts.toLong) } diff --git a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/CommonJsonFormats.scala b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/CommonJsonFormats.scala new file mode 100644 index 00000000..78d97454 --- /dev/null +++ b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/CommonJsonFormats.scala @@ -0,0 +1,35 @@ +package uk.gov.homeoffice.drt.services.api.v1.serialiser + +import spray.json.DefaultJsonProtocol.StringJsonFormat +import spray.json.{JsString, JsValue, RootJsonFormat, enrichAny} +import uk.gov.homeoffice.drt.ports.PortCode +import uk.gov.homeoffice.drt.ports.Terminals.Terminal +import uk.gov.homeoffice.drt.time.{SDate, SDateLike} + +trait CommonJsonFormats { + implicit object SDateLikeISOJsonFormat extends RootJsonFormat[SDateLike] { + override def read(json: JsValue): SDateLike = json match { + case JsString(dateStr) => SDate(dateStr) + } + + override def write(obj: SDateLike): JsValue = obj.toISOString.toJson + } + + implicit object PortCodeJsonFormat extends RootJsonFormat[PortCode] { + override def write(obj: PortCode): JsValue = obj.iata.toJson + + override def read(json: JsValue): PortCode = json match { + case JsString(value) => PortCode(value) + case unexpected => throw new Exception(s"Failed to parse Terminal. Expected JsString. Got ${unexpected.getClass}") + } + } + + implicit object TerminalJsonFormat extends RootJsonFormat[Terminal] { + override def write(obj: Terminal): JsValue = obj.toString.toJson + + override def read(json: JsValue): Terminal = json match { + case JsString(value) => Terminal(value) + case unexpected => throw new Exception(s"Failed to parse Terminal. Expected JsString. Got ${unexpected.getClass}") + } + } +} diff --git a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/FlightApiV1JsonFormats.scala b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/FlightApiV1JsonFormats.scala index 3bd9d507..e2b04f70 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/FlightApiV1JsonFormats.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/FlightApiV1JsonFormats.scala @@ -1,14 +1,11 @@ package uk.gov.homeoffice.drt.services.api.v1.serialiser -import spray.json.{DefaultJsonProtocol, JsObject, JsString, JsValue, RootJsonFormat, enrichAny} -import uk.gov.homeoffice.drt.json.SDateLikeJsonFormats.SDateLikeJsonFormat -import uk.gov.homeoffice.drt.ports.PortCode -import uk.gov.homeoffice.drt.ports.Terminals.Terminal +import spray.json.{DefaultJsonProtocol, JsObject, JsValue, RootJsonFormat, enrichAny} import uk.gov.homeoffice.drt.routes.api.v1.FlightApiV1Routes.FlightJsonResponse import uk.gov.homeoffice.drt.services.api.v1.FlightExport.{FlightJson, PortFlightsJson, TerminalFlightsJson} import uk.gov.homeoffice.drt.time.SDate -trait FlightApiV1JsonFormats extends DefaultJsonProtocol { +trait FlightApiV1JsonFormats extends DefaultJsonProtocol with CommonJsonFormats { implicit object FlightJsonJsonFormat extends RootJsonFormat[FlightJson] { override def write(obj: FlightJson): JsValue = { val maybePax = obj.estimatedPaxCount.filter(_ > 0) @@ -45,28 +42,10 @@ trait FlightApiV1JsonFormats extends DefaultJsonProtocol { implicit val flightJsonFormat: RootJsonFormat[FlightJson] = jsonFormat10(FlightJson.apply) - implicit object TerminalJsonFormat extends RootJsonFormat[Terminal] { - override def write(obj: Terminal): JsValue = obj.toString.toJson - - override def read(json: JsValue): Terminal = json match { - case JsString(value) => Terminal(value) - case unexpected => throw new Exception(s"Failed to parse Terminal. Expected JsString. Got ${unexpected.getClass}") - } - } - implicit val terminalFlightsJsonFormat: RootJsonFormat[TerminalFlightsJson] = jsonFormat2(TerminalFlightsJson.apply) - implicit object PortCodeJsonFormat extends RootJsonFormat[PortCode] { - override def write(obj: PortCode): JsValue = obj.iata.toJson - - override def read(json: JsValue): PortCode = json match { - case JsString(value) => PortCode(value) - case unexpected => throw new Exception(s"Failed to parse Terminal. Expected JsString. Got ${unexpected.getClass}") - } - } - - implicit val portFlightsJsonFormat: RootJsonFormat[PortFlightsJson] = jsonFormat2(PortFlightsJson.apply) + implicit object jsonResponseFormat extends RootJsonFormat[FlightJsonResponse] { override def write(obj: FlightJsonResponse): JsValue = JsObject(Map( diff --git a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/QueueApiV1JsonFormats.scala b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/QueueApiV1JsonFormats.scala index 52f6db02..f84acf63 100644 --- a/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/QueueApiV1JsonFormats.scala +++ b/src/main/scala/uk/gov/homeoffice/drt/services/api/v1/serialiser/QueueApiV1JsonFormats.scala @@ -1,15 +1,11 @@ package uk.gov.homeoffice.drt.services.api.v1.serialiser import spray.json._ -import uk.gov.homeoffice.drt.json.SDateLikeJsonFormats.SDateLikeJsonFormat -import uk.gov.homeoffice.drt.ports.PortCode import uk.gov.homeoffice.drt.ports.Queues.Queue -import uk.gov.homeoffice.drt.ports.Terminals.Terminal import uk.gov.homeoffice.drt.routes.api.v1.QueueApiV1Routes.QueueJsonResponse import uk.gov.homeoffice.drt.services.api.v1.QueueExport.{PeriodJson, PortQueuesJson, QueueJson, TerminalQueuesJson} -trait QueueApiV1JsonFormats extends DefaultJsonProtocol { - +trait QueueApiV1JsonFormats extends DefaultJsonProtocol with CommonJsonFormats { implicit object QueueJsonFormat extends RootJsonFormat[Queue] { override def write(obj: Queue): JsValue = obj.stringValue.toJson @@ -23,26 +19,8 @@ trait QueueApiV1JsonFormats extends DefaultJsonProtocol { implicit val periodJsonFormat: RootJsonFormat[PeriodJson] = jsonFormat2(PeriodJson.apply) - implicit object TerminalJsonFormat extends RootJsonFormat[Terminal] { - override def write(obj: Terminal): JsValue = obj.toString.toJson - - override def read(json: JsValue): Terminal = json match { - case JsString(value) => Terminal(value) - case unexpected => throw new Exception(s"Failed to parse Terminal. Expected JsString. Got ${unexpected.getClass}") - } - } - implicit val terminalQueuesJsonFormat: RootJsonFormat[TerminalQueuesJson] = jsonFormat2(TerminalQueuesJson.apply) - implicit object PortCodeJsonFormat extends RootJsonFormat[PortCode] { - override def write(obj: PortCode): JsValue = obj.iata.toJson - - override def read(json: JsValue): PortCode = json match { - case JsString(value) => PortCode(value) - case unexpected => throw new Exception(s"Failed to parse Terminal. Expected JsString. Got ${unexpected.getClass}") - } - } - implicit val portQueuesJsonFormat: RootJsonFormat[PortQueuesJson] = jsonFormat2(PortQueuesJson.apply) implicit object jsonResponseFormat extends RootJsonFormat[QueueJsonResponse] { From 8c8dd236ce7cd66688e26254a7063899b2eb1afc Mon Sep 17 00:00:00 2001 From: Rich Birch Date: Wed, 4 Dec 2024 10:53:52 +0000 Subject: [PATCH 7/7] DRTII-1691 drt-lib release version --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 5a315a92..db558b8f 100644 --- a/build.sbt +++ b/build.sbt @@ -1,6 +1,6 @@ import sbt.Keys.resolvers -lazy val drtLibVersion = "v976" +lazy val drtLibVersion = "v981" lazy val drtCiriumVersion = "203" lazy val akkaHttpVersion = "10.5.3" lazy val akkaVersion = "2.8.5"