New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Proposal] Add type hierarchy of requests and backends #1703
Conversation
RequestT is replaced by a hierarchy of types, comprised of: - PartialRequest[T] - Request[T] - WebSocketRequest[F[_], T] - StreamRequest[T, S] - WebSocketStreamRequest[T, S]
SttpBackend[F[_], R] is replaced by a hierarchy of types comprised of: - SyncBackend - Backend[F[_]] - WebSocketBackend[F[_]] - StreamBackend[F[_], S] - WebSocketStreamBackend[F[_], S]
@adpi2 Thank you! That's a really well-prepared (& most importantly - working!) proposal. It would be of course quite a big revolution in the sttp space. But definitely worth considering. Since you kick-started discussions around sttp4, I created a forum thread to attempt to gather feedback from existing sttp users, to understand what are the biggest pain points both for beginners & long-time users, so that we fix the right problems, and don't create new ones. Some quite comments on the proposal itself:
It boils down to a tradeoff: simpler & more user-friendly types of requests/backends, vs the flexibility to be able to customise any request and create any backend wrapper without code duplication Some things that are missing:
Questions:
And also to consider
|
@adamw Thanks for the good feedback.
That's a great! It would be good to have some feedback from complete Scala beginners, but that's harder to get.
It is still possible, but slightly more constrained. This is how: def customize[R <: RequestBuilder[R]](request: R): R =
request.body("foo" -> "bar")
.auth.basic("user", "password")
.cookies("foo" -> "bar")
.maxRedirects(2)
val request: Request[String] =
customize(basicRequest.get(uri"...").response(asStringAlways))
val wsRequest: WebSocketRequest[Future,String] =
customize(
basicRequest.get(uri"").response(asWebSocketAlways(???))
)
It would change:
I wonder if it would be possible to automate the migration. I think it is partially possible: we can define Scalafix rules to automate some of the changes, but definitely not all of them. A good migration guide is still very much needed.
As a user of the toolkit, I would prefer not to have access to the In other words, the asStream methods should only be available if I import for instance def asStream[F[_], T](f: fs2.Stream[F, Byte] => F[T]) instead of: def asStream[F[_], T, S](s: S)(f: s.BinaryStream => F[T]) This is only an idea, and I am already quite happy with the current default import.
As far as I am concerned, I don't think it is needed. If we release an experimental version of sttp4, we can later decide to break other parts of the API, even the shared model, and coordinate with a new version of tapir. Or rollback some of the changes, if the migration is too hard. That would be the safest strategy IMO.
To be honest, I don't have much experience in maintaining such a library with so many different implementations, depending on many different libraries and ecosystems. I imagine we can maintain sttp3 and sttp4 in two branches. As long as sttp4 is experimental, sttp3 is the default branch, it receives all bug-fixes, and added features. Once in a while we port all the changes to sttp4. Once sttp4 is stable, it becomes the default and we backport the bugfixes to sttp3.
👍 for dropping Scala 2.11 |
Thanks for making this proposal @adpi2, and thank you for writing all the detailed descriptions. I think that the proposed changes indeed make the signatures of the user API simpler and more discoverable, and this is a step in the right direction. Looking at the big picture however, I think that the sttp API is in general still more advanced than what I would imagine when thinking of a "basic" HTTP client. Personally, my ideal simple client would be a bunch of static methods which wrap HTTP verbs eagerly, and where everything would be accessible with only one non-wildcard import. My favorite examples of such an API are Python's requests library and the requests-scala library, and, if you stretch the definition a bit, the Now, I don't think that sttp's interface should change to accomodate this, because otherwise it wouldn't be sttp anymore. Hence I wonder if it would make sense to split the offering in two: one library for a high-level interface, similar to scala-requests, and one more powerful and flexible interface -- yet still very discoverable -- like this one proposed here, but where all still use sttp's backends. I haven't looked too much into what would be required for this, but I can try to spend some time next week on a proof-of-concept, porting scala-requests (JVM to start) to use sttp under the hood. |
@adpi2 sorry for the long silence - I was trying to gather some feedback both regarding the proposition and what users would like to see changed in sttp. Unfortunately, this mostly failed - despite quite large engagement on various posts in social media - I got only a couple of replies, and about areas other than discussed here (though these will be probably need to be addressed in sttp4 anyway, so good to know about them and thanks for the people that did respond :) ). But also, there was not a word of negative feedback, so I guess that's a good sign ;). I'll do a more thorough review next (though maybe I won't have any comments) and we'll take it from there. Another thing to consider is the cost of migrating. A number of people are already using sttp, and a new major version means that eventually, they should migrate. So we should be careful to impose this additional cost only when this really pays off. One idea regarding the various backends and their wrappers - maybe it would be possible to have sth like a
This is good enough - as long as it's possible to write a generic request-modifying function, should be fine. However, this would mean that
I understand that, but on the other hand this limits discoverability as it's harder to get to know about the streaming capabilities in the first place, plus having to lookup the specific import (if an IDE doesn't help us). So I think the cons outweight the pros here.
Yeah, it's the backporting that is the whole problem. Each PR after being merges needs to be rebased and a PR needs to be created for the other branch (this should be possible to automate). Then you need to merge, release, etc. It's additional boring work that needs to be done ;) |
@jodersky I might be wrong here, but I don't have the impression that the main audience for the Toolkit is one-off programs and scripts: an area where Scala is currently almost non-existent (I haven't heard about anybody doing scripting in Scala, to be honest, comparing to a whole lot of other use-cases). But of course, I'm not the one defining what's in the Toolkit or not :). An alternative API is of course possible, but that would be a different library. As above - I'm not the one picking what goes into the Toolkit, I can just try to do my best if sttp is offered the opportunity. So I'm not sure if that's the best place for discussing whether sttp, or another more Python-like library should be the Toolkit's HTTP client. It might be that another library is a better choice - but they I'm afraid I or other people from the sttp team won't be of much help :). One thing I'll definitely not want to do, is include an alternative API in sttp itself. Firstly because of maintenance reasons, secondly because I don't think having two libraries glued into one is a good idea. If another approach is better, fine, but let's not overcomplicate and try to call it a single thing. sttp's power lies in the fact that it scales from direct, synchronous backends, making simple GET requests, all the way to integrating with effect systems and streaming libraries. You only have to learn a single API. I don't think that HTTP is complicated enough so that you should have to decide when to use the "simple" and when the "advanced" library. Not the fact that there are multiple approaches to effect handling in Scala, ranging from direct, through |
Have we thought about including both sstp and a simple requests-like library that wraps it in the toolkit? The upside would be that simple use cases would be handled simply but that full sttp is available as a fallback for more advanced stuff. |
I think that Scala has for many years now been in a vicious cycle where library authors don't cater to this use case because they think the users aren't out there, but the users aren't there in part because they aren't usually well serviced by library authors (except Haoyi). Breaking out of this cycle is one of the aims of the Toolkit. |
Toolkit is the motivation for this PR, but is not really the main topic here. I feel like motivating this change based on Toolkit can shift the perspective from the usability argumentation, and I feel there's a significant improvement. Mainly I don't really think that it makes the sttp more basic, it definitely simplifies the type signatures, making them less scary, but, per the discussion, does not make it more limited. And, in my opinion, that makes it a great change, regardless of the existence of Toolkit. In this case it's not really shifting any balance, I feel that both newcomers and more experienced Scala users can benefit from this in some way. Nevertheless, it's not an easy task to make change this big in a big library. But I would really like to use sttp API like this in the future :)
Toolkit is an attempt to address a community need and every perspective has to be heard and addressed, without it we miss the point. Everyone willing to get engaged in creating the Toolkit will be the part of process of defining what's in the Toolkit. Of course we need to make a decision at some point and have processes for this purpose, otherwise we could discuss every step indefinitely :) We will be opening a discussion about it soon, I hope that all the parties that have something to say will speak up. Even when it's purely negative feedback, given of course that it is constructive. |
You're right, though I'd say that scripts are the main audience of sttp; so I wouldn't prioritise this over all the other use-cases. So this would be a "nice to have" in terms of priorities. Though I'd argue that we have sth that isn't far off. It's a single wildcard import (which is a difference from what @jodersky is describing, but we need to bring in both a default backend instance & the API to describe requests): import sttp.client3.quick._
simpleHttpClient.send(quickRequest.get(uri"http://httpbin.org/ip")) If we replace import sttp.client3.quick._
quickRequest.get(uri"http://httpbin.org/ip").send() |
Huge thanks to @adamw for sttp and @adpi2 for that promising PR. As an sttp user I would love to get an experimental release, to try the proposed API changes. In my experience APIs are always hard to judge without using them. In my opinion it would be great to have a scalable API for a scalable language. The quickRequest example from @adamw looks promising to me. Scalable in my opinion means, to avoid having two APIs, but sensible defaults and convenience methods to keep simple things simple. Additionally I would love, if sttp would be easier to debug. I would like to be able to call |
@DaniRey thanks for sharing your perspective :) There is |
Thanks @adamw, that's amazing! Don't know how I missed that. I will check it out after my vacation. |
For completeness, here's a (positive) comment by @baldram: https://functional.cafe/@baldram/109806495134758932 |
# Conflicts: # armeria-backend/fs2-ce2/src/test/scala/sttp/client3/armeria/fs2/ArmeriaFs2StreamingTest.scala # armeria-backend/fs2/src/main/scala/sttp/client3/armeria/fs2/ArmeriaFs2Backend.scala # armeria-backend/fs2/src/test/scala/sttp/client3/armeria/fs2/ArmeriaFs2StreamingTest.scala # armeria-backend/monix/src/test/scala/sttp/client3/armeria/monix/ArmeriaMonixStreamingTest.scala # armeria-backend/zio/src/test/scala/sttp/client3/armeria/zio/ArmeriaZioStreamingTest.scala # armeria-backend/zio1/src/test/scala/sttp/client3/armeria/zio/ArmeriaZioStreamingTest.scala # core/src/main/scala/sttp/client3/SttpClientException.scala # core/src/main/scala/sttp/client3/testing/SttpBackendStub.scala # core/src/main/scalajs/sttp/client3/AbstractFetchBackend.scala # core/src/main/scalajvm/sttp/client3/HttpClientAsyncBackend.scala # core/src/main/scalajvm/sttp/client3/HttpClientFutureBackend.scala # core/src/main/scalanative/sttp/client3/AbstractCurlBackend.scala # core/src/test/scala/sttp/client3/testing/BackendStubTests.scala # effects/cats/src/main/scalajvm/sttp/client3/httpclient/cats/HttpClientCatsBackend.scala # effects/fs2-ce2/src/main/scalajvm/sttp/client3/httpclient/fs2/HttpClientFs2Backend.scala # effects/fs2/src/main/scalajvm/sttp/client3/httpclient/fs2/HttpClientFs2Backend.scala # effects/monix/src/main/scalajvm/sttp/client3/httpclient/monix/HttpClientMonixBackend.scala # effects/zio/src/main/scalajvm/sttp/client3/httpclient/zio/HttpClientZioBackend.scala # effects/zio1/src/main/scalajvm/sttp/client3/httpclient/zio/HttpClientZioBackend.scala # effects/zio1/src/test/scala/sttp/client3/impl/zio/ZioTestBase.scala # finagle-backend/src/main/scala/sttp/client3/finagle/FinagleBackend.scala # observability/opentelemetry-metrics-backend/src/test/scala/sttp/client3/opentelemetry/OpenTelemetryMetricsBackendTest.scala # observability/prometheus-backend/src/test/scala/sttp/client3/prometheus/PrometheusBackendTest.scala # okhttp-backend/src/main/scala/sttp/client3/okhttp/OkHttpSyncBackend.scala
I've started working on a PR changing a bit how |
Progress report:
|
More updates:
|
…tion; rename to GenericResponseAs
Another round: time for I made an attempt at renaming this so it would still make sense, and I ended up with:
This works, but reading the code I'm getting lost myself (which response-as variant we are currently working with - the wrapped or generic). Moreover, both So I'm looking at some way to improve this. One solution would be to simply keep the old I think that's a reasonable tradeoff, as far as I'm aware the response-as descriptions are rarely captured as top-level values (instead being used directly in request descriptions), so the type would be barely visible. A downside is that the type signatures would be a bit more complicated, as we would need to require implicit evidence when setting the |
I agree this is over-complicated and confusing.
That's right, I don't think we often capture them as top-level values. But the type is still visible in the signatures of the The problem I had with the original My best proposal would be to put the raw responses ( |
Hmm I guess this might work. Let's see in code :) As for separation of concerns, that's more or less how the code works today, see |
@adpi2 can you maybe share what was the rationale behind the changes to the |
@adpi2 will you be attempting to implement this, or should I take a shot? |
First I think we can rename The rationale behind the changes are:
|
I am starting now and will continue tomorrow. |
All clear, thanks! I'll do the renaming (later, not to collide with your work) & maybe add the above points to an ADR so that it gets persisted for future generations :)
Great :)
Request & response bodies are independent, so while this is highly unlikely that you'd stream a request body to get back a websocket, there might be use-cases for it out there ;) So well spotted, 👍 for fixing this. |
# Conflicts: # armeria-backend/zio/src/main/scala/sttp/client3/armeria/zio/ArmeriaZioBackend.scala # async-http-client-backend/zio/src/main/scala/sttp/client3/asynchttpclient/zio/AsyncHttpClientZioBackend.scala # async-http-client-backend/zio/src/test/scala/sttp/client3/asynchttpclient/zio/BackendStubZioTests.scala # core/src/test/scala/sttp/client3/testing/BackendStubTests.scala # effects/zio/src/main/scalajvm/sttp/client3/httpclient/zio/HttpClientZioBackend.scala # effects/zio/src/test/scalajvm/sttp/client3/httpclient/zio/BackendStubZioTests.scala # examples/src/main/scala/sttp/client3/examples/GetAndParseJsonZioCirce.scala # examples/src/main/scala/sttp/client3/examples/StreamZio.scala # observability/opentelemetry-metrics-backend/src/test/scala/sttp/client3/opentelemetry/OpenTelemetryMetricsBackendTest.scala
So I tried to define a new type hierarchy of It works well in the sense that it is now possible to pattern match exhaustively on ra match {
case _: ResponseAs[_] =>
case _: WebSocketResponseAs[_, _] =>
case _: StreamResponseAs[_, _] =>
case _: WebSocketStreamResponseAs[_, _] =>
} ra match {
case InoreResponse =>
case ResponseAsByteArray =>
case ResponseAsFile(file) =>
case ResponseAsWebSocket(f) =>
case ResponseAsWebSocketUnsafe =>
case ResponseAsStream(s, f) =>
case ResponseAsStreamUnsafe(s) =>
case ResponseAsWebSocketStream(s, p) =>
case MappedResponseAs(raw, f, showAs) =>
case ResponseAsFromMetadata(conditions, default) =>
case ResponseAsBoth(l, r) =>
} For the record, this is how I defined the
My conclusion is that the current implementation with the I am going to try a few more things and let you know when I am done. |
@adpi2 thanks for the ongoing investigation! :) |
# Conflicts: # docs/backends/http4s.md # http4s-backend/src/main/scala/sttp/client3/http4s/Http4sBackend.scala # http4s-backend/src/test/scala/sttp/client3/http4s/Http4sHttpStreamingTest.scala # http4s-backend/src/test/scala/sttp/client3/http4s/Http4sHttpTest.scala # http4s-ce2-backend/src/test/scala/sttp/client3/http4s/Http4sHttpStreamingTest.scala # http4s-ce2-backend/src/test/scala/sttp/client3/http4s/Http4sHttpTest.scala
I'll merge this as-is, and we can iterate on further design improvements in subsequent PRs. Thanks @adpi2 for your work! :) |
Amazing! Thanks @adamw for supporting this initiative and reviewing it thoroughly. |
Would it be possible to have a SNAPSHOT, nightly or milestone version released any time soon? |
@adpi2 yes, next week :) |
Following the discussion in #1607, I started experimenting with sttp, to see if it was possible to get a more user-friendly API while preserving the full power and flexibility. I did not try to maintain the binary-compatibility.
I pushed the experiment into a fully working implementation of sttp, which is, in my opinion, a decent proposal of its next
client4
API. I submit it to you to get your feedback and to decide if we should continue working in this direction.As you may know, we are currently building the first version of the Scala Toolkit (as announced by @odersky in the Simply Scala talk and by @romanowski in the Scala with Batteries-included talk). I think it would make sense to include this new API of sttp in the Toolkit. It could be a milestone version, if not a stable one, but we first need to decide if we want to go forward with this proposal.
Description
Requests
The single type
RequestT[U[_], T, -R]
is replaced by a hierarchy of classes:To avoid duplication of the common builder methods, I also introduced the
PartialRequestBuilder
andRequestBuilder
traits. They should only be used internally and never appear in user code.This hierarchy of types is defined in core in this commit and applied downstream in this commit. It adds a total of 356 lines of code, mostly scaladoc comments, to document the different types of requests.
Backends
Similarly, the single type
SttpBackend[F[_], +R]
is replaced by a hierarchy of traits:Each trait defines a concrete
send
method, implemented by calling the sameinternalSend
method which is defined inAbstractBackend
:To implement a concrete backend one must implement the
internalSend
method.Example:
This new hierarchy of backends is defined in core in this commit and applied downstream in this commit. It adds a total of 639 lines of code, mostly made of overload constructors of delegate backends (see section in
Drawbacks and limitations
below about delegate backends).Motivation
Less abstraction in inferred types
The level of abstraction of the inferred types is closer to the expectation of the user. There is no
RequestT[Identity, T, Any]
orSttpBackend[Identity, Any]
exposed to the user anymore. It leads to less confusion for beginners, but also more conciseness and better readability for all users.Some examples on the types of requests as inferred by the compiler (the comments contain the former inferred types):
Another example, on the response of a synchronous backend:
Discoverability
One can use autocompletion, to discover the capababilities of a backend or a request.
Using the proposed API
Using the former API:
Error Messages
We generally get better error messages:
Formerly it was:
Specialized Documentation
On each types of requests or backends we can specialize the scaladoc documentation to clearly state which concrete backend can send which request.
For instance on the scaladoc of
WebSocketRequest
, we can list all the backends that support websockets:No separate synchronous-only API
The proposed API contains a
SyncBackend
that can send any request of typeRequest[T]
.We don't need a separate synchronous-only API and the current
SimpleHttpClient
may not be useful anymore.Drawback and limitations
Overload constructors of delegate backend
Since we now have 5 types of backends, we now need 5 overloads of each delegate backend. (A delegate backend is invariant on the type of backend that it encapsulates)
This is an example of how I implemented it:
I tried to reduce the amount of added code as much as possible, but this is still kind of tedious work. It becomes even more cumbersome when we have to deal with default arguments in the constructor of a delegate backend, because overloaded methods cannot have any default argument. To solve this problem I added a few
Config
case class: theFollowRedirectsConfig
, theLogConfig
, thePrometheusConfig
...This is how it is done for
FollowRedirectsBackend
:Some users will probably need to use the same technique to define their own delegate backends.
Hopefully, a lot of users don't have to define any overloaded constructor, since they use a single type of concrete backend.
Less flexibility in PartialRequest
I assumed that there is no need to introduce
PartialWebSocketRequest
,PartialStreamRequest
andPartialWebSocketStreamRequest
types. But that means the user must specify the method and uri before they can specify the websocket response-as, or the stream response-as or the stream body.For instance, this does not compile anymore:
One must write this instead:
Should we add the
PartialWebSocketRequest
,PartialStreamRequest
andPartialWebSocketStreamRequest
types?Adding more capabilities
If, in the future, we need to add more capabilities, it will be a lot more tedious. We will need to define many new types of requests and backends, for each meaningful combination of capababilities.
As a solution of this problem, we can use a more flexible type hierarchy, made of two requests, and three backends:
Request[+T]
, theSyncBackend
and theBackend[F[_]]
for simple requests and backends with no capabilities.SttpRequest[+T, -R]
and theSttpBackend[F[_], +R]
for any request and backend that needs one or more capabilities.This would be still better for simple use cases, such as scripting, and super flexible for the more advanced use cases: websocket app or streaming.
If we plan on more capabilities we should use the more flexible type hierarchy, otherwise I think the proposed type hierarchy is better.
StreamRequest
The proposed stream request is defined as
StreamRequest[T, -R]
where R is the capability.R
must contain a stream of typeStreams
, but it can also contain anEffect[F]
. I could make this more explicit by defining two types of stream request, theStreamRequest[T, S <: Streams]
, and theStreamRequestF[F[_], T, S <: Streams]
.Correspondingly the stream backend would define two
send
methods:I decided not to, but I am still not 100% convinced.
Going further
To go further on this new API, I wonder if it would be possible to split the
SttpApi
in parts so that we can decide to import a subset of methods: the simple response-as, the websocket response-as, the streaming response-as and bodies, the streaming and websocket response-as.Next Steps
client4
and claim group-idcom.softwaremill.sttp.client4