Skip to content

Commit 30be422

Browse files
registry: implement downloadBlob method in OciRegistryClient (#312)
Co-authored-by: Abhilaksh Sharma <iamabhilakshsharma@gmail.com>
1 parent 454dea0 commit 30be422

File tree

2 files changed

+283
-5
lines changed

2 files changed

+283
-5
lines changed

registry/registry-client/src/main/java/com/salesforce/multicloudj/registry/driver/OciRegistryClient.java

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@
2727
import org.apache.http.protocol.HttpContext;
2828
import org.apache.http.util.EntityUtils;
2929

30+
import java.io.FilterInputStream;
3031
import java.io.IOException;
32+
import java.io.InputStream;
3133
import java.security.MessageDigest;
3234
import java.security.NoSuchAlgorithmException;
33-
import java.io.InputStream;
3435
import java.nio.charset.StandardCharsets;
3536
import java.util.ArrayList;
3637
import java.util.Base64;
@@ -456,16 +457,85 @@ private List<Manifest.LayerInfo> parseLayerInfos(JsonObject json) {
456457
return layerInfos;
457458
}
458459

459-
/** Downloads a blob (layer or config) by digest. */
460+
/**
461+
* Downloads a blob (layer or config) by digest.
462+
* HTTP GET /v2/{repository}/blobs/{digest}.
463+
*
464+
* <p>The response body is returned as an InputStream for streaming consumption.
465+
* Caller is responsible for closing the stream.
466+
*
467+
* @param repository the repository name
468+
* @param digest the blob digest (e.g., "sha256:...")
469+
* @return InputStream of blob content (caller must close)
470+
* @throws ResourceNotFoundException if blob not found (404)
471+
* @throws UnAuthorizedException if authentication fails (401/403)
472+
* @throws UnknownException if the request fails
473+
*/
460474
public InputStream downloadBlob(String repository, String digest) {
461-
// TODO: need to be implemented
462-
throw new UnSupportedOperationException("downloadBlob() not yet implemented");
475+
String url = String.format("%s/v2/%s/blobs/%s", registryEndpoint, repository, digest);
476+
477+
HttpGet request = new HttpGet(url);
478+
String authHeader = getHttpAuthHeader(repository);
479+
if (authHeader != null) {
480+
request.setHeader(HttpHeaders.AUTHORIZATION, authHeader);
481+
}
482+
483+
try {
484+
CloseableHttpResponse response = httpClient.execute(request);
485+
int statusCode = response.getStatusLine().getStatusCode();
486+
487+
if (statusCode != HttpStatus.SC_OK) {
488+
String errorBody = response.getEntity() != null
489+
? EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8)
490+
: StringUtils.EMPTY;
491+
response.close();
492+
String message = String.format(
493+
"Failed to download blob %s from %s - HTTP %d: %s",
494+
digest, repository, statusCode, errorBody);
495+
496+
throw mapHttpStatusToException(statusCode, message);
497+
}
498+
499+
if (response.getEntity() == null) {
500+
response.close();
501+
throw new UnknownException("Failed to download blob: empty response body");
502+
}
503+
504+
// Return InputStream wrapped to close HTTP response on close()
505+
return new FilterInputStream(response.getEntity().getContent()) {
506+
@Override
507+
public void close() throws IOException {
508+
try {
509+
super.close();
510+
} finally {
511+
response.close();
512+
}
513+
}
514+
};
515+
} catch (IOException e) {
516+
throw new UnknownException("Failed to download blob", e);
517+
}
463518
}
464519

465520
public String getRegistryEndpoint() {
466521
return registryEndpoint;
467522
}
468523

