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}