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