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