Skip to content

Commit 7d55623

Browse files
authored
Allow more precise definition of response body handling in stubs (#2436)
1 parent 4de36db commit 7d55623

File tree

42 files changed

+594
-357
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+594
-357
lines changed

akka-http-backend/src/test/scala/sttp/client4/akkahttp/BackendStubAkkaTests.scala

+2-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import sttp.client4._
1010
import scala.concurrent.Await
1111
import scala.concurrent.ExecutionContext.Implicits.global
1212
import scala.concurrent.duration._
13+
import sttp.client4.testing.ResponseStub
1314

1415
class BackendStubAkkaTests extends AnyFlatSpec with Matchers with ScalaFutures with BeforeAndAfterAll {
1516

@@ -22,7 +23,7 @@ class BackendStubAkkaTests extends AnyFlatSpec with Matchers with ScalaFutures w
2223
// given
2324
val backend = AkkaHttpBackend.stub
2425
.whenRequestMatches(_ => true)
25-
.thenRespondCyclic("a", "b", "c")
26+
.thenRespondCyclic(ResponseStub.adjust("a"), ResponseStub.adjust("b"), ResponseStub.adjust("c"))
2627

2728
// when
2829
def r = basicRequest.get(uri"http://example.org/a/b/c").send(backend).futureValue

armeria-backend/zio1/src/main/scala/sttp/client4/armeria/zio/zio.scala

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import sttp.capabilities.Effect
55
import sttp.capabilities.zio.ZioStreams
66
import sttp.client4._
77
import sttp.client4.impl.zio.{StreamBackendExtendEnv, StreamClientStubbing}
8+
import sttp.client4.testing.StubBody
89

910
package object zio {
1011

@@ -59,7 +60,7 @@ package object zio {
5960
StubbingWhenRequest(_ => true)
6061

6162
def whenRequestMatchesPartial(
62-
partial: PartialFunction[GenericRequest[_, _], Response[_]]
63+
partial: PartialFunction[GenericRequest[_, _], Response[StubBody]]
6364
): URIO[SttpClientStubbing, Unit] =
6465
ZIO.accessM(_.get.whenRequestMatchesPartial(partial))
6566
}

caching/src/test/scala/sttp/client4/caching/CachingBackendTest.scala

+4-4
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,13 @@ class CachingBackendTest extends AnyFlatSpec with Matchers {
5252
.thenRespond {
5353
invocationCounter += 1
5454
ResponseStub
55-
.ok("response body 1")
55+
.adjust("response body 1")
5656
.copy(headers = List(Header(HeaderNames.CacheControl, CacheDirective.MaxAge(5.seconds).toString)))
5757
}
5858
.whenRequestMatches(_.uri.toString == "http://example2.org")
5959
.thenRespond {
6060
invocationCounter += 1
61-
ResponseStub.ok("response body 2") // no cache-control header
61+
ResponseStub.adjust("response body 2") // no cache-control header
6262
}
6363
val cachingBackend = CachingBackend(delegate, cache)
6464

@@ -102,7 +102,7 @@ class CachingBackendTest extends AnyFlatSpec with Matchers {
102102
.thenRespond {
103103
invocationCounter += 1
104104
ResponseStub
105-
.ok("""{"v1": 42, "v2": "foo", "v3": true}""")
105+
.adjust("""{"v1": 42, "v2": "foo", "v3": true}""")
106106
.copy(headers = List(Header(HeaderNames.CacheControl, CacheDirective.MaxAge(5.seconds).toString)))
107107
}
108108
val cachingBackend = CachingBackend(delegate, cache)
@@ -129,7 +129,7 @@ class CachingBackendTest extends AnyFlatSpec with Matchers {
129129
.thenRespondF { request =>
130130
invocationCounter += 1
131131
ResponseStub
132-
.ok(s"response body: ${request.header("X-Test").getOrElse("no-x-test")}")
132+
.adjust(s"response body: ${request.header("X-Test").getOrElse("no-x-test")}")
133133
.copy(headers = List(Header(HeaderNames.CacheControl, CacheDirective.MaxAge(5.seconds).toString)))
134134
}
135135
val cachingBackend = CachingBackend(delegate, cache)

core/src/main/scala/sttp/client4/testing/AbstractBackendStub.scala

+116-74
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,15 @@ import sttp.ws.WebSocket
1212
import sttp.ws.testing.WebSocketStub
1313

1414
import scala.util.{Failure, Success, Try}
15+
import sttp.model.StatusText
1516

1617
abstract class AbstractBackendStub[F[_], P](
1718
_monad: MonadError[F],
18-
matchers: PartialFunction[GenericRequest[_, _], F[Response[_]]],
19+
matchers: PartialFunction[GenericRequest[_, _], F[Response[StubBody]]],
1920
fallback: Option[GenericBackend[F, P]]
2021
) extends GenericBackend[F, P] {
21-
2222
type Self
23-
24-
protected def withMatchers(matchers: PartialFunction[GenericRequest[_, _], F[Response[_]]]): Self
25-
23+
protected def withMatchers(matchers: PartialFunction[GenericRequest[_, _], F[Response[StubBody]]]): Self
2624
override def monad: MonadError[F] = _monad
2725

2826
/** Specify how the stub backend should respond to requests matching the given predicate.
@@ -42,9 +40,9 @@ abstract class AbstractBackendStub[F[_], P](
4240
*
4341
* Note that the stubs are immutable, and each new specification that is added yields a new stub instance.
4442
*/
45-
def whenRequestMatchesPartial(partial: PartialFunction[GenericRequest[_, _], Response[_]]): Self = {
46-
val wrappedPartial: PartialFunction[GenericRequest[_, _], F[Response[_]]] =
47-
partial.andThen((r: Response[_]) => monad.unit(r))
43+
def whenRequestMatchesPartial(partial: PartialFunction[GenericRequest[_, _], Response[StubBody]]): Self = {
44+
val wrappedPartial: PartialFunction[GenericRequest[_, _], F[Response[StubBody]]] =
45+
partial.andThen((r: Response[StubBody]) => monad.unit(r))
4846
withMatchers(matchers.orElse(wrappedPartial))
4947
}
5048

@@ -54,7 +52,14 @@ abstract class AbstractBackendStub[F[_], P](
5452
adjustExceptions(request) {
5553
monad.flatMap(response) { r =>
5654
request.options.onBodyReceived(r)
57-
tryAdjustResponseType(request.response, r.asInstanceOf[Response[T]])(monad)
55+
56+
r.body match {
57+
case StubBody.Exact(v) => monad.unit(r.copy(body = v.asInstanceOf[T]))
58+
case StubBody.Adjust(v) =>
59+
monad.map(adjustResponseBody(request.response.delegate, v, r.asInstanceOf[Response[T]])(monad))(b =>
60+
r.copy(body = b)
61+
)
62+
}
5863
}
5964
}
6065
case Success(None) =>
@@ -80,35 +85,70 @@ abstract class AbstractBackendStub[F[_], P](
8085
override def close(): F[Unit] = monad.unit(())
8186

8287
class WhenRequest(p: GenericRequest[_, _] => Boolean) {
83-
def thenRespondOk(): Self = thenRespondWithCode(StatusCode.Ok, "OK")
84-
def thenRespondNotFound(): Self = thenRespondWithCode(StatusCode.NotFound, "Not found")
85-
def thenRespondServerError(): Self = thenRespondWithCode(StatusCode.InternalServerError, "Internal server error")
86-
def thenRespondWithCode(status: StatusCode, msg: String = ""): Self = thenRespond(ResponseStub(msg, status, msg))
87-
def thenRespond[T](body: T): Self = thenRespond(ResponseStub[T](body, StatusCode.Ok, "OK"))
88-
def thenRespond[T](body: T, statusCode: StatusCode): Self = thenRespond(ResponseStub[T](body, statusCode))
89-
def thenRespond[T](resp: => Response[T]): Self = {
90-
val m: PartialFunction[GenericRequest[_, _], F[Response[_]]] = {
91-
case r if p(r) => monad.eval(resp.copy(request = r.onlyMetadata))
88+
89+
/** Respond with an empty body and the 200 status code */
90+
def thenRespondOk(): Self = thenRespondWithCode(StatusCode.Ok)
91+
def thenRespondBadRequest(): Self = thenRespondWithCode(StatusCode.BadRequest)
92+
def thenRespondNotFound(): Self = thenRespondWithCode(StatusCode.NotFound)
93+
def thenRespondServerError(): Self = thenRespondWithCode(StatusCode.InternalServerError)
94+
def thenRespondUnauthorized(): Self = thenRespondWithCode(StatusCode.Unauthorized)
95+
def thenRespondForbidden(): Self = thenRespondWithCode(StatusCode.Forbidden)
96+
97+
/** Respond with an empty body (for 1xx/2xx responses), or a body with an error message (for 4xx/5xx responses) and
98+
* the given status code.
99+
*/
100+
def thenRespondWithCode(code: StatusCode): Self =
101+
thenRespondAdjust(
102+
if (code.isClientError || code.isServerError) StatusText.default(code).getOrElse("") else "",
103+
code
104+
)
105+
106+
/** Adjust the given body, as specified in the request's response handling description. */
107+
def thenRespondAdjust(body: Any): Self = thenRespond(ResponseStub.adjust(body))
108+
109+
/** Adjust the given body, as specified in the request's response handling description. */
110+
def thenRespondAdjust(body: Any, code: StatusCode): Self = thenRespond(ResponseStub.adjust(body, code))
111+
112+
/** Respond with the given body, regardless of what's specified in the request's response handling description. */
113+
def thenRespondExact(body: Any): Self = thenRespond(ResponseStub.exact(body))
114+
115+
/** Respond with the given body, regardless of what's specified in the request's response handling description. */
116+
def thenRespondExact(body: Any, code: StatusCode): Self = thenRespond(ResponseStub.exact(body, code))
117+
118+
def thenThrow(e: Throwable): Self = {
119+
val m: PartialFunction[GenericRequest[_, _], F[Response[StubBody]]] = {
120+
case r if p(r) => monad.error(e)
92121
}
93122
withMatchers(matchers.orElse(m))
94123
}
95124

96-
def thenRespondCyclic[T](bodies: T*): Self =
97-
thenRespondCyclicResponses(bodies.map(body => ResponseStub[T](body, StatusCode.Ok, "OK")): _*)
125+
/** Response with the given response (lazily evaluated). To create responses, use [[ResponseStub]]. */
126+
def thenRespond[T](resp: => Response[StubBody]): Self = {
127+
val m: PartialFunction[GenericRequest[_, _], F[Response[StubBody]]] = {
128+
case r if p(r) => monad.eval(resp.copy(request = r.onlyMetadata))
129+
}
130+
withMatchers(matchers.orElse(m))
131+
}
98132

99-
def thenRespondCyclicResponses[T](responses: Response[T]*): Self = {
133+
/** Response with the given responses, in a loop. To create responses, use [[ResponseStub]]. */
134+
def thenRespondCyclic(responses: Response[StubBody]*): Self = {
100135
val iterator = AtomicCyclicIterator.unsafeFrom(responses)
101136
thenRespond(iterator.next())
102137
}
103138

104-
def thenRespondF(resp: => F[Response[_]]): Self = {
105-
val m: PartialFunction[GenericRequest[_, _], F[Response[_]]] = {
139+
/** Response with the given response, given as an F-effect. To create responses, use [[ResponseStub]]. */
140+
def thenRespondF(resp: => F[Response[StubBody]]): Self = {
141+
val m: PartialFunction[GenericRequest[_, _], F[Response[StubBody]]] = {
106142
case r if p(r) => resp
107143
}
108144
withMatchers(matchers.orElse(m))
109145
}
110-
def thenRespondF(resp: GenericRequest[_, _] => F[Response[_]]): Self = {
111-
val m: PartialFunction[GenericRequest[_, _], F[Response[_]]] = {
146+
147+
/** Response with the given response, given as an F-effect, created basing on the received request. To create
148+
* responses, use [[ResponseStub]].
149+
*/
150+
def thenRespondF(resp: GenericRequest[_, _] => F[Response[StubBody]]): Self = {
151+
val m: PartialFunction[GenericRequest[_, _], F[Response[StubBody]]] = {
112152
case r if p(r) => resp(r)
113153
}
114154
withMatchers(matchers.orElse(m))
@@ -117,79 +157,81 @@ abstract class AbstractBackendStub[F[_], P](
117157
}
118158

119159
object AbstractBackendStub {
120-
121-
private[client4] def tryAdjustResponseType[DesiredRType, RType, F[_]](
122-
ra: ResponseAsDelegate[DesiredRType, _],
123-
m: Response[RType]
124-
)(implicit monad: MonadError[F]): F[Response[DesiredRType]] =
125-
tryAdjustResponseBody(ra.delegate, m.body, m).getOrElse(monad.unit(m.body)).map { nb =>
126-
m.copy(body = nb.asInstanceOf[DesiredRType])
127-
}
128-
129-
private[client4] def tryAdjustResponseBody[F[_], T, U](
160+
private def adjustResponseBody[F[_], T, U](
130161
ra: GenericResponseAs[T, _],
131162
b: U,
132163
meta: ResponseMetadata
133-
)(implicit monad: MonadError[F]): Option[F[T]] = {
164+
)(implicit monad: MonadError[F]): F[T] = {
134165
def bAsInputStream = b match {
135-
case s: String => Some(new ByteArrayInputStream(s.getBytes(Utf8)))
136-
case a: Array[Byte] => Some(new ByteArrayInputStream(a))
137-
case is: InputStream => Some(is)
138-
case () => Some(new ByteArrayInputStream(new Array[Byte](0)))
139-
case _ => None
166+
case s: String => (new ByteArrayInputStream(s.getBytes(Utf8))).unit
167+
case a: Array[Byte] => (new ByteArrayInputStream(a)).unit
168+
case is: InputStream => is.unit
169+
case () => (new ByteArrayInputStream(new Array[Byte](0))).unit
170+
case _ =>
171+
monad.error(throw new IllegalArgumentException(s"Provided body: $b, cannot be adjusted to an input stream"))
140172
}
141173

142174
ra match {
143-
case IgnoreResponse => Some(().unit.asInstanceOf[F[T]])
175+
case IgnoreResponse => ().unit.asInstanceOf[F[T]]
144176
case ResponseAsByteArray =>
145177
b match {
146-
case s: String => Some(s.getBytes(Utf8).unit.asInstanceOf[F[T]])
147-
case a: Array[Byte] => Some(a.unit.asInstanceOf[F[T]])
148-
case is: InputStream => Some(toByteArray(is).unit.asInstanceOf[F[T]])
149-
case () => Some(Array[Byte]().unit.asInstanceOf[F[T]])
150-
case _ => None
151-
}
152-
case ResponseAsStream(_, f) =>
153-
b match {
154-
case RawStream(s) => Some(monad.suspend(f.asInstanceOf[(Any, ResponseMetadata) => F[T]](s, meta)))
155-
case _ => None
178+
case s: String => s.getBytes(Utf8).unit.asInstanceOf[F[T]]
179+
case a: Array[Byte] => a.unit.asInstanceOf[F[T]]
180+
case is: InputStream => toByteArray(is).unit.asInstanceOf[F[T]]
181+
case () => Array[Byte]().unit.asInstanceOf[F[T]]
182+
case _ => monad.error(new IllegalArgumentException(s"Provided body: $b, cannot be adjusted to a byte array"))
156183
}
157-
case ResponseAsStreamUnsafe(_) =>
158-
b match {
159-
case RawStream(s) => Some(s.unit.asInstanceOf[F[T]])
160-
case _ => None
161-
}
162-
case ResponseAsInputStream(f) => bAsInputStream.map(f).map(_.unit.asInstanceOf[F[T]])
163-
case ResponseAsInputStreamUnsafe => bAsInputStream.map(_.unit.asInstanceOf[F[T]])
184+
case ResponseAsStream(_, f) => monad.suspend(f.asInstanceOf[(Any, ResponseMetadata) => F[T]](b, meta))
185+
case ResponseAsStreamUnsafe(_) => b.unit.asInstanceOf[F[T]]
186+
case ResponseAsInputStream(f) => bAsInputStream.map(f).asInstanceOf[F[T]]
187+
case ResponseAsInputStreamUnsafe => bAsInputStream.asInstanceOf[F[T]]
164188
case ResponseAsFile(_) =>
165189
b match {
166-
case f: SttpFile => Some(f.unit.asInstanceOf[F[T]])
167-
case _ => None
190+
case f: SttpFile => f.unit.asInstanceOf[F[T]]
191+
case _ => monad.error(new IllegalArgumentException(s"Provided body: $b, cannot be adjusted to a file"))
168192
}
169193
case ResponseAsWebSocket(f) =>
170194
b match {
171195
case wss: WebSocketStub[_] =>
172-
Some(f.asInstanceOf[(WebSocket[F], ResponseMetadata) => F[T]](wss.build[F](monad), meta))
196+
f.asInstanceOf[(WebSocket[F], ResponseMetadata) => F[T]](wss.build[F](monad), meta)
173197
case ws: WebSocket[_] =>
174-
Some(f.asInstanceOf[(WebSocket[F], ResponseMetadata) => F[T]](ws.asInstanceOf[WebSocket[F]], meta))
175-
case _ => None
198+
f.asInstanceOf[(WebSocket[F], ResponseMetadata) => F[T]](ws.asInstanceOf[WebSocket[F]], meta)
199+
case _ =>
200+
monad.error(
201+
new IllegalArgumentException(
202+
s"Provided body: $b is neither a WebSocket, nor a WebSocketStub instance"
203+
)
204+
)
176205
}
177206
case ResponseAsWebSocketUnsafe() =>
178207
b match {
179-
case wss: WebSocketStub[_] => Some(wss.build[F](monad).unit.asInstanceOf[F[T]])
180-
case _ => None
208+
case wss: WebSocketStub[_] => wss.build[F](monad).unit.asInstanceOf[F[T]]
209+
case ws: WebSocket[_] => ws.asInstanceOf[WebSocket[F]].unit.asInstanceOf[F[T]]
210+
case _ =>
211+
monad.error(
212+
new IllegalArgumentException(
213+
s"Provided body: $b is neither a WebSocket, nor a WebSocketStub instance"
214+
)
215+
)
216+
}
217+
case ResponseAsWebSocketStream(_, pipe) =>
218+
b match {
219+
case WebSocketStreamConsumer(consume) => consume.asInstanceOf[Any => F[T]].apply(pipe)
220+
case _ => monad.error(new IllegalArgumentException(s"Provided body: $b is not a WebSocketStreamConsumer"))
181221
}
182-
case ResponseAsWebSocketStream(_, _) => None
183222
case MappedResponseAs(raw, g, _) =>
184-
tryAdjustResponseBody(raw, b, meta).map(_.flatMap(result => monad.eval(g(result, meta))))
185-
case rfm: ResponseAsFromMetadata[_, _] => tryAdjustResponseBody(rfm(meta), b, meta)
223+
adjustResponseBody(raw, b, meta).flatMap(result => monad.eval(g(result, meta)))
224+
case rfm: ResponseAsFromMetadata[_, _] => adjustResponseBody(rfm(meta), b, meta)
186225
case ResponseAsBoth(l, r) =>
187-
tryAdjustResponseBody(l, b, meta).map { lAdjusted =>
188-
tryAdjustResponseBody(r, b, meta) match {
189-
case None => lAdjusted.map((_, None))
190-
case Some(rAdjusted) => lAdjusted.flatMap(lResult => rAdjusted.map(rResult => (lResult, Some(rResult))))
226+
adjustResponseBody(l, b, meta)
227+
.flatMap { lAdjusted =>
228+
adjustResponseBody(r, b, meta)
229+
.map(rAdjusted => (lAdjusted, Option(rAdjusted)))
230+
.handleError { case _: IllegalArgumentException =>
231+
monad.unit((lAdjusted, Option.empty))
232+
}
191233
}
192-
}
234+
.asInstanceOf[F[T]] // needed by Scala2
193235
}
194236
}
195237
}

core/src/main/scala/sttp/client4/testing/AtomicCyclicIterator.scala

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package sttp.client4.testing
33
import java.util.concurrent.atomic.AtomicInteger
44
import scala.util.{Failure, Success, Try}
55

6-
final class AtomicCyclicIterator[+T] private (val elements: Seq[T]) {
6+
private[testing] final class AtomicCyclicIterator[+T] private (val elements: Seq[T]) {
77
private val vector = elements.toVector
88
private val length = elements.length
99
private val currentIndex = new AtomicInteger(0)
@@ -14,7 +14,7 @@ final class AtomicCyclicIterator[+T] private (val elements: Seq[T]) {
1414
}
1515
}
1616

17-
object AtomicCyclicIterator {
17+
private[testing] object AtomicCyclicIterator {
1818

1919
def tryFrom[T](elements: Seq[T]): Try[AtomicCyclicIterator[T]] =
2020
if (elements.nonEmpty)

core/src/main/scala/sttp/client4/testing/BackendStub.scala

+12-10
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,28 @@ import scala.concurrent.ExecutionContext
88

99
/** A stub backend to use in tests.
1010
*
11-
* The stub can be configured to respond with a given response if the request matches a predicate (see the
12-
* [[whenRequestMatches()]] method).
11+
* The stub can be configured to respond with a given response if the request matches a predicate (see the `when...`
12+
* methods).
1313
*
14-
* Note however, that this is not type-safe with respect to the type of the response body - the stub doesn't have a way
15-
* to check if the type of the body in the configured response is the same as the one specified by the request. Some
16-
* conversions will be attempted (e.g. from a `String` to a custom mapped type, as specified in the request, see the
17-
* documentation for more details).
14+
* The response bodies can be adjusted to what's described in the request description, or returned exactly as provided.
15+
* See [[StubBody]] for details on how the body is adjusted, and [[ResponseStub]] for convenience methods to create
16+
* responses to be used in tests. The `.thenRespondAdjust` and `.thenRespondExact` methods cover the common use-cases.
1817
*
19-
* Predicates can match requests basing on the URI or headers. A [[ClassCastException]] might occur if for a given
20-
* request, a response is specified with the incorrect or inconvertible body type.
18+
* Note that providing the stub body is not type-safe: the stub doesn't have a way to check if the type of the body in
19+
* the configured response is the same as, or can be converted to, the one specified by the request; hence, a
20+
* [[ClassCastException]] or [[IllegalArgumentException]] might occur, while sending requests using the stub backend.
21+
*
22+
* Predicates can match requests basing on the URI or headers.
2123
*/
2224
class BackendStub[F[_]](
2325
monad: MonadError[F],
24-
matchers: PartialFunction[GenericRequest[_, _], F[Response[_]]],
26+
matchers: PartialFunction[GenericRequest[_, _], F[Response[StubBody]]],
2527
fallback: Option[Backend[F]]
2628
) extends AbstractBackendStub[F, Any](monad, matchers, fallback)
2729
with Backend[F] {
2830

2931
type Self = BackendStub[F]
30-
override protected def withMatchers(matchers: PartialFunction[GenericRequest[_, _], F[Response[_]]]) =
32+
override protected def withMatchers(matchers: PartialFunction[GenericRequest[_, _], F[Response[StubBody]]]) =
3133
new BackendStub(monad, matchers, fallback)
3234
}
3335

core/src/main/scala/sttp/client4/testing/RawStream.scala

-3
This file was deleted.

0 commit comments

Comments
 (0)