Skip to content

Commit

Permalink
Merge pull request #2 from nationalarchives/setup-http4s-server
Browse files Browse the repository at this point in the history
Setup http4s server
  • Loading branch information
TomJKing authored Jul 8, 2024
2 parents af9a4ee + ba5d3e5 commit 7c9e9cd
Show file tree
Hide file tree
Showing 20 changed files with 449 additions and 13 deletions.
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
FROM alpine
FROM alpine:3
#For alpine versions need to create a group before adding a user to the image
WORKDIR /transferservice
RUN addgroup --system transferservicegroup && adduser --system transferserviceuser -G transferservicegroup && \
chown -R transferserviceuser /transferservice && \
apk update && apk upgrade p11-kit busybox expat libretls zlib openssl libcrypto3 libssl3 && \
apk add openjdk17 --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community
COPY target/scala-2.13/transferservice.jar /transferservice

Expand Down
35 changes: 28 additions & 7 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,22 +1,43 @@
import Dependencies.*

ThisBuild / version := "0.0.1-SNAPSHOT"
ThisBuild / scalaVersion := "2.13.14"
ThisBuild / organization := "uk.gov.nationalarchives"

lazy val root = (project in file("."))
.settings(
name := "tdr-transfer-service"
name := "tdr-transfer-service",
libraryDependencies ++= Seq(
authUtils,
catsEffect,
http4sCirce,
http4sDsl,
http4sEmberServer,
keycloakMock % Test,
logbackClassic,
logBackEncoder,
mockito % Test,
pekkoTestKitHttp % Test,
pureConfig,
pureConfigCatsEffect,
scalaTest % Test,
tapirHttp4sServer,
tapirJsonCirce,
tapirSwaggerUI
)
)

(Compile / run / mainClass) := Some("Main")
(Compile / run / mainClass) := Some("uk.gov.nationalarchives.tdr.transfer.service.api.TransferServiceServer")

(assembly / assemblyJarName) := "transferservice.jar"

(assembly / assemblyMergeStrategy) := {
case PathList("META-INF", x, xs @ _*) if x.toLowerCase == "services" => MergeStrategy.filterDistinctLines
case PathList("META-INF", xs @ _*) => MergeStrategy.discard
case PathList("reference.conf") => MergeStrategy.concat
case _ => MergeStrategy.first
case PathList("META-INF", "maven", "org.webjars", "swagger-ui", "pom.properties") => MergeStrategy.singleOrError
case PathList("META-INF", "resources", "webjars", "swagger-ui", _*) => MergeStrategy.singleOrError
case PathList("META-INF", x, xs @ _*) if x.toLowerCase == "services" => MergeStrategy.filterDistinctLines
case PathList("META-INF", xs @ _*) => MergeStrategy.discard
case PathList("reference.conf") => MergeStrategy.concat
case _ => MergeStrategy.first
}

(assembly / mainClass) := Some("Main")
(assembly / mainClass) := Some("uk.gov.nationalarchives.tdr.transfer.service.api.TransferServiceServer")
33 changes: 33 additions & 0 deletions project/Dependencies.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import sbt.*

