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}