Skip to content

Commit

Permalink
Manually merge from 'main'
Browse files Browse the repository at this point in the history
  • Loading branch information
TomJKing committed Aug 13, 2024
2 parents 200dd95 + 5de4cd0 commit c5afced
Show file tree
Hide file tree
Showing 11 changed files with 240 additions and 48 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
repo-name: tdr-transfer-service
image-name: transfer-service
build-command: |
sbt dist
sbt assembly
secrets:
MANAGEMENT_ACCOUNT: ${{ secrets.MANAGEMENT_ACCOUNT }}
WORKFLOW_PAT: ${{ secrets.WORKFLOW_PAT }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ on:
pull_request:
push:
branches-ignore:
- master
- main
- release-*
permissions:
id-token: write
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import uk.gov.nationalarchives.tdr.transfer.service.api.errors.BackendException.
import uk.gov.nationalarchives.tdr.transfer.service.api.model.Serializers._
import uk.gov.nationalarchives.tdr.transfer.service.api.model.SourceSystem.SourceSystemEnum.SourceSystem

import java.util.UUID

trait BaseController {

val sourceSystem: EndpointInput[SourceSystem] = path("sourceSystem")
Expand All @@ -21,6 +23,8 @@ trait BaseController {
.errorOut(statusCode(StatusCode.Unauthorized))
.errorOut(jsonBody[AuthenticationError])

val transferId: EndpointInput[UUID] = path("transferId")

val securedWithBearer: PartialServerEndpoint[String, AuthenticatedContext, Unit, AuthenticationError, Unit, Any, IO] = securedWithBearerEndpoint
.serverSecurityLogic(
tokenAuthenticator.authenticateUserToken
Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,28 @@
package uk.gov.nationalarchives.tdr.transfer.service.api.controllers

import cats.effect.IO
import graphql.codegen.AddConsignment.{addConsignment => ac}
import graphql.codegen.StartUpload.{startUpload => su}
import cats.implicits.toSemigroupKOps
import org.http4s.HttpRoutes
import sttp.client3.{Identity, SttpBackend}
import sttp.tapir._
import sttp.tapir.json.circe.jsonBody
import sttp.tapir.server.PartialServerEndpoint
import sttp.tapir.server.http4s.Http4sServerInterpreter
import uk.gov.nationalarchives.tdr.GraphQLClient
import uk.gov.nationalarchives.tdr.transfer.service.ApplicationConfig
import uk.gov.nationalarchives.tdr.transfer.service.ApplicationConfig.appConfig
import uk.gov.nationalarchives.tdr.transfer.service.api.auth.AuthenticatedContext
import uk.gov.nationalarchives.tdr.transfer.service.api.errors.BackendException
import uk.gov.nationalarchives.tdr.transfer.service.api.model.LoadModel.{AWSS3LoadDestination, LoadDetails}
import uk.gov.nationalarchives.tdr.transfer.service.api.model.LoadModel.LoadDetails
import uk.gov.nationalarchives.tdr.transfer.service.api.model.Serializers._
import uk.gov.nationalarchives.tdr.transfer.service.api.model.SourceSystem.SourceSystemEnum.SourceSystem
import uk.gov.nationalarchives.tdr.transfer.service.services.GraphQlApiService
import uk.gov.nationalarchives.tdr.transfer.service.services.dataload.{DataLoadInitiation, DataLoadProcessor}

import java.util.UUID
import scala.concurrent.ExecutionContext.Implicits.global

class LoadController(graphqlApiService: GraphQlApiService) extends BaseController {
private val s3Config = ApplicationConfig.appConfig.s3
class LoadController(dataLoadInitiation: DataLoadInitiation, dataLoadProcessor: DataLoadProcessor) extends BaseController {
def endpoints: List[
Endpoint[String, _ >: SourceSystem with (SourceSystem, UUID) <: Serializable, BackendException.AuthenticationError, _ >: LoadDetails with String <: Serializable, Any]
] =
List(initiateLoadEndpoint.endpoint, completeLoadEndpoint.endpoint)

def endpoints: List[Endpoint[String, SourceSystem, BackendException.AuthenticationError, LoadDetails, Any]] = List(initiateLoadEndpoint.endpoint)

def routes: HttpRoutes[IO] = initiateLoadRoute
def routes: HttpRoutes[IO] = initiateLoadRoute <+> completeLoadRoute

private val initiateLoadEndpoint: PartialServerEndpoint[String, AuthenticatedContext, SourceSystem, BackendException.AuthenticationError, LoadDetails, Any, IO] =
securedWithBearer
Expand All @@ -36,32 +31,21 @@ class LoadController(graphqlApiService: GraphQlApiService) extends BaseControlle
.in("load" / sourceSystem / "initiate")
.out(jsonBody[LoadDetails])

private def loadDetails(consignmentId: UUID, userId: UUID): IO[LoadDetails] = {
val recordsS3Bucket = AWSS3LoadDestination(s"${s3Config.recordsUploadBucket}", s"$userId/$consignmentId")
val metadataS3Bucket = AWSS3LoadDestination(s"${s3Config.metadataUploadBucket}", s"$consignmentId/dataload/data-load-metadata.csv")
IO(LoadDetails(consignmentId, recordsLoadDestination = recordsS3Bucket, metadataLoadDestination = metadataS3Bucket))
}
private val completeLoadEndpoint: PartialServerEndpoint[String, AuthenticatedContext, (SourceSystem, UUID), BackendException.AuthenticationError, String, Any, IO] =
securedWithBearer
.summary("Notify that loading has completed")
.description("Triggers the processing of the transfer's loaded metadata and records in TDR")
.post
.in("load" / sourceSystem / "complete" / transferId)
.out(jsonBody[String])

val initiateLoadRoute: HttpRoutes[IO] =
Http4sServerInterpreter[IO]().toRoutes(
initiateLoadEndpoint.serverLogicSuccess(ac =>
_ =>
for {
addConsignmentResult <- graphqlApiService.addConsignment(ac.token)
consignmentId = addConsignmentResult.consignmentid.get
_ <- graphqlApiService.startUpload(ac.token, consignmentId)
result <- loadDetails(consignmentId, ac.token.userId)
} yield result
)
)
Http4sServerInterpreter[IO]().toRoutes(initiateLoadEndpoint.serverLogicSuccess(ac => _ => dataLoadInitiation.initiateConsignmentLoad(ac.token)))

val completeLoadRoute: HttpRoutes[IO] =
Http4sServerInterpreter[IO]().toRoutes(completeLoadEndpoint.serverLogicSuccess(ac => ci => dataLoadProcessor.trigger(ci._2, ac.token)))
}

object LoadController {

def apply()(implicit backend: SttpBackend[Identity, Any]) = new LoadController(
GraphQlApiService.apply(
new GraphQLClient[ac.Data, ac.Variables](appConfig.consignmentApi.url),
new GraphQLClient[su.Data, su.Variables](appConfig.consignmentApi.url)
)
)
def apply() = new LoadController(DataLoadInitiation(), DataLoadProcessor())
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@ import java.util.UUID

sealed trait LoadModel
sealed trait LoadDestinationModel
sealed trait MetadataPropertyModel

object LoadModel {
case class AWSS3LoadDestination(bucketName: String, bucketKey: String) extends LoadDestinationModel
case class LoadDetails(consignmentId: UUID, recordsLoadDestination: AWSS3LoadDestination, metadataLoadDestination: AWSS3LoadDestination) extends LoadModel
case class MetadataPropertyDetails(propertyName: String, required: Boolean) extends MetadataPropertyModel
case class AWSS3LoadDestination(bucketName: String, bucketKeyPrefix: String) extends LoadDestinationModel
case class LoadDetails(
transferId: UUID,
recordsLoadDestination: AWSS3LoadDestination,
metadataLoadDestination: AWSS3LoadDestination,
metadataProperties: List[MetadataPropertyDetails] = List()
) extends LoadModel
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import graphql.codegen.AddConsignment
import graphql.codegen.AddConsignment.{addConsignment => ac}
import graphql.codegen.StartUpload.{startUpload => su}
import graphql.codegen.types.{AddConsignmentInput, StartUploadInput}
import sttp.client3.{Identity, SttpBackend}
import sttp.client3.{HttpURLConnectionBackend, Identity, SttpBackend}
import uk.gov.nationalarchives.tdr.GraphQLClient
import uk.gov.nationalarchives.tdr.keycloak.Token
import uk.gov.nationalarchives.tdr.transfer.service.ApplicationConfig.appConfig

import java.util.UUID
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.Future

class GraphQlApiService(addConsignmentClient: GraphQLClient[ac.Data, ac.Variables], startUploadClient: GraphQLClient[su.Data, su.Variables])(implicit
Expand All @@ -37,6 +39,14 @@ class GraphQlApiService(addConsignmentClient: GraphQLClient[ac.Data, ac.Variable
}

object GraphQlApiService {
implicit val backend: SttpBackend[Identity, Any] = HttpURLConnectionBackend()
private val apiUrl = appConfig.consignmentApi.url

val service: GraphQlApiService = GraphQlApiService.apply(
new GraphQLClient[ac.Data, ac.Variables](apiUrl),
new GraphQLClient[su.Data, su.Variables](apiUrl)
)

def apply(
addConsignmentClient: GraphQLClient[ac.Data, ac.Variables],
startUploadClient: GraphQLClient[su.Data, su.Variables]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package uk.gov.nationalarchives.tdr.transfer.service.services.dataload

import cats.effect.IO
import uk.gov.nationalarchives.tdr.keycloak.Token
import uk.gov.nationalarchives.tdr.transfer.service.ApplicationConfig
import uk.gov.nationalarchives.tdr.transfer.service.api.model.LoadModel.{AWSS3LoadDestination, LoadDetails}
import uk.gov.nationalarchives.tdr.transfer.service.services.GraphQlApiService
import uk.gov.nationalarchives.tdr.transfer.service.services.dataload.DataLoadInitiation.s3Config

import java.util.UUID

class DataLoadInitiation(graphQlApiService: GraphQlApiService) {
def initiateConsignmentLoad(token: Token): IO[LoadDetails] = {
for {
addConsignmentResult <- graphQlApiService.addConsignment(token)
consignmentId = addConsignmentResult.consignmentid.get
_ <- graphQlApiService.startUpload(token, consignmentId)
result <- loadDetails(consignmentId, token.userId)
} yield result
}

private def loadDetails(transferId: UUID, userId: UUID): IO[LoadDetails] = {
val recordsS3Bucket = AWSS3LoadDestination(s"${s3Config.recordsUploadBucket}", s"$userId/$transferId")
val metadataS3Bucket = AWSS3LoadDestination(s"${s3Config.metadataUploadBucket}", s"$transferId/dataload")
IO(LoadDetails(transferId, recordsLoadDestination = recordsS3Bucket, metadataLoadDestination = metadataS3Bucket))
}
}

object DataLoadInitiation {
val s3Config: ApplicationConfig.S3 = ApplicationConfig.appConfig.s3
def apply() = new DataLoadInitiation(GraphQlApiService.service)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package uk.gov.nationalarchives.tdr.transfer.service.services.dataload

import cats.effect.IO
import uk.gov.nationalarchives.tdr.keycloak.Token

import java.util.UUID

class DataLoadProcessor {
def trigger(transferId: UUID, token: Token): IO[String] = {
// Trigger data load processing
IO("Data Load Processor: Stubbed Response")
}
}

object DataLoadProcessor {
def apply() = new DataLoadProcessor
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package uk.gov.nationalarchives.tdr.transfer.service.api
import cats.effect.IO
import cats.effect.unsafe.implicits.global
import io.circe.Json
import io.circe.generic.auto.exportEncoder
import io.circe.syntax.KeyOps
import org.http4s.circe.jsonDecoder
import org.http4s.implicits.http4sLiteralsSyntax
Expand All @@ -11,11 +12,15 @@ import org.scalatest.matchers.should.Matchers
import org.typelevel.ci.CIString
import uk.gov.nationalarchives.tdr.transfer.service.TestUtils.{invalidToken, userId, validUserToken}
import uk.gov.nationalarchives.tdr.transfer.service.api.controllers.LoadController
import uk.gov.nationalarchives.tdr.transfer.service.api.model.LoadModel.MetadataPropertyDetails
import uk.gov.nationalarchives.tdr.transfer.service.services.ExternalServicesSpec

class TransferServiceServerSpec extends ExternalServicesSpec with Matchers {

val consignmentId = "6e3b76c4-1745-4467-8ac5-b4dd736e1b3e"
val transferId = "6e3b76c4-1745-4467-8ac5-b4dd736e1b3e"
private val invalidTokenExpectedResponse = Json.obj(
"message" := "Invalid token issuer. Expected 'http://localhost:8000/auth/realms/tdr'"
)

"'healthcheck' endpoint" should "return 200 if server running" in {
val getHealthCheck = Request[IO](Method.GET, uri"/healthcheck")
Expand All @@ -42,18 +47,21 @@ class TransferServiceServerSpec extends ExternalServicesSpec with Matchers {

val recordsDestination = Json.obj(
"bucketName" := "s3BucketNameRecords",
"bucketKey" := s"$userId/$consignmentId"
"bucketKeyPrefix" := s"$userId/$transferId"
)

val metadataLoadDestination = Json.obj(
"bucketName" := "s3BucketNameMetadata",
"bucketKey" := s"$consignmentId/dataload/data-load-metadata.csv"
"bucketKeyPrefix" := s"$transferId/dataload"
)

val metadataProperties: List[MetadataPropertyDetails] = List()

val expectedResponse = Json.obj(
"consignmentId" := consignmentId,
"transferId" := transferId,
"recordsLoadDestination" := recordsDestination,
"metadataLoadDestination" := metadataLoadDestination
"metadataLoadDestination" := metadataLoadDestination,
"metadataProperties" := metadataProperties
)

response.status shouldBe Status.Ok
Expand All @@ -79,6 +87,42 @@ class TransferServiceServerSpec extends ExternalServicesSpec with Matchers {
)

response.status shouldBe Status.Unauthorized
response.as[Json].unsafeRunSync() shouldEqual expectedResponse
response.as[Json].unsafeRunSync() shouldEqual invalidTokenExpectedResponse
}

"'load/sharepoint/complete' endpoint" should "return 200 with correct authorisation header" in {
val validToken = validUserToken()
val bearer = CIString("Authorization")
val authHeader = Header.Raw.apply(bearer, s"$validToken")
val fakeHeaders = Headers.apply(authHeader)
val response = LoadController
.apply()
.completeLoadRoute
.orNotFound
.run(
Request(method = Method.POST, uri = uri"/load/sharepoint/complete/6e3b76c4-1745-4467-8ac5-b4dd736e1b3e", headers = fakeHeaders)
)
.unsafeRunSync()

response.status shouldBe Status.Ok
response.as[String].unsafeRunSync() shouldEqual "\"Data Load Processor: Stubbed Response\""
}

"'load/sharepoint/complete' endpoint" should "return 401 response with incorrect authorisation header" in {
val token = invalidToken
val bearer = CIString("Authorization")
val authHeader = Header.Raw.apply(bearer, s"$token")
val fakeHeaders = Headers.apply(authHeader)
val response = LoadController
.apply()
.completeLoadRoute
.orNotFound
.run(
Request(method = Method.POST, uri = uri"/load/sharepoint/complete/6e3b76c4-1745-4467-8ac5-b4dd736e1b3e", headers = fakeHeaders)
)
.unsafeRunSync()

response.status shouldBe Status.Unauthorized
response.as[Json].unsafeRunSync() shouldEqual invalidTokenExpectedResponse
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package uk.gov.nationalarchives.tdr.transfer.service.services.dataload

import cats.effect.IO
import cats.effect.unsafe.implicits.global
import com.nimbusds.oauth2.sdk.token.BearerAccessToken
import graphql.codegen.AddConsignment.addConsignment.AddConsignment
import uk.gov.nationalarchives.tdr.keycloak.Token
import uk.gov.nationalarchives.tdr.transfer.service.BaseSpec
import uk.gov.nationalarchives.tdr.transfer.service.api.model.LoadModel.{AWSS3LoadDestination, LoadDetails}
import uk.gov.nationalarchives.tdr.transfer.service.services.GraphQlApiService

import java.util.UUID

class DataLoadInitiationSpec extends BaseSpec {
private val mockToken = mock[Token]
private val mockBearerAccessToken = mock[BearerAccessToken]
private val consignmentId = UUID.fromString("6e3b76c4-1745-4467-8ac5-b4dd736e1b3e")
private val userId = UUID.randomUUID()

"'initiateConsignmentLoad'" should "create a consignment and return expected 'LoadDetails' object" in {
val addConsignmentResponse = AddConsignment(Some(consignmentId), None)
val mockGraphQlApiService = mock[GraphQlApiService]

when(mockGraphQlApiService.addConsignment(mockToken)).thenReturn(IO(addConsignmentResponse))
when(mockGraphQlApiService.startUpload(mockToken, consignmentId)).thenReturn(IO("response string"))
when(mockToken.bearerAccessToken).thenReturn(mockBearerAccessToken)
when(mockToken.bearerAccessToken.getValue).thenReturn("some value")
when(mockToken.userId).thenReturn(userId)

val expectedResult = LoadDetails(
consignmentId,
AWSS3LoadDestination("s3BucketNameRecords", s"$userId/$consignmentId"),
AWSS3LoadDestination("s3BucketNameMetadata", s"$consignmentId/dataload"),
List()
)

val service = new DataLoadInitiation(mockGraphQlApiService)
val result = service.initiateConsignmentLoad(mockToken).unsafeRunSync()
result shouldBe expectedResult
verify(mockGraphQlApiService, times(1)).addConsignment(mockToken)
verify(mockGraphQlApiService, times(1)).startUpload(mockToken, consignmentId, None)
}

"'initiateConsignmentLoad'" should "throw an error if 'addConsignment' GraphQl service call fails" in {
val mockGraphQlApiService = mock[GraphQlApiService]
when(mockGraphQlApiService.addConsignment(mockToken)).thenThrow(new RuntimeException("Error adding consignment"))

val service = new DataLoadInitiation(mockGraphQlApiService)

val exception = intercept[RuntimeException] {
service.initiateConsignmentLoad(mockToken).attempt.unsafeRunSync()
}
exception.getMessage shouldBe "Error adding consignment"
verify(mockGraphQlApiService, times(1)).addConsignment(mockToken)
verify(mockGraphQlApiService, times(0)).startUpload(mockToken, consignmentId, None)
}

"'initiateConsignmentLoad'" should "throw an error if 'startUpload' GraphQl service call fails" in {
val addConsignmentResponse = AddConsignment(Some(consignmentId), None)
val mockGraphQlApiService = mock[GraphQlApiService]

when(mockGraphQlApiService.addConsignment(mockToken)).thenReturn(IO(addConsignmentResponse))
when(mockGraphQlApiService.startUpload(mockToken, consignmentId)).thenThrow(new RuntimeException("Error starting upload"))

val service = new DataLoadInitiation(mockGraphQlApiService)
val response = service.initiateConsignmentLoad(mockToken).attempt.unsafeRunSync()

response.isLeft should equal(true)
response.left.value.getMessage should equal("Error starting upload")
verify(mockGraphQlApiService, times(1)).addConsignment(mockToken)
verify(mockGraphQlApiService, times(1)).startUpload(mockToken, consignmentId, None)
}
}
Loading

0 comments on commit c5afced

Please sign in to comment.