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.core.MarshaledResponse;
020import com.soklet.core.Request;
021import com.soklet.core.Response;
022import com.soklet.core.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                doSendRedirect(location, HttpServletResponse.SC_FOUND, true);
364        }
365
366        @Override
367        public void setDateHeader(@Nullable String name,
368                                                                                                                long date) {
369                ensureResponseIsUncommitted();
370                setHeader(name, dateHeaderRepresentation(date));
371        }
372
373        @Override
374        public void addDateHeader(@Nullable String name,
375                                                                                                                long date) {
376                ensureResponseIsUncommitted();
377                addHeader(name, dateHeaderRepresentation(date));
378        }
379
380        @Override
381        public void setHeader(@Nullable String name,
382                                                                                                @Nullable String value) {
383                ensureResponseIsUncommitted();
384
385                if (name != null && !name.isBlank() && value != null) {
386                        List<String> values = new ArrayList<>();
387                        values.add(value);
388                        getHeaders().put(name, values);
389                }
390        }
391
392        @Override
393        public void addHeader(@Nullable String name,
394                                                                                                @Nullable String value) {
395                ensureResponseIsUncommitted();
396
397                if (name != null && !name.isBlank() && value != null)
398                        getHeaders().computeIfAbsent(name, k -> new ArrayList<>()).add(value);
399        }
400
401        @Override
402        public void setIntHeader(@Nullable String name,
403                                                                                                         int value) {
404                ensureResponseIsUncommitted();
405                setHeader(name, String.valueOf(value));
406        }
407
408        @Override
409        public void addIntHeader(@Nullable String name,
410                                                                                                         int value) {
411                ensureResponseIsUncommitted();
412                addHeader(name, String.valueOf(value));
413        }
414
415        @Override
416        public void setStatus(int sc) {
417                ensureResponseIsUncommitted();
418                this.statusCode = sc;
419        }
420
421        @Override
422        public int getStatus() {
423                return getStatusCode();
424        }
425
426        @Override
427        @Nullable
428        public String getHeader(@Nullable String name) {
429                if (name == null)
430                        return null;
431
432                List<String> values = getHeaders().get(name);
433                return values == null || values.size() == 0 ? null : values.get(0);
434        }
435
436        @Override
437        @Nonnull
438        public Collection<String> getHeaders(@Nullable String name) {
439                if (name == null)
440                        return List.of();
441
442                List<String> values = getHeaders().get(name);
443                return values == null ? List.of() : Collections.unmodifiableList(values);
444        }
445
446        @Override
447        @Nonnull
448        public Collection<String> getHeaderNames() {
449                return Collections.unmodifiableSet(getHeaders().keySet());
450        }
451
452        @Override
453        @Nonnull
454        public String getCharacterEncoding() {
455                return getCharset().orElse(DEFAULT_CHARSET).name();
456        }
457
458        @Override
459        @Nullable
460        public String getContentType() {
461                return this.contentType;
462        }
463
464        @Override
465        @Nonnull
466        public ServletOutputStream getOutputStream() throws IOException {
467                // Returns a ServletOutputStream suitable for writing binary data in the response.
468                // The servlet container does not encode the binary data.
469                // Calling flush() on the ServletOutputStream commits the response.
470                // Either this method or getWriter() may be called to write the body, not both, except when reset() has been called.
471                ResponseWriteMethod currentResponseWriteMethod = getResponseWriteMethod();
472
473                if (currentResponseWriteMethod == ResponseWriteMethod.UNSPECIFIED) {
474                        setResponseWriteMethod(ResponseWriteMethod.SERVLET_OUTPUT_STREAM);
475                        this.servletOutputStream = SokletServletOutputStream.builderWithOutputStream(getResponseOutputStream())
476                                        .writeOccurredCallback((ignored) -> {
477                                                // Flip to "committed" if any write occurs
478                                                setResponseCommitted(true);
479                                        }).writeFinalizedCallback((ignored) -> {
480                                                setResponseFinalized(true);
481                                        }).build();
482                        return getServletOutputStream().get();
483                } else if (currentResponseWriteMethod == ResponseWriteMethod.SERVLET_OUTPUT_STREAM) {
484                        return getServletOutputStream().get();
485                } else {
486                        throw new IllegalStateException(format("Cannot use %s for writing response; already using %s",
487                                        ServletOutputStream.class.getSimpleName(), PrintWriter.class.getSimpleName()));
488                }
489        }
490
491        @Nonnull
492        protected Boolean writerObtained() {
493                return getResponseWriteMethod() == ResponseWriteMethod.PRINT_WRITER;
494        }
495
496        @Nonnull
497        protected Optional<String> extractCharsetFromContentType(@Nullable String type) {
498                if (type == null)
499                        return Optional.empty();
500
501                String[] parts = type.split(";");
502
503                for (int i = 1; i < parts.length; i++) {
504                        String p = parts[i].trim();
505                        if (p.toLowerCase(Locale.ROOT).startsWith("charset=")) {
506                                String cs = p.substring("charset=".length()).trim();
507
508                                if (cs.startsWith("\"") && cs.endsWith("\"") && cs.length() >= 2)
509                                        cs = cs.substring(1, cs.length() - 1);
510
511                                return Optional.of(cs);
512                        }
513                }
514
515                return Optional.empty();
516        }
517
518        // Helper: remove any charset=... from Content-Type (preserve other params)
519        @Nonnull
520        protected Optional<String> stripCharsetParam(@Nullable String type) {
521                if (type == null)
522                        return Optional.empty();
523
524                String[] parts = type.split(";");
525                String base = parts[0].trim();
526                List<String> kept = new ArrayList<>();
527
528                for (int i = 1; i < parts.length; i++) {
529                        String p = parts[i].trim();
530
531                        if (!p.toLowerCase(Locale.ROOT).startsWith("charset=") && !p.isEmpty())
532                                kept.add(p);
533                }
534
535                return Optional.ofNullable(kept.isEmpty() ? base : base + "; " + String.join("; ", kept));
536        }
537
538        // Helper: ensure Content-Type includes the given charset (replacing any existing one)
539        @Nonnull
540        protected Optional<String> withCharset(@Nullable String type,
541                                                                                                                                                                 @Nonnull String charsetName) {
542                requireNonNull(charsetName);
543
544                if (type == null)
545                        return Optional.empty();
546
547                String baseNoCs = stripCharsetParam(type).orElse("text/plain");
548                return Optional.of(baseNoCs + "; charset=" + charsetName);
549        }
550
551        @Override
552        public PrintWriter getWriter() throws IOException {
553                // Returns a PrintWriter object that can send character text to the client.
554                // The PrintWriter uses the character encoding returned by getCharacterEncoding().
555                // If the response's character encoding has not been specified as described in getCharacterEncoding
556                // (i.e., the method just returns the default value ISO-8859-1), getWriter updates it to ISO-8859-1.
557                // Calling flush() on the PrintWriter commits the response.
558                //
559                // Either this method or getOutputStream() may be called to write the body, not both, except when reset() has been called.
560                // Returns a PrintWriter that uses the character encoding returned by getCharacterEncoding().
561                // If not specified yet, calling getWriter() fixes the encoding to ISO-8859-1 per spec.
562                ResponseWriteMethod currentResponseWriteMethod = getResponseWriteMethod();
563
564                if (currentResponseWriteMethod == ResponseWriteMethod.UNSPECIFIED) {
565                        // Freeze encoding now
566                        Charset enc = getCharset().orElse(DEFAULT_CHARSET);
567                        setCharset(enc); // record the chosen encoding explicitly
568
569                        // If a content type is already present and lacks charset, append the frozen charset to header
570                        if (this.contentType != null) {
571                                Optional<String> csInHeader = extractCharsetFromContentType(this.contentType);
572                                if (csInHeader.isEmpty() || !csInHeader.get().equalsIgnoreCase(enc.name())) {
573                                        String updated = withCharset(this.contentType, enc.name()).orElse(null);
574
575                                        if (updated != null) {
576                                                this.contentType = updated;
577                                                setHeader("Content-Type", updated);
578                                        } else {
579                                                setHeader("Content-Type", this.contentType);
580                                        }
581                                }
582                        }
583
584                        setResponseWriteMethod(ResponseWriteMethod.PRINT_WRITER);
585
586                        this.printWriter =
587                                        SokletServletPrintWriter.builderWithWriter(
588                                                                        new OutputStreamWriter(getResponseOutputStream(), enc))
589                                                        .writeOccurredCallback((ignored) -> setResponseCommitted(true))   // commit on first write
590                                                        .writeFinalizedCallback((ignored) -> setResponseFinalized(true))
591                                                        .build();
592
593                        return getPrintWriter().get();
594                } else if (currentResponseWriteMethod == ResponseWriteMethod.PRINT_WRITER) {
595                        return getPrintWriter().get();
596                } else {
597                        throw new IllegalStateException(format("Cannot use %s for writing response; already using %s",
598                                        PrintWriter.class.getSimpleName(), ServletOutputStream.class.getSimpleName()));
599                }
600        }
601
602        @Override
603        public void setCharacterEncoding(@Nullable String charset) {
604                ensureResponseIsUncommitted();
605
606                // Spec: no effect after getWriter() or after commit
607                if (writerObtained())
608                        return;
609
610                if (charset == null || charset.isBlank()) {
611                        // Clear explicit charset; default will be chosen at writer time if needed
612                        setCharset(null);
613
614                        // If a Content-Type is set, remove its charset=... parameter
615                        if (this.contentType != null) {
616                                String updated = stripCharsetParam(this.contentType).orElse(null);
617                                this.contentType = updated;
618                                if (updated == null || updated.isBlank()) {
619                                        getHeaders().remove("Content-Type");
620                                } else {
621                                        setHeader("Content-Type", updated);
622                                }
623                        }
624
625                        return;
626                }
627
628                Charset cs = Charset.forName(charset);
629                setCharset(cs);
630
631                // If a Content-Type is set, reflect/replace the charset=... in the header
632                if (this.contentType != null) {
633                        String updated = withCharset(this.contentType, cs.name()).orElse(null);
634
635                        if (updated != null) {
636                                this.contentType = updated;
637                                setHeader("Content-Type", updated);
638                        } else {
639                                setHeader("Content-Type", this.contentType);
640                        }
641                }
642        }
643
644        @Override
645        public void setContentLength(int len) {
646                ensureResponseIsUncommitted();
647                setHeader("Content-Length", String.valueOf(len));
648        }
649
650        @Override
651        public void setContentLengthLong(long len) {
652                ensureResponseIsUncommitted();
653                setHeader("Content-Length", String.valueOf(len));
654        }
655
656        @Override
657        public void setContentType(@Nullable String type) {
658                // This method may be called repeatedly to change content type and character encoding.
659                // This method has no effect if called after the response has been committed.
660                // It does not set the response's character encoding if it is called after getWriter has been called
661                // or after the response has been committed.
662                if (isCommitted())
663                        return;
664
665                if (!writerObtained()) {
666                        // Before writer: charset can still be established/overridden
667                        this.contentType = type;
668
669                        if (type == null || type.isBlank()) {
670                                getHeaders().remove("Content-Type");
671                                return;
672                        }
673
674                        // If caller specified charset=..., adopt it as the current explicit charset
675                        Optional<String> cs = extractCharsetFromContentType(type);
676                        if (cs.isPresent()) {
677                                setCharset(Charset.forName(cs.get()));
678                                setHeader("Content-Type", type);
679                        } else {
680                                // No charset in type. If an explicit charset already exists (via setCharacterEncoding),
681                                // reflect it in the header; otherwise just set the type as-is.
682                                if (getCharset().isPresent()) {
683                                        String updated = withCharset(type, getCharset().get().name()).orElse(null);
684
685                                        if (updated != null) {
686                                                this.contentType = updated;
687                                                setHeader("Content-Type", updated);
688                                        } else {
689                                                setHeader("Content-Type", type);
690                                        }
691                                } else {
692                                        setHeader("Content-Type", type);
693                                }
694                        }
695                } else {
696                        // After writer: charset is frozen. We can change the MIME type, but we must NOT change encoding.
697                        // If caller supplies a charset, normalize the header back to the locked encoding.
698                        this.contentType = type;
699
700                        if (type == null || type.isBlank()) {
701                                // Allowed: clear header; does not change actual encoding used by writer
702                                getHeaders().remove("Content-Type");
703                                return;
704                        }
705
706                        String locked = getCharacterEncoding(); // the frozen encoding name
707                        String normalized = withCharset(type, locked).orElse(null);
708
709                        if (normalized != null) {
710                                this.contentType = normalized;
711                                setHeader("Content-Type", normalized);
712                        } else {
713                                this.contentType = type;
714                                setHeader("Content-Type", type);
715                        }
716                }
717        }
718
719        @Override
720        public void setBufferSize(int size) {
721                ensureResponseIsUncommitted();
722
723                // Per Servlet spec, setBufferSize must be called before any content is written
724                if (writerObtained() || getServletOutputStream().isPresent() || getResponseOutputStream().size() > 0)
725                        throw new IllegalStateException("setBufferSize must be called before any content is written");
726
727                setResponseBufferSizeInBytes(size);
728                setResponseOutputStream(new ByteArrayOutputStream(getResponseBufferSizeInBytes()));
729        }
730
731        @Override
732        public int getBufferSize() {
733                return getResponseBufferSizeInBytes();
734        }
735
736        @Override
737        public void flushBuffer() throws IOException {
738                ensureResponseIsUncommitted();
739                setResponseCommitted(true);
740                getResponseOutputStream().flush();
741        }
742
743        @Override
744        public void resetBuffer() {
745                ensureResponseIsUncommitted();
746                getResponseOutputStream().reset();
747        }
748
749        @Override
750        public boolean isCommitted() {
751                return getResponseCommitted();
752        }
753
754        @Override
755        public void reset() {
756                // Clears any data that exists in the buffer as well as the status code, headers.
757                // The state of calling getWriter() or getOutputStream() is also cleared.
758                // It is legal, for instance, to call getWriter(), reset() and then getOutputStream().
759                // If getWriter() or getOutputStream() have been called before this method, then the corresponding returned
760                // Writer or OutputStream will be staled and the behavior of using the stale object is undefined.
761                // If the response has been committed, this method throws an IllegalStateException.
762
763                ensureResponseIsUncommitted();
764
765                setStatusCode(HttpServletResponse.SC_OK);
766                setServletOutputStream(null);
767                setPrintWriter(null);
768                setResponseWriteMethod(ResponseWriteMethod.UNSPECIFIED);
769                setResponseOutputStream(new ByteArrayOutputStream(getResponseBufferSizeInBytes()));
770                getHeaders().clear();
771                getCookies().clear();
772
773                // Clear content-type/charset & locale to a pristine state
774                this.contentType = null;
775                setCharset(null);
776                this.locale = null;
777        }
778
779        @Override
780        public void setLocale(@Nullable Locale locale) {
781                ensureResponseIsUncommitted();
782                this.locale = locale;
783        }
784
785        @Override
786        public Locale getLocale() {
787                return this.locale;
788        }
789
790        // *** Jakarta-specific below
791
792        @Nullable
793        private Supplier<Map<String, String>> trailerFieldsSupplier;
794
795        @Nonnull
796        protected String resolveRedirectLocation(@Nonnull String location) {
797                requireNonNull(location);
798
799                // This accepts relative URLs and converts them to a location String suitable for the Location header.
800                String finalLocation;
801
802                if (location.startsWith("/")) {
803                        finalLocation = location;
804                } else {
805                        try {
806                                new URL(location); // absolute
807                                finalLocation = location;
808                        } catch (MalformedURLException ignored) {
809                                String base = getRequestPath();
810                                int idx = base.lastIndexOf('/');
811                                String parent = (idx <= 0) ? "/" : base.substring(0, idx);
812                                finalLocation = parent.endsWith("/") ? parent + location : parent + "/" + location;
813                        }
814                }
815
816                return finalLocation;
817        }
818
819        protected void doSendRedirect(@Nonnull String location,
820                                                                                                                                int statusCode,
821                                                                                                                                boolean clearBuffer) throws IOException {
822                requireNonNull(location);
823
824                ensureResponseIsUncommitted();
825                setStatus(statusCode);
826
827                String finalLocation = resolveRedirectLocation(location);
828
829                setRedirectUrl(finalLocation);
830                setHeader("Location", finalLocation);
831
832                if (clearBuffer)
833                        resetBuffer();
834
835                flushBuffer();
836                setResponseCommitted(true);
837        }
838
839        @Override
840        public void sendRedirect(@Nonnull String location,
841                                                                                                         int statusCode) throws IOException {
842                requireNonNull(location);
843                doSendRedirect(location, statusCode, true);
844        }
845
846        @Override
847        public void sendRedirect(@Nonnull String location,
848                                                                                                         boolean clearBuffer) throws IOException {
849                requireNonNull(location);
850                doSendRedirect(location, HttpServletResponse.SC_FOUND, clearBuffer);
851        }
852
853        @Override
854        public void sendRedirect(@Nonnull String location,
855                                                                                                         int statusCode,
856                                                                                                         boolean clearBuffer) throws IOException {
857                requireNonNull(location);
858                doSendRedirect(location, statusCode, clearBuffer);
859        }
860
861        @Override
862        public void setTrailerFields(@Nullable Supplier<Map<String, String>> supplier) {
863                // Store the supplier; Soklet does not currently write HTTP trailers when sending the response body.
864                this.trailerFieldsSupplier = supplier;
865        }
866
867        @Override
868        @Nullable
869        public Supplier<Map<String, String>> getTrailerFields() {
870                return this.trailerFieldsSupplier;
871        }
872
873        @Override
874        public void setCharacterEncoding(@Nullable Charset charset) {
875                if (charset == null)
876                        setCharacterEncoding((String) null);
877                else
878                        setCharacterEncoding(charset.name());
879        }
880}