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