Skip to Content
Course content

138: Practice Exercise: Building a Type-Safe Builder for API Requests

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

We've talked about function literals with receivers, but the real magic happens when you chain them together to create a Domain Specific Language (DSL). I've always found that the best way to internalize this is by building something that actually replaces a clunky pattern. Today, we're going to build a type-safe builder for API requests. Instead of a constructor with ten nullable arguments or a chain of .setX().setY() calls, we want something that looks like a configuration block.

Defining the Request Blueprint

First, we need the actual data classes that will hold our request information. I'll keep this simple: a URL, a method, some headers, and an optional body. Notice that these are immutable data classes; the builder's job is to handle the mutation during the construction phase and then hand us back a read-only object.

data class HttpRequest(
    val url: String,
    val method: String,
    val headers: Map<String, String>,
    val body: String?
)

Structuring the Builder Logic

Now for the builder. I'll create a class that collects these values. To make it a DSL, I need a function that takes a lambda with the builder as the receiver (HttpRequestBuilder.() -> Unit). This allows the caller to use this implicitly inside the block.

class HttpRequestBuilder {
    var url: String = ""
    var method: String = "GET"
    private val headers = mutableMapOf<String, String>()
    var body: String? = null

    fun header(name: String, value: String) {
        headers[name] = value
    }

    fun build(): HttpRequest = HttpRequest(url, method, headers.toMap(), body)
}

fun httpRequest(block: HttpRequestBuilder.() -> Unit): HttpRequest {
    return HttpRequestBuilder().apply(block).build()
}

The "Scope Leak" Mistake

I want to make the headers section even cleaner. Instead of calling header() repeatedly, I'll create a nested builder block specifically for headers. I'll add a headers { ... } function to the main builder.

class HeaderBuilder {
    fun add(name: String, value: String) {
        // I'll just implement this as a placeholder for now
    }
}

// Inside HttpRequestBuilder...
fun headers(block: HeaderBuilder.() -> Unit) {
    HeaderBuilder().apply(block)
}

Here is where I usually make a mistake. If I run this, I'll realize that inside the headers { ... } block, I can still call url = "..." or method = "...". Why? Because HeaderBuilder is nested inside HttpRequestBuilder's context. This "scope leak" makes the DSL confusing and error-prone. You could accidentally change the URL while you're supposed to be defining headers.

Locking Down the Scope with DslMarker

To fix this, Kotlin provides @DslMarker. This tells the compiler: "Once you are inside a receiver of this marker, you cannot access receivers of the same marker from an outer scope." I'll define a custom annotation for our API DSL.

@DslMarker
annotation class ApiDsl

@ApiDsl
class HttpRequestBuilder {
    var url: String = ""
    var method: String = "GET"
    private val headers = mutableMapOf<String, String>()
    var body: String? = null

    fun header(name: String, value: String) {
        headers[name] = value
    }

    fun headers(block: HeaderBuilder.() -> Unit) {
        HeaderBuilder(this).apply(block)
    }

    fun build(): HttpRequest = HttpRequest(url, method, headers.toMap(), body)
}

@ApiDsl
class HeaderBuilder(private val parent: HttpRequestBuilder) {
    fun add(name: String, value: String) {
        parent.header(name, value)
    }
}

Now, if you try to call url = "..." inside the headers { } block, the compiler will throw an error. It forces you to stay within the HeaderBuilder context, which is exactly what we want for a clean, type-safe API.

Putting it all together

With the marker in place, the final usage looks incredibly declarative. It reads almost like a JSON object, but with the full power of Kotlin's type checking.

val request = httpRequest {
    url = "https://api.example.com/v1/users"
    method = "POST"
    body = "{ \"name\": \"Kotlin Developer\" }"
    
    headers {
        add("Content-Type", "application/json")
        add("Authorization", "Bearer token123")
        // url = "hack" // This would now cause a compiler error!
    }
}



📋 Practical Task

Exercise: Implementing a Query Parameter Builder for API Requests

Your task is to extend the HttpRequest DSL to support query parameters. Currently, the URL is just a string, but in a real API, we often need to build complex query strings (e.g., ?sort=desc&limit=10).

Requirements:

  • Modify the HttpRequest data class to include a val queryParams: Map<String, String>.
  • Create a QueryBuilder class marked with @ApiDsl.
  • Add a queryParams { ... } function to HttpRequestBuilder that utilizes the QueryBuilder.
  • Ensure that QueryBuilder has an add(key: String, value: String) method that updates the parent builder.
  • Verify that you cannot access HttpRequestBuilder properties (like method) from inside the queryParams block.

Goal: You should be able to write a request like this:

val request = httpRequest {
    url = "https://api.example.com/search"
    queryParams {
        add("q", "kotlin dsl")
        add("page", "1")
    }
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.