Skip to Content
Course content

135: Practice Exercise: Building a Type-Safe HTTP Client Wrapper

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Why should I bother wrapping a client instead of just calling the library directly?

I've seen this a lot in production codebases: sttp or zio-http calls scattered across every single service class. It seems fine at first, but the moment you need to add a global correlation ID to your headers or change how you handle 401 Unauthorized errors, you're stuck doing a find-and-replace across fifty files. It's a nightmare.

By building a type-safe wrapper, you create a "boundary." The rest of your app doesn't need to know that you're using a specific library or how the JSON is being parsed. You're basically saying, "I don't care how the bytes move over the wire; I just want a User object or a clear explanation of why I didn't get one."

How do I make the response types generic so it works for any endpoint?

The trick here is combining generics with a decoder. You don't want a getUser method and a getPosts method that do the exact same thing. Instead, you want a single get method that takes a type parameter T.

Here is how I usually structure this. I'll assume we're using a JSON library like Circe for the decoding part:

case class ApiClient(baseUrl: String, httpClient: HttpClient) {
  def get[T](path: String)(implicit decoder: Decoder[T]): Either[ApiError, T] = {
    val response = httpClient.get(s"$baseUrl$path")
    
    response.body match {
      case Some(json) => 
        decoder.decode(json).fold(
          err => Left(ApiError.DecodeError(err.getMessage)),
          data => Right(data)
        )
      case None => Left(ApiError.EmptyResponse)
    }
  }
}

Notice the (implicit decoder: Decoder[T]). This is the secret sauce. It tells Scala, "I can fetch this data for you, provided you can show me how to turn the resulting JSON into the type T you're asking for."

How should I handle errors so I don't just crash on a 404 or bad JSON?

Please, for the love of your on-call rotation, don't use throw or Try for API errors. HTTP failures are "expected" failures—they aren't exceptional crashes; they're just part of the business logic. I always use a sealed trait to represent the specific ways an API call can fail.

It looks something like this:

sealed trait ApiError
case class NetworkError(message: String) extends ApiError
case class DecodeError(message: String) extends ApiError
case class ServerError(code: Int, body: String) extends ApiError
case object EmptyResponse extends ApiError

By returning Either[ApiError, T], you force the developer using your client to actually think about the failure cases. They can't just ignore the possibility of a 404; the compiler won't let them get to the T without handling the ApiError first. It makes your entire system significantly more resilient.




📋 Practical Task

Exercise: Implement a Type-Safe GitHub User Client

Your task is to build a wrapper for the GitHub API that fetches user profile information. You need to ensure the client is type-safe and handles errors gracefully.

  • The Data Model: Create a case class GitHubUser with fields for login (String), id (Long), and public_repos (Int).
  • The Error Logic: Implement a sealed trait GitHubError that distinguishes between a UserNotFound (404), a RateLimitExceeded (403), and a generic UnexpectedError.
  • The Wrapper: Write a class GitHubClient with a method fetchUser(username: String): Either[GitHubError, GitHubUser].
  • The Requirement: The method must use a mock HTTP response (you can simulate this with a Map or a simple if/else block) to return a GitHubUser if the username is "scala_dev", a UserNotFound error if the username is "unknown", and a RateLimitExceeded error for any other input.

Focus on the type signatures and the way you map the HTTP status codes to your GitHubError types.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.