diff --git a/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/JiraClient.scala b/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/JiraClient.scala index d9e809be5b..291fbfa1ea 100644 --- a/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/JiraClient.scala +++ b/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/JiraClient.scala @@ -1,6 +1,8 @@ package ch.epfl.bluebrain.nexus.delta.plugins.jira import akka.http.scaladsl.model.Uri +import cats.effect.IO +import cats.syntax.all._ import ch.epfl.bluebrain.nexus.delta.plugins.jira.JiraError.{AccessTokenExpected, NoTokenError, RequestTokenExpected} import ch.epfl.bluebrain.nexus.delta.plugins.jira.OAuthToken.{AccessToken, RequestToken} import ch.epfl.bluebrain.nexus.delta.plugins.jira.config.JiraConfig @@ -13,7 +15,6 @@ import com.google.api.client.http.{ByteArrayContent, GenericUrl} import com.typesafe.scalalogging.Logger import io.circe.JsonObject import io.circe.syntax.EncoderOps -import monix.bio.{IO, Task} import org.apache.commons.codec.binary.Base64 import java.nio.charset.StandardCharsets @@ -29,47 +30,47 @@ trait JiraClient { /** * Creates an authorization request for the current user */ - def requestToken()(implicit caller: User): IO[JiraError, AuthenticationRequest] + def requestToken()(implicit caller: User): IO[AuthenticationRequest] /** * Generates an access token for the current user by providing the verifier code provided by the user */ - def accessToken(verifier: Verifier)(implicit caller: User): IO[JiraError, Unit] + def accessToken(verifier: Verifier)(implicit caller: User): IO[Unit] /** * Create an issue on behalf of the user in Jira * @param payload * the issue payload */ - def createIssue(payload: JsonObject)(implicit caller: User): IO[JiraError, JiraResponse] + def createIssue(payload: JsonObject)(implicit caller: User): IO[JiraResponse] /** * Edits an issue on behalf of the user in Jira * @param payload * the issue payload */ - def editIssue(issueId: String, payload: JsonObject)(implicit caller: User): IO[JiraError, JiraResponse] + def editIssue(issueId: String, payload: JsonObject)(implicit caller: User): IO[JiraResponse] /** * Get the issue matching the provided identifier * @param issueId * the identifier */ - def getIssue(issueId: String)(implicit caller: User): IO[JiraError, JiraResponse] + def getIssue(issueId: String)(implicit caller: User): IO[JiraResponse] /** * List the projects the current user has access to * @param recent * when provided, return the n most recent projects the user was active in */ - def listProjects(recent: Option[Int])(implicit caller: User): IO[JiraError, JiraResponse] + def listProjects(recent: Option[Int])(implicit caller: User): IO[JiraResponse] /** * Search issues in Jira the user has access to according to the provided search payload * @param payload * the search payload */ - def search(payload: JsonObject)(implicit caller: User): IO[JiraError, JiraResponse] + def search(payload: JsonObject)(implicit caller: User): IO[JiraResponse] } @@ -101,34 +102,32 @@ object JiraClient { * @param jiraConfig * the jira configuration */ - def apply(store: TokenStore, jiraConfig: JiraConfig): Task[JiraClient] = { - Task - .delay { - // Create the RSA signer according to the PKCS8 key provided by the configuration - val privateBytes = Base64.decodeBase64(jiraConfig.privateKey.value) - val keySpec = new PKCS8EncodedKeySpec(privateBytes) - val kf = KeyFactory.getInstance("RSA") - val signer = new OAuthRsaSigner() - signer.privateKey = kf.generatePrivate(keySpec) - signer - } + def apply(store: TokenStore, jiraConfig: JiraConfig): IO[JiraClient] = { + IO { + // Create the RSA signer according to the PKCS8 key provided by the configuration + val privateBytes = Base64.decodeBase64(jiraConfig.privateKey.value) + val keySpec = new PKCS8EncodedKeySpec(privateBytes) + val kf = KeyFactory.getInstance("RSA") + val signer = new OAuthRsaSigner() + signer.privateKey = kf.generatePrivate(keySpec) + signer + } .map { signer => new JiraClient { private val netHttpTransport = new NetHttpTransport() - override def requestToken()(implicit caller: User): IO[JiraError, AuthenticationRequest] = - Task - .delay { - val tempToken = new JiraOAuthGetTemporaryToken(jiraConfig.base) - tempToken.consumerKey = jiraConfig.consumerKey - tempToken.signer = signer - tempToken.transport = netHttpTransport - tempToken.callback = "oob" - val response = tempToken.execute() - logger.debug(s"Request Token value: ${response.token}") - response.token - } + override def requestToken()(implicit caller: User): IO[AuthenticationRequest] = + IO { + val tempToken = new JiraOAuthGetTemporaryToken(jiraConfig.base) + tempToken.consumerKey = jiraConfig.consumerKey + tempToken.signer = signer + tempToken.transport = netHttpTransport + tempToken.callback = "oob" + val response = tempToken.execute() + logger.debug(s"Request Token value: ${response.token}") + response.token + } .flatMap { token => store.save(caller, RequestToken(token)).as { val authorizationURL = @@ -137,33 +136,32 @@ object JiraClient { AuthenticationRequest(Uri(authorizationURL.toString)) } } - .mapError { JiraError.from } + .adaptError { e => JiraError.from(e) } - override def accessToken(verifier: Verifier)(implicit caller: User): IO[JiraError, Unit] = + override def accessToken(verifier: Verifier)(implicit caller: User): IO[Unit] = store .get(caller) .flatMap { case None => IO.raiseError(NoTokenError) case Some(_: AccessToken) => IO.raiseError(RequestTokenExpected) case Some(RequestToken(value)) => - Task - .delay { - val accessToken = new JiraOAuthGetAccessToken(jiraConfig.base) - accessToken.consumerKey = jiraConfig.consumerKey - accessToken.signer = signer - accessToken.transport = netHttpTransport - accessToken.verifier = verifier.value - accessToken.temporaryToken = value - accessToken.execute().token - } + IO { + val accessToken = new JiraOAuthGetAccessToken(jiraConfig.base) + accessToken.consumerKey = jiraConfig.consumerKey + accessToken.signer = signer + accessToken.transport = netHttpTransport + accessToken.verifier = verifier.value + accessToken.temporaryToken = value + accessToken.execute().token + } .flatMap { token => logger.debug("Access Token:" + token) store.save(caller, AccessToken(token)) } } - .mapError { JiraError.from } + .adaptError { e => JiraError.from(e) } - override def createIssue(payload: JsonObject)(implicit caller: User): IO[JiraError, JiraResponse] = + override def createIssue(payload: JsonObject)(implicit caller: User): IO[JiraResponse] = requestFactory(caller).flatMap { factory => val url = jiraConfig.base / issueUrl JiraResponse( @@ -176,7 +174,7 @@ object JiraClient { override def editIssue(issueId: String, payload: JsonObject)(implicit caller: User - ): IO[JiraError, JiraResponse] = + ): IO[JiraResponse] = requestFactory(caller).flatMap { factory => val url = jiraConfig.base / issueUrl / issueId JiraResponse( @@ -187,7 +185,7 @@ object JiraClient { ) } - override def getIssue(issueId: String)(implicit caller: User): IO[JiraError, JiraResponse] = + override def getIssue(issueId: String)(implicit caller: User): IO[JiraResponse] = requestFactory(caller).flatMap { factory => val url = jiraConfig.base / issueUrl / issueId JiraResponse( @@ -197,7 +195,7 @@ object JiraClient { ) } - override def listProjects(recent: Option[Int])(implicit caller: User): IO[JiraError, JiraResponse] = + override def listProjects(recent: Option[Int])(implicit caller: User): IO[JiraResponse] = requestFactory(caller).flatMap { factory => val url = recent.fold(jiraConfig.base / projectUrl) { r => (jiraConfig.base / projectUrl).withQuery(Uri.Query("recent" -> r.toString)) @@ -209,7 +207,7 @@ object JiraClient { ) } - def search(payload: JsonObject)(implicit caller: User): IO[JiraError, JiraResponse] = + def search(payload: JsonObject)(implicit caller: User): IO[JiraResponse] = requestFactory(caller).flatMap { factory => JiraResponse( factory.buildPostRequest( @@ -219,7 +217,7 @@ object JiraClient { ) } - private def requestFactory(caller: User) = store.get(caller).hideErrors.flatMap { + private def requestFactory(caller: User) = store.get(caller).flatMap { case None => IO.raiseError(NoTokenError) case Some(_: RequestToken) => IO.raiseError(AccessTokenExpected) case Some(AccessToken(token)) => diff --git a/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/JiraPluginModule.scala b/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/JiraPluginModule.scala index 417f2ed4c4..b3f2894b6e 100644 --- a/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/JiraPluginModule.scala +++ b/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/JiraPluginModule.scala @@ -1,6 +1,6 @@ package ch.epfl.bluebrain.nexus.delta.plugins.jira -import cats.effect.Clock +import cats.effect.{Clock, IO} import ch.epfl.bluebrain.nexus.delta.plugins.jira.config.JiraConfig import ch.epfl.bluebrain.nexus.delta.plugins.jira.routes.JiraRoutes import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.RemoteContextResolution @@ -11,7 +11,6 @@ import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities import ch.epfl.bluebrain.nexus.delta.sdk.model._ import ch.epfl.bluebrain.nexus.delta.sourcing.Transactors import izumi.distage.model.definition.{Id, ModuleDef} -import monix.bio.UIO import monix.execution.Scheduler /** @@ -21,7 +20,7 @@ class JiraPluginModule(priority: Int) extends ModuleDef { make[JiraConfig].from { JiraConfig.load(_) } - make[JiraClient].fromEffect { (xas: Transactors, jiraConfig: JiraConfig, clock: Clock[UIO]) => + make[JiraClient].fromEffect { (xas: Transactors, jiraConfig: JiraConfig, clock: Clock[IO]) => JiraClient(TokenStore(xas)(clock), jiraConfig) } diff --git a/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/TokenStore.scala b/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/TokenStore.scala index 1dad495167..6b91f52d77 100644 --- a/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/TokenStore.scala +++ b/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/TokenStore.scala @@ -1,7 +1,8 @@ package ch.epfl.bluebrain.nexus.delta.plugins.jira -import cats.effect.Clock -import ch.epfl.bluebrain.nexus.delta.kernel.utils.IOUtils.instant +import cats.effect.{Clock, IO} +import ch.epfl.bluebrain.nexus.delta.kernel.effect.migration._ +import ch.epfl.bluebrain.nexus.delta.kernel.utils.IOInstant import ch.epfl.bluebrain.nexus.delta.sourcing.Transactors import ch.epfl.bluebrain.nexus.delta.sourcing.implicits._ import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity @@ -10,7 +11,6 @@ import doobie.implicits._ import doobie.postgres.implicits._ import io.circe.Json import io.circe.syntax._ -import monix.bio.{Task, UIO} /** * Stores Jira tokens in the underlying databases @@ -22,7 +22,7 @@ trait TokenStore { * @param user * the user */ - def get(user: User): Task[Option[OAuthToken]] + def get(user: User): IO[Option[OAuthToken]] /** * Save the token for the given user @@ -31,7 +31,7 @@ trait TokenStore { * @param oauthToken * the associated token */ - def save(user: User, oauthToken: OAuthToken): Task[Unit] + def save(user: User, oauthToken: OAuthToken): IO[Unit] } @@ -40,21 +40,23 @@ object TokenStore { /** * Create a token store */ - def apply(xas: Transactors)(implicit clock: Clock[UIO]): TokenStore = { + def apply(xas: Transactors)(implicit clock: Clock[IO]): TokenStore = { new TokenStore { - override def get(user: Identity.User): Task[Option[OAuthToken]] = - sql"SELECT token_value FROM jira_tokens WHERE realm = ${user.realm.value} and subject = ${user.subject}" - .query[Json] - .option - .transact(xas.read) + override def get(user: Identity.User): IO[Option[OAuthToken]] = + toCatsIO( + sql"SELECT token_value FROM jira_tokens WHERE realm = ${user.realm.value} and subject = ${user.subject}" + .query[Json] + .option + .transact(xas.read) + ) .flatMap { case Some(token) => - Task.fromEither(token.as[OAuthToken]).map(Some(_)) - case None => Task.none + IO.fromEither(token.as[OAuthToken]).map(Some(_)) + case None => IO.none } - override def save(user: Identity.User, oauthToken: OAuthToken): Task[Unit] = - instant.flatMap { now => + override def save(user: Identity.User, oauthToken: OAuthToken): IO[Unit] = + IOInstant.now.flatMap { now => sql""" INSERT INTO jira_tokens(realm, subject, instant, token_value) | VALUES(${user.realm.value}, ${user.subject}, $now, ${oauthToken.asJson}) | ON CONFLICT (realm, subject) DO UPDATE SET instant = EXCLUDED.instant, token_value = EXCLUDED.token_value diff --git a/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/model/JiraResponse.scala b/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/model/JiraResponse.scala index a6ad91f471..63f1576ec7 100644 --- a/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/model/JiraResponse.scala +++ b/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/model/JiraResponse.scala @@ -1,9 +1,10 @@ package ch.epfl.bluebrain.nexus.delta.plugins.jira.model +import cats.effect.IO +import cats.syntax.all._ import ch.epfl.bluebrain.nexus.delta.plugins.jira.JiraError import com.google.api.client.http.HttpRequest import io.circe.{parser, Json} -import monix.bio.{IO, Task} /** * Jira response @@ -12,19 +13,16 @@ final case class JiraResponse(content: Option[Json]) object JiraResponse { - def apply(request: HttpRequest): IO[JiraError, JiraResponse] = { - Task - .delay( - request.execute() - ) + def apply(request: HttpRequest): IO[JiraResponse] = { + IO(request.execute()) .flatMap { response => val content = response.parseAsString() if (content.nonEmpty) { - Task.fromEither(parser.parse(content)).map { r => JiraResponse(Some(r)) } + IO.fromEither(parser.parse(content)).map { r => JiraResponse(Some(r)) } } else { - Task.pure(JiraResponse(None)) + IO.pure(JiraResponse(None)) } } - .mapError { JiraError.from } + .adaptError { e => JiraError.from(e) } } } diff --git a/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/routes/JiraRoutes.scala b/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/routes/JiraRoutes.scala index 47fb023d4e..32e1079476 100644 --- a/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/routes/JiraRoutes.scala +++ b/delta/plugins/jira/src/main/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/routes/JiraRoutes.scala @@ -1,20 +1,23 @@ package ch.epfl.bluebrain.nexus.delta.plugins.jira.routes +import cats.syntax.all._ import akka.http.scaladsl.model.StatusCodes import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.{Directive1, Route} -import ch.epfl.bluebrain.nexus.delta.plugins.jira.JiraClient -import ch.epfl.bluebrain.nexus.delta.plugins.jira.model.Verifier +import cats.effect.IO +import ch.epfl.bluebrain.nexus.delta.plugins.jira.{JiraClient, JiraError} +import ch.epfl.bluebrain.nexus.delta.plugins.jira.model.{JiraResponse, Verifier} import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.RemoteContextResolution import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck import ch.epfl.bluebrain.nexus.delta.sdk.circe.CirceUnmarshalling import ch.epfl.bluebrain.nexus.delta.sdk.directives.AuthDirectives -import ch.epfl.bluebrain.nexus.delta.sdk.directives.DeltaDirectives._ +import ch.epfl.bluebrain.nexus.delta.sdk.ce.DeltaDirectives._ import ch.epfl.bluebrain.nexus.delta.sdk.error.ServiceError.AuthorizationFailed import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities import ch.epfl.bluebrain.nexus.delta.sdk.marshalling.RdfMarshalling import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri +import ch.epfl.bluebrain.nexus.delta.sdk.realms.model.RealmRejection import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.User import io.circe.JsonObject import io.circe.syntax.EncoderOps @@ -46,6 +49,9 @@ class JiraRoutes( } } + private def adaptResponse(io: IO[JiraResponse]) = + io.map(_.content).attemptNarrow[JiraError] + def routes: Route = baseUriPrefix(baseUri.prefix) { pathPrefix("jira") { @@ -53,12 +59,12 @@ class JiraRoutes( concat( // Request token (pathPrefix("request-token") & post & pathEndOrSingleSlash) { - emit(jiraClient.requestToken().map(_.asJson)) + emit(jiraClient.requestToken().map(_.asJson).attemptNarrow[RealmRejection]) }, // Get the access token (pathPrefix("access-token") & post & pathEndOrSingleSlash) { entity(as[Verifier]) { verifier => - emit(jiraClient.accessToken(verifier).map(_.asJson)) + emit(jiraClient.accessToken(verifier).attemptNarrow[RealmRejection]) } }, // Issues @@ -66,28 +72,28 @@ class JiraRoutes( concat( // Create an issue (post & entity(as[JsonObject])) { payload => - emit(StatusCodes.Created, jiraClient.createIssue(payload).map(_.content)) + emit(StatusCodes.Created, adaptResponse(jiraClient.createIssue(payload))) }, // Edit an issue (put & pathPrefix(Segment)) { issueId => entity(as[JsonObject]) { payload => - emit(StatusCodes.NoContent, jiraClient.editIssue(issueId, payload).map(_.content)) + emit(StatusCodes.NoContent, adaptResponse(jiraClient.editIssue(issueId, payload))) } }, // Get an issue (get & pathPrefix(Segment)) { issueId => - emit(jiraClient.getIssue(issueId).map(_.content)) + emit(adaptResponse(jiraClient.getIssue(issueId))) } ) }, // List projects (get & pathPrefix("project") & get & parameter("recent".as[Int].?)) { recent => - emit(jiraClient.listProjects(recent).map(_.content)) + emit(adaptResponse(jiraClient.listProjects(recent))) }, // Search issues (post & pathPrefix("search") & pathEndOrSingleSlash) { entity(as[JsonObject]) { payload => - emit(jiraClient.search(payload).map(_.content)) + emit(adaptResponse(jiraClient.search(payload))) } } ) diff --git a/delta/plugins/jira/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/TokenStoreSpec.scala b/delta/plugins/jira/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/TokenStoreSpec.scala deleted file mode 100644 index e5b06d890f..0000000000 --- a/delta/plugins/jira/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/TokenStoreSpec.scala +++ /dev/null @@ -1,46 +0,0 @@ -package ch.epfl.bluebrain.nexus.delta.plugins.jira - -import ch.epfl.bluebrain.nexus.delta.plugins.jira.OAuthToken.{AccessToken, RequestToken} -import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.User -import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label -import ch.epfl.bluebrain.nexus.delta.sourcing.postgres.DoobieScalaTestFixture -import ch.epfl.bluebrain.nexus.testkit._ -import org.scalatest.OptionValues - -class TokenStoreSpec - extends DoobieScalaTestFixture - with IOFixedClock - with IOValues - with OptionValues - with TestHelpers - with ShouldMatchers { - - private lazy val tokenStore: TokenStore = TokenStore(xas) - - "A store" should { - - val user = User("Alice", Label.unsafe("Wonderland")) - - val request = RequestToken("request") - val access = AccessToken("access") - - "return none if no token exist for the user" in { - tokenStore.get(user).accepted shouldEqual None - } - - "save a given token for the user" in { - tokenStore.save(user, request).accepted - } - - "get a token for the user" in { - tokenStore.get(user).accepted.value shouldEqual request - } - - "overwrite an existing token for the user" in { - tokenStore.save(user, access).accepted - tokenStore.get(user).accepted.value shouldEqual access - } - - } - -} diff --git a/delta/plugins/jira/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/TokenStoreSuite.scala b/delta/plugins/jira/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/TokenStoreSuite.scala new file mode 100644 index 0000000000..2c6579ce0a --- /dev/null +++ b/delta/plugins/jira/src/test/scala/ch/epfl/bluebrain/nexus/delta/plugins/jira/TokenStoreSuite.scala @@ -0,0 +1,41 @@ +package ch.epfl.bluebrain.nexus.delta.plugins.jira + +import ch.epfl.bluebrain.nexus.delta.plugins.jira.OAuthToken.{AccessToken, RequestToken} +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.User +import ch.epfl.bluebrain.nexus.delta.sourcing.model.Label +import ch.epfl.bluebrain.nexus.delta.sourcing.postgres.Doobie +import ch.epfl.bluebrain.nexus.testkit.ce.{CatsEffectSuite, IOFixedClock} +import munit.AnyFixture + +class TokenStoreSuite extends CatsEffectSuite with Doobie.Fixture with IOFixedClock { + + override def munitFixtures: Seq[AnyFixture[_]] = List(doobie) + + private lazy val xas = doobie() + + private lazy val tokenStore: TokenStore = TokenStore(xas) + + private val user = User("Alice", Label.unsafe("Wonderland")) + + private val request = RequestToken("request") + private val access = AccessToken("access") + + test("Return none if no token exist for the user") { + tokenStore.get(user).assertEquals(None) + } + + test("Save a given token for the user and return it") { + for { + _ <- tokenStore.save(user, request) + _ <- tokenStore.get(user).assertEquals(Some(request)) + } yield () + } + + test("Overwrite an existing token for the user") { + for { + _ <- tokenStore.save(user, access) + _ <- tokenStore.get(user).assertEquals(Some(access)) + } yield () + } + +}