Skip to content

Commit fa3a490

Browse files
committed
feat: Add Request.principals and withPrincipals immutable copy
1 parent 83b9937 commit fa3a490

2 files changed

Lines changed: 82 additions & 0 deletions

File tree

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public final class Request {
2929
private final Map<String, String> pathParameters;
3030
private final String rawQuery;
3131
private final UnaryOperator<String> headerLookup;
32+
private final Map<String, Object> principals;
3233
private Map<String, String> queryParamCache;
3334

3435
/**
@@ -52,13 +53,39 @@ public Request(
5253
Map<String, String> pathParameters,
5354
String rawQuery,
5455
UnaryOperator<String> headerLookup) {
56+
this(body, parsed, bodyMapper, operationId, pathParameters, rawQuery, headerLookup, Map.of());
57+
}
58+
59+
/**
60+
* Builds a {@code Request} from transport-neutral primitives with an explicit principals map.
61+
*
62+
* @param body raw request body bytes; never {@code null}, may be empty
63+
* @param parsed loose structural view of the body (Map / List / boxed primitive), or {@code null}
64+
* @param bodyMapper {@link TypeMapper} that produced {@code parsed}, used for typed conversion;
65+
* may be {@code null} if there is no body
66+
* @param operationId the OpenAPI {@code operationId} the request was routed to
67+
* @param pathParameters path variables extracted by the router
68+
* @param rawQuery raw (percent-encoded) query string, or {@code null} if absent
69+
* @param headerLookup first-value, case-insensitive header lookup; returns {@code null} if absent
70+
* @param principals principals stashed by the security filter, keyed by scheme name
71+
*/
72+
public Request(
73+
byte[] body,
74+
Object parsed,
75+
TypeMapper bodyMapper,
76+
String operationId,
77+
Map<String, String> pathParameters,
78+
String rawQuery,
79+
UnaryOperator<String> headerLookup,
80+
Map<String, Object> principals) {
5581
this.body = body;
5682
this.parsed = parsed;
5783
this.bodyMapper = bodyMapper;
5884
this.operationId = operationId;
5985
this.pathParameters = pathParameters;
6086
this.rawQuery = rawQuery;
6187
this.headerLookup = headerLookup;
88+
this.principals = Map.copyOf(principals);
6289
}
6390

6491
public byte[] bytes() {
@@ -163,6 +190,29 @@ public Optional<String> queryParam(String name) {
163190
return raw == null || raw.isBlank() ? Optional.empty() : Optional.of(raw);
164191
}
165192

193+
/**
194+
* Principals stashed by {@code SecurityFilter}, keyed by securityScheme name. Empty when the
195+
* request had no security requirements or when {@code useExternalAuthentication()} is set.
196+
*/
197+
public Map<String, Object> principals() {
198+
return principals;
199+
}
200+
201+
/** Convenience for the common single-scheme case. */
202+
public Optional<Object> principal(String schemeName) {
203+
return Optional.ofNullable(principals.get(schemeName));
204+
}
205+
206+
/**
207+
* Returns a new {@code Request} identical to this one except with the supplied principals. Used
208+
* by {@code SecurityFilter} on success; the returned instance carries the principals through to
209+
* the {@link RequestHandler}.
210+
*/
211+
public Request withPrincipals(Map<String, Object> principals) {
212+
return new Request(
213+
body, parsed, bodyMapper, operationId, pathParameters, rawQuery, headerLookup, principals);
214+
}
215+
166216
private static Map<String, String> parseQuery(String query) {
167217
if (query == null || query.isBlank()) {
168218
return Map.of();

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import com.fasterxml.jackson.databind.ObjectMapper;
77
import com.retailsvc.http.internal.DispatchHandler;
88
import java.nio.charset.StandardCharsets;
9+
import java.util.HashMap;
910
import java.util.Map;
1011
import java.util.concurrent.atomic.AtomicReference;
1112
import java.util.function.UnaryOperator;
@@ -199,6 +200,37 @@ void contentTypeEmptyWhenHeaderAbsent() {
199200
assertThat(req.contentType()).isEmpty();
200201
}
201202

203+
@Test
204+
void principalsDefaultsEmpty() {
205+
Request r = new Request(new byte[0], null, null, "op", Map.of(), null, NO_HEADERS);
206+
207+
assertThat(r.principals()).isEmpty();
208+
assertThat(r.principal("anything")).isEmpty();
209+
}
210+
211+
@Test
212+
void withPrincipalsCreatesImmutableCopy() {
213+
Request r = new Request(new byte[0], null, null, "op", Map.of(), null, NO_HEADERS);
214+
Map<String, Object> principals = Map.of("bearerAuth", "user-123");
215+
Request copy = r.withPrincipals(principals);
216+
217+
assertThat(copy).isNotSameAs(r);
218+
assertThat(r.principals()).isEmpty();
219+
assertThat(copy.principals()).isEqualTo(principals);
220+
assertThat(copy.principal("bearerAuth")).contains("user-123");
221+
}
222+
223+
@Test
224+
void withPrincipalsDoesNotShareUnderlyingMap() {
225+
Request r = new Request(new byte[0], null, null, "op", Map.of(), null, NO_HEADERS);
226+
HashMap<String, Object> mutable = new HashMap<>();
227+
mutable.put("a", "b");
228+
Request copy = r.withPrincipals(mutable);
229+
mutable.put("a", "MUTATED");
230+
231+
assertThat(copy.principal("a")).contains("b");
232+
}
233+
202234
@Test
203235
void headerReturnsOptionalAndBlankIsAbsent() {
204236
Request req =

0 commit comments

Comments
 (0)