001/*
002 * Copyright 2024-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.servlet.javax;
018
019import com.soklet.MarshaledResponse;
020import com.soklet.Request;
021import com.soklet.Response;
022import com.soklet.ResponseCookie;
023import com.soklet.StatusCode;
024import org.jspecify.annotations.NonNull;
025import org.jspecify.annotations.Nullable;
026
027import javax.annotation.concurrent.NotThreadSafe;
028import javax.servlet.ServletContext;
029import javax.servlet.ServletOutputStream;
030import javax.servlet.http.Cookie;
031import javax.servlet.http.HttpServletRequest;
032import javax.servlet.http.HttpServletResponse;
033import java.io.ByteArrayOutputStream;
034import java.io.IOException;
035import java.io.OutputStreamWriter;
036import java.io.PrintWriter;
037import java.net.IDN;
038import java.net.URI;
039import java.net.URISyntaxException;
040import java.nio.charset.Charset;
041import java.nio.charset.IllegalCharsetNameException;
042import java.nio.charset.StandardCharsets;
043import java.nio.charset.UnsupportedCharsetException;
044import java.time.Duration;
045import java.time.Instant;
046import java.time.ZoneId;
047import java.time.format.DateTimeFormatter;
048import java.util.ArrayList;
049import java.util.Collection;
050import java.util.Collections;
051import java.util.LinkedHashMap;
052import java.util.LinkedHashSet;
053import java.util.List;
054import java.util.Locale;
055import java.util.Map;
056import java.util.Optional;
057import java.util.Set;
058import java.util.TreeMap;
059import java.util.stream.Collectors;
060
061import static java.lang.String.format;
062import static java.util.Objects.requireNonNull;
063
064/**
065 * Soklet integration implementation of {@link HttpServletResponse}.
066 *
067 * @author <a href="https://www.revetkn.com">Mark Allen</a>
068 */
069@NotThreadSafe
070public final class SokletHttpServletResponse implements HttpServletResponse {
071        @NonNull
072        private static final Integer DEFAULT_RESPONSE_BUFFER_SIZE_IN_BYTES;
073        @NonNull
074        private static final Charset DEFAULT_CHARSET;
075        @NonNull
076        private static final DateTimeFormatter DATE_TIME_FORMATTER;
077
078        static {
079                DEFAULT_RESPONSE_BUFFER_SIZE_IN_BYTES = 1_024;
080                DEFAULT_CHARSET = StandardCharsets.ISO_8859_1; // Per Servlet spec
081                DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss zzz")
082                                .withLocale(Locale.US)
083                                .withZone(ZoneId.of("GMT"));
084        }
085
086        @NonNull
087        private final String rawPath; // Raw path (no query), e.g. "/test/abc" or "*"
088        @Nullable
089        private final HttpServletRequest httpServletRequest;
090        @NonNull
091        private final ServletContext servletContext;
092        @NonNull
093        private final List<@NonNull Cookie> cookies;
094        @NonNull
095        private final Map<@NonNull String, @NonNull List<@NonNull String>> headers;
096        @NonNull
097        private ByteArrayOutputStream responseOutputStream;
098        @NonNull
099        private ResponseWriteMethod responseWriteMethod;
100        @NonNull
101        private Integer statusCode;
102        @NonNull
103        private Boolean responseCommitted;
104        @NonNull
105        private Boolean responseFinalized;
106        @Nullable
107        private Locale locale;
108        @Nullable
109        private String errorMessage;
110        @Nullable
111        private String redirectUrl;
112        @Nullable
113        private Charset charset;
114        @Nullable
115        private String contentType;
116        @NonNull
117        private Integer responseBufferSizeInBytes;
118        @Nullable
119        private SokletServletOutputStream servletOutputStream;
120        @Nullable
121        private SokletServletPrintWriter printWriter;
122
123        @NonNull
124        public static SokletHttpServletResponse fromRequest(@NonNull HttpServletRequest request) {
125                requireNonNull(request);
126                String rawPath = request.getRequestURI();
127                if (rawPath == null || rawPath.isEmpty())
128                        rawPath = "/";
129                ServletContext servletContext = requireNonNull(request.getServletContext());
130                return new SokletHttpServletResponse(request, rawPath, servletContext);
131        }
132
133        @NonNull
134        public static SokletHttpServletResponse fromRequest(@NonNull Request request,
135                                                                                                                                                                                                                        @NonNull ServletContext servletContext) {
136                requireNonNull(request);
137                requireNonNull(servletContext);
138                HttpServletRequest httpServletRequest = SokletHttpServletRequest.withRequest(request)
139                                .servletContext(servletContext)
140                                .build();
141                return fromRequest(httpServletRequest);
142        }
143
144        /**
145         * Creates a response bound to Soklet's raw path construct.
146         * <p>
147         * This is the exact path component sent by the client, without URL decoding and without a query string
148         * (for example, {@code "/a%20b/c"}). It corresponds to {@link Request#getRawPath()}.
149         *
150         * @param rawPath raw path component of the request (no query string)
151         * @param servletContext servlet context for this response
152         * @return a response bound to the raw request path
153         */
154        @NonNull
155        public static SokletHttpServletResponse fromRawPath(@NonNull String rawPath,
156                                                                                                                                                                                                                        @NonNull ServletContext servletContext) {
157                requireNonNull(rawPath);
158                requireNonNull(servletContext);
159                return new SokletHttpServletResponse(null, rawPath, servletContext);
160        }
161
162        private SokletHttpServletResponse(@Nullable HttpServletRequest httpServletRequest,
163                                                                                                                                                @NonNull String rawPath,
164                                                                                                                                                @NonNull ServletContext servletContext) {
165                requireNonNull(rawPath);
166                requireNonNull(servletContext);
167
168                this.httpServletRequest = httpServletRequest;
169                this.rawPath = rawPath;
170                this.servletContext = servletContext;
171                this.statusCode = HttpServletResponse.SC_OK;
172                this.responseWriteMethod = ResponseWriteMethod.UNSPECIFIED;
173                this.responseBufferSizeInBytes = DEFAULT_RESPONSE_BUFFER_SIZE_IN_BYTES;
174                this.responseOutputStream = new ByteArrayOutputStream(DEFAULT_RESPONSE_BUFFER_SIZE_IN_BYTES);
175                this.cookies = new ArrayList<>();
176                this.headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
177                this.responseCommitted = false;
178                this.responseFinalized = false;
179        }
180
181        @NonNull
182        public Response toResponse() {
183                MarshaledResponse marshaledResponse = toMarshaledResponse();
184
185                return Response.withStatusCode(marshaledResponse.getStatusCode())
186                                .body(getResponseOutputStream().toByteArray())
187                                .headers(marshaledResponse.getHeaders())
188                                .cookies(marshaledResponse.getCookies())
189                                .build();
190        }
191
192        @NonNull
193        public MarshaledResponse toMarshaledResponse() {
194                byte[] body = getResponseOutputStream().toByteArray();
195
196                Map<@NonNull String, @NonNull Set<@NonNull String>> headers = getHeaders().entrySet().stream()
197                                .collect(Collectors.toMap(
198                                                Map.Entry::getKey,
199                                                entry -> new LinkedHashSet<>(entry.getValue()),
200                                                (left, right) -> {
201                                                        left.addAll(right);
202                                                        return left;
203                                                },
204                                                LinkedHashMap::new
205                                ));
206
207                Set<@NonNull ResponseCookie> cookies = getCookies().stream()
208                                .map(cookie -> {
209                                        ResponseCookie.Builder builder = ResponseCookie.with(cookie.getName(), cookie.getValue())
210                                                        .path(cookie.getPath())
211                                                        .secure(cookie.getSecure())
212                                                        .httpOnly(cookie.isHttpOnly())
213                                                        .domain(cookie.getDomain());
214
215                                        if (cookie.getMaxAge() >= 0)
216                                                builder.maxAge(Duration.ofSeconds(cookie.getMaxAge()));
217
218                                        return builder.build();
219                                })
220                                .collect(Collectors.toSet());
221
222                return MarshaledResponse.withStatusCode(getStatus())
223                                .body(body)
224                                .headers(headers)
225                                .cookies(cookies)
226                                .build();
227        }
228
229        @NonNull
230        private String getRawPath() {
231                return this.rawPath;
232        }
233
234        @NonNull
235        private Optional<HttpServletRequest> getHttpServletRequest() {
236                return Optional.ofNullable(this.httpServletRequest);
237        }
238
239        @NonNull
240        private ServletContext getServletContext() {
241                return this.servletContext;
242        }
243
244        @NonNull
245        private List<@NonNull Cookie> getCookies() {
246                return this.cookies;
247        }
248
249        @NonNull
250        private Map<@NonNull String, @NonNull List<@NonNull String>> getHeaders() {
251                return this.headers;
252        }
253
254        @NonNull
255        private List<@NonNull String> getSetCookieHeaderValues() {
256                if (getCookies().isEmpty())
257                        return List.of();
258
259                List<@NonNull String> values = new ArrayList<>(getCookies().size());
260
261                for (Cookie cookie : getCookies())
262                        values.add(toSetCookieHeaderValue(cookie));
263
264                return values;
265        }
266
267        @NonNull
268        private String toSetCookieHeaderValue(@NonNull Cookie cookie) {
269                requireNonNull(cookie);
270
271                ResponseCookie.Builder builder = ResponseCookie.with(cookie.getName(), cookie.getValue())
272                                .path(cookie.getPath())
273                                .secure(cookie.getSecure())
274                                .httpOnly(cookie.isHttpOnly())
275                                .domain(cookie.getDomain());
276
277                if (cookie.getMaxAge() >= 0)
278                        builder.maxAge(Duration.ofSeconds(cookie.getMaxAge()));
279
280                return builder.build().toSetCookieHeaderRepresentation();
281        }
282
283        private void putHeaderValue(@NonNull String name,
284                                                                                                                        @NonNull String value,
285                                                                                                                        boolean replace) {
286                requireNonNull(name);
287                requireNonNull(value);
288
289                if (replace) {
290                        List<@NonNull String> values = new ArrayList<>();
291                        values.add(value);
292                        getHeaders().put(name, values);
293                } else {
294                        getHeaders().computeIfAbsent(name, k -> new ArrayList<>()).add(value);
295                }
296        }
297
298        @NonNull
299        private Integer getStatusCode() {
300                return this.statusCode;
301        }
302
303        private void setStatusCode(@NonNull Integer statusCode) {
304                requireNonNull(statusCode);
305                this.statusCode = statusCode;
306        }
307
308        @NonNull
309        private Optional<String> getErrorMessage() {
310                return Optional.ofNullable(this.errorMessage);
311        }
312
313        private void setErrorMessage(@Nullable String errorMessage) {
314                this.errorMessage = errorMessage;
315        }
316
317        @NonNull
318        private Optional<String> getRedirectUrl() {
319                return Optional.ofNullable(this.redirectUrl);
320        }
321
322        private void setRedirectUrl(@Nullable String redirectUrl) {
323                this.redirectUrl = redirectUrl;
324        }
325
326        @NonNull
327        private Optional<Charset> getCharset() {
328                return Optional.ofNullable(this.charset);
329        }
330
331        @Nullable
332        private Charset getContextResponseCharset() {
333                String encoding = getServletContext().getResponseCharacterEncoding();
334
335                if (encoding == null || encoding.isBlank())
336                        return null;
337
338                try {
339                        return Charset.forName(encoding);
340                } catch (IllegalCharsetNameException | UnsupportedCharsetException e) {
341                        return null;
342                }
343        }
344
345        @NonNull
346        private Charset getEffectiveCharset() {
347                Charset explicit = this.charset;
348
349                if (explicit != null)
350                        return explicit;
351
352                Charset context = getContextResponseCharset();
353                return context == null ? DEFAULT_CHARSET : context;
354        }
355
356        private void setCharset(@Nullable Charset charset) {
357                this.charset = charset;
358        }
359
360        @NonNull
361        private Boolean getResponseCommitted() {
362                return this.responseCommitted;
363        }
364
365        private void setResponseCommitted(@NonNull Boolean responseCommitted) {
366                requireNonNull(responseCommitted);
367                this.responseCommitted = responseCommitted;
368        }
369
370        @NonNull
371        private Boolean getResponseFinalized() {
372                return this.responseFinalized;
373        }
374
375        private void setResponseFinalized(@NonNull Boolean responseFinalized) {
376                requireNonNull(responseFinalized);
377                this.responseFinalized = responseFinalized;
378        }
379
380        private void writeDefaultErrorBody(int statusCode,
381                                                                                                                                                 @Nullable String message) {
382                if (getResponseOutputStream().size() > 0)
383                        return;
384
385                String payload = message;
386
387                if (payload == null || payload.isBlank())
388                        payload = StatusCode.fromStatusCode(statusCode)
389                                        .map(StatusCode::getReasonPhrase)
390                                        .orElse("Error");
391
392                if (payload.isBlank())
393                        return;
394
395                Charset charset = getEffectiveCharset();
396                byte[] bytes = payload.getBytes(charset);
397                getResponseOutputStream().write(bytes, 0, bytes.length);
398
399                String currentContentType = getContentType();
400
401                if (currentContentType == null || currentContentType.isBlank())
402                        setContentType("text/plain; charset=" + charset.name());
403        }
404
405        private void maybeCommitOnWrite() {
406                if (!getResponseCommitted() && getResponseOutputStream().size() >= getResponseBufferSizeInBytes())
407                        setResponseCommitted(true);
408        }
409
410        private void ensureResponseIsUncommitted() {
411                if (getResponseCommitted())
412                        throw new IllegalStateException("Response has already been committed.");
413        }
414
415        @NonNull
416        private String dateHeaderRepresentation(@NonNull Long millisSinceEpoch) {
417                requireNonNull(millisSinceEpoch);
418                return DATE_TIME_FORMATTER.format(Instant.ofEpochMilli(millisSinceEpoch));
419        }
420
421        @NonNull
422        private Optional<SokletServletOutputStream> getServletOutputStream() {
423                return Optional.ofNullable(this.servletOutputStream);
424        }
425
426        private void setServletOutputStream(@Nullable SokletServletOutputStream servletOutputStream) {
427                this.servletOutputStream = servletOutputStream;
428        }
429
430        @NonNull
431        private Optional<SokletServletPrintWriter> getPrintWriter() {
432                return Optional.ofNullable(this.printWriter);
433        }
434
435        public void setPrintWriter(@Nullable SokletServletPrintWriter printWriter) {
436                this.printWriter = printWriter;
437        }
438
439        @NonNull
440        private ByteArrayOutputStream getResponseOutputStream() {
441                return this.responseOutputStream;
442        }
443
444        private void setResponseOutputStream(@NonNull ByteArrayOutputStream responseOutputStream) {
445                requireNonNull(responseOutputStream);
446                this.responseOutputStream = responseOutputStream;
447        }
448
449        @NonNull
450        private Integer getResponseBufferSizeInBytes() {
451                return this.responseBufferSizeInBytes;
452        }
453
454        private void setResponseBufferSizeInBytes(@NonNull Integer responseBufferSizeInBytes) {
455                requireNonNull(responseBufferSizeInBytes);
456                this.responseBufferSizeInBytes = responseBufferSizeInBytes;
457        }
458
459        @NonNull
460        private ResponseWriteMethod getResponseWriteMethod() {
461                return this.responseWriteMethod;
462        }
463
464        private void setResponseWriteMethod(@NonNull ResponseWriteMethod responseWriteMethod) {
465                requireNonNull(responseWriteMethod);
466                this.responseWriteMethod = responseWriteMethod;
467        }
468
469        private enum ResponseWriteMethod {
470                UNSPECIFIED,
471                SERVLET_OUTPUT_STREAM,
472                PRINT_WRITER
473        }
474
475        // Implementation of HttpServletResponse methods below:
476
477        @Override
478        public void addCookie(@Nullable Cookie cookie) {
479                if (isCommitted())
480                        return;
481
482                if (cookie != null)
483                        getCookies().add(cookie);
484        }
485
486        @Override
487        public boolean containsHeader(@Nullable String name) {
488                if (name == null)
489                        return false;
490
491                if ("Set-Cookie".equalsIgnoreCase(name))
492                        return !getCookies().isEmpty() || getHeaders().containsKey(name);
493
494                return getHeaders().containsKey(name);
495        }
496
497        @Override
498        @Nullable
499        public String encodeURL(@Nullable String url) {
500                return url;
501        }
502
503        @Override
504        @Nullable
505        public String encodeRedirectURL(@Nullable String url) {
506                return url;
507        }
508
509        @Override
510        @Deprecated
511        public String encodeUrl(@Nullable String url) {
512                return url;
513        }
514
515        @Override
516        @Deprecated
517        public String encodeRedirectUrl(@Nullable String url) {
518                return url;
519        }
520
521        @Override
522        public void sendError(int sc,
523                                                                                                @Nullable String msg) throws IOException {
524                ensureResponseIsUncommitted();
525                resetBuffer();
526                setStatus(sc);
527                setErrorMessage(msg);
528                writeDefaultErrorBody(sc, msg);
529                setResponseCommitted(true);
530        }
531
532        @Override
533        public void sendError(int sc) throws IOException {
534                ensureResponseIsUncommitted();
535                resetBuffer();
536                setStatus(sc);
537                setErrorMessage(null);
538                writeDefaultErrorBody(sc, null);
539                setResponseCommitted(true);
540        }
541
542        @NonNull
543        private String getRedirectBaseUrl() {
544                HttpServletRequest httpServletRequest = getHttpServletRequest().orElse(null);
545
546                if (httpServletRequest == null)
547                        return "http://localhost";
548
549                String scheme = httpServletRequest.getScheme();
550                if (scheme == null || scheme.isBlank())
551                        scheme = "http";
552                String host = httpServletRequest.getServerName();
553                if (host == null || host.isBlank())
554                        host = "localhost";
555                host = normalizeHostForLocation(host);
556                int port = httpServletRequest.getServerPort();
557                boolean defaultPort = port <= 0 || ("https".equalsIgnoreCase(scheme) && port == 443) || ("http".equalsIgnoreCase(scheme) && port == 80);
558                String authorityHost = host;
559
560                if (host != null && host.indexOf(':') >= 0 && !host.startsWith("[") && !host.endsWith("]"))
561                        authorityHost = "[" + host + "]";
562
563                String authority = defaultPort ? authorityHost : format("%s:%d", authorityHost, port);
564                validateAuthority(scheme, authority);
565                return format("%s://%s", scheme, authority);
566        }
567
568        @Nullable
569        private String getRawQuery() {
570                HttpServletRequest httpServletRequest = getHttpServletRequest().orElse(null);
571
572                if (httpServletRequest == null)
573                        return null;
574
575                String rawQuery = httpServletRequest.getQueryString();
576                return rawQuery == null || rawQuery.isEmpty() ? null : rawQuery;
577        }
578
579        private static final class ParsedLocation {
580                @Nullable
581                private final String scheme;
582                @Nullable
583                private final String rawAuthority;
584                @NonNull
585                private final String rawPath;
586                @Nullable
587                private final String rawQuery;
588                @Nullable
589                private final String rawFragment;
590                private final boolean opaque;
591
592                private ParsedLocation(@Nullable String scheme,
593                                                                                                         @Nullable String rawAuthority,
594                                                                                                         @NonNull String rawPath,
595                                                                                                         @Nullable String rawQuery,
596                                                                                                         @Nullable String rawFragment,
597                                                                                                         boolean opaque) {
598                        this.scheme = scheme;
599                        this.rawAuthority = rawAuthority;
600                        this.rawPath = rawPath;
601                        this.rawQuery = rawQuery;
602                        this.rawFragment = rawFragment;
603                        this.opaque = opaque;
604                }
605        }
606
607        private static final class ParsedPath {
608                @NonNull
609                private final String rawPath;
610                @Nullable
611                private final String rawQuery;
612                @Nullable
613                private final String rawFragment;
614
615                private ParsedPath(@NonNull String rawPath,
616                                                                                         @Nullable String rawQuery,
617                                                                                         @Nullable String rawFragment) {
618                        this.rawPath = rawPath;
619                        this.rawQuery = rawQuery;
620                        this.rawFragment = rawFragment;
621                }
622        }
623
624        @NonNull
625        private ParsedPath parsePathAndSuffix(@NonNull String rawPath) {
626                String path = rawPath;
627                String rawQuery = null;
628                String rawFragment = null;
629
630                int hash = path.indexOf('#');
631                if (hash >= 0) {
632                        rawFragment = path.substring(hash + 1);
633                        path = path.substring(0, hash);
634                }
635
636                int question = path.indexOf('?');
637                if (question >= 0) {
638                        rawQuery = path.substring(question + 1);
639                        path = path.substring(0, question);
640                }
641
642                return new ParsedPath(path, rawQuery, rawFragment);
643        }
644
645        private boolean isAsciiAlpha(char c) {
646                return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');
647        }
648
649        private boolean isAsciiDigit(char c) {
650                return c >= '0' && c <= '9';
651        }
652
653        private boolean isSchemeChar(char c) {
654                return isAsciiAlpha(c) || isAsciiDigit(c) || c == '+' || c == '-' || c == '.';
655        }
656
657        private boolean isValidScheme(@NonNull String scheme) {
658                if (scheme.isEmpty())
659                        return false;
660
661                if (!isAsciiAlpha(scheme.charAt(0)))
662                        return false;
663
664                for (int i = 1; i < scheme.length(); i++) {
665                        if (!isSchemeChar(scheme.charAt(i)))
666                                return false;
667                }
668
669                return true;
670        }
671
672        private boolean containsNonAscii(@NonNull String value) {
673                for (int i = 0; i < value.length(); i++) {
674                        if (value.charAt(i) > 0x7F)
675                                return true;
676                }
677
678                return false;
679        }
680
681        @NonNull
682        private String normalizeHostForLocation(@NonNull String host) {
683                requireNonNull(host);
684                String normalized = host.trim();
685
686                if (normalized.isEmpty())
687                        throw new IllegalArgumentException("Redirect host is invalid");
688
689                if (normalized.startsWith("[") && normalized.endsWith("]"))
690                        return normalized;
691
692                if (normalized.indexOf(':') >= 0)
693                        return normalized;
694
695                if (containsNonAscii(normalized)) {
696                        try {
697                                normalized = IDN.toASCII(normalized);
698                        } catch (IllegalArgumentException e) {
699                                throw new IllegalArgumentException("Redirect host is invalid", e);
700                        }
701                }
702
703                return normalized;
704        }
705
706        private int countColons(@NonNull String value) {
707                int count = 0;
708
709                for (int i = 0; i < value.length(); i++) {
710                        if (value.charAt(i) == ':')
711                                count++;
712                }
713
714                return count;
715        }
716
717        @Nullable
718        private String normalizeAuthority(@NonNull String scheme,
719                                                                                                                                                @Nullable String rawAuthority) {
720                requireNonNull(scheme);
721
722                if (rawAuthority == null || rawAuthority.isBlank())
723                        return null;
724
725                String authority = rawAuthority.trim();
726                String userInfo = null;
727                String hostPort = authority;
728                int at = authority.lastIndexOf('@');
729
730                if (at >= 0) {
731                        userInfo = authority.substring(0, at);
732                        hostPort = authority.substring(at + 1);
733                }
734
735                String normalizedHostPort;
736
737                if (hostPort.startsWith("[")) {
738                        int close = hostPort.indexOf(']');
739                        if (close < 0)
740                                throw new IllegalArgumentException("Redirect location is invalid");
741
742                        normalizedHostPort = hostPort;
743                } else {
744                        int colonCount = countColons(hostPort);
745                        String host = hostPort;
746                        String port = null;
747
748                        if (colonCount > 1) {
749                                host = hostPort;
750                        } else if (colonCount == 1) {
751                                int colon = hostPort.lastIndexOf(':');
752
753                                if (colon <= 0 || colon == hostPort.length() - 1)
754                                        throw new IllegalArgumentException("Redirect location is invalid");
755
756                                String portCandidate = hostPort.substring(colon + 1);
757                                boolean allDigits = true;
758
759                                for (int i = 0; i < portCandidate.length(); i++) {
760                                        if (!isAsciiDigit(portCandidate.charAt(i))) {
761                                                allDigits = false;
762                                                break;
763                                        }
764                                }
765
766                                if (!allDigits)
767                                        throw new IllegalArgumentException("Redirect location is invalid");
768
769                                host = hostPort.substring(0, colon);
770                                port = portCandidate;
771                        }
772
773                        String normalizedHost = normalizeHostForLocation(host);
774
775                        if (normalizedHost.indexOf(':') >= 0 && !normalizedHost.startsWith("["))
776                                normalizedHost = "[" + normalizedHost + "]";
777
778                        normalizedHostPort = port == null ? normalizedHost : normalizedHost + ":" + port;
779                }
780
781                String normalized = userInfo == null ? normalizedHostPort : userInfo + "@" + normalizedHostPort;
782                validateAuthority(scheme, normalized);
783                return normalized;
784        }
785
786        private void validateAuthority(@NonNull String scheme,
787                                                                                                                                 @Nullable String authority) {
788                requireNonNull(scheme);
789
790                try {
791                        new URI(scheme, authority, null, null, null);
792                } catch (URISyntaxException e) {
793                        throw new IllegalArgumentException("Redirect location is invalid", e);
794                }
795        }
796
797        private boolean isUnreserved(char c) {
798                return isAsciiAlpha(c) || isAsciiDigit(c) || c == '-' || c == '.' || c == '_' || c == '~';
799        }
800
801        private boolean isSubDelim(char c) {
802                return c == '!' || c == '$' || c == '&' || c == '\'' || c == '(' || c == ')'
803                                || c == '*' || c == '+' || c == ',' || c == ';' || c == '=';
804        }
805
806        private boolean isPchar(char c) {
807                return isUnreserved(c) || isSubDelim(c) || c == ':' || c == '@';
808        }
809
810        private boolean isAllowedInPath(char c) {
811                return isPchar(c) || c == '/';
812        }
813
814        private boolean isAllowedInQueryOrFragment(char c) {
815                return isPchar(c) || c == '/' || c == '?';
816        }
817
818        private boolean isHexDigit(char c) {
819                return (c >= '0' && c <= '9')
820                                || (c >= 'A' && c <= 'F')
821                                || (c >= 'a' && c <= 'f');
822        }
823
824        @NonNull
825        private String encodePreservingEscapes(@NonNull String input,
826                                                                                                                                                                 boolean allowQueryOrFragmentChars) {
827                requireNonNull(input);
828
829                StringBuilder out = new StringBuilder(input.length());
830                int length = input.length();
831
832                for (int i = 0; i < length; ) {
833                        char c = input.charAt(i);
834
835                        if (c == '%' && i + 2 < length
836                                        && isHexDigit(input.charAt(i + 1)) && isHexDigit(input.charAt(i + 2))) {
837                                out.append('%').append(input.charAt(i + 1)).append(input.charAt(i + 2));
838                                i += 3;
839                                continue;
840                        }
841
842                        boolean allowed = allowQueryOrFragmentChars ? isAllowedInQueryOrFragment(c) : isAllowedInPath(c);
843
844                        if (allowed) {
845                                out.append(c);
846                                i++;
847                                continue;
848                        }
849
850                        int codePoint = input.codePointAt(i);
851                        byte[] bytes = new String(Character.toChars(codePoint)).getBytes(StandardCharsets.UTF_8);
852
853                        for (byte b : bytes) {
854                                out.append('%');
855                                int v = b & 0xFF;
856                                out.append(Character.toUpperCase(Character.forDigit((v >> 4) & 0xF, 16)));
857                                out.append(Character.toUpperCase(Character.forDigit(v & 0xF, 16)));
858                        }
859
860                        i += Character.charCount(codePoint);
861                }
862
863                return out.toString();
864        }
865
866        private int firstDelimiterIndex(@NonNull String value) {
867                int slash = value.indexOf('/');
868                int question = value.indexOf('?');
869                int hash = value.indexOf('#');
870                int index = -1;
871
872                if (slash >= 0)
873                        index = slash;
874                if (question >= 0 && (index == -1 || question < index))
875                        index = question;
876                if (hash >= 0 && (index == -1 || hash < index))
877                        index = hash;
878
879                return index;
880        }
881
882        @Nullable
883        private ParsedLocation parseLocationFallback(@NonNull String location) {
884                int colon = location.indexOf(':');
885                if (colon <= 0)
886                        return null;
887
888                String scheme = location.substring(0, colon);
889                if (!isValidScheme(scheme))
890                        return null;
891
892                String rest = location.substring(colon + 1);
893
894                if (rest.startsWith("//")) {
895                        String authorityAndPath = rest.substring(2);
896                        int delimiterIndex = firstDelimiterIndex(authorityAndPath);
897                        String rawAuthority = delimiterIndex == -1 ? authorityAndPath : authorityAndPath.substring(0, delimiterIndex);
898                        String remainder = delimiterIndex == -1 ? "" : authorityAndPath.substring(delimiterIndex);
899                        ParsedPath parsedPath = parsePathAndSuffix(remainder);
900                        return new ParsedLocation(scheme, rawAuthority.isEmpty() ? null : rawAuthority,
901                                        parsedPath.rawPath, parsedPath.rawQuery, parsedPath.rawFragment, false);
902                }
903
904                if (rest.startsWith("/")) {
905                        ParsedPath parsedPath = parsePathAndSuffix(rest);
906                        return new ParsedLocation(scheme, null, parsedPath.rawPath, parsedPath.rawQuery, parsedPath.rawFragment, false);
907                }
908
909                return new ParsedLocation(scheme, null, rest, null, null, true);
910        }
911
912        @NonNull
913        private ParsedLocation parseLocation(@NonNull String location) {
914                requireNonNull(location);
915
916                try {
917                        URI uri = URI.create(location);
918                        String rawPath = uri.getRawPath() == null ? "" : uri.getRawPath();
919                        return new ParsedLocation(uri.getScheme(), uri.getRawAuthority(), rawPath, uri.getRawQuery(), uri.getRawFragment(), uri.isOpaque());
920                } catch (Exception ignored) {
921                        ParsedLocation fallback = parseLocationFallback(location);
922                        if (fallback != null)
923                                return fallback;
924
925                        ParsedPath parsedPath = parsePathAndSuffix(location);
926                        return new ParsedLocation(null, null, parsedPath.rawPath, parsedPath.rawQuery, parsedPath.rawFragment, false);
927                }
928        }
929
930        @NonNull
931        private String normalizePath(@NonNull String path) {
932                requireNonNull(path);
933
934                if (path.isEmpty())
935                        return path;
936
937                String input = path;
938                StringBuilder output = new StringBuilder();
939
940                while (!input.isEmpty()) {
941                        if (input.startsWith("../")) {
942                                input = input.substring(3);
943                        } else if (input.startsWith("./")) {
944                                input = input.substring(2);
945                        } else if (input.startsWith("/./")) {
946                                input = input.substring(2);
947                        } else if (input.equals("/.")) {
948                                input = "/";
949                        } else if (input.startsWith("/../")) {
950                                input = input.substring(3);
951                                removeLastSegment(output);
952                        } else if (input.equals("/..")) {
953                                input = "/";
954                                removeLastSegment(output);
955                        } else if (input.equals(".") || input.equals("..")) {
956                                input = "";
957                        } else {
958                                int start = input.startsWith("/") ? 1 : 0;
959                                int nextSlash = input.indexOf('/', start);
960
961                                if (nextSlash == -1) {
962                                        output.append(input);
963                                        input = "";
964                                } else {
965                                        output.append(input, 0, nextSlash);
966                                        input = input.substring(nextSlash);
967                                }
968                        }
969                }
970
971                return output.toString();
972        }
973
974        private void removeLastSegment(@NonNull StringBuilder output) {
975                requireNonNull(output);
976
977                int length = output.length();
978
979                if (length == 0)
980                        return;
981
982                int end = length;
983
984                if (end > 0 && output.charAt(end - 1) == '/')
985                        end--;
986
987                if (end <= 0) {
988                        output.setLength(0);
989                        return;
990                }
991
992                int lastSlash = output.lastIndexOf("/", end - 1);
993
994                if (lastSlash >= 0)
995                        output.delete(lastSlash, output.length());
996                else
997                        output.setLength(0);
998        }
999
1000        @NonNull
1001        private String buildAbsoluteLocation(@NonNull String scheme,
1002                                                                                                                                                         @Nullable String rawAuthority,
1003                                                                                                                                                         @NonNull String rawPath,
1004                                                                                                                                                         @Nullable String rawQuery,
1005                                                                                                                                                         @Nullable String rawFragment) {
1006                requireNonNull(scheme);
1007                requireNonNull(rawPath);
1008
1009                String encodedPath = encodePreservingEscapes(rawPath, false);
1010                String encodedQuery = rawQuery == null ? null : encodePreservingEscapes(rawQuery, true);
1011                String encodedFragment = rawFragment == null ? null : encodePreservingEscapes(rawFragment, true);
1012
1013                StringBuilder out = new StringBuilder();
1014                out.append(scheme).append(':');
1015
1016                if (rawAuthority != null) {
1017                        out.append("//").append(rawAuthority);
1018                }
1019
1020                out.append(encodedPath);
1021
1022                if (encodedQuery != null)
1023                        out.append('?').append(encodedQuery);
1024
1025                if (encodedFragment != null)
1026                        out.append('#').append(encodedFragment);
1027
1028                return out.toString();
1029        }
1030
1031        @NonNull
1032        private String buildOpaqueLocation(@NonNull String location) {
1033                requireNonNull(location);
1034
1035                try {
1036                        return URI.create(location).toASCIIString();
1037                } catch (Exception e) {
1038                        throw new IllegalArgumentException("Redirect location is invalid", e);
1039                }
1040        }
1041
1042        @Override
1043        public void sendRedirect(@Nullable String location) throws IOException {
1044                ensureResponseIsUncommitted();
1045
1046                if (location == null)
1047                        throw new IllegalArgumentException("Redirect location must not be null");
1048
1049                setStatus(HttpServletResponse.SC_FOUND);
1050                resetBuffer();
1051
1052                // This method can accept relative URLs; the servlet container must convert the relative URL to an absolute URL
1053                // before sending the response to the client. If the location is relative without a leading '/' the container
1054                // interprets it as relative to the current request URI. If the location is relative with a leading '/'
1055                // the container interprets it as relative to the servlet container root. If the location is relative with two
1056                // leading '/' the container interprets it as a network-path reference (see RFC 3986: Uniform Resource
1057                // Identifier (URI): Generic Syntax, section 4.2 "Relative Reference").
1058                String baseUrl = getRedirectBaseUrl();
1059                URI baseUri = URI.create(baseUrl);
1060                String scheme = baseUri.getScheme();
1061                String baseAuthority = baseUri.getRawAuthority();
1062                String finalLocation;
1063                ParsedLocation parsed = parseLocation(location);
1064
1065                if (parsed.opaque) {
1066                        finalLocation = buildOpaqueLocation(location);
1067                } else if (location.startsWith("//")) {
1068                        // Network-path reference: keep host from location but inherit scheme
1069                        String normalizedAuthority = normalizeAuthority(scheme, parsed.rawAuthority);
1070
1071                        if (normalizedAuthority == null || normalizedAuthority.isBlank())
1072                                throw new IllegalArgumentException("Redirect location is invalid");
1073
1074                        String normalized = normalizePath(parsed.rawPath);
1075                        finalLocation = buildAbsoluteLocation(scheme, normalizedAuthority, normalized, parsed.rawQuery, parsed.rawFragment);
1076                } else if (parsed.scheme != null) {
1077                        // URL is already absolute
1078                        String normalizedAuthority = normalizeAuthority(parsed.scheme, parsed.rawAuthority);
1079                        finalLocation = buildAbsoluteLocation(parsed.scheme, normalizedAuthority, parsed.rawPath, parsed.rawQuery, parsed.rawFragment);
1080                } else if (location.startsWith("/")) {
1081                        // URL is relative with leading /
1082                        String normalized = normalizePath(parsed.rawPath);
1083                        finalLocation = buildAbsoluteLocation(scheme, baseAuthority, normalized, parsed.rawQuery, parsed.rawFragment);
1084                } else {
1085                        // URL is relative but does not have leading '/', resolve against the parent of the current path
1086                        String base = getRawPath();
1087                        String path = parsed.rawPath;
1088                        String query = parsed.rawQuery;
1089
1090                        if (path.isEmpty() && query == null)
1091                                query = getRawQuery();
1092
1093                        if (path.isEmpty()) {
1094                                String normalized = normalizePath(base);
1095                                finalLocation = buildAbsoluteLocation(scheme, baseAuthority, normalized, query, parsed.rawFragment);
1096                        } else {
1097                                int idx = base.lastIndexOf('/');
1098                                String parent = (idx <= 0) ? "/" : base.substring(0, idx);
1099                                String resolvedPath = parent.endsWith("/") ? parent + path : parent + "/" + path;
1100                                String normalized = normalizePath(resolvedPath);
1101                                finalLocation = buildAbsoluteLocation(scheme, baseAuthority, normalized, query, parsed.rawFragment);
1102                        }
1103                }
1104
1105                setRedirectUrl(finalLocation);
1106                setHeader("Location", finalLocation);
1107
1108                flushBuffer();
1109                setResponseCommitted(true);
1110        }
1111
1112        @Override
1113        public void setDateHeader(@Nullable String name,
1114                                                                                                                long date) {
1115                if (isCommitted())
1116                        return;
1117
1118                setHeader(name, dateHeaderRepresentation(date));
1119        }
1120
1121        @Override
1122        public void addDateHeader(@Nullable String name,
1123                                                                                                                long date) {
1124                if (isCommitted())
1125                        return;
1126
1127                addHeader(name, dateHeaderRepresentation(date));
1128        }
1129
1130        @Override
1131        public void setHeader(@Nullable String name,
1132                                                                                                @Nullable String value) {
1133                if (isCommitted())
1134                        return;
1135
1136                if (name != null && !name.isBlank() && value != null) {
1137                        if ("Content-Type".equalsIgnoreCase(name)) {
1138                                setContentType(value);
1139                                return;
1140                        }
1141
1142                        putHeaderValue(name, value, true);
1143                }
1144        }
1145
1146        @Override
1147        public void addHeader(@Nullable String name,
1148                                                                                                @Nullable String value) {
1149                if (isCommitted())
1150                        return;
1151
1152                if (name != null && !name.isBlank() && value != null) {
1153                        if ("Content-Type".equalsIgnoreCase(name)) {
1154                                setContentType(value);
1155                                return;
1156                        }
1157
1158                        putHeaderValue(name, value, false);
1159                }
1160        }
1161
1162        @Override
1163        public void setIntHeader(@Nullable String name,
1164                                                                                                         int value) {
1165                setHeader(name, String.valueOf(value));
1166        }
1167
1168        @Override
1169        public void addIntHeader(@Nullable String name,
1170                                                                                                         int value) {
1171                addHeader(name, String.valueOf(value));
1172        }
1173
1174        @Override
1175        public void setStatus(int sc) {
1176                if (isCommitted())
1177                        return;
1178
1179                this.statusCode = sc;
1180        }
1181
1182        @Override
1183        @Deprecated
1184        public void setStatus(int sc,
1185                                                                                                @Nullable String sm) {
1186                if (isCommitted())
1187                        return;
1188
1189                this.statusCode = sc;
1190                this.errorMessage = sm;
1191        }
1192
1193        @Override
1194        public int getStatus() {
1195                return getStatusCode();
1196        }
1197
1198        @Override
1199        @Nullable
1200        public String getHeader(@Nullable String name) {
1201                if (name == null)
1202                        return null;
1203
1204                if ("Set-Cookie".equalsIgnoreCase(name)) {
1205                        List<@NonNull String> values = getHeaders().get(name);
1206
1207                        if (values != null && !values.isEmpty())
1208                                return values.get(0);
1209
1210                        List<@NonNull String> cookieValues = getSetCookieHeaderValues();
1211                        return cookieValues.isEmpty() ? null : cookieValues.get(0);
1212                }
1213
1214                List<@NonNull String> values = getHeaders().get(name);
1215                return values == null || values.size() == 0 ? null : values.get(0);
1216        }
1217
1218        @Override
1219        @NonNull
1220        public Collection<@NonNull String> getHeaders(@Nullable String name) {
1221                if (name == null)
1222                        return List.of();
1223
1224                if ("Set-Cookie".equalsIgnoreCase(name)) {
1225                        List<@NonNull String> values = getHeaders().get(name);
1226                        List<@NonNull String> cookieValues = getSetCookieHeaderValues();
1227
1228                        if ((values == null || values.isEmpty()) && cookieValues.isEmpty())
1229                                return List.of();
1230
1231                        List<@NonNull String> combined = new ArrayList<>();
1232
1233                        if (values != null)
1234                                combined.addAll(values);
1235
1236                        combined.addAll(cookieValues);
1237                        return Collections.unmodifiableList(combined);
1238                }
1239
1240                List<@NonNull String> values = getHeaders().get(name);
1241                return values == null ? List.of() : Collections.unmodifiableList(values);
1242        }
1243
1244        @Override
1245        @NonNull
1246        public Collection<@NonNull String> getHeaderNames() {
1247                Set<@NonNull String> names = new java.util.TreeSet<>(String.CASE_INSENSITIVE_ORDER);
1248                names.addAll(getHeaders().keySet());
1249
1250                if (!getCookies().isEmpty())
1251                        names.add("Set-Cookie");
1252
1253                return Collections.unmodifiableSet(names);
1254        }
1255
1256        @Override
1257        @NonNull
1258        public String getCharacterEncoding() {
1259                return getEffectiveCharset().name();
1260        }
1261
1262        @Override
1263        @Nullable
1264        public String getContentType() {
1265                String headerValue = getHeader("Content-Type");
1266                return headerValue != null ? headerValue : this.contentType;
1267        }
1268
1269        @Override
1270        @NonNull
1271        public ServletOutputStream getOutputStream() throws IOException {
1272                // Returns a ServletOutputStream suitable for writing binary data in the response.
1273                // The servlet container does not encode the binary data.
1274                // Calling flush() on the ServletOutputStream commits the response.
1275                // Either this method or getWriter() may be called to write the body, not both, except when reset() has been called.
1276                ResponseWriteMethod currentResponseWriteMethod = getResponseWriteMethod();
1277
1278                if (currentResponseWriteMethod == ResponseWriteMethod.UNSPECIFIED) {
1279                        setResponseWriteMethod(ResponseWriteMethod.SERVLET_OUTPUT_STREAM);
1280                        this.servletOutputStream = SokletServletOutputStream.withOutputStream(getResponseOutputStream())
1281                                        .onWriteOccurred((ignored1, ignored2) -> maybeCommitOnWrite())
1282                                        .onWriteFinalized((ignored) -> {
1283                                                setResponseCommitted(true);
1284                                                setResponseFinalized(true);
1285                                        }).build();
1286                        return getServletOutputStream().get();
1287                } else if (currentResponseWriteMethod == ResponseWriteMethod.SERVLET_OUTPUT_STREAM) {
1288                        return getServletOutputStream().get();
1289                } else {
1290                        throw new IllegalStateException(format("Cannot use %s for writing response; already using %s",
1291                                        ServletOutputStream.class.getSimpleName(), PrintWriter.class.getSimpleName()));
1292                }
1293        }
1294
1295        @NonNull
1296        private Boolean writerObtained() {
1297                return getResponseWriteMethod() == ResponseWriteMethod.PRINT_WRITER;
1298        }
1299
1300        @NonNull
1301        private Optional<String> extractCharsetFromContentType(@Nullable String type) {
1302                if (type == null)
1303                        return Optional.empty();
1304
1305                String[] parts = type.split(";");
1306
1307                for (int i = 1; i < parts.length; i++) {
1308                        String p = parts[i].trim();
1309                        if (p.toLowerCase(Locale.ROOT).startsWith("charset=")) {
1310                                String cs = p.substring("charset=".length()).trim();
1311
1312                                if (cs.startsWith("\"") && cs.endsWith("\"") && cs.length() >= 2)
1313                                        cs = cs.substring(1, cs.length() - 1);
1314
1315                                return Optional.of(cs);
1316                        }
1317                }
1318
1319                return Optional.empty();
1320        }
1321
1322        // Helper: remove any charset=... from Content-Type (preserve other params)
1323        @NonNull
1324        private Optional<String> stripCharsetParam(@Nullable String type) {
1325                if (type == null)
1326                        return Optional.empty();
1327
1328                String[] parts = type.split(";");
1329                String base = parts[0].trim();
1330                List<@NonNull String> kept = new ArrayList<>();
1331
1332                for (int i = 1; i < parts.length; i++) {
1333                        String p = parts[i].trim();
1334
1335                        if (!p.toLowerCase(Locale.ROOT).startsWith("charset=") && !p.isEmpty())
1336                                kept.add(p);
1337                }
1338
1339                return Optional.ofNullable(kept.isEmpty() ? base : base + "; " + String.join("; ", kept));
1340        }
1341
1342        // Helper: ensure Content-Type includes the given charset (replacing any existing one)
1343        @NonNull
1344        private Optional<String> withCharset(@Nullable String type,
1345                                                                                                                                                                 @NonNull String charsetName) {
1346                requireNonNull(charsetName);
1347
1348                if (type == null)
1349                        return Optional.empty();
1350
1351                String baseNoCs = stripCharsetParam(type).orElse("text/plain");
1352                return Optional.of(baseNoCs + "; charset=" + charsetName);
1353        }
1354
1355        @Override
1356        public PrintWriter getWriter() throws IOException {
1357                // Returns a PrintWriter object that can send character text to the client.
1358                // The PrintWriter uses the character encoding returned by getCharacterEncoding().
1359                // If the response's character encoding has not been specified as described in getCharacterEncoding
1360                // (i.e., the method just returns the default value), getWriter updates it to the effective default.
1361                // Calling flush() on the PrintWriter commits the response.
1362                //
1363                // Either this method or getOutputStream() may be called to write the body, not both, except when reset() has been called.
1364                // Returns a PrintWriter that uses the character encoding returned by getCharacterEncoding().
1365                // If not specified yet, calling getWriter() fixes the encoding to the effective default.
1366                ResponseWriteMethod currentResponseWriteMethod = getResponseWriteMethod();
1367
1368                if (currentResponseWriteMethod == ResponseWriteMethod.UNSPECIFIED) {
1369                        // Freeze encoding now
1370                        Charset enc = getEffectiveCharset();
1371                        setCharset(enc); // record the chosen encoding explicitly
1372
1373                        // If a content type is already present and lacks charset, append the frozen charset to header
1374                        String currentContentType = getContentType();
1375
1376                        if (currentContentType != null) {
1377                                Optional<String> csInHeader = extractCharsetFromContentType(currentContentType);
1378                                if (csInHeader.isEmpty() || !csInHeader.get().equalsIgnoreCase(enc.name())) {
1379                                        String updated = withCharset(currentContentType, enc.name()).orElse(null);
1380
1381                                        if (updated != null) {
1382                                                this.contentType = updated;
1383                                                putHeaderValue("Content-Type", updated, true);
1384                                        } else {
1385                                                this.contentType = currentContentType;
1386                                                putHeaderValue("Content-Type", currentContentType, true);
1387                                        }
1388                                }
1389                        }
1390
1391                        setResponseWriteMethod(ResponseWriteMethod.PRINT_WRITER);
1392
1393                        this.printWriter =
1394                                        SokletServletPrintWriter.withWriter(
1395                                                                        new OutputStreamWriter(getResponseOutputStream(), enc))
1396                                                        .onWriteOccurred((ignored1, ignored2) -> maybeCommitOnWrite())
1397                                                        .onWriteFinalized((ignored) -> {
1398                                                                setResponseCommitted(true);
1399                                                                setResponseFinalized(true);
1400                                                        })
1401                                                        .build();
1402
1403                        return getPrintWriter().get();
1404                } else if (currentResponseWriteMethod == ResponseWriteMethod.PRINT_WRITER) {
1405                        return getPrintWriter().get();
1406                } else {
1407                        throw new IllegalStateException(format("Cannot use %s for writing response; already using %s",
1408                                        PrintWriter.class.getSimpleName(), ServletOutputStream.class.getSimpleName()));
1409                }
1410        }
1411
1412        @Override
1413        public void setCharacterEncoding(@Nullable String charset) {
1414                if (isCommitted())
1415                        return;
1416
1417                // Spec: no effect after getWriter() or after commit
1418                if (writerObtained())
1419                        return;
1420
1421                if (charset == null || charset.isBlank()) {
1422                        // Clear explicit charset; default will be chosen at writer time if needed
1423                        setCharset(null);
1424
1425                        // If a Content-Type is set, remove its charset=... parameter
1426                        String currentContentType = getContentType();
1427
1428                        if (currentContentType != null) {
1429                                String updated = stripCharsetParam(currentContentType).orElse(null);
1430                                this.contentType = updated;
1431                                if (updated == null || updated.isBlank()) {
1432                                        getHeaders().remove("Content-Type");
1433                                } else {
1434                                        putHeaderValue("Content-Type", updated, true);
1435                                }
1436                        }
1437
1438                        return;
1439                }
1440
1441                Charset cs;
1442
1443                try {
1444                        cs = Charset.forName(charset);
1445                } catch (IllegalCharsetNameException | UnsupportedCharsetException e) {
1446                        return;
1447                }
1448                setCharset(cs);
1449
1450                // If a Content-Type is set, reflect/replace the charset=... in the header
1451                String currentContentType = getContentType();
1452
1453                if (currentContentType != null) {
1454                        String updated = withCharset(currentContentType, cs.name()).orElse(null);
1455
1456                        if (updated != null) {
1457                                this.contentType = updated;
1458                                putHeaderValue("Content-Type", updated, true);
1459                        } else {
1460                                this.contentType = currentContentType;
1461                                putHeaderValue("Content-Type", currentContentType, true);
1462                        }
1463                }
1464        }
1465
1466        @Override
1467        public void setContentLength(int len) {
1468                if (isCommitted())
1469                        return;
1470
1471                setHeader("Content-Length", String.valueOf(len));
1472        }
1473
1474        @Override
1475        public void setContentLengthLong(long len) {
1476                if (isCommitted())
1477                        return;
1478
1479                setHeader("Content-Length", String.valueOf(len));
1480        }
1481
1482        @Override
1483        public void setContentType(@Nullable String type) {
1484                // This method may be called repeatedly to change content type and character encoding.
1485                // This method has no effect if called after the response has been committed.
1486                // It does not set the response's character encoding if it is called after getWriter has been called
1487                // or after the response has been committed.
1488                if (isCommitted())
1489                        return;
1490
1491                if (!writerObtained()) {
1492                        // Before writer: charset can still be established/overridden
1493                        this.contentType = type;
1494
1495                        if (type == null || type.isBlank()) {
1496                                getHeaders().remove("Content-Type");
1497                                return;
1498                        }
1499
1500                        // If caller specified charset=..., adopt it as the current explicit charset
1501                        Optional<String> cs = extractCharsetFromContentType(type);
1502                        if (cs.isPresent()) {
1503                                try {
1504                                        setCharset(Charset.forName(cs.get()));
1505                                } catch (IllegalCharsetNameException | UnsupportedCharsetException ignored) {
1506                                        // Ignore invalid charset token; leave current charset unchanged.
1507                                }
1508                                putHeaderValue("Content-Type", type, true);
1509                        } else {
1510                                // No charset in type. If an explicit charset already exists (via setCharacterEncoding),
1511                                // reflect it in the header; otherwise just set the type as-is.
1512                                if (getCharset().isPresent()) {
1513                                        String updated = withCharset(type, getCharset().get().name()).orElse(null);
1514
1515                                        if (updated != null) {
1516                                                this.contentType = updated;
1517                                                putHeaderValue("Content-Type", updated, true);
1518                                        } else {
1519                                                putHeaderValue("Content-Type", type, true);
1520                                        }
1521                                } else {
1522                                        putHeaderValue("Content-Type", type, true);
1523                                }
1524                        }
1525                } else {
1526                        // After writer: charset is frozen. We can change the MIME type, but we must NOT change encoding.
1527                        // If caller supplies a charset, normalize the header back to the locked encoding.
1528                        this.contentType = type;
1529
1530                        if (type == null || type.isBlank()) {
1531                                // Allowed: clear header; does not change actual encoding used by writer
1532                                getHeaders().remove("Content-Type");
1533                                return;
1534                        }
1535
1536                        String locked = getCharacterEncoding(); // the frozen encoding name
1537                        String normalized = withCharset(type, locked).orElse(null);
1538
1539                        if (normalized != null) {
1540                                this.contentType = normalized;
1541                                putHeaderValue("Content-Type", normalized, true);
1542                        } else {
1543                                this.contentType = type;
1544                                putHeaderValue("Content-Type", type, true);
1545                        }
1546                }
1547        }
1548
1549        @Override
1550        public void setBufferSize(int size) {
1551                ensureResponseIsUncommitted();
1552
1553                if (size <= 0)
1554                        throw new IllegalArgumentException("Buffer size must be greater than 0");
1555
1556                // Per Servlet spec, setBufferSize must be called before any content is written
1557                if (getResponseOutputStream().size() > 0)
1558                        throw new IllegalStateException("setBufferSize must be called before any content is written");
1559
1560                setResponseBufferSizeInBytes(size);
1561
1562                if (!writerObtained() && getServletOutputStream().isEmpty())
1563                        setResponseOutputStream(new ByteArrayOutputStream(getResponseBufferSizeInBytes()));
1564        }
1565
1566        @Override
1567        public int getBufferSize() {
1568                return getResponseBufferSizeInBytes();
1569        }
1570
1571        @Override
1572        public void flushBuffer() throws IOException {
1573                if (!isCommitted())
1574                        setResponseCommitted(true);
1575
1576                SokletServletPrintWriter currentWriter = getPrintWriter().orElse(null);
1577                SokletServletOutputStream currentOutputStream = getServletOutputStream().orElse(null);
1578
1579                if (currentWriter != null) {
1580                        currentWriter.flush();
1581                } else if (currentOutputStream != null) {
1582                        currentOutputStream.flush();
1583                } else {
1584                        getResponseOutputStream().flush();
1585                }
1586        }
1587
1588        @Override
1589        public void resetBuffer() {
1590                ensureResponseIsUncommitted();
1591                getResponseOutputStream().reset();
1592        }
1593
1594        @Override
1595        public boolean isCommitted() {
1596                return getResponseCommitted();
1597        }
1598
1599        @Override
1600        public void reset() {
1601                // Clears any data that exists in the buffer as well as the status code, headers.
1602                // The state of calling getWriter() or getOutputStream() is also cleared.
1603                // It is legal, for instance, to call getWriter(), reset() and then getOutputStream().
1604                // If getWriter() or getOutputStream() have been called before this method, then the corresponding returned
1605                // Writer or OutputStream will be staled and the behavior of using the stale object is undefined.
1606                // If the response has been committed, this method throws an IllegalStateException.
1607
1608                ensureResponseIsUncommitted();
1609
1610                setStatusCode(HttpServletResponse.SC_OK);
1611                setServletOutputStream(null);
1612                setPrintWriter(null);
1613                setResponseWriteMethod(ResponseWriteMethod.UNSPECIFIED);
1614                setResponseOutputStream(new ByteArrayOutputStream(getResponseBufferSizeInBytes()));
1615                getHeaders().clear();
1616                getCookies().clear();
1617
1618                // Clear content-type/charset & locale to a pristine state
1619                this.contentType = null;
1620                setCharset(null);
1621                this.locale = null;
1622                this.errorMessage = null;
1623                this.redirectUrl = null;
1624        }
1625
1626        @Override
1627        public void setLocale(@Nullable Locale locale) {
1628                if (isCommitted())
1629                        return;
1630
1631                this.locale = locale;
1632
1633                if (locale != null && !writerObtained() && getCharset().isEmpty()) {
1634                        Charset contextCharset = getContextResponseCharset();
1635                        Charset selectedCharset = contextCharset == null ? DEFAULT_CHARSET : contextCharset;
1636                        setCharacterEncoding(selectedCharset.name());
1637                }
1638
1639                if (locale == null) {
1640                        getHeaders().remove("Content-Language");
1641                        return;
1642                }
1643
1644                String tag = locale.toLanguageTag();
1645
1646                if (tag.isBlank())
1647                        getHeaders().remove("Content-Language");
1648                else
1649                        putHeaderValue("Content-Language", tag, true);
1650        }
1651
1652        @Override
1653        public Locale getLocale() {
1654                return this.locale == null ? Locale.getDefault() : this.locale;
1655        }
1656}