Skip to content

Commit 5d39aa0

Browse files
author
Bennett Lynch
authored
Add support for filtering objects as part of S3 Transfer Manager's DownloadDirectory API (#3042)
* Add support for filtering objects as part of S3 Transfer Manager's DownloadDirectory API This allows users to specify a filter that will be used to evaluate which objects should be downloaded from the target S3 directory. You can use a filter, for example, to only download objects of a given size, of a given file extension, of a given last-modified date, etc. See `DownloadFilter` for some ready-made implementations. By default, if no filter is specified, all objects will be downloaded.
1 parent 76d48d0 commit 5d39aa0

File tree

11 files changed

+474
-79
lines changed

11 files changed

+474
-79
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"category": "S3 Transfer Manager",
3+
"contributor": "",
4+
"type": "feature",
5+
"description": "Add support for filtering objects as part of S3 Transfer Manager's DownloadDirectory API"
6+
}

services-custom/s3-transfer-manager/src/it/java/software/amazon/awssdk/transfer/s3/S3TransferManagerDownloadDirectoryIntegrationTest.java

Lines changed: 73 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,24 @@
1616
package software.amazon.awssdk.transfer.s3;
1717

1818
import static org.assertj.core.api.Assertions.assertThat;
19+
import static software.amazon.awssdk.testutils.FileUtils.toFileTreeString;
1920
import static software.amazon.awssdk.testutils.service.S3BucketUtils.temporaryBucketName;
2021
import static software.amazon.awssdk.utils.IoUtils.closeQuietly;
2122

