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.javax; 018 019import com.soklet.MarshaledResponse; 020import com.soklet.Request; 021import com.soklet.Response; 022import com.soklet.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.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.stream.Collectors; 053 054import static java.lang.String.format; 055import static java.util.Objects.requireNonNull; 056 057/** 058 * Soklet integration implementation of {@link HttpServletResponse}. 059 * 060 * @author <a href="https://www.revetkn.com">Mark Allen</a> 061 */ 062@NotThreadSafe 063public final class SokletHttpServletResponse implements HttpServletResponse { 064 @Nonnull 065 private static final Integer DEFAULT_RESPONSE_BUFFER_SIZE_IN_BYTES; 066 @Nonnull 067 private static final Charset DEFAULT_CHARSET; 068 @Nonnull 069 private static final DateTimeFormatter DATE_TIME_FORMATTER; 070 071 static { 072 DEFAULT_RESPONSE_BUFFER_SIZE_IN_BYTES = 1_024; 073 DEFAULT_CHARSET = StandardCharsets.ISO_8859_1; // Per Servlet spec 074 DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss zzz") 075 .withLocale(Locale.US) 076 .withZone(ZoneId.of("GMT")); 077 } 078 079 @Nonnull 080 private final String requestPath; // e.g. "/test/abc". Always starts with "/" 081 @Nonnull 082 private final List<Cookie> cookies; 083 @Nonnull 084 private final Map<String, List<String>> headers; 085 @Nonnull 086 private ByteArrayOutputStream responseOutputStream; 087 @Nonnull 088 private ResponseWriteMethod responseWriteMethod; 089 @Nonnull 090 private Integer statusCode; 091 @Nonnull 092 private Boolean responseCommitted; 093 @Nonnull 094 private Boolean responseFinalized; 095 @Nullable 096 private Locale locale; 097 @Nullable 098 private String errorMessage; 099 @Nullable 100 private String redirectUrl; 101 @Nullable 102 private Charset charset; 103 @Nullable 104 private String contentType; 105 @Nonnull 106 private Integer responseBufferSizeInBytes; 107 @Nullable 108 private SokletServletOutputStream servletOutputStream; 109 @Nullable 110 private SokletServletPrintWriter printWriter; 111 112 @Nonnull 113 public static SokletHttpServletResponse withRequest(@Nonnull Request request) { 114 requireNonNull(request); 115 return new SokletHttpServletResponse(request.getPath()); 116 } 117 118 @Nonnull 119 public static SokletHttpServletResponse withRequestPath(@Nonnull String requestPath) { 120 requireNonNull(requestPath); 121 return new SokletHttpServletResponse(requestPath); 122 } 123 124 private SokletHttpServletResponse(@Nonnull String requestPath) { 125 requireNonNull(requestPath); 126 127 this.requestPath = requestPath; 128 this.statusCode = HttpServletResponse.SC_OK; 129 this.responseWriteMethod = ResponseWriteMethod.UNSPECIFIED; 130 this.responseBufferSizeInBytes = DEFAULT_RESPONSE_BUFFER_SIZE_IN_BYTES; 131 this.responseOutputStream = new ByteArrayOutputStream(DEFAULT_RESPONSE_BUFFER_SIZE_IN_BYTES); 132 this.cookies = new ArrayList<>(); 133 this.headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); 134 this.responseCommitted = false; 135 this.responseFinalized = false; 136 } 137 138 @Nonnull 139 public Response toResponse() { 140 // In the servlet world, there is really no difference between Response and MarshaledResponse 141 MarshaledResponse marshaledResponse = toMarshaledResponse(); 142 143 return Response.withStatusCode(marshaledResponse.getStatusCode()) 144 .body(marshaledResponse.getBody().orElse(null)) 145 .headers(marshaledResponse.getHeaders()) 146 .cookies(marshaledResponse.getCookies()) 147 .build(); 148 } 149 150 @Nonnull 151 public MarshaledResponse toMarshaledResponse() { 152 byte[] body = getResponseOutputStream().toByteArray(); 153 154 Map<String, Set<String>> headers = getHeaders().entrySet().stream() 155 .collect(Collectors.toMap(entry -> entry.getKey(), entry -> new HashSet<>(entry.getValue()))); 156 157 Set<ResponseCookie> cookies = getCookies().stream() 158 .map(cookie -> { 159 ResponseCookie.Builder builder = ResponseCookie.with(cookie.getName(), cookie.getValue()) 160 .path(cookie.getPath()) 161 .secure(cookie.getSecure()) 162 .httpOnly(cookie.isHttpOnly()) 163 .domain(cookie.getDomain()); 164 165 if (cookie.getMaxAge() >= 0) 166 builder.maxAge(Duration.ofSeconds(cookie.getMaxAge())); 167 168 return builder.build(); 169 }) 170 .collect(Collectors.toSet()); 171 172 return MarshaledResponse.withStatusCode(getStatus()) 173 .body(body) 174 .headers(headers) 175 .cookies(cookies) 176 .build(); 177 } 178 179 @Nonnull 180 protected String getRequestPath() { 181 return this.requestPath; 182 } 183 184 @Nonnull 185 protected List<Cookie> getCookies() { 186 return this.cookies; 187 } 188 189 @Nonnull 190 protected Map<String, List<String>> getHeaders() { 191 return this.headers; 192 } 193 194 @Nonnull 195 protected Integer getStatusCode() { 196 return this.statusCode; 197 } 198 199 protected void setStatusCode(@Nonnull Integer statusCode) { 200 requireNonNull(statusCode); 201 this.statusCode = statusCode; 202 } 203 204 @Nonnull 205 protected Optional<String> getErrorMessage() { 206 return Optional.ofNullable(this.errorMessage); 207 } 208 209 protected void setErrorMessage(@Nullable String errorMessage) { 210 this.errorMessage = errorMessage; 211 } 212 213 @Nonnull 214 protected Optional<String> getRedirectUrl() { 215 return Optional.ofNullable(this.redirectUrl); 216 } 217 218 protected void setRedirectUrl(@Nullable String redirectUrl) { 219 this.redirectUrl = redirectUrl; 220 } 221 222 @Nonnull 223 protected Optional<Charset> getCharset() { 224 return Optional.ofNullable(this.charset); 225 } 226 227 protected void setCharset(@Nullable Charset charset) { 228 this.charset = charset; 229 } 230 231 @Nonnull 232 protected Boolean getResponseCommitted() { 233 return this.responseCommitted; 234 } 235 236 protected void setResponseCommitted(@Nonnull Boolean responseCommitted) { 237 requireNonNull(responseCommitted); 238 this.responseCommitted = responseCommitted; 239 } 240 241 @Nonnull 242 protected Boolean getResponseFinalized() { 243 return this.responseFinalized; 244 } 245 246 protected void setResponseFinalized(@Nonnull Boolean responseFinalized) { 247 requireNonNull(responseFinalized); 248 this.responseFinalized = responseFinalized; 249 } 250 251 protected void ensureResponseIsUncommitted() { 252 if (getResponseCommitted()) 253 throw new IllegalStateException("Response has already been committed."); 254 } 255 256 @Nonnull 257 protected String dateHeaderRepresentation(@Nonnull Long millisSinceEpoch) { 258 requireNonNull(millisSinceEpoch); 259 return DATE_TIME_FORMATTER.format(Instant.ofEpochMilli(millisSinceEpoch)); 260 } 261 262 @Nonnull 263 protected Optional<SokletServletOutputStream> getServletOutputStream() { 264 return Optional.ofNullable(this.servletOutputStream); 265 } 266 267 protected void setServletOutputStream(@Nullable SokletServletOutputStream servletOutputStream) { 268 this.servletOutputStream = servletOutputStream; 269 } 270 271 @Nonnull 272 protected Optional<SokletServletPrintWriter> getPrintWriter() { 273 return Optional.ofNullable(this.printWriter); 274 } 275 276 public void setPrintWriter(@Nullable SokletServletPrintWriter printWriter) { 277 this.printWriter = printWriter; 278 } 279 280 @Nonnull 281 protected ByteArrayOutputStream getResponseOutputStream() { 282 return this.responseOutputStream; 283 } 284 285 protected void setResponseOutputStream(@Nonnull ByteArrayOutputStream responseOutputStream) { 286 requireNonNull(responseOutputStream); 287 this.responseOutputStream = responseOutputStream; 288 } 289 290 @Nonnull 291 protected Integer getResponseBufferSizeInBytes() { 292 return this.responseBufferSizeInBytes; 293 } 294 295 protected void setResponseBufferSizeInBytes(@Nonnull Integer responseBufferSizeInBytes) { 296 requireNonNull(responseBufferSizeInBytes); 297 this.responseBufferSizeInBytes = responseBufferSizeInBytes; 298 } 299 300 @Nonnull 301 protected ResponseWriteMethod getResponseWriteMethod() { 302 return this.responseWriteMethod; 303 } 304 305 protected void setResponseWriteMethod(@Nonnull ResponseWriteMethod responseWriteMethod) { 306 requireNonNull(responseWriteMethod); 307 this.responseWriteMethod = responseWriteMethod; 308 } 309 310 protected enum ResponseWriteMethod { 311 UNSPECIFIED, 312 SERVLET_OUTPUT_STREAM, 313 PRINT_WRITER 314 } 315 316 // Implementation of HttpServletResponse methods below: 317 318 @Override 319 public void addCookie(@Nullable Cookie cookie) { 320 ensureResponseIsUncommitted(); 321 322 if (cookie != null) 323 getCookies().add(cookie); 324 } 325 326 @Override 327 public boolean containsHeader(@Nullable String name) { 328 return getHeaders().containsKey(name); 329 } 330 331 @Override 332 @Nullable 333 public String encodeURL(@Nullable String url) { 334 return url; 335 } 336 337 @Override 338 @Nullable 339 public String encodeRedirectURL(@Nullable String url) { 340 return url; 341 } 342 343 @Override 344 @Deprecated 345 public String encodeUrl(@Nullable String url) { 346 return url; 347 } 348 349 @Override 350 @Deprecated 351 public String encodeRedirectUrl(@Nullable String url) { 352 return url; 353 } 354 355 @Override 356 public void sendError(int sc, 357 @Nullable String msg) throws IOException { 358 ensureResponseIsUncommitted(); 359 setStatus(sc); 360 setErrorMessage(msg); 361 setResponseCommitted(true); 362 } 363 364 @Override 365 public void sendError(int sc) throws IOException { 366 ensureResponseIsUncommitted(); 367 setStatus(sc); 368 setErrorMessage(null); 369 setResponseCommitted(true); 370 } 371 372 @Override 373 public void sendRedirect(@Nullable String location) throws IOException { 374 ensureResponseIsUncommitted(); 375 setStatus(HttpServletResponse.SC_FOUND); 376 377 // This method can accept relative URLs; the servlet container must convert the relative URL to an absolute URL 378 // before sending the response to the client. If the location is relative without a leading '/' the container 379 // interprets it as relative to the current request URI. If the location is relative with a leading '/' 380 // the container interprets it as relative to the servlet container root. If the location is relative with two 381 // leading '/' the container interprets it as a network-path reference (see RFC 3986: Uniform Resource 382 // Identifier (URI): Generic Syntax, section 4.2 "Relative Reference"). 383 String finalLocation; 384 385 if (location.startsWith("/")) { 386 // URL is relative with leading / 387 finalLocation = location; 388 } else { 389 try { 390 new URL(location); 391 // URL is absolute 392 finalLocation = location; 393 } catch (MalformedURLException ignored) { 394 // URL is relative but does not have leading '/', resolve against the parent of the current path 395 String base = getRequestPath(); 396 int idx = base.lastIndexOf('/'); 397 String parent = (idx <= 0) ? "/" : base.substring(0, idx); 398 finalLocation = parent.endsWith("/") ? parent + location : parent + "/" + location; 399 } 400 } 401 402 setRedirectUrl(finalLocation); 403 setHeader("Location", finalLocation); 404 405 flushBuffer(); 406 setResponseCommitted(true); 407 } 408 409 @Override 410 public void setDateHeader(@Nullable String name, 411 long date) { 412 ensureResponseIsUncommitted(); 413 setHeader(name, dateHeaderRepresentation(date)); 414 } 415 416 @Override 417 public void addDateHeader(@Nullable String name, 418 long date) { 419 ensureResponseIsUncommitted(); 420 addHeader(name, dateHeaderRepresentation(date)); 421 } 422 423 @Override 424 public void setHeader(@Nullable String name, 425 @Nullable String value) { 426 ensureResponseIsUncommitted(); 427 428 if (name != null && !name.isBlank() && value != null) { 429 List<String> values = new ArrayList<>(); 430 values.add(value); 431 getHeaders().put(name, values); 432 } 433 } 434 435 @Override 436 public void addHeader(@Nullable String name, 437 @Nullable String value) { 438 ensureResponseIsUncommitted(); 439 440 if (name != null && !name.isBlank() && value != null) 441 getHeaders().computeIfAbsent(name, k -> new ArrayList<>()).add(value); 442 } 443 444 @Override 445 public void setIntHeader(@Nullable String name, 446 int value) { 447 ensureResponseIsUncommitted(); 448 setHeader(name, String.valueOf(value)); 449 } 450 451 @Override 452 public void addIntHeader(@Nullable String name, 453 int value) { 454 ensureResponseIsUncommitted(); 455 addHeader(name, String.valueOf(value)); 456 } 457 458 @Override 459 public void setStatus(int sc) { 460 ensureResponseIsUncommitted(); 461 this.statusCode = sc; 462 } 463 464 @Override 465 @Deprecated 466 public void setStatus(int sc, 467 @Nullable String sm) { 468 ensureResponseIsUncommitted(); 469 this.statusCode = sc; 470 this.errorMessage = sm; 471 } 472 473 @Override 474 public int getStatus() { 475 return getStatusCode(); 476 } 477 478 @Override 479 @Nullable 480 public String getHeader(@Nullable String name) { 481 if (name == null) 482 return null; 483 484 List<String> values = getHeaders().get(name); 485 return values == null || values.size() == 0 ? null : values.get(0); 486 } 487 488 @Override 489 @Nonnull 490 public Collection<String> getHeaders(@Nullable String name) { 491 if (name == null) 492 return List.of(); 493 494 List<String> values = getHeaders().get(name); 495 return values == null ? List.of() : Collections.unmodifiableList(values); 496 } 497 498 @Override 499 @Nonnull 500 public Collection<String> getHeaderNames() { 501 return Collections.unmodifiableSet(getHeaders().keySet()); 502 } 503 504 @Override 505 @Nonnull 506 public String getCharacterEncoding() { 507 return getCharset().orElse(DEFAULT_CHARSET).name(); 508 } 509 510 @Override 511 @Nullable 512 public String getContentType() { 513 return this.contentType; 514 } 515 516 @Override 517 @Nonnull 518 public ServletOutputStream getOutputStream() throws IOException { 519 // Returns a ServletOutputStream suitable for writing binary data in the response. 520 // The servlet container does not encode the binary data. 521 // Calling flush() on the ServletOutputStream commits the response. 522 // Either this method or getWriter() may be called to write the body, not both, except when reset() has been called. 523 ResponseWriteMethod currentResponseWriteMethod = getResponseWriteMethod(); 524 525 if (currentResponseWriteMethod == ResponseWriteMethod.UNSPECIFIED) { 526 setResponseWriteMethod(ResponseWriteMethod.SERVLET_OUTPUT_STREAM); 527 this.servletOutputStream = SokletServletOutputStream.withOutputStream(getResponseOutputStream()) 528 .onWriteOccurred((ignored1, ignored2) -> { 529 // Flip to "committed" if any write occurs 530 setResponseCommitted(true); 531 }).onWriteFinalized((ignored) -> { 532 setResponseFinalized(true); 533 }).build(); 534 return getServletOutputStream().get(); 535 } else if (currentResponseWriteMethod == ResponseWriteMethod.SERVLET_OUTPUT_STREAM) { 536 return getServletOutputStream().get(); 537 } else { 538 throw new IllegalStateException(format("Cannot use %s for writing response; already using %s", 539 ServletOutputStream.class.getSimpleName(), PrintWriter.class.getSimpleName())); 540 } 541 } 542 543 @Nonnull 544 protected Boolean writerObtained() { 545 return getResponseWriteMethod() == ResponseWriteMethod.PRINT_WRITER; 546 } 547 548 @Nonnull 549 protected Optional<String> extractCharsetFromContentType(@Nullable String type) { 550 if (type == null) 551 return Optional.empty(); 552 553 String[] parts = type.split(";"); 554 555 for (int i = 1; i < parts.length; i++) { 556 String p = parts[i].trim(); 557 if (p.toLowerCase(Locale.ROOT).startsWith("charset=")) { 558 String cs = p.substring("charset=".length()).trim(); 559 560 if (cs.startsWith("\"") && cs.endsWith("\"") && cs.length() >= 2) 561 cs = cs.substring(1, cs.length() - 1); 562 563 return Optional.of(cs); 564 } 565 } 566 567 return Optional.empty(); 568 } 569 570 // Helper: remove any charset=... from Content-Type (preserve other params) 571 @Nonnull 572 protected Optional<String> stripCharsetParam(@Nullable String type) { 573 if (type == null) 574 return Optional.empty(); 575 576 String[] parts = type.split(";"); 577 String base = parts[0].trim(); 578 List<String> kept = new ArrayList<>(); 579 580 for (int i = 1; i < parts.length; i++) { 581 String p = parts[i].trim(); 582 583 if (!p.toLowerCase(Locale.ROOT).startsWith("charset=") && !p.isEmpty()) 584 kept.add(p); 585 } 586 587 return Optional.ofNullable(kept.isEmpty() ? base : base + "; " + String.join("; ", kept)); 588 } 589 590 // Helper: ensure Content-Type includes the given charset (replacing any existing one) 591 @Nonnull 592 protected Optional<String> withCharset(@Nullable String type, 593 @Nonnull String charsetName) { 594 requireNonNull(charsetName); 595 596 if (type == null) 597 return Optional.empty(); 598 599 String baseNoCs = stripCharsetParam(type).orElse("text/plain"); 600 return Optional.of(baseNoCs + "; charset=" + charsetName); 601 } 602 603 @Override 604 public PrintWriter getWriter() throws IOException { 605 // Returns a PrintWriter object that can send character text to the client. 606 // The PrintWriter uses the character encoding returned by getCharacterEncoding(). 607 // If the response's character encoding has not been specified as described in getCharacterEncoding 608 // (i.e., the method just returns the default value ISO-8859-1), getWriter updates it to ISO-8859-1. 609 // Calling flush() on the PrintWriter commits the response. 610 // 611 // Either this method or getOutputStream() may be called to write the body, not both, except when reset() has been called. 612 // Returns a PrintWriter that uses the character encoding returned by getCharacterEncoding(). 613 // If not specified yet, calling getWriter() fixes the encoding to ISO-8859-1 per spec. 614 ResponseWriteMethod currentResponseWriteMethod = getResponseWriteMethod(); 615 616 if (currentResponseWriteMethod == ResponseWriteMethod.UNSPECIFIED) { 617 // Freeze encoding now 618 Charset enc = getCharset().orElse(DEFAULT_CHARSET); 619 setCharset(enc); // record the chosen encoding explicitly 620 621 // If a content type is already present and lacks charset, append the frozen charset to header 622 if (this.contentType != null) { 623 Optional<String> csInHeader = extractCharsetFromContentType(this.contentType); 624 if (csInHeader.isEmpty() || !csInHeader.get().equalsIgnoreCase(enc.name())) { 625 String updated = withCharset(this.contentType, enc.name()).orElse(null); 626 627 if (updated != null) { 628 this.contentType = updated; 629 setHeader("Content-Type", updated); 630 } else { 631 setHeader("Content-Type", this.contentType); 632 } 633 } 634 } 635 636 setResponseWriteMethod(ResponseWriteMethod.PRINT_WRITER); 637 638 this.printWriter = 639 SokletServletPrintWriter.withWriter( 640 new OutputStreamWriter(getResponseOutputStream(), enc)) 641 .onWriteOccurred((ignored1, ignored2) -> setResponseCommitted(true)) // commit on first write 642 .onWriteFinalized((ignored) -> setResponseFinalized(true)) 643 .build(); 644 645 return getPrintWriter().get(); 646 } else if (currentResponseWriteMethod == ResponseWriteMethod.PRINT_WRITER) { 647 return getPrintWriter().get(); 648 } else { 649 throw new IllegalStateException(format("Cannot use %s for writing response; already using %s", 650 PrintWriter.class.getSimpleName(), ServletOutputStream.class.getSimpleName())); 651 } 652 } 653 654 @Override 655 public void setCharacterEncoding(@Nullable String charset) { 656 ensureResponseIsUncommitted(); 657 658 // Spec: no effect after getWriter() or after commit 659 if (writerObtained()) 660 return; 661 662 if (charset == null || charset.isBlank()) { 663 // Clear explicit charset; default will be chosen at writer time if needed 664 setCharset(null); 665 666 // If a Content-Type is set, remove its charset=... parameter 667 if (this.contentType != null) { 668 String updated = stripCharsetParam(this.contentType).orElse(null); 669 this.contentType = updated; 670 if (updated == null || updated.isBlank()) { 671 getHeaders().remove("Content-Type"); 672 } else { 673 setHeader("Content-Type", updated); 674 } 675 } 676 677 return; 678 } 679 680 Charset cs = Charset.forName(charset); 681 setCharset(cs); 682 683 // If a Content-Type is set, reflect/replace the charset=... in the header 684 if (this.contentType != null) { 685 String updated = withCharset(this.contentType, cs.name()).orElse(null); 686 687 if (updated != null) { 688 this.contentType = updated; 689 setHeader("Content-Type", updated); 690 } else { 691 setHeader("Content-Type", this.contentType); 692 } 693 } 694 } 695 696 @Override 697 public void setContentLength(int len) { 698 ensureResponseIsUncommitted(); 699 setHeader("Content-Length", String.valueOf(len)); 700 } 701 702 @Override 703 public void setContentLengthLong(long len) { 704 ensureResponseIsUncommitted(); 705 setHeader("Content-Length", String.valueOf(len)); 706 } 707 708 @Override 709 public void setContentType(@Nullable String type) { 710 // This method may be called repeatedly to change content type and character encoding. 711 // This method has no effect if called after the response has been committed. 712 // It does not set the response's character encoding if it is called after getWriter has been called 713 // or after the response has been committed. 714 if (isCommitted()) 715 return; 716 717 if (!writerObtained()) { 718 // Before writer: charset can still be established/overridden 719 this.contentType = type; 720 721 if (type == null || type.isBlank()) { 722 getHeaders().remove("Content-Type"); 723 return; 724 } 725 726 // If caller specified charset=..., adopt it as the current explicit charset 727 Optional<String> cs = extractCharsetFromContentType(type); 728 if (cs.isPresent()) { 729 setCharset(Charset.forName(cs.get())); 730 setHeader("Content-Type", type); 731 } else { 732 // No charset in type. If an explicit charset already exists (via setCharacterEncoding), 733 // reflect it in the header; otherwise just set the type as-is. 734 if (getCharset().isPresent()) { 735 String updated = withCharset(type, getCharset().get().name()).orElse(null); 736 737 if (updated != null) { 738 this.contentType = updated; 739 setHeader("Content-Type", updated); 740 } else { 741 setHeader("Content-Type", type); 742 } 743 } else { 744 setHeader("Content-Type", type); 745 } 746 } 747 } else { 748 // After writer: charset is frozen. We can change the MIME type, but we must NOT change encoding. 749 // If caller supplies a charset, normalize the header back to the locked encoding. 750 this.contentType = type; 751 752 if (type == null || type.isBlank()) { 753 // Allowed: clear header; does not change actual encoding used by writer 754 getHeaders().remove("Content-Type"); 755 return; 756 } 757 758 String locked = getCharacterEncoding(); // the frozen encoding name 759 String normalized = withCharset(type, locked).orElse(null); 760 761 if (normalized != null) { 762 this.contentType = normalized; 763 setHeader("Content-Type", normalized); 764 } else { 765 this.contentType = type; 766 setHeader("Content-Type", type); 767 } 768 } 769 } 770 771 @Override 772 public void setBufferSize(int size) { 773 ensureResponseIsUncommitted(); 774 775 // Per Servlet spec, setBufferSize must be called before any content is written 776 if (writerObtained() || getServletOutputStream().isPresent() || getResponseOutputStream().size() > 0) 777 throw new IllegalStateException("setBufferSize must be called before any content is written"); 778 779 setResponseBufferSizeInBytes(size); 780 setResponseOutputStream(new ByteArrayOutputStream(getResponseBufferSizeInBytes())); 781 } 782 783 @Override 784 public int getBufferSize() { 785 return getResponseBufferSizeInBytes(); 786 } 787 788 @Override 789 public void flushBuffer() throws IOException { 790 ensureResponseIsUncommitted(); 791 setResponseCommitted(true); 792 getResponseOutputStream().flush(); 793 } 794 795 @Override 796 public void resetBuffer() { 797 ensureResponseIsUncommitted(); 798 getResponseOutputStream().reset(); 799 } 800 801 @Override 802 public boolean isCommitted() { 803 return getResponseCommitted(); 804 } 805 806 @Override 807 public void reset() { 808 // Clears any data that exists in the buffer as well as the status code, headers. 809 // The state of calling getWriter() or getOutputStream() is also cleared. 810 // It is legal, for instance, to call getWriter(), reset() and then getOutputStream(). 811 // If getWriter() or getOutputStream() have been called before this method, then the corresponding returned 812 // Writer or OutputStream will be staled and the behavior of using the stale object is undefined. 813 // If the response has been committed, this method throws an IllegalStateException. 814 815 ensureResponseIsUncommitted(); 816 817 setStatusCode(HttpServletResponse.SC_OK); 818 setServletOutputStream(null); 819 setPrintWriter(null); 820 setResponseWriteMethod(ResponseWriteMethod.UNSPECIFIED); 821 setResponseOutputStream(new ByteArrayOutputStream(getResponseBufferSizeInBytes())); 822 getHeaders().clear(); 823 getCookies().clear(); 824 825 // Clear content-type/charset & locale to a pristine state 826 this.contentType = null; 827 setCharset(null); 828 this.locale = null; 829 } 830 831 @Override 832 public void setLocale(@Nullable Locale locale) { 833 ensureResponseIsUncommitted(); 834 this.locale = locale; 835 } 836 837 @Override 838 public Locale getLocale() { 839 return this.locale; 840 } 841}