Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Setup http4s server #2

Merged
merged 13 commits into from
Jul 8, 2024
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
Loading