Taming Telescoping Overloads: A Fluent Request Builder Pattern for Rest Assured
My API test client needed a new overload for almost every kind of request. I replaced them all with one small fluent builder. Here’s how I did it, and why I didn’t just call Rest Assured directly.
If you’ve ever looked at a line like send(method, path, null, null, pathParams, false) and tried to guess which null is which, you know the feeling.
That was my API client layer for sometime. I maintain a Cucumber BDD framework that tests the Conduit (RealWorld) app at three levels: the REST API with Rest Assured, the web UI with Selenium, and the Postgres database underneath.
Every API call in the framework goes through one class, HttpRestClient. That’s on purpose. It’s where auth gets attached, where passwords are kept out of the logs, and where each request and response is saved, so a failed scenario can attach the full HTTP exchange to the report.
It worked fine. Until the class had to do one more thing.
The problem: an overload for every combination
Here’s what the client’s public API looked like before the refactor:
public Response sendHttpRequest(Method method, String path) {
return send(method, path, null, null, null, false);
}
public Response sendHttpRequest(Method method, String path, Object body) {
return send(method, path, body, null, null, false);
}
public Response sendHttpRequestWithQueryParams(Method method, String path, Map<String, ?> queryParams) {
return send(method, path, null, queryParams, null, false);
}
public Response sendHttpRequestWithPathParams(Method method, String path, Map<String, ?> pathParams) {
return send(method, path, null, null, pathParams, false);
}
/** Same as sendHttpRequest but bodies (e.g. credentials, tokens) are not logged. */
public Response sendSensitiveHttpRequest(Method method, String path, Object body) {
return send(method, path, body, null, null, true);
}Five public methods, all calling one private send with six parameters in a row. The call sites weren’t much better:
httpRestClient.sendHttpRequestWithPathParams(Method.DELETE, ARTICLES_SLUG, Map.of("slug", slug));
httpRestClient.sendSensitiveHttpRequest(Method.POST, LOGIN, Map.of("user", user));Then the questions started. What about a request that needs a path parameter and a query parameter? A custom header? A sensitive call with query parameters? Multipart uploads?
Each answer meant another overload. And the combinations grow much faster than the features do.
This has a name: the telescoping method anti-pattern. The usual way out is a builder.
The obvious question: why not call Rest Assured directly?
It’s the first thing any reviewer asks, and it’s a fair one. Rest Assured already has a fluent API: given().queryParam(…).body(…).when().post(…). Why wrap it in another one?
Because every request in this framework has to do four things, every single time:
1. Start from a fresh request on an immutable base spec. An earlier version merged specs between calls, and the merged spec quietly reset the base URI and body. Building each request from a fresh given().spec(baseSpec) closes that leak.
2. Attach logging filters that respect sensitive data. Login and registration bodies carry passwords, so those calls log only the URI and status, never the body.
3. Add the right Authorization header. Conduit uses Token <jwt>, not Bearer. And some negative tests need to send a deliberately broken header instead.
4. Save the response and the exchange log in the scenario context, so assertion steps can read them and failure hooks can attach them to the report.
If step definitions called given() themselves, each of these rules would become a habit. And sooner or later, someone forgets a habit.
So the goal was simple: keep the one entry point, lose the overloads.
The fix: the builder collects, the client sends
The main idea is a strict split of jobs.
A new class, ApiRequest, only collects data: method, path, body, query params, path params, extra headers and a sensitive flag. It never touches Rest Assured. When you call send(), it hands itself back to the client, and the client builds the real request the same way it always did.
/**
* One API call, described fluently. Created only by HttpRestClient.request(...);
* nothing is sent until send(). Single-use: build it, send it, don't store it.
*/
@Getter(AccessLevel.PACKAGE)
public class ApiRequest {
private final HttpRestClient httpRestClient;
private final Method method;
private final String path;
private final Map<String, Object> queryParams = new LinkedHashMap<>();
private final Map<String, Object> pathParams = new LinkedHashMap<>();
private final Map<String, String> extraHeaders = new LinkedHashMap<>();
private Object body;
private boolean sensitive;
ApiRequest(HttpRestClient httpRestClient, Method method, String path) {
this.httpRestClient = httpRestClient;
this.method = method;
this.path = path;
}
public ApiRequest body(Object body) {
this.body = body;
return this;
}
public ApiRequest queryParam(String name, Object value) {
queryParams.put(name, requireValue(name, value));
return this;
}
public ApiRequest pathParam(String name, Object value) {
pathParams.put(name, requireValue(name, value));
return this;
}
public ApiRequest header(String name, String value) {
extraHeaders.put(name, value);
return this;
}
/** Request and response bodies are kept out of logs and reports. */
public ApiRequest sensitive() {
sensitive = true;
return this;
}
public Response send() {
return httpRestClient.send(this);
}
private static Object requireValue(String name, Object value) {
if (value == null) {
throw new FrameworkException("Request parameter '" + name + "' is null");
}
return value;
}
}A few small details matter more than they look:
Get Ankita Bhattacharjee’s stories in your inbox
Join Medium for free to get updates from this writer.
• The constructor is package-private. The only way to get an ApiRequest is httpRestClient.request(…), so a request can’t exist without the client that owns auth and logging. That makes request() a small factory method.
• send(ApiRequest) on the client is package-private too. Step definitions and API classes can’t skip the builder.
• The maps are LinkedHashMaps. Parameters show up in the logs in the order you added them, which makes two runs easier to compare.
• Null values fail fast. Otherwise queryParam(“author”, null) would make Rest Assured send a parameter with no value. That’s a silent bug that shows up as a confusing 4xx a few steps later.
On the client side, the public API shrinks to one method. The old send keeps its body but takes the request object instead of six parameters:
public ApiRequest request(Method method, String path) {
return new ApiRequest(this, method, path);
}
Response send(ApiRequest apiRequest) {
LogHelper.logSendHttpRequest(apiRequest.getMethod(), getConfigurations().getApiBaseUri() + apiRequest.getPath());
ByteArrayOutputStream exchangeLog = new ByteArrayOutputStream();
PrintStream logStream = new PrintStream(exchangeLog, true);
boolean sensitive = apiRequest.isSensitive();
RequestSpecification request = RestAssured.given()
.spec(baseSpec)
.filter(new RequestLoggingFilter(sensitive ? LogDetail.URI : LogDetail.ALL, true, logStream, true))
.filter(new ResponseLoggingFilter(sensitive ? LogDetail.STATUS : LogDetail.ALL, true, logStream));
if (apiRequest.getBody() != null) {
request.body(apiRequest.getBody());
}
if (!apiRequest.getQueryParams().isEmpty()) {
request.queryParams(apiRequest.getQueryParams());
}
if (!apiRequest.getPathParams().isEmpty()) {
request.pathParams(apiRequest.getPathParams());
}
if (!apiRequest.getExtraHeaders().isEmpty()) {
request.headers(apiRequest.getExtraHeaders());
}
if (!setsOwnAuthorization(apiRequest)) {
applyClientAuth(request);
}
Response response = request.request(apiRequest.getMethod(), apiRequest.getPath());
commonContext.storeResponse(response);
commonContext.recordExchange(exchangeLog.toString());
return response;
}The result: calls that read like sentences
Each call now says only what it needs:
httpRestClient.request(Method.DELETE, ARTICLES_SLUG).pathParam("slug", slug).send();
httpRestClient.request(Method.GET, ARTICLES).queryParam("author", user.getUsername()).send();
httpRestClient.request(Method.POST, LOGIN).sensitive().body(Map.of("user", user)).send();Compare that last line with the old sendSensitiveHttpRequest(Method.POST, LOGIN, Map.of(“user”, user)). Before, “sensitive” was baked into a method name. Now it’s just one more thing you can add to any request.
Not quite a textbook builder
If you’ve read about the builder pattern before, you might notice this one ends a little differently.
A classic builder ends in build(), which returns a finished, usually immutable object. Mine ends in send(), which runs the request straight away.
That makes it a builder with a terminal operation. It’s the same style as Rest Assured’s own given()…when().get(), or Java Streams’ .filter().map().collect(). You describe the work step by step, and the last call actually does it.
The trade-offs
No pattern is free. Here’s what this one costs:
• A forgotten send() after request(…) compiles and does nothing. Worse, the next assertion step reads the previous response from the scenario context and fails with a confusing message. Error Prone’s @CheckReturnValue can catch it. Otherwise, it’s down to code review.
• The builder is mutable and single-use. That’s fine when you build and send a request in one line, but don’t store ApiRequest objects in fields or contexts.
• It’s one more layer. New people on the team have to learn request(…).send() on top of Rest Assured. In return, the four rules above are enforced instead of remembered.
What it bought me
The real test of a design like this is the next feature.
Before, adding multipart upload would have meant new overloads for every combination it could show up in. Now it’s one field and one method on ApiRequest, plus one if in send(). Nothing else changes, and no existing call site gets touched.
What I’d tell anyone trying this
• Keep one choke point. Rules that live in one class get enforced. Rules that live in people’s heads get forgotten.
• Let the builder collect and the client execute. Keeping Rest Assured out of the builder means auth, logging and the base spec stay in one place.
• Fail fast on bad input. A null parameter that blows up straight away is much easier to debug than a confusing 4xx a few steps later.
Next, I want to make authentication explicit, instead of the client quietly storing a token as a side effect of logging in. That one deserves its own article.
The full framework is on GitHub: [https://github.com/bbankita/demo-automation-framework]







