This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 12dcf49b2738 CAMEL-25757: camel-azure-files - accept 206 Partial
Content for a resumed download (#28072)
12dcf49b2738 is described below
commit 12dcf49b27385fdd2443003b512cdcde98f9b33c
Author: allthingssecurity <[email protected]>
AuthorDate: Mon Oct 19 17:14:28 2026 +0530
CAMEL-25757: camel-azure-files - accept 206 Partial Content for a resumed
download (#28072)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../component/file/azure/FilesOperations.java | 5 +-
.../azure/FilesOperationsResumeDownloadTest.java | 228 +++++++++++++++++++++
2 files changed, 232 insertions(+), 1 deletion(-)
diff --git
a/components/camel-azure/camel-azure-files/src/main/java/org/apache/camel/component/file/azure/FilesOperations.java
b/components/camel-azure/camel-azure-files/src/main/java/org/apache/camel/component/file/azure/FilesOperations.java
index 2ea3064b12a4..8c66fc7e60c3 100644
---
a/components/camel-azure/camel-azure-files/src/main/java/org/apache/camel/component/file/azure/FilesOperations.java
+++
b/components/camel-azure/camel-azure-files/src/main/java/org/apache/camel/component/file/azure/FilesOperations.java
@@ -76,6 +76,7 @@ public class FilesOperations extends NormalizedOperations {
static final int HTTP_OK = 200;
static final int HTTP_CREATED = 201;
static final int HTTP_ACCEPTED = 202;
+ static final int HTTP_PARTIAL_CONTENT = 206;
static final int HTTP_NOT_FOUND = 404;
private final Logger log = LoggerFactory.getLogger(getClass());
@@ -385,7 +386,9 @@ public class FilesOperations extends NormalizedOperations {
log.trace("Downloading {} to local work directory.", remoteName);
var ret = cwd().getFileClient(remoteName).downloadWithResponse(os,
range, null, endpoint.getDataTimeout(),
Context.NONE);
- result = ret.getStatusCode() == HTTP_OK;
+ // a download with a range (resumed download) is answered with 206
Partial Content
+ int status = ret.getStatusCode();
+ result = status == HTTP_OK || (status == HTTP_PARTIAL_CONTENT &&
range.getStart() > 0);
} catch (RuntimeException e) {
log.trace("Error occurred during retrieving file: {} to local
directory.", name);
diff --git
a/components/camel-azure/camel-azure-files/src/test/java/org/apache/camel/component/file/azure/FilesOperationsResumeDownloadTest.java
b/components/camel-azure/camel-azure-files/src/test/java/org/apache/camel/component/file/azure/FilesOperationsResumeDownloadTest.java
new file mode 100644
index 000000000000..9198b929960b
--- /dev/null
+++
b/components/camel-azure/camel-azure-files/src/test/java/org/apache/camel/component/file/azure/FilesOperationsResumeDownloadTest.java
@@ -0,0 +1,228 @@
+/*
+ * 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.camel.component.file.azure;
+
+import java.lang.reflect.Field;
+import java.nio.ByteBuffer;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayDeque;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import com.azure.core.http.HttpClient;
+import com.azure.core.http.HttpHeaderName;
+import com.azure.core.http.HttpHeaders;
+import com.azure.core.http.HttpRequest;
+import com.azure.core.http.HttpResponse;
+import com.azure.storage.common.policy.RequestRetryOptions;
+import com.azure.storage.file.share.ShareDirectoryClient;
+import com.azure.storage.file.share.ShareServiceClient;
+import com.azure.storage.file.share.ShareServiceClientBuilder;
+import com.azure.storage.file.share.models.ShareFileItem;
+import org.apache.camel.Exchange;
+import org.apache.camel.component.file.FileComponent;
+import org.apache.camel.component.file.remote.RemoteFile;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * resumeDownload=true continues an interrupted download with a ranged GET,
which the file service answers with 206
+ * Partial Content. The fake service below answers like Azure Files: 200
without a range, 206 with a range that starts
+ * inside the file and 416 with a range that starts at or after its end.
+ */
+public class FilesOperationsResumeDownloadTest extends CamelTestSupport {
+
+ private static final byte[] CONTENT =
"0123456789".getBytes(StandardCharsets.US_ASCII);
+
+ @TempDir
+ Path workDir;
+
+ @Test
+ void resumedDownloadIsCompletedAndRenamed() throws Exception {
+ FakeFileService service = new FakeFileService();
+ FilesOperations operations = operations(service);
+ // a previous download of the file was interrupted after 4 bytes
+ Files.write(workDir.resolve("hello.txt.inprogress"),
Arrays.copyOf(CONTENT, 4));
+
+ boolean retrieved = operations.retrieveFile("hello.txt", exchange(),
CONTENT.length);
+
+ assertEquals(List.of("bytes=4-"), service.ranges, "the download is
resumed with a range");
+ assertTrue(retrieved, "a resumed download that returned the rest of
the file must be retrieved");
+ assertEquals("0123456789",
Files.readString(workDir.resolve("hello.txt"), StandardCharsets.US_ASCII));
+ assertFalse(Files.exists(workDir.resolve("hello.txt.inprogress")));
+ }
+
+ @Test
+ void downloadWithoutPartialFileIsRetrieved() throws Exception {
+ FakeFileService service = new FakeFileService();
+ FilesOperations operations = operations(service);
+
+ boolean retrieved = operations.retrieveFile("hello.txt", exchange(),
CONTENT.length);
+
+ assertEquals(1, service.ranges.size());
+ assertEquals("none", service.ranges.get(0));
+ assertTrue(retrieved);
+ assertEquals("0123456789",
Files.readString(workDir.resolve("hello.txt"), StandardCharsets.US_ASCII));
+ }
+
+ private Exchange exchange() {
+ RemoteFile<ShareFileItem> file = new RemoteFile<>();
+ file.setAbsoluteFilePath("/hello.txt");
+ file.setRelativeFilePath("hello.txt");
+ file.setFileName("hello.txt");
+ file.setFileNameOnly("hello.txt");
+ Exchange exchange = new DefaultExchange(context);
+ exchange.setProperty(FileComponent.FILE_EXCHANGE_FILE, file);
+ return exchange;
+ }
+
+ private FilesOperations operations(FakeFileService service) throws
Exception {
+ FilesEndpoint endpoint = context.getEndpoint(
+
"azure-files://account.file.core.windows.net/share?credentialType=AZURE_SAS&sv=2022-11-02&sig=x"
+ +
"&resumeDownload=true&localWorkDirectory=" + workDir,
+ FilesEndpoint.class);
+ FilesOperations operations = new FilesOperations(endpoint);
+ ShareServiceClient client = new ShareServiceClientBuilder()
+ .endpoint("https://account.file.core.windows.net")
+ .sasToken("sv=2022-11-02&sig=x")
+ .httpClient(service)
+ .retryOptions(new RequestRetryOptions(null, 1, (Integer) null,
null, null, null))
+ .buildClient();
+ ShareDirectoryClient root =
client.getShareClient("share").getRootDirectoryClient();
+ ArrayDeque<ShareDirectoryClient> dirStack = new ArrayDeque<>();
+ dirStack.push(root);
+ setField(operations, "client", client);
+ setField(operations, "root", root);
+ setField(operations, "dirStack", dirStack);
+ return operations;
+ }
+
+ private static void setField(Object target, String name, Object value)
throws Exception {
+ Field field = FilesOperations.class.getDeclaredField(name);
+ field.setAccessible(true);
+ field.set(target, value);
+ }
+
+ /**
+ * Answers Get File like Azure Files for the file /share/hello.txt.
+ */
+ private static final class FakeFileService implements HttpClient {
+ final List<String> ranges = new CopyOnWriteArrayList<>();
+
+ @Override
+ public Mono<HttpResponse> send(HttpRequest request) {
+ if (!"GET".equals(request.getHttpMethod().name()) ||
!request.getUrl().getPath().equals("/share/hello.txt")) {
+ return Mono.just(new FakeResponse(request, 404, new
HttpHeaders(), new byte[0]));
+ }
+ String range =
request.getHeaders().getValue(HttpHeaderName.fromString("x-ms-range"));
+ if (range == null) {
+ range = request.getHeaders().getValue(HttpHeaderName.RANGE);
+ }
+ ranges.add(range == null ? "none" : range);
+ HttpHeaders headers = new HttpHeaders()
+ .set(HttpHeaderName.ETAG, "\"0x1\"")
+ .set(HttpHeaderName.LAST_MODIFIED, "Sun, 18 Oct 2026
10:00:00 GMT")
+ .set(HttpHeaderName.CONTENT_TYPE,
"application/octet-stream")
+ .set(HttpHeaderName.fromString("x-ms-type"), "File")
+ .set(HttpHeaderName.fromString("x-ms-version"),
"2025-05-05")
+ .set(HttpHeaderName.ACCEPT_RANGES, "bytes");
+ if (range == null) {
+ headers.set(HttpHeaderName.CONTENT_LENGTH,
String.valueOf(CONTENT.length));
+ return Mono.just(new FakeResponse(request, 200, headers,
CONTENT));
+ }
+ long start = Long.parseLong(range.substring("bytes=".length(),
range.indexOf('-')));
+ if (start >= CONTENT.length) {
+ byte[] error = ("<?xml version=\"1.0\"
encoding=\"utf-8\"?><Error><Code>InvalidRange</Code>"
+ + "<Message>The range specified is invalid for
the current size of the resource.</Message>"
+ + "</Error>")
+ .getBytes(StandardCharsets.UTF_8);
+ HttpHeaders errorHeaders = new HttpHeaders()
+ .set(HttpHeaderName.fromString("x-ms-error-code"),
"InvalidRange")
+ .set(HttpHeaderName.CONTENT_TYPE, "application/xml")
+ .set(HttpHeaderName.CONTENT_RANGE, "bytes */" +
CONTENT.length)
+ .set(HttpHeaderName.CONTENT_LENGTH,
String.valueOf(error.length));
+ return Mono.just(new FakeResponse(request, 416, errorHeaders,
error));
+ }
+ byte[] rest = Arrays.copyOfRange(CONTENT, (int) start,
CONTENT.length);
+ headers.set(HttpHeaderName.CONTENT_LENGTH,
String.valueOf(rest.length));
+ headers.set(HttpHeaderName.CONTENT_RANGE,
+ "bytes " + start + "-" + (CONTENT.length - 1) + "/" +
CONTENT.length);
+ return Mono.just(new FakeResponse(request, 206, headers, rest));
+ }
+ }
+
+ private static final class FakeResponse extends HttpResponse {
+ private final int status;
+ private final HttpHeaders headers;
+ private final byte[] body;
+
+ FakeResponse(HttpRequest request, int status, HttpHeaders headers,
byte[] body) {
+ super(request);
+ this.status = status;
+ this.headers = headers;
+ this.body = body;
+ }
+
+ @Override
+ public int getStatusCode() {
+ return status;
+ }
+
+ @Override
+ @Deprecated
+ public String getHeaderValue(String name) {
+ return headers.getValue(HttpHeaderName.fromString(name));
+ }
+
+ @Override
+ public HttpHeaders getHeaders() {
+ return headers;
+ }
+
+ @Override
+ public Flux<ByteBuffer> getBody() {
+ return Flux.just(ByteBuffer.wrap(body));
+ }
+
+ @Override
+ public Mono<byte[]> getBodyAsByteArray() {
+ return Mono.just(body);
+ }
+
+ @Override
+ public Mono<String> getBodyAsString() {
+ return Mono.just(new String(body, StandardCharsets.UTF_8));
+ }
+
+ @Override
+ public Mono<String> getBodyAsString(Charset charset) {
+ return Mono.just(new String(body, charset));
+ }
+ }
+}