001/*
002 * Copyright 2024 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.core.MarshaledResponse;
020import com.soklet.core.Request;
021import com.soklet.core.Response;
022import com.soklet.core.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.HashMap;
046import java.util.HashSet;
047import java.util.List;
048import java.util.Locale;
049import java.util.Map;
050import java.util.Optional;
051import java.util.Set;
052import java.util.stream.Collectors;
053
054import static java.lang.String.format;
055import static java.util.Objects.requireNonNull;
056
057/**
058 * @author <a href="https://www.revetkn.com">Mark Allen</a>
059 */
060@NotThreadSafe
061public class SokletHttpServletResponse implements HttpServletResponse {
062        @Nonnull
063        private static final Integer DEFAULT_RESPONSE_BUFFER_SIZE_IN_BYTES;
064        @Nonnull
065        private static final Charset DEFAULT_CHARSET;
066        @Nonnull
067        private static final DateTimeFormatter DATE_TIME_FORMATTER;
068
069        static {
070                DEFAULT_RESPONSE_BUFFER_SIZE_IN_BYTES = 1_024;
071                DEFAULT_CHARSET = StandardCharsets.ISO_8859_1; // Per Servlet spec
072                DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss zzz")
073                                .withLocale(Locale.US)
074                                .withZone(ZoneId.of("GMT"));
075        }
076
077        @Nonnull
078        private final String requestPath; // e.g. "/test/abc".  Always starts with "/"
079        @Nonnull
080        private final List<Cookie> cookies;
081        @Nonnull
082        private final Map<String, List<String>> headers;
083        @Nonnull
084        private ByteArrayOutputStream responseOutputStream;
085        @Nonnull
086        private ResponseWriteMethod responseWriteMethod;
087        @Nonnull
088        private Integer statusCode;
089        @Nonnull
090        private Boolean responseCommitted;
091        @Nonnull
092        private Boolean responseFinalized;
093        @Nullable
094        private Locale locale;
095        @Nullable
096        private String errorMessage;
097        @Nullable
098        private String redirectUrl;
099        @Nullable
100        private Charset charset;
101        @Nullable
102        private String contentType;
103        @Nonnull
104        private Integer responseBufferSizeInBytes;
105        @Nullable
106        private SokletServletOutputStream servletOutputStream;
107        @Nullable
108        private SokletServletPrintWriter printWriter;
109
110        public SokletHttpServletResponse(@Nonnull Request request) {
111                this(requireNonNull(request).getPath());
112        }
113
114        public SokletHttpServletResponse(@Nonnull String requestPath) {
115                requireNonNull(requestPath);
116
117                this.requestPath = requestPath;
118                this.statusCode = HttpServletResponse.SC_OK;
119                this.responseWriteMethod = ResponseWriteMethod.UNSPECIFIED;
120                this.responseBufferSizeInBytes = DEFAULT_RESPONSE_BUFFER_SIZE_IN_BYTES;
121                this.responseOutputStream = new ByteArrayOutputStream(DEFAULT_RESPONSE_BUFFER_SIZE_IN_BYTES);
122                this.cookies = new ArrayList<>();
123                this.headers = new HashMap<>();
124                this.responseCommitted = false;
125                this.responseFinalized = false;
126        }
127
128        @Nonnull
129        public Response toResponse() {
130                // In the servlet world, there is really no difference between Response and MarshaledResponse
131                MarshaledResponse marshaledResponse = toMarshaledResponse();
132
133                return new Response.Builder(marshaledResponse.getStatusCode())
134                                .body(marshaledResponse.getBody().orElse(null))
135                                .headers(marshaledResponse.getHeaders())
136                                .cookies(marshaledResponse.getCookies())
137                                .build();
138        }
139
140        @Nonnull
141        public MarshaledResponse toMarshaledResponse() {
142                byte[] body = getResponseOutputStream().toByteArray();
143
144                Map<String, Set<String>> headers = getHeaders().entrySet().stream()
145                                .collect(Collectors.toMap(entry -> entry.getKey(), entry -> new HashSet<>(entry.getValue())));
146
147                Set<ResponseCookie> cookies = getCookies().stream()
148                                .map(cookie -> new ResponseCookie.Builder(cookie.getName(), cookie.getValue())
149                                                .path(cookie.getPath())
150                                                .domain(cookie.getDomain())
151                                                .maxAge(Duration.ofSeconds(cookie.getMaxAge()))
152                                                .build())
153                                .collect(Collectors.toSet());
154
155                return new MarshaledResponse.Builder(getStatus())
156                                .body(body)
157                                .headers(headers)
158                                .cookies(cookies)
159                                .build();
160        }
161
162        @Nonnull
163        protected String getRequestPath() {
164                return this.requestPath;
165        }
166
167        @Nonnull
168        protected List<Cookie> getCookies() {
169                return this.cookies;
170        }
171
172        @Nonnull
173        protected Map<String, List<String>> getHeaders() {
174                return this.headers;
175        }
176
177        @Nonnull
178        protected Integer getStatusCode() {
179                return this.statusCode;
180        }
181
182        protected void setStatusCode(@Nonnull Integer statusCode) {
183                requireNonNull(statusCode);
184                this.statusCode = statusCode;
185        }
186
187        @Nonnull
188        protected Optional<String> getErrorMessage() {
189                return Optional.ofNullable(this.errorMessage);
190        }
191
192        protected void setErrorMessage(@Nullable String errorMessage) {
193                this.errorMessage = errorMessage;
194        }
195
196        @Nonnull
197        protected Optional<String> getRedirectUrl() {
198                return Optional.ofNullable(this.redirectUrl);
199        }
200
201        protected void setRedirectUrl(@Nullable String redirectUrl) {
202                this.redirectUrl = redirectUrl;
203        }
204
205        @Nonnull
206        protected Optional<Charset> getCharset() {
207                return Optional.ofNullable(this.charset);
208        }
209
210        protected void setCharset(@Nullable Charset charset) {
211                this.charset = charset;
212        }
213
214        @Nonnull
215        protected Boolean getResponseCommitted() {
216                return this.responseCommitted;
217        }
218
219        protected void setResponseCommitted(@Nonnull Boolean responseCommitted) {
220                requireNonNull(responseCommitted);
221                this.responseCommitted = responseCommitted;
222        }
223
224        @Nonnull
225        protected Boolean getResponseFinalized() {
226                return this.responseFinalized;
227        }
228
229        protected void setResponseFinalized(@Nonnull Boolean responseFinalized) {
230                requireNonNull(responseFinalized);
231                this.responseFinalized = responseFinalized;
232        }
233
234        protected void ensureResponseIsUncommitted() {
235                if (getResponseCommitted())
236                        throw new IllegalStateException("Response has already been committed.");
237        }
238
239        @Nonnull
240        protected String dateHeaderRepresentation(@Nonnull Long millisSinceEpoch) {
241                requireNonNull(millisSinceEpoch);
242                return DATE_TIME_FORMATTER.format(Instant.ofEpochMilli(millisSinceEpoch));
243        }
244
245        @Nonnull
246        protected Optional<SokletServletOutputStream> getServletOutputStream() {
247                return Optional.ofNullable(this.servletOutputStream);
248        }
249
250        protected void setServletOutputStream(@Nullable SokletServletOutputStream servletOutputStream) {
251                this.servletOutputStream = servletOutputStream;
252        }
253
254        @Nonnull
255        protected Optional<SokletServletPrintWriter> getPrintWriter() {
256                return Optional.ofNullable(this.printWriter);
257        }
258
259        public void setPrintWriter(@Nullable SokletServletPrintWriter printWriter) {
260                this.printWriter = printWriter;
261        }
262
263        @Nonnull
264        protected ByteArrayOutputStream getResponseOutputStream() {
265                return this.responseOutputStream;
266        }
267
268        protected void setResponseOutputStream(@Nonnull ByteArrayOutputStream responseOutputStream) {
269                requireNonNull(responseOutputStream);
270                this.responseOutputStream = responseOutputStream;
271        }
272
273        @Nonnull
274        protected Integer getResponseBufferSizeInBytes() {
275                return this.responseBufferSizeInBytes;
276        }
277
278        protected void setResponseBufferSizeInBytes(@Nonnull Integer responseBufferSizeInBytes) {
279                requireNonNull(responseBufferSizeInBytes);
280                this.responseBufferSizeInBytes = responseBufferSizeInBytes;
281        }
282
283        @Nonnull
284        protected ResponseWriteMethod getResponseWriteMethod() {
285                return this.responseWriteMethod;
286        }
287
288        protected void setResponseWriteMethod(@Nonnull ResponseWriteMethod responseWriteMethod) {
289                requireNonNull(responseWriteMethod);
290                this.responseWriteMethod = responseWriteMethod;
291        }
292
293        protected enum ResponseWriteMethod {
294                UNSPECIFIED,
295                SERVLET_OUTPUT_STREAM,
296                PRINT_WRITER
297        }
298
299        // Implementation of HttpServletResponse methods below:
300
301        @Override
302        public void addCookie(@Nullable Cookie cookie) {
303                ensureResponseIsUncommitted();
304
305                if (cookie != null)
306                        getCookies().add(cookie);
307        }
308
309        @Override
310        public boolean containsHeader(@Nullable String name) {
311                return getHeaders().containsKey(name);
312        }
313
314        @Override
315        @Nullable
316        public String encodeURL(@Nullable String url) {
317                return url;
318        }
319
320        @Override
321        @Nullable
322        public String encodeRedirectURL(@Nullable String url) {
323                return url;
324        }
325
326        @Override
327        @Deprecated
328        public String encodeUrl(@Nullable String url) {
329                return url;
330        }
331
332        @Override
333        @Deprecated
334        public String encodeRedirectUrl(@Nullable String url) {
335                return url;
336        }
337
338        @Override
339        public void sendError(int sc,
340                                                                                                @Nullable String msg) throws IOException {
341                ensureResponseIsUncommitted();
342                setStatus(sc);
343                setErrorMessage(msg);
344                setResponseCommitted(true);
345        }
346
347        @Override
348        public void sendError(int sc) throws IOException {
349                ensureResponseIsUncommitted();
350                setStatus(sc);
351                setErrorMessage(null);
352                setResponseCommitted(true);
353        }
354
355        @Override
356        public void sendRedirect(@Nullable String location) throws IOException {
357                ensureResponseIsUncommitted();
358                setStatus(HttpServletResponse.SC_FOUND);
359
360                // This method can accept relative URLs; the servlet container must convert the relative URL to an absolute URL
361                // before sending the response to the client. If the location is relative without a leading '/' the container
362                // interprets it as relative to the current request URI. If the location is relative with a leading '/'
363                // the container interprets it as relative to the servlet container root. If the location is relative with two
364                // leading '/' the container interprets it as a network-path reference (see RFC 3986: Uniform Resource
365                // Identifier (URI): Generic Syntax, section 4.2 "Relative Reference").
366                if (location.startsWith("/")) {
367                        // URL is relative with leading /
368                        setRedirectUrl(location);
369                } else {
370                        try {
371                                new URL(location);
372                                // URL is absolute
373                                setRedirectUrl(location);
374                        } catch (MalformedURLException ignored) {
375                                // URL is relative but does not have leading /
376                                setRedirectUrl(format("%s/%s", getRequestPath(), location));
377                        }
378                }
379
380                flushBuffer();
381                setResponseCommitted(true);
382        }
383
384        @Override
385        public void setDateHeader(@Nullable String name,
386                                                                                                                long date) {
387                ensureResponseIsUncommitted();
388                setHeader(name, dateHeaderRepresentation(date));
389        }
390
391        @Override
392        public void addDateHeader(@Nullable String name,
393                                                                                                                long date) {
394                ensureResponseIsUncommitted();
395                addHeader(name, dateHeaderRepresentation(date));
396        }
397
398        @Override
399        public void setHeader(@Nullable String name,
400                                                                                                @Nullable String value) {
401                ensureResponseIsUncommitted();
402
403                if (name == null || name.trim().length() == 0 || value == null)
404                        return;
405
406                List<String> values = new ArrayList<>();
407                values.add(value);
408                getHeaders().put(name, values);
409        }
410
411        @Override
412        public void addHeader(@Nullable String name,
413                                                                                                @Nullable String value) {
414                ensureResponseIsUncommitted();
415
416                if (name == null || name.trim().length() == 0 || value == null)
417                        return;
418
419                List<String> values = getHeaders().get(name);
420
421                if (values == null)
422                        setHeader(name, value);
423                else
424                        values.add(value);
425        }
426
427        @Override
428        public void setIntHeader(@Nullable String name,
429                                                                                                         int value) {
430                ensureResponseIsUncommitted();
431                setHeader(name, String.valueOf(value));
432        }
433
434        @Override
435        public void addIntHeader(@Nullable String name,
436                                                                                                         int value) {
437                ensureResponseIsUncommitted();
438                addHeader(name, String.valueOf(value));
439        }
440
441        @Override
442        public void setStatus(int sc) {
443                ensureResponseIsUncommitted();
444                this.statusCode = sc;
445        }
446
447        @Override
448        @Deprecated
449        public void setStatus(int sc,
450                                                                                                @Nullable String sm) {
451                ensureResponseIsUncommitted();
452                this.statusCode = sc;
453                this.errorMessage = sm;
454        }
455
456        @Override
457        public int getStatus() {
458                return getStatusCode();
459        }
460
461        @Override
462        @Nullable
463        public String getHeader(@Nullable String name) {
464                if (name == null)
465                        return null;
466
467                List<String> values = getHeaders().get(name);
468                return values == null || values.size() == 0 ? null : values.get(0);
469        }
470
471        @Override
472        @Nonnull
473        public Collection<String> getHeaders(@Nullable String name) {
474                if (name == null)
475                        return List.of();
476
477                List<String> values = getHeaders().get(name);
478                return values == null ? List.of() : Collections.unmodifiableList(values);
479        }
480
481        @Override
482        @Nonnull
483        public Collection<String> getHeaderNames() {
484                return Collections.unmodifiableSet(getHeaders().keySet());
485        }
486
487        @Override
488        @Nonnull
489        public String getCharacterEncoding() {
490                return getCharset().orElse(DEFAULT_CHARSET).name();
491        }
492
493        @Override
494        @Nullable
495        public String getContentType() {
496                return this.contentType;
497        }
498
499        @Override
500        @Nonnull
501        public ServletOutputStream getOutputStream() throws IOException {
502                // Returns a ServletOutputStream suitable for writing binary data in the response.
503                // The servlet container does not encode the binary data.
504                // Calling flush() on the ServletOutputStream commits the response.
505                // Either this method or getWriter() may be called to write the body, not both, except when reset() has been called.
506                ResponseWriteMethod currentResponseWriteMethod = getResponseWriteMethod();
507
508                if (currentResponseWriteMethod == ResponseWriteMethod.UNSPECIFIED) {
509                        setResponseWriteMethod(ResponseWriteMethod.SERVLET_OUTPUT_STREAM);
510                        this.servletOutputStream = new SokletServletOutputStream(getResponseOutputStream(), (ignored) -> {
511                                // Flip to "committed" if any write occurs
512                                setResponseCommitted(true);
513                        }, (ignored) -> {
514                                setResponseFinalized(true);
515                        });
516                        return getServletOutputStream().get();
517                } else if (currentResponseWriteMethod == ResponseWriteMethod.SERVLET_OUTPUT_STREAM) {
518                        return getServletOutputStream().get();
519                } else {
520                        throw new IllegalStateException(format("Cannot use %s for writing response; already using %s",
521                                        ServletOutputStream.class.getSimpleName(), PrintWriter.class.getSimpleName()));
522                }
523        }
524
525        @Override
526        public PrintWriter getWriter() throws IOException {
527                // Returns a PrintWriter object that can send character text to the client.
528                // The PrintWriter uses the character encoding returned by getCharacterEncoding().
529                // If the response's character encoding has not been specified as described in getCharacterEncoding
530                // (i.e., the method just returns the default value ISO-8859-1), getWriter updates it to ISO-8859-1.
531                // Calling flush() on the PrintWriter commits the response.
532                //
533                // Either this method or getOutputStream() may be called to write the body, not both, except when reset() has been called.
534                ResponseWriteMethod currentResponseWriteMethod = getResponseWriteMethod();
535
536                if (currentResponseWriteMethod == ResponseWriteMethod.UNSPECIFIED) {
537                        // Per spec, if not already ISO-8859-1, update the encoding...
538                        Charset currentCharset = getCharset().orElse(null);
539
540                        if (currentCharset == null)
541                                setCharset(StandardCharsets.ISO_8859_1);
542
543                        setResponseWriteMethod(ResponseWriteMethod.PRINT_WRITER);
544                        this.printWriter = new SokletServletPrintWriter(new OutputStreamWriter(getResponseOutputStream(), getCharacterEncoding()), (ignored) -> {
545                                // Flip to "committed" if any write occurs
546                                setResponseCommitted(true);
547                        }, (ignored) -> {
548                                setResponseFinalized(true);
549                        });
550                        return getPrintWriter().get();
551                } else if (currentResponseWriteMethod == ResponseWriteMethod.PRINT_WRITER) {
552                        return getPrintWriter().get();
553                } else {
554                        throw new IllegalStateException(format("Cannot use %s for writing response; already using %s",
555                                        PrintWriter.class.getSimpleName(), ServletOutputStream.class.getSimpleName()));
556                }
557        }
558
559        @Override
560        public void setCharacterEncoding(@Nullable String charset) {
561                ensureResponseIsUncommitted();
562                setCharset(charset == null ? null : Charset.forName(charset));
563        }
564
565        @Override
566        public void setContentLength(int len) {
567                ensureResponseIsUncommitted();
568                setHeader("Content-Length", String.valueOf(len));
569        }
570
571        @Override
572        public void setContentLengthLong(long len) {
573                ensureResponseIsUncommitted();
574                setHeader("Content-Length", String.valueOf(len));
575        }
576
577        @Override
578        public void setContentType(@Nullable String type) {
579                // This method may be called repeatedly to change content type and character encoding.
580                // This method has no effect if called after the response has been committed.
581                // It does not set the response's character encoding if it is called after getWriter has been called
582                // or after the response has been committed.
583                if (isCommitted())
584                        return;
585
586                this.contentType = type;
587                setHeader("Content-Type", type);
588        }
589
590        @Override
591        public void setBufferSize(int size) {
592                ensureResponseIsUncommitted();
593                setResponseBufferSizeInBytes(size);
594                setResponseOutputStream(new ByteArrayOutputStream(getResponseBufferSizeInBytes()));
595        }
596
597        @Override
598        public int getBufferSize() {
599                return getResponseBufferSizeInBytes();
600        }
601
602        @Override
603        public void flushBuffer() throws IOException {
604                ensureResponseIsUncommitted();
605                setResponseCommitted(true);
606                getResponseOutputStream().flush();
607        }
608
609        @Override
610        public void resetBuffer() {
611                ensureResponseIsUncommitted();
612                getResponseOutputStream().reset();
613        }
614
615        @Override
616        public boolean isCommitted() {
617                return getResponseCommitted();
618        }
619
620        @Override
621        public void reset() {
622                // Clears any data that exists in the buffer as well as the status code, headers.
623                // The state of calling getWriter() or getOutputStream() is also cleared.
624                // It is legal, for instance, to call getWriter(), reset() and then getOutputStream().
625                // If getWriter() or getOutputStream() have been called before this method, then the corresponding returned
626                // Writer or OutputStream will be staled and the behavior of using the stale object is undefined.
627                // If the response has been committed, this method throws an IllegalStateException.
628
629                ensureResponseIsUncommitted();
630
631                setStatusCode(HttpServletResponse.SC_OK);
632                setServletOutputStream(null);
633                setPrintWriter(null);
634                setResponseWriteMethod(ResponseWriteMethod.UNSPECIFIED);
635                setResponseOutputStream(new ByteArrayOutputStream(getResponseBufferSizeInBytes()));
636                getHeaders().clear();
637                getCookies().clear();
638        }
639
640        @Override
641        public void setLocale(@Nullable Locale locale) {
642                ensureResponseIsUncommitted();
643                this.locale = locale;
644        }
645
646        @Override
647        public Locale getLocale() {
648                return this.locale;
649        }
650}