├── .circleci └── config.yml ├── .gitignore ├── LICENSE ├── README.md ├── build.sbt ├── project ├── build.properties └── plugins.sbt ├── sttp-akka-monix-bio └── src │ └── main │ └── scala │ └── com │ └── fullfacing │ └── akka │ └── monix │ └── bio │ └── backend │ ├── AkkaMonixBioHttpBackend.scala │ ├── AkkaMonixBioHttpClient.scala │ └── utils │ ├── BioMonadAsyncError.scala │ └── ConvertToSttp.scala ├── sttp-akka-monix-core └── src │ └── main │ └── scala │ └── com │ └── fullfacing │ └── akka │ └── monix │ └── core │ ├── ConvertToAkka.scala │ ├── ProxySettings.scala │ └── core.scala ├── sttp-akka-monix-task └── src │ └── main │ └── scala │ └── com │ └── fullfacing │ └── akka │ └── monix │ └── task │ └── backend │ ├── AkkaMonixHttpBackend.scala │ ├── AkkaMonixHttpClient.scala │ └── utils │ ├── ConvertToSttp.scala │ └── TaskMonadAsyncError.scala ├── sttp3-akka-monix-bio └── src │ └── main │ └── scala │ └── com │ └── fullfacing │ └── akka │ └── monix │ └── bio │ └── backend │ ├── AkkaMonixBioHttpBackend.scala │ ├── AkkaMonixBioHttpClient.scala │ └── utils │ ├── AkkaStreams.scala │ ├── BioMonadAsyncError.scala │ ├── BodyFromAkka.scala │ └── ConvertToSttp.scala ├── sttp3-akka-monix-core └── src │ └── main │ └── scala │ └── com │ └── fullfacing │ └── akka │ └── monix │ └── core │ ├── ConvertToAkka.scala │ ├── ProxySettings.scala │ └── core.scala ├── sttp3-akka-monix-task └── src │ └── main │ └── scala │ └── com │ └── fullfacing │ └── akka │ └── monix │ └── task │ └── backend │ ├── AkkaMonixHttpBackend.scala │ ├── AkkaMonixHttpClient.scala │ └── utils │ ├── AkkaStreams.scala │ ├── BodyFromAkka.scala │ ├── ConvertToSttp.scala │ └── TaskMonadAsyncError.scala └── version.sbt /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | compile212: 4 | docker: 5 | - image: circleci/openjdk:latest 6 | working_directory: ~/code 7 | steps: 8 | - checkout 9 | - restore_cache: 10 | key: sbt-cache-0 11 | - run: 12 | name: Compile JAR 13 | command: sbt ++2.12.13! clean update compile 14 | - save_cache: 15 | key: sbt-cache-0 16 | paths: 17 | - "~/.m2" 18 | - "~/.sbt" 19 | - "~/.ivy2/cache" 20 | compile213: 21 | working_directory: ~/code 22 | docker: 23 | - image: circleci/openjdk:latest 24 | environment: 25 | steps: 26 | - checkout 27 | - restore_cache: 28 | key: sbt-cache-0 29 | - run: 30 | name: Compile JAR 31 | command: sbt ++2.13.5! clean update compile 32 | - save_cache: 33 | key: sbt-cache-0 34 | paths: 35 | - "~/.m2" 36 | - "~/.sbt" 37 | - "~/.ivy2/cache" 38 | Publish-And-Release: 39 | docker: 40 | - image: circleci/openjdk:latest 41 | working_directory: ~/code 42 | steps: 43 | - checkout 44 | - restore_cache: 45 | key: sbt-cache-0 46 | - run: 47 | name: Import PGP Key 48 | command: | 49 | sudo rm -rf /var/lib/apt/lists/* && sudo apt-get update 50 | sudo apt-get install -y dos2unix 51 | echo -e "$PGP_KEY" > key.asc 52 | dos2unix key.asc 53 | gpg --import key.asc 54 | - run: 55 | name: Set Git Config 56 | command: | 57 | git push --set-upstream origin master 58 | git config --global user.email "shadowrhyder@gmail.com" 59 | git config --global user.name "Richard" 60 | - run: 61 | name: Compile JAR 62 | command: sbt 'release with-defaults' 63 | - save_cache: 64 | key: sbt-cache-0 65 | paths: 66 | - "~/.m2" 67 | - "~/.sbt" 68 | - "~/.ivy2/cache" 69 | workflows: 70 | deployment: 71 | jobs: 72 | - compile212: 73 | filters: 74 | branches: 75 | only: 76 | - develop 77 | - compile213: 78 | filters: 79 | branches: 80 | only: 81 | - develop 82 | - Publish-And-Release: 83 | filters: 84 | branches: 85 | only: 86 | - master 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # scala 2 | target 3 | *.sc 4 | 5 | #IntelliJ 6 | *.ipr 7 | *.iws 8 | .idea 9 | .bsp 10 | 11 | # sbt specific 12 | .cache/ 13 | .lib/ 14 | dist/* 15 | lib_managed/ 16 | project/boot/ 17 | project/plugins/project/ 18 | 19 | # Mac stuff 20 | .DS_Store 21 | project/project/ 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Full Facing 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CircleCI](https://circleci.com/gh/fullfacing/akkaMonixSttpBackend.svg?style=shield&circle-token=2547983c39c2197e6663282e9ae20f77eb97e03b)](https://circleci.com/gh/fullfacing/akkaMonixSttpBackend) 2 | [![Maven Central](https://img.shields.io/maven-central/v/com.fullfacing/sttp-akka-monix-task_2.13.svg)](https://search.maven.org/search?q=a:sttp-akka-monix-task_2.13) 3 | [![Scala Steward badge](https://img.shields.io/badge/Scala_Steward-helping-blue.svg?style=flat&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAQCAMAAAARSr4IAAAAVFBMVEUAAACHjojlOy5NWlrKzcYRKjGFjIbp293YycuLa3pYY2LSqql4f3pCUFTgSjNodYRmcXUsPD/NTTbjRS+2jomhgnzNc223cGvZS0HaSD0XLjbaSjElhIr+AAAAAXRSTlMAQObYZgAAAHlJREFUCNdNyosOwyAIhWHAQS1Vt7a77/3fcxxdmv0xwmckutAR1nkm4ggbyEcg/wWmlGLDAA3oL50xi6fk5ffZ3E2E3QfZDCcCN2YtbEWZt+Drc6u6rlqv7Uk0LdKqqr5rk2UCRXOk0vmQKGfc94nOJyQjouF9H/wCc9gECEYfONoAAAAASUVORK5CYII=)](https://scala-steward.org) 4 | 5 | # akkaMonixSttpBackend 6 | **Introduction:**
7 | akkaMonixSttpBackend is a backend for [sttp](https://sttp.readthedocs.io/en/latest/index.html) using [Akka-HTTP](https://doc.akka.io/docs/akka-http/current/index.html) to handle requests, and with [Task](https://monix.io/docs/3x/eval/task.html) as the response Monad and [Observable](https://monix.io/docs/3x/reactive/observable.html) as the streaming type. It is a modification of the [Akka-HTTP backend](https://sttp.readthedocs.io/en/latest/backends/akkahttp.html) provided by sttp, with instances of Futures deferred to Tasks and Akka-Streams Sources converted to Observables. 8 | 9 | The motivation behind creating this backend as opposed to using the existing [Monix wrapped async-http-client backend](https://sttp.readthedocs.io/en/latest/backends/asynchttpclient.html) is to give an alternative for projects that already have Akka-HTTP as a dependency, removing the need for the async-http-client dependency as well. 10 | 11 | **Installation:**
Add the following sbt dependency:
12 | `"com.fullfacing" %% "sttp-akka-monix-task" % "1.5.0"`
13 | 14 | **Usage:**
15 | Usage is identical to the [Akka-HTTP backend](https://sttp.readthedocs.io/en/latest/backends/akkahttp.html) with only the response type differing: 16 | ```scala 17 | import akka.util.ByteString 18 | import com.fullfacing.akka.monix.task.backend.AkkaMonixHttpBackend 19 | import sttp.client.{Response, SttpBackend, _} 20 | import monix.eval.Task 21 | import monix.reactive.Observable 22 | 23 | implicit val backend: SttpBackend[Task, Observable[ByteString], NothingT] = AkkaMonixHttpBackend() 24 | 25 | // To set the request body as a stream: 26 | val observable: Observable[ByteString] = ??? 27 | 28 | sttp 29 | .streamBody(observable) 30 | .post(uri"...") 31 | .send() 32 | 33 | // To receive the response body as a stream: 34 | val response: Task[Response[Observable[ByteString]]] = 35 | sttp 36 | .post(uri"...") 37 | .response(asStream[Observable[ByteString]]) 38 | .send() 39 | ``` 40 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import sbt.Keys.{publishLocalConfiguration, scalaVersion} 2 | import ReleaseTransformations._ 3 | 4 | import sbt.url 5 | import xerial.sbt.Sonatype.GitHubHosting 6 | 7 | val scalaV = "2.13.6" 8 | val scalacOpts = Seq( 9 | "-Ywarn-unused:implicits", 10 | "-Ywarn-unused:imports", 11 | "-Ywarn-unused:locals", 12 | "-Ywarn-unused:params", 13 | "-Ywarn-unused:patvars", 14 | "-Ywarn-unused:privates", 15 | "-deprecation", 16 | "-encoding", "UTF-8", 17 | "-feature", 18 | "-language:existentials", 19 | "-language:higherKinds", 20 | "-language:implicitConversions", 21 | "-unchecked", 22 | "-Xlint", 23 | "-Ywarn-dead-code", 24 | "-Ywarn-numeric-widen", 25 | "-Ywarn-value-discard" 26 | ) 27 | 28 | // Global sbt project settings. 29 | lazy val global = { 30 | Seq( 31 | organization := "com.fullfacing", 32 | scalaVersion := scalaV, 33 | crossScalaVersions := Seq(scalaVersion.value, "2.12.14"), 34 | scalacOptions ++= scalacOpts ++ (CrossVersion.partialVersion(scalaVersion.value) match { 35 | case Some((2, n)) if n <= 12 => 36 | scalacOpts ++ Seq( 37 | "-Ypartial-unification", 38 | "-Xfuture", 39 | "-Yno-adapted-args", 40 | "-Ywarn-unused-import" 41 | ) 42 | case _ => scalacOpts 43 | }), 44 | addCompilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1"), 45 | addCompilerPlugin("org.typelevel" %% "kind-projector" % "0.13.0" cross CrossVersion.full), 46 | resolvers ++= Seq(Resolver.sonatypeRepo("releases")), 47 | 48 | credentials += Credentials("GnuPG Key ID", "gpg", "419C90FB607D11B0A7FE51CFDAF842ABC601C14F", "ignored"), 49 | 50 | publishTo := sonatypePublishToBundle.value, 51 | publishConfiguration := publishConfiguration.value.withOverwrite(true), 52 | publishLocalConfiguration := publishLocalConfiguration.value.withOverwrite(true), 53 | 54 | // Your profile name of the sonatype account. The default is the same with the organization value 55 | sonatypeProfileName := "com.fullfacing", 56 | 57 | // To sync with Maven central, you need to supply the following information: 58 | publishMavenStyle := true, 59 | 60 | // MIT Licence 61 | licenses := Seq("MIT" -> url("http://opensource.org/licenses/MIT")), 62 | 63 | // Github Project Information 64 | sonatypeProjectHosting := Some(GitHubHosting("fullfacing", "sttp-akka-monix", "curious@fullfacing.com")), 65 | 66 | // Developer Contact Information 67 | developers := List( 68 | Developer( 69 | id = "lmuller90", 70 | name = "Louis Muller", 71 | email = "lmuller@fullfacing.com", 72 | url = url("https://www.fullfacing.com/") 73 | ), 74 | Developer( 75 | id = "neil-fladderak", 76 | name = "Neil Fladderak", 77 | email = "neil@fullfacing.com", 78 | url = url("https://www.fullfacing.com/") 79 | ), 80 | Developer( 81 | id = "execution1939", 82 | name = "Richard Peters", 83 | email = "rpeters@fullfacing.com", 84 | url = url("https://www.fullfacing.com/") 85 | ) 86 | ), 87 | 88 | releaseIgnoreUntrackedFiles := true, 89 | releasePublishArtifactsAction := PgpKeys.publishSigned.value, 90 | releaseCrossBuild := true, 91 | releaseVersionBump := sbtrelease.Version.Bump.Minor, 92 | releaseProcess := Seq[ReleaseStep]( 93 | checkSnapshotDependencies, 94 | inquireVersions, 95 | runClean, 96 | setReleaseVersion, 97 | tagRelease, 98 | pushChanges, 99 | releaseStepCommandAndRemaining("+publishSigned"), 100 | releaseStepCommand("sonatypeBundleRelease"), 101 | swapToDevelopAction, 102 | setNextVersion, 103 | commitNextVersion, 104 | pushChanges 105 | ) 106 | ) 107 | } 108 | 109 | import sbtrelease.Git 110 | import sbtrelease.ReleasePlugin.autoImport._ 111 | import sbtrelease.Utilities._ 112 | 113 | def swapToDevelop: State => State = { st: State => 114 | val git = st.extract.get(releaseVcs).get.asInstanceOf[Git] 115 | git.cmd("checkout", "develop") ! st.log 116 | st 117 | } 118 | 119 | lazy val swapToDevelopAction = { st: State => 120 | val newState = swapToDevelop(st) 121 | newState 122 | } 123 | 124 | val akka: Seq[ModuleID] = Seq( 125 | "com.typesafe.akka" %% "akka-stream" % "2.6.15", 126 | "com.typesafe.akka" %% "akka-http" % "10.2.4" 127 | ) 128 | 129 | val sttp: Seq[ModuleID] = Seq( 130 | "com.softwaremill.sttp.client" %% "core" % "2.2.9" 131 | ) 132 | 133 | val sttp3: Seq[ModuleID] = Seq( 134 | "com.softwaremill.sttp.client3" %% "core" % "3.3.6", 135 | ) 136 | 137 | val monix: Seq[ModuleID] = Seq( 138 | "io.monix" %% "monix" % "3.4.0" 139 | ) 140 | 141 | val `monix-bio`: Seq[ModuleID] = Seq( 142 | "io.monix" %% "monix-bio" % "1.2.0" 143 | ) 144 | 145 | lazy val `sttp-akka-monix-core` = (project in file("./sttp-akka-monix-core")) 146 | .settings(global: _*) 147 | .settings(libraryDependencies ++= akka ++ monix ++ sttp) 148 | .settings(name := "sttp-akka-monix-core", publishArtifact := true) 149 | 150 | 151 | lazy val `sttp-akka-monix-task` = (project in file("./sttp-akka-monix-task")) 152 | .settings(global: _*) 153 | .settings(name := "sttp-akka-monix-task", publishArtifact := true) 154 | .dependsOn(`sttp-akka-monix-core`) 155 | 156 | lazy val `sttp-akka-monix-bio` = (project in file("./sttp-akka-monix-bio")) 157 | .settings(global: _*) 158 | .settings(libraryDependencies ++= `monix-bio`) 159 | .settings(name := "sttp-akka-monix-bio", publishArtifact := true) 160 | .dependsOn(`sttp-akka-monix-core`) 161 | 162 | lazy val `sttp3-akka-monix-core` = (project in file("./sttp3-akka-monix-core")) 163 | .settings(global: _*) 164 | .settings(libraryDependencies ++= akka ++ monix ++ sttp3) 165 | .settings(name := "sttp3-akka-monix-core", publishArtifact := true) 166 | 167 | lazy val `sttp3-akka-monix-task` = (project in file("./sttp3-akka-monix-task")) 168 | .settings(global: _*) 169 | .settings(name := "sttp3-akka-monix-task", publishArtifact := true) 170 | .dependsOn(`sttp3-akka-monix-core`) 171 | 172 | lazy val `sttp3-akka-monix-bio` = (project in file("./sttp3-akka-monix-bio")) 173 | .settings(global: _*) 174 | .settings(libraryDependencies ++= `monix-bio`) 175 | .settings(name := "sttp3-akka-monix-bio", publishArtifact := true) 176 | .dependsOn(`sttp3-akka-monix-core`) 177 | 178 | lazy val root = (project in file(".")) 179 | .settings(global: _*) 180 | .settings(publishArtifact := false) 181 | .aggregate( 182 | `sttp-akka-monix-core`, 183 | `sttp-akka-monix-task`, 184 | `sttp-akka-monix-bio`, 185 | `sttp3-akka-monix-core`, 186 | `sttp3-akka-monix-task`, 187 | `sttp3-akka-monix-bio` 188 | ) -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.5.4 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | logLevel := Level.Warn 2 | 3 | addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.5.3") 4 | addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.1.2") 5 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.9.7") 6 | addSbtPlugin("com.github.sbt" % "sbt-release" % "1.0.15") 7 | -------------------------------------------------------------------------------- /sttp-akka-monix-bio/src/main/scala/com/fullfacing/akka/monix/bio/backend/AkkaMonixBioHttpBackend.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.bio.backend 2 | 3 | import akka.actor.ActorSystem 4 | import akka.event.LoggingAdapter 5 | import akka.http.scaladsl.HttpsConnectionContext 6 | import akka.http.scaladsl.model.HttpRequest 7 | import akka.http.scaladsl.settings.ConnectionPoolSettings 8 | import akka.util.ByteString 9 | import com.fullfacing.akka.monix.bio.backend.utils.{BioMonadAsyncError, ConvertToSttp} 10 | import com.fullfacing.akka.monix.core.{ConvertToAkka, ProxySettings} 11 | import monix.bio.{IO, Task} 12 | import monix.execution.Scheduler 13 | import monix.reactive.Observable 14 | import sttp.client.monad.MonadError 15 | import sttp.client.ws.WebSocketResponse 16 | import sttp.client.{FollowRedirectsBackend, NothingT, Request, Response, SttpBackend, SttpBackendOptions} 17 | 18 | import scala.concurrent.Future 19 | 20 | class AkkaMonixBioHttpBackend(actorSystem: ActorSystem, 21 | ec: Scheduler, 22 | terminateActorSystemOnClose: Boolean, 23 | options: SttpBackendOptions, 24 | client: AkkaMonixBioHttpClient, 25 | customConnectionPoolSettings: Option[ConnectionPoolSettings]) 26 | extends SttpBackend[Task, Observable[ByteString], NothingT] { 27 | 28 | /* Initiates the Akka Actor system. */ 29 | implicit val system: ActorSystem = actorSystem 30 | 31 | override def send[T](sttpRequest: Request[T, Observable[ByteString]]): Task[Response[T]] = { 32 | implicit val sch: Scheduler = ec 33 | 34 | val settingsWithProxy = ProxySettings.includeProxy( 35 | ProxySettings.connectionSettings(actorSystem, options, customConnectionPoolSettings), options 36 | ) 37 | val updatedSettings = settingsWithProxy.withUpdatedConnectionSettings(_.withIdleTimeout(sttpRequest.options.readTimeout)) 38 | val requestMethod = ConvertToAkka.toAkkaMethod(sttpRequest.method) 39 | val partialRequest = HttpRequest(uri = sttpRequest.uri.toString, method = requestMethod) 40 | 41 | val request = for { 42 | headers <- ConvertToAkka.toAkkaHeaders(sttpRequest.headers.toList) 43 | body <- ConvertToAkka.toAkkaRequestBody(sttpRequest.body, sttpRequest.headers, partialRequest) 44 | } yield body.withHeaders(headers) 45 | 46 | IO.fromEither(request) 47 | .flatMap(client.singleRequest(_, updatedSettings)) 48 | .flatMap(ConvertToSttp.toSttpResponse(_, sttpRequest)) 49 | } 50 | 51 | def responseMonad: MonadError[Task] = BioMonadAsyncError 52 | 53 | override def close(): Task[Unit] = Task.deferFuture { 54 | if (terminateActorSystemOnClose) actorSystem.terminate else Future.unit 55 | }.void 56 | 57 | override def openWebsocket[T, WS_RESULT](request: Request[T, Observable[ByteString]], 58 | handler: NothingT[WS_RESULT]): Task[WebSocketResponse[WS_RESULT]] = handler 59 | } 60 | 61 | object AkkaMonixBioHttpBackend { 62 | 63 | /* Creates a new Actor system and Akka-HTTP client by default. */ 64 | def apply(options: SttpBackendOptions = SttpBackendOptions.Default, 65 | customHttpsContext: Option[HttpsConnectionContext] = None, 66 | customConnectionPoolSettings: Option[ConnectionPoolSettings] = None, 67 | customLog: Option[LoggingAdapter] = None) 68 | (implicit ec: Scheduler = monix.execution.Scheduler.global): SttpBackend[Task, Observable[ByteString], NothingT] = { 69 | 70 | val actorSystem = ActorSystem("sttp") 71 | val client = AkkaMonixBioHttpClient.default(actorSystem, customHttpsContext, customLog) 72 | 73 | val akkaMonixHttpBackend = new AkkaMonixBioHttpBackend( 74 | actorSystem = actorSystem, 75 | ec = ec, 76 | terminateActorSystemOnClose = false, 77 | options = options, 78 | client = client, 79 | customConnectionPoolSettings = customConnectionPoolSettings 80 | ) 81 | 82 | new FollowRedirectsBackend[Task, Observable[ByteString], NothingT](akkaMonixHttpBackend) 83 | } 84 | 85 | /* This constructor allows for a specified Actor system. */ 86 | def usingActorSystem(actorSystem: ActorSystem, 87 | options: SttpBackendOptions = SttpBackendOptions.Default, 88 | customHttpsContext: Option[HttpsConnectionContext] = None, 89 | customConnectionPoolSettings: Option[ConnectionPoolSettings] = None, 90 | customLog: Option[LoggingAdapter] = None) 91 | (implicit ec: Scheduler = monix.execution.Scheduler.global): SttpBackend[Task, Observable[ByteString], NothingT] = { 92 | val client = AkkaMonixBioHttpClient.default(actorSystem, customHttpsContext, customLog) 93 | usingClient(actorSystem, options, customConnectionPoolSettings, client) 94 | } 95 | 96 | /* This constructor allows for a specified Actor system. */ 97 | def usingClient(actorSystem: ActorSystem, 98 | options: SttpBackendOptions = SttpBackendOptions.Default, 99 | poolSettings: Option[ConnectionPoolSettings] = None, 100 | client: AkkaMonixBioHttpClient) 101 | (implicit ec: Scheduler = monix.execution.Scheduler.global): SttpBackend[Task, Observable[ByteString], NothingT] = { 102 | 103 | val akkaMonixHttpBackend = new AkkaMonixBioHttpBackend( 104 | actorSystem = actorSystem, 105 | ec = ec, 106 | terminateActorSystemOnClose = false, 107 | options = options, 108 | client = client, 109 | customConnectionPoolSettings = poolSettings 110 | ) 111 | 112 | new FollowRedirectsBackend[Task, Observable[ByteString], NothingT](akkaMonixHttpBackend) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /sttp-akka-monix-bio/src/main/scala/com/fullfacing/akka/monix/bio/backend/AkkaMonixBioHttpClient.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.bio.backend 2 | 3 | import akka.actor.ActorSystem 4 | import akka.event.LoggingAdapter 5 | import akka.http.scaladsl.model.{HttpRequest, HttpResponse} 6 | import akka.http.scaladsl.server.{ExceptionHandler, RejectionHandler, Route, RoutingLog} 7 | import akka.http.scaladsl.settings.{ConnectionPoolSettings, ParserSettings, RoutingSettings} 8 | import akka.http.scaladsl.{Http, HttpsConnectionContext} 9 | import akka.stream.Materializer 10 | import monix.bio.Task 11 | 12 | import scala.concurrent.{ExecutionContextExecutor, Future} 13 | 14 | trait AkkaMonixBioHttpClient { 15 | def singleRequest(request: HttpRequest, settings: ConnectionPoolSettings): Task[HttpResponse] 16 | } 17 | 18 | object AkkaMonixBioHttpClient { 19 | def default(system: ActorSystem, 20 | connectionContext: Option[HttpsConnectionContext], 21 | customLog: Option[LoggingAdapter]): AkkaMonixBioHttpClient = new AkkaMonixBioHttpClient { 22 | 23 | private val http = Http()(system) 24 | 25 | override def singleRequest(request: HttpRequest, settings: ConnectionPoolSettings): Task[HttpResponse] = Task.deferFuture { 26 | http.singleRequest( 27 | request, 28 | connectionContext.getOrElse(http.defaultClientHttpsContext), 29 | settings, 30 | customLog.getOrElse(system.log) 31 | ) 32 | } 33 | } 34 | 35 | def stubFromAsyncHandler(run: HttpRequest => Future[HttpResponse]): AkkaMonixBioHttpClient = 36 | (request: HttpRequest, _: ConnectionPoolSettings) => Task.deferFuture(run(request)) 37 | 38 | 39 | def stubFromRoute(route: Route)( 40 | implicit routingSettings: RoutingSettings, 41 | parserSettings: ParserSettings, 42 | materializer: Materializer, 43 | routingLog: RoutingLog, 44 | executionContext: ExecutionContextExecutor = null, 45 | rejectionHandler: RejectionHandler = RejectionHandler.default, 46 | exceptionHandler: ExceptionHandler = null 47 | ): AkkaMonixBioHttpClient = stubFromAsyncHandler(Route.asyncHandler(route)) 48 | } -------------------------------------------------------------------------------- /sttp-akka-monix-bio/src/main/scala/com/fullfacing/akka/monix/bio/backend/utils/BioMonadAsyncError.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.bio.backend.utils 2 | 3 | import monix.bio.Task 4 | import sttp.client.monad.{Canceler, MonadAsyncError} 5 | 6 | object BioMonadAsyncError extends MonadAsyncError[Task] { 7 | override def unit[T](t: T): Task[T] = Task.now(t) 8 | 9 | override def map[T, T2](fa: Task[T])(f: (T) => T2): Task[T2] = fa.map(f) 10 | 11 | override def flatMap[T, T2](fa: Task[T])(f: (T) => Task[T2]): Task[T2] = 12 | fa.flatMap(f) 13 | 14 | override def async[T](register: (Either[Throwable, T] => Unit) => Canceler): Task[T] = 15 | Task.async { cb => 16 | register { 17 | case Left(t) => cb.onError(t) 18 | case Right(t) => cb.onSuccess(t) 19 | } 20 | } 21 | 22 | override def error[T](t: Throwable): Task[T] = Task.raiseError(t) 23 | 24 | override protected def handleWrappedError[T](rt: Task[T])(h: PartialFunction[Throwable, Task[T]]): Task[T] = 25 | rt.onErrorRecoverWith(h) 26 | } 27 | -------------------------------------------------------------------------------- /sttp-akka-monix-bio/src/main/scala/com/fullfacing/akka/monix/bio/backend/utils/ConvertToSttp.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.bio.backend.utils 2 | 3 | import akka.http.scaladsl.model.HttpResponse 4 | import akka.stream.Materializer 5 | import akka.util.ByteString 6 | import com.fullfacing.akka.monix.core._ 7 | import monix.bio.Task 8 | import monix.execution.Scheduler 9 | import monix.reactive.Observable 10 | import sttp.client.{IgnoreResponse, MappedResponseAs, Request, Response, ResponseAs, ResponseAsByteArray, ResponseAsFile, ResponseAsFromMetadata, ResponseAsStream, ResponseMetadata} 11 | import sttp.model.{Header, HeaderNames, StatusCode} 12 | 13 | import scala.collection.immutable.Seq 14 | import scala.concurrent.Future 15 | 16 | object ConvertToSttp { 17 | 18 | /* Converts Akka-HTTP headers to the STTP equivalent. */ 19 | def toSttpHeaders(response: HttpResponse): Seq[Header] = { 20 | val headCont = HeaderNames.ContentType -> response.entity.contentType.toString() 21 | val contLength = response.entity.contentLengthOption.map(HeaderNames.ContentLength -> _.toString) 22 | val other = response.headers.map(h => (h.name, h.value)) 23 | val headerMap = headCont :: (contLength.toList ++ other) 24 | 25 | headerMap.flatMap { case (k, v) => Header.safeApply(k, v).toOption } 26 | } 27 | 28 | /* Converts an Akka-HTTP response to a STTP equivalent. */ 29 | def toSttpResponse[T](response: HttpResponse, sttpRequest: Request[T, Observable[ByteString]]) 30 | (implicit scheduler: Scheduler, mat: Materializer): Task[Response[T]] = { 31 | val statusCode = StatusCode.notValidated(response.status.intValue()) 32 | val statusText = response.status.reason() 33 | val respHeaders = toSttpHeaders(response) 34 | val respMetadata = ResponseMetadata(respHeaders, statusCode, statusText) 35 | val decodedResp = decodeAkkaResponse(response) 36 | 37 | val body = toSttpResponseBody(sttpRequest.response, decodedResp, respMetadata) 38 | body.map(t => Response(t, statusCode, statusText, respHeaders, Nil)) 39 | } 40 | 41 | /* Converts an Akka response body to a STTP equivalent. */ 42 | def toSttpResponseBody[T](sttpBody: ResponseAs[T, Observable[ByteString]], 43 | resp: HttpResponse, 44 | metadata: ResponseMetadata) 45 | (implicit scheduler: Scheduler, mat: Materializer): Task[T] = { 46 | 47 | type R[A] = ResponseAs[A, Observable[ByteString]] 48 | 49 | def processBody(body: ResponseAs[T, Observable[ByteString]]): Future[T] = body match { 50 | case MappedResponseAs(raw: R[T], f) => processBody(raw).map(f(_, metadata)) 51 | case ResponseAsFromMetadata(f) => processBody(f(metadata)) 52 | case IgnoreResponse => discardEntity(resp.entity) 53 | case ResponseAsByteArray => entityToByteArray(resp.entity) 54 | case r @ ResponseAsStream() => Future.successful(r.responseIsStream(entityToObservable(resp.entity))) 55 | case ResponseAsFile(file) => entityToFile(resp.entity, file.toFile).map(_ => file) 56 | } 57 | 58 | Task.deferFuture(processBody(sttpBody)) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /sttp-akka-monix-core/src/main/scala/com/fullfacing/akka/monix/core/ConvertToAkka.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.core 2 | 3 | import akka.http.scaladsl.model.HttpHeader.ParsingResult 4 | import akka.http.scaladsl.model._ 5 | import akka.stream.scaladsl.{Source, StreamConverters} 6 | import akka.util.ByteString 7 | import cats.implicits._ 8 | import monix.execution.Scheduler 9 | import monix.reactive.Observable 10 | import sttp.client.{ByteArrayBody, ByteBufferBody, FileBody, InputStreamBody, MultipartBody, NoBody, RequestBody, StreamBody, StringBody} 11 | import sttp.model.{Header, Method} 12 | 13 | import scala.collection.immutable 14 | 15 | object ConvertToAkka { 16 | 17 | /* Converts a HTTP method from STTP to the Akka-HTTP equivalent. */ 18 | def toAkkaMethod(method: Method): HttpMethod = method match { 19 | case Method.GET => HttpMethods.GET 20 | case Method.HEAD => HttpMethods.HEAD 21 | case Method.POST => HttpMethods.POST 22 | case Method.PUT => HttpMethods.PUT 23 | case Method.DELETE => HttpMethods.DELETE 24 | case Method.OPTIONS => HttpMethods.OPTIONS 25 | case Method.PATCH => HttpMethods.PATCH 26 | case Method.CONNECT => HttpMethods.CONNECT 27 | case Method.TRACE => HttpMethods.TRACE 28 | case _ => HttpMethod.custom(method.method) 29 | } 30 | 31 | /* Converts STTP headers to Akka-HTTP equivalents. */ 32 | def toAkkaHeaders(headers: List[Header]): Either[Throwable, immutable.Seq[HttpHeader]] = { 33 | val parsingResults = headers.collect { 34 | case Header(n, v) if !isContentType(n) && !isContentLength(n) => HttpHeader.parse(n, v) 35 | } 36 | 37 | val errors = parsingResults.collect { case ParsingResult.Error(e) => e } 38 | 39 | if (errors.isEmpty) { 40 | parsingResults.collect { case ParsingResult.Ok(httpHeader, _) => httpHeader }.asRight 41 | } else { 42 | new RuntimeException(s"Cannot parse headers: $errors").asLeft 43 | } 44 | } 45 | 46 | /* Converts a STTP request body into an Akka-HTTP request with the same body. */ 47 | def toAkkaRequestBody(body: RequestBody[Observable[ByteString]], headers: Seq[Header], request: HttpRequest) 48 | (implicit scheduler: Scheduler): Either[Throwable, HttpRequest] = { 49 | 50 | val header = headers.collectFirst { case Header(k, v) if isContentType(k) => v } 51 | 52 | createContentType(header).flatMap { ct => 53 | body match { 54 | case NoBody => request.asRight 55 | case StringBody(b, enc, _) => request.withEntity(contentTypeWithEncoding(ct, enc), b.getBytes(enc)).asRight 56 | case ByteArrayBody(b, _) => request.withEntity(HttpEntity(ct, b)).asRight 57 | case ByteBufferBody(b, _) => request.withEntity(HttpEntity(ct, ByteString(b))).asRight 58 | case InputStreamBody(b, _) => request.withEntity(HttpEntity(ct, StreamConverters.fromInputStream(() => b))).asRight 59 | case FileBody(b, _) => request.withEntity(ct, b.toPath).asRight 60 | case StreamBody(s) => request.withEntity(HttpEntity(ct, Source.fromPublisher(s.toReactivePublisher))).asRight 61 | case MultipartBody(ps) => createMultiPartRequest(ps, request) 62 | } 63 | } 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /sttp-akka-monix-core/src/main/scala/com/fullfacing/akka/monix/core/ProxySettings.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.core 2 | 3 | import akka.actor.ActorSystem 4 | import akka.http.scaladsl.ClientTransport 5 | import akka.http.scaladsl.model.headers.BasicHttpCredentials 6 | import akka.http.scaladsl.settings.ConnectionPoolSettings 7 | import sttp.client.SttpBackendOptions 8 | 9 | object ProxySettings { 10 | 11 | /* Sets up the Connection Pool with the correct settings. */ 12 | def connectionSettings(actorSystem: ActorSystem, 13 | options: SttpBackendOptions, 14 | customConnectionPoolSettings: Option[ConnectionPoolSettings]): ConnectionPoolSettings = { 15 | 16 | val connectionPoolSettings: ConnectionPoolSettings = customConnectionPoolSettings 17 | .getOrElse(ConnectionPoolSettings(actorSystem)) 18 | .withUpdatedConnectionSettings(_.withConnectingTimeout(options.connectionTimeout)) 19 | 20 | connectionPoolSettings 21 | } 22 | 23 | /* Includes the proxy if one exists. */ 24 | def includeProxy(connectionPoolSettings: ConnectionPoolSettings, options: SttpBackendOptions): ConnectionPoolSettings = { 25 | 26 | options.proxy.fold(connectionPoolSettings) { p => 27 | val clientTransport = p.auth match { 28 | case Some(auth) => ClientTransport.httpsProxy(p.inetSocketAddress, BasicHttpCredentials(auth.username, auth.password)) 29 | case None => ClientTransport.httpsProxy(p.inetSocketAddress) 30 | } 31 | connectionPoolSettings.withTransport(clientTransport) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /sttp-akka-monix-core/src/main/scala/com/fullfacing/akka/monix/core/core.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix 2 | 3 | import java.io.{File, UnsupportedEncodingException} 4 | import java.nio.ByteBuffer 5 | 6 | import akka.http.scaladsl.coding.{Deflate, Gzip, NoCoding} 7 | import akka.http.scaladsl.model.ContentTypes.`application/octet-stream` 8 | import akka.http.scaladsl.model.Multipart.FormData 9 | import akka.http.scaladsl.model._ 10 | import akka.http.scaladsl.model.headers.{HttpEncodings, `Content-Length`, `Content-Type`} 11 | import akka.stream.scaladsl.{FileIO, Sink, StreamConverters} 12 | import akka.stream.{IOResult, Materializer} 13 | import akka.util.ByteString 14 | import cats.implicits._ 15 | import monix.execution.Scheduler 16 | import monix.reactive.Observable 17 | import sttp.client.{BasicRequestBody, ByteArrayBody, ByteBufferBody, FileBody, InputStreamBody, StringBody} 18 | import sttp.model.Part 19 | 20 | import scala.concurrent.Future 21 | 22 | package object core { 23 | 24 | /* Converts an Akka-HTTP response entity into a byte array. */ 25 | def entityToByteArray(entity: ResponseEntity)(implicit scheduler: Scheduler, mat: Materializer): Future[Array[Byte]] = { 26 | entity.dataBytes.runFold(ByteString(""))(_ ++ _).map(_.toArray[Byte]) 27 | } 28 | 29 | /* Converts an Akka-HTTP response entity into a String. */ 30 | def entityToString(entity: ResponseEntity, charset: Option[String], encoding: String) 31 | (implicit scheduler: Scheduler, mat: Materializer): Future[String] = { 32 | entityToByteArray(entity).map { byteArray => 33 | new String(byteArray, charset.getOrElse(encoding)) 34 | } 35 | } 36 | 37 | /* Converts an Akka-HTTP response entity into a file. */ 38 | def entityToFile(entity: ResponseEntity, file: File)(implicit mat: Materializer): Future[IOResult] = { 39 | if (!file.exists()) 40 | file.getParentFile.mkdirs() & file.createNewFile() 41 | 42 | entity.dataBytes.runWith(FileIO.toPath(file.toPath)) 43 | } 44 | 45 | /* Converts an Akka-HTTP response entity into an Observable. */ 46 | def entityToObservable(entity: ResponseEntity)(implicit mat: Materializer): Observable[ByteString] = { 47 | Observable.fromReactivePublisher(entity.dataBytes.runWith(Sink.asPublisher[ByteString](fanout = false))) 48 | } 49 | 50 | /* Discards an Akka-HTTP response entity. */ 51 | def discardEntity(entity: ResponseEntity)(implicit mat: Materializer, scheduler: Scheduler): Future[Unit] = { 52 | entity.dataBytes.runWith(Sink.ignore).map(_ => ()) 53 | } 54 | 55 | /* Parses the headers to a Content Type. */ 56 | def createContentType(header: Option[String]): Either[Throwable, ContentType] = { 57 | header.map { contentType => 58 | ContentType.parse(contentType).leftMap { errors => 59 | new RuntimeException(s"Cannot parse content type: $errors") 60 | } 61 | }.getOrElse(`application/octet-stream`.asRight) 62 | } 63 | 64 | /* Creates a MultiPart Request. */ 65 | def createMultiPartRequest(mps: Seq[Part[BasicRequestBody]], request: HttpRequest): Either[Throwable, HttpRequest] = { 66 | mps.map(convertMultiPart).toList.sequence.map { bodyPart => 67 | FormData() 68 | request.withEntity(FormData(bodyPart: _*).toEntity()) 69 | } 70 | } 71 | 72 | /* Converts a MultiPart to a MultiPartFormData Type. */ 73 | def convertMultiPart(mp: Part[BasicRequestBody]): Either[Throwable, FormData.BodyPart] = { 74 | for { 75 | ct <- createContentType(mp.contentType) 76 | headers <- ConvertToAkka.toAkkaHeaders(mp.headers.toList) 77 | } yield { 78 | val fileName = mp.fileName.fold(Map.empty[String, String])(fn => Map("filename" -> fn)) 79 | val bodyPart = createBodyPartEntity(ct, mp.body) 80 | FormData.BodyPart(mp.name, bodyPart, fileName, headers) 81 | } 82 | } 83 | 84 | /* Creates a BodyPart entity. */ 85 | def createBodyPartEntity(ct: ContentType, body: BasicRequestBody): BodyPartEntity = body match { 86 | case StringBody(b, encoding, _) => HttpEntity(contentTypeWithEncoding(ct, encoding), b.getBytes(encoding)) 87 | case ByteArrayBody(b, _) => HttpEntity(ct, b) 88 | case ByteBufferBody(b, _) => HttpEntity(ct, ByteString(b)) 89 | case isb: InputStreamBody => HttpEntity.IndefiniteLength(ct, StreamConverters.fromInputStream(() => isb.b)) 90 | case FileBody(b, _) => HttpEntity.fromPath(ct, b.toPath) 91 | } 92 | 93 | /* Decodes an Akka-HTTP response. */ 94 | def decodeAkkaResponse(response: HttpResponse): HttpResponse = { 95 | val decoder = response.encoding match { 96 | case HttpEncodings.gzip => Gzip 97 | case HttpEncodings.deflate => Deflate 98 | case HttpEncodings.identity => NoCoding 99 | case ce => throw new UnsupportedEncodingException(s"Unsupported encoding: $ce") 100 | } 101 | decoder.decodeMessage(response) 102 | } 103 | 104 | /* Sets the Content Type encoding. */ 105 | def contentTypeWithEncoding(ct: String, enc: String): String = 106 | s"$ct; charset=$enc" 107 | 108 | /* Gets the encoding from the Content Type. */ 109 | def encodingFromContentType(ct: String): Option[String] = 110 | ct.split(";").map(_.trim.toLowerCase).collectFirst { 111 | case s if s.startsWith("charset=") && s.substring(8).trim != "" => s.substring(8).trim 112 | } 113 | 114 | /* Concatenates the Byte Buffers. */ 115 | def concatByteBuffers(bb1: ByteBuffer, bb2: ByteBuffer): ByteBuffer = 116 | ByteBuffer 117 | .allocate(bb1.array().length + bb2.array().length) 118 | .put(bb1) 119 | .put(bb2) 120 | 121 | /* Includes the encoding with the Content Type. */ 122 | def contentTypeWithEncoding(ct: ContentType, encoding: String): ContentType = HttpCharsets 123 | .getForKey(encoding) 124 | .fold(ct)(hc => ContentType.apply(ct.mediaType, () => hc)) 125 | 126 | /* Checks if it is the correct Content type. */ 127 | def isContentType(headerKey: String): Boolean = 128 | headerKey.toLowerCase.contains(`Content-Type`.lowercaseName) 129 | 130 | /* Checks if it is the correct Content length. */ 131 | def isContentLength(headerKey: String): Boolean = 132 | headerKey.toLowerCase.contains(`Content-Length`.lowercaseName) 133 | } 134 | -------------------------------------------------------------------------------- /sttp-akka-monix-task/src/main/scala/com/fullfacing/akka/monix/task/backend/AkkaMonixHttpBackend.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.task.backend 2 | 3 | import akka.actor.ActorSystem 4 | import akka.event.LoggingAdapter 5 | import akka.http.scaladsl.HttpsConnectionContext 6 | import akka.http.scaladsl.model.HttpRequest 7 | import akka.http.scaladsl.settings.ConnectionPoolSettings 8 | import akka.util.ByteString 9 | import com.fullfacing.akka.monix.core.{ConvertToAkka, ProxySettings} 10 | import com.fullfacing.akka.monix.task.backend.utils.{ConvertToSttp, TaskMonadAsyncError} 11 | import monix.eval.Task 12 | import monix.execution.Scheduler 13 | import monix.reactive.Observable 14 | import sttp.client._ 15 | import sttp.client.monad.MonadError 16 | import sttp.client.ws.WebSocketResponse 17 | 18 | import scala.concurrent.Future 19 | 20 | class AkkaMonixHttpBackend(actorSystem: ActorSystem, 21 | ec: Scheduler, 22 | terminateActorSystemOnClose: Boolean, 23 | options: SttpBackendOptions, 24 | client: AkkaMonixHttpClient, 25 | customConnectionPoolSettings: Option[ConnectionPoolSettings]) 26 | extends SttpBackend[Task, Observable[ByteString], NothingT] { 27 | 28 | /* Initiates the Akka Actor system. */ 29 | implicit val system: ActorSystem = actorSystem 30 | 31 | override def send[T](sttpRequest: Request[T, Observable[ByteString]]): Task[Response[T]] = { 32 | implicit val sch: Scheduler = ec 33 | 34 | val settingsWithProxy = ProxySettings.includeProxy( 35 | ProxySettings.connectionSettings(actorSystem, options, customConnectionPoolSettings), options 36 | ) 37 | val updatedSettings = settingsWithProxy.withUpdatedConnectionSettings(_.withIdleTimeout(sttpRequest.options.readTimeout)) 38 | val requestMethod = ConvertToAkka.toAkkaMethod(sttpRequest.method) 39 | val partialRequest = HttpRequest(uri = sttpRequest.uri.toString, method = requestMethod) 40 | 41 | val request = for { 42 | headers <- ConvertToAkka.toAkkaHeaders(sttpRequest.headers.toList) 43 | body <- ConvertToAkka.toAkkaRequestBody(sttpRequest.body, sttpRequest.headers, partialRequest) 44 | } yield body.withHeaders(headers) 45 | 46 | Task.fromEither(request) 47 | .flatMap(client.singleRequest(_, updatedSettings)) 48 | .flatMap(ConvertToSttp.toSttpResponse(_, sttpRequest)) 49 | } 50 | 51 | def responseMonad: MonadError[Task] = TaskMonadAsyncError 52 | 53 | override def close(): Task[Unit] = Task.deferFuture { 54 | if (terminateActorSystemOnClose) actorSystem.terminate else Future.unit 55 | }.void 56 | 57 | override def openWebsocket[T, WS_RESULT](request: Request[T, Observable[ByteString]], 58 | handler: NothingT[WS_RESULT]): Task[WebSocketResponse[WS_RESULT]] = handler 59 | } 60 | 61 | object AkkaMonixHttpBackend { 62 | 63 | /* Creates a new Actor system and Akka-HTTP client by default. */ 64 | def apply(options: SttpBackendOptions = SttpBackendOptions.Default, 65 | customHttpsContext: Option[HttpsConnectionContext] = None, 66 | customConnectionPoolSettings: Option[ConnectionPoolSettings] = None, 67 | customLog: Option[LoggingAdapter] = None) 68 | (implicit ec: Scheduler = monix.execution.Scheduler.global): SttpBackend[Task, Observable[ByteString], NothingT] = { 69 | 70 | val actorSystem = ActorSystem("sttp") 71 | val client = AkkaMonixHttpClient.default(actorSystem, customHttpsContext, customLog) 72 | 73 | val akkaMonixHttpBackend = new AkkaMonixHttpBackend( 74 | actorSystem = actorSystem, 75 | ec = ec, 76 | terminateActorSystemOnClose = false, 77 | options = options, 78 | client = client, 79 | customConnectionPoolSettings = customConnectionPoolSettings 80 | ) 81 | 82 | new FollowRedirectsBackend[Task, Observable[ByteString], NothingT](akkaMonixHttpBackend) 83 | } 84 | 85 | /* This constructor allows for a specified Actor system. */ 86 | def usingActorSystem(actorSystem: ActorSystem, 87 | options: SttpBackendOptions = SttpBackendOptions.Default, 88 | customHttpsContext: Option[HttpsConnectionContext] = None, 89 | customConnectionPoolSettings: Option[ConnectionPoolSettings] = None, 90 | customLog: Option[LoggingAdapter] = None) 91 | (implicit ec: Scheduler = monix.execution.Scheduler.global): SttpBackend[Task, Observable[ByteString], NothingT] = { 92 | val client = AkkaMonixHttpClient.default(actorSystem, customHttpsContext, customLog) 93 | usingClient(actorSystem, options, customConnectionPoolSettings, client) 94 | } 95 | 96 | /* This constructor allows for a specified Actor system. */ 97 | def usingClient(actorSystem: ActorSystem, 98 | options: SttpBackendOptions = SttpBackendOptions.Default, 99 | poolSettings: Option[ConnectionPoolSettings] = None, 100 | client: AkkaMonixHttpClient) 101 | (implicit ec: Scheduler = monix.execution.Scheduler.global): SttpBackend[Task, Observable[ByteString], NothingT] = { 102 | 103 | val akkaMonixHttpBackend = new AkkaMonixHttpBackend( 104 | actorSystem = actorSystem, 105 | ec = ec, 106 | terminateActorSystemOnClose = false, 107 | options = options, 108 | client = client, 109 | customConnectionPoolSettings = poolSettings 110 | ) 111 | 112 | new FollowRedirectsBackend[Task, Observable[ByteString], NothingT](akkaMonixHttpBackend) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /sttp-akka-monix-task/src/main/scala/com/fullfacing/akka/monix/task/backend/AkkaMonixHttpClient.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.task.backend 2 | 3 | import akka.actor.ActorSystem 4 | import akka.event.LoggingAdapter 5 | import akka.http.scaladsl.model.{HttpRequest, HttpResponse} 6 | import akka.http.scaladsl.server.{ExceptionHandler, RejectionHandler, Route, RoutingLog} 7 | import akka.http.scaladsl.settings.{ConnectionPoolSettings, ParserSettings, RoutingSettings} 8 | import akka.http.scaladsl.{Http, HttpsConnectionContext} 9 | import akka.stream.Materializer 10 | import monix.eval.Task 11 | 12 | import scala.concurrent.{ExecutionContextExecutor, Future} 13 | 14 | trait AkkaMonixHttpClient { 15 | def singleRequest(request: HttpRequest, settings: ConnectionPoolSettings): Task[HttpResponse] 16 | } 17 | 18 | object AkkaMonixHttpClient { 19 | def default(system: ActorSystem, 20 | connectionContext: Option[HttpsConnectionContext], 21 | customLog: Option[LoggingAdapter]): AkkaMonixHttpClient = new AkkaMonixHttpClient { 22 | 23 | private val http = Http()(system) 24 | 25 | override def singleRequest(request: HttpRequest, settings: ConnectionPoolSettings): Task[HttpResponse] = Task.deferFuture { 26 | http.singleRequest( 27 | request, 28 | connectionContext.getOrElse(http.defaultClientHttpsContext), 29 | settings, 30 | customLog.getOrElse(system.log) 31 | ) 32 | } 33 | } 34 | 35 | def stubFromAsyncHandler(run: HttpRequest => Future[HttpResponse]): AkkaMonixHttpClient = 36 | (request: HttpRequest, _: ConnectionPoolSettings) => Task.deferFuture(run(request)) 37 | 38 | 39 | def stubFromRoute(route: Route)( 40 | implicit routingSettings: RoutingSettings, 41 | parserSettings: ParserSettings, 42 | materializer: Materializer, 43 | routingLog: RoutingLog, 44 | executionContext: ExecutionContextExecutor = null, 45 | rejectionHandler: RejectionHandler = RejectionHandler.default, 46 | exceptionHandler: ExceptionHandler = null 47 | ): AkkaMonixHttpClient = stubFromAsyncHandler(Route.asyncHandler(route)) 48 | } -------------------------------------------------------------------------------- /sttp-akka-monix-task/src/main/scala/com/fullfacing/akka/monix/task/backend/utils/ConvertToSttp.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.task.backend.utils 2 | 3 | import akka.http.scaladsl.model.HttpResponse 4 | import akka.stream.Materializer 5 | import akka.util.ByteString 6 | import com.fullfacing.akka.monix.core._ 7 | import monix.eval.Task 8 | import monix.execution.Scheduler 9 | import monix.reactive.Observable 10 | import sttp.client.{IgnoreResponse, MappedResponseAs, Request, Response, ResponseAs, ResponseAsByteArray, ResponseAsFile, ResponseAsFromMetadata, ResponseAsStream, ResponseMetadata} 11 | import sttp.model.{Header, HeaderNames, StatusCode} 12 | 13 | import scala.collection.immutable.Seq 14 | import scala.concurrent.Future 15 | 16 | object ConvertToSttp { 17 | 18 | /* Converts Akka-HTTP headers to the STTP equivalent. */ 19 | def toSttpHeaders(response: HttpResponse): Seq[Header] = { 20 | val headCont = HeaderNames.ContentType -> response.entity.contentType.toString() 21 | val contLength = response.entity.contentLengthOption.map(HeaderNames.ContentLength -> _.toString) 22 | val other = response.headers.map(h => (h.name, h.value)) 23 | val headerMap = headCont :: (contLength.toList ++ other) 24 | 25 | headerMap.flatMap { case (k, v) => Header.safeApply(k, v).toOption } 26 | } 27 | 28 | /* Converts an Akka-HTTP response to a STTP equivalent. */ 29 | def toSttpResponse[T](response: HttpResponse, sttpRequest: Request[T, Observable[ByteString]]) 30 | (implicit scheduler: Scheduler, mat: Materializer): Task[Response[T]] = { 31 | val statusCode = StatusCode.notValidated(response.status.intValue()) 32 | val statusText = response.status.reason() 33 | val respHeaders = toSttpHeaders(response) 34 | val respMetadata = ResponseMetadata(respHeaders, statusCode, statusText) 35 | val decodedResp = decodeAkkaResponse(response) 36 | 37 | val body = toSttpResponseBody(sttpRequest.response, decodedResp, respMetadata) 38 | body.map(t => Response(t, statusCode, statusText, respHeaders, Nil)) 39 | } 40 | 41 | /* Converts an Akka response body to a STTP equivalent. */ 42 | def toSttpResponseBody[T](sttpBody: ResponseAs[T, Observable[ByteString]], 43 | resp: HttpResponse, 44 | metadata: ResponseMetadata) 45 | (implicit scheduler: Scheduler, mat: Materializer): Task[T] = { 46 | 47 | type R[A] = ResponseAs[A, Observable[ByteString]] 48 | 49 | def processBody(body: ResponseAs[T, Observable[ByteString]]): Future[T] = body match { 50 | 51 | case MappedResponseAs(raw: R[T], f) => processBody(raw).map(f(_, metadata)) 52 | case ResponseAsFromMetadata(f) => processBody(f(metadata)) 53 | case IgnoreResponse => discardEntity(resp.entity) 54 | case ResponseAsByteArray => entityToByteArray(resp.entity) 55 | case r @ ResponseAsStream() => Future.successful(r.responseIsStream(entityToObservable(resp.entity))) 56 | case ResponseAsFile(file) => entityToFile(resp.entity, file.toFile).map(_ => file) 57 | } 58 | 59 | Task.deferFuture(processBody(sttpBody)) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /sttp-akka-monix-task/src/main/scala/com/fullfacing/akka/monix/task/backend/utils/TaskMonadAsyncError.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.task.backend.utils 2 | 3 | import monix.eval.Task 4 | import sttp.client.monad.{Canceler, MonadAsyncError} 5 | 6 | import scala.util.{Failure, Success} 7 | 8 | object TaskMonadAsyncError extends MonadAsyncError[Task] { 9 | override def unit[T](t: T): Task[T] = Task.now(t) 10 | 11 | override def map[T, T2](fa: Task[T])(f: (T) => T2): Task[T2] = fa.map(f) 12 | 13 | override def flatMap[T, T2](fa: Task[T])(f: (T) => Task[T2]): Task[T2] = 14 | fa.flatMap(f) 15 | 16 | override def async[T](register: (Either[Throwable, T] => Unit) => Canceler): Task[T] = 17 | Task.async { cb => 18 | register { 19 | case Left(t) => cb(Failure(t)) 20 | case Right(t) => cb(Success(t)) 21 | } 22 | } 23 | 24 | override def error[T](t: Throwable): Task[T] = Task.raiseError(t) 25 | 26 | override protected def handleWrappedError[T](rt: Task[T])(h: PartialFunction[Throwable, Task[T]]): Task[T] = 27 | rt.onErrorRecoverWith(h) 28 | } 29 | -------------------------------------------------------------------------------- /sttp3-akka-monix-bio/src/main/scala/com/fullfacing/akka/monix/bio/backend/AkkaMonixBioHttpBackend.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.bio.backend 2 | 3 | import akka.actor.ActorSystem 4 | import akka.event.LoggingAdapter 5 | import akka.http.scaladsl.HttpsConnectionContext 6 | import akka.http.scaladsl.model.HttpRequest 7 | import akka.http.scaladsl.settings.ConnectionPoolSettings 8 | import akka.stream.Materializer 9 | import akka.util.ByteString 10 | import com.fullfacing.akka.monix.bio.backend.utils.{BioMonadAsyncError, BodyFromAkka, ConvertToSttp} 11 | import com.fullfacing.akka.monix.core.{ConvertToAkka, ProxySettings} 12 | import monix.bio.{IO, Task} 13 | import monix.execution.Scheduler 14 | import monix.reactive.Observable 15 | import sttp.capabilities 16 | import sttp.client3.{FollowRedirectsBackend, Request, Response, SttpBackend, SttpBackendOptions} 17 | import sttp.monad.MonadError 18 | 19 | import scala.concurrent.Future 20 | 21 | class AkkaMonixBioHttpBackend(actorSystem: ActorSystem, 22 | ec: Scheduler, 23 | terminateActorSystemOnClose: Boolean, 24 | options: SttpBackendOptions, 25 | client: AkkaMonixBioHttpClient, 26 | customConnectionPoolSettings: Option[ConnectionPoolSettings]) 27 | extends SttpBackend[Task, Observable[ByteString]] { 28 | 29 | /* Initiates the Akka Actor system. */ 30 | implicit val system: ActorSystem = actorSystem 31 | 32 | private lazy val bodyFromAkka = new BodyFromAkka()(ec, implicitly[Materializer], responseMonad) 33 | 34 | def send[T, R >: Observable[ByteString] with capabilities.Effect[Task]](sttpRequest: Request[T, R]): Task[Response[T]] = { 35 | implicit val sch: Scheduler = ec 36 | 37 | val settingsWithProxy = ProxySettings.includeProxy( 38 | ProxySettings.connectionSettings(actorSystem, options, customConnectionPoolSettings), options 39 | ) 40 | val updatedSettings = settingsWithProxy.withUpdatedConnectionSettings(_.withIdleTimeout(sttpRequest.options.readTimeout)) 41 | val requestMethod = ConvertToAkka.toAkkaMethod(sttpRequest.method) 42 | val partialRequest = HttpRequest(uri = sttpRequest.uri.toString, method = requestMethod) 43 | 44 | val request = for { 45 | headers <- ConvertToAkka.toAkkaHeaders(sttpRequest.headers.toList) 46 | body <- ConvertToAkka.toAkkaRequestBody(sttpRequest.body, sttpRequest.headers, partialRequest) 47 | } yield body.withHeaders(headers) 48 | 49 | IO.fromEither(request) 50 | .flatMap(client.singleRequest(_, updatedSettings)) 51 | .flatMap(ConvertToSttp.toSttpResponse[T, R](_, sttpRequest)(bodyFromAkka)) 52 | } 53 | 54 | def responseMonad: MonadError[Task] = BioMonadAsyncError 55 | 56 | override def close(): Task[Unit] = Task.deferFuture { 57 | if (terminateActorSystemOnClose) actorSystem.terminate else Future.unit 58 | }.void 59 | } 60 | 61 | object AkkaMonixBioHttpBackend { 62 | 63 | /* Creates a new Actor system and Akka-HTTP client by default. */ 64 | def apply(options: SttpBackendOptions = SttpBackendOptions.Default, 65 | customHttpsContext: Option[HttpsConnectionContext] = None, 66 | customConnectionPoolSettings: Option[ConnectionPoolSettings] = None, 67 | customLog: Option[LoggingAdapter] = None) 68 | (implicit ec: Scheduler = monix.execution.Scheduler.global): SttpBackend[Task, Observable[ByteString]] = { 69 | 70 | val actorSystem = ActorSystem("sttp") 71 | val client = AkkaMonixBioHttpClient.default(actorSystem, customHttpsContext, customLog) 72 | 73 | val akkaMonixHttpBackend = new AkkaMonixBioHttpBackend( 74 | actorSystem = actorSystem, 75 | ec = ec, 76 | terminateActorSystemOnClose = false, 77 | options = options, 78 | client = client, 79 | customConnectionPoolSettings = customConnectionPoolSettings 80 | ) 81 | 82 | new FollowRedirectsBackend[Task, Observable[ByteString]](akkaMonixHttpBackend) 83 | } 84 | 85 | /* This constructor allows for a specified Actor system. */ 86 | def usingActorSystem(actorSystem: ActorSystem, 87 | options: SttpBackendOptions = SttpBackendOptions.Default, 88 | customHttpsContext: Option[HttpsConnectionContext] = None, 89 | customConnectionPoolSettings: Option[ConnectionPoolSettings] = None, 90 | customLog: Option[LoggingAdapter] = None) 91 | (implicit ec: Scheduler = monix.execution.Scheduler.global): SttpBackend[Task, Observable[ByteString]] = { 92 | val client = AkkaMonixBioHttpClient.default(actorSystem, customHttpsContext, customLog) 93 | usingClient(actorSystem, options, customConnectionPoolSettings, client) 94 | } 95 | 96 | /* This constructor allows for a specified Actor system. */ 97 | def usingClient(actorSystem: ActorSystem, 98 | options: SttpBackendOptions = SttpBackendOptions.Default, 99 | poolSettings: Option[ConnectionPoolSettings] = None, 100 | client: AkkaMonixBioHttpClient) 101 | (implicit ec: Scheduler = monix.execution.Scheduler.global): SttpBackend[Task, Observable[ByteString]] = { 102 | 103 | val akkaMonixHttpBackend = new AkkaMonixBioHttpBackend( 104 | actorSystem = actorSystem, 105 | ec = ec, 106 | terminateActorSystemOnClose = false, 107 | options = options, 108 | client = client, 109 | customConnectionPoolSettings = poolSettings 110 | ) 111 | 112 | new FollowRedirectsBackend[Task, Observable[ByteString]](akkaMonixHttpBackend) 113 | } 114 | } -------------------------------------------------------------------------------- /sttp3-akka-monix-bio/src/main/scala/com/fullfacing/akka/monix/bio/backend/AkkaMonixBioHttpClient.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.bio.backend 2 | 3 | import akka.actor.ActorSystem 4 | import akka.event.LoggingAdapter 5 | import akka.http.scaladsl.{Http, HttpsConnectionContext} 6 | import akka.http.scaladsl.model.{HttpRequest, HttpResponse} 7 | import akka.http.scaladsl.server.{ExceptionHandler, RejectionHandler, Route, RoutingLog} 8 | import akka.http.scaladsl.settings.{ConnectionPoolSettings, ParserSettings, RoutingSettings} 9 | import akka.stream.Materializer 10 | import monix.bio.Task 11 | 12 | import scala.concurrent.{ExecutionContextExecutor, Future} 13 | 14 | trait AkkaMonixBioHttpClient { 15 | def singleRequest(request: HttpRequest, settings: ConnectionPoolSettings): Task[HttpResponse] 16 | } 17 | 18 | object AkkaMonixBioHttpClient { 19 | def default(system: ActorSystem, 20 | connectionContext: Option[HttpsConnectionContext], 21 | customLog: Option[LoggingAdapter]): AkkaMonixBioHttpClient = new AkkaMonixBioHttpClient { 22 | 23 | private val http = Http()(system) 24 | 25 | override def singleRequest(request: HttpRequest, settings: ConnectionPoolSettings): Task[HttpResponse] = Task.deferFuture { 26 | http.singleRequest( 27 | request, 28 | connectionContext.getOrElse(http.defaultClientHttpsContext), 29 | settings, 30 | customLog.getOrElse(system.log) 31 | ) 32 | } 33 | } 34 | 35 | def stubFromAsyncHandler(run: HttpRequest => Future[HttpResponse]): AkkaMonixBioHttpClient = 36 | (request: HttpRequest, _: ConnectionPoolSettings) => Task.deferFuture(run(request)) 37 | 38 | 39 | def stubFromRoute(route: Route)( 40 | implicit routingSettings: RoutingSettings, 41 | parserSettings: ParserSettings, 42 | materializer: Materializer, 43 | routingLog: RoutingLog, 44 | executionContext: ExecutionContextExecutor = null, 45 | rejectionHandler: RejectionHandler = RejectionHandler.default, 46 | exceptionHandler: ExceptionHandler = null 47 | ): AkkaMonixBioHttpClient = stubFromAsyncHandler(Route.asyncHandler(route)) 48 | } -------------------------------------------------------------------------------- /sttp3-akka-monix-bio/src/main/scala/com/fullfacing/akka/monix/bio/backend/utils/AkkaStreams.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.bio.backend.utils 2 | 3 | import akka.stream.scaladsl.{Flow, Source} 4 | import akka.util.ByteString 5 | import sttp.capabilities.Streams 6 | 7 | trait AkkaStreams extends Streams[AkkaStreams] { 8 | override type BinaryStream = Source[ByteString, Any] 9 | override type Pipe[A, B] = Flow[A, B, Any] 10 | } 11 | object AkkaStreams extends AkkaStreams -------------------------------------------------------------------------------- /sttp3-akka-monix-bio/src/main/scala/com/fullfacing/akka/monix/bio/backend/utils/BioMonadAsyncError.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.bio.backend.utils 2 | 3 | import cats.implicits._ 4 | import monix.bio.Task 5 | import sttp.monad.{Canceler, MonadAsyncError} 6 | 7 | object BioMonadAsyncError extends MonadAsyncError[Task] { 8 | override def unit[T](t: T): Task[T] = Task.now(t) 9 | 10 | override def map[T, T2](fa: Task[T])(f: (T) => T2): Task[T2] = fa.map(f) 11 | 12 | override def flatMap[T, T2](fa: Task[T])(f: (T) => Task[T2]): Task[T2] = 13 | fa.flatMap(f) 14 | 15 | override def async[T](register: (Either[Throwable, T] => Unit) => Canceler): Task[T] = 16 | Task.async { cb => 17 | register { 18 | case Left(t) => cb.onError(t) 19 | case Right(t) => cb.onSuccess(t) 20 | } 21 | } 22 | 23 | override def error[T](t: Throwable): Task[T] = Task.raiseError(t) 24 | 25 | override protected def handleWrappedError[T](rt: Task[T])(h: PartialFunction[Throwable, Task[T]]): Task[T] = 26 | rt.onErrorRecoverWith(h) 27 | 28 | def ensure[T](f: Task[T], e: => Task[Unit]): Task[T] = f.flatTap(_ => e) 29 | } 30 | -------------------------------------------------------------------------------- /sttp3-akka-monix-bio/src/main/scala/com/fullfacing/akka/monix/bio/backend/utils/BodyFromAkka.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.bio.backend.utils 2 | 3 | import akka.http.scaladsl.model.ws.{BinaryMessage, Message, TextMessage} 4 | import akka.http.scaladsl.model.{HttpEntity, HttpResponse} 5 | import akka.stream.scaladsl.{FileIO, Flow, Sink, SinkQueueWithCancel, Source, SourceQueueWithComplete} 6 | import akka.stream.{Materializer, OverflowStrategy, QueueOfferResult} 7 | import akka.util.ByteString 8 | import akka.{Done, NotUsed} 9 | import com.fullfacing.akka.monix.core.entityToObservable 10 | import monix.bio.Task 11 | import monix.reactive.Observable 12 | import sttp.client3.internal.{BodyFromResponseAs, SttpFile} 13 | import sttp.client3.ws.{GotAWebSocketException, NotAWebSocketException} 14 | import sttp.client3.{ResponseAs, ResponseAsWebSocket, ResponseAsWebSocketStream, ResponseAsWebSocketUnsafe, WebSocketResponseAs} 15 | import sttp.model.{Headers, ResponseMetadata} 16 | import sttp.monad.{FutureMonad, MonadError} 17 | import sttp.ws.{WebSocket, WebSocketBufferFull, WebSocketClosed, WebSocketFrame} 18 | 19 | import java.util.concurrent.atomic.AtomicBoolean 20 | import scala.concurrent.{ExecutionContext, Future, Promise} 21 | import scala.util.Failure 22 | import scala.util.chaining.scalaUtilChainingOps 23 | 24 | final class BodyFromAkka()(implicit ec: ExecutionContext, mat: Materializer, m: MonadError[Task]) { 25 | 26 | def apply[T, R](responseAs: ResponseAs[T, R], 27 | meta: ResponseMetadata, 28 | response: Either[HttpResponse, Promise[Flow[Message, Message, NotUsed]]]): Task[T] = { 29 | bodyFromResponseAs(responseAs, meta, response) 30 | } 31 | 32 | private lazy val bodyFromResponseAs = 33 | new BodyFromResponseAs[Task, HttpResponse, Promise[Flow[Message, Message, NotUsed]], Observable[ByteString]] { 34 | 35 | override protected def withReplayableBody(response: HttpResponse, replayableBody: Either[Array[Byte], SttpFile]): Task[HttpResponse] = { 36 | val replayEntity = replayableBody match { 37 | case Left(byteArray) => HttpEntity(byteArray) 38 | case Right(file) => HttpEntity.fromFile(response.entity.contentType, file.toFile) 39 | } 40 | 41 | Task.deferFuture(Future.successful(response.withEntity(replayEntity))) 42 | } 43 | 44 | override protected def regularIgnore(response: HttpResponse): Task[Unit] = { 45 | // todo: Replace with HttpResponse#discardEntityBytes() once https://github.com/akka/akka-http/issues/1459 is resolved 46 | Task.deferFuture(response.entity.dataBytes.runWith(Sink.ignore).map(_ => ())) 47 | } 48 | 49 | override protected def regularAsByteArray(response: HttpResponse): Task[Array[Byte]] = { 50 | response.entity.dataBytes 51 | .runFold(ByteString(""))(_ ++ _) 52 | .map(_.toArray[Byte]) 53 | .pipe(Task.deferFuture(_)) 54 | } 55 | 56 | override protected def regularAsFile(response: HttpResponse, file: SttpFile): Task[SttpFile] = { 57 | val f = file.toFile 58 | if (!f.exists()) { 59 | f.getParentFile.mkdirs() 60 | f.createNewFile() 61 | } 62 | 63 | Task.deferFuture(response.entity.dataBytes.runWith(FileIO.toPath(file.toPath)).map(_ => file)) 64 | } 65 | 66 | override protected def regularAsStream(response: HttpResponse): Task[(Observable[ByteString], () => Task[Unit])] = { 67 | val task = Task.deferFuture(response.discardEntityBytes().future().map(_ => ()).recover { case _ => () }) 68 | Task.now((entityToObservable(response.entity), () => task)) 69 | } 70 | 71 | override protected def handleWS[T](responseAs: WebSocketResponseAs[T, _], 72 | meta: ResponseMetadata, 73 | ws: Promise[Flow[Message, Message, NotUsed]]): Task[T] = { 74 | wsFromAkka(responseAs, ws, meta) 75 | } 76 | 77 | override protected def cleanupWhenNotAWebSocket(response: HttpResponse, e: NotAWebSocketException): Task[Unit] = { 78 | Task.deferFuture(response.entity.discardBytes().future().map(_ => ())) 79 | } 80 | 81 | override protected def cleanupWhenGotWebSocket(response: Promise[Flow[Message, Message, NotUsed]], 82 | e: GotAWebSocketException): Task[Unit] = { 83 | Task.deferFuture(Future.successful(response.failure(e))) 84 | } 85 | } 86 | 87 | private def wsFromAkka[T, R](rr: WebSocketResponseAs[T, R], 88 | wsFlow: Promise[Flow[Message, Message, NotUsed]], 89 | meta: ResponseMetadata) 90 | (implicit ec: ExecutionContext, 91 | mat: Materializer): Task[T] = { 92 | rr match { 93 | case ResponseAsWebSocket(f) => 94 | Task.deferFuture { 95 | val (flow, wsFuture) = webSocketAndFlow(meta) 96 | wsFlow.success(flow) 97 | wsFuture.flatMap { ws => 98 | val result = f.asInstanceOf[(WebSocket[Future], ResponseMetadata) => Future[T]](ws, meta) 99 | result.onComplete(_ => ws.close()) 100 | result 101 | } 102 | } 103 | 104 | case ResponseAsWebSocketUnsafe() => 105 | Task.deferFuture { 106 | val (flow, wsFuture) = webSocketAndFlow(meta) 107 | wsFlow.success(flow) 108 | wsFuture.asInstanceOf[Future[T]] 109 | } 110 | 111 | case ResponseAsWebSocketStream(_, p) => 112 | Task.deferFuture { 113 | val donePromise = Promise[Done]() 114 | 115 | val flow = Flow[Message] 116 | .mapAsync(1)(messageToFrame) 117 | .via(p.asInstanceOf[AkkaStreams.Pipe[WebSocketFrame.Data[_], WebSocketFrame]]) 118 | .takeWhile { 119 | case WebSocketFrame.Close(_, _) => false 120 | case _ => true 121 | } 122 | .mapConcat(incoming => frameToMessage(incoming).toList) 123 | .watchTermination() { (notUsed, done) => 124 | donePromise.completeWith(done) 125 | notUsed 126 | } 127 | 128 | wsFlow.success(flow) 129 | 130 | donePromise.future.map(_ => ()) 131 | } 132 | } 133 | } 134 | 135 | private def webSocketAndFlow(meta: ResponseMetadata)(implicit 136 | ec: ExecutionContext, 137 | mat: Materializer 138 | ): (Flow[Message, Message, NotUsed], Future[WebSocket[Future]]) = { 139 | val sinkQueuePromise = Promise[SinkQueueWithCancel[Message]]() 140 | val sink = Sink 141 | .queue[Message]() 142 | .mapMaterializedValue(sinkQueuePromise.success) 143 | 144 | val sourceQueuePromise = Promise[SourceQueueWithComplete[Message]]() 145 | val source = 146 | Source.queue[Message](1, OverflowStrategy.backpressure).mapMaterializedValue(sourceQueuePromise.success) 147 | 148 | val flow = Flow.fromSinkAndSource(sink, source) 149 | 150 | val ws = for { 151 | sinkQueue <- sinkQueuePromise.future 152 | sourceQueue <- sourceQueuePromise.future 153 | } yield new WebSocket[Future] { 154 | private val open = new AtomicBoolean(true) 155 | private val closeReceived = new AtomicBoolean(false) 156 | 157 | override def receive: Future[WebSocketFrame] = { 158 | val result = sinkQueue.pull().flatMap { 159 | case Some(m) => messageToFrame(m) 160 | case None => 161 | open.set(false) 162 | val c = closeReceived.getAndSet(true) 163 | if (!c) Future.successful(WebSocketFrame.close) 164 | else Future.failed(WebSocketClosed(Some(WebSocketFrame.close))) 165 | } 166 | 167 | result.onComplete { 168 | case Failure(_) => open.set(false) 169 | case _ => 170 | } 171 | 172 | result 173 | } 174 | 175 | override def send(f: WebSocketFrame, isContinuation: Boolean): Future[Unit] = 176 | f match { 177 | case WebSocketFrame.Close(_, _) => 178 | val wasOpen = open.getAndSet(false) 179 | if (wasOpen) sourceQueue.complete() 180 | sourceQueue.watchCompletion().map(_ => ()) 181 | 182 | case frame: WebSocketFrame => 183 | frameToMessage(frame) match { 184 | case Some(m) => 185 | sourceQueue.offer(m).flatMap { 186 | case QueueOfferResult.Enqueued => Future.successful(()) 187 | case QueueOfferResult.Dropped => 188 | Future.failed(throw new IllegalStateException(WebSocketBufferFull(1))) 189 | case QueueOfferResult.Failure(cause) => Future.failed(cause) 190 | case QueueOfferResult.QueueClosed => 191 | Future.failed(throw new IllegalStateException(WebSocketClosed(None))) 192 | } 193 | case None => Future.successful(()) 194 | } 195 | } 196 | 197 | override def upgradeHeaders: Headers = Headers(meta.headers) 198 | 199 | override def isOpen: Future[Boolean] = Future.successful(open.get()) 200 | 201 | override implicit def monad: MonadError[Future] = new FutureMonad()(ec) 202 | } 203 | 204 | (flow, ws) 205 | } 206 | 207 | private def messageToFrame(m: Message)(implicit ec: ExecutionContext, mat: Materializer): Future[WebSocketFrame.Data[_]] = 208 | m match { 209 | case msg: TextMessage => 210 | msg.textStream.runFold("")(_ + _).map(t => WebSocketFrame.text(t)) 211 | case msg: BinaryMessage => 212 | msg.dataStream.runFold(ByteString.empty)(_ ++ _).map(b => WebSocketFrame.binary(b.toArray)) 213 | } 214 | 215 | private def frameToMessage(w: WebSocketFrame): Option[Message] = { 216 | w match { 217 | case WebSocketFrame.Text(p, _, _) => Some(TextMessage(p)) 218 | case WebSocketFrame.Binary(p, _, _) => Some(BinaryMessage(ByteString(p))) 219 | case WebSocketFrame.Ping(_) => None 220 | case WebSocketFrame.Pong(_) => None 221 | case WebSocketFrame.Close(_, _) => throw WebSocketClosed(None) 222 | } 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /sttp3-akka-monix-bio/src/main/scala/com/fullfacing/akka/monix/bio/backend/utils/ConvertToSttp.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.bio.backend.utils 2 | 3 | import akka.http.scaladsl.model.HttpResponse 4 | import akka.stream.Materializer 5 | import com.fullfacing.akka.monix.core._ 6 | import monix.bio.Task 7 | import monix.execution.Scheduler 8 | import sttp.client3.{Request, Response} 9 | import sttp.model.{Header, HeaderNames, ResponseMetadata, StatusCode} 10 | 11 | import scala.collection.immutable.Seq 12 | 13 | object ConvertToSttp { 14 | 15 | /* Converts Akka-HTTP headers to the STTP equivalent. */ 16 | def toSttpHeaders(response: HttpResponse): Seq[Header] = { 17 | val headCont = HeaderNames.ContentType -> response.entity.contentType.toString() 18 | val contLength = response.entity.contentLengthOption.map(HeaderNames.ContentLength -> _.toString) 19 | val other = response.headers.map(h => (h.name, h.value)) 20 | val headerMap = headCont :: (contLength.toList ++ other) 21 | 22 | headerMap.flatMap { case (k, v) => Header.safeApply(k, v).toOption } 23 | } 24 | 25 | /* Converts an Akka-HTTP response to a STTP equivalent. */ 26 | def toSttpResponse[T, R](response: HttpResponse, sttpRequest: Request[T, R]) 27 | (bodyFromAkka: BodyFromAkka) 28 | (implicit scheduler: Scheduler, mat: Materializer): Task[Response[T]] = { 29 | val statusCode = StatusCode(response.status.intValue()) 30 | val statusText = response.status.reason() 31 | val respHeaders = toSttpHeaders(response) 32 | val respMetadata = ResponseMetadata(statusCode, statusText, respHeaders) 33 | val decodedResp = decodeAkkaResponse(response) 34 | 35 | val body = bodyFromAkka(sttpRequest.response, respMetadata, Left(decodedResp)) 36 | body.map(t => Response(t, statusCode, statusText, respHeaders)) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /sttp3-akka-monix-core/src/main/scala/com/fullfacing/akka/monix/core/ConvertToAkka.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.core 2 | 3 | import akka.http.scaladsl.model.HttpHeader.ParsingResult 4 | import akka.http.scaladsl.model._ 5 | import akka.stream.scaladsl.{Source, StreamConverters} 6 | import akka.util.ByteString 7 | import cats.implicits._ 8 | import monix.execution.Scheduler 9 | import monix.reactive.Observable 10 | import sttp.client3.{ByteArrayBody, ByteBufferBody, FileBody, InputStreamBody, MultipartBody, NoBody, RequestBody, StreamBody, StringBody} 11 | import sttp.model.{Header, Method} 12 | 13 | import scala.collection.immutable 14 | 15 | object ConvertToAkka { 16 | 17 | /* Converts a HTTP method from STTP to the Akka-HTTP equivalent. */ 18 | def toAkkaMethod(method: Method): HttpMethod = method match { 19 | case Method.GET => HttpMethods.GET 20 | case Method.HEAD => HttpMethods.HEAD 21 | case Method.POST => HttpMethods.POST 22 | case Method.PUT => HttpMethods.PUT 23 | case Method.DELETE => HttpMethods.DELETE 24 | case Method.OPTIONS => HttpMethods.OPTIONS 25 | case Method.PATCH => HttpMethods.PATCH 26 | case Method.CONNECT => HttpMethods.CONNECT 27 | case Method.TRACE => HttpMethods.TRACE 28 | case _ => HttpMethod.custom(method.method) 29 | } 30 | 31 | /* Converts STTP headers to Akka-HTTP equivalents. */ 32 | def toAkkaHeaders(headers: List[Header]): Either[Throwable, immutable.Seq[HttpHeader]] = { 33 | val parsingResults = headers.collect { 34 | case Header(n, v) if !isContentType(n) && !isContentLength(n) => HttpHeader.parse(n, v) 35 | } 36 | 37 | val errors = parsingResults.collect { case ParsingResult.Error(e) => e } 38 | 39 | if (errors.isEmpty) { 40 | parsingResults.collect { case ParsingResult.Ok(httpHeader, _) => httpHeader }.asRight 41 | } else { 42 | new RuntimeException(s"Cannot parse headers: $errors").asLeft 43 | } 44 | } 45 | 46 | /* Converts a STTP request body into an Akka-HTTP request with the same body. */ 47 | def toAkkaRequestBody[R](body: RequestBody[R], headers: Seq[Header], request: HttpRequest) 48 | (implicit scheduler: Scheduler): Either[Throwable, HttpRequest] = { 49 | 50 | val header = headers.collectFirst { case Header(k, v) if isContentType(k) => v } 51 | 52 | createContentType(header).flatMap { ct => 53 | body match { 54 | case NoBody => request.asRight 55 | case StringBody(b, enc, _) => request.withEntity(contentTypeWithEncoding(ct, enc), b.getBytes(enc)).asRight 56 | case ByteArrayBody(b, _) => request.withEntity(HttpEntity(ct, b)).asRight 57 | case ByteBufferBody(b, _) => request.withEntity(HttpEntity(ct, ByteString(b))).asRight 58 | case InputStreamBody(b, _) => request.withEntity(HttpEntity(ct, StreamConverters.fromInputStream(() => b))).asRight 59 | case FileBody(b, _) => request.withEntity(ct, b.toPath).asRight 60 | case StreamBody(s) => request.withEntity(HttpEntity(ct, Source.fromPublisher(s.asInstanceOf[Observable[ByteString]].toReactivePublisher))).asRight 61 | case MultipartBody(ps) => createMultiPartRequest(ps, request) 62 | } 63 | } 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /sttp3-akka-monix-core/src/main/scala/com/fullfacing/akka/monix/core/ProxySettings.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.core 2 | 3 | import akka.actor.ActorSystem 4 | import akka.http.scaladsl.ClientTransport 5 | import akka.http.scaladsl.model.headers.BasicHttpCredentials 6 | import akka.http.scaladsl.settings.ConnectionPoolSettings 7 | import sttp.client3.SttpBackendOptions 8 | 9 | object ProxySettings { 10 | 11 | /* Sets up the Connection Pool with the correct settings. */ 12 | def connectionSettings(actorSystem: ActorSystem, 13 | options: SttpBackendOptions, 14 | customConnectionPoolSettings: Option[ConnectionPoolSettings]): ConnectionPoolSettings = { 15 | 16 | val connectionPoolSettings: ConnectionPoolSettings = customConnectionPoolSettings 17 | .getOrElse(ConnectionPoolSettings(actorSystem)) 18 | .withUpdatedConnectionSettings(_.withConnectingTimeout(options.connectionTimeout)) 19 | 20 | connectionPoolSettings 21 | } 22 | 23 | /* Includes the proxy if one exists. */ 24 | def includeProxy(connectionPoolSettings: ConnectionPoolSettings, options: SttpBackendOptions): ConnectionPoolSettings = { 25 | 26 | options.proxy.fold(connectionPoolSettings) { p => 27 | val clientTransport = p.auth match { 28 | case Some(auth) => ClientTransport.httpsProxy(p.inetSocketAddress, BasicHttpCredentials(auth.username, auth.password)) 29 | case None => ClientTransport.httpsProxy(p.inetSocketAddress) 30 | } 31 | connectionPoolSettings.withTransport(clientTransport) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /sttp3-akka-monix-core/src/main/scala/com/fullfacing/akka/monix/core/core.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix 2 | 3 | import akka.http.scaladsl.coding.Coders 4 | import akka.http.scaladsl.model.ContentTypes.`application/octet-stream` 5 | import akka.http.scaladsl.model.Multipart.FormData 6 | import akka.http.scaladsl.model._ 7 | import akka.http.scaladsl.model.headers.{HttpEncodings, `Content-Length`, `Content-Type`} 8 | import akka.stream.scaladsl.{FileIO, Sink, StreamConverters} 9 | import akka.stream.{IOResult, Materializer} 10 | import akka.util.ByteString 11 | import cats.implicits._ 12 | import monix.execution.Scheduler 13 | import monix.reactive.Observable 14 | import sttp.client3.{ByteArrayBody, ByteBufferBody, FileBody, InputStreamBody, RequestBody, StringBody} 15 | import sttp.model.Part 16 | 17 | import java.io.{File, UnsupportedEncodingException} 18 | import java.nio.ByteBuffer 19 | import scala.concurrent.Future 20 | 21 | package object core { 22 | 23 | /* Converts an Akka-HTTP response entity into a byte array. */ 24 | def entityToByteArray(entity: ResponseEntity)(implicit scheduler: Scheduler, mat: Materializer): Future[Array[Byte]] = { 25 | entity.dataBytes.runFold(ByteString(""))(_ ++ _).map(_.toArray[Byte]) 26 | } 27 | 28 | /* Converts an Akka-HTTP response entity into a String. */ 29 | def entityToString(entity: ResponseEntity, charset: Option[String], encoding: String) 30 | (implicit scheduler: Scheduler, mat: Materializer): Future[String] = { 31 | entityToByteArray(entity).map { byteArray => 32 | new String(byteArray, charset.getOrElse(encoding)) 33 | } 34 | } 35 | 36 | /* Converts an Akka-HTTP response entity into a file. */ 37 | def entityToFile(entity: ResponseEntity, file: File)(implicit mat: Materializer): Future[IOResult] = { 38 | if (!file.exists()) 39 | file.getParentFile.mkdirs() & file.createNewFile() 40 | 41 | entity.dataBytes.runWith(FileIO.toPath(file.toPath)) 42 | } 43 | 44 | /* Converts an Akka-HTTP response entity into an Observable. */ 45 | def entityToObservable(entity: ResponseEntity)(implicit mat: Materializer): Observable[ByteString] = { 46 | Observable.fromReactivePublisher(entity.dataBytes.runWith(Sink.asPublisher[ByteString](fanout = false))) 47 | } 48 | 49 | /* Discards an Akka-HTTP response entity. */ 50 | def discardEntity(entity: ResponseEntity)(implicit mat: Materializer, scheduler: Scheduler): Future[Unit] = { 51 | entity.dataBytes.runWith(Sink.ignore).map(_ => ()) 52 | } 53 | 54 | /* Parses the headers to a Content Type. */ 55 | def createContentType(header: Option[String]): Either[Throwable, ContentType] = { 56 | header.map { contentType => 57 | ContentType.parse(contentType).leftMap { errors => 58 | new RuntimeException(s"Cannot parse content type: $errors") 59 | } 60 | }.getOrElse(`application/octet-stream`.asRight) 61 | } 62 | 63 | /* Creates a MultiPart Request. */ 64 | def createMultiPartRequest(mps: Seq[Part[RequestBody[_]]], request: HttpRequest): Either[Throwable, HttpRequest] = { 65 | mps.map(convertMultiPart).toList.sequence.map { bodyPart => 66 | FormData() 67 | request.withEntity(FormData(bodyPart: _*).toEntity()) 68 | } 69 | } 70 | 71 | /* Converts a MultiPart to a MultiPartFormData Type. */ 72 | def convertMultiPart(mp: Part[RequestBody[_]]): Either[Throwable, FormData.BodyPart] = { 73 | for { 74 | ct <- createContentType(mp.contentType) 75 | headers <- ConvertToAkka.toAkkaHeaders(mp.headers.toList) 76 | } yield { 77 | val fileName = mp.fileName.fold(Map.empty[String, String])(fn => Map("filename" -> fn)) 78 | val bodyPart = createBodyPartEntity(ct, mp.body) 79 | FormData.BodyPart(mp.name, bodyPart, fileName, headers) 80 | } 81 | } 82 | 83 | /* Creates a BodyPart entity. */ 84 | def createBodyPartEntity(ct: ContentType, body: RequestBody[_]): BodyPartEntity = body match { 85 | case StringBody(b, encoding, _) => HttpEntity(contentTypeWithEncoding(ct, encoding), b.getBytes(encoding)) 86 | case ByteArrayBody(b, _) => HttpEntity(ct, b) 87 | case ByteBufferBody(b, _) => HttpEntity(ct, ByteString(b)) 88 | case isb: InputStreamBody => HttpEntity.IndefiniteLength(ct, StreamConverters.fromInputStream(() => isb.b)) 89 | case FileBody(b, _) => HttpEntity.fromPath(ct, b.toPath) 90 | } 91 | 92 | /* Decodes an Akka-HTTP response. */ 93 | def decodeAkkaResponse(response: HttpResponse): HttpResponse = { 94 | val decoder = response.encoding match { 95 | case HttpEncodings.gzip => Coders.Gzip 96 | case HttpEncodings.deflate => Coders.Deflate 97 | case HttpEncodings.identity => Coders.NoCoding 98 | case ce => throw new UnsupportedEncodingException(s"Unsupported encoding: $ce") 99 | } 100 | decoder.decodeMessage(response) 101 | } 102 | 103 | /* Sets the Content Type encoding. */ 104 | def contentTypeWithEncoding(ct: String, enc: String): String = 105 | s"$ct; charset=$enc" 106 | 107 | /* Gets the encoding from the Content Type. */ 108 | def encodingFromContentType(ct: String): Option[String] = 109 | ct.split(";").map(_.trim.toLowerCase).collectFirst { 110 | case s if s.startsWith("charset=") && s.substring(8).trim != "" => s.substring(8).trim 111 | } 112 | 113 | /* Concatenates the Byte Buffers. */ 114 | def concatByteBuffers(bb1: ByteBuffer, bb2: ByteBuffer): ByteBuffer = 115 | ByteBuffer 116 | .allocate(bb1.array().length + bb2.array().length) 117 | .put(bb1) 118 | .put(bb2) 119 | 120 | /* Includes the encoding with the Content Type. */ 121 | def contentTypeWithEncoding(ct: ContentType, encoding: String): ContentType = HttpCharsets 122 | .getForKey(encoding) 123 | .fold(ct)(hc => ContentType.apply(ct.mediaType, () => hc)) 124 | 125 | /* Checks if it is the correct Content type. */ 126 | def isContentType(headerKey: String): Boolean = 127 | headerKey.toLowerCase.contains(`Content-Type`.lowercaseName) 128 | 129 | /* Checks if it is the correct Content length. */ 130 | def isContentLength(headerKey: String): Boolean = 131 | headerKey.toLowerCase.contains(`Content-Length`.lowercaseName) 132 | } 133 | -------------------------------------------------------------------------------- /sttp3-akka-monix-task/src/main/scala/com/fullfacing/akka/monix/task/backend/AkkaMonixHttpBackend.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.task.backend 2 | 3 | import akka.actor.ActorSystem 4 | import akka.event.LoggingAdapter 5 | import akka.http.scaladsl.HttpsConnectionContext 6 | import akka.http.scaladsl.model.HttpRequest 7 | import akka.http.scaladsl.settings.ConnectionPoolSettings 8 | import akka.stream.Materializer 9 | import akka.util.ByteString 10 | import com.fullfacing.akka.monix.core.{ConvertToAkka, ProxySettings} 11 | import com.fullfacing.akka.monix.task.backend.utils.{BodyFromAkka, ConvertToSttp, TaskMonadAsyncError} 12 | import monix.eval.Task 13 | import monix.execution.Scheduler 14 | import monix.reactive.Observable 15 | import sttp.capabilities 16 | import sttp.client3.{FollowRedirectsBackend, Request, Response, SttpBackend, SttpBackendOptions} 17 | import sttp.monad.MonadError 18 | 19 | import scala.concurrent.Future 20 | 21 | class AkkaMonixHttpBackend(actorSystem: ActorSystem, 22 | ec: Scheduler, 23 | terminateActorSystemOnClose: Boolean, 24 | options: SttpBackendOptions, 25 | client: AkkaMonixHttpClient, 26 | customConnectionPoolSettings: Option[ConnectionPoolSettings]) 27 | extends SttpBackend[Task, Observable[ByteString]] { 28 | 29 | /* Initiates the Akka Actor system. */ 30 | implicit val system: ActorSystem = actorSystem 31 | 32 | private lazy val bodyFromAkka = new BodyFromAkka()(ec, implicitly[Materializer], responseMonad) 33 | 34 | def send[T, R >: Observable[ByteString] with capabilities.Effect[Task]](sttpRequest: Request[T, R]): Task[Response[T]] = { 35 | implicit val sch: Scheduler = ec 36 | 37 | val settingsWithProxy = ProxySettings.includeProxy( 38 | ProxySettings.connectionSettings(actorSystem, options, customConnectionPoolSettings), options 39 | ) 40 | val updatedSettings = settingsWithProxy.withUpdatedConnectionSettings(_.withIdleTimeout(sttpRequest.options.readTimeout)) 41 | val requestMethod = ConvertToAkka.toAkkaMethod(sttpRequest.method) 42 | val partialRequest = HttpRequest(uri = sttpRequest.uri.toString, method = requestMethod) 43 | 44 | val request = for { 45 | headers <- ConvertToAkka.toAkkaHeaders(sttpRequest.headers.toList) 46 | body <- ConvertToAkka.toAkkaRequestBody(sttpRequest.body, sttpRequest.headers, partialRequest) 47 | } yield body.withHeaders(headers) 48 | 49 | Task.fromEither(request) 50 | .flatMap(client.singleRequest(_, updatedSettings)) 51 | .flatMap(ConvertToSttp.toSttpResponse[T, R](_, sttpRequest)(bodyFromAkka)) 52 | } 53 | 54 | def responseMonad: MonadError[Task] = TaskMonadAsyncError 55 | 56 | override def close(): Task[Unit] = Task.deferFuture { 57 | if (terminateActorSystemOnClose) actorSystem.terminate else Future.unit 58 | }.void 59 | } 60 | 61 | object AkkaMonixHttpBackend { 62 | 63 | /* Creates a new Actor system and Akka-HTTP client by default. */ 64 | def apply(options: SttpBackendOptions = SttpBackendOptions.Default, 65 | customHttpsContext: Option[HttpsConnectionContext] = None, 66 | customConnectionPoolSettings: Option[ConnectionPoolSettings] = None, 67 | customLog: Option[LoggingAdapter] = None) 68 | (implicit ec: Scheduler = monix.execution.Scheduler.global): SttpBackend[Task, Observable[ByteString]] = { 69 | 70 | val actorSystem = ActorSystem("sttp") 71 | val client = AkkaMonixHttpClient.default(actorSystem, customHttpsContext, customLog) 72 | 73 | val akkaMonixHttpBackend = new AkkaMonixHttpBackend( 74 | actorSystem = actorSystem, 75 | ec = ec, 76 | terminateActorSystemOnClose = false, 77 | options = options, 78 | client = client, 79 | customConnectionPoolSettings = customConnectionPoolSettings 80 | ) 81 | 82 | new FollowRedirectsBackend[Task, Observable[ByteString]](akkaMonixHttpBackend) 83 | } 84 | 85 | /* This constructor allows for a specified Actor system. */ 86 | def usingActorSystem(actorSystem: ActorSystem, 87 | options: SttpBackendOptions = SttpBackendOptions.Default, 88 | customHttpsContext: Option[HttpsConnectionContext] = None, 89 | customConnectionPoolSettings: Option[ConnectionPoolSettings] = None, 90 | customLog: Option[LoggingAdapter] = None) 91 | (implicit ec: Scheduler = monix.execution.Scheduler.global): SttpBackend[Task, Observable[ByteString]] = { 92 | val client = AkkaMonixHttpClient.default(actorSystem, customHttpsContext, customLog) 93 | usingClient(actorSystem, options, customConnectionPoolSettings, client) 94 | } 95 | 96 | /* This constructor allows for a specified Actor system. */ 97 | def usingClient(actorSystem: ActorSystem, 98 | options: SttpBackendOptions = SttpBackendOptions.Default, 99 | poolSettings: Option[ConnectionPoolSettings] = None, 100 | client: AkkaMonixHttpClient) 101 | (implicit ec: Scheduler = monix.execution.Scheduler.global): SttpBackend[Task, Observable[ByteString]] = { 102 | 103 | val akkaMonixHttpBackend = new AkkaMonixHttpBackend( 104 | actorSystem = actorSystem, 105 | ec = ec, 106 | terminateActorSystemOnClose = false, 107 | options = options, 108 | client = client, 109 | customConnectionPoolSettings = poolSettings 110 | ) 111 | 112 | new FollowRedirectsBackend[Task, Observable[ByteString]](akkaMonixHttpBackend) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /sttp3-akka-monix-task/src/main/scala/com/fullfacing/akka/monix/task/backend/AkkaMonixHttpClient.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.task.backend 2 | 3 | import akka.actor.ActorSystem 4 | import akka.event.LoggingAdapter 5 | import akka.http.scaladsl.model.{HttpRequest, HttpResponse} 6 | import akka.http.scaladsl.server.{ExceptionHandler, RejectionHandler, Route, RoutingLog} 7 | import akka.http.scaladsl.settings.{ConnectionPoolSettings, ParserSettings, RoutingSettings} 8 | import akka.http.scaladsl.{Http, HttpsConnectionContext} 9 | import akka.stream.Materializer 10 | import monix.eval.Task 11 | 12 | import scala.concurrent.{ExecutionContextExecutor, Future} 13 | 14 | trait AkkaMonixHttpClient { 15 | def singleRequest(request: HttpRequest, settings: ConnectionPoolSettings): Task[HttpResponse] 16 | } 17 | 18 | object AkkaMonixHttpClient { 19 | def default(system: ActorSystem, 20 | connectionContext: Option[HttpsConnectionContext], 21 | customLog: Option[LoggingAdapter]): AkkaMonixHttpClient = new AkkaMonixHttpClient { 22 | 23 | private val http = Http()(system) 24 | 25 | override def singleRequest(request: HttpRequest, settings: ConnectionPoolSettings): Task[HttpResponse] = Task.deferFuture { 26 | http.singleRequest( 27 | request, 28 | connectionContext.getOrElse(http.defaultClientHttpsContext), 29 | settings, 30 | customLog.getOrElse(system.log) 31 | ) 32 | } 33 | } 34 | 35 | def stubFromAsyncHandler(run: HttpRequest => Future[HttpResponse]): AkkaMonixHttpClient = 36 | (request: HttpRequest, _: ConnectionPoolSettings) => Task.deferFuture(run(request)) 37 | 38 | 39 | def stubFromRoute(route: Route)( 40 | implicit routingSettings: RoutingSettings, 41 | parserSettings: ParserSettings, 42 | materializer: Materializer, 43 | routingLog: RoutingLog, 44 | executionContext: ExecutionContextExecutor = null, 45 | rejectionHandler: RejectionHandler = RejectionHandler.default, 46 | exceptionHandler: ExceptionHandler = null 47 | ): AkkaMonixHttpClient = stubFromAsyncHandler(Route.asyncHandler(route)) 48 | } -------------------------------------------------------------------------------- /sttp3-akka-monix-task/src/main/scala/com/fullfacing/akka/monix/task/backend/utils/AkkaStreams.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.task.backend.utils 2 | 3 | import akka.stream.scaladsl.{Flow, Source} 4 | import akka.util.ByteString 5 | import sttp.capabilities.Streams 6 | 7 | trait AkkaStreams extends Streams[AkkaStreams] { 8 | override type BinaryStream = Source[ByteString, Any] 9 | override type Pipe[A, B] = Flow[A, B, Any] 10 | } 11 | object AkkaStreams extends AkkaStreams 12 | -------------------------------------------------------------------------------- /sttp3-akka-monix-task/src/main/scala/com/fullfacing/akka/monix/task/backend/utils/BodyFromAkka.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.task.backend.utils 2 | 3 | import akka.http.scaladsl.model.ws.{BinaryMessage, Message, TextMessage} 4 | import akka.http.scaladsl.model.{HttpEntity, HttpResponse} 5 | import akka.stream.scaladsl.{FileIO, Flow, Sink, SinkQueueWithCancel, Source, SourceQueueWithComplete} 6 | import akka.stream.{Materializer, OverflowStrategy, QueueOfferResult} 7 | import akka.util.ByteString 8 | import akka.{Done, NotUsed} 9 | import com.fullfacing.akka.monix.core.entityToObservable 10 | import monix.eval.Task 11 | import monix.reactive.Observable 12 | import sttp.client3.internal.{BodyFromResponseAs, SttpFile} 13 | import sttp.client3.ws.{GotAWebSocketException, NotAWebSocketException} 14 | import sttp.client3.{ResponseAs, ResponseAsWebSocket, ResponseAsWebSocketStream, ResponseAsWebSocketUnsafe, WebSocketResponseAs} 15 | import sttp.model.{Headers, ResponseMetadata} 16 | import sttp.monad.{FutureMonad, MonadError} 17 | import sttp.ws.{WebSocket, WebSocketBufferFull, WebSocketClosed, WebSocketFrame} 18 | 19 | import java.util.concurrent.atomic.AtomicBoolean 20 | import scala.concurrent.{ExecutionContext, Future, Promise} 21 | import scala.util.Failure 22 | import scala.util.chaining.scalaUtilChainingOps 23 | 24 | final class BodyFromAkka()(implicit ec: ExecutionContext, mat: Materializer, m: MonadError[Task]) { 25 | 26 | def apply[T, R](responseAs: ResponseAs[T, R], 27 | meta: ResponseMetadata, 28 | response: Either[HttpResponse, Promise[Flow[Message, Message, NotUsed]]]): Task[T] = { 29 | bodyFromResponseAs(responseAs, meta, response) 30 | } 31 | 32 | private lazy val bodyFromResponseAs = 33 | new BodyFromResponseAs[Task, HttpResponse, Promise[Flow[Message, Message, NotUsed]], Observable[ByteString]] { 34 | 35 | override protected def withReplayableBody(response: HttpResponse, replayableBody: Either[Array[Byte], SttpFile]): Task[HttpResponse] = { 36 | val replayEntity = replayableBody match { 37 | case Left(byteArray) => HttpEntity(byteArray) 38 | case Right(file) => HttpEntity.fromFile(response.entity.contentType, file.toFile) 39 | } 40 | 41 | Task.now(response.withEntity(replayEntity)) 42 | } 43 | 44 | override protected def regularIgnore(response: HttpResponse): Task[Unit] = { 45 | // todo: Replace with HttpResponse#discardEntityBytes() once https://github.com/akka/akka-http/issues/1459 is resolved 46 | Task.deferFuture(response.entity.dataBytes.runWith(Sink.ignore)).void 47 | } 48 | 49 | override protected def regularAsByteArray(response: HttpResponse): Task[Array[Byte]] = { 50 | response.entity.dataBytes 51 | .runFold(ByteString(""))(_ ++ _) 52 | .map(_.toArray[Byte]) 53 | .pipe(Task.deferFuture(_)) 54 | } 55 | 56 | override protected def regularAsFile(response: HttpResponse, file: SttpFile): Task[SttpFile] = { 57 | val f = file.toFile 58 | if (!f.exists()) { 59 | f.getParentFile.mkdirs() 60 | f.createNewFile() 61 | } 62 | 63 | Task.deferFuture(response.entity.dataBytes.runWith(FileIO.toPath(file.toPath)).map(_ => file)) 64 | } 65 | 66 | override protected def regularAsStream(response: HttpResponse): Task[(Observable[ByteString], () => Task[Unit])] = { 67 | val task = Task.deferFuture(response.discardEntityBytes().future().map(_ => ()).recover { case _ => () }) 68 | Task.now((entityToObservable(response.entity), () => task)) 69 | } 70 | 71 | override protected def handleWS[T](responseAs: WebSocketResponseAs[T, _], 72 | meta: ResponseMetadata, 73 | ws: Promise[Flow[Message, Message, NotUsed]]): Task[T] = { 74 | wsFromAkka(responseAs, ws, meta) 75 | } 76 | 77 | override protected def cleanupWhenNotAWebSocket(response: HttpResponse, e: NotAWebSocketException): Task[Unit] = { 78 | Task.deferFuture(response.entity.discardBytes().future().map(_ => ())) 79 | } 80 | 81 | override protected def cleanupWhenGotWebSocket(response: Promise[Flow[Message, Message, NotUsed]], 82 | e: GotAWebSocketException): Task[Unit] = { 83 | Task.deferFuture(Future.successful(response.failure(e))) 84 | } 85 | } 86 | 87 | private def wsFromAkka[T, R](rr: WebSocketResponseAs[T, R], 88 | wsFlow: Promise[Flow[Message, Message, NotUsed]], 89 | meta: ResponseMetadata) 90 | (implicit ec: ExecutionContext, 91 | mat: Materializer): Task[T] = { 92 | rr match { 93 | case ResponseAsWebSocket(f) => 94 | Task.deferFuture { 95 | val (flow, wsFuture) = webSocketAndFlow(meta) 96 | wsFlow.success(flow) 97 | wsFuture.flatMap { ws => 98 | val result = f.asInstanceOf[(WebSocket[Future], ResponseMetadata) => Future[T]](ws, meta) 99 | result.onComplete(_ => ws.close()) 100 | result 101 | } 102 | } 103 | 104 | case ResponseAsWebSocketUnsafe() => 105 | Task.deferFuture { 106 | val (flow, wsFuture) = webSocketAndFlow(meta) 107 | wsFlow.success(flow) 108 | wsFuture.asInstanceOf[Future[T]] 109 | } 110 | 111 | case ResponseAsWebSocketStream(_, p) => 112 | Task.deferFuture { 113 | val donePromise = Promise[Done]() 114 | 115 | val flow = Flow[Message] 116 | .mapAsync(1)(messageToFrame) 117 | .via(p.asInstanceOf[AkkaStreams.Pipe[WebSocketFrame.Data[_], WebSocketFrame]]) 118 | .takeWhile { 119 | case WebSocketFrame.Close(_, _) => false 120 | case _ => true 121 | } 122 | .mapConcat(incoming => frameToMessage(incoming).toList) 123 | .watchTermination() { (notUsed, done) => 124 | donePromise.completeWith(done) 125 | notUsed 126 | } 127 | 128 | wsFlow.success(flow) 129 | 130 | donePromise.future.map(_ => ()) 131 | } 132 | } 133 | } 134 | 135 | private def webSocketAndFlow(meta: ResponseMetadata)(implicit 136 | ec: ExecutionContext, 137 | mat: Materializer 138 | ): (Flow[Message, Message, NotUsed], Future[WebSocket[Future]]) = { 139 | val sinkQueuePromise = Promise[SinkQueueWithCancel[Message]]() 140 | val sink = Sink 141 | .queue[Message]() 142 | .mapMaterializedValue(sinkQueuePromise.success) 143 | 144 | val sourceQueuePromise = Promise[SourceQueueWithComplete[Message]]() 145 | val source = 146 | Source.queue[Message](1, OverflowStrategy.backpressure).mapMaterializedValue(sourceQueuePromise.success) 147 | 148 | val flow = Flow.fromSinkAndSource(sink, source) 149 | 150 | val ws = for { 151 | sinkQueue <- sinkQueuePromise.future 152 | sourceQueue <- sourceQueuePromise.future 153 | } yield new WebSocket[Future] { 154 | private val open = new AtomicBoolean(true) 155 | private val closeReceived = new AtomicBoolean(false) 156 | 157 | override def receive: Future[WebSocketFrame] = { 158 | val result = sinkQueue.pull().flatMap { 159 | case Some(m) => messageToFrame(m) 160 | case None => 161 | open.set(false) 162 | val c = closeReceived.getAndSet(true) 163 | if (!c) Future.successful(WebSocketFrame.close) 164 | else Future.failed(WebSocketClosed(Some(WebSocketFrame.close))) 165 | } 166 | 167 | result.onComplete { 168 | case Failure(_) => open.set(false) 169 | case _ => 170 | } 171 | 172 | result 173 | } 174 | 175 | override def send(f: WebSocketFrame, isContinuation: Boolean): Future[Unit] = 176 | f match { 177 | case WebSocketFrame.Close(_, _) => 178 | val wasOpen = open.getAndSet(false) 179 | if (wasOpen) sourceQueue.complete() 180 | sourceQueue.watchCompletion().map(_ => ()) 181 | 182 | case frame: WebSocketFrame => 183 | frameToMessage(frame) match { 184 | case Some(m) => 185 | sourceQueue.offer(m).flatMap { 186 | case QueueOfferResult.Enqueued => Future.successful(()) 187 | case QueueOfferResult.Dropped => 188 | Future.failed(throw new IllegalStateException(WebSocketBufferFull(1))) 189 | case QueueOfferResult.Failure(cause) => Future.failed(cause) 190 | case QueueOfferResult.QueueClosed => 191 | Future.failed(throw new IllegalStateException(WebSocketClosed(None))) 192 | } 193 | case None => Future.successful(()) 194 | } 195 | } 196 | 197 | override def upgradeHeaders: Headers = Headers(meta.headers) 198 | 199 | override def isOpen: Future[Boolean] = Future.successful(open.get()) 200 | 201 | override implicit def monad: MonadError[Future] = new FutureMonad()(ec) 202 | } 203 | 204 | (flow, ws) 205 | } 206 | 207 | private def messageToFrame(m: Message)(implicit ec: ExecutionContext, mat: Materializer): Future[WebSocketFrame.Data[_]] = 208 | m match { 209 | case msg: TextMessage => 210 | msg.textStream.runFold("")(_ + _).map(t => WebSocketFrame.text(t)) 211 | case msg: BinaryMessage => 212 | msg.dataStream.runFold(ByteString.empty)(_ ++ _).map(b => WebSocketFrame.binary(b.toArray)) 213 | } 214 | 215 | private def frameToMessage(w: WebSocketFrame): Option[Message] = { 216 | w match { 217 | case WebSocketFrame.Text(p, _, _) => Some(TextMessage(p)) 218 | case WebSocketFrame.Binary(p, _, _) => Some(BinaryMessage(ByteString(p))) 219 | case WebSocketFrame.Ping(_) => None 220 | case WebSocketFrame.Pong(_) => None 221 | case WebSocketFrame.Close(_, _) => throw WebSocketClosed(None) 222 | } 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /sttp3-akka-monix-task/src/main/scala/com/fullfacing/akka/monix/task/backend/utils/ConvertToSttp.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.task.backend.utils 2 | 3 | import akka.http.scaladsl.model.HttpResponse 4 | import akka.stream.Materializer 5 | import com.fullfacing.akka.monix.core._ 6 | import monix.eval.Task 7 | import monix.execution.Scheduler 8 | import sttp.client3.{Request, Response} 9 | import sttp.model.{Header, HeaderNames, ResponseMetadata, StatusCode} 10 | 11 | import scala.collection.immutable.Seq 12 | 13 | object ConvertToSttp { 14 | 15 | /* Converts Akka-HTTP headers to the STTP equivalent. */ 16 | def toSttpHeaders(response: HttpResponse): Seq[Header] = { 17 | val headCont = HeaderNames.ContentType -> response.entity.contentType.toString() 18 | val contLength = response.entity.contentLengthOption.map(HeaderNames.ContentLength -> _.toString) 19 | val other = response.headers.map(h => (h.name, h.value)) 20 | val headerMap = headCont :: (contLength.toList ++ other) 21 | 22 | headerMap.flatMap { case (k, v) => Header.safeApply(k, v).toOption } 23 | } 24 | 25 | /* Converts an Akka-HTTP response to a STTP equivalent. */ 26 | def toSttpResponse[T, R](response: HttpResponse, 27 | sttpRequest: Request[T, R]) 28 | (bodyFromAkka: BodyFromAkka) 29 | (implicit scheduler: Scheduler, mat: Materializer): Task[Response[T]] = { 30 | val statusCode = StatusCode(response.status.intValue()) 31 | val statusText = response.status.reason() 32 | val respHeaders = toSttpHeaders(response) 33 | val respMetadata = ResponseMetadata(statusCode, statusText, respHeaders) 34 | val decodedResp = decodeAkkaResponse(response) 35 | 36 | val body = bodyFromAkka(sttpRequest.response, respMetadata, Left(decodedResp)) 37 | body.map(t => Response(t, statusCode, statusText, respHeaders)) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /sttp3-akka-monix-task/src/main/scala/com/fullfacing/akka/monix/task/backend/utils/TaskMonadAsyncError.scala: -------------------------------------------------------------------------------- 1 | package com.fullfacing.akka.monix.task.backend.utils 2 | 3 | import cats.implicits._ 4 | import monix.eval.Task 5 | import sttp.monad.{Canceler, MonadAsyncError} 6 | 7 | import scala.util.{Failure, Success} 8 | 9 | object TaskMonadAsyncError extends MonadAsyncError[Task] { 10 | override def unit[T](t: T): Task[T] = Task.now(t) 11 | 12 | override def map[T, T2](fa: Task[T])(f: (T) => T2): Task[T2] = fa.map(f) 13 | 14 | override def flatMap[T, T2](fa: Task[T])(f: (T) => Task[T2]): Task[T2] = 15 | fa.flatMap(f) 16 | 17 | override def async[T](register: (Either[Throwable, T] => Unit) => Canceler): Task[T] = 18 | Task.async { cb => 19 | register { 20 | case Left(t) => cb(Failure(t)) 21 | case Right(t) => cb(Success(t)) 22 | } 23 | } 24 | 25 | override def error[T](t: Throwable): Task[T] = Task.raiseError(t) 26 | 27 | override protected def handleWrappedError[T](rt: Task[T])(h: PartialFunction[Throwable, Task[T]]): Task[T] = 28 | rt.onErrorRecoverWith(h) 29 | 30 | def ensure[T](f: Task[T], e: => Task[Unit]): Task[T] = { 31 | f.flatTap(_ => e) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /version.sbt: -------------------------------------------------------------------------------- 1 | ThisBuild / version := "2.1.0-SNAPSHOT" 2 | --------------------------------------------------------------------------------