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}