From fae80c6cea318daf40afe4fb4a8504c974f3645f Mon Sep 17 00:00:00 2001 From: Tatiana Nesterenko Date: Thu, 22 Aug 2024 21:52:43 +0100 Subject: [PATCH] api: Add support for `Range` header in object GET requests The `Range` header is now accepted for `/objects/{cid}/by_id/{oid}` and `/objects/{cid}/by_attribute/{oid}` requests. Close #224. Signed-off-by: Tatiana Nesterenko --- cmd/neofs-rest-gw/integration_test.go | 41 +++- handlers/apiserver/rest-server.gen.go | 324 +++++++++++++----------- handlers/newObjects.go | 155 ++++++++++++ handlers/rangeReader.go | 71 ++++++ handlers/rangeReader_test.go | 339 ++++++++++++++++++++++++++ spec/rest.yaml | 66 +++++ 6 files changed, 848 insertions(+), 148 deletions(-) create mode 100644 handlers/rangeReader.go create mode 100644 handlers/rangeReader_test.go diff --git a/cmd/neofs-rest-gw/integration_test.go b/cmd/neofs-rest-gw/integration_test.go index 1569b58..27154f6 100644 --- a/cmd/neofs-rest-gw/integration_test.go +++ b/cmd/neofs-rest-gw/integration_test.go @@ -155,8 +155,15 @@ func runTests(ctx context.Context, t *testing.T, key *keys.PrivateKey, node stri t.Run("rest new head object with wallet connect", func(t *testing.T) { restNewObjectHead(ctx, t, clientPool, &owner, cnrID, signer, true) }) t.Run("rest new head by attribute", func(t *testing.T) { restNewObjectHeadByAttribute(ctx, t, clientPool, &owner, cnrID, signer, false) }) t.Run("rest new head by attribute with wallet connect", func(t *testing.T) { restNewObjectHeadByAttribute(ctx, t, clientPool, &owner, cnrID, signer, true) }) - t.Run("rest new get by attribute", func(t *testing.T) { restNewObjectGetByAttribute(ctx, t, clientPool, &owner, cnrID, signer, false) }) - t.Run("rest new get by attribute with wallet connect", func(t *testing.T) { restNewObjectGetByAttribute(ctx, t, clientPool, &owner, cnrID, signer, true) }) + t.Run("rest new get by attribute", func(t *testing.T) { + restNewObjectGetByAttribute(ctx, t, clientPool, &owner, cnrID, signer, false, false) + }) + t.Run("rest new get by attribute with wallet connect", func(t *testing.T) { + restNewObjectGetByAttribute(ctx, t, clientPool, &owner, cnrID, signer, true, false) + }) + t.Run("rest new get by attribute with range", func(t *testing.T) { + restNewObjectGetByAttribute(ctx, t, clientPool, &owner, cnrID, signer, false, true) + }) } func createDockerContainer(ctx context.Context, t *testing.T, image, version string) testcontainers.Container { @@ -2278,11 +2285,13 @@ func restNewObjectHeadByAttribute(ctx context.Context, t *testing.T, p *pool.Poo }) } -func restNewObjectGetByAttribute(ctx context.Context, t *testing.T, p *pool.Pool, ownerID *user.ID, cnrID cid.ID, signer user.Signer, walletConnect bool) { +func restNewObjectGetByAttribute(ctx context.Context, t *testing.T, p *pool.Pool, ownerID *user.ID, cnrID cid.ID, signer user.Signer, walletConnect, addRange bool) { bearer := apiserver.Bearer{ Object: []apiserver.Record{ formAllowRecord(apiserver.OperationGET), formAllowRecord(apiserver.OperationSEARCH), + formAllowRecord(apiserver.OperationHEAD), + formAllowRecord(apiserver.OperationRANGE), }, } bearer.Object = append(bearer.Object, getRestrictBearerRecords()...) @@ -2304,7 +2313,7 @@ func restNewObjectGetByAttribute(ctx context.Context, t *testing.T, p *pool.Pool var ( content = []byte("some content") - fileNameAttr = "new-get-obj-by-attr-name-" + strconv.FormatBool(walletConnect) + fileNameAttr = "new-get-obj-by-attr-name-" + strconv.FormatBool(walletConnect) + strconv.FormatBool(addRange) createTS = time.Now().Unix() attrKey = "user-attribute" attrValue = "user value" @@ -2333,7 +2342,15 @@ func restNewObjectGetByAttribute(ctx context.Context, t *testing.T, p *pool.Pool prepareCommonHeaders(request.Header, bearerToken) } - headers, rawPayload := doRequest(t, httpClient, request, http.StatusOK, nil) + start, end := 5, 10 + status := http.StatusOK + if addRange { + request.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end)) + status = http.StatusPartialContent + } + + headers, rawPayload := doRequest(t, httpClient, request, status, nil) + require.NotEmpty(t, headers) for key, vals := range headers { @@ -2358,7 +2375,11 @@ func restNewObjectGetByAttribute(ctx context.Context, t *testing.T, p *pool.Pool case "X-Container-Id": require.Equal(t, cnrID.String(), vals[0]) case "Content-Length": - require.Equal(t, strconv.FormatInt(int64(len(content)), 10), vals[0]) + if addRange { + require.Equal(t, strconv.Itoa(end-start+1), vals[0]) + } else { + require.Equal(t, strconv.Itoa(len(content)), vals[0]) + } case "Content-Type": require.Equal(t, "text/plain; charset=utf-8", vals[0]) case "Date": @@ -2367,10 +2388,16 @@ func restNewObjectGetByAttribute(ctx context.Context, t *testing.T, p *pool.Pool require.GreaterOrEqual(t, tm.Unix(), createTS) case "Access-Control-Allow-Origin": require.Equal(t, "*", vals[0]) + case "Content-Range": + require.Equal(t, fmt.Sprintf("bytes %d-%d/%d", start, end, len(content)), vals[0]) } } - require.Equal(t, content, rawPayload) + if addRange { + require.Equal(t, content[start:end+1], rawPayload) + } else { + require.Equal(t, content, rawPayload) + } }) } diff --git a/handlers/apiserver/rest-server.gen.go b/handlers/apiserver/rest-server.gen.go index 0d830d4..fd33e2d 100644 --- a/handlers/apiserver/rest-server.gen.go +++ b/handlers/apiserver/rest-server.gen.go @@ -422,6 +422,9 @@ type ContainerId = string // ObjectId defines model for objectId. type ObjectId = string +// Range defines model for range. +type Range = string + // SignatureKeyParam defines model for signatureKeyParam. type SignatureKeyParam = string @@ -607,6 +610,9 @@ type NewGetByAttributeParams struct { // XBearerSignatureKey Hex encoded the public part of the key that signed the bearer token. XBearerSignatureKey *SignatureKeyParam `json:"X-Bearer-Signature-Key,omitempty"` + + // Range Request a specific range of bytes from the object. + Range *Range `json:"Range,omitempty"` } // NewHeadByAttributeParams defines parameters for NewHeadByAttribute. @@ -649,6 +655,9 @@ type NewGetContainerObjectParams struct { // XBearerSignatureKey Hex encoded the public part of the key that signed the bearer token. XBearerSignatureKey *SignatureKeyParam `json:"X-Bearer-Signature-Key,omitempty"` + + // Range Request a specific range of bytes from the object. + Range *Range `json:"Range,omitempty"` } // NewHeadContainerObjectParams defines parameters for NewHeadContainerObject. @@ -1893,6 +1902,21 @@ func (w *ServerInterfaceWrapper) NewGetByAttribute(ctx echo.Context) error { params.XBearerSignatureKey = &XBearerSignatureKey } + // ------------- Optional header parameter "Range" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("Range")]; found { + var Range Range + n := len(valueList) + if n != 1 { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Expected one value for Range, got %d", n)) + } + + err = runtime.BindStyledParameterWithOptions("simple", "Range", valueList[0], &Range, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter Range: %s", err)) + } + + params.Range = &Range + } // Invoke the callback with all the unmarshaled arguments err = w.Handler.NewGetByAttribute(ctx, containerId, attrKey, attrVal, params) @@ -2126,6 +2150,21 @@ func (w *ServerInterfaceWrapper) NewGetContainerObject(ctx echo.Context) error { params.XBearerSignatureKey = &XBearerSignatureKey } + // ------------- Optional header parameter "Range" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("Range")]; found { + var Range Range + n := len(valueList) + if n != 1 { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Expected one value for Range, got %d", n)) + } + + err = runtime.BindStyledParameterWithOptions("simple", "Range", valueList[0], &Range, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter Range: %s", err)) + } + + params.Range = &Range + } // Invoke the callback with all the unmarshaled arguments err = w.Handler.NewGetContainerObject(ctx, containerId, objectId, params) @@ -2688,147 +2727,150 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "Xr+7vd7dvXg1+3J/bbb/MdW9jJGxH+zilcNlqvtiar0TbdK0kR3zMK+y9YrCCupX7d6VRyN2rQL7KT8a", - "EHiNB3IN3S++LDqXdEBUtWVT8v60HncxFhTkcuqV71yrCRb58ONYY8WGZrwZQLaMkaXRFo/iLkZhhzgO", - "cfsUjfF9SpGoe+pr5OlROQlxxaMEOTichCpJhhyrSq0EZy2THcHgxcg+Daiey6FOGJmIS4ecpcY+nRGe", - "+HpGeF9dIw6+JDCeJ0wGviy8URw/1g2ioHGS+Ez9HCUvJDAe/pr1sKfQF7TMQ5+W3tmbnTBI3FFxJKGm", - "hOco6OiSCcGti2dMZdeZNmSy8ggEtubyKDGbRVrQcyGAZZcXWTkhuN1otJqWubM7Nnetvaa1s9eE6OVu", - "YwfuoUYDNVBjBEe75ghaO7u78FVrG+60Xm5tvdp+uQNfvYR7aGtPbk19OEtxnydBkvxaECOODlSqD6KF", - "OQiiTY7kEAee7J9LBXGcFPNJylnnQMqm0JY57uIYej5O5IQTmn8h/kW6KEJ4392+fnPQnnS67QbstCeH", - "3XYXtyeTQ33/vaPvv/c67d5FG/c6nfapbtcL2x0cxNtdxdsdJ9rdB+16o870trnXet89PPLbX75suYzR", - "43N40jh70/h7+vVob+T/7/p4ctGDB5OukcVa/MJ+x4Nu7+B1x3vtv/663Xpz7hyfDtzulL05Hr7/4sO/", - "Wm+/7B5Pp+fbY3h+ffv2sDF+9f72L//6tf1lGx60p+5x+xT3Ll5PjsxedzK9ONixv2y1X/99/o7Neu7M", - "bPaOp/bFy7PO1XT78Oh866o97HU77e5F++LPPyPgFpVscIuMlPDW/qOSFCTv5CYpBDUuClMUoq7Z/S+s", - "FjKOigQEUlP3jtllubLvndZFkqOKrzqfXX4fISYli7wRmCgLG9lPUsVLQ5Exd4ZC98uB5KFiMGT6FPO5", - "KiMqSaFSLNs+ny6pLBE47wLPl3QTKbMisMjAaZi5+Uz1fQbUgVtYClVMTCj+Giq/Ws54+A2aq0ut5Baj", - "hfCZsokCSHnZqmFBWQm6vnXGCqFQI2SnFzjDueGny+5gCNr9nqSJLqsqnamaVBF2auBKXlKS7hPRI/Cp", - "SNsemjyOSZXSLV1p0IUTFL/ZDpMaJcdcSvQQEjlC4krzXVPbPi70sLFvbNUataZ0d/CpJH4dmibxXY7d", - "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", - "/l7jVaN+1zQePj78XwAAAP//O0bidbzVAAA=", + "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", + "Gvcf7/8vAAD//1RGqACs2wAA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/handlers/newObjects.go b/handlers/newObjects.go index 505d31f..5ba91af 100644 --- a/handlers/newObjects.go +++ b/handlers/newObjects.go @@ -2,15 +2,18 @@ package handlers import ( "errors" + "fmt" "io" "net/http" "strconv" + "strings" "time" "github.com/labstack/echo/v4" "github.com/nspcc-dev/neofs-rest-gw/handlers/apiserver" "github.com/nspcc-dev/neofs-rest-gw/internal/util" "github.com/nspcc-dev/neofs-sdk-go/bearer" + "github.com/nspcc-dev/neofs-sdk-go/client" cid "github.com/nspcc-dev/neofs-sdk-go/container/id" "github.com/nspcc-dev/neofs-sdk-go/object" oid "github.com/nspcc-dev/neofs-sdk-go/object/id" @@ -158,6 +161,9 @@ func (a *RestAPI) NewGetContainerObject(ctx echo.Context, containerID apiserver. return ctx.JSON(http.StatusBadRequest, resp) } + if params.Range != nil { + return a.getRange(ctx, addr, *params.Range, params.Download, btoken) + } return a.getByAddress(ctx, addr, params.Download, btoken, true) } @@ -244,6 +250,9 @@ func (a *RestAPI) NewGetByAttribute(ctx echo.Context, containerID apiserver.Cont addrObj.SetContainer(cnrID) addrObj.SetObject(buf[0]) + if params.Range != nil { + return a.getRange(ctx, addrObj, *params.Range, params.Download, btoken) + } return a.getByAddress(ctx, addrObj, params.Download, btoken, true) } @@ -305,3 +314,149 @@ func (a *RestAPI) NewHeadByAttribute(ctx echo.Context, containerID apiserver.Con return a.headByAddress(ctx, addrObj, params.Download, btoken, true) } + +func (a *RestAPI) getRange(ctx echo.Context, addr oid.Address, rangeParam string, downloadParam *string, btoken *bearer.Token) error { + // Read the object header to determine the attributes and the size of the payload. + var prm client.PrmObjectHead + if btoken != nil { + attachBearer(&prm, btoken) + } + + header, err := a.pool.ObjectHead(ctx.Request().Context(), addr.Container(), addr.Object(), a.signer, prm) + if err != nil { + if isNotFoundError(err) { + resp := a.logAndGetErrorResponse("not found", err) + return ctx.JSON(http.StatusNotFound, resp) + } + resp := a.logAndGetErrorResponse("head object", err) + return ctx.JSON(http.StatusBadRequest, resp) + } + + payloadSize := header.PayloadSize() + + // Parse range parameters. + var start, end, offset, length uint64 + start, end, err = getRangeParams(rangeParam, payloadSize) + if err != nil { + resp := a.logAndGetErrorResponse("range", err, zap.String("range", rangeParam)) + return ctx.JSON(http.StatusRequestedRangeNotSatisfiable, resp) + } + + a.log.Debug("Range", + zap.Uint64("start", start), + zap.Uint64("end", end), + zap.Uint64("payloadSize", payloadSize)) + + // Set attributes. + param := setAttributeParams{ + cid: addr.Container().String(), + oid: addr.Object().String(), + payloadSize: payloadSize, + download: downloadParam, + useJSON: true, + header: *header, + } + contentType := a.setAttributes(ctx, param) + + // Find offset and length. + if len(contentType) == 0 { + // We should read payload from the beginning to detect ContentType. + offset = 0 + } else { + offset = start + } + length = end - offset + 1 + a.log.Debug("Params for ObjectRangeInit", + zap.Uint64("offset", offset), + zap.Uint64("length", length)) + + // Get object range. + var prmRange client.PrmObjectRange + if btoken != nil { + attachBearer(&prmRange, btoken) + } + + resObj, err := a.pool.ObjectRangeInit(ctx.Request().Context(), addr.Container(), addr.Object(), offset, length, a.signer, prmRange) + if err != nil { + return ctx.JSON(http.StatusBadRequest, util.NewErrorResponse(err)) + } + + payload := io.ReadCloser(resObj) + + if len(contentType) == 0 { + // Determine the Content-Type from the payload head. + var payloadHead []byte + + contentType, payloadHead, err = readContentType(length, func(uint64) (io.Reader, error) { + return payload, nil + }) + if err != nil { + resp := a.logAndGetErrorResponse("invalid ContentType", err) + return ctx.JSON(http.StatusBadRequest, resp) + } + + // A piece of `payload` was read and is stored in `payloadHead`. + // RangeReader allows reading data from both `payloadHead` and `payload` starting from position `start`, + // regardless of where the `start` is. + a.log.Debug("RangeReader params", + zap.Int("payloadHead length", len(payloadHead)), + zap.Uint64("length", length), + zap.Uint64("start", start)) + rangeReader := NewRangeReader(payload, payloadHead, length, start) + payload = readCloser{rangeReader, rangeReader} + } + + ctx.Response().Header().Set("Content-Type", contentType) + ctx.Response().Header().Set(accessControlAllowOriginHeader, "*") + + ctx.Response().Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, payloadSize)) + ctx.Response().Header().Set("Content-Length", strconv.FormatUint(end-start+1, 10)) + + return ctx.Stream(http.StatusPartialContent, contentType, payload) +} + +func getRangeParams(rangeParam string, payloadSize uint64) (end, start uint64, err error) { + const ( + prefix = "bytes=" + base = 10 + bitSize = 64 + ) + + // Preliminary checks. + if payloadSize == 0 { + return 0, 0, errors.New("zero payload size") + } + var found bool + if rangeParam, found = strings.CutPrefix(rangeParam, prefix); !found { + return 0, 0, errors.New("bytes= prefix required") + } + arr := strings.Split(rangeParam, "-") + if len(arr) > 2 { + return 0, 0, errors.New("unsupported multipart range request") + } else if len(arr) != 2 || (arr[0] == "" && arr[1] == "") { + return 0, 0, errors.New("wrong Range header format") + } + + // Parse range parameters. + var err0, err1 error + + if len(arr[0]) == 0 { + end, err1 = strconv.ParseUint(arr[1], base, bitSize) + start = payloadSize - end + end = payloadSize - 1 + } else if len(arr[1]) == 0 { + start, err0 = strconv.ParseUint(arr[0], base, bitSize) + end = payloadSize - 1 + } else { + start, err0 = strconv.ParseUint(arr[0], base, bitSize) + end, err1 = strconv.ParseUint(arr[1], base, bitSize) + if end > payloadSize-1 { + end = payloadSize - 1 + } + } + + if err0 != nil || err1 != nil || start > end || start > payloadSize { + return 0, 0, errors.New("invalid range parameters") + } + return start, end, nil +} diff --git a/handlers/rangeReader.go b/handlers/rangeReader.go new file mode 100644 index 0000000..9dbf4e8 --- /dev/null +++ b/handlers/rangeReader.go @@ -0,0 +1,71 @@ +package handlers + +import ( + "errors" + "io" +) + +// RangeReader allows reading data from both []byte `payloadHead` and io.ReadCloser `payload` +// starting from position `start`, regardless of where the `start` is. Note that the first +// part of `payload` was read into `payloadHead`. +// RangeReader wraps an existing io.ReadCloser `payload` to provide a specific byte range. +type RangeReader struct { + payload io.ReadCloser + payloadHead []byte + totalLength uint64 + start uint64 +} + +// NewRangeReader creates a new RangeReader for a given byte range. +func NewRangeReader(payload io.ReadCloser, payloadHead []byte, totalLength uint64, start uint64) *RangeReader { + return &RangeReader{ + payload: payload, + payloadHead: payloadHead, + totalLength: totalLength, + start: start, + } +} + +// Read implements the io.Reader interface. +func (r *RangeReader) Read(p []byte) (int, error) { + headLen := uint64(len(r.payloadHead)) + pLen := uint64(len(p)) + + if headLen < r.start { + // We have needless part after r.payloadHead that isn't requested. + // Calculate needless totalLength of object - skipLen. + skipLen := r.start - headLen + // We need to read needless part and p or till the end of r.payload, which is (r.totalLength-headLen). + readLen := min(skipLen+pLen, r.totalLength-headLen) + buf := make([]byte, readLen) + n, err := r.payload.Read(buf) + if err != nil && !errors.Is(err, io.EOF) { + return n, err + } + // Copy bytes to p without needless part. + n = copy(p, buf[skipLen:]) + return n, nil + } else if headLen == r.start { + // We have read bytes from the object exactly up to the beginning of the requested piece. + return r.payload.Read(p) + } + // We have already read some of requested bytes in r.payloadHead. + // The first part of p is in r.payloadHead, from the position r.start. + n1 := uint64(copy(p, r.payloadHead[r.start:])) + if n1 < pLen { + // We need to read more. + buf := make([]byte, pLen-n1) + n2, err := r.payload.Read(buf) + if err != nil && !errors.Is(err, io.EOF) { + return n2, err + } + copy(p[n1:], buf[:n2]) + return int(n1) + n2, nil + } + return int(n1), nil +} + +// Close implements the io.Closer interface. +func (r *RangeReader) Close() error { + return r.payload.Close() +} diff --git a/handlers/rangeReader_test.go b/handlers/rangeReader_test.go new file mode 100644 index 0000000..cd6a77f --- /dev/null +++ b/handlers/rangeReader_test.go @@ -0,0 +1,339 @@ +package handlers + +import ( + "bytes" + "io" + "testing" + + "github.com/stretchr/testify/require" +) + +// MockReadCloser is a mock implementation of io.ReadCloser for testing purposes. +type MockReadCloser struct { + Data []byte + ReadAt int + Err error + Closed bool +} + +// Read implements the io.Reader interface. +func (m *MockReadCloser) Read(p []byte) (int, error) { + if m.Err != nil { + return 0, m.Err + } + + if m.ReadAt >= len(m.Data) { + return 0, io.EOF + } + + n := copy(p, m.Data[m.ReadAt:]) + m.ReadAt += n + return n, nil +} + +// Close implements the io.Closer interface. +func (m *MockReadCloser) Close() error { + m.Closed = true + return nil +} + +func TestRangeReaderMy_Read(t *testing.T) { + tests := []struct { + name string + payloadHead []byte + payload []byte + totalLength uint64 + start uint64 + length uint64 + expected []byte + expectError bool + }{ + { + name: "start after payloadHead and read to the end", + payloadHead: []byte{1, 2, 3, 4, 5}, + payload: []byte{6, 7, 8, 9, 10}, + totalLength: 10, + start: 5, + length: 5, + expected: []byte{6, 7, 8, 9, 10}, + expectError: false, + }, + { + name: "start after payloadHead and do not read until the end", + payloadHead: []byte{1, 2, 3, 4, 5}, + payload: []byte{6, 7, 8, 9, 10}, + totalLength: 10, + start: 5, + length: 3, + expected: []byte{6, 7, 8}, + expectError: false, + }, + { + name: "start after payloadHead and read beyond the end", + payloadHead: []byte{1, 2, 3, 4, 5}, + payload: []byte{6, 7, 8, 9, 10}, + totalLength: 10, + start: 5, + length: 6, + expected: []byte{6, 7, 8, 9, 10}, + expectError: false, + }, + { + name: "start after payloadHead with gap and read to the end", + payloadHead: []byte{1, 2, 3, 4, 5}, + payload: []byte{6, 7, 8, 9, 10}, + totalLength: 10, + start: 7, + length: 5, + expected: []byte{8, 9, 10}, + expectError: false, + }, + { + name: "start after payloadHead with gap and do not read until the end", + payloadHead: []byte{1, 2, 3, 4, 5}, + payload: []byte{6, 7, 8, 9, 10}, + totalLength: 10, + start: 7, + length: 2, + expected: []byte{8, 9}, + expectError: false, + }, + { + name: "start after payloadHead with gap and read beyond the end", + payloadHead: []byte{1, 2, 3, 4, 5}, + payload: []byte{6, 7, 8, 9, 10}, + totalLength: 10, + start: 7, + length: 5, + expected: []byte{8, 9, 10}, + expectError: false, + }, + { + name: "start within payloadHead and read to the end", + payloadHead: []byte{1, 2, 3, 4, 5}, + payload: []byte{6, 7, 8, 9, 10}, + totalLength: 10, + start: 2, + length: 8, + expected: []byte{3, 4, 5, 6, 7, 8, 9, 10}, + expectError: false, + }, + { + name: "start within payloadHead and do not read until the end", + payloadHead: []byte{1, 2, 3, 4, 5}, + payload: []byte{6, 7, 8, 9, 10}, + totalLength: 10, + start: 2, + length: 5, + expected: []byte{3, 4, 5, 6, 7}, + expectError: false, + }, + { + name: "start within payloadHead and read beyond the end", + payloadHead: []byte{1, 2, 3, 4, 5}, + payload: []byte{6, 7, 8, 9, 10}, + totalLength: 10, + start: 2, + length: 10, + expected: []byte{3, 4, 5, 6, 7, 8, 9, 10}, + expectError: false, + }, + { + name: "piece of payloadHead", + payloadHead: []byte{1, 2, 3, 4, 5}, + payload: []byte{6, 7, 8, 9, 10}, + totalLength: 10, + start: 1, + length: 3, + expected: []byte{2, 3, 4}, + expectError: false, + }, + { + name: "read full", + payloadHead: []byte{1, 2, 3, 4, 5}, + payload: []byte{6, 7, 8, 9, 10}, + totalLength: 10, + start: 0, + length: 10, + expected: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, + expectError: false, + }, + { + name: "empty payloadHead", + payloadHead: []byte{}, + payload: []byte{1, 2, 3, 4, 5}, + totalLength: 5, + start: 0, + length: 5, + expected: []byte{1, 2, 3, 4, 5}, + expectError: false, + }, + { + name: "empty payload", + payloadHead: []byte{1, 2, 3, 4, 5}, + payload: []byte{}, + totalLength: 5, + start: 0, + length: 5, + expected: []byte{1, 2, 3, 4, 5}, + expectError: false, + }, + { + name: "empty payloadHead and payload return error", + payloadHead: []byte{}, + payload: []byte{}, + totalLength: 0, + start: 0, + length: 5, + expected: nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &MockReadCloser{Data: tt.payload} + r := NewRangeReader(mock, tt.payloadHead, tt.totalLength, tt.start) + + buf := make([]byte, tt.length) + n, err := r.Read(buf) + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, n, len(tt.expected)) + require.True(t, bytes.Equal(buf[:n], tt.expected)) + } + }) + } +} + +func TestRangeReader_Close(t *testing.T) { + mock := &MockReadCloser{} + r := NewRangeReader(mock, nil, 0, 0) + + err := r.Close() + require.NoError(t, err) + require.True(t, mock.Closed) +} + +func TestGetRangeParams(t *testing.T) { + tests := []struct { + name string + rangeParam string + payloadSize uint64 + expectedStart uint64 + expectedEnd uint64 + expectedError bool + }{ + { + name: "beginning", + rangeParam: "bytes=0-499", + payloadSize: 1000, + expectedStart: 0, + expectedEnd: 499, + expectedError: false, + }, + { + name: "end", + rangeParam: "bytes=500-999", + payloadSize: 1000, + expectedStart: 500, + expectedEnd: 999, + expectedError: false, + }, + { + name: "empty start", + rangeParam: "bytes=-500", + payloadSize: 1000, + expectedStart: 500, + expectedEnd: 999, + expectedError: false, + }, + { + name: "empty end", + rangeParam: "bytes=500-", + payloadSize: 1000, + expectedStart: 500, + expectedEnd: 999, + expectedError: false, + }, + { + name: "beyond payloadSize", + rangeParam: "bytes=500-1500", + payloadSize: 1000, + expectedStart: 500, + expectedEnd: 999, + expectedError: false, + }, + { + name: "wrong prefix", + rangeParam: "sweets=500-600", + payloadSize: 1000, + expectedStart: 0, + expectedEnd: 0, + expectedError: true, + }, + { + name: "out of payloadSize", + rangeParam: "bytes=1500-1600", + payloadSize: 1000, + expectedStart: 0, + expectedEnd: 0, + expectedError: true, + }, + { + name: "wrong order", + rangeParam: "bytes=500-300", + payloadSize: 1000, + expectedStart: 0, + expectedEnd: 0, + expectedError: true, + }, + { + name: "invalid", + rangeParam: "bytes=abc-def", + payloadSize: 1000, + expectedStart: 0, + expectedEnd: 0, + expectedError: true, + }, + { + name: "empty", + rangeParam: "bytes=", + payloadSize: 1000, + expectedStart: 0, + expectedEnd: 0, + expectedError: true, + }, + { + name: "zero payloadSize", + rangeParam: "bytes=0-", + payloadSize: 0, + expectedStart: 0, + expectedEnd: 0, + expectedError: true, + }, + { + name: "multipart ranges", + rangeParam: "bytes=0-50, 100-150", + payloadSize: 0, + expectedStart: 0, + expectedEnd: 0, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + start, end, err := getRangeParams(tt.rangeParam, tt.payloadSize) + if tt.expectedError { + require.Error(t, err, "Expected error but got none") + } else { + require.NoError(t, err, "Unexpected error: %v", err) + require.Equal(t, tt.expectedStart, start, "Unexpected start value") + require.Equal(t, tt.expectedEnd, end, "Unexpected end value") + } + }) + } +} diff --git a/spec/rest.yaml b/spec/rest.yaml index c790d8c..0d9f9df 100644 --- a/spec/rest.yaml +++ b/spec/rest.yaml @@ -760,6 +760,7 @@ paths: - $ref: '#/components/parameters/signatureParam' - $ref: '#/components/parameters/signatureKeyParam' - $ref: '#/components/parameters/signatureScheme' + - $ref: '#/components/parameters/range' - name: download in: query description: Set the Content-Disposition header as attachment in response. @@ -771,6 +772,8 @@ paths: responses: "200": $ref: '#/components/responses/NewObjectContentOK' + "206": + $ref: '#/components/responses/ObjectRangeOK' "400": description: Bad request. content: @@ -783,6 +786,12 @@ paths: application/octet-stream: schema: $ref: '#/components/schemas/ErrorResponse' + "416": + description: Range not satisfiable. + content: + application/octet-stream: + schema: + $ref: '#/components/schemas/ErrorResponse' security: - { } - BearerAuth: [ ] @@ -1135,6 +1144,7 @@ paths: - $ref: '#/components/parameters/signatureParam' - $ref: '#/components/parameters/signatureKeyParam' - $ref: '#/components/parameters/signatureScheme' + - $ref: '#/components/parameters/range' - name: download in: query description: Set the Content-Disposition header as attachment in response. @@ -1146,6 +1156,8 @@ paths: responses: "200": $ref: '#/components/responses/NewObjectContentOK' + "206": + $ref: '#/components/responses/ObjectRangeOK' "400": description: Bad request. content: @@ -1158,6 +1170,12 @@ paths: application/octet-stream: schema: $ref: '#/components/schemas/ErrorResponse' + "416": + description: Range not satisfiable. + content: + application/octet-stream: + schema: + $ref: '#/components/schemas/ErrorResponse' security: - { } - BearerAuth: [ ] @@ -2043,6 +2061,47 @@ components: schema: type: string format: binary + ObjectRangeOK: + description: Partial Content - Successfully retrieved a partial object based on the Range header. + headers: + X-Attributes: + schema: + type: string + description: All object attributes are included here in JSON formatted key-value map, + except for `X-Object-Id`, `X-Container-Id`, `X-Owner-Id`. + Access-Control-Allow-Origin: + schema: + type: string + Content-Disposition: + schema: + type: string + X-Object-Id: + schema: + type: string + Last-Modified: + schema: + type: string + X-Owner-Id: + schema: + type: string + X-Container-Id: + schema: + type: string + Content-Length: + schema: + type: string + Content-Type: + schema: + type: string + Content-Range: + schema: + type: string + description: Indicates the range of bytes being returned in the response. + content: + '*/*': + schema: + type: string + format: binary ObjectHeadOK: description: Object info. headers: @@ -2150,6 +2209,13 @@ components: schema: type: boolean default: false + range: + name: Range + in: header + description: Request a specific range of bytes from the object. + schema: + type: string + example: "bytes=500-999" containerId: name: containerId in: path