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