object Dependencies {
private val http4sVersion = "0.23.26"
private val pureConfigVersion = "0.17.6"
private val tapirVersion = "1.10.7"

lazy val authUtils = "uk.gov.nationalarchives" %% "tdr-auth-utils" % "0.0.196"

lazy val catsEffect = "org.typelevel" %% "cats-effect" % "3.5.4"

lazy val http4sCirce = "org.http4s" %% "http4s-circe" % http4sVersion
lazy val http4sEmberServer = "org.http4s" %% "http4s-ember-server" % http4sVersion
lazy val http4sDsl = "org.http4s" %% "http4s-dsl" % http4sVersion

lazy val keycloakMock = "com.tngtech.keycloakmock" % "mock" % "0.16.0"

lazy val logBackEncoder = "net.logstash.logback" % "logstash-logback-encoder" % "7.4"
lazy val logbackClassic = "ch.qos.logback" % "logback-classic" % "1.5.6"

lazy val mockito = "org.mockito" %% "mockito-scala" % "1.17.31"
lazy val mockitoScalaTest = "org.mockito" %% "mockito-scala-scalatest" % "1.17.31"

lazy val pekkoTestKitHttp = "org.apache.pekko" %% "pekko-http-testkit" % "1.0.1"
lazy val pureConfig = "com.github.pureconfig" %% "pureconfig" % pureConfigVersion
lazy val pureConfigCatsEffect = "com.github.pureconfig" %% "pureconfig-cats-effect" % pureConfigVersion

lazy val tapirHttp4sServer = "com.softwaremill.sttp.tapir" %% "tapir-http4s-server" % tapirVersion
lazy val tapirJsonCirce = "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % tapirVersion
lazy val tapirSwaggerUI = "com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle" % tapirVersion

lazy val scalaTest = "org.scalatest" %% "scalatest" % "3.2.18"
}
10 changes: 10 additions & 0 deletions src/main/resources/application.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
api {
port = "8080"
port = ${?API_PORT}
}

auth {
url = "https://auth.tdr-integration.nationalarchives.gov.uk"
url = ${?AUTH_URL}
realm = "tdr"
}
14 changes: 14 additions & 0 deletions src/main/resources/logback.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- encoders are assigned the type
ch.qos.logback.classic.encoder.PatternLayoutEncoder by default -->
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<customFields>{"application":"transfer-service"}</customFields>
</encoder>
</appender>

<root level="info">
<appender-ref ref="STDOUT" />
</root>
</configuration>
5 changes: 0 additions & 5 deletions src/main/scala/Main.scala

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package uk.gov.nationalarchives.tdr.transfer.service

import pureconfig.ConfigSource
import pureconfig.generic.auto._

