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}