diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java index c6a2b653909..eb23b1223a3 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java @@ -614,7 +614,6 @@ public Response head( key = getClientProtocol().headS3Object(bucketName, keyPath); isFile(keyPath, key); - // TODO: return the specified range bytes of this object. } catch (OMException ex) { auditReadFailure(s3GAction, ex); getMetrics().updateHeadKeyFailureStats(startNanos); @@ -637,10 +636,38 @@ public Response head( S3StorageType.STANDARD : S3StorageType.fromReplicationConfig(key.getReplicationConfig()); - ResponseBuilder response = Response.ok().status(HttpStatus.SC_OK) - .header(HttpHeaders.CONTENT_LENGTH, key.getDataSize()) - .header(HttpHeaders.CONTENT_TYPE, "binary/octet-stream") - .header(STORAGE_CLASS_HEADER, s3StorageType.toString()); + long length = key.getDataSize(); + String rangeHeaderVal = getHeaders() != null ? + getHeaders().getHeaderString(RANGE_HEADER) : null; + + RangeHeader rangeHeader = null; + if (rangeHeaderVal != null) { + rangeHeader = RangeHeaderParserUtil.parseRangeHeader(rangeHeaderVal, length); + if (rangeHeader.isInValidRange()) { + throw newError(S3ErrorTable.INVALID_RANGE, rangeHeaderVal); + } + } + + ResponseBuilder response; + + if (rangeHeaderVal == null || rangeHeader.isReadFull()) { + response = Response.ok().status(HttpStatus.SC_OK) + .header(HttpHeaders.CONTENT_LENGTH, length); + } else { + long startOffset = rangeHeader.getStartOffset(); + long endOffset = rangeHeader.getEndOffset(); + long contentLength = endOffset - startOffset + 1; + String contentRangeVal = RANGE_HEADER_SUPPORTED_UNIT + " " + + startOffset + "-" + endOffset + "/" + length; + + response = Response.status(Status.PARTIAL_CONTENT) + .header(HttpHeaders.CONTENT_LENGTH, contentLength) + .header(CONTENT_RANGE_HEADER, contentRangeVal); + } + + response.header(HttpHeaders.CONTENT_TYPE, "binary/octet-stream") + .header(STORAGE_CLASS_HEADER, s3StorageType.toString()) + .header(ACCEPT_RANGE_HEADER, RANGE_HEADER_SUPPORTED_UNIT); String eTag = key.getMetadata().get(OzoneConsts.ETAG); if (eTag != null) { diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectHead.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectHead.java index acecf7f81a8..ac077a4c10d 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectHead.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectHead.java @@ -20,12 +20,20 @@ import static java.net.HttpURLConnection.HTTP_NOT_FOUND; import static java.nio.charset.StandardCharsets.UTF_8; import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_FSO_DIRECTORY_CREATION_ENABLED; +import static org.apache.hadoop.ozone.s3.util.S3Consts.ACCEPT_RANGE_HEADER; +import static org.apache.hadoop.ozone.s3.util.S3Consts.CONTENT_RANGE_HEADER; +import static org.apache.hadoop.ozone.s3.util.S3Consts.RANGE_HEADER; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import java.io.IOException; import java.time.format.DateTimeFormatter; import java.util.HashMap; +import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.Response; import org.apache.commons.lang3.RandomStringUtils; import org.apache.hadoop.hdds.client.ReplicationConfig; @@ -33,11 +41,12 @@ import org.apache.hadoop.hdds.client.ReplicationType; import org.apache.hadoop.hdds.conf.OzoneConfiguration; import org.apache.hadoop.ozone.client.OzoneBucket; -import org.apache.hadoop.ozone.client.OzoneClient; import org.apache.hadoop.ozone.client.OzoneClientStub; import org.apache.hadoop.ozone.client.io.OzoneOutputStream; import org.apache.hadoop.ozone.s3.exception.OS3Exception; +import org.apache.hadoop.ozone.s3.exception.S3ErrorTable; import org.apache.http.HttpStatus; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -45,48 +54,70 @@ * Test head object. */ public class TestObjectHead { + private static final String TEST_KEY = "key1"; + private static final String TEST_VALUE = "0123456789"; + private String bucketName = "b1"; private ObjectEndpoint keyEndpoint; private OzoneBucket bucket; + private HttpHeaders headers; @BeforeEach public void setup() throws IOException { - //Create client stub and object store stub. - OzoneClient clientStub = new OzoneClientStub(); + OzoneClientStub client = new OzoneClientStub(); + client.getObjectStore().createS3Bucket(bucketName); + bucket = client.getObjectStore().getS3Bucket(bucketName); - // Create volume and bucket - clientStub.getObjectStore().createS3Bucket(bucketName); - - bucket = clientStub.getObjectStore().getS3Bucket(bucketName); + try (OzoneOutputStream out = bucket.createKey(TEST_KEY, + TEST_VALUE.getBytes(UTF_8).length, + ReplicationConfig.fromTypeAndFactor(ReplicationType.RATIS, + ReplicationFactor.ONE), new HashMap<>())) { + out.write(TEST_VALUE.getBytes(UTF_8)); + } - // Create HeadBucket and setClient to OzoneClientStub + headers = mock(HttpHeaders.class); + when(headers.getHeaderString(RANGE_HEADER)).thenReturn(null); keyEndpoint = EndpointBuilder.newObjectEndpointBuilder() - .setClient(clientStub) + .setClient(client) + .setHeaders(headers) .build(); } + @AfterEach + public void cleanup() throws IOException { + if (bucket != null) { + try { + bucket.deleteKey(TEST_KEY); + } catch (Exception e) { + // Ignore errors during cleanup to ensure test isolation + } + } + } + @Test public void testHeadObject() throws Exception { //GIVEN String value = RandomStringUtils.secure().nextAlphanumeric(32); - OzoneOutputStream out = bucket.createKey("key1", + String testKey = "testKey"; + try (OzoneOutputStream out = bucket.createKey(testKey, value.getBytes(UTF_8).length, ReplicationConfig.fromTypeAndFactor(ReplicationType.RATIS, - ReplicationFactor.ONE), new HashMap<>()); - out.write(value.getBytes(UTF_8)); - out.close(); + ReplicationFactor.ONE), new HashMap<>())) { + out.write(value.getBytes(UTF_8)); + } //WHEN - Response response = keyEndpoint.head(bucketName, "key1"); + Response response = keyEndpoint.head(bucketName, testKey); //THEN assertEquals(200, response.getStatus()); assertEquals(value.getBytes(UTF_8).length, - Long.parseLong(response.getHeaderString("Content-Length"))); + Long.parseLong(response.getHeaderString(HttpHeaders.CONTENT_LENGTH))); DateTimeFormatter.RFC_1123_DATE_TIME .parse(response.getHeaderString("Last-Modified")); + bucket.deleteKey(testKey); } @Test @@ -185,4 +216,54 @@ public void testHeadWhenKeyIsAFileAndKeyPathEndsWithASlash() assertEquals(HttpStatus.SC_NOT_FOUND, response.getStatus()); bucket.deleteKey(keyPath); } + + @Test + public void testHeadWithRangeHeader() throws Exception { + //GIVEN + when(headers.getHeaderString(RANGE_HEADER)).thenReturn("bytes=0-0"); + + //WHEN + Response response = keyEndpoint.head(bucketName, TEST_KEY); + + //THEN + assertEquals(206, response.getStatus()); + assertEquals("1", response.getHeaderString(HttpHeaders.CONTENT_LENGTH)); + assertEquals(String.format("bytes 0-0/%d", TEST_VALUE.length()), + response.getHeaderString(CONTENT_RANGE_HEADER)); + assertEquals("bytes", response.getHeaderString(ACCEPT_RANGE_HEADER)); + + // Test range from start to end of file + when(headers.getHeaderString(RANGE_HEADER)).thenReturn("bytes=0-"); + response = keyEndpoint.head(bucketName, TEST_KEY); + assertEquals(206, response.getStatus()); + assertEquals(String.valueOf(TEST_VALUE.length()), + response.getHeaderString(HttpHeaders.CONTENT_LENGTH)); + assertEquals(String.format("bytes 0-%d/%d", TEST_VALUE.length() - 1, TEST_VALUE.length()), + response.getHeaderString(CONTENT_RANGE_HEADER)); + } + + @Test + public void testHeadWithInvalidRangeHeader() throws Exception { + // Invalid range: start (11) and end (10) exceed file length (10) + when(headers.getHeaderString(RANGE_HEADER)).thenReturn("bytes=11-10"); + + //WHEN/THEN + OS3Exception ex = assertThrows(OS3Exception.class, + () -> keyEndpoint.head(bucketName, TEST_KEY)); + assertEquals(S3ErrorTable.INVALID_RANGE.getCode(), ex.getCode()); + assertEquals(416, ex.getHttpCode()); + } + + @Test + public void testHeadWithoutRangeHeader() throws Exception { + //WHEN + Response response = keyEndpoint.head(bucketName, TEST_KEY); + + //THEN + assertEquals(200, response.getStatus()); + assertEquals(String.valueOf(TEST_VALUE.length()), + response.getHeaderString(HttpHeaders.CONTENT_LENGTH)); + assertEquals("bytes", response.getHeaderString(ACCEPT_RANGE_HEADER)); + assertNull(response.getHeaderString(CONTENT_RANGE_HEADER)); + } }