This is an automated email from the ASF dual-hosted git repository.

mcgilman pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 89c757999b NIFI-13652 Added Range Header Handling for Content Download 
(#9172)
89c757999b is described below

commit 89c757999b367b63bb6a2009f713b2b3af7fd905
Author: David Handermann <[email protected]>
AuthorDate: Wed Aug 14 13:44:21 2024 -0500

    NIFI-13652 Added Range Header Handling for Content Download (#9172)
    
    This closes #9172
---
 .../apache/nifi/web/NiFiWebApiResourceConfig.java  |   2 +
 .../apache/nifi/web/api/FlowFileQueueResource.java |  36 ++----
 .../nifi/web/api/ProvenanceEventResource.java      |  65 +++-------
 .../config/RangeNotSatisfiableExceptionMapper.java |  45 +++++++
 .../apache/nifi/web/api/streaming/ByteRange.java   |  55 ++++++++
 .../api/streaming/ByteRangeFormatException.java    |  27 ++++
 .../nifi/web/api/streaming/ByteRangeParser.java    |  32 +++++
 .../api/streaming/ByteRangeStreamingOutput.java    |  90 +++++++++++++
 .../web/api/streaming/InputStreamingOutput.java    |  48 +++++++
 .../streaming/RangeNotSatisfiableException.java    |  29 +++++
 .../web/api/streaming/StandardByteRangeParser.java |  83 ++++++++++++
 .../streaming/StreamingOutputResponseBuilder.java  | 132 +++++++++++++++++++
 .../streaming/ByteRangeStreamingOutputTest.java    | 119 +++++++++++++++++
 .../api/streaming/StandardByteRangeParserTest.java | 142 +++++++++++++++++++++
 .../StreamingOutputResponseBuilderTest.java        |  70 ++++++++++
 15 files changed, 905 insertions(+), 70 deletions(-)

diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiResourceConfig.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiResourceConfig.java
index 2590e73646..bf65c1b723 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiResourceConfig.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiResourceConfig.java
@@ -42,6 +42,7 @@ import 
org.apache.nifi.web.api.config.NoResponseFromNodesExceptionMapper;
 import org.apache.nifi.web.api.config.NodeDisconnectionExceptionMapper;
 import org.apache.nifi.web.api.config.NodeReconnectionExceptionMapper;
 import org.apache.nifi.web.api.config.NotFoundExceptionMapper;
+import org.apache.nifi.web.api.config.RangeNotSatisfiableExceptionMapper;
 import org.apache.nifi.web.api.config.ResourceNotFoundExceptionMapper;
 import org.apache.nifi.web.api.config.ThrowableMapper;
 import org.apache.nifi.web.api.config.UnknownNodeExceptionMapper;
@@ -131,6 +132,7 @@ public class NiFiWebApiResourceConfig extends 
ResourceConfig {
         register(NoResponseFromNodesExceptionMapper.class);
         register(NodeDisconnectionExceptionMapper.class);
         register(NodeReconnectionExceptionMapper.class);
+        register(RangeNotSatisfiableExceptionMapper.class);
         register(ResourceNotFoundExceptionMapper.class);
         register(NotFoundExceptionMapper.class);
         register(UnknownNodeExceptionMapper.class);
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowFileQueueResource.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowFileQueueResource.java
index a641846e57..f860f7a55b 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowFileQueueResource.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowFileQueueResource.java
@@ -16,9 +16,6 @@
  */
 package org.apache.nifi.web.api;
 
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
 import java.net.URI;
 
 import io.swagger.v3.oas.annotations.Operation;
@@ -33,13 +30,13 @@ import jakarta.ws.rs.Consumes;
 import jakarta.ws.rs.DELETE;
 import jakarta.ws.rs.DefaultValue;
 import jakarta.ws.rs.GET;
+import jakarta.ws.rs.HeaderParam;
 import jakarta.ws.rs.HttpMethod;
 import jakarta.ws.rs.POST;
 import jakarta.ws.rs.Path;
 import jakarta.ws.rs.PathParam;
 import jakarta.ws.rs.Produces;
 import jakarta.ws.rs.QueryParam;
-import jakarta.ws.rs.WebApplicationException;
 import jakarta.ws.rs.core.MediaType;
 import jakarta.ws.rs.core.Response;
 import jakarta.ws.rs.core.Response.Status;
@@ -52,7 +49,6 @@ import org.apache.nifi.authorization.resource.Authorizable;
 import org.apache.nifi.authorization.user.NiFiUserUtils;
 import org.apache.nifi.cluster.manager.exception.UnknownNodeException;
 import org.apache.nifi.cluster.protocol.NodeIdentifier;
-import org.apache.nifi.stream.io.StreamUtils;
 import org.apache.nifi.web.DownloadableContent;
 import org.apache.nifi.web.NiFiServiceFacade;
 import org.apache.nifi.web.api.dto.DropRequestDTO;
@@ -65,6 +61,7 @@ import org.apache.nifi.web.api.entity.Entity;
 import org.apache.nifi.web.api.entity.FlowFileEntity;
 import org.apache.nifi.web.api.entity.ListingRequestEntity;
 import org.apache.nifi.web.api.request.ClientIdParameter;
+import org.apache.nifi.web.api.streaming.StreamingOutputResponseBuilder;
 import org.apache.nifi.web.util.ResponseBuilderUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Controller;
@@ -194,7 +191,6 @@ public class FlowFileQueueResource extends 
ApplicationResource {
      * @param flowFileUuid The flowfile uuid
      * @param clusterNodeId The cluster node id
      * @return The content stream
-     * @throws InterruptedException if interrupted
      */
     @GET
     @Consumes(MediaType.WILDCARD)
@@ -209,14 +205,20 @@ public class FlowFileQueueResource extends 
ApplicationResource {
     )
     @ApiResponses(
             value = {
+                    @ApiResponse(responseCode = "206", description = "Partial 
Content with range of bytes requested"),
                     @ApiResponse(responseCode = "400", description = "NiFi was 
unable to complete the request because it was invalid. The request should not 
be retried without modification."),
                     @ApiResponse(responseCode = "401", description = "Client 
could not be authenticated."),
                     @ApiResponse(responseCode = "403", description = "Client 
is not authorized to make this request."),
                     @ApiResponse(responseCode = "404", description = "The 
specified resource could not be found."),
-                    @ApiResponse(responseCode = "409", description = "The 
request was valid but NiFi was not in the appropriate state to process it.")
+                    @ApiResponse(responseCode = "409", description = "The 
request was valid but NiFi was not in the appropriate state to process it."),
+                    @ApiResponse(responseCode = "416", description = 
"Requested Range Not Satisfiable based on bytes requested")
             }
     )
     public Response downloadFlowFileContent(
+            @Parameter(
+                    description = "Range of bytes requested"
+            )
+            @HeaderParam("Range") final String rangeHeader,
             @Parameter(
                     description = "If the client id is not specified, new one 
will be generated. This value (whether specified or generated) is included in 
the response."
             )
@@ -257,30 +259,14 @@ public class FlowFileQueueResource extends 
ApplicationResource {
         // get the uri of the request
         final String uri = generateResourceUri("flowfile-queues", 
connectionId, "flowfiles", flowFileUuid, "content");
 
-        // get an input stream to the content
         final DownloadableContent content = 
serviceFacade.getContent(connectionId, flowFileUuid, uri);
+        final Response.ResponseBuilder responseBuilder = noCache(new 
StreamingOutputResponseBuilder(content.getContent()).range(rangeHeader).build());
 
-        // generate a streaming response
-        final StreamingOutput response = new StreamingOutput() {
-            @Override
-            public void write(final OutputStream output) throws IOException, 
WebApplicationException {
-                try (InputStream is = content.getContent()) {
-                    // stream the content to the response
-                    StreamUtils.copy(is, output);
-
-                    // flush the response
-                    output.flush();
-                }
-            }
-        };
-
-        // use the appropriate content type
         String contentType = content.getType();
         if (contentType == null) {
             contentType = MediaType.APPLICATION_OCTET_STREAM;
         }
-
-        final Response.ResponseBuilder responseBuilder = 
generateOkResponse(response).type(contentType);
+        responseBuilder.type(contentType);
         return ResponseBuilderUtils.setContentDisposition(responseBuilder, 
content.getFilename()).build();
     }
 
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ProvenanceEventResource.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ProvenanceEventResource.java
index ec705ebb18..ff72e78535 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ProvenanceEventResource.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ProvenanceEventResource.java
@@ -28,13 +28,13 @@ import jakarta.servlet.http.HttpServletRequest;
 import jakarta.ws.rs.Consumes;
 import jakarta.ws.rs.DefaultValue;
 import jakarta.ws.rs.GET;
+import jakarta.ws.rs.HeaderParam;
 import jakarta.ws.rs.HttpMethod;
 import jakarta.ws.rs.POST;
 import jakarta.ws.rs.Path;
 import jakarta.ws.rs.PathParam;
 import jakarta.ws.rs.Produces;
 import jakarta.ws.rs.QueryParam;
-import jakarta.ws.rs.WebApplicationException;
 import jakarta.ws.rs.core.Context;
 import jakarta.ws.rs.core.MediaType;
 import jakarta.ws.rs.core.Response;
@@ -48,7 +48,6 @@ import 
org.apache.nifi.cluster.coordination.ClusterCoordinator;
 import org.apache.nifi.cluster.coordination.http.replication.RequestReplicator;
 import org.apache.nifi.cluster.protocol.NodeIdentifier;
 import org.apache.nifi.controller.repository.claim.ContentDirection;
-import org.apache.nifi.stream.io.StreamUtils;
 import org.apache.nifi.web.DownloadableContent;
 import org.apache.nifi.web.NiFiServiceFacade;
 import org.apache.nifi.web.api.dto.provenance.ProvenanceEventDTO;
@@ -59,15 +58,13 @@ import 
org.apache.nifi.web.api.entity.ReplayLastEventResponseEntity;
 import org.apache.nifi.web.api.entity.ReplayLastEventSnapshotDTO;
 import org.apache.nifi.web.api.entity.SubmitReplayRequestEntity;
 import org.apache.nifi.web.api.request.LongParameter;
+import org.apache.nifi.web.api.streaming.StreamingOutputResponseBuilder;
 import org.apache.nifi.web.util.ResponseBuilderUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Controller;
 
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
 import java.net.URI;
 import java.util.Collections;
 
@@ -104,14 +101,20 @@ public class ProvenanceEventResource extends 
ApplicationResource {
     )
     @ApiResponses(
             value = {
+                    @ApiResponse(responseCode = "206", description = "Partial 
Content with range of bytes requested"),
                     @ApiResponse(responseCode = "400", description = "NiFi was 
unable to complete the request because it was invalid. The request should not 
be retried without modification."),
                     @ApiResponse(responseCode = "401", description = "Client 
could not be authenticated."),
                     @ApiResponse(responseCode = "403", description = "Client 
is not authorized to make this request."),
                     @ApiResponse(responseCode = "404", description = "The 
specified resource could not be found."),
-                    @ApiResponse(responseCode = "409", description = "The 
request was valid but NiFi was not in the appropriate state to process it.")
+                    @ApiResponse(responseCode = "409", description = "The 
request was valid but NiFi was not in the appropriate state to process it."),
+                    @ApiResponse(responseCode = "416", description = 
"Requested Range Not Satisfiable based on bytes requested")
             }
     )
     public Response getInputContent(
+            @Parameter(
+                    description = "Range of bytes requested"
+            )
+            @HeaderParam("Range") final String rangeHeader,
             @Parameter(
                     description = "The id of the node where the content exists 
if clustered."
             )
@@ -140,30 +143,13 @@ public class ProvenanceEventResource extends 
ApplicationResource {
         // get the uri of the request
         final String uri = generateResourceUri("provenance", "events", 
String.valueOf(id.getLong()), "content", "input");
 
-        // get an input stream to the content
         final DownloadableContent content = 
serviceFacade.getContent(id.getLong(), uri, ContentDirection.INPUT);
-
-        // generate a streaming response
-        final StreamingOutput response = new StreamingOutput() {
-            @Override
-            public void write(OutputStream output) throws IOException, 
WebApplicationException {
-                try (InputStream is = content.getContent()) {
-                    // stream the content to the response
-                    StreamUtils.copy(is, output);
-
-                    // flush the response
-                    output.flush();
-                }
-            }
-        };
-
-        // use the appropriate content type
+        final Response.ResponseBuilder responseBuilder = noCache(new 
StreamingOutputResponseBuilder(content.getContent()).range(rangeHeader).build());
         String contentType = content.getType();
         if (contentType == null) {
             contentType = MediaType.APPLICATION_OCTET_STREAM;
         }
-
-        final Response.ResponseBuilder responseBuilder = 
generateOkResponse(response).type(contentType);
+        responseBuilder.type(contentType);
         return ResponseBuilderUtils.setContentDisposition(responseBuilder, 
content.getFilename()).build();
     }
 
@@ -188,14 +174,20 @@ public class ProvenanceEventResource extends 
ApplicationResource {
     )
     @ApiResponses(
             value = {
+                    @ApiResponse(responseCode = "206", description = "Partial 
Content with range of bytes requested"),
                     @ApiResponse(responseCode = "400", description = "NiFi was 
unable to complete the request because it was invalid. The request should not 
be retried without modification."),
                     @ApiResponse(responseCode = "401", description = "Client 
could not be authenticated."),
                     @ApiResponse(responseCode = "403", description = "Client 
is not authorized to make this request."),
                     @ApiResponse(responseCode = "404", description = "The 
specified resource could not be found."),
-                    @ApiResponse(responseCode = "409", description = "The 
request was valid but NiFi was not in the appropriate state to process it.")
+                    @ApiResponse(responseCode = "409", description = "The 
request was valid but NiFi was not in the appropriate state to process it."),
+                    @ApiResponse(responseCode = "416", description = 
"Requested Range Not Satisfiable based on bytes requested"),
             }
     )
     public Response getOutputContent(
+            @Parameter(
+                    description = "Range of bytes requested"
+            )
+            @HeaderParam("Range") final String rangeHeader,
             @Parameter(
                     description = "The id of the node where the content exists 
if clustered."
             )
@@ -224,30 +216,13 @@ public class ProvenanceEventResource extends 
ApplicationResource {
         // get the uri of the request
         final String uri = generateResourceUri("provenance", "events", 
String.valueOf(id.getLong()), "content", "output");
 
-        // get an input stream to the content
         final DownloadableContent content = 
serviceFacade.getContent(id.getLong(), uri, ContentDirection.OUTPUT);
-
-        // generate a streaming response
-        final StreamingOutput response = new StreamingOutput() {
-            @Override
-            public void write(OutputStream output) throws IOException, 
WebApplicationException {
-                try (InputStream is = content.getContent()) {
-                    // stream the content to the response
-                    StreamUtils.copy(is, output);
-
-                    // flush the response
-                    output.flush();
-                }
-            }
-        };
-
-        // use the appropriate content type
+        final Response.ResponseBuilder responseBuilder = noCache(new 
StreamingOutputResponseBuilder(content.getContent()).range(rangeHeader).build());
         String contentType = content.getType();
         if (contentType == null) {
             contentType = MediaType.APPLICATION_OCTET_STREAM;
         }
-
-        final Response.ResponseBuilder responseBuilder = 
generateOkResponse(response).type(contentType);
+        responseBuilder.type(contentType);
         return ResponseBuilderUtils.setContentDisposition(responseBuilder, 
content.getFilename()).build();
     }
 
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/config/RangeNotSatisfiableExceptionMapper.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/config/RangeNotSatisfiableExceptionMapper.java
new file mode 100644
index 0000000000..34e89dc06e
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/config/RangeNotSatisfiableExceptionMapper.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.web.api.config;
+
+import jakarta.ws.rs.core.MediaType;
+import org.apache.nifi.web.api.streaming.RangeNotSatisfiableException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.ext.ExceptionMapper;
+import jakarta.ws.rs.ext.Provider;
+
+/**
+ * Map Range Not Satisfiable Exception to HTTP 416 Responses
+ */
+@Provider
+public class RangeNotSatisfiableExceptionMapper implements 
ExceptionMapper<RangeNotSatisfiableException> {
+
+    private static final Logger logger = 
LoggerFactory.getLogger(RangeNotSatisfiableExceptionMapper.class);
+
+    @Override
+    public Response toResponse(final RangeNotSatisfiableException exception) {
+        logger.info("HTTP 416 Range Not Satisfiable: {}", 
exception.getMessage());
+
+        return Response.status(Response.Status.REQUESTED_RANGE_NOT_SATISFIABLE)
+                .entity(exception.getMessage())
+                .type(MediaType.TEXT_PLAIN_TYPE)
+                .build();
+    }
+}
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/ByteRange.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/ByteRange.java
new file mode 100644
index 0000000000..b6d95c937c
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/ByteRange.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.web.api.streaming;
+
+import java.util.Objects;
+import java.util.OptionalLong;
+
+/**
+ * Range of bytes requested as described in RFC 9110 Section 14.1.2 with 
optional first and last positions
+ */
+public class ByteRange {
+    private final Long firstPosition;
+
+    private final Long lastPosition;
+
+    public ByteRange(final Long firstPosition, final Long lastPosition) {
+        if (firstPosition == null) {
+            Objects.requireNonNull(lastPosition, "Last Position required");
+        }
+        this.firstPosition = firstPosition;
+        this.lastPosition = lastPosition;
+    }
+
+    /**
+     * Get first position in byte range which can be empty indicating the last 
position must be specified
+     *
+     * @return First position starting with 0 or empty
+     */
+    public OptionalLong getFirstPosition() {
+        return firstPosition == null ? OptionalLong.empty() : 
OptionalLong.of(firstPosition);
+    }
+
+    /**
+     * Get last position in byte range which can empty indicating the first 
position must be specified
+     *
+     * @return Last position starting with 0 or empty
+     */
+    public OptionalLong getLastPosition() {
+        return lastPosition == null ? OptionalLong.empty() : 
OptionalLong.of(lastPosition);
+    }
+}
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/ByteRangeFormatException.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/ByteRangeFormatException.java
new file mode 100644
index 0000000000..fb4f18cfa7
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/ByteRangeFormatException.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.web.api.streaming;
+
+/**
+ * Byte Range Format Exception indicating invalid units specified in Range 
Header
+ */
+public class ByteRangeFormatException extends IllegalArgumentException {
+
+    public ByteRangeFormatException(final String message) {
+        super(message);
+    }
+}
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/ByteRangeParser.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/ByteRangeParser.java
new file mode 100644
index 0000000000..e2d3610921
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/ByteRangeParser.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.web.api.streaming;
+
+import java.util.Optional;
+
+/**
+ *  HTTP Range Header Parser abstraction supporting byte ranges as described 
in RFC 9110 Section 14.1.2
+ */
+public interface ByteRangeParser {
+    /**
+     * Read Byte Range from HTTP Range Header
+     *
+     * @param rangeHeader HTTP Range Header
+     * @return Byte Range or empty when Range Header not provided
+     */
+    Optional<ByteRange> readByteRange(String rangeHeader);
+}
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/ByteRangeStreamingOutput.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/ByteRangeStreamingOutput.java
new file mode 100644
index 0000000000..2324bfac01
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/ByteRangeStreamingOutput.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.web.api.streaming;
+
+import jakarta.ws.rs.WebApplicationException;
+import jakarta.ws.rs.core.StreamingOutput;
+import org.apache.nifi.stream.io.LimitingInputStream;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Objects;
+import java.util.OptionalLong;
+
+/**
+ * Streaming Output implementation supporting HTTP Range Header with specified 
first and last byte positions
+ */
+public class ByteRangeStreamingOutput implements StreamingOutput {
+    private final InputStream inputStream;
+
+    private final ByteRange byteRange;
+
+    /**
+     * Byte Range Streaming Output with required arguments
+     *
+     * @param inputStream Input Stream to be transferred
+     * @param byteRange Byte Range containing first and last positions
+     */
+    public ByteRangeStreamingOutput(final InputStream inputStream, final 
ByteRange byteRange) {
+        Objects.requireNonNull(inputStream, "Input Stream required");
+        this.byteRange = Objects.requireNonNull(byteRange, "Byte Range 
required");
+
+        final OptionalLong lastPositionFound = byteRange.getLastPosition();
+        if (lastPositionFound.isPresent()) {
+            final long lastPosition = lastPositionFound.getAsLong();
+
+            final OptionalLong firstPositionFound = 
byteRange.getFirstPosition();
+            if (firstPositionFound.isPresent()) {
+                // Handle int-range when last position indicates limited 
number of bytes
+                this.inputStream = new LimitingInputStream(inputStream, 
lastPosition);
+            } else {
+                // Handle suffix-range when last position indicates the last 
number of bytes from the end
+                this.inputStream = inputStream;
+            }
+        } else {
+            this.inputStream = inputStream;
+        }
+    }
+
+    @Override
+    public void write(final OutputStream outputStream) throws IOException, 
WebApplicationException {
+        try (inputStream) {
+            final OptionalLong firstPositionFound = 
byteRange.getFirstPosition();
+            final OptionalLong lastPositionFound = byteRange.getLastPosition();
+
+            if (firstPositionFound.isPresent()) {
+                // Handle int-range with first position specified
+                final long firstPosition = firstPositionFound.getAsLong();
+                try {
+                    inputStream.skipNBytes(firstPosition);
+                } catch (final EOFException e) {
+                    throw new RangeNotSatisfiableException("First Range 
Position [%d] not valid".formatted(firstPosition), e);
+                }
+            } else if (lastPositionFound.isPresent()) {
+                // Handle suffix-range for last number of bytes specified
+                final long lastPosition = lastPositionFound.getAsLong();
+                final long available = inputStream.available();
+                final long skip = available - lastPosition;
+                inputStream.skipNBytes(skip);
+            }
+
+            inputStream.transferTo(outputStream);
+        }
+    }
+}
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/InputStreamingOutput.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/InputStreamingOutput.java
new file mode 100644
index 0000000000..ffcb07965e
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/InputStreamingOutput.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.web.api.streaming;
+
+import jakarta.ws.rs.WebApplicationException;
+import jakarta.ws.rs.core.StreamingOutput;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Objects;
+
+/**
+ * Streaming Output implementation supporting direct transfer of Input Stream
+ */
+public class InputStreamingOutput implements StreamingOutput {
+    private final InputStream inputStream;
+
+    /**
+     * Streaming Output with required arguments
+     *
+     * @param inputStream Input Stream to be transferred
+     */
+    public InputStreamingOutput(final InputStream inputStream) {
+        this.inputStream = Objects.requireNonNull(inputStream, "Input Stream 
required");
+    }
+
+    @Override
+    public void write(final OutputStream outputStream) throws IOException, 
WebApplicationException {
+        try (inputStream) {
+            inputStream.transferTo(outputStream);
+        }
+    }
+}
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/RangeNotSatisfiableException.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/RangeNotSatisfiableException.java
new file mode 100644
index 0000000000..b290a26e97
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/RangeNotSatisfiableException.java
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.web.api.streaming;
+
+import java.io.IOException;
+
+/**
+ * Runtime Exception indicating that the requested range is outside the bounds 
of available content streams
+ */
+public class RangeNotSatisfiableException extends IOException {
+
+    public RangeNotSatisfiableException(final String message, final Throwable 
cause) {
+        super(message, cause);
+    }
+}
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/StandardByteRangeParser.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/StandardByteRangeParser.java
new file mode 100644
index 0000000000..e985ab92df
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/StandardByteRangeParser.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.web.api.streaming;
+
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Standard implementation of Byte Range Header Parser supporting one range 
specifier of bytes with int-range or suffix-range values
+ */
+public class StandardByteRangeParser implements ByteRangeParser {
+    private static final Pattern BYTE_RANGE_PATTERN = 
Pattern.compile("^bytes=(0|[1-9][0-9]{0,18})?-(0|[1-9][0-9]{0,18})?$");
+
+    private static final int FIRST_POSITION_GROUP = 1;
+
+    private static final int LAST_POSITION_GROUP = 2;
+
+    @Override
+    public Optional<ByteRange> readByteRange(final String rangeHeader) {
+        final ByteRange byteRange;
+
+        if (rangeHeader == null || rangeHeader.isBlank()) {
+            byteRange = null;
+        } else {
+            final Matcher matcher = BYTE_RANGE_PATTERN.matcher(rangeHeader);
+            if (matcher.matches()) {
+                final Long firstPosition;
+                final Long lastPosition;
+
+                final String firstPositionGroup = 
matcher.group(FIRST_POSITION_GROUP);
+                final String lastPositionGroup = 
matcher.group(LAST_POSITION_GROUP);
+
+                if (firstPositionGroup == null) {
+                    if (lastPositionGroup == null) {
+                        throw new ByteRangeFormatException("Range header 
missing first and last positions");
+                    }
+                    firstPosition = null;
+                    lastPosition = parsePosition(lastPositionGroup);
+                } else {
+                    firstPosition = parsePosition(firstPositionGroup);
+                    if (lastPositionGroup == null) {
+                        lastPosition = null;
+                    } else {
+                        lastPosition = parsePosition(lastPositionGroup);
+
+                        if (lastPosition < firstPosition) {
+                            throw new ByteRangeFormatException("Range header 
not valid: last position less than first position");
+                        }
+                    }
+                }
+
+                byteRange = new ByteRange(firstPosition, lastPosition);
+            } else {
+                throw new ByteRangeFormatException("Range header not valid");
+            }
+        }
+
+        return Optional.ofNullable(byteRange);
+    }
+
+    private long parsePosition(final String positionGroup) {
+        try {
+            return Long.parseLong(positionGroup);
+        } catch (final NumberFormatException e) {
+            throw new ByteRangeFormatException("Range header position not 
valid");
+        }
+    }
+}
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/StreamingOutputResponseBuilder.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/StreamingOutputResponseBuilder.java
new file mode 100644
index 0000000000..6aad5004bd
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/streaming/StreamingOutputResponseBuilder.java
@@ -0,0 +1,132 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.web.api.streaming;
+
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.StreamingOutput;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.OptionalLong;
+
+/**
+ * HTTP Response Builder for Streaming Output with optional Range header 
handling
+ */
+public class StreamingOutputResponseBuilder {
+    static final String ACCEPT_RANGES_HEADER = "Accept-Ranges";
+
+    static final String CONTENT_RANGE_HEADER = "Content-Range";
+
+    private static final String BYTES_UNIT = "bytes";
+
+    private static final String CONTENT_RANGE_BYTES = "bytes %d-%d/%d";
+
+    private static final int LAST_POSITION_OFFSET = -1;
+
+    private static final ByteRangeParser byteRangeParser = new 
StandardByteRangeParser();
+
+    private final InputStream inputStream;
+
+    private String range;
+
+    private boolean acceptRanges;
+
+    /**
+     * Streaming Output Response Builder with required Input Stream
+     *
+     * @param inputStream Input Stream to be transferred
+     */
+    public StreamingOutputResponseBuilder(final InputStream inputStream) {
+        this.inputStream = Objects.requireNonNull(inputStream, "Input Stream 
required");
+    }
+
+    /**
+     * Set HTTP Range header
+     *
+     * @param range Range header can be null or empty
+     * @return Builder
+     */
+    public StreamingOutputResponseBuilder range(final String range) {
+        this.range = range;
+        this.acceptRanges = true;
+        return this;
+    }
+
+    /**
+     * Process arguments and prepare HTTP Response Builder
+     *
+     * @return Response Builder
+     */
+    public Response.ResponseBuilder build() {
+        final Response.ResponseBuilder responseBuilder;
+
+        final Optional<ByteRange> byteRangeFound = 
byteRangeParser.readByteRange(range);
+        if (byteRangeFound.isPresent()) {
+            final int completeLength = getCompleteLength();
+            final ByteRange byteRange = byteRangeFound.get();
+            final StreamingOutput streamingOutput = new 
ByteRangeStreamingOutput(inputStream, byteRange);
+            responseBuilder = 
Response.status(Response.Status.PARTIAL_CONTENT).entity(streamingOutput);
+
+            final String contentRange = getContentRange(byteRange, 
completeLength);
+            responseBuilder.header(CONTENT_RANGE_HEADER, contentRange);
+        } else {
+            final StreamingOutput streamingOutput = new 
InputStreamingOutput(inputStream);
+            responseBuilder = Response.ok(streamingOutput);
+        }
+
+        if (acceptRanges) {
+            responseBuilder.header(ACCEPT_RANGES_HEADER, BYTES_UNIT);
+        }
+
+        return responseBuilder;
+    }
+
+    private int getCompleteLength() {
+        try {
+            return inputStream.available();
+        } catch (final IOException e) {
+            throw new UncheckedIOException("Complete Length read failed", e);
+        }
+    }
+
+    private String getContentRange(final ByteRange byteRange, final int 
completeLength) {
+        final OptionalLong lastPositionFound = byteRange.getLastPosition();
+        final OptionalLong firstPositionFound = byteRange.getFirstPosition();
+
+        final long lastPositionCompleteLength = completeLength - 
LAST_POSITION_OFFSET;
+
+        final long lastPosition;
+        if (lastPositionFound.isEmpty()) {
+            lastPosition = lastPositionCompleteLength;
+        } else {
+            final long lastPositionRequested = lastPositionFound.getAsLong();
+            lastPosition = Math.min(lastPositionRequested, 
lastPositionCompleteLength);
+        }
+
+        final long firstPosition;
+        if (firstPositionFound.isEmpty()) {
+            firstPosition = 0;
+        } else {
+            firstPosition = firstPositionFound.getAsLong();
+        }
+
+        return CONTENT_RANGE_BYTES.formatted(firstPosition, lastPosition, 
completeLength);
+    }
+}
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/streaming/ByteRangeStreamingOutputTest.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/streaming/ByteRangeStreamingOutputTest.java
new file mode 100644
index 0000000000..3d060b847c
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/streaming/ByteRangeStreamingOutputTest.java
@@ -0,0 +1,119 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.web.api.streaming;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class ByteRangeStreamingOutputTest {
+
+    private static final byte[] INPUT_BYTES = 
String.class.getSimpleName().getBytes(StandardCharsets.UTF_8);;
+
+    private static final long NOT_SATISFIABLE_LENGTH = 1000;
+
+    @Test
+    void testWriteRangeZeroToUnspecified() throws IOException {
+        final ByteRange byteRange = new ByteRange(0L, null);
+
+        final byte[] outputBytes = writeBytes(byteRange);
+
+        assertArrayEquals(INPUT_BYTES, outputBytes);
+    }
+
+    @Test
+    void testWriteRangeZeroToOne() throws IOException {
+        final ByteRange byteRange = new ByteRange(0L, 1L);
+
+        final byte[] outputBytes = writeBytes(byteRange);
+
+        assertEquals(1, outputBytes.length);
+
+        final byte first = outputBytes[0];
+        assertEquals(INPUT_BYTES[0], first);
+    }
+
+    @Test
+    void testWriteRangeZeroToAvailableLength() throws IOException {
+        final ByteRange byteRange = new ByteRange(0L, (long) 
INPUT_BYTES.length);
+
+        final byte[] outputBytes = writeBytes(byteRange);
+
+        assertArrayEquals(INPUT_BYTES, outputBytes);
+    }
+
+    @Test
+    void testWriteRangeZeroToMaximumLong() throws IOException {
+        final ByteRange byteRange = new ByteRange(0L, Long.MAX_VALUE);
+
+        final byte[] outputBytes = writeBytes(byteRange);
+
+        assertArrayEquals(INPUT_BYTES, outputBytes);
+    }
+
+    @Test
+    void testWriteRangeOneToTwo() throws IOException {
+        final ByteRange byteRange = new ByteRange(1L, 2L);
+
+        final byte[] outputBytes = writeBytes(byteRange);
+
+        assertEquals(1, outputBytes.length);
+
+        final byte first = outputBytes[0];
+        assertEquals(INPUT_BYTES[1], first);
+    }
+
+    @Test
+    void testWriteRangeFirstPositionNotSatisfiable() {
+        final ByteRange byteRange = new ByteRange(NOT_SATISFIABLE_LENGTH, 
Long.MAX_VALUE);
+
+        assertThrows(RangeNotSatisfiableException.class, () -> 
writeBytes(byteRange));
+    }
+
+    @Test
+    void testWriteRangeUnspecifiedToOne() throws IOException {
+        final ByteRange byteRange = new ByteRange(null, 1L);
+
+        final byte[] outputBytes = writeBytes(byteRange);
+
+        assertEquals(1, outputBytes.length);
+
+        final byte first = outputBytes[0];
+        final int lastIndex = INPUT_BYTES.length - 1;
+        final byte lastInput = INPUT_BYTES[lastIndex];
+        assertEquals(lastInput, first);
+    }
+
+    private byte[] writeBytes(final ByteRange byteRange) throws IOException {
+        final InputStream inputStream = new ByteArrayInputStream(INPUT_BYTES);
+
+        final ByteRangeStreamingOutput streamingOutput =  new 
ByteRangeStreamingOutput(inputStream, byteRange);
+
+        final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+        streamingOutput.write(outputStream);
+
+        return outputStream.toByteArray();
+    }
+}
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/streaming/StandardByteRangeParserTest.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/streaming/StandardByteRangeParserTest.java
new file mode 100644
index 0000000000..98d65ea1e7
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/streaming/StandardByteRangeParserTest.java
@@ -0,0 +1,142 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.web.api.streaming;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Optional;
+import java.util.OptionalLong;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class StandardByteRangeParserTest {
+    private static final String EMPTY = "";
+
+    private static final String INVALID_UNIT = "octets=0-1";
+
+    private static final String BYTES_RANGE_FORMAT = "bytes=%d-%d";
+
+    private static final String BYTES_RANGE_LAST_POSITION_EMPTY = "bytes=%d-";
+
+    private static final String BYTES_RANGE_FIRST_POSITION_EMPTY = "bytes=-%d";
+
+    private static final String BYTES_RANGE_INVALID_NUMBER = 
"bytes=0-9988776655443322110";
+
+    private static final String BYTES_RANGE_MISSING_NUMBERS = "bytes=-";
+
+    private final StandardByteRangeParser parser = new 
StandardByteRangeParser();
+
+    @Test
+    void testReadByteRangeNull() {
+        final Optional<ByteRange> byteRangeFound = parser.readByteRange(null);
+
+        assertTrue(byteRangeFound.isEmpty());
+    }
+
+    @Test
+    void testReadByteRangeEmpty() {
+        final Optional<ByteRange> byteRangeFound = parser.readByteRange(EMPTY);
+
+        assertTrue(byteRangeFound.isEmpty());
+    }
+
+    @Test
+    void testReadByteRangeUnitNotValid() {
+        assertThrows(ByteRangeFormatException.class, () -> 
parser.readByteRange(INVALID_UNIT));
+    }
+
+    @Test
+    void testReadByteRangeNumbersNotSpecified() {
+        assertThrows(ByteRangeFormatException.class, () -> 
parser.readByteRange(BYTES_RANGE_MISSING_NUMBERS));
+    }
+
+    @Test
+    void testReadByteRangeNumberNotValid() {
+        assertThrows(ByteRangeFormatException.class, () -> 
parser.readByteRange(BYTES_RANGE_INVALID_NUMBER));
+    }
+
+    @Test
+    void testReadByteRangeLastPositionLessThanFirstPosition() {
+        final String rangeHeader = 
BYTES_RANGE_FORMAT.formatted(Long.MAX_VALUE, 0);
+
+        assertThrows(ByteRangeFormatException.class, () -> 
parser.readByteRange(rangeHeader));
+    }
+
+    @Test
+    void testReadByteRangeZeroToUnspecified() {
+        final long firstPosition = 0;
+
+        final String rangeHeader = 
BYTES_RANGE_LAST_POSITION_EMPTY.formatted(firstPosition);
+
+        final Optional<ByteRange> byteRangeFound = 
parser.readByteRange(rangeHeader);
+
+        assertTrue(byteRangeFound.isPresent());
+
+        final ByteRange byteRange = byteRangeFound.get();
+
+        final OptionalLong firstPositionFound = byteRange.getFirstPosition();
+        assertTrue(firstPositionFound.isPresent());
+        assertEquals(firstPosition, firstPositionFound.getAsLong());
+
+        final OptionalLong lastPositionFound = byteRange.getLastPosition();
+        assertTrue(lastPositionFound.isEmpty());
+    }
+
+    @Test
+    void testReadByteRangeSuffixRangeOne() {
+        final long lastPosition = 1;
+
+        final String rangeHeader = 
BYTES_RANGE_FIRST_POSITION_EMPTY.formatted(lastPosition);
+
+        final Optional<ByteRange> byteRangeFound = 
parser.readByteRange(rangeHeader);
+
+        assertTrue(byteRangeFound.isPresent());
+
+        final ByteRange byteRange = byteRangeFound.get();
+
+        final OptionalLong firstPositionFound = byteRange.getFirstPosition();
+        assertTrue(firstPositionFound.isEmpty());
+
+        final OptionalLong lastPositionFound = byteRange.getLastPosition();
+        assertTrue(lastPositionFound.isPresent());
+        assertEquals(lastPosition, lastPositionFound.getAsLong());
+    }
+
+    @Test
+    void testReadByteRangeZeroToMaximumLong() {
+        final long firstPosition = 0;
+        final long lastPosition = Long.MAX_VALUE;
+
+        final String rangeHeader = BYTES_RANGE_FORMAT.formatted(firstPosition, 
lastPosition);
+
+        final Optional<ByteRange> byteRangeFound = 
parser.readByteRange(rangeHeader);
+
+        assertTrue(byteRangeFound.isPresent());
+
+        final ByteRange byteRange = byteRangeFound.get();
+
+        final OptionalLong firstPositionFound = byteRange.getFirstPosition();
+        assertTrue(firstPositionFound.isPresent());
+        assertEquals(firstPosition, firstPositionFound.getAsLong());
+
+        final OptionalLong lastPositionFound = byteRange.getLastPosition();
+        assertTrue(lastPositionFound.isPresent());
+        assertEquals(lastPosition, lastPositionFound.getAsLong());
+    }
+}
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/streaming/StreamingOutputResponseBuilderTest.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/streaming/StreamingOutputResponseBuilderTest.java
new file mode 100644
index 0000000000..8b0481f37b
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/streaming/StreamingOutputResponseBuilderTest.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.web.api.streaming;
+
+import jakarta.ws.rs.core.Response;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class StreamingOutputResponseBuilderTest {
+
+    private static final byte[] INPUT_BYTES = 
String.class.getSimpleName().getBytes(StandardCharsets.UTF_8);;
+
+    private static final String RANGE = 
"bytes=0-%d".formatted(INPUT_BYTES.length);
+
+    private static final String ACCEPT_RANGES_BYTES = "bytes";
+
+    private static final String CONTENT_RANGE_EXPECTED = "bytes 
0-%d/%d".formatted(INPUT_BYTES.length, INPUT_BYTES.length);
+
+    @Test
+    void testBuildInputStream() {
+        final InputStream inputStream = new ByteArrayInputStream(INPUT_BYTES);
+
+        final StreamingOutputResponseBuilder builder = new 
StreamingOutputResponseBuilder(inputStream);
+
+        final Response.ResponseBuilder responseBuilder = builder.build();
+
+        try (Response response = responseBuilder.build()) {
+            assertEquals(Response.Status.OK.getStatusCode(), 
response.getStatus());
+        }
+    }
+
+    @Test
+    void testBuildRange() {
+        final InputStream inputStream = new ByteArrayInputStream(INPUT_BYTES);
+
+        final StreamingOutputResponseBuilder builder = new 
StreamingOutputResponseBuilder(inputStream);
+        builder.range(RANGE);
+
+        final Response.ResponseBuilder responseBuilder = builder.build();
+
+        try (Response response = responseBuilder.build()) {
+            assertEquals(Response.Status.PARTIAL_CONTENT.getStatusCode(), 
response.getStatus());
+
+            final String acceptRanges = 
response.getHeaderString(StreamingOutputResponseBuilder.ACCEPT_RANGES_HEADER);
+            assertEquals(ACCEPT_RANGES_BYTES, acceptRanges);
+
+            final String contentRange = 
response.getHeaderString(StreamingOutputResponseBuilder.CONTENT_RANGE_HEADER);
+            assertEquals(CONTENT_RANGE_EXPECTED, contentRange);
+        }
+    }
+}

Reply via email to