001/* 002 * Copyright 2022-2026 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; 018 019import com.soklet.internal.spring.LinkedCaseInsensitiveMap; 020import org.jspecify.annotations.NonNull; 021import org.jspecify.annotations.Nullable; 022 023import javax.annotation.concurrent.NotThreadSafe; 024import javax.annotation.concurrent.ThreadSafe; 025import java.io.EOFException; 026import java.io.IOException; 027import java.io.UncheckedIOException; 028import java.nio.ByteBuffer; 029import java.nio.channels.FileChannel; 030import java.nio.file.Files; 031import java.nio.file.Path; 032import java.nio.file.attribute.BasicFileAttributes; 033import java.time.Instant; 034import java.util.LinkedHashSet; 035import java.util.Map; 036import java.util.Map.Entry; 037import java.util.Optional; 038import java.util.Set; 039import java.util.function.Consumer; 040 041import static java.lang.String.format; 042import static java.nio.file.StandardOpenOption.READ; 043import static java.util.Objects.requireNonNull; 044 045/** 046 * A finalized representation of a {@link Response}, suitable for sending to clients over the wire. 047 * <p> 048 * Your application's {@link ResponseMarshaler} is responsible for taking the {@link Response} returned by a <em>Resource Method</em> as input 049 * and converting its {@link Response#getBody()} to a {@link MarshaledResponseBody}. 050 * <p> 051 * For example, if a {@link Response} were to specify a body of {@code List.of("one", "two")}, a {@link ResponseMarshaler} might 052 * convert it to the JSON string {@code ["one", "two"]} and provide as output a corresponding {@link MarshaledResponse} with a byte-array-backed body containing UTF-8 bytes that represent {@code ["one", "two"]}. 053 * <p> 054 * Alternatively, your <em>Resource Method</em> might want to directly serve bytes to clients (e.g. an image or PDF) and skip the {@link ResponseMarshaler} entirely. 055 * To accomplish this, just have your <em>Resource Method</em> return a {@link MarshaledResponse} instance: this tells Soklet "I already know exactly what bytes I want to send; don't go through the normal marshaling process". 056 * <p> 057 * Instances can be acquired via the {@link #withResponse(Response)}, {@link #withStatusCode(Integer)}, or {@link #withFile(Path, Request)} builder factory methods. 058 * Convenience instance factories are also available via {@link #fromResponse(Response)} and {@link #fromStatusCode(Integer)}. 059 * <p> 060 * Full documentation is available at <a href="https://www.soklet.com/docs/response-writing">https://www.soklet.com/docs/response-writing</a>. 061 * 062 * @author <a href="https://www.revetkn.com">Mark Allen</a> 063 */ 064@ThreadSafe 065public final class MarshaledResponse { 066 @NonNull 067 private final Integer statusCode; 068 @NonNull 069 private final Map<@NonNull String, @NonNull Set<@NonNull String>> headers; 070 @NonNull 071 private final Set<@NonNull ResponseCookie> cookies; 072 @Nullable 073 private final MarshaledResponseBody body; 074 @Nullable 075 private final StreamingResponseBody stream; 076 @NonNull 077 private final Boolean headResponseGzipCandidate; 078 079 /** 080 * Acquires a builder for {@link MarshaledResponse} instances. 081 * 082 * @param response the logical response whose values are used to prime this builder 083 * @return the builder 084 */ 085 @NonNull 086 public static Builder withResponse(@NonNull Response response) { 087 requireNonNull(response); 088 089 Object rawBody = response.getBody().orElse(null); 090 MarshaledResponseBody body = null; 091 092 if (rawBody != null && rawBody instanceof byte[] byteArrayBody) 093 body = new MarshaledResponseBody.Bytes(byteArrayBody); 094 095 Builder builder = new Builder(response.getStatusCode()) 096 .headers(response.getHeaders()) 097 .cookies(response.getCookies()); 098 099 if (body != null) 100 builder.body(body); 101 102 return builder; 103 } 104 105 /** 106 * Creates a {@link MarshaledResponse} from a logical {@link Response} without additional customization. 107 * 108 * @param response the logical response whose values are used to construct this instance 109 * @return a {@link MarshaledResponse} instance 110 */ 111 @NonNull 112 public static MarshaledResponse fromResponse(@NonNull Response response) { 113 return withResponse(response).build(); 114 } 115 116 /** 117 * Acquires a builder for {@link MarshaledResponse} instances. 118 * 119 * @param statusCode the HTTP status code for this response 120 * @return the builder 121 */ 122 @NonNull 123 public static Builder withStatusCode(@NonNull Integer statusCode) { 124 requireNonNull(statusCode); 125 return new Builder(statusCode); 126 } 127 128 /** 129 * Creates a {@link MarshaledResponse} with the given status code and no additional customization. 130 * 131 * @param statusCode the HTTP status code for this response 132 * @return a {@link MarshaledResponse} instance 133 */ 134 @NonNull 135 public static MarshaledResponse fromStatusCode(@NonNull Integer statusCode) { 136 return withStatusCode(statusCode).build(); 137 } 138 139 /** 140 * Acquires a file-specific builder for {@link MarshaledResponse} instances. 141 * <p> 142 * Files are special among known-length response bodies: correct file responses can depend on request 143 * headers such as {@code Range}, {@code If-Range}, {@code If-Match}, and {@code If-None-Match}, plus 144 * filesystem metadata such as length and last-modified time. This custom factory keeps that behavior 145 * in one place. Other known-length body types should use {@link Builder#body(MarshaledResponseBody)} 146 * or one of its overloads. 147 * 148 * @param path the file path to write 149 * @param request the incoming request whose method and conditional/range headers should be honored 150 * @return a file-specific builder 151 */ 152 @NonNull 153 public static FileBuilder withFile(@NonNull Path path, 154 @NonNull Request request) { 155 requireNonNull(path); 156 requireNonNull(request); 157 return new FileBuilder(path, request); 158 } 159 160 @NonNull 161 static FileBuilder withFile(@NonNull Path path, 162 @NonNull Request request, 163 @NonNull BasicFileAttributes attributes) { 164 requireNonNull(path); 165 requireNonNull(request); 166 requireNonNull(attributes); 167 return new FileBuilder(path, request).attributes(attributes); 168 } 169 170 /** 171 * Vends a mutable copier seeded with this instance's data, suitable for building new instances. 172 * 173 * @return a copier for this instance 174 */ 175 @NonNull 176 public Copier copy() { 177 return new Copier(this); 178 } 179 180 MarshaledResponse(@NonNull Builder builder) { 181 requireNonNull(builder); 182 183 this.statusCode = builder.statusCode; 184 this.headers = builder.headers == null ? Map.of() : new LinkedCaseInsensitiveMap<>(builder.headers); 185 this.cookies = builder.cookies == null ? Set.of() : new LinkedHashSet<>(builder.cookies); 186 this.body = builder.body; 187 this.stream = builder.stream; 188 this.headResponseGzipCandidate = builder.headResponseGzipCandidate; 189 190 // Verify headers are legal 191 for (Entry<String, Set<String>> entry : this.headers.entrySet()) { 192 String headerName = entry.getKey(); 193 Set<String> headerValues = entry.getValue(); 194 195 for (String headerValue : headerValues) 196 Utilities.validateHeaderNameAndValue(headerName, headerValue); 197 } 198 199 if (getBody().isPresent() && getStream().isPresent()) 200 throw new IllegalStateException("A MarshaledResponse may not specify both a known-length body and a streaming response body."); 201 202 if (getStream().isPresent()) { 203 if (isBodylessStatusCode(getStatusCode())) 204 throw new IllegalStateException(format("HTTP status code %d must not include a streaming response body.", getStatusCode())); 205 206 if (this.headers.containsKey("Content-Length")) 207 throw new IllegalStateException("Streaming responses must not specify Content-Length."); 208 209 if (this.headers.containsKey("Transfer-Encoding")) 210 throw new IllegalStateException("Streaming responses must not specify Transfer-Encoding."); 211 } 212 } 213 214 @Override 215 public String toString() { 216 return format("%s{statusCode=%s, headers=%s, cookies=%s, body=%s}", getClass().getSimpleName(), 217 getStatusCode(), getHeaders(), getCookies(), 218 format("%d bytes", getBodyLength())); 219 } 220 221 /** 222 * The HTTP status code for this response. 223 * 224 * @return the status code 225 */ 226 @NonNull 227 public Integer getStatusCode() { 228 return this.statusCode; 229 } 230 231 /** 232 * The HTTP headers to write for this response. 233 * <p> 234 * Soklet writes one header line per value. If order matters, provide either a {@link java.util.SortedSet} or 235 * {@link java.util.LinkedHashSet} to preserve the desired ordering; otherwise values are naturally sorted for consistency. 236 * 237 * @return the headers to write 238 */ 239 @NonNull 240 public Map<@NonNull String, @NonNull Set<@NonNull String>> getHeaders() { 241 return this.headers; 242 } 243 244 /** 245 * The HTTP cookies to write for this response. 246 * 247 * @return the cookies to write 248 */ 249 @NonNull 250 public Set<@NonNull ResponseCookie> getCookies() { 251 return this.cookies; 252 } 253 254 /** 255 * The finalized HTTP response body to write, if available. 256 * 257 * @return the response body to write, or {@link Optional#empty()}) if no body should be written 258 */ 259 @NonNull 260 public Optional<MarshaledResponseBody> getBody() { 261 return Optional.ofNullable(this.body); 262 } 263 264 /** 265 * The finalized streaming HTTP response body to write, if available. 266 * 267 * @return the streaming response body to write, or {@link Optional#empty()} if no stream should be written 268 */ 269 @NonNull 270 public Optional<StreamingResponseBody> getStream() { 271 return Optional.ofNullable(this.stream); 272 } 273 274 /** 275 * Whether this response has a streaming response body. 276 * 277 * @return {@code true} if this response is streaming 278 */ 279 @NonNull 280 public Boolean isStreaming() { 281 return getStream().isPresent(); 282 } 283 284 /** 285 * The number of bytes this response body will write. 286 * 287 * @return the body length, or {@code 0} if no body is present 288 */ 289 @NonNull 290 public Long getBodyLength() { 291 MarshaledResponseBody body = getBody().orElse(null); 292 return body == null ? 0L : body.getLength(); 293 } 294 295 @NonNull 296 Boolean isHeadResponseGzipCandidate() { 297 return this.headResponseGzipCandidate; 298 } 299 300 byte @Nullable [] bodyBytesOrNull() { 301 MarshaledResponseBody body = getBody().orElse(null); 302 303 if (body == null) 304 return null; 305 306 if (body instanceof MarshaledResponseBody.Bytes bytes) 307 return bytes.getBytes(); 308 309 if (body instanceof MarshaledResponseBody.File file) 310 return materializeFile(file.getPath(), file.getOffset(), file.getCount()); 311 312 if (body instanceof MarshaledResponseBody.FileChannel fileChannel) 313 return materializeFileChannel(fileChannel.getChannel(), fileChannel.getOffset(), fileChannel.getCount(), fileChannel.getCloseOnComplete()); 314 315 if (body instanceof MarshaledResponseBody.ByteBuffer byteBuffer) 316 return materializeByteBuffer(byteBuffer.getBuffer()); 317 318 throw new IllegalStateException(format("Unsupported marshaled response body type: %s", body.getClass().getName())); 319 } 320 321 byte @NonNull [] bodyBytesOrEmpty() { 322 byte[] bytes = bodyBytesOrNull(); 323 return bytes == null ? Utilities.emptyByteArray() : bytes; 324 } 325 326 /** 327 * Builder used to construct instances of {@link MarshaledResponse} via {@link MarshaledResponse#withResponse(Response)} or {@link MarshaledResponse#withStatusCode(Integer)}. 328 * <p> 329 * Known-length bodies and streaming bodies are mutually exclusive. This builder does not automatically clear one 330 * when the other is set; use {@link #withoutBody()} or {@link #withoutStream()} before {@link #build()} when 331 * switching body modes. 332 * <p> 333 * This class is intended for use by a single thread. 334 * 335 * @author <a href="https://www.revetkn.com">Mark Allen</a> 336 */ 337 @NotThreadSafe 338 public static final class Builder { 339 @NonNull 340 private Integer statusCode; 341 @Nullable 342 private Set<@NonNull ResponseCookie> cookies; 343 @Nullable 344 private Map<@NonNull String, @NonNull Set<@NonNull String>> headers; 345 @Nullable 346 private MarshaledResponseBody body; 347 @Nullable 348 private StreamingResponseBody stream; 349 @NonNull 350 private Boolean headResponseGzipCandidate = false; 351 352 Builder(@NonNull Integer statusCode) { 353 requireNonNull(statusCode); 354 this.statusCode = statusCode; 355 } 356 357 @NonNull 358 public Builder statusCode(@NonNull Integer statusCode) { 359 requireNonNull(statusCode); 360 this.statusCode = statusCode; 361 return this; 362 } 363 364 @NonNull 365 public Builder cookies(@Nullable Set<@NonNull ResponseCookie> cookies) { 366 this.cookies = cookies; 367 return this; 368 } 369 370 @NonNull 371 public Builder headers(@Nullable Map<@NonNull String, @NonNull Set<@NonNull String>> headers) { 372 this.headers = headers; 373 return this; 374 } 375 376 /** 377 * Sets a byte-array-backed response body, or removes any current body if {@code bytes} is {@code null}. 378 * 379 * @param bytes the response bytes to write, or {@code null} for no body 380 * @return this builder 381 */ 382 @NonNull 383 public Builder body(byte @Nullable [] bytes) { 384 return bytes == null 385 ? withoutBody() 386 : body(new MarshaledResponseBody.Bytes(bytes)); 387 } 388 389 /** 390 * Sets a response body descriptor, or removes any current body if {@code body} is {@code null}. 391 * 392 * @param body the response body descriptor to write, or {@code null} for no body 393 * @return this builder 394 */ 395 @NonNull 396 public Builder body(@Nullable MarshaledResponseBody body) { 397 if (body == null) 398 return withoutBody(); 399 400 this.body = body; 401 this.headResponseGzipCandidate = false; 402 return this; 403 } 404 405 /** 406 * Sets a path-backed response body, or removes any current body if {@code path} is {@code null}. 407 * 408 * @param path the file path to write, or {@code null} for no body 409 * @return this builder 410 */ 411 @NonNull 412 public Builder body(@Nullable Path path) { 413 if (path == null) 414 return withoutBody(); 415 416 this.body = fileBody(path); 417 this.headResponseGzipCandidate = false; 418 return this; 419 } 420 421 /** 422 * Sets a ranged path-backed response body. 423 * 424 * @param path the file path to write 425 * @param offset the zero-based file offset from which response bytes should be written 426 * @param count the number of file bytes to write 427 * @return this builder 428 */ 429 @NonNull 430 public Builder body(@NonNull Path path, @NonNull Long offset, @NonNull Long count) { 431 requireNonNull(path); 432 this.body = fileBody(path, offset, count); 433 this.headResponseGzipCandidate = false; 434 return this; 435 } 436 437 /** 438 * Sets a file-channel-backed response body. 439 * 440 * @param fileChannel the file channel to write 441 * @param offset the zero-based channel offset from which response bytes should be written 442 * @param count the number of channel bytes to write 443 * @param closeOnComplete whether Soklet should close the channel after response completion 444 * @return this builder 445 */ 446 @NonNull 447 public Builder body(@NonNull FileChannel fileChannel, 448 @NonNull Long offset, 449 @NonNull Long count, 450 @NonNull Boolean closeOnComplete) { 451 requireNonNull(fileChannel); 452 this.body = fileChannelBody(fileChannel, offset, count, closeOnComplete); 453 this.headResponseGzipCandidate = false; 454 return this; 455 } 456 457 /** 458 * Sets a byte-buffer-backed response body, or removes any current body if {@code byteBuffer} is {@code null}. 459 * 460 * @param byteBuffer the byte buffer to write, or {@code null} for no body 461 * @return this builder 462 */ 463 @NonNull 464 public Builder body(@Nullable ByteBuffer byteBuffer) { 465 if (byteBuffer == null) 466 return withoutBody(); 467 468 this.body = new MarshaledResponseBody.ByteBuffer(byteBuffer); 469 this.headResponseGzipCandidate = false; 470 return this; 471 } 472 473 /** 474 * Sets a streaming response body, or removes any current stream if {@code stream} is {@code null}. 475 * <p> 476 * A response may have a known-length body or a stream, but not both. Setting a stream does not remove any 477 * current known-length body; call {@link #withoutBody()} first if replacing a known-length body with a stream. 478 * {@link #build()} rejects responses that still specify both. 479 * 480 * @param stream the streaming response body to write, or {@code null} for no stream 481 * @return this builder 482 */ 483 @NonNull 484 public Builder stream(@Nullable StreamingResponseBody stream) { 485 if (stream == null) 486 return withoutStream(); 487 488 this.stream = stream; 489 this.headResponseGzipCandidate = false; 490 return this; 491 } 492 493 /** 494 * Removes the response body from this builder. 495 * <p> 496 * If the current body owns a caller-supplied {@link FileChannel}, the channel is closed before it is 497 * removed. Path-backed file bodies are lazy and do not hold open resources at this point. 498 * 499 * @return this builder 500 */ 501 @NonNull 502 public Builder withoutBody() { 503 releaseBodyResources(this.body); 504 this.body = null; 505 this.headResponseGzipCandidate = false; 506 return this; 507 } 508 509 /** 510 * Removes the streaming response body from this builder. 511 * 512 * @return this builder 513 */ 514 @NonNull 515 public Builder withoutStream() { 516 this.stream = null; 517 return this; 518 } 519 520 @NonNull 521 Builder headResponseGzipCandidate(@NonNull Boolean headResponseGzipCandidate) { 522 this.headResponseGzipCandidate = requireNonNull(headResponseGzipCandidate); 523 return this; 524 } 525 526 @NonNull 527 public MarshaledResponse build() { 528 return new MarshaledResponse(this); 529 } 530 } 531 532 /** 533 * File-specific builder used by {@link MarshaledResponse#withFile(Path, Request)}. 534 * <p> 535 * Files are special among known-length response bodies because validators, byte ranges, and 536 * {@code HEAD} behavior depend on the current request and filesystem metadata. This builder produces 537 * the final {@link MarshaledResponse} directly from those inputs; for ordinary precomputed bytes, 538 * buffers, channels, or path-backed bodies without HTTP file semantics, use {@link Builder#body(byte[])}, 539 * {@link Builder#body(ByteBuffer)}, {@link Builder#body(FileChannel, Long, Long, Boolean)}, or 540 * {@link Builder#body(Path)} instead. 541 * <p> 542 * This class is intended for use by a single thread. 543 * 544 * @author <a href="https://www.revetkn.com">Mark Allen</a> 545 */ 546 @NotThreadSafe 547 public static final class FileBuilder { 548 private final FileResponse.@NonNull Builder builder; 549 private final FileResponse.@NonNull RequestContext requestContext; 550 551 private FileBuilder(@NonNull Path path, 552 @NonNull Request request) { 553 requireNonNull(path); 554 requireNonNull(request); 555 this.builder = FileResponse.withPath(path); 556 this.requestContext = FileResponse.RequestContext.fromRequest(request); 557 } 558 559 @NonNull 560 public FileBuilder contentType(@Nullable String contentType) { 561 this.builder.contentType(contentType); 562 return this; 563 } 564 565 @NonNull 566 public FileBuilder contentEncoding(@Nullable String contentEncoding) { 567 this.builder.contentEncoding(contentEncoding); 568 return this; 569 } 570 571 @NonNull 572 public FileBuilder entityTag(@Nullable EntityTag entityTag) { 573 this.builder.entityTag(entityTag); 574 return this; 575 } 576 577 @NonNull 578 public FileBuilder lastModified(@Nullable Instant lastModified) { 579 this.builder.lastModified(lastModified); 580 return this; 581 } 582 583 @NonNull 584 public FileBuilder cacheControl(@Nullable String cacheControl) { 585 this.builder.cacheControl(cacheControl); 586 return this; 587 } 588 589 @NonNull 590 public FileBuilder headers(@Nullable Map<@NonNull String, @NonNull Set<@NonNull String>> headers) { 591 this.builder.headers(headers); 592 return this; 593 } 594 595 @NonNull 596 public FileBuilder rangeRequests(@Nullable Boolean rangeRequests) { 597 this.builder.rangeRequests(rangeRequests); 598 return this; 599 } 600 601 @NonNull 602 FileBuilder attributes(@Nullable BasicFileAttributes attributes) { 603 this.builder.attributes(attributes); 604 return this; 605 } 606 607 @NonNull 608 public MarshaledResponse build() { 609 return this.builder.build().marshaledResponseFor(this.requestContext); 610 } 611 } 612 613 /** 614 * Builder used to copy instances of {@link MarshaledResponse} via {@link MarshaledResponse#copy()}. 615 * <p> 616 * This class is intended for use by a single thread. 617 * 618 * @author <a href="https://www.revetkn.com">Mark Allen</a> 619 */ 620 @NotThreadSafe 621 public static final class Copier { 622 @NonNull 623 private final Builder builder; 624 625 Copier(@NonNull MarshaledResponse marshaledResponse) { 626 requireNonNull(marshaledResponse); 627 628 this.builder = new Builder(marshaledResponse.getStatusCode()) 629 .headers(new LinkedCaseInsensitiveMap<>(marshaledResponse.getHeaders())) 630 .cookies(new LinkedHashSet<>(marshaledResponse.getCookies())); 631 632 marshaledResponse.getBody().ifPresent(this.builder::body); 633 marshaledResponse.getStream().ifPresent(this.builder::stream); 634 this.builder.headResponseGzipCandidate(marshaledResponse.isHeadResponseGzipCandidate()); 635 } 636 637 @NonNull 638 public Copier statusCode(@NonNull Integer statusCode) { 639 requireNonNull(statusCode); 640 this.builder.statusCode(statusCode); 641 return this; 642 } 643 644 @NonNull 645 public Copier headers(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> headers) { 646 this.builder.headers(headers); 647 return this; 648 } 649 650 // Convenience method for mutation 651 @NonNull 652 public Copier headers(@NonNull Consumer<Map<@NonNull String, @NonNull Set<@NonNull String>>> headersConsumer) { 653 requireNonNull(headersConsumer); 654 655 if (this.builder.headers == null) 656 this.builder.headers(new LinkedCaseInsensitiveMap<>()); 657 658 headersConsumer.accept(this.builder.headers); 659 return this; 660 } 661 662 @NonNull 663 public Copier cookies(@Nullable Set<@NonNull ResponseCookie> cookies) { 664 this.builder.cookies(cookies); 665 return this; 666 } 667 668 // Convenience method for mutation 669 @NonNull 670 public Copier cookies(@NonNull Consumer<Set<@NonNull ResponseCookie>> cookiesConsumer) { 671 requireNonNull(cookiesConsumer); 672 673 if (this.builder.cookies == null) 674 this.builder.cookies(new LinkedHashSet<>()); 675 676 cookiesConsumer.accept(this.builder.cookies); 677 return this; 678 } 679 680 @NonNull 681 public Copier body(byte @Nullable [] bytes) { 682 this.builder.body(bytes); 683 return this; 684 } 685 686 @NonNull 687 public Copier body(@Nullable MarshaledResponseBody body) { 688 this.builder.body(body); 689 return this; 690 } 691 692 @NonNull 693 public Copier body(@Nullable Path path) { 694 this.builder.body(path); 695 return this; 696 } 697 698 @NonNull 699 public Copier body(@NonNull Path path, @NonNull Long offset, @NonNull Long count) { 700 this.builder.body(path, offset, count); 701 return this; 702 } 703 704 @NonNull 705 public Copier body(@NonNull FileChannel fileChannel, 706 @NonNull Long offset, 707 @NonNull Long count, 708 @NonNull Boolean closeOnComplete) { 709 this.builder.body(fileChannel, offset, count, closeOnComplete); 710 return this; 711 } 712 713 @NonNull 714 public Copier body(@Nullable ByteBuffer byteBuffer) { 715 this.builder.body(byteBuffer); 716 return this; 717 } 718 719 @NonNull 720 public Copier stream(@Nullable StreamingResponseBody stream) { 721 this.builder.stream(stream); 722 return this; 723 } 724 725 /** 726 * Removes the response body from this copier. 727 * <p> 728 * If the current body owns a caller-supplied {@link FileChannel}, the channel is closed before it is 729 * removed. Path-backed file bodies are lazy and do not hold open resources at this point. 730 * 731 * @return this copier 732 */ 733 @NonNull 734 public Copier withoutBody() { 735 this.builder.withoutBody(); 736 return this; 737 } 738 739 /** 740 * Removes the streaming response body from this copier. 741 * 742 * @return this copier 743 */ 744 @NonNull 745 public Copier withoutStream() { 746 this.builder.withoutStream(); 747 return this; 748 } 749 750 @NonNull 751 Copier headResponseGzipCandidate(@NonNull Boolean headResponseGzipCandidate) { 752 this.builder.headResponseGzipCandidate(headResponseGzipCandidate); 753 return this; 754 } 755 756 @NonNull 757 public MarshaledResponse finish() { 758 return this.builder.build(); 759 } 760 } 761 762 private static MarshaledResponseBody.File fileBody(@NonNull Path path) { 763 Long size = fileSize(path); 764 return new MarshaledResponseBody.File(path, 0L, size); 765 } 766 767 private static MarshaledResponseBody.File fileBody(@NonNull Path path, @NonNull Long offset, @NonNull Long count) { 768 Long size = fileSize(path); 769 validateRangeWithinLength(offset, count, size); 770 return new MarshaledResponseBody.File(path, offset, count); 771 } 772 773 private static MarshaledResponseBody.FileChannel fileChannelBody(@NonNull FileChannel fileChannel, 774 @NonNull Long offset, 775 @NonNull Long count, 776 @NonNull Boolean closeOnComplete) { 777 requireNonNull(fileChannel); 778 requireNonNull(closeOnComplete); 779 validateRangeWithinLength(offset, count, fileChannelSize(fileChannel)); 780 return new MarshaledResponseBody.FileChannel(fileChannel, offset, count, closeOnComplete); 781 } 782 783 private static void releaseBodyResources(@Nullable MarshaledResponseBody body) { 784 if (!(body instanceof MarshaledResponseBody.FileChannel fileChannelBody) || !fileChannelBody.getCloseOnComplete()) 785 return; 786 787 try { 788 fileChannelBody.getChannel().close(); 789 } catch (IOException e) { 790 throw new UncheckedIOException("Unable to close file-channel response body.", e); 791 } 792 } 793 794 @NonNull 795 private static Long fileSize(@NonNull Path path) { 796 requireNonNull(path); 797 798 if (!Files.isRegularFile(path)) 799 throw new IllegalArgumentException(format("File body path must reference a regular file: %s", path)); 800 801 if (!Files.isReadable(path)) 802 throw new IllegalArgumentException(format("File body path must be readable: %s", path)); 803 804 try { 805 return Files.size(path); 806 } catch (IOException e) { 807 throw new UncheckedIOException(format("Unable to determine file body length for path: %s", path), e); 808 } 809 } 810 811 @NonNull 812 private static Long fileChannelSize(@NonNull FileChannel fileChannel) { 813 requireNonNull(fileChannel); 814 815 try { 816 return fileChannel.size(); 817 } catch (IOException e) { 818 throw new UncheckedIOException("Unable to determine file-channel body length.", e); 819 } 820 } 821 822 private static void validateRangeWithinLength(@NonNull Long offset, @NonNull Long count, @NonNull Long length) { 823 requireNonNull(offset); 824 requireNonNull(count); 825 requireNonNull(length); 826 827 if (offset < 0) 828 throw new IllegalArgumentException("Offset must be >= 0."); 829 830 if (count < 0) 831 throw new IllegalArgumentException("Count must be >= 0."); 832 833 if (Long.MAX_VALUE - offset < count) 834 throw new IllegalArgumentException("Offset plus count exceeds maximum supported file position."); 835 836 if (offset + count > length) 837 throw new IllegalArgumentException(format("Offset plus count must be <= body length %d.", length)); 838 } 839 840 private static boolean isBodylessStatusCode(@NonNull Integer statusCode) { 841 requireNonNull(statusCode); 842 return (statusCode >= 100 && statusCode < 200) || statusCode == 204 || statusCode == 304; 843 } 844 845 private static byte @NonNull [] materializeFile(@NonNull Path path, @NonNull Long offset, @NonNull Long count) { 846 try (FileChannel fileChannel = FileChannel.open(path, READ)) { 847 return materializeFileChannel(fileChannel, offset, count, false); 848 } catch (IOException e) { 849 throw new UncheckedIOException(format("Unable to read file response body: %s", path), e); 850 } 851 } 852 853 private static byte @NonNull [] materializeFileChannel(@NonNull FileChannel fileChannel, 854 @NonNull Long offset, 855 @NonNull Long count, 856 @NonNull Boolean closeOnComplete) { 857 requireNonNull(fileChannel); 858 requireNonNull(offset); 859 requireNonNull(count); 860 requireNonNull(closeOnComplete); 861 862 if (closeOnComplete) { 863 try (FileChannel managedFileChannel = fileChannel) { 864 return materializeFileChannel(managedFileChannel, offset, count, false); 865 } catch (IOException e) { 866 throw new UncheckedIOException("Unable to close file-channel response body.", e); 867 } 868 } 869 870 if (count > Integer.MAX_VALUE) 871 throw new IllegalStateException("Response body is too large to materialize as a byte array."); 872 873 byte[] bytes = new byte[Math.toIntExact(count)]; 874 ByteBuffer buffer = ByteBuffer.wrap(bytes); 875 long position = offset; 876 877 try { 878 while (buffer.hasRemaining()) { 879 int read = fileChannel.read(buffer, position); 880 if (read < 0) 881 throw new EOFException("File ended before the expected response body length was read."); 882 if (read == 0) 883 throw new EOFException("File did not provide the expected response body length."); 884 position += read; 885 } 886 return bytes; 887 } catch (IOException e) { 888 throw new UncheckedIOException("Unable to materialize file-channel response body.", e); 889 } 890 } 891 892 @NonNull 893 private static byte[] materializeByteBuffer(@NonNull ByteBuffer byteBuffer) { 894 requireNonNull(byteBuffer); 895 896 ByteBuffer duplicate = byteBuffer.asReadOnlyBuffer(); 897 byte[] bytes = new byte[duplicate.remaining()]; 898 duplicate.get(bytes); 899 return bytes; 900 } 901}