Skip to content

Commit 6b64b6c

Browse files
committed
feat: Add per-request after-hook queue to Request
1 parent bdd8a87 commit 6b64b6c

2 files changed

Lines changed: 102 additions & 1 deletion

File tree

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

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import java.net.URLDecoder;
44
import java.nio.charset.StandardCharsets;
5+
import java.util.ArrayList;
56
import java.util.LinkedHashMap;
7+
import java.util.List;
68
import java.util.Map;
79
import java.util.Objects;
810
import java.util.Optional;
@@ -31,6 +33,7 @@ public final class Request {
3133
private final UnaryOperator<String> headerLookup;
3234
private final Map<String, Object> principals;
3335
private Map<String, String> queryParamCache;
36+
private final List<Runnable> afterHooks;
3437

3538
/**
3639
* Builds a {@code Request} from transport-neutral primitives. Adapters call this; handlers
@@ -90,6 +93,32 @@ public Request(
9093
this.rawQuery = rawQuery;
9194
this.headerLookup = headerLookup;
9295
this.principals = Map.copyOf(principals);
96+
this.afterHooks = new ArrayList<>();
97+
}
98+
99+
// Package-private: lets withPrincipals(...) thread the after-hook queue through so that
100+
// runnables registered on either the original Request or the principals-enriched copy
101+
// land in the same backing list.
102+
@SuppressWarnings("java:S107")
103+
Request(
104+
byte[] body,
105+
Object parsed,
106+
TypeMapper bodyMapper,
107+
String operationId,
108+
Map<String, String> pathParameters,
109+
String rawQuery,
110+
UnaryOperator<String> headerLookup,
111+
Map<String, Object> principals,
112+
List<Runnable> afterHooks) {
113+
this.body = body;
114+
this.parsed = parsed;
115+
this.bodyMapper = bodyMapper;
116+
this.operationId = operationId;
117+
this.pathParameters = pathParameters;
118+
this.rawQuery = rawQuery;
119+
this.headerLookup = headerLookup;
120+
this.principals = Map.copyOf(principals);
121+
this.afterHooks = afterHooks;
93122
}
94123

95124
public byte[] bytes() {
@@ -214,7 +243,35 @@ public Optional<Object> principal(String schemeName) {
214243
*/
215244
public Request withPrincipals(Map<String, Object> principals) {
216245
return new Request(
217-
body, parsed, bodyMapper, operationId, pathParameters, rawQuery, headerLookup, principals);
246+
body,
247+
parsed,
248+
bodyMapper,
249+
operationId,
250+
pathParameters,
251+
rawQuery,
252+
headerLookup,
253+
principals,
254+
afterHooks);
255+
}
256+
257+
/**
258+
* Queues a {@link Runnable} to execute after the HTTP response has been sent to the client. Runs
259+
* on the request thread inside the library's request {@link ScopedValue} binding. Multiple calls
260+
* queue FIFO. Exceptions thrown by the runnable are logged at DEBUG and swallowed.
261+
*
262+
* <p>Calls made after the runner has snapshotted the queue (e.g. from inside a running hook, or
263+
* from a leaked {@code Request} reference held past the response) are silently ignored.
264+
*
265+
* @throws NullPointerException if {@code runnable} is null
266+
*/
267+
public void afterResponse(Runnable runnable) {
268+
Objects.requireNonNull(runnable, "runnable must not be null");
269+
afterHooks.add(runnable);
270+
}
271+
272+
/** Package-private accessor for the after-hook queue; used by RequestPreparationFilter. */
273+
List<Runnable> afterHooks() {
274+
return afterHooks;
218275
}
219276

220277
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,11 +2,14 @@
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 java.nio.charset.StandardCharsets;
10+
import java.util.ArrayList;
911
import java.util.HashMap;
12+
import java.util.List;
1013
import java.util.Map;
1114
import java.util.concurrent.atomic.AtomicReference;
1215
import java.util.function.UnaryOperator;
@@ -231,6 +234,47 @@ void withPrincipalsDoesNotShareUnderlyingMap() {
231234
assertThat(copy.principal("a")).contains("b");
232235
}
233236

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

0 commit comments

Comments
 (0)