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