2223
import java.io.IOException;
2324
import java.io.UncheckedIOException;
2425
import java.nio.charset.StandardCharsets;
25-
import java.nio.file.FileVisitResult;
2626
import java.nio.file.Files;
2727
import java.nio.file.Path;
2828
import java.nio.file.Paths;
29-
import java.nio.file.SimpleFileVisitor;
30-
import java.nio.file.attribute.BasicFileAttributes;
3129
import java.util.concurrent.TimeUnit;
30+
import java.util.stream.Stream;
3231
import org.apache.commons.lang3.RandomStringUtils;
3332
import org.junit.After;
3433
import org.junit.AfterClass;
3534
import org.junit.Before;
3635
import org.junit.BeforeClass;
36+
import org.junit.ComparisonFailure;
3737
import org.junit.Test;
3838
import software.amazon.awssdk.testutils.FileUtils;
3939
import software.amazon.awssdk.utils.Logger;
@@ -109,12 +109,15 @@ public static void teardown() {
109109
* <pre>
110110
* {@code
111111
* - destination
112-
* - 2021
113-
* - 1.txt
114-
* - 2.txt
115-
* - 2022
116-
* - 1.txt
117-
* - important.txt
112+
* - README.md
113+
* - CHANGELOG.md
114+
* - notes
115+
* - 2021
116+
* - 1.txt
117+
* - 2.txt
118+
* - 2022
119+
* - 1.txt
120+
* - important.txt
118121
* }
119122
* </pre>
120123
*/
@@ -131,16 +134,13 @@ public void downloadDirectory() throws Exception {
131134
* The destination directory structure should be the following with prefix "notes"
132135
* <pre>
133136
* {@code
134-
* - source
135-
* - README.md
136-
* - CHANGELOG.md
137-
* - notes
138-
* - 2021
139-
* - 1.txt
140-
* - 2.txt
141-
* - 2022
142-
* - 1.txt
143-
* - important.txt
137+
* - destination
138+
* - 2021
139+
* - 1.txt
140+
* - 2.txt
141+
* - 2022
142+
* - 1.txt
143+
* - important.txt
144144
* }
145145
* </pre>
146146
*/
@@ -178,24 +178,64 @@ public void downloadDirectory_withPrefixAndDelimiter() throws Exception {
178178
assertTwoDirectoriesHaveSameStructure(sourceDirectory.resolve("notes").resolve("2021"), destinationDirectory);
179179
}
180180

181-
private static void assertTwoDirectoriesHaveSameStructure(Path path, Path otherPath) {
181+
/**
182+
* The destination directory structure should only contain file names starting with "2":
183+
* <pre>
184+
* {@code
185+
* - destination
186+
* - notes
187+
* - 2021
188+
* - 2.txt
189+
* }
190+
* </pre>
191+
*/
192+
@Test
193+
public void downloadDirectory_withFilter() throws Exception {
194+
DirectoryDownload downloadDirectory = tm.downloadDirectory(u -> u
195+
.destinationDirectory(destinationDirectory)
196+
.bucket(TEST_BUCKET)
197+
.filter(ctx -> ctx.destination().getFileName().toString().startsWith("2")));
198+
CompletedDirectoryDownload completedDirectoryDownload = downloadDirectory.completionFuture().get(5, TimeUnit.SECONDS);
199+
assertThat(completedDirectoryDownload.failedTransfers()).isEmpty();
200+
201+
Path expectedDirectory = Files.createTempDirectory("expectedDirectory");
182202
try {
183-
Files.walkFileTree(path, new SimpleFileVisitor<Path>() {
184-
@Override
185-
public FileVisitResult visitFile(Path file,
186-
BasicFileAttributes attrs)
187-
throws IOException {
188-
FileVisitResult result = super.visitFile(file, attrs);
189-
190-
Path relativePath = path.relativize(file);
191-
Path otherFile = otherPath.resolve(relativePath);
192-
log.debug(() -> String.format("Comparing %s with %s", file, otherFile));
193-
assertThat(file).hasSameBinaryContentAs(otherFile);
194-
return result;
203+
FileUtils.copyDirectory(sourceDirectory, expectedDirectory);
204+
Files.delete(expectedDirectory.resolve("README.md"));
205+
Files.delete(expectedDirectory.resolve("CHANGELOG.md"));
206+
Files.delete(expectedDirectory.resolve("notes/2022/1.txt"));
207+
Files.delete(expectedDirectory.resolve("notes/2022"));
208+
Files.delete(expectedDirectory.resolve("notes/important.txt"));
209+
Files.delete(expectedDirectory.resolve("notes/2021/1.txt"));
210+
211+
assertTwoDirectoriesHaveSameStructure(expectedDirectory, destinationDirectory);
212+
} finally {
213+
FileUtils.cleanUpTestDirectory(expectedDirectory);
214+
}
215+
}
216+
217+
private static void assertTwoDirectoriesHaveSameStructure(Path a, Path b) {
218+
assertLeftHasRight(a, b);
219+
assertLeftHasRight(b, a);
220+
}
221+
222+
private static void assertLeftHasRight(Path left, Path right) {
223+
try (Stream<Path> paths = Files.walk(left)) {
224+
paths.forEach(leftPath -> {
225+
Path leftRelative = left.relativize(leftPath);
226+
Path rightPath = right.resolve(leftRelative);
227+
log.debug(() -> String.format("Comparing %s with %s", leftPath, rightPath));
228+
try {
229+
assertThat(rightPath).exists();
230+
} catch (AssertionError e) {
231+
throw new ComparisonFailure(e.getMessage(), toFileTreeString(left), toFileTreeString(right));
232+
}
233+
if (Files.isRegularFile(leftPath)) {
234+
assertThat(leftPath).hasSameBinaryContentAs(rightPath);
195235
}
196236
});
197237
} catch (IOException e) {
198-
throw new UncheckedIOException(String.format("Failed to compare %s with %s", path, otherPath), e);
238+
throw new UncheckedIOException(String.format("Failed to compare %s with %s", left, right), e);
199239
}
200240
}
201241

services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/DownloadDirectoryRequest.java

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,14 @@ public final class DownloadDirectoryRequest
4040
private final String bucket;
4141
private final String prefix;
4242
private final String delimiter;
43+
private final DownloadFilter filter;
4344

4445
public DownloadDirectoryRequest(DefaultBuilder builder) {
4546
this.destinationDirectory = Validate.paramNotNull(builder.destinationDirectory, "destinationDirectory");
4647
this.bucket = Validate.paramNotNull(builder.bucket, "bucket");
4748
this.prefix = builder.prefix;
4849
this.delimiter = builder.delimiter;
50+
this.filter = builder.filter;
4951
}
5052

5153
/**
@@ -84,6 +86,14 @@ public Optional<String> delimiter() {
8486
return Optional.ofNullable(delimiter);
8587
}
8688

89+
/**
90+
* @return the optional filter, or {@link DownloadFilter#allObjects()} if no filter was provided
91+
* @see Builder#filter(DownloadFilter)
92+
*/
93+
public DownloadFilter filter() {
94+
return filter == null ? DownloadFilter.allObjects() : filter;
95+
}
96+
8797
public static Builder builder() {
8898
return new DefaultBuilder();
8999
}
@@ -117,7 +127,10 @@ public boolean equals(Object o) {
117127
if (!Objects.equals(prefix, that.prefix)) {
118128
return false;
119129
}
120-
return Objects.equals(delimiter, that.delimiter);
130+
if (!Objects.equals(delimiter, that.delimiter)) {
131+
return false;
132+
}
133+
return Objects.equals(filter, that.filter);
121134
}
122135

123136
@Override
@@ -126,6 +139,7 @@ public int hashCode() {
126139
result = 31 * result + (bucket != null ? bucket.hashCode() : 0);
127140
result = 31 * result + (prefix != null ? prefix.hashCode() : 0);
128141
result = 31 * result + (delimiter != null ? delimiter.hashCode() : 0);
142+
result = 31 * result + (filter != null ? filter.hashCode() : 0);
129143
return result;
130144
}
131145

@@ -136,6 +150,7 @@ public String toString() {
136150
.add("bucket", bucket)
137151
.add("prefix", prefix)
138152
.add("delimiter", delimiter)
153+
.add("filter", filter)
139154
.build();
140155
}
141156

@@ -236,8 +251,20 @@ public interface Builder extends CopyableBuilder<Builder, DownloadDirectoryReque
236251
*/
237252
Builder delimiter(String delimiter);
238253

239-
@Override
240-
DownloadDirectoryRequest build();
254+
/**
255+
* Specify a filter that will be used to evaluate which objects should be downloaded from the target directory.
256+
* <p>
257+
* You can use a filter, for example, to only download objects of a given size, of a given file extension, of a given
258+
* last-modified date, etc. See {@link DownloadFilter} for some ready-made implementations. Multiple {@link
259+
* DownloadFilter}s can be composed together via the {@code and} and {@code or} methods.
260+
* <p>
261+
* By default, if no filter is specified, all objects will be downloaded.
262+
*
263+
* @param filter the filter
264+
* @return This builder for method chaining.
265+
* @see DownloadFilter
266+
*/
267+
Builder filter(DownloadFilter filter);
241268
}
242269

243270
private static final class DefaultBuilder implements Builder {
@@ -246,6 +273,7 @@ private static final class DefaultBuilder implements Builder {
246273
private String bucket;
247274
private String prefix;
248275
private String delimiter;
276+
private DownloadFilter filter;
249277

250278
private DefaultBuilder() {
251279
}
@@ -254,6 +282,7 @@ private DefaultBuilder(DownloadDirectoryRequest request) {
254282
this.destinationDirectory = request.destinationDirectory;
255283
this.bucket = request.bucket;
256284
this.prefix = request.prefix;
285+
this.filter = request.filter;
257286
}
258287

259288
@Override
@@ -312,6 +341,20 @@ public String getDelimiter() {
312341
return delimiter;
313342
}
314343

344+
@Override
345+
public Builder filter(DownloadFilter filter) {
346+
this.filter = filter;
347+
return this;
348+
}
349+
350+
public void setFilter(DownloadFilter filter) {
351+
filter(filter);
352+
}
353+
354+
public DownloadFilter getFilter() {
355+
return filter;
356+
}
357+
315358
@Override
316359
public DownloadDirectoryRequest build() {
317360
return new DownloadDirectoryRequest(this);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package software.amazon.awssdk.transfer.s3;
17+
18+
import java.nio.file.Path;
19+
import software.amazon.awssdk.annotations.SdkPreviewApi;
20+
import software.amazon.awssdk.annotations.SdkPublicApi;
21+
import software.amazon.awssdk.services.s3.model.S3Object;
22+
23+
/**
24+
* Context object for determining which objects should be downloaded as part of a {@link DownloadDirectoryRequest}.
25+
*
26+
* @see DownloadFilter
27+
*/
28+
@SdkPublicApi
29+
@SdkPreviewApi
30+
public interface DownloadFileContext {
31+
/**
32+
* @return A description of the remote S3 object
33+
*/
34+
S3Object source();
35+
36+
/**
37+
* @return A path representing the local file destination
38+
*/
39+
Path destination();
40+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package software.amazon.awssdk.transfer.s3;
17+
18+
import java.nio.file.Path;
19+
import java.util.function.Predicate;
20+
import software.amazon.awssdk.annotations.SdkPreviewApi;
21+
import software.amazon.awssdk.annotations.SdkPublicApi;
22+
import software.amazon.awssdk.services.s3.model.S3Object;
23+
24+
/**
25+
* {@link DownloadFilter} allows you to filter out which objects should be downloaded as part of a {@link
26+
* DownloadDirectoryRequest}. You could use it, for example, to only download objects of a given size, of a given file extension,
27+
* of a given last-modified date, etc. Multiple {@link DownloadFilter}s can be composed together via {@link #and(Predicate)} and
28+
* {@link #or(Predicate)} methods.
29+
*/
30+
@SdkPublicApi
31+
@SdkPreviewApi
32+
public interface DownloadFilter extends Predicate<DownloadFileContext> {
33+
34+
/**
35+
* Evaluate whether the remote {@link S3Object} should be downloaded to the destination {@link Path}.
36+
*
37+
* @param context A context object containing a description of the remote {@link S3Object} and a {@link Path} representing the
38+
* local file destination
39+
* @return true if the object should be downloaded, false if the object should not be downloaded
40+
*/
41+
@Override
42+
boolean test(DownloadFileContext context);
43+
44+
/**
45+
* A {@link DownloadFilter} that downloads all objects. This is the default behavior if no filter is provided.
46+
*/
47+
@SdkPreviewApi
48+
static DownloadFilter allObjects() {
49+
return ctx -> true;
50+
}
51+
}

0 commit comments

Comments
 (0)