package dev.coph.nextusweb.server.router; import dev.coph.nextusweb.server.json.JsonMapper; import dev.coph.nextusweb.server.router.exception.BadRequestException; import io.netty.handler.codec.http.*; import io.netty.util.CharsetUtil; import tools.jackson.core.JacksonException; import tools.jackson.databind.JsonNode; import java.util.*; /** * A convenience wrapper around a Netty {@link FullHttpRequest} that exposes the parts of an * HTTP request handlers typically need: path parameters, query parameters, headers and the * request body (raw, as a parsed JSON tree, or deserialized into a type). * *

Query parameters and the parsed JSON body are computed lazily and cached, so repeated * accessors do not re-parse the request. A single {@code Request} instance is not intended to * be shared across threads.

*/ public final class Request { /** The underlying Netty request this wrapper delegates to. */ private final FullHttpRequest raw; /** Path parameters captured by the router while matching, keyed by name. */ private final Map pathParams; /** Lazily decoded query-string parameters; {@code null} until first accessed. */ private Map> queryParams; /** Lazily parsed JSON body; {@code null} until {@link #json()} is first called. */ private JsonNode jsonCache; /** * Creates a request wrapper. * * @param raw the underlying Netty request * @param pathParams the path parameters captured during routing, keyed by name */ public Request(FullHttpRequest raw, Map pathParams) { this.raw = raw; this.pathParams = pathParams; } /** * Returns the value of a path parameter captured during routing. * * @param name the parameter name as declared in the route (without braces) * @return the captured value, or {@code null} if no such parameter was matched */ public String pathParam(String name) { return pathParams.get(name); } /** * Returns the first value of a query-string parameter, decoding the query string on first * access. * * @param name the query parameter name * @return the first value, or {@code null} if the parameter is absent or has no value */ public String queryParam(String name) { if (queryParams == null) { queryParams = new QueryStringDecoder(raw.uri()).parameters(); } var values = queryParams.get(name); return values == null || values.isEmpty() ? null : values.getFirst(); } /** * Returns all values of a query-string parameter, decoding the query string on first * access. * * @param name the query parameter name * @return the (possibly empty) list of values for the parameter; never {@code null} */ public List queryParams(String name) { if (queryParams == null) { queryParams = new QueryStringDecoder(raw.uri()).parameters(); } return queryParams.getOrDefault(name, List.of()); } /** * Returns the value of a request header. * * @param name the (case-insensitive) header name * @return the header value, or {@code null} if not present */ public String header(String name) { return raw.headers().get(name); } /** * Returns the request body decoded as a UTF-8 string. * * @return the body as text (empty if there is no body) */ public String body() { return raw.content().toString(CharsetUtil.UTF_8); } /** * Parses the request body as a JSON tree, caching the result for subsequent calls. An * empty body resolves to a JSON {@code null} node rather than an error. * * @return the parsed JSON tree * @throws BadRequestException if the body is not valid JSON */ public JsonNode json() { if (jsonCache == null) { try { byte[] bytes = new byte[raw.content().readableBytes()]; raw.content().getBytes(raw.content().readerIndex(), bytes); if (bytes.length == 0) { jsonCache = JsonMapper.MAPPER.nullNode(); } else { jsonCache = JsonMapper.MAPPER.readTree(bytes); } } catch (JacksonException e) { throw new BadRequestException("Invalid JSON: " + e.getOriginalMessage()); } } return jsonCache; } /** * Deserializes the request body directly into an instance of the given type. * *

Unlike {@link #json()}, the result is not cached and the body is read fresh on each * call.

* * @param type the target type to deserialize into * @param the target type * @return the deserialized value * @throws BadRequestException if the body cannot be deserialized into {@code type} */ public T jsonAs(Class type) { try { byte[] bytes = new byte[raw.content().readableBytes()]; raw.content().getBytes(raw.content().readerIndex(), bytes); return JsonMapper.MAPPER.readValue(bytes, type); } catch (JacksonException e) { throw new BadRequestException( "Could not deserialize body as " + type.getSimpleName() + ": " + e.getOriginalMessage()); } } /** * Returns the request's HTTP method. * * @return the HTTP method */ public HttpMethod method() { return raw.method(); } /** * Returns the request's path, with any query string stripped off. * * @return the decoded request path */ public String path() { return new QueryStringDecoder(raw.uri()).path(); } }