001/*
002 * Copyright 2022-2026 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.soklet;
018
019import com.soklet.exception.IllegalFormParameterException;
020import com.soklet.exception.IllegalMultipartFieldException;
021import com.soklet.exception.IllegalQueryParameterException;
022import com.soklet.exception.IllegalRequestCookieException;
023import com.soklet.exception.IllegalRequestException;
024import com.soklet.exception.IllegalRequestHeaderException;
025import com.soklet.internal.spring.LinkedCaseInsensitiveMap;
026import org.jspecify.annotations.NonNull;
027import org.jspecify.annotations.Nullable;
028
029import javax.annotation.concurrent.NotThreadSafe;
030import javax.annotation.concurrent.ThreadSafe;
031import java.net.InetSocketAddress;
032import java.nio.charset.Charset;
033import java.nio.charset.StandardCharsets;
034import java.util.Arrays;
035import java.util.Collections;
036import java.util.LinkedHashMap;
037import java.util.LinkedHashSet;
038import java.util.List;
039import java.util.Locale;
040import java.util.Locale.LanguageRange;
041import java.util.Map;
042import java.util.Objects;
043import java.util.Optional;
044import java.util.Set;
045import java.util.concurrent.locks.ReentrantLock;
046import java.util.function.Consumer;
047import java.util.stream.Collectors;
048
049import static com.soklet.Utilities.trimAggressivelyToEmpty;
050import static java.lang.String.format;
051import static java.util.Collections.unmodifiableList;
052import static java.util.Objects.requireNonNull;
053
054/**
055 * Encapsulates information specified in an HTTP request.
056 * <p>
057 * Instances can be acquired via the {@link #withRawUrl(HttpMethod, String)} (e.g. provided by clients on a "raw" HTTP/1.1 request line, un-decoded) and {@link #withPath(HttpMethod, String)} (e.g. manually-constructed during integration testing, understood to be already-decoded) builder factory methods.
058 * Convenience instance factories are also available via {@link #fromRawUrl(HttpMethod, String)} and {@link #fromPath(HttpMethod, String)}.
059 * <p>
060 * Any necessary decoding (path, URL parameter, {@code Content-Type: application/x-www-form-urlencoded}, etc.) will be automatically performed.  Unless otherwise indicated, all accessor methods will return decoded data.
061 * <p>
062 * For performance, collection values (headers, query parameters, form parameters, cookies, multipart fields) are shallow-copied and not defensively deep-copied. Treat returned collections as immutable.
063 * <p>
064 * Detailed documentation available at <a href="https://www.soklet.com/docs/request-handling">https://www.soklet.com/docs/request-handling</a>.
065 *
066 * @author <a href="https://www.revetkn.com">Mark Allen</a>
067 */
068@ThreadSafe
069public final class Request {
070        @NonNull
071        private static final Charset DEFAULT_CHARSET;
072        @NonNull
073        private static final IdGenerator DEFAULT_ID_GENERATOR;
074
075        static {
076                DEFAULT_CHARSET = StandardCharsets.UTF_8;
077                DEFAULT_ID_GENERATOR = DefaultIdGenerator.defaultInstance();
078        }
079
080        @NonNull
081        private final Object id;
082        @NonNull
083        private final HttpMethod httpMethod;
084        @NonNull
085        private final String rawPath;
086        @Nullable
087        private final String rawQuery;
088        @NonNull
089        private final String path;
090        @NonNull
091        private final ResourcePath resourcePath;
092        @NonNull
093        private final Map<@NonNull String, @NonNull Set<@NonNull String>> queryParameters;
094        @Nullable
095        private final String contentType;
096        @Nullable
097        private final Charset charset;
098        @NonNull
099        private final Map<@NonNull String, @NonNull Set<@NonNull String>> headers;
100        @Nullable
101        private final InetSocketAddress remoteAddress;
102        @Nullable
103        private final Cors cors;
104        @Nullable
105        private final CorsPreflight corsPreflight;
106        @Nullable
107        private final byte[] body;
108        @NonNull
109        private final Boolean multipart;
110        @NonNull
111        private final Boolean contentTooLarge;
112        @NonNull
113        private final MultipartParser multipartParser;
114        @NonNull
115        private final IdGenerator<?> idGenerator;
116        @NonNull
117        private final ReentrantLock lock;
118        @Nullable
119        private volatile String bodyAsString = null;
120        @Nullable
121        private volatile List<@NonNull Locale> locales = null;
122        @Nullable
123        private volatile List<@NonNull LanguageRange> languageRanges = null;
124        @Nullable
125        private volatile Map<@NonNull String, @NonNull Set<@NonNull String>> cookies = null;
126        @Nullable
127        private volatile Map<@NonNull String, @NonNull Set<@NonNull MultipartField>> multipartFields = null;
128        @Nullable
129        private volatile Map<@NonNull String, @NonNull Set<@NonNull String>> formParameters = null;
130
131        /**
132         * Acquires a builder for {@link Request} instances from the URL provided by clients on a "raw" HTTP/1.1 request line.
133         * <p>
134         * The provided {@code rawUrl} must be un-decoded and in either "path-and-query" form (i.e. starts with a {@code /} character) or an absolute URL (i.e. starts with {@code http://} or {@code https://}).
135         * It might include un-decoded query parameters, e.g. {@code https://www.example.com/one?two=thr%20ee} or {@code /one?two=thr%20ee}.  An exception to this rule is {@code OPTIONS *} requests, where the URL is the {@code *} "splat" symbol.
136         * <p>
137         * Note: request targets are normalized to origin-form. For example, if a client sends an absolute-form URL like {@code http://example.com/path?query}, only the path and query components are retained.
138         * <p>
139         * Paths will be percent-decoded. Percent-encoded slashes (e.g. {@code %2F}) are rejected.
140         * Malformed percent-encoding is rejected.
141         * <p>
142         * Query parameters are parsed and decoded using RFC 3986 semantics - see {@link QueryFormat#RFC_3986_STRICT}.
143         * Query decoding always uses UTF-8, regardless of any {@code Content-Type} charset.
144         * <p>
145         * Request body form parameters with {@code Content-Type: application/x-www-form-urlencoded} are parsed and decoded by using {@link QueryFormat#X_WWW_FORM_URLENCODED}.
146         *
147         * @param httpMethod the HTTP method for this request ({@code GET, POST, etc.})
148         * @param rawUrl     the raw (un-decoded) URL for this request
149         * @return the builder
150         */
151        @NonNull
152        public static RawBuilder withRawUrl(@NonNull HttpMethod httpMethod,
153                                                                                                                                                        @NonNull String rawUrl) {
154                requireNonNull(httpMethod);
155                requireNonNull(rawUrl);
156
157                return new RawBuilder(httpMethod, rawUrl);
158        }
159
160        /**
161         * Creates a {@link Request} from a raw request target without additional customization.
162         *
163         * @param httpMethod the HTTP method
164         * @param rawUrl     a raw HTTP/1.1 request target (not URL-decoded)
165         * @return a {@link Request} instance
166         */
167        @NonNull
168        public static Request fromRawUrl(@NonNull HttpMethod httpMethod,
169                                                                                                                                         @NonNull String rawUrl) {
170                return withRawUrl(httpMethod, rawUrl).build();
171        }
172
173        /**
174         * Acquires a builder for {@link Request} instances from already-decoded path and query components - useful for manual construction, e.g. integration tests.
175         * <p>
176         * The provided {@code path} must start with the {@code /} character and already be decoded (e.g. {@code "/my path"}, not {@code "/my%20path"}).  It must not include query parameters. For {@code OPTIONS *} requests, the {@code path} must be {@code *} - the "splat" symbol.
177         * <p>
178         * Query parameters must be specified via {@link PathBuilder#queryParameters(Map)} and are assumed to be already-decoded.
179         * <p>
180         * Request body form parameters with {@code Content-Type: application/x-www-form-urlencoded} are parsed and decoded by using {@link QueryFormat#X_WWW_FORM_URLENCODED}.
181         *
182         * @param httpMethod the HTTP method for this request ({@code GET, POST, etc.})
183         * @param path       the decoded URL path for this request
184         * @return the builder
185         */
186        @NonNull
187        public static PathBuilder withPath(@NonNull HttpMethod httpMethod,
188                                                                                                                                                 @NonNull String path) {
189                requireNonNull(httpMethod);
190                requireNonNull(path);
191
192                return new PathBuilder(httpMethod, path);
193        }
194
195        /**
196         * Creates a {@link Request} from a path without additional customization.
197         *
198         * @param httpMethod the HTTP method
199         * @param path       a decoded request path (e.g. {@code /widgets/123})
200         * @return a {@link Request} instance
201         */
202        @NonNull
203        public static Request fromPath(@NonNull HttpMethod httpMethod,
204                                                                                                                                 @NonNull String path) {
205                return withPath(httpMethod, path).build();
206        }
207
208        /**
209         * Vends a mutable copier seeded with this instance's data, suitable for building new instances.
210         *
211         * @return a copier for this instance
212         */
213        @NonNull
214        public Copier copy() {
215                return new Copier(this);
216        }
217
218        private Request(@Nullable RawBuilder rawBuilder,
219                                                                        @Nullable PathBuilder pathBuilder) {
220                // Should never occur
221                if (rawBuilder == null && pathBuilder == null)
222                        throw new IllegalStateException(format("Neither %s nor %s were specified", RawBuilder.class.getSimpleName(), PathBuilder.class.getSimpleName()));
223
224                IdGenerator builderIdGenerator = rawBuilder == null ? pathBuilder.idGenerator : rawBuilder.idGenerator;
225                Object builderId = rawBuilder == null ? pathBuilder.id : rawBuilder.id;
226                HttpMethod builderHttpMethod = rawBuilder == null ? pathBuilder.httpMethod : rawBuilder.httpMethod;
227                byte[] builderBody = rawBuilder == null ? pathBuilder.body : rawBuilder.body;
228                MultipartParser builderMultipartParser = rawBuilder == null ? pathBuilder.multipartParser : rawBuilder.multipartParser;
229                Boolean builderContentTooLarge = rawBuilder == null ? pathBuilder.contentTooLarge : rawBuilder.contentTooLarge;
230                Map<String, Set<String>> builderHeaders = rawBuilder == null ? pathBuilder.headers : rawBuilder.headers;
231                InetSocketAddress builderRemoteAddress = rawBuilder == null ? pathBuilder.remoteAddress : rawBuilder.remoteAddress;
232
233                if (builderHeaders == null)
234                        builderHeaders = Map.of();
235
236                this.idGenerator = builderIdGenerator == null ? DEFAULT_ID_GENERATOR : builderIdGenerator;
237                this.multipartParser = builderMultipartParser == null ? DefaultMultipartParser.defaultInstance() : builderMultipartParser;
238
239                // Header names are case-insensitive.  Enforce that here with a special map
240                Map<String, Set<String>> caseInsensitiveHeaders = new LinkedCaseInsensitiveMap<>(builderHeaders);
241                this.headers = Collections.unmodifiableMap(caseInsensitiveHeaders);
242                this.contentType = Utilities.extractContentTypeFromHeaders(this.headers).orElse(null);
243                this.charset = Utilities.extractCharsetFromHeaders(this.headers).orElse(null);
244                this.remoteAddress = builderRemoteAddress;
245
246                String path;
247
248                // If we use PathBuilder, use its path directly.
249                // If we use RawBuilder, parse and decode its path.
250                if (pathBuilder != null) {
251                        path = trimAggressivelyToEmpty(pathBuilder.path);
252
253                        // Validate path
254                        if (!path.startsWith("/") && !path.equals("*"))
255                                throw new IllegalRequestException("Path must start with '/' or be '*'");
256
257                        if (path.contains("?"))
258                                throw new IllegalRequestException(format("Path should not contain a query string. Use %s.withPath(...).queryParameters(...) to specify query parameters as a %s.",
259                                                Request.class.getSimpleName(), Map.class.getSimpleName()));
260
261                        // Use already-decoded query parameters as provided by the path builder
262                        this.queryParameters = pathBuilder.queryParameters == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(pathBuilder.queryParameters));
263                } else {
264                        // RawBuilder scenario
265                        String rawUrl = trimAggressivelyToEmpty(rawBuilder.rawUrl);
266
267                        // Special handling for OPTIONS *
268                        if ("*".equals(rawUrl)) {
269                                path = "*";
270                                this.queryParameters = Map.of();
271                        } else {
272                                // First, parse and decode the path...
273                                path = Utilities.extractPathFromUrl(rawUrl, true);
274
275                                // ...then, parse out any query parameters.
276                                if (rawUrl.contains("?")) {
277                                        // We always assume RFC_3986_STRICT for query parameters because Soklet is for modern systems - HTML Form "GET" submissions are rare/legacy.
278                                        // This means we leave "+" as "+" (not decode to " ") and then apply any percent-decoding rules.
279                                        // Query parameters are decoded as UTF-8 regardless of Content-Type.
280                                        // In the future, we might expose a way to let applications prefer QueryFormat.X_WWW_FORM_URLENCODED instead, which treats "+" as a space
281                                        this.queryParameters = Collections.unmodifiableMap(Utilities.extractQueryParametersFromUrl(rawUrl, QueryFormat.RFC_3986_STRICT, DEFAULT_CHARSET));
282                                } else {
283                                        this.queryParameters = Map.of();
284                                }
285                        }
286                }
287
288                if (path.equals("*") && builderHttpMethod != HttpMethod.OPTIONS)
289                        throw new IllegalRequestException(format("Path '*' is only legal for HTTP %s", HttpMethod.OPTIONS.name()));
290
291                if (path.contains("\u0000") || path.contains("%00"))
292                        throw new IllegalRequestException(format("Illegal null byte in path '%s'", path));
293
294                this.path = path;
295
296                String rawPath;
297                String rawQuery;
298
299                if (pathBuilder != null) {
300                        // PathBuilder scenario: check if explicit raw values were provided
301                        if (pathBuilder.rawPath != null) {
302                                // Explicit raw values provided (e.g. from Copier preserving originals)
303                                rawPath = pathBuilder.rawPath;
304                                rawQuery = pathBuilder.rawQuery;
305                        } else {
306                                // No explicit raw values; encode from decoded values
307                                if (path.equals("*")) {
308                                        rawPath = "*";
309                                } else {
310                                        rawPath = Utilities.encodePath(path);
311                                }
312
313                                if (this.queryParameters.isEmpty()) {
314                                        rawQuery = null;
315                                } else {
316                                        rawQuery = Utilities.encodeQueryParameters(this.queryParameters, QueryFormat.RFC_3986_STRICT);
317                                }
318                        }
319                } else {
320                        // RawBuilder scenario: extract raw components from rawUrl
321                        String rawUrl = trimAggressivelyToEmpty(rawBuilder.rawUrl);
322
323                        if ("*".equals(rawUrl)) {
324                                rawPath = "*";
325                                rawQuery = null;
326                        } else {
327                                rawPath = Utilities.extractPathFromUrl(rawUrl, false);
328                                if (containsEncodedSlash(rawPath))
329                                        throw new IllegalRequestException("Encoded slashes are not allowed in request paths");
330                                rawQuery = Utilities.extractRawQueryFromUrl(rawUrl).orElse(null);
331                        }
332                }
333
334                this.rawPath = rawPath;
335                this.rawQuery = rawQuery;
336
337                this.lock = new ReentrantLock();
338                this.httpMethod = builderHttpMethod;
339                this.corsPreflight = this.httpMethod == HttpMethod.OPTIONS ? CorsPreflight.fromHeaders(this.headers).orElse(null) : null;
340                this.cors = this.corsPreflight == null ? Cors.fromHeaders(this.httpMethod, this.headers).orElse(null) : null;
341                this.resourcePath = this.path.equals("*") ? ResourcePath.OPTIONS_SPLAT_RESOURCE_PATH : ResourcePath.fromPath(this.path);
342                this.multipart = this.contentType != null && this.contentType.toLowerCase(Locale.ENGLISH).startsWith("multipart/");
343                this.contentTooLarge = builderContentTooLarge == null ? false : builderContentTooLarge;
344
345                // It's illegal to specify a body if the request is marked "content too large"
346                this.body = this.contentTooLarge ? null : builderBody;
347
348                // Last step of ctor: generate an ID (if necessary) using this fully-constructed Request
349                this.id = builderId == null ? this.idGenerator.generateId(this) : builderId;
350
351                // Note that cookies, form parameters, and multipart data are lazily parsed/instantiated when callers try to access them
352        }
353
354        @Override
355        public String toString() {
356                return format("%s{id=%s, httpMethod=%s, path=%s, cookies=%s, queryParameters=%s, headers=%s, body=%s}",
357                                getClass().getSimpleName(), getId(), getHttpMethod(), getPath(), getCookies(), getQueryParameters(),
358                                getHeaders(), format("%d bytes", getBody().isPresent() ? getBody().get().length : 0));
359        }
360
361        @Override
362        public boolean equals(@Nullable Object object) {
363                if (this == object)
364                        return true;
365
366                if (!(object instanceof Request request))
367                        return false;
368
369                return Objects.equals(getId(), request.getId())
370                                && Objects.equals(getHttpMethod(), request.getHttpMethod())
371                                && Objects.equals(getPath(), request.getPath())
372                                && Objects.equals(getQueryParameters(), request.getQueryParameters())
373                                && Objects.equals(getHeaders(), request.getHeaders())
374                                && Arrays.equals(this.body, request.body)
375                                && Objects.equals(isContentTooLarge(), request.isContentTooLarge());
376        }
377
378        @Override
379        public int hashCode() {
380                return Objects.hash(getId(), getHttpMethod(), getPath(), getQueryParameters(), getHeaders(), Arrays.hashCode(this.body), isContentTooLarge());
381        }
382
383        private static boolean containsEncodedSlash(@NonNull String rawPath) {
384                requireNonNull(rawPath);
385                return rawPath.toLowerCase(Locale.ROOT).contains("%2f");
386        }
387
388        /**
389         * An application-specific identifier for this request.
390         * <p>
391         * The identifier is not necessarily unique (for example, numbers that "wrap around" if they get too large).
392         *
393         * @return the request's identifier
394         */
395        @NonNull
396        public Object getId() {
397                return this.id;
398        }
399
400        /**
401         * The <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods">HTTP method</a> for this request.
402         *
403         * @return the request's HTTP method
404         */
405        @NonNull
406        public HttpMethod getHttpMethod() {
407                return this.httpMethod;
408        }
409
410        /**
411         * The percent-decoded path component of this request (no query string).
412         *
413         * @return the path for this request
414         */
415        @NonNull
416        public String getPath() {
417                return this.path;
418        }
419
420        /**
421         * Convenience method to acquire a {@link ResourcePath} representation of {@link #getPath()}.
422         *
423         * @return the resource path for this request
424         */
425        @NonNull
426        public ResourcePath getResourcePath() {
427                return this.resourcePath;
428        }
429
430        /**
431         * The cookies provided by the client for this request.
432         * <p>
433         * The keys are the {@code Cookie} header names and the values are {@code Cookie} header values
434         * (it is possible for a client to send multiple {@code Cookie} headers with the same name).
435         * <p>
436         * <em>Note that {@code Cookie} headers, like all request headers, have case-insensitive names per the HTTP spec.</em>
437         * <p>
438         * Use {@link #getCookie(String)} for a convenience method to access cookie values when only one is expected.
439         *
440         * @return the request's cookies
441         */
442        @NonNull
443        public Map<@NonNull String, @NonNull Set<@NonNull String>> getCookies() {
444                Map<String, Set<String>> result = this.cookies;
445
446                if (result == null) {
447                        getLock().lock();
448
449                        try {
450                                result = this.cookies;
451
452                                if (result == null) {
453                                        result = Collections.unmodifiableMap(Utilities.extractCookiesFromHeaders(getHeaders()));
454                                        this.cookies = result;
455                                }
456                        } finally {
457                                getLock().unlock();
458                        }
459                }
460
461                return result;
462        }
463
464        /**
465         * The decoded query parameters provided by the client for this request.
466         * <p>
467         * The keys are the query parameter names and the values are query parameter values
468         * (it is possible for a client to send multiple query parameters with the same name, e.g. {@code ?test=1&test=2}).
469         * <p>
470         * <em>Note that query parameters have case-sensitive names per the HTTP spec.</em>
471         * <p>
472         * Use {@link #getQueryParameter(String)} for a convenience method to access query parameter values when only one is expected.
473         *
474         * @return the request's query parameters
475         */
476        @NonNull
477        public Map<@NonNull String, @NonNull Set<@NonNull String>> getQueryParameters() {
478                return this.queryParameters;
479        }
480
481        /**
482         * The decoded HTML {@code application/x-www-form-urlencoded} form parameters provided by the client for this request.
483         * <p>
484         * The keys are the form parameter names and the values are form parameter values
485         * (it is possible for a client to send multiple form parameters with the same name, e.g. {@code ?test=1&test=2}).
486         * <p>
487         * <em>Note that form parameters have case-sensitive names per the HTTP spec.</em>
488         * <p>
489         * Use {@link #getFormParameter(String)} for a convenience method to access form parameter values when only one is expected.
490         *
491         * @return the request's form parameters
492         */
493        @NonNull
494        public Map<@NonNull String, @NonNull Set<@NonNull String>> getFormParameters() {
495                Map<String, Set<String>> result = this.formParameters;
496
497                if (result == null) {
498                        getLock().lock();
499                        try {
500                                result = this.formParameters;
501
502                                if (result == null) {
503                                        if (this.body != null && this.contentType != null && this.contentType.equalsIgnoreCase("application/x-www-form-urlencoded")) {
504                                                String bodyAsString = getBodyAsString().orElse(null);
505                                                result = Collections.unmodifiableMap(Utilities.extractQueryParametersFromQuery(bodyAsString, QueryFormat.X_WWW_FORM_URLENCODED, getCharset().orElse(DEFAULT_CHARSET)));
506                                        } else {
507                                                result = Map.of();
508                                        }
509
510                                        this.formParameters = result;
511                                }
512                        } finally {
513                                getLock().unlock();
514                        }
515                }
516
517                return result;
518        }
519
520        /**
521         * The raw (un-decoded) path component of this request exactly as the client specified.
522         * <p>
523         * For example, {@code "/a%20b"} (never decoded).
524         * <p>
525         * <em>Note: For requests constructed via {@link #withPath(HttpMethod, String)}, this value is
526         * generated by encoding the decoded path, which may not exactly match the original wire format.</em>
527         *
528         * @return the raw path for this request
529         */
530        @NonNull
531        public String getRawPath() {
532                return this.rawPath;
533        }
534
535        /**
536         * The raw (un-decoded) query component of this request exactly as the client specified.
537         * <p>
538         * For example, {@code "a=b&c=d+e"} (never decoded).
539         * <p>
540         * This is useful for special cases like HMAC signature verification, which relies on the exact client format.
541         * <p>
542         * <em>Note: For requests constructed via {@link #withPath(HttpMethod, String)}, this value is
543         * generated by encoding the decoded query parameters, which may not exactly match the original wire format.</em>
544         *
545         * @return the raw query for this request, or {@link Optional#empty()} if none was specified
546         */
547        @NonNull
548        public Optional<String> getRawQuery() {
549                return Optional.ofNullable(this.rawQuery);
550        }
551
552        /**
553         * The raw (un-decoded) path and query components of this request exactly as the client specified.
554         * <p>
555         * For example, {@code "/my%20path?a=b&c=d%20e"} (never decoded).
556         * <p>
557         * <em>Note: For requests constructed via {@link #withPath(HttpMethod, String)}, this value is
558         * generated by encoding the decoded path and query parameters, which may not exactly match the original wire format.</em>
559         *
560         * @return the raw path and query for this request
561         */
562        @NonNull
563        public String getRawPathAndQuery() {
564                if (this.rawQuery == null)
565                        return this.rawPath;
566
567                return this.rawPath + "?" + this.rawQuery;
568        }
569
570        /**
571         * The remote network address for the client connection, if available.
572         *
573         * @return the remote address for this request, or {@link Optional#empty()} if unavailable
574         */
575        @NonNull
576        public Optional<InetSocketAddress> getRemoteAddress() {
577                return Optional.ofNullable(this.remoteAddress);
578        }
579
580        /**
581         * The headers provided by the client for this request.
582         * <p>
583         * The keys are the header names and the values are header values
584         * (it is possible for a client to send multiple headers with the same name).
585         * <p>
586         * <em>Note that request headers have case-insensitive names per the HTTP spec.</em>
587         * <p>
588         * Use {@link #getHeader(String)} for a convenience method to access header values when only one is expected.
589         *
590         * @return the request's headers
591         */
592        @NonNull
593        public Map<@NonNull String, @NonNull Set<@NonNull String>> getHeaders() {
594                return this.headers;
595        }
596
597        /**
598         * The {@code Content-Type} header value, as specified by the client.
599         *
600         * @return the request's {@code Content-Type} header value, or {@link Optional#empty()} if not specified
601         */
602        @NonNull
603        public Optional<String> getContentType() {
604                return Optional.ofNullable(this.contentType);
605        }
606
607        /**
608         * The request's character encoding, as specified by the client in the {@code Content-Type} header value.
609         *
610         * @return the request's character encoding, or {@link Optional#empty()} if not specified
611         */
612        @NonNull
613        public Optional<Charset> getCharset() {
614                return Optional.ofNullable(this.charset);
615        }
616
617        /**
618         * Is this a request with {@code Content-Type} of {@code multipart/form-data}?
619         *
620         * @return {@code true} if this is a {@code multipart/form-data} request, {@code false} otherwise
621         */
622        @NonNull
623        public Boolean isMultipart() {
624                return this.multipart;
625        }
626
627        /**
628         * The decoded HTML {@code multipart/form-data} fields provided by the client for this request.
629         * <p>
630         * The keys are the multipart field names and the values are multipart field values
631         * (it is possible for a client to send multiple multipart fields with the same name).
632         * <p>
633         * <em>Note that multipart fields have case-sensitive names per the HTTP spec.</em>
634         * <p>
635         * Use {@link #getMultipartField(String)} for a convenience method to access a multipart parameter field value when only one is expected.
636         * <p>
637         * When using Soklet's default {@link Server}, multipart fields are parsed using the {@link MultipartParser} as configured by {@link Server.Builder#multipartParser(MultipartParser)}.
638         *
639         * @return the request's multipart fields, or the empty map if none are present
640         */
641        @NonNull
642        public Map<@NonNull String, @NonNull Set<@NonNull MultipartField>> getMultipartFields() {
643                if (!isMultipart())
644                        return Map.of();
645
646                Map<String, Set<MultipartField>> result = this.multipartFields;
647
648                if (result == null) {
649                        getLock().lock();
650                        try {
651                                result = this.multipartFields;
652
653                                if (result == null) {
654                                        result = Collections.unmodifiableMap(getMultipartParser().extractMultipartFields(this));
655                                        this.multipartFields = result;
656                                }
657                        } finally {
658                                getLock().unlock();
659                        }
660                }
661
662                return result;
663        }
664
665        /**
666         * The raw bytes of the request body - <strong>callers should not modify this array; it is not defensively copied for performance reasons</strong>.
667         * <p>
668         * For convenience, {@link #getBodyAsString()} is available if you expect your request body to be of type {@link String}.
669         *
670         * @return the request body bytes, or {@link Optional#empty()} if none was supplied
671         */
672        @NonNull
673        public Optional<byte[]> getBody() {
674                return Optional.ofNullable(this.body);
675
676                // Note: it would be nice to defensively copy, but it's inefficient
677                // return Optional.ofNullable(this.body == null ? null : Arrays.copyOf(this.body, this.body.length));
678        }
679
680        /**
681         * Was this request too large for the server to handle?
682         * <p>
683         * <em>If so, this request might have incomplete sets of headers/cookies. It will always have a zero-length body.</em>
684         * <p>
685         * Soklet is designed to power systems that exchange small "transactional" payloads that live entirely in memory. It is not appropriate for handling multipart files at scale, buffering uploads to disk, streaming, etc.
686         * <p>
687         * When using Soklet's default {@link Server}, maximum request size is configured by {@link Server.Builder#maximumRequestSizeInBytes(Integer)}.
688         *
689         * @return {@code true} if this request is larger than the server is able to handle, {@code false} otherwise
690         */
691        @NonNull
692        public Boolean isContentTooLarge() {
693                return this.contentTooLarge;
694        }
695
696        /**
697         * Convenience method that provides the {@link #getBody()} bytes as a {@link String} encoded using the client-specified character set per {@link #getCharset()}.
698         * <p>
699         * If no character set is specified, {@link StandardCharsets#UTF_8} is used to perform the encoding.
700         * <p>
701         * This method will lazily convert the raw bytes as specified by {@link #getBody()} to an instance of {@link String} when first invoked.  The {@link String} representation is then cached and re-used for subsequent invocations.
702         * <p>
703         * This method is threadsafe.
704         *
705         * @return a {@link String} representation of this request's body, or {@link Optional#empty()} if no request body was specified by the client
706         */
707        @NonNull
708        public Optional<String> getBodyAsString() {
709                // Lazily instantiate a string instance using double-checked locking
710                String result = this.bodyAsString;
711
712                if (this.body != null && result == null) {
713                        getLock().lock();
714
715                        try {
716                                result = this.bodyAsString;
717
718                                if (this.body != null && result == null) {
719                                        result = new String(this.body, getCharset().orElse(DEFAULT_CHARSET));
720                                        this.bodyAsString = result;
721                                }
722                        } finally {
723                                getLock().unlock();
724                        }
725                }
726
727                return Optional.ofNullable(result);
728        }
729
730        /**
731         * <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS">Non-preflight CORS</a> request data.
732         * <p>
733         * See <a href="https://www.soklet.com/docs/cors">https://www.soklet.com/docs/cors</a> for details.
734         *
735         * @return non-preflight CORS request data, or {@link Optional#empty()} if none was specified
736         */
737        @NonNull
738        public Optional<Cors> getCors() {
739                return Optional.ofNullable(this.cors);
740        }
741
742        /**
743         * <a href="https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request">CORS preflight</a>-related request data.
744         * <p>
745         * See <a href="https://www.soklet.com/docs/cors">https://www.soklet.com/docs/cors</a> for details.
746         *
747         * @return preflight CORS request data, or {@link Optional#empty()} if none was specified
748         */
749        @NonNull
750        public Optional<CorsPreflight> getCorsPreflight() {
751                return Optional.ofNullable(this.corsPreflight);
752        }
753
754        /**
755         * Locale information for this request as specified by {@code Accept-Language} header value[s] and ordered by weight as defined by <a href="https://www.rfc-editor.org/rfc/rfc7231#section-5.3.5">RFC 7231, Section 5.3.5</a>.
756         * <p>
757         * This method will lazily parse {@code Accept-Language} header values into to an ordered {@link List} of {@link Locale} when first invoked.  This representation is then cached and re-used for subsequent invocations.
758         * <p>
759         * This method is threadsafe.
760         * <p>
761         * See {@link #getLanguageRanges()} for a variant that pulls {@link LanguageRange} values.
762         *
763         * @return locale information for this request, or the empty list if none was specified
764         */
765        @NonNull
766        public List<@NonNull Locale> getLocales() {
767                // Lazily instantiate our parsed locales using double-checked locking
768                List<Locale> result = this.locales;
769
770                if (result == null) {
771                        getLock().lock();
772
773                        try {
774                                result = this.locales;
775
776                                if (result == null) {
777                                        Set<String> acceptLanguageHeaderValues = getHeaders().get("Accept-Language");
778
779                                        if (acceptLanguageHeaderValues != null && !acceptLanguageHeaderValues.isEmpty()) {
780                                                // Support data spread across multiple header lines, which spec allows
781                                                String acceptLanguageHeaderValue = acceptLanguageHeaderValues.stream()
782                                                                .filter(value -> trimAggressivelyToEmpty(value).length() > 0)
783                                                                .collect(Collectors.joining(","));
784
785                                                try {
786                                                        result = unmodifiableList(Utilities.extractLocalesFromAcceptLanguageHeaderValue(acceptLanguageHeaderValue));
787                                                } catch (Exception ignored) {
788                                                        // Malformed Accept-Language header; ignore it
789                                                        result = List.of();
790                                                }
791                                        } else {
792                                                result = List.of();
793                                        }
794
795                                        this.locales = result;
796                                }
797                        } finally {
798                                getLock().unlock();
799                        }
800                }
801
802                return result;
803        }
804
805        /**
806         * {@link LanguageRange} information for this request as specified by {@code Accept-Language} header value[s].
807         * <p>
808         * This method will lazily parse {@code Accept-Language} header values into to an ordered {@link List} of {@link LanguageRange} when first invoked.  This representation is then cached and re-used for subsequent invocations.
809         * <p>
810         * This method is threadsafe.
811         * <p>
812         * See {@link #getLocales()} for a variant that pulls {@link Locale} values.
813         *
814         * @return language range information for this request, or the empty list if none was specified
815         */
816        @NonNull
817        public List<@NonNull LanguageRange> getLanguageRanges() {
818                // Lazily instantiate our parsed language ranges using double-checked locking
819                List<LanguageRange> result = this.languageRanges;
820
821                if (result == null) {
822                        getLock().lock();
823                        try {
824                                result = this.languageRanges;
825
826                                if (result == null) {
827                                        Set<String> acceptLanguageHeaderValues = getHeaders().get("Accept-Language");
828
829                                        if (acceptLanguageHeaderValues != null && !acceptLanguageHeaderValues.isEmpty()) {
830                                                // Support data spread across multiple header lines, which spec allows
831                                                String acceptLanguageHeaderValue = acceptLanguageHeaderValues.stream()
832                                                                .filter(value -> trimAggressivelyToEmpty(value).length() > 0)
833                                                                .collect(Collectors.joining(","));
834
835                                                try {
836                                                        result = Collections.unmodifiableList(LanguageRange.parse(acceptLanguageHeaderValue));
837                                                } catch (Exception ignored) {
838                                                        // Malformed Accept-Language header; ignore it
839                                                        result = List.of();
840                                                }
841                                        } else {
842                                                result = List.of();
843                                        }
844
845                                        this.languageRanges = result;
846                                }
847                        } finally {
848                                getLock().unlock();
849                        }
850                }
851
852                return result;
853        }
854
855        /**
856         * Convenience method to access a decoded query parameter's value when at most one is expected for the given {@code name}.
857         * <p>
858         * If a query parameter {@code name} can support multiple values, {@link #getQueryParameters()} should be used instead of this method.
859         * <p>
860         * If this method is invoked for a query parameter {@code name} with multiple values, Soklet will throw {@link IllegalQueryParameterException}.
861         * <p>
862         * <em>Note that query parameters have case-sensitive names per the HTTP spec.</em>
863         *
864         * @param name the name of the query parameter
865         * @return the value for the query parameter, or {@link Optional#empty()} if none is present
866         * @throws IllegalQueryParameterException if the query parameter with the given {@code name} has multiple values
867         */
868        @NonNull
869        public Optional<String> getQueryParameter(@NonNull String name) {
870                requireNonNull(name);
871
872                try {
873                        return singleValueForName(name, getQueryParameters());
874                } catch (MultipleValuesException e) {
875                        @SuppressWarnings("unchecked")
876                        String valuesAsString = format("[%s]", ((Set<String>) e.getValues()).stream().collect(Collectors.joining(", ")));
877                        throw new IllegalQueryParameterException(format("Multiple values specified for query parameter '%s' (but expected single value): %s", name, valuesAsString), name, valuesAsString);
878                }
879        }
880
881        /**
882         * Convenience method to access a decoded form parameter's value when at most one is expected for the given {@code name}.
883         * <p>
884         * If a form parameter {@code name} can support multiple values, {@link #getFormParameters()} should be used instead of this method.
885         * <p>
886         * If this method is invoked for a form parameter {@code name} with multiple values, Soklet will throw {@link IllegalFormParameterException}.
887         * <p>
888         * <em>Note that form parameters have case-sensitive names per the HTTP spec.</em>
889         *
890         * @param name the name of the form parameter
891         * @return the value for the form parameter, or {@link Optional#empty()} if none is present
892         * @throws IllegalFormParameterException if the form parameter with the given {@code name} has multiple values
893         */
894        @NonNull
895        public Optional<String> getFormParameter(@NonNull String name) {
896                requireNonNull(name);
897
898                try {
899                        return singleValueForName(name, getFormParameters());
900                } catch (MultipleValuesException e) {
901                        @SuppressWarnings("unchecked")
902                        String valuesAsString = format("[%s]", ((Set<String>) e.getValues()).stream().collect(Collectors.joining(", ")));
903                        throw new IllegalFormParameterException(format("Multiple values specified for form parameter '%s' (but expected single value): %s", name, valuesAsString), name, valuesAsString);
904                }
905        }
906
907        /**
908         * Convenience method to access a header's value when at most one is expected for the given {@code name}.
909         * <p>
910         * If a header {@code name} can support multiple values, {@link #getHeaders()} should be used instead of this method.
911         * <p>
912         * If this method is invoked for a header {@code name} with multiple values, Soklet will throw {@link IllegalRequestHeaderException}.
913         * <p>
914         * <em>Note that request headers have case-insensitive names per the HTTP spec.</em>
915         *
916         * @param name the name of the header
917         * @return the value for the header, or {@link Optional#empty()} if none is present
918         * @throws IllegalRequestHeaderException if the header with the given {@code name} has multiple values
919         */
920        @NonNull
921        public Optional<String> getHeader(@NonNull String name) {
922                requireNonNull(name);
923
924                try {
925                        return singleValueForName(name, getHeaders());
926                } catch (MultipleValuesException e) {
927                        @SuppressWarnings("unchecked")
928                        String valuesAsString = format("[%s]", ((Set<String>) e.getValues()).stream().collect(Collectors.joining(", ")));
929                        throw new IllegalRequestHeaderException(format("Multiple values specified for request header '%s' (but expected single value): %s", name, valuesAsString), name, valuesAsString);
930                }
931        }
932
933        /**
934         * Convenience method to access a cookie's value when at most one is expected for the given {@code name}.
935         * <p>
936         * If a cookie {@code name} can support multiple values, {@link #getCookies()} should be used instead of this method.
937         * <p>
938         * If this method is invoked for a cookie {@code name} with multiple values, Soklet will throw {@link IllegalRequestCookieException}.
939         * <p>
940         * <em>Note that {@code Cookie} headers, like all request headers, have case-insensitive names per the HTTP spec.</em>
941         *
942         * @param name the name of the cookie
943         * @return the value for the cookie, or {@link Optional#empty()} if none is present
944         * @throws IllegalRequestCookieException if the cookie with the given {@code name} has multiple values
945         */
946        @NonNull
947        public Optional<String> getCookie(@NonNull String name) {
948                requireNonNull(name);
949
950                try {
951                        return singleValueForName(name, getCookies());
952                } catch (MultipleValuesException e) {
953                        @SuppressWarnings("unchecked")
954                        String valuesAsString = format("[%s]", ((Set<String>) e.getValues()).stream().collect(Collectors.joining(", ")));
955                        throw new IllegalRequestCookieException(format("Multiple values specified for request cookie '%s' (but expected single value): %s", name, valuesAsString), name, valuesAsString);
956                }
957        }
958
959        /**
960         * Convenience method to access a decoded multipart field when at most one is expected for the given {@code name}.
961         * <p>
962         * If a {@code name} can support multiple multipart fields, {@link #getMultipartFields()} should be used instead of this method.
963         * <p>
964         * If this method is invoked for a {@code name} with multiple multipart field values, Soklet will throw {@link IllegalMultipartFieldException}.
965         * <p>
966         * <em>Note that multipart fields have case-sensitive names per the HTTP spec.</em>
967         *
968         * @param name the name of the multipart field
969         * @return the multipart field value, or {@link Optional#empty()} if none is present
970         * @throws IllegalMultipartFieldException if the multipart field with the given {@code name} has multiple values
971         */
972        @NonNull
973        public Optional<MultipartField> getMultipartField(@NonNull String name) {
974                requireNonNull(name);
975
976                try {
977                        return singleValueForName(name, getMultipartFields());
978                } catch (MultipleValuesException e) {
979                        @SuppressWarnings("unchecked")
980                        MultipartField firstMultipartField = getMultipartFields().get(name).stream().findFirst().get();
981                        String valuesAsString = format("[%s]", e.getValues().stream()
982                                        .map(multipartField -> multipartField.toString())
983                                        .collect(Collectors.joining(", ")));
984
985                        throw new IllegalMultipartFieldException(format("Multiple values specified for multipart field '%s' (but expected single value): %s", name, valuesAsString), firstMultipartField);
986                }
987        }
988
989        @NonNull
990        private MultipartParser getMultipartParser() {
991                return this.multipartParser;
992        }
993
994        @NonNull
995        private IdGenerator<?> getIdGenerator() {
996                return this.idGenerator;
997        }
998
999        @NonNull
1000        private ReentrantLock getLock() {
1001                return this.lock;
1002        }
1003
1004        @NonNull
1005        private <T> Optional<T> singleValueForName(@NonNull String name,
1006                                                                                                                                                                                 @Nullable Map<String, Set<T>> valuesByName) throws MultipleValuesException {
1007                if (valuesByName == null)
1008                        return Optional.empty();
1009
1010                Set<T> values = valuesByName.get(name);
1011
1012                if (values == null)
1013                        return Optional.empty();
1014
1015                if (values.size() > 1)
1016                        throw new MultipleValuesException(name, values);
1017
1018                return values.stream().findFirst();
1019        }
1020
1021        @NotThreadSafe
1022        private static class MultipleValuesException extends Exception {
1023                @NonNull
1024                private final String name;
1025                @NonNull
1026                private final Set<?> values;
1027
1028                private MultipleValuesException(@NonNull String name,
1029                                                                                                                                                @NonNull Set<?> values) {
1030                        super(format("Expected single value but found %d values for '%s': %s", values.size(), name, values));
1031
1032                        requireNonNull(name);
1033                        requireNonNull(values);
1034
1035                        this.name = name;
1036                        this.values = Collections.unmodifiableSet(new LinkedHashSet<>(values));
1037                }
1038
1039                @NonNull
1040                public String getName() {
1041                        return this.name;
1042                }
1043
1044                @NonNull
1045                public Set<?> getValues() {
1046                        return this.values;
1047                }
1048        }
1049
1050        /**
1051         * Builder used to construct instances of {@link Request} via {@link Request#withRawUrl(HttpMethod, String)}.
1052         * <p>
1053         * This class is intended for use by a single thread.
1054         *
1055         * @author <a href="https://www.revetkn.com">Mark Allen</a>
1056         */
1057        @NotThreadSafe
1058        public static final class RawBuilder {
1059                @NonNull
1060                private HttpMethod httpMethod;
1061                @NonNull
1062                private String rawUrl;
1063                @Nullable
1064                private Object id;
1065                @Nullable
1066                private IdGenerator idGenerator;
1067                @Nullable
1068                private MultipartParser multipartParser;
1069                @Nullable
1070                private Map<@NonNull String, @NonNull Set<@NonNull String>> headers;
1071                @Nullable
1072                private InetSocketAddress remoteAddress;
1073                @Nullable
1074                private byte[] body;
1075                @Nullable
1076                private Boolean contentTooLarge;
1077
1078                protected RawBuilder(@NonNull HttpMethod httpMethod,
1079                                                                                                 @NonNull String rawUrl) {
1080                        requireNonNull(httpMethod);
1081                        requireNonNull(rawUrl);
1082
1083                        this.httpMethod = httpMethod;
1084                        this.rawUrl = rawUrl;
1085                }
1086
1087                @NonNull
1088                public RawBuilder httpMethod(@NonNull HttpMethod httpMethod) {
1089                        requireNonNull(httpMethod);
1090                        this.httpMethod = httpMethod;
1091                        return this;
1092                }
1093
1094                @NonNull
1095                public RawBuilder rawUrl(@NonNull String rawUrl) {
1096                        requireNonNull(rawUrl);
1097                        this.rawUrl = rawUrl;
1098                        return this;
1099                }
1100
1101                @NonNull
1102                public RawBuilder id(@Nullable Object id) {
1103                        this.id = id;
1104                        return this;
1105                }
1106
1107                @NonNull
1108                public RawBuilder idGenerator(@Nullable IdGenerator idGenerator) {
1109                        this.idGenerator = idGenerator;
1110                        return this;
1111                }
1112
1113                @NonNull
1114                public RawBuilder multipartParser(@Nullable MultipartParser multipartParser) {
1115                        this.multipartParser = multipartParser;
1116                        return this;
1117                }
1118
1119                @NonNull
1120                public RawBuilder headers(@Nullable Map<@NonNull String, @NonNull Set<@NonNull String>> headers) {
1121                        this.headers = headers;
1122                        return this;
1123                }
1124
1125                @NonNull
1126                public RawBuilder remoteAddress(@Nullable InetSocketAddress remoteAddress) {
1127                        this.remoteAddress = remoteAddress;
1128                        return this;
1129                }
1130
1131                @NonNull
1132                public RawBuilder body(@Nullable byte[] body) {
1133                        this.body = body;
1134                        return this;
1135                }
1136
1137                @NonNull
1138                public RawBuilder contentTooLarge(@Nullable Boolean contentTooLarge) {
1139                        this.contentTooLarge = contentTooLarge;
1140                        return this;
1141                }
1142
1143                @NonNull
1144                public Request build() {
1145                        return new Request(this, null);
1146                }
1147        }
1148
1149        /**
1150         * Builder used to construct instances of {@link Request} via {@link Request#withPath(HttpMethod, String)}.
1151         * <p>
1152         * This class is intended for use by a single thread.
1153         *
1154         * @author <a href="https://www.revetkn.com">Mark Allen</a>
1155         */
1156        @NotThreadSafe
1157        public static final class PathBuilder {
1158                @NonNull
1159                private HttpMethod httpMethod;
1160                @NonNull
1161                private String path;
1162                @Nullable
1163                private String rawPath;
1164                @Nullable
1165                private String rawQuery;
1166                @Nullable
1167                private Object id;
1168                @Nullable
1169                private IdGenerator idGenerator;
1170                @Nullable
1171                private MultipartParser multipartParser;
1172                @Nullable
1173                private Map<@NonNull String, @NonNull Set<@NonNull String>> queryParameters;
1174                @Nullable
1175                private Map<@NonNull String, @NonNull Set<@NonNull String>> headers;
1176                @Nullable
1177                private InetSocketAddress remoteAddress;
1178                @Nullable
1179                private byte[] body;
1180                @Nullable
1181                private Boolean contentTooLarge;
1182
1183                protected PathBuilder(@NonNull HttpMethod httpMethod,
1184                                                                                                        @NonNull String path) {
1185                        requireNonNull(httpMethod);
1186                        requireNonNull(path);
1187
1188                        this.httpMethod = httpMethod;
1189                        this.path = path;
1190                }
1191
1192                @NonNull
1193                public PathBuilder httpMethod(@NonNull HttpMethod httpMethod) {
1194                        requireNonNull(httpMethod);
1195                        this.httpMethod = httpMethod;
1196                        return this;
1197                }
1198
1199                @NonNull
1200                public PathBuilder path(@NonNull String path) {
1201                        requireNonNull(path);
1202                        this.path = path;
1203                        return this;
1204                }
1205
1206                // Package-private setter for raw value (used by Copier)
1207                @NonNull
1208                PathBuilder rawPath(@Nullable String rawPath) {
1209                        this.rawPath = rawPath;
1210                        return this;
1211                }
1212
1213                // Package-private setter for raw value (used by Copier)
1214                @NonNull
1215                PathBuilder rawQuery(@Nullable String rawQuery) {
1216                        this.rawQuery = rawQuery;
1217                        return this;
1218                }
1219
1220                @NonNull
1221                public PathBuilder id(@Nullable Object id) {
1222                        this.id = id;
1223                        return this;
1224                }
1225
1226                @NonNull
1227                public PathBuilder idGenerator(@Nullable IdGenerator idGenerator) {
1228                        this.idGenerator = idGenerator;
1229                        return this;
1230                }
1231
1232                @NonNull
1233                public PathBuilder multipartParser(@Nullable MultipartParser multipartParser) {
1234                        this.multipartParser = multipartParser;
1235                        return this;
1236                }
1237
1238                @NonNull
1239                public PathBuilder queryParameters(@Nullable Map<@NonNull String, @NonNull Set<@NonNull String>> queryParameters) {
1240                        this.queryParameters = queryParameters;
1241                        return this;
1242                }
1243
1244                @NonNull
1245                public PathBuilder headers(@Nullable Map<@NonNull String, @NonNull Set<@NonNull String>> headers) {
1246                        this.headers = headers;
1247                        return this;
1248                }
1249
1250                @NonNull
1251                public PathBuilder remoteAddress(@Nullable InetSocketAddress remoteAddress) {
1252                        this.remoteAddress = remoteAddress;
1253                        return this;
1254                }
1255
1256                @NonNull
1257                public PathBuilder body(@Nullable byte[] body) {
1258                        this.body = body;
1259                        return this;
1260                }
1261
1262                @NonNull
1263                public PathBuilder contentTooLarge(@Nullable Boolean contentTooLarge) {
1264                        this.contentTooLarge = contentTooLarge;
1265                        return this;
1266                }
1267
1268                @NonNull
1269                public Request build() {
1270                        return new Request(null, this);
1271                }
1272        }
1273
1274        /**
1275         * Builder used to copy instances of {@link Request} via {@link Request#copy()}.
1276         * <p>
1277         * This class is intended for use by a single thread.
1278         *
1279         * @author <a href="https://www.revetkn.com">Mark Allen</a>
1280         */
1281        @NotThreadSafe
1282        public static final class Copier {
1283                @NonNull
1284                private final PathBuilder builder;
1285
1286                // Track original raw values and modification state
1287                @Nullable
1288                private String originalRawPath;
1289                @Nullable
1290                private String originalRawQuery;
1291                @Nullable
1292                private InetSocketAddress originalRemoteAddress;
1293                @NonNull
1294                private Boolean pathModified = false;
1295                @NonNull
1296                private Boolean queryParametersModified = false;
1297
1298                Copier(@NonNull Request request) {
1299                        requireNonNull(request);
1300
1301                        this.originalRawPath = request.getRawPath();
1302                        this.originalRawQuery = request.rawQuery; // Direct field access
1303                        this.originalRemoteAddress = request.getRemoteAddress().orElse(null);
1304
1305                        this.builder = new PathBuilder(request.getHttpMethod(), request.getPath())
1306                                        .id(request.getId())
1307                                        .queryParameters(new LinkedHashMap<>(request.getQueryParameters()))
1308                                        .headers(new LinkedCaseInsensitiveMap<>(request.getHeaders()))
1309                                        .body(request.body) // Direct field access to avoid array copy
1310                                        .multipartParser(request.getMultipartParser())
1311                                        .idGenerator(request.getIdGenerator())
1312                                        .contentTooLarge(request.isContentTooLarge())
1313                                        .remoteAddress(this.originalRemoteAddress)
1314                                        // Preserve original raw values initially
1315                                        .rawPath(this.originalRawPath)
1316                                        .rawQuery(this.originalRawQuery);
1317                }
1318
1319                @NonNull
1320                public Copier httpMethod(@NonNull HttpMethod httpMethod) {
1321                        requireNonNull(httpMethod);
1322                        this.builder.httpMethod(httpMethod);
1323                        return this;
1324                }
1325
1326                @NonNull
1327                public Copier path(@NonNull String path) {
1328                        requireNonNull(path);
1329                        this.builder.path(path);
1330                        this.pathModified = true;
1331                        // Clear preserved raw path since decoded path changed
1332                        this.builder.rawPath(null);
1333                        return this;
1334                }
1335
1336                @NonNull
1337                public Copier id(@Nullable Object id) {
1338                        this.builder.id(id);
1339                        return this;
1340                }
1341
1342                @NonNull
1343                public Copier queryParameters(@Nullable Map<@NonNull String, @NonNull Set<@NonNull String>> queryParameters) {
1344                        this.builder.queryParameters(queryParameters);
1345                        this.queryParametersModified = true;
1346                        // Clear preserved raw query since decoded query parameters changed
1347                        this.builder.rawQuery(null);
1348                        return this;
1349                }
1350
1351                // Convenience method for mutation
1352                @NonNull
1353                public Copier queryParameters(@NonNull Consumer<Map<@NonNull String, @NonNull Set<@NonNull String>>> queryParametersConsumer) {
1354                        requireNonNull(queryParametersConsumer);
1355
1356                        if (this.builder.queryParameters == null)
1357                                this.builder.queryParameters(new LinkedHashMap<>());
1358
1359                        queryParametersConsumer.accept(this.builder.queryParameters);
1360                        this.queryParametersModified = true;
1361                        // Clear preserved raw query since decoded query parameters changed
1362                        this.builder.rawQuery(null);
1363                        return this;
1364                }
1365
1366                @NonNull
1367                public Copier headers(@Nullable Map<@NonNull String, @NonNull Set<@NonNull String>> headers) {
1368                        this.builder.headers(headers);
1369                        return this;
1370                }
1371
1372                @NonNull
1373                public Copier remoteAddress(@Nullable InetSocketAddress remoteAddress) {
1374                        this.builder.remoteAddress(remoteAddress);
1375                        return this;
1376                }
1377
1378                // Convenience method for mutation
1379                @NonNull
1380                public Copier headers(@NonNull Consumer<Map<@NonNull String, @NonNull Set<@NonNull String>>> headersConsumer) {
1381                        requireNonNull(headersConsumer);
1382
1383                        if (this.builder.headers == null)
1384                                this.builder.headers(new LinkedCaseInsensitiveMap<>());
1385
1386                        headersConsumer.accept(this.builder.headers);
1387                        return this;
1388                }
1389
1390                @NonNull
1391                public Copier body(@Nullable byte[] body) {
1392                        this.builder.body(body);
1393                        return this;
1394                }
1395
1396                @NonNull
1397                public Copier contentTooLarge(@Nullable Boolean contentTooLarge) {
1398                        this.builder.contentTooLarge(contentTooLarge);
1399                        return this;
1400                }
1401
1402                @NonNull
1403                public Request finish() {
1404                        if (this.queryParametersModified) {
1405                                Map<String, Set<String>> queryParameters = this.builder.queryParameters;
1406
1407                                if (queryParameters == null || queryParameters.isEmpty()) {
1408                                        this.builder.rawQuery(null);
1409                                } else {
1410                                        this.builder.rawQuery(Utilities.encodeQueryParameters(queryParameters, QueryFormat.RFC_3986_STRICT));
1411                                }
1412                        }
1413
1414                        return this.builder.build();
1415                }
1416        }
1417}