diff --git a/src/main/java/land/oras/utils/Const.java b/src/main/java/land/oras/utils/Const.java index db8289f1..56c480dc 100644 --- a/src/main/java/land/oras/utils/Const.java +++ b/src/main/java/land/oras/utils/Const.java @@ -237,4 +237,19 @@ public static String currentTimestamp() { * Application octet stream header value */ public static final String APPLICATION_OCTET_STREAM_HEADER_VALUE = "application/octet-stream"; + + /** + * Content Range header + */ + public static final String CONTENT_RANGE_HEADER = "Content-Range"; + + /** + * Range header + */ + public static final String RANGE_HEADER = "Range"; + + /** + * OCI Chunk Minimum Length header + */ + public static final String OCI_CHUNK_MIN_LENGTH_HEADER = "OCI-Chunk-Min-Length"; } diff --git a/src/main/java/land/oras/utils/OrasHttpClient.java b/src/main/java/land/oras/utils/OrasHttpClient.java index d5f91279..f78d63a3 100644 --- a/src/main/java/land/oras/utils/OrasHttpClient.java +++ b/src/main/java/land/oras/utils/OrasHttpClient.java @@ -273,6 +273,23 @@ public ResponseWrapper post(URI uri, byte[] body, Map he HttpRequest.BodyPublishers.ofByteArray(body)); } + /** + * Perform a Patch request + * @param uri The URI + * @param body The body + * @param headers The headers + * @return The response + */ + public ResponseWrapper patch(URI uri, byte[] body, Map headers) { + return executeRequest( + "PATCH", + uri, + headers, + body, + HttpResponse.BodyHandlers.ofString(), + HttpRequest.BodyPublishers.ofByteArray(body)); + } + /** * Perform a PUT request * @param uri The URI diff --git a/src/test/java/land/oras/RegistryWireMockTest.java b/src/test/java/land/oras/RegistryWireMockTest.java index 5fa7c5cd..cebb6423 100644 --- a/src/test/java/land/oras/RegistryWireMockTest.java +++ b/src/test/java/land/oras/RegistryWireMockTest.java @@ -20,6 +20,11 @@ package land.oras; +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.patch; +import static com.github.tomakehurst.wiremock.client.WireMock.patchRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -30,11 +35,14 @@ import com.github.tomakehurst.wiremock.stubbing.Scenario; import java.io.IOException; import java.io.InputStream; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.ZonedDateTime; +import java.util.HashMap; import java.util.List; +import java.util.Map; import land.oras.auth.AuthStore; import land.oras.auth.AuthStoreAuthenticationProvider; import land.oras.auth.BearerTokenProvider; @@ -42,6 +50,7 @@ import land.oras.exception.OrasException; import land.oras.utils.Const; import land.oras.utils.JsonUtils; +import land.oras.utils.OrasHttpClient; import land.oras.utils.SupportedAlgorithm; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -356,4 +365,43 @@ void shouldRefreshExpiredToken(WireMockRuntimeInfo wmRuntimeInfo) { byte[] blob = registry.getBlob(containerRef.withDigest(digest)); assertEquals("blob-data", new String(blob)); } + + @Test + void shouldExecutePatchRequestWithHeaders(WireMockRuntimeInfo wMockRuntimeInfo) { + WireMock wireMock = wMockRuntimeInfo.getWireMock(); + String registryUrl = wMockRuntimeInfo.getHttpBaseUrl().replace("http://", ""); + OrasHttpClient client = + OrasHttpClient.Builder.builder().withSkipTlsVerify(true).build(); + + // Setup Mock to craete a PATCH request with Headers + wireMock.register(patch(urlEqualTo("/v2/test/blobs/uploads/session1")) + .withHeader(Const.CONTENT_TYPE_HEADER, equalTo(Const.APPLICATION_OCTET_STREAM_HEADER_VALUE)) + .withHeader(Const.CONTENT_RANGE_HEADER, equalTo("0-1023")) + .willReturn(aResponse() + .withStatus(202) + .withHeader(Const.LOCATION_HEADER, "/v2/test/blobs/uploads/session2") + .withHeader(Const.RANGE_HEADER, "0-1023") + .withHeader(Const.OCI_CHUNK_MIN_LENGTH_HEADER, "4096"))); + + // Create sample data with headers + byte[] data = "test patch".getBytes(); + Map headers = new HashMap<>(); + headers.put(Const.CONTENT_TYPE_HEADER, Const.APPLICATION_OCTET_STREAM_HEADER_VALUE); + headers.put(Const.CONTENT_RANGE_HEADER, "0-1023"); + + // Execute Patch + URI uri = URI.create("http://" + registryUrl + "/v2/test/blobs/uploads/session1"); + OrasHttpClient.ResponseWrapper response = client.patch(uri, data, headers); + + // Verify response uses all our constants + assertEquals(202, response.statusCode()); + assertEquals("/v2/test/blobs/uploads/session2", response.headers().get(Const.LOCATION_HEADER.toLowerCase())); + assertEquals("0-1023", response.headers().get(Const.RANGE_HEADER.toLowerCase())); + assertEquals("4096", response.headers().get(Const.OCI_CHUNK_MIN_LENGTH_HEADER.toLowerCase())); + + // Verify the PATCH request was made with correct headers + wireMock.verifyThat(patchRequestedFor(urlEqualTo("/v2/test/blobs/uploads/session1")) + .withHeader(Const.CONTENT_TYPE_HEADER, equalTo(Const.APPLICATION_OCTET_STREAM_HEADER_VALUE)) + .withHeader(Const.CONTENT_RANGE_HEADER, equalTo("0-1023"))); + } }