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                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        public void sendError(int sc,
511                                                                                                @Nullable String msg) throws IOException {
512                ensureResponseIsUncommitted();
513                resetBuffer();
514                setStatus(sc);
515                setErrorMessage(msg);
516                writeDefaultErrorBody(sc, msg);
517                setResponseCommitted(true);
518        }
519
520        @Override
521        public void sendError(int sc) throws IOException {
522                ensureResponseIsUncommitted();
523                resetBuffer();
524                setStatus(sc);
525                setErrorMessage(null);
526                writeDefaultErrorBody(sc, null);
527                setResponseCommitted(true);
528        }
529
530        @NonNull
531        private String getRedirectBaseUrl() {
532                HttpServletRequest httpServletRequest = getHttpServletRequest().orElse(null);
533
534                if (httpServletRequest == null)
535                        return "http://localhost";
536
537                String scheme = httpServletRequest.getScheme();
538                if (scheme == null || scheme.isBlank())
539                        scheme = "http";
540                String host = httpServletRequest.getServerName();
541                if (host == null || host.isBlank())
542                        host = "localhost";
543                host = normalizeHostForLocation(host);
544                int port = httpServletRequest.getServerPort();
545                boolean defaultPort = port <= 0 || ("https".equalsIgnoreCase(scheme) && port == 443) || ("http".equalsIgnoreCase(scheme) && port == 80);
546                String authorityHost = host;
547
548                if (host != null && host.indexOf(':') >= 0 && !host.startsWith("[") && !host.endsWith("]"))
549                        authorityHost = "[" + host + "]";
550
551                String authority = defaultPort ? authorityHost : format("%s:%d", authorityHost, port);
552                validateAuthority(scheme, authority);
553                return format("%s://%s", scheme, authority);
554        }
555
556        @Nullable
557        private String getRawQuery() {
558                HttpServletRequest httpServletRequest = getHttpServletRequest().orElse(null);
559
560                if (httpServletRequest == null)
561                        return null;
562
563                String rawQuery = httpServletRequest.getQueryString();
564                return rawQuery == null || rawQuery.isEmpty() ? null : rawQuery;
565        }
566
567        private static final class ParsedLocation {
568                @Nullable
569                private final String scheme;
570                @Nullable
571                private final String rawAuthority;
572                @NonNull
573                private final String rawPath;
574                @Nullable
575                private final String rawQuery;
576                @Nullable
577                private final String rawFragment;
578                private final boolean opaque;
579
580                private ParsedLocation(@Nullable String scheme,
581                                                                                                         @Nullable String rawAuthority,
582                                                                                                         @NonNull String rawPath,
583                                                                                                         @Nullable String rawQuery,
584                                                                                                         @Nullable String rawFragment,
585                                                                                                         boolean opaque) {
586                        this.scheme = scheme;
587                        this.rawAuthority = rawAuthority;
588                        this.rawPath = rawPath;
589                        this.rawQuery = rawQuery;
590                        this.rawFragment = rawFragment;
591                        this.opaque = opaque;
592                }
593        }
594
595        private static final class ParsedPath {
596                @NonNull
597                private final String rawPath;
598                @Nullable
599                private final String rawQuery;
600                @Nullable
601                private final String rawFragment;
602
603                private ParsedPath(@NonNull String rawPath,
604                                                                                         @Nullable String rawQuery,
605                                                                                         @Nullable String rawFragment) {
606                        this.rawPath = rawPath;
607                        this.rawQuery = rawQuery;
608                        this.rawFragment = rawFragment;
609                }
610        }
611
612        @NonNull
613        private ParsedPath parsePathAndSuffix(@NonNull String rawPath) {
614                String path = rawPath;
615                String rawQuery = null;
616                String rawFragment = null;
617
618                int hash = path.indexOf('#');
619                if (hash >= 0) {
620                        rawFragment = path.substring(hash + 1);
621                        path = path.substring(0, hash);
622                }
623
624                int question = path.indexOf('?');
625                if (question >= 0) {
626                        rawQuery = path.substring(question + 1);
627                        path = path.substring(0, question);
628                }
629
630                return new ParsedPath(path, rawQuery, rawFragment);
631        }
632
633        private boolean isAsciiAlpha(char c) {
634                return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');
635        }
636
637        private boolean isAsciiDigit(char c) {
638                return c >= '0' && c <= '9';
639        }
640
641        private boolean isSchemeChar(char c) {
642                return isAsciiAlpha(c) || isAsciiDigit(c) || c == '+' || c == '-' || c == '.';
643        }
644
645        private boolean isValidScheme(@NonNull String scheme) {
646                if (scheme.isEmpty())
647                        return false;
648
649                if (!isAsciiAlpha(scheme.charAt(0)))
650                        return false;
651
652                for (int i = 1; i < scheme.length(); i++) {
653                        if (!isSchemeChar(scheme.charAt(i)))
654                                return false;
655                }
656
657                return true;
658        }
659
660        private boolean containsNonAscii(@NonNull String value) {
661                for (int i = 0; i < value.length(); i++) {
662                        if (value.charAt(i) > 0x7F)
663                                return true;
664                }
665
666                return false;
667        }
668
669        @NonNull
670        private String normalizeHostForLocation(@NonNull String host) {
671                requireNonNull(host);
672                String normalized = host.trim();
673
674                if (normalized.isEmpty())
675                        throw new IllegalArgumentException("Redirect host is invalid");
676
677                if (normalized.startsWith("[") && normalized.endsWith("]"))
678                        return normalized;
679
680                if (normalized.indexOf(':') >= 0)
681                        return normalized;
682
683                if (containsNonAscii(normalized)) {
684                        try {
685                                normalized = IDN.toASCII(normalized);
686                        } catch (IllegalArgumentException e) {
687                                throw new IllegalArgumentException("Redirect host is invalid", e);
688                        }
689                }
690
691                return normalized;
692        }
693
694        private int countColons(@NonNull String value) {
695                int count = 0;
696
697                for (int i = 0; i < value.length(); i++) {
698                        if (value.charAt(i) == ':')
699                                count++;
700                }
701
702                return count;
703        }
704
705        @Nullable
706        private String normalizeAuthority(@NonNull String scheme,
707                                                                                                                                                @Nullable String rawAuthority) {
708                requireNonNull(scheme);
709
710                if (rawAuthority == null || rawAuthority.isBlank())
711                        return null;
712
713                String authority = rawAuthority.trim();
714                String userInfo = null;
715                String hostPort = authority;
716                int at = authority.lastIndexOf('@');
717
718                if (at >= 0) {
719                        userInfo = authority.substring(0, at);
720                        hostPort = authority.substring(at + 1);
721                }
722
723                String normalizedHostPort;
724
725                if (hostPort.startsWith("[")) {
726                        int close = hostPort.indexOf(']');
727                        if (close < 0)
728                                throw new IllegalArgumentException("Redirect location is invalid");
729
730                        normalizedHostPort = hostPort;
731                } else {
732                        int colonCount = countColons(hostPort);
733                        String host = hostPort;
734                        String port = null;
735
736                        if (colonCount > 1) {
737                                host = hostPort;
738                        } else if (colonCount == 1) {
739                                int colon = hostPort.lastIndexOf(':');
740
741                                if (colon <= 0 || colon == hostPort.length() - 1)
742                                        throw new IllegalArgumentException("Redirect location is invalid");
743
744                                String portCandidate = hostPort.substring(colon + 1);
745                                boolean allDigits = true;
746
747                                for (int i = 0; i < portCandidate.length(); i++) {
748                                        if (!isAsciiDigit(portCandidate.charAt(i))) {
749                                                allDigits = false;
750                                                break;
751                                        }
752                                }
753
754                                if (!allDigits)
755                                        throw new IllegalArgumentException("Redirect location is invalid");
756
757                                host = hostPort.substring(0, colon);
758                                port = portCandidate;
759                        }
760
761                        String normalizedHost = normalizeHostForLocation(host);
762
763                        if (normalizedHost.indexOf(':') >= 0 && !normalizedHost.startsWith("["))
764                                normalizedHost = "[" + normalizedHost + "]";
765
766                        normalizedHostPort = port == null ? normalizedHost : normalizedHost + ":" + port;
767                }
768
769                String normalized = userInfo == null ? normalizedHostPort : userInfo + "@" + normalizedHostPort;
770                validateAuthority(scheme, normalized);
771                return normalized;
772        }
773
774        private void validateAuthority(@NonNull String scheme,
775                                                                                                                                 @Nullable String authority) {
776                requireNonNull(scheme);
777
778                try {
779                        new URI(scheme, authority, null, null, null);
780                } catch (URISyntaxException e) {
781                        throw new IllegalArgumentException("Redirect location is invalid", e);
782                }
783        }
784
785        private boolean isUnreserved(char c) {
786                return isAsciiAlpha(c) || isAsciiDigit(c) || c == '-' || c == '.' || c == '_' || c == '~';
787        }
788
789        private boolean isSubDelim(char c) {
790                return c == '!' || c == '$' || c == '&' || c == '\'' || c == '(' || c == ')'
791                                || c == '*' || c == '+' || c == ',' || c == ';' || c == '=';
792        }
793
794        private boolean isPchar(char c) {
795                return isUnreserved(c) || isSubDelim(c) || c == ':' || c == '@';
796        }
797
798        private boolean isAllowedInPath(char c) {
799                return isPchar(c) || c == '/';
800        }
801
802        private boolean isAllowedInQueryOrFragment(char c) {
803                return isPchar(c) || c == '/' || c == '?';
804        }
805
806        private boolean isHexDigit(char c) {
807                return (c >= '0' && c <= '9')
808                                || (c >= 'A' && c <= 'F')
809                                || (c >= 'a' && c <= 'f');
810        }
811
812        @NonNull
813        private String encodePreservingEscapes(@NonNull String input,
814                                                                                                                                                                 boolean allowQueryOrFragmentChars) {
815                requireNonNull(input);
816
817                StringBuilder out = new StringBuilder(input.length());
818                int length = input.length();
819
820                for (int i = 0; i < length; ) {
821                        char c = input.charAt(i);
822
823                        if (c == '%' && i + 2 < length
824                                        && isHexDigit(input.charAt(i + 1)) && isHexDigit(input.charAt(i + 2))) {
825                                out.append('%').append(input.charAt(i + 1)).append(input.charAt(i + 2));
826                                i += 3;
827                                continue;
828                        }
829
830                        boolean allowed = allowQueryOrFragmentChars ? isAllowedInQueryOrFragment(c) : isAllowedInPath(c);
831
832                        if (allowed) {
833                                out.append(c);
834                                i++;
835                                continue;
836                        }
837
838                        int codePoint = input.codePointAt(i);
839                        byte[] bytes = new String(Character.toChars(codePoint)).getBytes(StandardCharsets.UTF_8);
840
841                        for (byte b : bytes) {
842                                out.append('%');
843                                int v = b & 0xFF;
844                                out.append(Character.toUpperCase(Character.forDigit((v >> 4) & 0xF, 16)));
845                                out.append(Character.toUpperCase(Character.forDigit(v & 0xF, 16)));
846                        }
847
848                        i += Character.charCount(codePoint);
849                }
850
851                return out.toString();
852        }
853
854        private int firstDelimiterIndex(@NonNull String value) {
855                int slash = value.indexOf('/');
856                int question = value.indexOf('?');
857                int hash = value.indexOf('#');
858                int index = -1;
859
860                if (slash >= 0)
861                        index = slash;
862                if (question >= 0 && (index == -1 || question < index))
863                        index = question;
864                if (hash >= 0 && (index == -1 || hash < index))
865                        index = hash;
866
867                return index;
868        }
869
870        @Nullable
871        private ParsedLocation parseLocationFallback(@NonNull String location) {
872                int colon = location.indexOf(':');
873                if (colon <= 0)
874                        return null;
875
876                String scheme = location.substring(0, colon);
877                if (!isValidScheme(scheme))
878                        return null;
879
880                String rest = location.substring(colon + 1);
881
882                if (rest.startsWith("//")) {
883                        String authorityAndPath = rest.substring(2);
884                        int delimiterIndex = firstDelimiterIndex(authorityAndPath);
885                        String rawAuthority = delimiterIndex == -1 ? authorityAndPath : authorityAndPath.substring(0, delimiterIndex);
886                        String remainder = delimiterIndex == -1 ? "" : authorityAndPath.substring(delimiterIndex);
887                        ParsedPath parsedPath = parsePathAndSuffix(remainder);
888                        return new ParsedLocation(scheme, rawAuthority.isEmpty() ? null : rawAuthority,
889                                        parsedPath.rawPath, parsedPath.rawQuery, parsedPath.rawFragment, false);
890                }
891
892                if (rest.startsWith("/")) {
893                        ParsedPath parsedPath = parsePathAndSuffix(rest);
894                        return new ParsedLocation(scheme, null, parsedPath.rawPath, parsedPath.rawQuery, parsedPath.rawFragment, false);
895                }
896
897                return new ParsedLocation(scheme, null, rest, null, null, true);
898        }
899
900        @NonNull
901        private ParsedLocation parseLocation(@NonNull String location) {
902                requireNonNull(location);
903
904                try {
905                        URI uri = URI.create(location);
906                        String rawPath = uri.getRawPath() == null ? "" : uri.getRawPath();
907                        return new ParsedLocation(uri.getScheme(), uri.getRawAuthority(), rawPath, uri.getRawQuery(), uri.getRawFragment(), uri.isOpaque());
908                } catch (Exception ignored) {
909                        ParsedLocation fallback = parseLocationFallback(location);
910                        if (fallback != null)
911                                return fallback;
912
913                        ParsedPath parsedPath = parsePathAndSuffix(location);
914                        return new ParsedLocation(null, null, parsedPath.rawPath, parsedPath.rawQuery, parsedPath.rawFragment, false);
915                }
916        }
917
918        @NonNull
919        private String normalizePath(@NonNull String path) {
920                requireNonNull(path);
921
922                if (path.isEmpty())
923                        return path;
924
925                String input = path;
926                StringBuilder output = new StringBuilder();
927
928                while (!input.isEmpty()) {
929                        if (input.startsWith("../")) {
930                                input = input.substring(3);
931                        } else if (input.startsWith("./")) {
932                                input = input.substring(2);
933                        } else if (input.startsWith("/./")) {
934                                input = input.substring(2);
935                        } else if (input.equals("/.")) {
936                                input = "/";
937                        } else if (input.startsWith("/../")) {
938                                input = input.substring(3);
939                                removeLastSegment(output);
940                        } else if (input.equals("/..")) {
941                                input = "/";
942                                removeLastSegment(output);
943                        } else if (input.equals(".") || input.equals("..")) {
944                                input = "";
945                        } else {
946                                int start = input.startsWith("/") ? 1 : 0;
947                                int nextSlash = input.indexOf('/', start);
948
949                                if (nextSlash == -1) {
950                                        output.append(input);
951                                        input = "";
952                                } else {
953                                        output.append(input, 0, nextSlash);
954                                        input = input.substring(nextSlash);
955                                }
956                        }
957                }
958
959                return output.toString();
960        }
961
962        private void removeLastSegment(@NonNull StringBuilder output) {
963                requireNonNull(output);
964
965                int length = output.length();
966
967                if (length == 0)
968                        return;
969
970                int end = length;
971
972                if (end > 0 && output.charAt(end - 1) == '/')
973                        end--;
974
975                if (end <= 0) {
976                        output.setLength(0);
977                        return;
978                }
979
980                int lastSlash = output.lastIndexOf("/", end - 1);
981
982                if (lastSlash >= 0)
983                        output.delete(lastSlash, output.length());
984                else
985                        output.setLength(0);
986        }
987
988        @NonNull
989        private String buildAbsoluteLocation(@NonNull String scheme,
990                                                                                                                                                         @Nullable String rawAuthority,
991                                                                                                                                                         @NonNull String rawPath,
992                                                                                                                                                         @Nullable String rawQuery,
993                                                                                                                                                         @Nullable String rawFragment) {
994                requireNonNull(scheme);
995                requireNonNull(rawPath);
996
997                String encodedPath = encodePreservingEscapes(rawPath, false);
998                String encodedQuery = rawQuery == null ? null : encodePreservingEscapes(rawQuery, true);
999                String encodedFragment = rawFragment == null ? null : encodePreservingEscapes(rawFragment, true);
1000
1001                StringBuilder out = new StringBuilder();
1002                out.append(scheme).append(':');
1003
1004                if (rawAuthority != null) {
1005                        out.append("//").append(rawAuthority);
1006                }
1007
1008                out.append(encodedPath);
1009
1010                if (encodedQuery != null)
1011                        out.append('?').append(encodedQuery);
1012
1013                if (encodedFragment != null)
1014                        out.append('#').append(encodedFragment);
1015
1016                return out.toString();
1017        }
1018
1019        @NonNull
1020        private String buildOpaqueLocation(@NonNull String location) {
1021                requireNonNull(location);
1022
1023                try {
1024                        return URI.create(location).toASCIIString();
1025                } catch (Exception e) {
1026                        throw new IllegalArgumentException("Redirect location is invalid", e);
1027                }
1028        }
1029
1030        @Override
1031        public void sendRedirect(@Nullable String location) throws IOException {
1032                sendRedirect(location, HttpServletResponse.SC_FOUND, true);
1033        }
1034
1035        @Override
1036        public void sendRedirect(@Nullable String location,
1037                                                                                                         int sc,
1038                                                                                                         boolean clearBuffer) throws IOException {
1039                ensureResponseIsUncommitted();
1040
1041                if (location == null)
1042                        throw new IllegalArgumentException("Redirect location must not be null");
1043
1044                setStatus(sc);
1045
1046                if (clearBuffer)
1047                        resetBuffer();
1048
1049                // This method can accept relative URLs; the servlet container must convert the relative URL to an absolute URL
1050                // before sending the response to the client. If the location is relative without a leading '/' the container
1051                // interprets it as relative to the current request URI. If the location is relative with a leading '/'
1052                // the container interprets it as relative to the servlet container root. If the location is relative with two
1053                // leading '/' the container interprets it as a network-path reference (see RFC 3986: Uniform Resource
1054                // Identifier (URI): Generic Syntax, section 4.2 "Relative Reference").
1055                String baseUrl = getRedirectBaseUrl();
1056                URI baseUri = URI.create(baseUrl);
1057                String scheme = baseUri.getScheme();
1058                String baseAuthority = baseUri.getRawAuthority();
1059                String finalLocation;
1060                ParsedLocation parsed = parseLocation(location);
1061
1062                if (parsed.opaque) {
1063                        finalLocation = buildOpaqueLocation(location);
1064                } else if (location.startsWith("//")) {
1065                        // Network-path reference: keep host from location but inherit scheme
1066                        String normalizedAuthority = normalizeAuthority(scheme, parsed.rawAuthority);
1067
1068                        if (normalizedAuthority == null || normalizedAuthority.isBlank())
1069                                throw new IllegalArgumentException("Redirect location is invalid");
1070
1071                        String normalized = normalizePath(parsed.rawPath);
1072                        finalLocation = buildAbsoluteLocation(scheme, normalizedAuthority, normalized, parsed.rawQuery, parsed.rawFragment);
1073                } else if (parsed.scheme != null) {
1074                        // URL is already absolute
1075                        String normalizedAuthority = normalizeAuthority(parsed.scheme, parsed.rawAuthority);
1076                        finalLocation = buildAbsoluteLocation(parsed.scheme, normalizedAuthority, parsed.rawPath, parsed.rawQuery, parsed.rawFragment);
1077                } else if (location.startsWith("/")) {
1078                        // URL is relative with leading /
1079                        String normalized = normalizePath(parsed.rawPath);
1080                        finalLocation = buildAbsoluteLocation(scheme, baseAuthority, normalized, parsed.rawQuery, parsed.rawFragment);
1081                } else {
1082                        // URL is relative but does not have leading '/', resolve against the parent of the current path
1083                        String base = getRawPath();
1084                        String path = parsed.rawPath;
1085                        String query = parsed.rawQuery;
1086
1087                        if (path.isEmpty() && query == null)
1088                                query = getRawQuery();
1089
1090                        if (path.isEmpty()) {
1091                                String normalized = normalizePath(base);
1092                                finalLocation = buildAbsoluteLocation(scheme, baseAuthority, normalized, query, parsed.rawFragment);
1093                        } else {
1094                                int idx = base.lastIndexOf('/');
1095                                String parent = (idx <= 0) ? "/" : base.substring(0, idx);
1096                                String resolvedPath = parent.endsWith("/") ? parent + path : parent + "/" + path;
1097                                String normalized = normalizePath(resolvedPath);
1098                                finalLocation = buildAbsoluteLocation(scheme, baseAuthority, normalized, query, parsed.rawFragment);
1099                        }
1100                }
1101
1102                setRedirectUrl(finalLocation);
1103                setHeader("Location", finalLocation);
1104
1105                flushBuffer();
1106                setResponseCommitted(true);
1107        }
1108
1109        @Override
1110        public void setDateHeader(@Nullable String name,
1111                                                                                                                long date) {
1112                if (isCommitted())
1113                        return;
1114
1115                setHeader(name, dateHeaderRepresentation(date));
1116        }
1117
1118        @Override
1119        public void addDateHeader(@Nullable String name,
1120                                                                                                                long date) {
1121                if (isCommitted())
1122                        return;
1123
1124                addHeader(name, dateHeaderRepresentation(date));
1125        }
1126
1127        @Override
1128        public void setHeader(@Nullable String name,
1129                                                                                                @Nullable String value) {
1130                if (isCommitted())
1131                        return;
1132
1133                if (name != null && !name.isBlank() && value != null) {
1134                        if ("Content-Type".equalsIgnoreCase(name)) {
1135                                setContentType(value);
1136                                return;
1137                        }
1138
1139                        putHeaderValue(name, value, true);
1140                }
1141        }
1142
1143        @Override
1144        public void addHeader(@Nullable String name,
1145                                                                                                @Nullable String value) {
1146                if (isCommitted())
1147                        return;
1148
1149                if (name != null && !name.isBlank() && value != null) {
1150                        if ("Content-Type".equalsIgnoreCase(name)) {
1151                                setContentType(value);
1152                                return;
1153                        }
1154
1155                        putHeaderValue(name, value, false);
1156                }
1157        }
1158
1159        @Override
1160        public void setIntHeader(@Nullable String name,
1161                                                                                                         int value) {
1162                setHeader(name, String.valueOf(value));
1163        }
1164
1165        @Override
1166        public void addIntHeader(@Nullable String name,
1167                                                                                                         int value) {
1168                addHeader(name, String.valueOf(value));
1169        }
1170
1171        @Override
1172        public void setStatus(int sc) {
1173                if (isCommitted())
1174                        return;
1175
1176                this.statusCode = sc;
1177        }
1178
1179        @Override
1180        public int getStatus() {
1181                return getStatusCode();
1182        }
1183
1184        @Override
1185        @Nullable
1186        public String getHeader(@Nullable String name) {
1187                if (name == null)
1188                        return null;
1189
1190                if ("Set-Cookie".equalsIgnoreCase(name)) {
1191                        List<@NonNull String> values = getHeaders().get(name);
1192
1193                        if (values != null && !values.isEmpty())
1194                                return values.get(0);
1195
1196                        List<@NonNull String> cookieValues = getSetCookieHeaderValues();
1197                        return cookieValues.isEmpty() ? null : cookieValues.get(0);
1198                }
1199
1200                List<@NonNull String> values = getHeaders().get(name);
1201                return values == null || values.size() == 0 ? null : values.get(0);
1202        }
1203
1204        @Override
1205        @NonNull
1206        public Collection<@NonNull String> getHeaders(@Nullable String name) {
1207                if (name == null)
1208                        return List.of();
1209
1210                if ("Set-Cookie".equalsIgnoreCase(name)) {
1211                        List<@NonNull String> values = getHeaders().get(name);
1212                        List<@NonNull String> cookieValues = getSetCookieHeaderValues();
1213
1214                        if ((values == null || values.isEmpty()) && cookieValues.isEmpty())
1215                                return List.of();
1216
1217                        List<@NonNull String> combined = new ArrayList<>();
1218
1219                        if (values != null)
1220                                combined.addAll(values);
1221
1222                        combined.addAll(cookieValues);
1223                        return Collections.unmodifiableList(combined);
1224                }
1225
1226                List<@NonNull String> values = getHeaders().get(name);
1227                return values == null ? List.of() : Collections.unmodifiableList(values);
1228        }
1229
1230        @Override
1231        @NonNull
1232        public Collection<@NonNull String> getHeaderNames() {
1233                Set<@NonNull String> names = new java.util.TreeSet<>(String.CASE_INSENSITIVE_ORDER);
1234                names.addAll(getHeaders().keySet());
1235
1236                if (!getCookies().isEmpty())
1237                        names.add("Set-Cookie");
1238
1239                return Collections.unmodifiableSet(names);
1240        }
1241
1242        @Override
1243        @NonNull
1244        public String getCharacterEncoding() {
1245                return getEffectiveCharset().name();
1246        }
1247
1248        @Override
1249        @Nullable
1250        public String getContentType() {
1251                String headerValue = getHeader("Content-Type");
1252                return headerValue != null ? headerValue : this.contentType;
1253        }
1254
1255        @Override
1256        @NonNull
1257        public ServletOutputStream getOutputStream() throws IOException {
1258                // Returns a ServletOutputStream suitable for writing binary data in the response.
1259                // The servlet container does not encode the binary data.
1260                // Calling flush() on the ServletOutputStream commits the response.
1261                // Either this method or getWriter() may be called to write the body, not both, except when reset() has been called.
1262                ResponseWriteMethod currentResponseWriteMethod = getResponseWriteMethod();
1263
1264                if (currentResponseWriteMethod == ResponseWriteMethod.UNSPECIFIED) {
1265                        setResponseWriteMethod(ResponseWriteMethod.SERVLET_OUTPUT_STREAM);
1266                        this.servletOutputStream = SokletServletOutputStream.withOutputStream(getResponseOutputStream())
1267                                        .onWriteOccurred((ignored1, ignored2) -> maybeCommitOnWrite())
1268                                        .onWriteFinalized((ignored) -> {
1269                                                setResponseCommitted(true);
1270                                                setResponseFinalized(true);
1271                                        }).build();
1272                        return getServletOutputStream().get();
1273                } else if (currentResponseWriteMethod == ResponseWriteMethod.SERVLET_OUTPUT_STREAM) {
1274                        return getServletOutputStream().get();
1275                } else {
1276                        throw new IllegalStateException(format("Cannot use %s for writing response; already using %s",
1277                                        ServletOutputStream.class.getSimpleName(), PrintWriter.class.getSimpleName()));
1278                }
1279        }
1280
1281        @NonNull
1282        private Boolean writerObtained() {
1283                return getResponseWriteMethod() == ResponseWriteMethod.PRINT_WRITER;
1284        }
1285
1286        @NonNull
1287        private Optional<String> extractCharsetFromContentType(@Nullable String type) {
1288                if (type == null)
1289                        return Optional.empty();
1290
1291                String[] parts = type.split(";");
1292
1293                for (int i = 1; i < parts.length; i++) {
1294                        String p = parts[i].trim();
1295                        if (p.toLowerCase(Locale.ROOT).startsWith("charset=")) {
1296                                String cs = p.substring("charset=".length()).trim();
1297
1298                                if (cs.startsWith("\"") && cs.endsWith("\"") && cs.length() >= 2)
1299                                        cs = cs.substring(1, cs.length() - 1);
1300
1301                                return Optional.of(cs);
1302                        }
1303                }
1304
1305                return Optional.empty();
1306        }
1307
1308        // Helper: remove any charset=... from Content-Type (preserve other params)
1309        @NonNull
1310        private Optional<String> stripCharsetParam(@Nullable String type) {
1311                if (type == null)
1312                        return Optional.empty();
1313
1314                String[] parts = type.split(";");
1315                String base = parts[0].trim();
1316                List<@NonNull String> kept = new ArrayList<>();
1317
1318                for (int i = 1; i < parts.length; i++) {
1319                        String p = parts[i].trim();
1320
1321                        if (!p.toLowerCase(Locale.ROOT).startsWith("charset=") && !p.isEmpty())
1322                                kept.add(p);
1323                }
1324
1325                return Optional.ofNullable(kept.isEmpty() ? base : base + "; " + String.join("; ", kept));
1326        }
1327
1328        // Helper: ensure Content-Type includes the given charset (replacing any existing one)
1329        @NonNull
1330        private Optional<String> withCharset(@Nullable String type,
1331                                                                                                                                                         @NonNull String charsetName) {
1332                requireNonNull(charsetName);
1333
1334                if (type == null)
1335                        return Optional.empty();
1336
1337                String baseNoCs = stripCharsetParam(type).orElse("text/plain");
1338                return Optional.of(baseNoCs + "; charset=" + charsetName);
1339        }
1340
1341        @Override
1342        public PrintWriter getWriter() throws IOException {
1343                // Returns a PrintWriter object that can send character text to the client.
1344                // The PrintWriter uses the character encoding returned by getCharacterEncoding().
1345                // If the response's character encoding has not been specified as described in getCharacterEncoding
1346                // (i.e., the method just returns the default value), getWriter updates it to the effective default.
1347                // Calling flush() on the PrintWriter commits the response.
1348                //
1349                // Either this method or getOutputStream() may be called to write the body, not both, except when reset() has been called.
1350                // Returns a PrintWriter that uses the character encoding returned by getCharacterEncoding().
1351                // If not specified yet, calling getWriter() fixes the encoding to the effective default.
1352                ResponseWriteMethod currentResponseWriteMethod = getResponseWriteMethod();
1353
1354                if (currentResponseWriteMethod == ResponseWriteMethod.UNSPECIFIED) {
1355                        // Freeze encoding now
1356                        Charset enc = getEffectiveCharset();
1357                        setCharset(enc); // record the chosen encoding explicitly
1358
1359                        // If a content type is already present and lacks charset, append the frozen charset to header
1360                        String currentContentType = getContentType();
1361
1362                        if (currentContentType != null) {
1363                                Optional<String> csInHeader = extractCharsetFromContentType(currentContentType);
1364                                if (csInHeader.isEmpty() || !csInHeader.get().equalsIgnoreCase(enc.name())) {
1365                                        String updated = withCharset(currentContentType, enc.name()).orElse(null);
1366
1367                                        if (updated != null) {
1368                                                this.contentType = updated;
1369                                                putHeaderValue("Content-Type", updated, true);
1370                                        } else {
1371                                                this.contentType = currentContentType;
1372                                                putHeaderValue("Content-Type", currentContentType, true);
1373                                        }
1374                                }
1375                        }
1376
1377                        setResponseWriteMethod(ResponseWriteMethod.PRINT_WRITER);
1378
1379                        this.printWriter =
1380                                        SokletServletPrintWriter.withWriter(
1381                                                                        new OutputStreamWriter(getResponseOutputStream(), enc))
1382                                                        .onWriteOccurred((ignored1, ignored2) -> maybeCommitOnWrite())
1383                                                        .onWriteFinalized((ignored) -> {
1384                                                                setResponseCommitted(true);
1385                                                                setResponseFinalized(true);
1386                                                        })
1387                                                        .build();
1388
1389                        return getPrintWriter().get();
1390                } else if (currentResponseWriteMethod == ResponseWriteMethod.PRINT_WRITER) {
1391                        return getPrintWriter().get();
1392                } else {
1393                        throw new IllegalStateException(format("Cannot use %s for writing response; already using %s",
1394                                        PrintWriter.class.getSimpleName(), ServletOutputStream.class.getSimpleName()));
1395                }
1396        }
1397
1398        @Override
1399        public void setCharacterEncoding(@Nullable String charset) {
1400                if (isCommitted())
1401                        return;
1402
1403                // Spec: no effect after getWriter() or after commit
1404                if (writerObtained())
1405                        return;
1406
1407                if (charset == null || charset.isBlank()) {
1408                        // Clear explicit charset; default will be chosen at writer time if needed
1409                        setCharset(null);
1410
1411                        // If a Content-Type is set, remove its charset=... parameter
1412                        String currentContentType = getContentType();
1413
1414                        if (currentContentType != null) {
1415                                String updated = stripCharsetParam(currentContentType).orElse(null);
1416                                this.contentType = updated;
1417                                if (updated == null || updated.isBlank()) {
1418                                        getHeaders().remove("Content-Type");
1419                                } else {
1420                                        putHeaderValue("Content-Type", updated, true);
1421                                }
1422                        }
1423
1424                        return;
1425                }
1426
1427                Charset cs;
1428
1429                try {
1430                        cs = Charset.forName(charset);
1431                } catch (IllegalCharsetNameException | UnsupportedCharsetException e) {
1432                        return;
1433                }
1434                setCharset(cs);
1435
1436                // If a Content-Type is set, reflect/replace the charset=... in the header
1437                String currentContentType = getContentType();
1438
1439                if (currentContentType != null) {
1440                        String updated = withCharset(currentContentType, cs.name()).orElse(null);
1441
1442                        if (updated != null) {
1443                                this.contentType = updated;
1444                                putHeaderValue("Content-Type", updated, true);
1445                        } else {
1446                                this.contentType = currentContentType;
1447                                putHeaderValue("Content-Type", currentContentType, true);
1448                        }
1449                }
1450        }
1451
1452        @Override
1453        public void setContentLength(int len) {
1454                if (isCommitted())
1455                        return;
1456
1457                setHeader("Content-Length", String.valueOf(len));
1458        }
1459
1460        @Override
1461        public void setContentLengthLong(long len) {
1462                if (isCommitted())
1463                        return;
1464
1465                setHeader("Content-Length", String.valueOf(len));
1466        }
1467
1468        @Override
1469        public void setContentType(@Nullable String type) {
1470                // This method may be called repeatedly to change content type and character encoding.
1471                // This method has no effect if called after the response has been committed.
1472                // It does not set the response's character encoding if it is called after getWriter has been called
1473                // or after the response has been committed.
1474                if (isCommitted())
1475                        return;
1476
1477                if (!writerObtained()) {
1478                        // Before writer: charset can still be established/overridden
1479                        this.contentType = type;
1480
1481                        if (type == null || type.isBlank()) {
1482                                getHeaders().remove("Content-Type");
1483                                return;
1484                        }
1485
1486                        // If caller specified charset=..., adopt it as the current explicit charset
1487                        Optional<String> cs = extractCharsetFromContentType(type);
1488                        if (cs.isPresent()) {
1489                                try {
1490                                        setCharset(Charset.forName(cs.get()));
1491                                } catch (IllegalCharsetNameException | UnsupportedCharsetException ignored) {
1492                                        // Ignore invalid charset token; leave current charset unchanged.
1493                                }
1494                                putHeaderValue("Content-Type", type, true);
1495                        } else {
1496                                // No charset in type. If an explicit charset already exists (via setCharacterEncoding),
1497                                // reflect it in the header; otherwise just set the type as-is.
1498                                if (getCharset().isPresent()) {
1499                                        String updated = withCharset(type, getCharset().get().name()).orElse(null);
1500
1501                                        if (updated != null) {
1502                                                this.contentType = updated;
1503                                                putHeaderValue("Content-Type", updated, true);
1504                                        } else {
1505                                                putHeaderValue("Content-Type", type, true);
1506                                        }
1507                                } else {
1508                                        putHeaderValue("Content-Type", type, true);
1509                                }
1510                        }
1511                } else {
1512                        // After writer: charset is frozen. We can change the MIME type, but we must NOT change encoding.
1513                        // If caller supplies a charset, normalize the header back to the locked encoding.
1514                        this.contentType = type;
1515
1516                        if (type == null || type.isBlank()) {
1517                                // Allowed: clear header; does not change actual encoding used by writer
1518                                getHeaders().remove("Content-Type");
1519                                return;
1520                        }
1521
1522                        String locked = getCharacterEncoding(); // the frozen encoding name
1523                        String normalized = withCharset(type, locked).orElse(null);
1524
1525                        if (normalized != null) {
1526                                this.contentType = normalized;
1527                                putHeaderValue("Content-Type", normalized, true);
1528                        } else {
1529                                this.contentType = type;
1530                                putHeaderValue("Content-Type", type, true);
1531                        }
1532                }
1533        }
1534
1535        @Override
1536        public void setBufferSize(int size) {
1537                ensureResponseIsUncommitted();
1538
1539                if (size <= 0)
1540                        throw new IllegalArgumentException("Buffer size must be greater than 0");
1541
1542                // Per Servlet spec, setBufferSize must be called before any content is written
1543                if (getResponseOutputStream().size() > 0)
1544                        throw new IllegalStateException("setBufferSize must be called before any content is written");
1545
1546                setResponseBufferSizeInBytes(size);
1547
1548                if (!writerObtained() && getServletOutputStream().isEmpty())
1549                        setResponseOutputStream(new ByteArrayOutputStream(getResponseBufferSizeInBytes()));
1550        }
1551
1552        @Override
1553        public int getBufferSize() {
1554                return getResponseBufferSizeInBytes();
1555        }
1556
1557        @Override
1558        public void flushBuffer() throws IOException {
1559                if (!isCommitted())
1560                        setResponseCommitted(true);
1561
1562                SokletServletPrintWriter currentWriter = getPrintWriter().orElse(null);
1563                SokletServletOutputStream currentOutputStream = getServletOutputStream().orElse(null);
1564
1565                if (currentWriter != null) {
1566                        currentWriter.flush();
1567                } else if (currentOutputStream != null) {
1568                        currentOutputStream.flush();
1569                } else {
1570                        getResponseOutputStream().flush();
1571                }
1572        }
1573
1574        @Override
1575        public void resetBuffer() {
1576                ensureResponseIsUncommitted();
1577                getResponseOutputStream().reset();
1578        }
1579
1580        @Override
1581        public boolean isCommitted() {
1582                return getResponseCommitted();
1583        }
1584
1585        @Override
1586        public void reset() {
1587                // Clears any data that exists in the buffer as well as the status code, headers.
1588                // The state of calling getWriter() or getOutputStream() is also cleared.
1589                // It is legal, for instance, to call getWriter(), reset() and then getOutputStream().
1590                // If getWriter() or getOutputStream() have been called before this method, then the corresponding returned
1591                // Writer or OutputStream will be staled and the behavior of using the stale object is undefined.
1592                // If the response has been committed, this method throws an IllegalStateException.
1593
1594                ensureResponseIsUncommitted();
1595
1596                setStatusCode(HttpServletResponse.SC_OK);
1597                setServletOutputStream(null);
1598                setPrintWriter(null);
1599                setResponseWriteMethod(ResponseWriteMethod.UNSPECIFIED);
1600                setResponseOutputStream(new ByteArrayOutputStream(getResponseBufferSizeInBytes()));
1601                getHeaders().clear();
1602                getCookies().clear();
1603
1604                // Clear content-type/charset & locale to a pristine state
1605                this.contentType = null;
1606                setCharset(null);
1607                this.locale = null;
1608                this.errorMessage = null;
1609                this.redirectUrl = null;
1610        }
1611
1612        @Override
1613        public void setLocale(@Nullable Locale locale) {
1614                if (isCommitted())
1615                        return;
1616
1617                this.locale = locale;
1618
1619                if (locale != null && !writerObtained() && getCharset().isEmpty()) {
1620                        Charset contextCharset = getContextResponseCharset();
1621                        Charset selectedCharset = contextCharset == null ? DEFAULT_CHARSET : contextCharset;
1622                        setCharacterEncoding(selectedCharset.name());
1623                }
1624
1625                if (locale == null) {
1626                        getHeaders().remove("Content-Language");
1627                        return;
1628                }
1629
1630                String tag = locale.toLanguageTag();
1631
1632                if (tag.isBlank())
1633                        getHeaders().remove("Content-Language");
1634                else
1635                        putHeaderValue("Content-Language", tag, true);
1636        }
1637
1638        @Override
1639        public Locale getLocale() {
1640                return this.locale == null ? Locale.getDefault() : this.locale;
1641        }
1642}