524+
/**
525+
* Maps HTTP status codes to SDK exceptions.
526+
*/
527+
private RuntimeException mapHttpStatusToException(int statusCode, String message) {
528+
switch (statusCode) {
529+
case HttpStatus.SC_NOT_FOUND:
530+
return new ResourceNotFoundException(message);
531+
case HttpStatus.SC_UNAUTHORIZED:
532+
case HttpStatus.SC_FORBIDDEN:
533+
return new UnAuthorizedException(message);
534+
default:
535+
return new UnknownException(message);
536+
}
537+
}
538+
469539
@Override
470540
public void close() throws Exception {
471541
if (httpClient != null) {

registry/registry-client/src/test/java/com/salesforce/multicloudj/registry/driver/OciRegistryClientTest.java

Lines changed: 209 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.mockito.MockitoAnnotations;
2525

2626
import java.io.ByteArrayInputStream;
27+
import java.io.InputStream;
2728
import java.nio.charset.StandardCharsets;
2829
import java.util.Base64;
2930

@@ -634,7 +635,152 @@ void testFetchManifest_LayerMissingDigest() throws Exception {
634635
testFetchManifestWithErrorExpected(invalidManifestJson, "layer missing required 'digest' field");
635636
}
636637

637-
// ========== Helper Methods ==========
638+
@Test
639+
void testDownloadBlob_Success() throws Exception {
640+
String blobContent = "test blob content";
641+
String digest = "sha256:abc123";
642+
643+
CloseableHttpClient mockHttpClient = createMockHttpClientForBlob(blobContent, HttpStatus.SC_OK, true);
644+
645+
try (MockedStatic<AuthChallenge> mockedAuthChallenge = mockAuthChallenge()) {
646+
OciRegistryClient client = new OciRegistryClient(REGISTRY_ENDPOINT, mockAuthProvider, mockHttpClient);
647+
648+
try (InputStream stream = client.downloadBlob(REPOSITORY, digest)) {
649+
assertNotNull(stream);
650+
byte[] content = stream.readAllBytes();
651+
assertEquals(blobContent, new String(content, StandardCharsets.UTF_8));
652+
}
653+
654+
client.close();
655+
}
656+
}
657+
658+
@Test
659+
void testDownloadBlob_HttpError_404_ThrowsResourceNotFoundException() throws Exception {
660+
String digest = "sha256:notfound";
661+
662+
CloseableHttpClient mockHttpClient = createMockHttpClientForBlob("Not Found", HttpStatus.SC_NOT_FOUND, true);
663+
664+
try (MockedStatic<AuthChallenge> mockedAuthChallenge = mockAuthChallenge()) {
665+
OciRegistryClient client = new OciRegistryClient(REGISTRY_ENDPOINT, mockAuthProvider, mockHttpClient);
666+
667+
ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class,
668+
() -> client.downloadBlob(REPOSITORY, digest));
669+
assertTrue(exception.getMessage().contains("HTTP 404"));
670+
671+
client.close();
672+
}
673+
}
674+
675+
@Test
676+
void testDownloadBlob_HttpError_401_ThrowsUnAuthorizedException() throws Exception {
677+
String digest = "sha256:unauthorized";
678+
679+
CloseableHttpClient mockHttpClient = createMockHttpClientForBlob("Unauthorized", HttpStatus.SC_UNAUTHORIZED, true);
680+
681+
try (MockedStatic<AuthChallenge> mockedAuthChallenge = mockAuthChallenge()) {
682+
OciRegistryClient client = new OciRegistryClient(REGISTRY_ENDPOINT, mockAuthProvider, mockHttpClient);
683+
684+
UnAuthorizedException exception = assertThrows(UnAuthorizedException.class,
685+
() -> client.downloadBlob(REPOSITORY, digest));
686+
assertTrue(exception.getMessage().contains("HTTP 401"));
687+
688+
client.close();
689+
}
690+
}
691+
692+
@Test
693+
void testDownloadBlob_HttpError_403_ThrowsUnAuthorizedException() throws Exception {
694+
String digest = "sha256:forbidden";
695+
696+
CloseableHttpClient mockHttpClient = createMockHttpClientForBlob("Forbidden", HttpStatus.SC_FORBIDDEN, true);
697+
698+
try (MockedStatic<AuthChallenge> mockedAuthChallenge = mockAuthChallenge()) {
699+
OciRegistryClient client = new OciRegistryClient(REGISTRY_ENDPOINT, mockAuthProvider, mockHttpClient);
700+
701+
UnAuthorizedException exception = assertThrows(UnAuthorizedException.class,
702+
() -> client.downloadBlob(REPOSITORY, digest));
703+
assertTrue(exception.getMessage().contains("HTTP 403"));
704+
705+
client.close();
706+
}
707+
}
708+
709+
@Test
710+
void testDownloadBlob_HttpError_500_ThrowsUnknownException() throws Exception {
711+
String digest = "sha256:servererror";
712+
713+
CloseableHttpClient mockHttpClient = createMockHttpClientForBlob("Internal Server Error", HttpStatus.SC_INTERNAL_SERVER_ERROR, true);
714+
715+
try (MockedStatic<AuthChallenge> mockedAuthChallenge = mockAuthChallenge()) {
716+
OciRegistryClient client = new OciRegistryClient(REGISTRY_ENDPOINT, mockAuthProvider, mockHttpClient);
717+
718+
UnknownException exception = assertThrows(UnknownException.class,
719+
() -> client.downloadBlob(REPOSITORY, digest));
720+
assertTrue(exception.getMessage().contains("HTTP 500"));
721+
722+
client.close();
723+
}
724+
}
725+
726+
@Test
727+
void testDownloadBlob_EmptyResponseBody_ThrowsUnknownException() throws Exception {
728+
String digest = "sha256:empty";
729+
730+
CloseableHttpClient mockHttpClient = createMockHttpClientForBlob(null, HttpStatus.SC_OK, false);
731+
732+
try (MockedStatic<AuthChallenge> mockedAuthChallenge = mockAuthChallenge()) {
733+
OciRegistryClient client = new OciRegistryClient(REGISTRY_ENDPOINT, mockAuthProvider, mockHttpClient);
734+
735+
UnknownException exception = assertThrows(UnknownException.class,
736+
() -> client.downloadBlob(REPOSITORY, digest));
737+
assertTrue(exception.getMessage().contains("empty response body"));
738+
739+
client.close();
740+
}
741+
}
742+
743+
@Test
744+
void testDownloadBlob_SetsAuthorizationHeader() throws Exception {
745+
String digest = "sha256:authtest";
746+
CloseableHttpClient mockHttpClient = createMockHttpClientWithExecuteAnswer("auth test", invocation -> {
747+
HttpGet request = invocation.getArgument(0);
748+
Header authHeader = request.getFirstHeader("Authorization");
749+
assertNotNull(authHeader, "Authorization header should be set");
750+
assertEquals("Basic dXNlcjp0b2tlbg==", authHeader.getValue()); // Base64("user:token")
751+
});
752+
753+
try (MockedStatic<AuthChallenge> mockedAuthChallenge = mockAuthChallenge()) {
754+
OciRegistryClient client = new OciRegistryClient(REGISTRY_ENDPOINT, mockAuthProvider, mockHttpClient);
755+
try (InputStream stream = client.downloadBlob(REPOSITORY, digest)) {
756+
assertNotNull(stream);
757+
}
758+
client.close();
759+
}
760+
}
761+
762+
@Test
763+
void testDownloadBlob_DoesNotSetAuthHeaderForAnonymous() throws Exception {
764+
String digest = "sha256:noauthtest";
765+
CloseableHttpClient mockHttpClient = createMockHttpClientWithExecuteAnswer("no auth test", invocation -> {
766+
HttpGet request = invocation.getArgument(0);
767+
Header authHeader = request.getFirstHeader("Authorization");
768+
assertNull(authHeader, "Authorization header should not be set for anonymous auth");
769+
});
770+
771+
AuthChallenge anonymousChallenge = AuthChallenge.anonymous();
772+
try (MockedStatic<AuthChallenge> mockedAuthChallenge = mockStatic(AuthChallenge.class)) {
773+
mockedAuthChallenge.when(() -> AuthChallenge.discover(any(CloseableHttpClient.class), anyString()))
774+
.thenReturn(anonymousChallenge);
775+
mockedAuthChallenge.when(AuthChallenge::anonymous).thenCallRealMethod();
776+
777+
OciRegistryClient client = new OciRegistryClient(REGISTRY_ENDPOINT, mockAuthProvider, mockHttpClient);
778+
try (InputStream stream = client.downloadBlob(REPOSITORY, digest)) {
779+
assertNotNull(stream);
780+
}
781+
client.close();
782+
}
783+
}
638784

639785
private void testFetchManifestWithResponse(String responseBody, String digestHeader,
640786
int statusCode, ManifestAssertion assertion) throws Exception {
@@ -729,6 +875,68 @@ private MockedStatic<AuthChallenge> mockAuthChallenge() {
729875
return mockedAuthChallenge;
730876
}
731877

878+
/**
879+
* Creates a mock HttpClient that invokes a custom assertion on the outgoing request before returning a 200 response.
880+
*
881+
* @param blobContent the blob content to return
882+
* @param requestAssertion assertion to run on the outgoing HttpGet (e.g. to check headers)
883+
* @return mocked CloseableHttpClient
884+
*/
885+
private CloseableHttpClient createMockHttpClientWithExecuteAnswer(String blobContent,
886+
java.util.function.Consumer<org.mockito.invocation.InvocationOnMock> requestAssertion) {
887+
CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class);
888+
CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class);
889+
StatusLine mockStatusLine = mock(StatusLine.class);
890+
HttpEntity mockEntity = mock(HttpEntity.class);
891+
try {
892+
when(mockStatusLine.getStatusCode()).thenReturn(HttpStatus.SC_OK);
893+
when(mockResponse.getStatusLine()).thenReturn(mockStatusLine);
894+
when(mockResponse.getEntity()).thenReturn(mockEntity);
895+
when(mockEntity.getContent()).thenReturn(
896+
new ByteArrayInputStream(blobContent.getBytes(StandardCharsets.UTF_8)));
897+
when(mockHttpClient.execute(any(HttpGet.class))).thenAnswer(invocation -> {
898+
requestAssertion.accept(invocation);
899+
return mockResponse;
900+
});
901+
} catch (Exception e) {
902+
throw new RuntimeException("Failed to setup mock", e);
903+
}
904+
return mockHttpClient;
905+
}
906+
907+
/**
908+
* Creates a mock HttpClient for blob download tests.
909+
*
910+
* @param blobContent the blob content to return (null for no entity)
911+
* @param statusCode the HTTP status code
912+
* @param hasEntity whether the response has an entity
913+
* @return mocked CloseableHttpClient
914+
*/
915+
private CloseableHttpClient createMockHttpClientForBlob(String blobContent, int statusCode, boolean hasEntity) {
916+
CloseableHttpClient mockHttpClient = mock(CloseableHttpClient.class);
917+
CloseableHttpResponse mockResponse = mock(CloseableHttpResponse.class);
918+
StatusLine mockStatusLine = mock(StatusLine.class);
919+
HttpEntity mockEntity = hasEntity ? mock(HttpEntity.class) : null;
920+
921+
try {
922+
when(mockStatusLine.getStatusCode()).thenReturn(statusCode);
923+
when(mockResponse.getStatusLine()).thenReturn(mockStatusLine);
924+
when(mockResponse.getEntity()).thenReturn(mockEntity);
925+
926+
if (hasEntity && blobContent != null) {
927+
// Create a fresh InputStream each time getContent() is called
928+
when(mockEntity.getContent()).thenAnswer(invocation ->
929+
new ByteArrayInputStream(blobContent.getBytes(StandardCharsets.UTF_8)));
930+
}
931+
932+
when(mockHttpClient.execute(any(HttpGet.class))).thenReturn(mockResponse);
933+
} catch (Exception e) {
934+
throw new RuntimeException("Failed to setup blob mocks", e);
935+
}
936+
937+
return mockHttpClient;
938+
}
939+
732940
@FunctionalInterface
733941
interface ManifestAssertion {
734942
void assertManifest(com.salesforce.multicloudj.registry.model.Manifest manifest);

0 commit comments

Comments
 (0)