object ApplicationConfig {
case class Api(port: Int)
case class Auth(url: String, realm: String)

case class Configuration(auth: Auth, api: Api)

val appConfig: Configuration = ConfigSource.default.load[Configuration] match {
case Left(value) => throw new RuntimeException(s"Failed to load transfer service application configuration ${value.prettyPrint()}")
case Right(value) => value
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package uk.gov.nationalarchives.tdr.transfer.service.api

import cats.data.Kleisli
import cats.effect.{ExitCode, IO, IOApp}
import cats.implicits.toSemigroupKOps
import com.comcast.ip4s.{IpLiteralSyntax, Port}
import org.http4s.dsl.io._
import org.http4s.ember.server.EmberServerBuilder
import org.http4s.server.middleware.Logger
import org.http4s.{HttpRoutes, Request, Response}
import sttp.apispec.openapi.Info
import sttp.tapir.server.http4s.Http4sServerInterpreter
import sttp.tapir.swagger.bundle.SwaggerInterpreter
import uk.gov.nationalarchives.tdr.transfer.service.ApplicationConfig
import uk.gov.nationalarchives.tdr.transfer.service.api.controllers.LoadController

object TransferServiceServer extends IOApp {
private val apiPort: Port = Port.fromInt(ApplicationConfig.appConfig.api.port).getOrElse(port"8080")

private val infoTitle = "TDR Transfer Service API"
private val infoVersion = "0.0.1"
private val infoDescription = Some("APIs to allow client services to transfer records to TDR")

private val openApiInfo: Info = Info(infoTitle, infoVersion, description = infoDescription)
private val loadController = LoadController.apply()

private val documentationEndpoints =
SwaggerInterpreter().fromEndpoints[IO](loadController.endpoints, openApiInfo)

val healthCheckRoute: HttpRoutes[IO] = HttpRoutes.of[IO] { case GET -> Root / "healthcheck" =>
Ok("Healthy")
}

private val allRoutes =
Http4sServerInterpreter[IO]().toRoutes(documentationEndpoints) <+> loadController.routes <+> healthCheckRoute

private val app: Kleisli[IO, Request[IO], Response[IO]] = allRoutes.orNotFound

private val finalApp = Logger.httpApp(logHeaders = true, logBody = false)(app)

private val transferServiceServer = EmberServerBuilder
.default[IO]
.withHost(ipv4"0.0.0.0")
.withPort(apiPort)
.withHttpApp(finalApp)
.build

override def run(args: List[String]): IO[ExitCode] = {
transferServiceServer.use(_ => IO.never).as(ExitCode.Success)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package uk.gov.nationalarchives.tdr.transfer.service.api.auth

import cats.effect.IO
import uk.gov.nationalarchives.tdr.keycloak.{KeycloakUtils, TdrKeycloakDeployment, Token}
import uk.gov.nationalarchives.tdr.transfer.service.ApplicationConfig
import uk.gov.nationalarchives.tdr.transfer.service.api.errors.BackendException.AuthenticationError

import scala.concurrent.ExecutionContext

case class AuthenticatedContext(token: Token)

class TokenAuthenticator {
implicit val ec: ExecutionContext = scala.concurrent.ExecutionContext.Implicits.global
private val appConfig = ApplicationConfig.appConfig

private val authUrl = appConfig.auth.url
private val realm = appConfig.auth.realm

implicit val tdrKeycloakDeployment: TdrKeycloakDeployment =
TdrKeycloakDeployment(s"$authUrl", realm, 8080)

def authenticateUserToken(bearer: String): IO[Either[AuthenticationError, AuthenticatedContext]] = {
IO {
KeycloakUtils().token(bearer) match {
case Right(t) => Right(AuthenticatedContext(t))
case Left(e) =>
Left {
println(e.getMessage)
AuthenticationError(e.getMessage)
}
}
}
}
}

object TokenAuthenticator {
def apply() = new TokenAuthenticator
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package uk.gov.nationalarchives.tdr.transfer.service.api.controllers

import cats.effect.IO
import sttp.model.StatusCode
import sttp.tapir.json.circe.jsonBody
import sttp.tapir.server.PartialServerEndpoint
import sttp.tapir.{Endpoint, auth, endpoint, statusCode}
import uk.gov.nationalarchives.tdr.transfer.service.api.auth.{AuthenticatedContext, TokenAuthenticator}
import uk.gov.nationalarchives.tdr.transfer.service.api.errors.BackendException.AuthenticationError
import uk.gov.nationalarchives.tdr.transfer.service.api.model.Serializers._

trait BaseController {

private val tokenAuthenticator = TokenAuthenticator()

private val securedWithBearerEndpoint: Endpoint[String, Unit, AuthenticationError, Unit, Any] = endpoint
.securityIn(auth.bearer[String]())
.errorOut(statusCode(StatusCode.Unauthorized))
.errorOut(jsonBody[AuthenticationError])

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

import cats.effect.IO
import org.http4s.HttpRoutes
import sttp.tapir.{Endpoint, _}
import sttp.tapir.json.circe.jsonBody
import sttp.tapir.server.PartialServerEndpoint
import sttp.tapir.server.http4s.Http4sServerInterpreter
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.Consignment.ConsignmentDetails
import uk.gov.nationalarchives.tdr.transfer.service.api.model.Serializers._
import uk.gov.nationalarchives.tdr.transfer.service.services.ConsignmentService

class LoadController(consignmentService: ConsignmentService) extends BaseController {
def endpoints: List[Endpoint[String, Unit, BackendException.AuthenticationError, ConsignmentDetails, Any]] = List(initiateLoadEndpoint.endpoint)

def routes: HttpRoutes[IO] = initiateLoadRoute

private val initiateLoadEndpoint: PartialServerEndpoint[String, AuthenticatedContext, Unit, BackendException.AuthenticationError, ConsignmentDetails, Any, IO] = securedWithBearer
.summary("Initiate the load of records and metadata")
.post
.in("load" / "sharepoint" / "initiate")
.out(jsonBody[ConsignmentDetails])

val initiateLoadRoute: HttpRoutes[IO] =
Http4sServerInterpreter[IO]().toRoutes(initiateLoadEndpoint.serverLogicSuccess(ac => _ => consignmentService.createConsignment(ac.token)))
}

object LoadController {
def apply() = new LoadController(ConsignmentService())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package uk.gov.nationalarchives.tdr.transfer.service.api.errors

sealed trait BackendException extends Exception

object BackendException {
case class AuthenticationError(message: String) extends BackendException
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package uk.gov.nationalarchives.tdr.transfer.service.api.model

import java.util.UUID

sealed trait ConsignmentModel

object Consignment {
case class ConsignmentDetails(consignmentId: UUID) extends ConsignmentModel
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package uk.gov.nationalarchives.tdr.transfer.service.api.model

import io.circe.generic.AutoDerivation
import sttp.tapir.generic.auto.SchemaDerivation
import sttp.tapir.generic.{Configuration => TapirConfiguration}

object Serializers extends AutoDerivation with SchemaDerivation {
implicit val schemaConfiguration: TapirConfiguration = TapirConfiguration.default.withDiscriminator("type")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package uk.gov.nationalarchives.tdr.transfer.service.services

import cats.effect.IO
import cats.implicits.catsSyntaxApplicativeId
import uk.gov.nationalarchives.tdr.keycloak.Token
import uk.gov.nationalarchives.tdr.transfer.service.api.model.Consignment.ConsignmentDetails

import java.util.UUID

class ConsignmentService {
def createConsignment(token: Token): IO[ConsignmentDetails] = {
// For now just return dummy response
ConsignmentDetails(UUID.fromString("ae4b7cad-ee83-46bd-b952-80bc8263c6c2")).pure[IO]
}
}

object ConsignmentService {
def apply() = new ConsignmentService
}
8 changes: 8 additions & 0 deletions src/test/resources/application.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
api {
port = "8080"
}

auth {
url = "http://localhost:8000/auth"
realm = "tdr"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package uk.gov.nationalarchives.tdr.transfer.service

import com.tngtech.keycloakmock.api.TokenConfig.aTokenConfig
import com.tngtech.keycloakmock.api.{KeycloakMock, ServerConfig}
import org.apache.pekko.http.scaladsl.model.headers.OAuth2BearerToken
import org.scalatest.BeforeAndAfterEach
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

import java.util.UUID

object TestUtils extends AnyFlatSpec with Matchers with BeforeAndAfterEach {
private val tdrKeycloakMock: KeycloakMock = createServer("tdr", 8000)
private val testKeycloakMock: KeycloakMock = createServer("test", 8001)

val userId: UUID = UUID.fromString("4ab14990-ed63-4615-8336-56fbb9960300")

def validUserToken(userId: UUID = userId, body: String = "Code", standardUser: String = "true"): OAuth2BearerToken =
OAuth2BearerToken(
tdrKeycloakMock.getAccessToken(
aTokenConfig()
.withResourceRole("tdr", "tdr_user")
.withClaim("body", body)
.withClaim("user_id", userId)
.withClaim("standard_user", standardUser)
.build
)
)

def invalidToken: OAuth2BearerToken = OAuth2BearerToken(testKeycloakMock.getAccessToken(aTokenConfig().build))

private def createServer(realm: String, port: Int): KeycloakMock = {
val config = ServerConfig.aServerConfig().withPort(port).withDefaultRealm(realm).build()
val mock: KeycloakMock = new KeycloakMock(config)
mock.start()
mock
}
}
Loading

0 comments on commit 7c9e9cd

Please sign in to comment.