Contents

Sttp client 4: the Scala HTTP client you always wanted, released!

Sttp client 4: the Scala HTTP client you always wanted, released! webp image

The sttp client is an open-source HTTP client for Scala, integrating with every Scala stack, be it synchronous, a.k.a. direct-style, Future-based, or using functional effect systems such as cats-effect, ZIO, Monix, Kyo. The library is available for Scala 2.12, 2.13, and 3 on the JVM, Scala.JS, and Scala Native.

Since starting the project in 2017, the focus of sttp client is on developer experience and productivity: using the same API for describing and sending HTTP requests, regardless of your approach to writing Scala code. We're staying true to this goal, and it's my pleasure to announce that a stable release of sttp client, version 4, is now available. This major release is the product of joint work by SoftwareMill and VirtusLab teams, after a long cycle of milestones and release candidates. Thank you for testing these early versions!

//> using dep com.softwaremill.sttp.client4::core:4.0.0

import sttp.client4.quick.*

@main def run(): Unit =
  println(quickRequest.get(uri"http://httpbin.org/ip").send())

All of the examples in this blog can be copy-pasted and run using scala-cli!

The main motivation for a new sttp client major release (version 4) is simplifying the code for the "common case": shorter names, simpler types, fewer type parameters, better IDE completions, and improved error reporting:

//> using dep com.softwaremill.sttp.client4::core:4.0.0

import sttp.client4.*
import sttp.model.HeaderNames

@main def run(): Unit =
  val password = "1234"

  val backend: SyncBackend = DefaultSyncBackend()

  val request: Request[String] = basicRequest
    .get(uri"http://httpbin.org/basic-auth/admin/$password")
    .auth
    .basic("admin", password)
    .header(HeaderNames.XForwardedFor, "http://example.com")
    .response(asStringOrFail)

  val response: Response[String] = request.send(backend)

  println(response.show())

This simplification is most visible for "ordinary" HTTP requests; as before, these are still defined using the same flexible, type-safe, immutable request builders.

Sttp client v4 offers a developer, IDE, and AI-friendly API. For example, for interacting with JSON APIs:

//> using dep com.softwaremill.sttp.client4::jsoniter:4.0.0
//> using dep com.github.plokhotnyuk.jsoniter-scala::jsoniter-scala-macros:2.33.3

import sttp.client4.*
import sttp.client4.jsoniter.*
import com.github.plokhotnyuk.jsoniter_scala.macros.ConfiguredJsonValueCodec

case class Address(street: String, city: String) 
  derives ConfiguredJsonValueCodec
case class PersonalData(name: String, age: Int, address: Address) 
  derives ConfiguredJsonValueCodec
case class HttpBinResponse(origin: String, headers: Map[String, String], 
  data: String) derives ConfiguredJsonValueCodec

@main def run(): Unit =
  val request = basicRequest
    .post(uri"https://httpbin.org/post")
    .body(asJson(PersonalData("Alice", 25, 
        Address("Marszałkowska", "Warsaw"))))
    .response(asJsonOrFail[HttpBinResponse])

  val backend: SyncBackend = DefaultSyncBackend()
  val response: Response[HttpBinResponse] = request.send(backend)

  println(s"Got response code: ${response.code}")
  println(response.body)

WebSockets & streaming

More complex requests are still possible, of course! If you've been using earlier sttp client versions, you might have noticed that the Request type above is missing the type parameters specifying the capabilities (such as streaming or web sockets) the request needs. Instead, these are encoded as separate types: StreamRequest, WebSocketRequest, and WebSocketStreamRequest.

Similarly, the Backend type does not have the type parameter specifying the capabilities it supports. Instead, there's now a type hierarchy: SyncBackend, StreamBackend etc. This means that writing a request-generic backend wrapper (which implements a cross-cutting concern such as logging or tracing) now needs more work and more code. But that's on the library side; on the user side, you get simpler types, which produce better error messages in case something goes wrong.

Here's a synchronous web socket request:

//> using dep com.softwaremill.sttp.client4::core:4.0.0

import sttp.client4.*
import sttp.client4.ws.SyncWebSocket
import sttp.client4.ws.sync.*

@main def wsOxExample =
  def useWebSocket(ws: SyncWebSocket): Unit =
    ws.sendText("Hello,")
    ws.sendText("world!")

    println(ws.receiveText())
    println(ws.receiveText())

  val backend = DefaultSyncBackend()
  try
    basicRequest
      .get(uri"wss://ws.postman-echo.com/raw")
      .response(asWebSocket(useWebSocket))
      .send(backend)
  finally
    backend.close()

And a request where the request body is passed as a (functional, lazily-evaluated) FS2 stream, with the response body being read the same way. Note that at no point the entire request or response body is held in memory:

​​//> using dep com.softwaremill.sttp.client4::fs2:4.0.0

package sttp.client4.examples.streaming

import cats.effect.{ExitCode, IO, IOApp}
import fs2.Stream
import sttp.capabilities.fs2.Fs2Streams
import sttp.client4.*
import sttp.client4.httpclient.fs2.HttpClientFs2Backend

object StreamFs2 extends IOApp:
  override def run(args: List[String]): IO[ExitCode] =
    HttpClientFs2Backend
      .resource[IO]()
      .use: backend =>
        val stream: Stream[IO, Byte] = 
          Stream.emits("Hello, world".getBytes).repeatN(1000)

        basicRequest
          .post(uri"https://httpbin.org/post")
          .streamBody(Fs2Streams[IO])(stream)
          .response(asStreamAlways(Fs2Streams[IO])(
              _.chunks.map(_.size).compile.foldMonoid))
          .send(backend)
          .map(response => println(s"Bytes count:\n${response.body}"))
      .map(_ => ExitCode.Success)

