001/*
002 * Copyright 2024-2025 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 jakarta.servlet.ServletOutputStream;
024import jakarta.servlet.http.Cookie;
025import jakarta.servlet.http.HttpServletResponse;
026
027import javax.annotation.Nonnull;
028import javax.annotation.Nullable;
029import javax.annotation.concurrent.NotThreadSafe;
030import java.io.ByteArrayOutputStream;
031import java.io.IOException;
032import java.io.OutputStreamWriter;
033import java.io.PrintWriter;
034import java.net.MalformedURLException;
035import java.net.URL;
036import java.nio.charset.Charset;
037import java.nio.charset.StandardCharsets;
038import java.time.Duration;
039import java.time.Instant;
040import java.time.ZoneId;
041import java.time.format.DateTimeFormatter;
042import java.util.ArrayList;
043import java.util.Collection;
044import java.util.Collections;
045import java.util.HashSet;
046import java.util.List;
047import java.util.Locale;
048import java.util.Map;
049import java.util.Optional;
050import java.util.Set;
051import java.util.TreeMap;
052import java.util.function.Supplier;
053import java.util.stream.Collectors;
054
055import static java.lang.String.format;
056import static java.util.Objects.requireNonNull;
057
058/**
059 * Soklet integration implementation of {@link HttpServletResponse}.
060 *
061 * @author <a href="https://www.revetkn.com">Mark Allen</a>
062 */
063@NotThreadSafe
064public final class SokletHttpServletResponse implements HttpServletResponse {
065        @Nonnull
066        private static final Integer DEFAULT_RESPONSE_BUFFER_SIZE_IN_BYTES;
067        @Nonnull
068        private static final Charset DEFAULT_CHARSET;
069        @Nonnull
070        private static final DateTimeFormatter DATE_TIME_FORMATTER;
071
072        static {
073                DEFAULT_RESPONSE_BUFFER_SIZE_IN_BYTES = 1_024;
074                DEFAULT_CHARSET = StandardCharsets.ISO_8859_1; // Per Servlet spec
075                DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss zzz")
076                                .withLocale(Locale.US)
077                                .withZone(ZoneId.of("GMT"));
078        }
079
080        @Nonnull
081        private final String requestPath; // e.g. "/test/abc".  Always starts with "/"
082        @Nonnull
083        private final List<Cookie> cookies;
084        @Nonnull
085        private final Map<String, List<String>> headers;
086        @Nonnull
087        private ByteArrayOutputStream responseOutputStream;
088        @Nonnull
089        private ResponseWriteMethod responseWriteMethod;
090        @Nonnull
091        private Integer statusCode;
092        @Nonnull
093        private Boolean responseCommitted;
094        @Nonnull
095        private Boolean responseFinalized;
096        @Nullable
097        private Locale locale;
098        @Nullable
099        private String errorMessage;
100        @Nullable
101        private String redirectUrl;
102        @Nullable
103        private Charset charset;
104        @Nullable
105        private String contentType;
106        @Nonnull
107        private Integer responseBufferSizeInBytes;
108        @Nullable
109        private SokletServletOutputStream servletOutputStream;
110        @Nullable
111        private SokletServletPrintWriter printWriter;
112
113        @Nonnull
114        public static SokletHttpServletResponse withRequest(@Nonnull Request request) {
115                requireNonNull(request);
116                return new SokletHttpServletResponse(request.getPath());
117        }
118
119        @Nonnull
120        public static SokletHttpServletResponse withRequestPath(@Nonnull String requestPath) {
121                requireNonNull(requestPath);
122                return new SokletHttpServletResponse(requestPath);
123        }
124
125        private SokletHttpServletResponse(@Nonnull String requestPath) {
126                requireNonNull(requestPath);
127
128                this.requestPath = requestPath;
129                this.statusCode = HttpServletResponse.SC_OK;
130                this.responseWriteMethod = ResponseWriteMethod.UNSPECIFIED;
131                this.responseBufferSizeInBytes = DEFAULT_RESPONSE_BUFFER_SIZE_IN_BYTES;
132                this.responseOutputStream = new ByteArrayOutputStream(DEFAULT_RESPONSE_BUFFER_SIZE_IN_BYTES);
133                this.cookies = new ArrayList<>();
134                this.headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
135                this.responseCommitted = false;
136                this.responseFinalized = false;
137        }
138
139        @Nonnull
140        public Response toResponse() {
141                // In the servlet world, there is really no difference between Response and MarshaledResponse
142                MarshaledResponse marshaledResponse = toMarshaledResponse();
143
144                return Response.withStatusCode(marshaledResponse.getStatusCode())
145                                .body(marshaledResponse.getBody().orElse(null))
146                                .headers(marshaledResponse.getHeaders())
147                                .cookies(marshaledResponse.getCookies())
148                                .build();
149        }
150
151        @Nonnull
152        public MarshaledResponse toMarshaledResponse() {
153                byte[] body = getResponseOutputStream().toByteArray();
154
155                Map<String, Set<String>> headers = getHeaders().entrySet().stream()
156                                .collect(Collectors.toMap(entry -> entry.getKey(), entry -> new HashSet<>(entry.getValue())));
157
158                Set<ResponseCookie> cookies = getCookies().stream()
159                                .map(cookie -> {
160                                        ResponseCookie.Builder builder = ResponseCookie.with(cookie.getName(), cookie.getValue())
161                                                        .path(cookie.getPath())
162                                                        .secure(cookie.getSecure())
163                                                        .httpOnly(cookie.isHttpOnly())
164                                                        .domain(cookie.getDomain());
165
166                                        if (cookie.getMaxAge() >= 0)
167                                                builder.maxAge(Duration.ofSeconds(cookie.getMaxAge()));
168
169                                        return builder.build();
170                                })
171                                .collect(Collectors.toSet());
172
173                return MarshaledResponse.withStatusCode(getStatus())
174                                .body(body)
175                                .headers(headers)
176                                .cookies(cookies)
177                                .build();
178        }
179
180        @Nonnull
181        protected String getRequestPath() {
182                return this.requestPath;
183        }
184
185        @Nonnull
186        protected List<Cookie> getCookies() {
187                return this.cookies;
188        }
189
190        @Nonnull
191        protected Map<String, List<String>> getHeaders() {
192                return this.headers;
193        }
194
195        @Nonnull
196        protected Integer getStatusCode() {
197                return this.statusCode;
198        }
199
200        protected void setStatusCode(@Nonnull Integer statusCode) {
201                requireNonNull(statusCode);
202                this.statusCode = statusCode;
203        }
204
205        @Nonnull
206        protected Optional<String> getErrorMessage() {
207                return Optional.ofNullable(this.errorMessage);
208        }
209
210        protected void setErrorMessage(@Nullable String errorMessage) {
211                this.errorMessage = errorMessage;
212        }
213
214        @Nonnull
215        protected Optional<String> getRedirectUrl() {
216                return Optional.ofNullable(this.redirectUrl);
217        }
218
219        protected void setRedirectUrl(@Nullable String redirectUrl) {
220                this.redirectUrl = redirectUrl;
221        }
222
223        @Nonnull
224        protected Optional<Charset> getCharset() {
225                return Optional.ofNullable(this.charset);
226        }
227
228        protected void setCharset(@Nullable Charset charset) {
229                this.charset = charset;
230        }
231
232        @Nonnull
233        protected Boolean getResponseCommitted() {
234                return this.responseCommitted;
235        }
236
237        protected void setResponseCommitted(@Nonnull Boolean responseCommitted) {
238                requireNonNull(responseCommitted);
239                this.responseCommitted = responseCommitted;
240        }
241
242        @Nonnull
243        protected Boolean getResponseFinalized() {
244                return this.responseFinalized;
245        }
246
247        protected void setResponseFinalized(@Nonnull Boolean responseFinalized) {
248                requireNonNull(responseFinalized);
249                this.responseFinalized = responseFinalized;
250        }
251
252        protected void ensureResponseIsUncommitted() {
253                if (getResponseCommitted())
254                        throw new IllegalStateException("Response has already been committed.");
255        }
256
257        @Nonnull
258        protected String dateHeaderRepresentation(@Nonnull Long millisSinceEpoch) {
259                requireNonNull(millisSinceEpoch);
260                return DATE_TIME_FORMATTER.format(Instant.ofEpochMilli(millisSinceEpoch));
261        }
262
263        @Nonnull
264        protected Optional<SokletServletOutputStream> getServletOutputStream() {
265                return Optional.ofNullable(this.servletOutputStream);
266        }
267
268        protected void setServletOutputStream(@Nullable SokletServletOutputStream servletOutputStream) {
269                this.servletOutputStream = servletOutputStream;
270        }
271
272        @Nonnull
273        protected Optional<SokletServletPrintWriter> getPrintWriter() {
274                return Optional.ofNullable(this.printWriter);
275        }
276
277        public void setPrintWriter(@Nullable SokletServletPrintWriter printWriter) {
278                this.printWriter = printWriter;
279        }
280
281        @Nonnull
282        protected ByteArrayOutputStream getResponseOutputStream() {
283                return this.responseOutputStream;
284        }
285
286        protected void setResponseOutputStream(@Nonnull ByteArrayOutputStream responseOutputStream) {
287                requireNonNull(responseOutputStream);
288                this.responseOutputStream = responseOutputStream;
289        }
290
291        @Nonnull
292        protected Integer getResponseBufferSizeInBytes() {
293                return this.responseBufferSizeInBytes;
294        }
295
296        protected void setResponseBufferSizeInBytes(@Nonnull Integer responseBufferSizeInBytes) {
297                requireNonNull(responseBufferSizeInBytes);
298                this.responseBufferSizeInBytes = responseBufferSizeInBytes;
299        }
300
301        @Nonnull
302        protected ResponseWriteMethod getResponseWriteMethod() {
303                return this.responseWriteMethod;
304        }
305
306        protected void setResponseWriteMethod(@Nonnull ResponseWriteMethod responseWriteMethod) {
307                requireNonNull(responseWriteMethod);
308                this.responseWriteMethod = responseWriteMethod;
309        }
310
311        protected enum ResponseWriteMethod {
312                UNSPECIFIED,
313                SERVLET_OUTPUT_STREAM,
314                PRINT_WRITER
315        }
316
317        // Implementation of HttpServletResponse methods below:
318
319        @Override
320        public void addCookie(@Nullable Cookie cookie) {
321                ensureResponseIsUncommitted();
322
323                if (cookie != null)
324                        getCookies().add(cookie);
325        }
326
327        @Override
328        public boolean containsHeader(@Nullable String name) {
329                return getHeaders().containsKey(name);
330        }
331
332        @Override
333        @Nullable
334        public String encodeURL(@Nullable String url) {
335                return url;
336        }
337
338        @Override
339        @Nullable
340        public String encodeRedirectURL(@Nullable String url) {
341                return url;
342        }
343
344        @Override
345        public void sendError(int sc,
346                                                                                                @Nullable String msg) throws IOException {
347                ensureResponseIsUncommitted();
348                setStatus(sc);
349                setErrorMessage(msg);
350                setResponseCommitted(true);
351        }
352
353        @Override
354        public void sendError(int sc) throws IOException {
355                ensureResponseIsUncommitted();
356                setStatus(sc);
357                setErrorMessage(null);
358                setResponseCommitted(true);
359        }
360
361        @Override
362        public void sendRedirect(@Nullable String location) throws IOException {
363                ensureResponseIsUncommitted();
364                setStatus(HttpServletResponse.SC_FOUND);
365
366                // This method can accept relative URLs; the servlet container must convert the relative URL to an absolute URL
367                // before sending the response to the client. If the location is relative without a leading '/' the container
368                // interprets it as relative to the current request URI. If the location is relative with a leading '/'
369                // the container interprets it as relative to the servlet container root. If the location is relative with two
370                // leading '/' the container interprets it as a network-path reference (see RFC 3986: Uniform Resource
371                // Identifier (URI): Generic Syntax, section 4.2 "Relative Reference").
372                String finalLocation;
373
374                if (location.startsWith("/")) {
375                        // URL is relative with leading /
376                        finalLocation = location;
377                } else {
378                        try {
379                                new URL(location);
380                                // URL is absolute
381                                finalLocation = location;
382                        } catch (MalformedURLException ignored) {
383                                // URL is relative but does not have leading '/', resolve against the parent of the current path
384                                String base = getRequestPath();
385                                int idx = base.lastIndexOf('/');
386                                String parent = (idx <= 0) ? "/" : base.substring(0, idx);
387                                finalLocation = parent.endsWith("/") ? parent + location : parent + "/" + location;
388                        }
389                }
390
391                setRedirectUrl(finalLocation);
392                setHeader("Location", finalLocation);
393
394                flushBuffer();
395                setResponseCommitted(true);
396        }
397
398        @Override
399        public void setDateHeader(@Nullable String name,
400                                                                                                                long date) {
401                ensureResponseIsUncommitted();
402                setHeader(name, dateHeaderRepresentation(date));
403        }
404
405        @Override
406        public void addDateHeader(@Nullable String name,
407                                                                                                                long date) {
408                ensureResponseIsUncommitted();
409                addHeader(name, dateHeaderRepresentation(date));
410        }
411
412        @Override
413        public void setHeader(@Nullable String name,
414                                                                                                @Nullable String value) {
415                ensureResponseIsUncommitted();
416
417                if (name != null && !name.isBlank() && value != null) {
418                        List<String> values = new ArrayList<>();
419                        values.add(value);
420                        getHeaders().put(name, values);
421                }
422        }
423
424        @Override
425        public void addHeader(@Nullable String name,
426                                                                                                @Nullable String value) {
427                ensureResponseIsUncommitted();
428
429                if (name != null && !name.isBlank() && value != null)
430                        getHeaders().computeIfAbsent(name, k -> new ArrayList<>()).add(value);
431        }
432
433        @Override
434        public void setIntHeader(@Nullable String name,
435                                                                                                         int value) {
436                ensureResponseIsUncommitted();
437                setHeader(name, String.valueOf(value));
438        }
439
440        @Override
441        public void addIntHeader(@Nullable String name,
442                                                                                                         int value) {
443                ensureResponseIsUncommitted();
444                addHeader(name, String.valueOf(value));
445        }
446
447        @Override
448        public void setStatus(int sc) {
449                ensureResponseIsUncommitted();
450                this.statusCode = sc;
451        }
452
453        @Override
454        public int getStatus() {
455                return getStatusCode();
456        }
457
458        @Override
459        @Nullable
460        public String getHeader(@Nullable String name) {
461                if (name == null)
462                        return null;
463
464                List<String> values = getHeaders().get(name);
465                return values == null || values.size() == 0 ? null : values.get(0);
466        }
467
468        @Override
469        @Nonnull
470        public Collection<String> getHeaders(@Nullable String name) {
471                if (name == null)
472                        return List.of();
473
474                List<String> values = getHeaders().get(name);
475                return values == null ? List.of() : Collections.unmodifiableList(values);
476        }
477
478        @Override
479        @Nonnull
480        public Collection<String> getHeaderNames() {
481                return Collections.unmodifiableSet(getHeaders().keySet());
482        }
483
484        @Override
485        @Nonnull
486        public String getCharacterEncoding() {
487                return getCharset().orElse(DEFAULT_CHARSET).name();
488        }
489
490        @Override
491        @Nullable
492        public String getContentType() {
493                return this.contentType;
494        }
495
496        @Override
497        @Nonnull
498        public ServletOutputStream getOutputStream() throws IOException {
499                // Returns a ServletOutputStream suitable for writing binary data in the response.
500                // The servlet container does not encode the binary data.
501                // Calling flush() on the ServletOutputStream commits the response.
502                // Either this method or getWriter() may be called to write the body, not both, except when reset() has been called.
503                ResponseWriteMethod currentResponseWriteMethod = getResponseWriteMethod();
504
505                if (currentResponseWriteMethod == ResponseWriteMethod.UNSPECIFIED) {
506                        setResponseWriteMethod(ResponseWriteMethod.SERVLET_OUTPUT_STREAM);
507                        this.servletOutputStream = SokletServletOutputStream.withOutputStream(getResponseOutputStream())
508                                        .onWriteOccurred((ignored1, ignored2) -> {
509                                                // Flip to "committed" if any write occurs
510                                                setResponseCommitted(true);
511                                        }).onWriteFinalized((ignored) -> {
512                                                setResponseFinalized(true);
513                                        }).build();
514                        return getServletOutputStream().get();
515                } else if (currentResponseWriteMethod == ResponseWriteMethod.SERVLET_OUTPUT_STREAM) {
516                        return getServletOutputStream().get();
517                } else {
518                        throw new IllegalStateException(format("Cannot use %s for writing response; already using %s",
519                                        ServletOutputStream.class.getSimpleName(), PrintWriter.class.getSimpleName()));
520                }
521        }
522
523        @Nonnull
524        protected Boolean writerObtained() {
525                return getResponseWriteMethod() == ResponseWriteMethod.PRINT_WRITER;
526        }
527
528        @Nonnull
529        protected Optional<String> extractCharsetFromContentType(@Nullable String type) {
530                if (type == null)
531                        return Optional.empty();
532
533                String[] parts = type.split(";");
534
535                for (int i = 1; i < parts.length; i++) {
536                        String p = parts[i].trim();
537                        if (p.toLowerCase(Locale.ROOT).startsWith("charset=")) {
538                                String cs = p.substring("charset=".length()).trim();
539
540                                if (cs.startsWith("\"") && cs.endsWith("\"") && cs.length() >= 2)
541                                        cs = cs.substring(1, cs.length() - 1);
542
543                                return Optional.of(cs);
544                        }
545                }
546
547                return Optional.empty();
548        }
549
550        // Helper: remove any charset=... from Content-Type (preserve other params)
551        @Nonnull
552        protected Optional<String> stripCharsetParam(@Nullable String type) {
553                if (type == null)
554                        return Optional.empty();
555
556                String[] parts = type.split(";");
557                String base = parts[0].trim();
558                List<String> kept = new ArrayList<>();
559
560                for (int i = 1; i < parts.length; i++) {
561                        String p = parts[i].trim();
562
563                        if (!p.toLowerCase(Locale.ROOT).startsWith("charset=") && !p.isEmpty())
564                                kept.add(p);
565                }
566
567                return Optional.ofNullable(kept.isEmpty() ? base : base + "; " + String.join("; ", kept));
568        }
569
570        // Helper: ensure Content-Type includes the given charset (replacing any existing one)
571        @Nonnull
572        protected Optional<String> withCharset(@Nullable String type,
573                                                                                                                                                                 @Nonnull String charsetName) {
574                requireNonNull(charsetName);
575
576                if (type == null)
577                        return Optional.empty();
578
579                String baseNoCs = stripCharsetParam(type).orElse("text/plain");
580                return Optional.of(baseNoCs + "; charset=" + charsetName);
581        }
582
583        @Override
584        public PrintWriter getWriter() throws IOException {
585                // Returns a PrintWriter object that can send character text to the client.
586                // The PrintWriter uses the character encoding returned by getCharacterEncoding().
587                // If the response's character encoding has not been specified as described in getCharacterEncoding
588                // (i.e., the method just returns the default value ISO-8859-1), getWriter updates it to ISO-8859-1.
589                // Calling flush() on the PrintWriter commits the response.
590                //
591                // Either this method or getOutputStream() may be called to write the body, not both, except when reset() has been called.
592                // Returns a PrintWriter that uses the character encoding returned by getCharacterEncoding().
593                // If not specified yet, calling getWriter() fixes the encoding to ISO-8859-1 per spec.
594                ResponseWriteMethod currentResponseWriteMethod = getResponseWriteMethod();
595
596                if (currentResponseWriteMethod == ResponseWriteMethod.UNSPECIFIED) {
597                        // Freeze encoding now
598                        Charset enc = getCharset().orElse(DEFAULT_CHARSET);
599                        setCharset(enc); // record the chosen encoding explicitly
600
601                        // If a content type is already present and lacks charset, append the frozen charset to header
602                        if (this.contentType != null) {
603                                Optional<String> csInHeader = extractCharsetFromContentType(this.contentType);
604                                if (csInHeader.isEmpty() || !csInHeader.get().equalsIgnoreCase(enc.name())) {
605                                        String updated = withCharset(this.contentType, enc.name()).orElse(null);
606
607                                        if (updated != null) {
608                                                this.contentType = updated;
609                                                setHeader("Content-Type", updated);
610                                        } else {
611                                                setHeader("Content-Type", this.contentType);
612                                        }
613                                }
614                        }
615
616                        setResponseWriteMethod(ResponseWriteMethod.PRINT_WRITER);
617
618                        this.printWriter =
619                                        SokletServletPrintWriter.withWriter(
620                                                                        new OutputStreamWriter(getResponseOutputStream(), enc))
621                                                        .onWriteOccurred((ignored1, ignored2) -> setResponseCommitted(true))   // commit on first write
622                                                        .onWriteFinalized((ignored) -> setResponseFinalized(true))
623                                                        .build();
624
625                        return getPrintWriter().get();
626                } else if (currentResponseWriteMethod == ResponseWriteMethod.PRINT_WRITER) {
627                        return getPrintWriter().get();
628                } else {
629                        throw new IllegalStateException(format("Cannot use %s for writing response; already using %s",
630                                        PrintWriter.class.getSimpleName(), ServletOutputStream.class.getSimpleName()));
631                }
632        }
633
634        @Override
635        public void setCharacterEncoding(@Nullable String charset) {
636                ensureResponseIsUncommitted();
637
638                // Spec: no effect after getWriter() or after commit
639                if (writerObtained())
640                        return;
641
642                if (charset == null || charset.isBlank()) {
643                        // Clear explicit charset; default will be chosen at writer time if needed
644                        setCharset(null);
645
646                        // If a Content-Type is set, remove its charset=... parameter
647                        if (this.contentType != null) {
648                                String updated = stripCharsetParam(this.contentType).orElse(null);
649                                this.contentType = updated;
650                                if (updated == null || updated.isBlank()) {
651                                        getHeaders().remove("Content-Type");
652                                } else {
653                                        setHeader("Content-Type", updated);
654                                }
655                        }
656
657                        return;
658                }
659
660                Charset cs = Charset.forName(charset);
661                setCharset(cs);
662
663                // If a Content-Type is set, reflect/replace the charset=... in the header
664                if (this.contentType != null) {
665                        String updated = withCharset(this.contentType, cs.name()).orElse(null);
666
667                        if (updated != null) {
668                                this.contentType = updated;
669                                setHeader("Content-Type", updated);
670                        } else {
671                                setHeader("Content-Type", this.contentType);
672                        }
673                }
674        }
675
676        @Override
677        public void setContentLength(int len) {
678                ensureResponseIsUncommitted();
679                setHeader("Content-Length", String.valueOf(len));
680        }
681
682        @Override
683        public void setContentLengthLong(long len) {
684                ensureResponseIsUncommitted();
685                setHeader("Content-Length", String.valueOf(len));
686        }
687
688        @Override
689        public void setContentType(@Nullable String type) {
690                // This method may be called repeatedly to change content type and character encoding.
691                // This method has no effect if called after the response has been committed.
692                // It does not set the response's character encoding if it is called after getWriter has been called
693                // or after the response has been committed.
694                if (isCommitted())
695                        return;
696
697                if (!writerObtained()) {
698                        // Before writer: charset can still be established/overridden
699                        this.contentType = type;
700
701                        if (type == null || type.isBlank()) {
702                                getHeaders().remove("Content-Type");
703                                return;
704                        }
705
706                        // If caller specified charset=..., adopt it as the current explicit charset
707                        Optional<String> cs = extractCharsetFromContentType(type);
708                        if (cs.isPresent()) {
709                                setCharset(Charset.forName(cs.get()));
710                                setHeader("Content-Type", type);
711                        } else {
712                                // No charset in type. If an explicit charset already exists (via setCharacterEncoding),
713                                // reflect it in the header; otherwise just set the type as-is.
714                                if (getCharset().isPresent()) {
715                                        String updated = withCharset(type, getCharset().get().name()).orElse(null);
716
717                                        if (updated != null) {
718                                                this.contentType = updated;
719                                                setHeader("Content-Type", updated);
720                                        } else {
721                                                setHeader("Content-Type", type);
722                                        }
723                                } else {
724                                        setHeader("Content-Type", type);
725                                }
726                        }
727                } else {
728                        // After writer: charset is frozen. We can change the MIME type, but we must NOT change encoding.
729                        // If caller supplies a charset, normalize the header back to the locked encoding.
730                        this.contentType = type;
731
732                        if (type == null || type.isBlank()) {
733                                // Allowed: clear header; does not change actual encoding used by writer
734                                getHeaders().remove("Content-Type");
735                                return;
736                        }
737
738                        String locked = getCharacterEncoding(); // the frozen encoding name
739                        String normalized = withCharset(type, locked).orElse(null);
740
741                        if (normalized != null) {
742                                this.contentType = normalized;
743                                setHeader("Content-Type", normalized);
744                        } else {
745                                this.contentType = type;
746                                setHeader("Content-Type", type);
747                        }
748                }
749        }
750
751        @Override
752        public void setBufferSize(int size) {
753                ensureResponseIsUncommitted();
754
755                // Per Servlet spec, setBufferSize must be called before any content is written
756                if (writerObtained() || getServletOutputStream().isPresent() || getResponseOutputStream().size() > 0)
757                        throw new IllegalStateException("setBufferSize must be called before any content is written");
758
759                setResponseBufferSizeInBytes(size);
760                setResponseOutputStream(new ByteArrayOutputStream(getResponseBufferSizeInBytes()));
761        }
762
763        @Override
764        public int getBufferSize() {
765                return getResponseBufferSizeInBytes();
766        }
767
768        @Override
769        public void flushBuffer() throws IOException {
770                ensureResponseIsUncommitted();
771                setResponseCommitted(true);
772                getResponseOutputStream().flush();
773        }
774
775        @Override
776        public void resetBuffer() {
777                ensureResponseIsUncommitted();
778                getResponseOutputStream().reset();
779        }
780
781        @Override
782        public boolean isCommitted() {
783                return getResponseCommitted();
784        }
785
786        @Override
787        public void reset() {
788                // Clears any data that exists in the buffer as well as the status code, headers.
789                // The state of calling getWriter() or getOutputStream() is also cleared.
790                // It is legal, for instance, to call getWriter(), reset() and then getOutputStream().
791                // If getWriter() or getOutputStream() have been called before this method, then the corresponding returned
792                // Writer or OutputStream will be staled and the behavior of using the stale object is undefined.
793                // If the response has been committed, this method throws an IllegalStateException.
794
795                ensureResponseIsUncommitted();
796
797                setStatusCode(HttpServletResponse.SC_OK);
798                setServletOutputStream(null);
799                setPrintWriter(null);
800                setResponseWriteMethod(ResponseWriteMethod.UNSPECIFIED);
801                setResponseOutputStream(new ByteArrayOutputStream(getResponseBufferSizeInBytes()));
802                getHeaders().clear();
803                getCookies().clear();
804
805                // Clear content-type/charset & locale to a pristine state
806                this.contentType = null;
807                setCharset(null);
808                this.locale = null;
809        }
810
811        @Override
812        public void setLocale(@Nullable Locale locale) {
813                ensureResponseIsUncommitted();
814                this.locale = locale;
815        }
816
817        @Override
818        public Locale getLocale() {
819                return this.locale;
820        }
821
822        // *** Jakarta-specific below
823
824        @Nullable
825        private Supplier<Map<String, String>> trailerFieldsSupplier;
826
827        @Nonnull
828        protected String resolveRedirectLocation(@Nonnull String location) {
829                requireNonNull(location);
830
831                // This accepts relative URLs and converts them to a location String suitable for the Location header.
832                String finalLocation;
833
834                if (location.startsWith("/")) {
835                        finalLocation = location;
836                } else {
837                        try {
838                                new URL(location); // absolute
839                                finalLocation = location;
840                        } catch (MalformedURLException ignored) {
841                                String base = getRequestPath();
842                                int idx = base.lastIndexOf('/');
843                                String parent = (idx <= 0) ? "/" : base.substring(0, idx);
844                                finalLocation = parent.endsWith("/") ? parent + location : parent + "/" + location;
845                        }
846                }
847
848                return finalLocation;
849        }
850
851        protected void doSendRedirect(@Nonnull String location,
852                                                                                                                                int statusCode,
853                                                                                                                                boolean clearBuffer) throws IOException {
854                requireNonNull(location);
855
856                ensureResponseIsUncommitted();
857                setStatus(statusCode);
858
859                String finalLocation = resolveRedirectLocation(location);
860
861                setRedirectUrl(finalLocation);
862                setHeader("Location", finalLocation);
863
864                if (clearBuffer)
865                        resetBuffer();
866
867                flushBuffer();
868                setResponseCommitted(true);
869        }
870
871        @Override
872        public void sendRedirect(@Nonnull String location,
873                                                                                                         int statusCode) throws IOException {
874                requireNonNull(location);
875                doSendRedirect(location, statusCode, true);
876        }
877
878        @Override
879        public void sendRedirect(@Nonnull String location,
880                                                                                                         boolean clearBuffer) throws IOException {
881                requireNonNull(location);
882                doSendRedirect(location, HttpServletResponse.SC_FOUND, clearBuffer);
883        }
884
885        @Override
886        public void sendRedirect(@Nonnull String location,
887                                                                                                         int statusCode,
888                                                                                                         boolean clearBuffer) throws IOException {
889                requireNonNull(location);
890                doSendRedirect(location, statusCode, clearBuffer);
891        }
892
893        @Override
894        public void setTrailerFields(@Nullable Supplier<Map<String, String>> supplier) {
895                // Store the supplier; Soklet does not currently write HTTP trailers when sending the response body.
896                this.trailerFieldsSupplier = supplier;
897        }
898
899        @Override
900        @Nullable
901        public Supplier<Map<String, String>> getTrailerFields() {
902                return this.trailerFieldsSupplier;
903        }
904
905        @Override
906        public void setCharacterEncoding(@Nullable Charset charset) {
907                if (charset == null)
908                        setCharacterEncoding((String) null);
909                else
910                        setCharacterEncoding(charset.name());
911        }
912}