Skip to content

Commit 506a192

Browse files
authored
Tags pagination (#628)
Signed-off-by: Valentin Delaye <jonesbusy@users.noreply.github.com>
1 parent ba7f777 commit 506a192

10 files changed

Lines changed: 222 additions & 3 deletions

File tree

src/main/java/land/oras/ContainerRef.java

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,12 +271,46 @@ public String getTagsPath(@Nullable Registry target) {
271271
return "%s/tags/list".formatted(getApiPrefix(target));
272272
}
273273

274+
/**
275+
* Return the tag URL
276+
* @param n The optional number of tags to return, for pagination
277+
* @param last The optional last tag index, for pagination
278+
* @return The tag URL
279+
*/
280+
public String getTagsPath(@Nullable Integer n, @Nullable String last) {
281+
return getTagsPath(null, n, last);
282+
}
283+
284+
/**
285+
* Return the tag URL
286+
* @param n The optional number of tags to return, for pagination
287+
* @param last The optional last tag index, for pagination
288+
* @param target The target registry
289+
* @return The tag URL
290+
*/
291+
public String getTagsPath(@Nullable Registry target, @Nullable Integer n, @Nullable String last) {
292+
if (n == null && last == null) {
293+
return getTagsPath(target);
294+
}
295+
StringBuilder url = new StringBuilder(getTagsPath(target)).append("?");
296+
if (n != null) {
297+
url.append("n=").append(n);
298+
}
299+
if (last != null) {
300+
if (n != null) {
301+
url.append("&");
302+
}
303+
url.append("last=").append(URLEncoder.encode(last, StandardCharsets.UTF_8));
304+
}
305+
return url.toString();
306+
}
307+
274308
/**
275309
* Return the tag URL
276310
* @return The tag URL
277311
*/
278312
public String getTagsPath() {
279-
return getTagsPath(null);
313+
return getTagsPath(null, null);
280314
}
281315

282316
/**

src/main/java/land/oras/OCI.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,15 @@ public final Manifest attachArtifact(T ref, ArtifactType artifactType, LocalPath
307307
*/
308308
public abstract Tags getTags(T ref);
309309

310+
/**
311+
* Get the tags for a ref
312+
* @param ref The ref
313+
* @param n The number of tags to return. If n is less than or equal to 0, return all tags
314+
* @param last The last tag index, to iterate. If null, start from the beginning
315+
* @return The tags
316+
*/
317+
public abstract Tags getTags(T ref, int n, @Nullable String last);
318+
310319
/**
311320
* Get the tags for a ref
312321
* @return The repositories

src/main/java/land/oras/OCILayout.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,10 +329,26 @@ public Tags getTags(LayoutRef ref) {
329329
List<String> tags = index.getManifests().stream()
330330
.filter(m -> m.getAnnotations() != null && m.getAnnotations().containsKey(Const.ANNOTATION_REF))
331331
.map(m -> m.getAnnotations().get(Const.ANNOTATION_REF))
332+
.sorted()
332333
.toList();
333334
return new Tags(name, tags);
334335
}
335336

337+
@Override
338+
public Tags getTags(LayoutRef ref, int n, @Nullable String last) {
339+
Tags allTags = getTags(ref);
340+
String name = allTags.name();
341+
List<String> tags = allTags.tags();
342+
int startIndex = 0;
343+
if (last != null) {
344+
int lastIndex = tags.indexOf(last);
345+
if (lastIndex == -1) {
346+
throw new OrasException("Last tag not found: %s".formatted(last));
347+
}
348+
}
349+
return new Tags(name, tags.stream().skip(startIndex).limit(n).toList());
350+
}
351+
336352
@Override
337353
public Repositories getRepositories() {
338354
return new Repositories(List.of(path.getFileName().toString()));

src/main/java/land/oras/Registry.java

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import java.util.List;
3333
import java.util.Map;
3434
import java.util.Objects;
35+
import java.util.Optional;
3536
import java.util.function.Supplier;
3637
import land.oras.auth.AuthProvider;
3738
import land.oras.auth.AuthStoreAuthenticationProvider;
@@ -200,8 +201,25 @@ public Tags getTags(ContainerRef containerRef) {
200201
URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getTagsPath(this)));
201202
HttpClient.ResponseWrapper<String> response = client.get(
202203
uri, Map.of(Const.ACCEPT_HEADER, Const.DEFAULT_JSON_MEDIA_TYPE), Scopes.of(this, ref), authProvider);
204+
logResponse(response);
203205
handleError(response);
204-
return JsonUtils.fromJson(response.response(), Tags.class);
206+
return JsonUtils.fromJson(response.response(), Tags.class)
207+
.withLast(getLastFromLink(response).orElse(null));
208+
}
209+
210+
@Override
211+
public Tags getTags(ContainerRef containerRef, int n, @Nullable String last) {
212+
ContainerRef ref = containerRef.forRegistry(this).checkBlocked(this);
213+
if (ref.isInsecure(this) && !this.isInsecure()) {
214+
return asInsecure().getTags(containerRef);
215+
}
216+
URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getTagsPath(this, n, last)));
217+
HttpClient.ResponseWrapper<String> response = client.get(
218+
uri, Map.of(Const.ACCEPT_HEADER, Const.DEFAULT_JSON_MEDIA_TYPE), Scopes.of(this, ref), authProvider);
219+
logResponse(response);
220+
handleError(response);
221+
return JsonUtils.fromJson(response.response(), Tags.class)
222+
.withLast(getLastFromLink(response).orElse(null));
205223
}
206224

207225
@Override
@@ -215,6 +233,7 @@ && getRegistriesConf().isInsecure(ContainerRef.parse(registry).forRegistry(regis
215233
URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getRepositoriesPath(this)));
216234
HttpClient.ResponseWrapper<String> response = client.get(
217235
uri, Map.of(Const.ACCEPT_HEADER, Const.DEFAULT_JSON_MEDIA_TYPE), Scopes.of(this, ref), authProvider);
236+
logResponse(response);
218237
handleError(response);
219238
return JsonUtils.fromJson(response.response(), Repositories.class);
220239
}
@@ -231,6 +250,7 @@ public Referrers getReferrers(ContainerRef containerRef, @Nullable ArtifactType
231250
URI uri = URI.create("%s://%s".formatted(getScheme(), ref.getReferrersPath(this, artifactType)));
232251
HttpClient.ResponseWrapper<String> response = client.get(
233252
uri, Map.of(Const.ACCEPT_HEADER, Const.DEFAULT_INDEX_MEDIA_TYPE), Scopes.of(this, ref), authProvider);
253+
logResponse(response);
234254
handleError(response);
235255
return JsonUtils.fromJson(response.response(), Referrers.class);
236256
}
@@ -968,6 +988,35 @@ ResolvedRegistry getResolvedHeaders(ContainerRef containerRef) {
968988
return new ResolvedRegistry(ref.getRegistry(), response.headers());
969989
}
970990

991+
private Optional<String> getLastFromLink(HttpClient.ResponseWrapper<String> response) {
992+
String linkHeader = response.headers().get(Const.LINK_HEADER.toLowerCase());
993+
if (linkHeader == null) {
994+
return Optional.empty();
995+
}
996+
997+
int start = linkHeader.indexOf('<');
998+
int end = linkHeader.indexOf('>', start + 1);
999+
if (start == -1 || end == -1) {
1000+
return Optional.empty();
1001+
}
1002+
1003+
String uri = linkHeader.substring(start + 1, end);
1004+
int q = uri.indexOf('?');
1005+
if (q == -1) {
1006+
return Optional.empty();
1007+
}
1008+
1009+
String query = uri.substring(q + 1);
1010+
for (String param : query.split("&")) {
1011+
int eq = param.indexOf('=');
1012+
if (eq > 0 && "last".equals(param.substring(0, eq))) {
1013+
return Optional.of(param.substring(eq + 1));
1014+
}
1015+
}
1016+
1017+
return Optional.empty();
1018+
}
1019+
9711020
/**
9721021
* Holds a resolved registry to avoid resolution on every request (specially like blob)
9731022
* @param registry The registry URL

src/main/java/land/oras/Tags.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,33 @@
2222

2323
import java.util.List;
2424
import org.jspecify.annotations.NullMarked;
25+
import org.jspecify.annotations.Nullable;
2526

2627
/**
2728
* The tags response object
2829
* @param name The name
2930
* @param tags The tags
31+
* @param last The last tag index, to iterate
3032
*/
3133
@NullMarked
3234
@OrasModel
33-
public record Tags(String name, List<String> tags) {}
35+
public record Tags(String name, List<String> tags, @Nullable String last) {
36+
37+
/**
38+
* Constructor without last
39+
* @param name The name
40+
* @param tags The tags
41+
*/
42+
public Tags(String name, List<String> tags) {
43+
this(name, tags, null);
44+
}
45+
46+
/**
47+
* With last
48+
* @param last The last tag index, to iterate
49+
* @return A new Tags object with the last index
50+
*/
51+
public Tags withLast(@Nullable String last) {
52+
return new Tags(this.name, this.tags, last);
53+
}
54+
}

src/main/java/land/oras/utils/Const.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,11 @@ public static String currentTimestamp() {
376376
*/
377377
public static final String AUTHORIZATION_HEADER = "Authorization";
378378

379+
/**
380+
* Link header, which is used for pagination in the registry API
381+
*/
382+
public static final String LINK_HEADER = "Link";
383+
379384
/**
380385
* User agent header
381386
*/

src/test/java/land/oras/ContainerRefTest.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,10 @@ void shouldParseImageWithNoTagAndNoRegistry() {
370370
void shouldGetTagsPathDockerIo() {
371371
ContainerRef containerRef = ContainerRef.parse("docker.io/library/foo/alpine:latest@sha256:1234567890abcdef");
372372
assertEquals("registry-1.docker.io/v2/library/foo/alpine/tags/list", containerRef.getTagsPath());
373+
assertEquals("registry-1.docker.io/v2/library/foo/alpine/tags/list?n=1", containerRef.getTagsPath(1, null));
374+
assertEquals(
375+
"registry-1.docker.io/v2/library/foo/alpine/tags/list?n=1&last=latest",
376+
containerRef.getTagsPath(1, "latest"));
373377
}
374378

375379
@Test

src/test/java/land/oras/DockerIoITCase.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,23 @@ class DockerIoITCase {
4141
@Container
4242
private final ZotUnsecureContainer unsecureRegistry = new ZotUnsecureContainer().withStartupAttempts(3);
4343

44+
@Test
45+
void shouldGetTags() {
46+
Registry registry = Registry.builder().build();
47+
ContainerRef containerRef = ContainerRef.parse("docker.io/library/alpine");
48+
Tags tags = registry.getTags(containerRef);
49+
assertNotNull(tags);
50+
assertTrue(tags.tags().size() > 100);
51+
tags = registry.getTags(containerRef, 2, null);
52+
assertNotNull(tags);
53+
assertEquals(2, tags.tags().size());
54+
assertEquals("2.7", tags.last());
55+
tags = registry.getTags(containerRef, 2, tags.last());
56+
assertNotNull(tags);
57+
assertEquals(2, tags.tags().size());
58+
assertEquals("20190408", tags.last());
59+
}
60+
4461
@Test
4562
void shouldPullAnonymousIndexFQDN() {
4663

src/test/java/land/oras/OCILayoutTest.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,33 @@ void shouldListTags() throws Exception {
141141
assertEquals("latest", tags.tags().get(0));
142142
}
143143

144+
@Test
145+
void shouldListTagsWithLimit() throws Exception {
146+
Path extractDir1 = extractDir.resolve("shouldListTags");
147+
Files.createDirectory(extractDir1);
148+
149+
LayoutRef layoutRef = LayoutRef.parse("src/test/resources/oci/subject:latest");
150+
OCILayout ociLayout =
151+
OCILayout.Builder.builder().defaults(layoutRef.getFolder()).build();
152+
Tags tags = ociLayout.getTags(layoutRef, 1, null);
153+
assertEquals("subject", tags.name());
154+
assertEquals(1, tags.tags().size());
155+
assertEquals("latest", tags.tags().get(0));
156+
}
157+
158+
@Test
159+
void shouldThrowIfLastTagInvalid() throws Exception {
160+
Path extractDir1 = extractDir.resolve("shouldListTags");
161+
Files.createDirectory(extractDir1);
162+
163+
LayoutRef layoutRef = LayoutRef.parse("src/test/resources/oci/subject:latest");
164+
OCILayout ociLayout =
165+
OCILayout.Builder.builder().defaults(layoutRef.getFolder()).build();
166+
assertThrows(OrasException.class, () -> {
167+
ociLayout.getTags(layoutRef, 1, "unknown");
168+
});
169+
}
170+
144171
@Test
145172
void shouldListRepositories() throws Exception {
146173
Path extractDir1 = extractDir.resolve("shouldListRepositories");

src/test/java/land/oras/RegistryWireMockTest.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,33 @@ void shouldListTags(WireMockRuntimeInfo wmRuntimeInfo) {
237237
assertEquals("0.1.1", tags.get(1));
238238
}
239239

240+
@Test
241+
void shouldListTagsWithLimit(WireMockRuntimeInfo wmRuntimeInfo) {
242+
243+
// Return data from wiremock
244+
WireMock wireMock = wmRuntimeInfo.getWireMock();
245+
wireMock.register(WireMock.get(WireMock.urlEqualTo("/v2/library/artifact-text/tags/list?n=1"))
246+
.willReturn(WireMock.okJson(JsonUtils.toJson(new Tags("artifact-text", List.of("latest"))))));
247+
248+
// Insecure registry
249+
Registry registry = Registry.Builder.builder()
250+
.withAuthProvider(authProvider)
251+
.withInsecure(true)
252+
.build();
253+
254+
// Test
255+
List<String> tags = registry.getTags(
256+
ContainerRef.parse("%s/library/artifact-text"
257+
.formatted(wmRuntimeInfo.getHttpBaseUrl().replace("http://", ""))),
258+
1,
259+
null)
260+
.tags();
261+
262+
// Assert
263+
assertEquals(1, tags.size());
264+
assertEquals("latest", tags.get(0));
265+
}
266+
240267
@Test
241268
void shouldListTagsWithConfig(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
242269

@@ -267,6 +294,16 @@ void shouldListTagsWithConfig(WireMockRuntimeInfo wmRuntimeInfo) throws Exceptio
267294
assertEquals(2, tags.size());
268295
assertEquals("latest", tags.get(0));
269296
assertEquals("0.1.1", tags.get(1));
297+
298+
// With limit
299+
tags = registry.getTags(
300+
ContainerRef.parse("%s/library/artifact-text-with-confg".formatted(registryAsString)),
301+
1,
302+
null)
303+
.tags();
304+
assertEquals(2, tags.size());
305+
assertEquals("latest", tags.get(0));
306+
assertEquals("0.1.1", tags.get(1));
270307
});
271308
}
272309

0 commit comments

Comments
 (0)