Skip to content

Commit 40ef8ff

Browse files
committed
feat: Add per-request after-hook queue to Request
1 parent 709fde9 commit 40ef8ff

2 files changed

Lines changed: 97 additions & 1 deletion

File tree

src/main/java/com/retailsvc/http/Request.java

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import com.retailsvc.http.spec.HttpMethod;
44
import java.net.URLDecoder;
55
import java.nio.charset.StandardCharsets;
6+
import java.util.ArrayList;
67
import java.util.LinkedHashMap;
8+
import java.util.List;
79
import java.util.Map;
810
import java.util.Objects;
911
import java.util.Optional;
@@ -33,6 +35,7 @@ public final class Request {
3335
private final UnaryOperator<String> headerLookup;
3436
private final Map<String, Object> principals;
3537
private Map<String, String> queryParamCache;
38+
private final List<Runnable> afterHooks;
3639

3740
/**
3841
* Builds a {@code Request} from transport-neutral primitives. Adapters call this; handlers
@@ -145,6 +148,34 @@ public Request(
145148
this.method = method;
146149
this.headerLookup = headerLookup;
147150
this.principals = Map.copyOf(principals);
151+
this.afterHooks = new ArrayList<>();
152+
}
153+
154+
// Package-private: lets withPrincipals(...) thread the after-hook queue through so that
155+
// runnables registered on either the original Request or the principals-enriched copy
156+
// land in the same backing list.
157+
@SuppressWarnings("java:S107")
158+
Request(
159+
byte[] body,
160+
Object parsed,
161+
TypeMapper bodyMapper,
162+
String operationId,
163+
Map<String, String> pathParameters,
164+
String rawQuery,
165+
UnaryOperator<String> headerLookup,
166+
Map<String, Object> principals,
167+
HttpMethod method,
168+
List<Runnable> afterHooks) {
169+
this.body = body;
170+
this.parsed = parsed;
171+
this.bodyMapper = bodyMapper;
172+
this.operationId = operationId;
173+
this.pathParameters = pathParameters;
174+
this.rawQuery = rawQuery;
175+
this.method = method;
176+
this.headerLookup = headerLookup;
177+
this.principals = Map.copyOf(principals);
178+
this.afterHooks = afterHooks;
148179
}
149180

150181
public byte[] bytes() {
@@ -286,7 +317,28 @@ public Request withPrincipals(Map<String, Object> principals) {
286317
rawQuery,
287318
headerLookup,
288319
principals,
289-
method);
320+
method,
321+
afterHooks);
322+
}
323+
324+
/**
325+
* Queues a {@link Runnable} to execute after the HTTP response has been sent to the client. Runs
326+
* on the request thread inside the library's request {@link ScopedValue} binding. Multiple calls
327+
* queue FIFO. Exceptions thrown by the runnable are logged at DEBUG and swallowed.
328+
*
329+
* <p>Calls made after the runner has snapshotted the queue (e.g. from inside a running hook, or
330+
* from a leaked {@code Request} reference held past the response) are silently ignored.
331+
*
332+
* @throws NullPointerException if {@code runnable} is null
333+
*/
334+
public void afterResponse(Runnable runnable) {
335+
Objects.requireNonNull(runnable, "runnable must not be null");
336+
afterHooks.add(runnable);
337+
}
338+
339+
/** Package-private accessor for the after-hook queue; used by RequestPreparationFilter. */
340+
List<Runnable> afterHooks() {
341+
return afterHooks;
290342
}
291343

292344
private static Map<String, String> parseQuery(String query) {

src/test/java/com/retailsvc/http/RequestTest.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22

33
import static org.assertj.core.api.Assertions.assertThat;
44
import static org.assertj.core.api.Assertions.assertThatThrownBy;
5+
import static org.junit.jupiter.api.Assertions.assertThrows;
56

67
import com.fasterxml.jackson.databind.ObjectMapper;
78
import com.retailsvc.http.internal.DispatchHandler;
89
import com.retailsvc.http.spec.HttpMethod;
910
import java.nio.charset.StandardCharsets;
11+
import java.util.ArrayList;
1012
import java.util.HashMap;
13+
import java.util.List;
1114
import java.util.Map;
1215
import java.util.concurrent.atomic.AtomicReference;
1316
import java.util.function.UnaryOperator;
@@ -232,6 +235,47 @@ void withPrincipalsDoesNotShareUnderlyingMap() {
232235
assertThat(copy.principal("a")).contains("b");
233236
}
234237

238+
private static Request newRequest() {
239+
return new Request(new byte[0], null, null, "op", Map.of(), null, name -> null);
240+
}
241+
242+
@Test
243+
void afterResponseRejectsNull() {
244+
Request request = newRequest();
245+
assertThrows(NullPointerException.class, () -> request.afterResponse(null));
246+
}
247+
248+
@Test
249+
void afterResponseQueuesInOrder() {
250+
Request request = newRequest();
251+
List<String> log = new ArrayList<>();
252+
request.afterResponse(() -> log.add("first"));
253+
request.afterResponse(() -> log.add("second"));
254+
255+
for (Runnable r : request.afterHooks()) {
256+
r.run();
257+
}
258+
259+
assertThat(log).containsExactly("first", "second");
260+
}
261+
262+
@Test
263+
void withPrincipalsSharesAfterHookQueue() {
264+
Request original = newRequest();
265+
List<String> log = new ArrayList<>();
266+
original.afterResponse(() -> log.add("from-original"));
267+
268+
Request enriched = original.withPrincipals(Map.of("scheme", "principal"));
269+
enriched.afterResponse(() -> log.add("from-enriched"));
270+
271+
for (Runnable r : original.afterHooks()) {
272+
r.run();
273+
}
274+
275+
assertThat(log).containsExactly("from-original", "from-enriched");
276+
assertThat(original.afterHooks()).isSameAs(enriched.afterHooks());
277+
}
278+
235279
@Test
236280
void exposesMethod() {
237281
Request req =

0 commit comments

Comments
 (0)