├── .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 | [](https://circleci.com/gh/fullfacing/akkaMonixSttpBackend)
2 | [](https://search.maven.org/search?q=a:sttp-akka-monix-task_2.13)
3 | [](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 |
--------------------------------------------------------------------------------