Example library

A snippet of code is worth a thousand words, as the saying goes; that's why we've introduced an ever-expanding library of examples. Each example is self-contained and scala-cli runnable, demonstrating a particular use case, ranging from basic requests through error handling and observability to testing. And if an example is missing, just create an issue; we'll try to fill the void!

sttp%20examples

Compression

We took the opportunity to fix several issues, some of which required breaking binary compatibility, either to improve ergonomics or simply remove deprecated APIs. For example, we've streamlined the API for dealing with compression: be it request body compression or response body decompression (which happens automatically):

//> using dep com.softwaremill.sttp.client4::core:4.0.0

import sttp.client4.quick.*
import sttp.model.Encodings

@main def run(): Unit =
  val response = quickRequest
    .post(uri"http://httpbin.org/post")
    .body("Hello, sttp v4!")
    .compressBody(Encodings.Gzip)
    .response(asStringOrFail)
    .send()

  println(response.show())

Observability

We've improved the observability backends so that they properly report the time it takes to process the request, excluding any client-side handling, such as parsing the response body as JSON. Whatever your response description—whether you are throwing exceptions when parsing fails, reading the response as a byte array, or doing costly post-processing—the metrics should now be accurate.

Speaking of observability, sttp client v4 ships with backends providing OpenTelemetry metrics & distributed tracing integration. There's a set of "sane defaults", so using them is a matter of wrapping your backend instance, but you can freely adjust the default span configuration, or customize the attributes, naming, etc.:

//> using dep com.softwaremill.sttp.client4::opentelemetry-backend:4.0.0-RC3
//> using dep io.opentelemetry:opentelemetry-exporter-otlp:1.49.0
//> using dep io.opentelemetry:opentelemetry-sdk-extension-autoconfigure:1.49.0

import io.opentelemetry.api.OpenTelemetry
import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk

import sttp.client4.*
import sttp.client4.opentelemetry.*
import sttp.client4.httpclient.HttpClientSyncBackend

@main def run(): Unit =
  val otel: OpenTelemetry = AutoConfiguredOpenTelemetrySdk
      .initialize().getOpenTelemetrySdk()

  val baseBackend: SyncBackend = HttpClientSyncBackend()
  val backend = OpenTelemetryTracingBackend(
      OpenTelemetryMetricsBackend(baseBackend, otel), otel)

  val response = basicRequest
    .post(uri"http://httpbin.org/post")
    .body("Hello, sttp v4!")
    .send(backend)

  println(response.show())

  Thread.sleep(5000) // wait for the metrics to be sent

sttp%20tracing

Testing

As in previous versions, sttp client v4 ships with a stub backend implementation, which is indispensable during testing. If your business logic depends on a Backend instance, and you don't want to make any network calls when running a test (you usually don't), all you need to do is pass in a fake backend. Such a fake backend can be pre-configured to respond with test data to requests that it expects.

This stubbing utility has been improved over earlier versions, making it explicit whether the response bodies should be adjusted (e.g., parsed from JSON) or if the values given are already high-level representations (exact). Just as with the other changes, this makes your code easier to read, understand, and follow:

//> using dep com.softwaremill.sttp.client4::core:4.0.0
//> using dep com.softwaremill.sttp.client4::jsoniter:4.0.0
//> using dep com.github.plokhotnyuk.jsoniter-scala::jsoniter-scala-macros:2.33.3

import sttp.client4.*
import sttp.client4.testing.*
import sttp.client4.jsoniter.*
import com.github.plokhotnyuk.jsoniter_scala.macros.ConfiguredJsonValueCodec

case class Data(v1: Int, v2: Boolean) derives ConfiguredJsonValueCodec

@main def testJsonEndpoint(): Unit =
  val backend = SyncBackendStub
    .whenRequestMatches(_.uri.path.contains("data1"))
    .thenRespondAdjust("""{"v1": 1, "v2": true}""")
    .whenRequestMatches(_.uri.path.contains("data2"))
    .thenRespondAdjust("""{"v1": 40, "v2": false}""")

  // Right(Data(1,true))
  println(
    basicRequest
      .response(asJson[Data])
      .get(uri"http://example.org/data1")
      .send(backend)
      .body
  )

  // Right(Data(40,false))
  println(
    basicRequest
      .response(asJson[Data])
      .get(uri"http://example.org/data2")
      .send(backend)
      .body
  )

Summary

Sttp client v4 brings us one step closer to implementing our motto, "the Scala HTTP client you always wanted." With simpler types, better IDE integration, and user-friendly error messages, it gives you the flexibility needed when communicating with HTTP services on the web.

Sttp client v4 is also integrated with other sttp projects:

  • Tapir, for rapidly developing self-documenting APIs
  • sttp-openai, the Scala client for OpenAI & OpenAI-compatible models

We've prepared a migration guide that provides a full summary of the changes; and that's only a fraction of sttp's documentation. If you have any questions, we'll be happy to help you out on our community forums.

Describing HTTP requests, sending them, and handling the response should be a boring, straightforward, and ultimately uninteresting task. In that spirit, sttp client tries to get out of your way so that instead of fighting with infrastructural libraries, you can focus on the business logic as a programmer. Version 4 brings us one step closer to that ideal, non-intrusive HTTP client!

Blog Comments powered by Disqus.