From 64f0f40d30ed888c1e32626cdbe1ba6ac9359709 Mon Sep 17 00:00:00 2001 From: Tatiana Nesterenko Date: Sun, 12 May 2024 16:34:37 +0100 Subject: [PATCH 1/8] util.go: Reformat func `filterHeaders` Signed-off-by: Tatiana Nesterenko --- handlers/util.go | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/handlers/util.go b/handlers/util.go index bf58542..a1a756d 100644 --- a/handlers/util.go +++ b/handlers/util.go @@ -274,26 +274,16 @@ func filterHeaders(l *zap.Logger, header http.Header) (map[string]string, error) value := values[0] - // checks that the key and the val not empty - if len(key) == 0 || len(value) == 0 { + // checks that the key and the val not empty and the key has attribute prefix + if !isValidKeyValue(key, value) { continue } - // checks that the key has attribute prefix - if !strings.HasPrefix(key, userAttributeHeaderPrefix) { - continue - } - - // removing attribute prefix - clearKey := strings.TrimPrefix(key, userAttributeHeaderPrefix) - - // checks that it's a system NeoFS header - if strings.HasPrefix(clearKey, neofsAttributeHeaderPrefix) { - clearKey = systemTranslator(clearKey, neofsAttributeHeaderPrefix) - } + // removing attribute prefix and checks that it's a system NeoFS header + clearKey := processKey(key) // checks that the attribute key is not empty - if len(clearKey) == 0 { + if clearKey == "" { continue } @@ -348,3 +338,15 @@ func getOffsetAndLimit(offset, limit *int) (int, int, error) { return off, lim, nil } + +func isValidKeyValue(key, value string) bool { + return len(key) > 0 && len(value) > 0 && strings.HasPrefix(key, userAttributeHeaderPrefix) +} + +func processKey(key string) string { + clearKey := strings.TrimPrefix(key, userAttributeHeaderPrefix) + if strings.HasPrefix(clearKey, neofsAttributeHeaderPrefix) { + return systemTranslator(clearKey, neofsAttributeHeaderPrefix) + } + return clearKey +} From 61199126a0da4cfe18ecf123525c0c6ddb016bb6 Mon Sep 17 00:00:00 2001 From: Tatiana Nesterenko Date: Mon, 13 May 2024 00:22:57 +0100 Subject: [PATCH 2/8] handlers: Add new requests for upload and download Add a new POST request `/objects/{cId}` that accepts the `X-Attributes` header. Also, new GET and HEAD requests are added for object downloading: `/objects/{containerId}/by_id/{objectId}` and `/objects/{containerId}/by_attribute/{attrKey}/{attrVal}`. Signed-off-by: Tatiana Nesterenko --- handlers/apiserver/rest-server.gen.go | 586 ++++++++++++++++++++------ handlers/newObjects.go | 254 +++++++++++ handlers/objects.go | 109 +++-- handlers/util.go | 50 +++ spec/rest.yaml | 292 +++++++++++++ 5 files changed, 1134 insertions(+), 157 deletions(-) create mode 100644 handlers/newObjects.go diff --git a/handlers/apiserver/rest-server.gen.go b/handlers/apiserver/rest-server.gen.go index d57ba53..b977eaa 100644 --- a/handlers/apiserver/rest-server.gen.go +++ b/handlers/apiserver/rest-server.gen.go @@ -551,6 +551,57 @@ type PutObjectParams struct { XBearerSignatureKey *SignatureKeyParam `json:"X-Bearer-Signature-Key,omitempty"` } +// NewUploadContainerObjectParams defines parameters for NewUploadContainerObject. +type NewUploadContainerObjectParams struct { + // XAttributes All attributes are in a JSON-formatted map of key-value pairs, where the key is the + // attribute name and the value is the attribute value. + // You can also use the special attribute: + // - `__NEOFS__EXPIRATION_EPOCH` - specifies the expiration epoch used by NeoFS. + // This attribute should be used if you are familiar with the NeoFS epoch system. + // More information can be found here: [NeoFS Specifications](https://github.com/nspcc-dev/neofs-spec/blob/master/01-arch/01-netmap.md). + // Instead of this attribute you can use one of `X-Neofs-*` headers below. + XAttributes *string `json:"X-Attributes,omitempty"` + + // XNeofsEXPIRATIONRFC3339 Specifies the expiration time in RFC3339 format. Examples: + // - "2024-12-31T23:59:59Z" represents the last moment of 2024 in UTC. + // - "2024-12-31T15:59:59-08:00" represents 3:59 PM on December 31, 2024, Pacific Time.\ + // It will be formatted into the `__NEOFS__EXPIRATION_EPOCH` attribute in the created object. + XNeofsEXPIRATIONRFC3339 *string `json:"X-Neofs-EXPIRATION_RFC3339,omitempty"` + + // XNeofsEXPIRATIONTIMESTAMP Specifies the exact timestamp of object expiration. It will be formatted into the `__NEOFS__EXPIRATION_EPOCH` attribute in the created object. + XNeofsEXPIRATIONTIMESTAMP *string `json:"X-Neofs-EXPIRATION_TIMESTAMP,omitempty"` + + // XNeofsEXPIRATIONDURATION Specifies the duration until object expiration in Go's duration format. Examples: + // - "300s" represents 5 minutes. + // - "2h45m" represents 2 hours and 45 minutes. \ + // It will be formatted into the `__NEOFS__EXPIRATION_EPOCH` attribute in the created object. + XNeofsEXPIRATIONDURATION *string `json:"X-Neofs-EXPIRATION_DURATION,omitempty"` +} + +// NewGetByAttributeParams defines parameters for NewGetByAttribute. +type NewGetByAttributeParams struct { + // Download Set the Content-Disposition header as attachment in response. This makes the browser to download object as file instead of showing it on the page. + Download *string `form:"download,omitempty" json:"download,omitempty"` +} + +// NewHeadByAttributeParams defines parameters for NewHeadByAttribute. +type NewHeadByAttributeParams struct { + // Download Set the Content-Disposition header as attachment in response. This makes the browser to download object as file instead of showing it on the page. + Download *string `form:"download,omitempty" json:"download,omitempty"` +} + +// NewGetContainerObjectParams defines parameters for NewGetContainerObject. +type NewGetContainerObjectParams struct { + // Download Set the Content-Disposition header as attachment in response. This make the browser to download object as file instead of showing it on the page. + Download *string `form:"download,omitempty" json:"download,omitempty"` +} + +// NewHeadContainerObjectParams defines parameters for NewHeadContainerObject. +type NewHeadContainerObjectParams struct { + // Download Set the Content-Disposition header as attachment in response. This make the browser to download object as file instead of showing it on the page. + Download *string `form:"download,omitempty" json:"download,omitempty"` +} + // SearchObjectsParams defines parameters for SearchObjects. type SearchObjectsParams struct { // WalletConnect Use wallet connect signature scheme or native NeoFS signature. @@ -713,6 +764,21 @@ type ServerInterface interface { // Upload object to NeoFS // (PUT /objects) PutObject(ctx echo.Context, params PutObjectParams) error + // Upload object to NeoFS + // (POST /objects/{containerId}) + NewUploadContainerObject(ctx echo.Context, containerId ContainerId, params NewUploadContainerObjectParams) error + // Find and get an object (payload and attributes) by a specific attribute. If more than one object is found, an arbitrary one will be returned. It returns the MIME type based on headers or object contents, so the actual Content-Type can differ from the list in the "Response content type" section. Also, returns custom users' object attributes in header `X-Attributes`. + // (GET /objects/{containerId}/by_attribute/{attrKey}/{attrVal}) + NewGetByAttribute(ctx echo.Context, containerId ContainerId, attrKey AttrKey, attrVal AttrVal, params NewGetByAttributeParams) error + // Get object attributes by a specific attribute. If more than one object is found, an arbitrary one will be used to get attributes. Also, returns custom users' object attributes in header `X-Attributes`. + // (HEAD /objects/{containerId}/by_attribute/{attrKey}/{attrVal}) + NewHeadByAttribute(ctx echo.Context, containerId ContainerId, attrKey AttrKey, attrVal AttrVal, params NewHeadByAttributeParams) error + // Get object by container ID and object ID. Also, returns custom users' object attributes in header `X-Attributes`. It returns the MIME type based on headers or object contents, so the actual Content-Type can differ from the list in the "Response content type" section. + // (GET /objects/{containerId}/by_id/{objectId}) + NewGetContainerObject(ctx echo.Context, containerId ContainerId, objectId ObjectId, params NewGetContainerObjectParams) error + // Get object info (head) by container ID and object ID. Also, returns custom users' object attributes in header `X-Attributes`. + // (HEAD /objects/{containerId}/by_id/{objectId}) + NewHeadContainerObject(ctx echo.Context, containerId ContainerId, objectId ObjectId, params NewHeadContainerObjectParams) error // (OPTIONS /objects/{containerId}/search) OptionsObjectsSearch(ctx echo.Context, containerId string) error @@ -1502,6 +1568,255 @@ func (w *ServerInterfaceWrapper) PutObject(ctx echo.Context) error { return err } +// NewUploadContainerObject converts echo context to params. +func (w *ServerInterfaceWrapper) NewUploadContainerObject(ctx echo.Context) error { + var err error + // ------------- Path parameter "containerId" ------------- + var containerId ContainerId + + err = runtime.BindStyledParameterWithOptions("simple", "containerId", ctx.Param("containerId"), &containerId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter containerId: %s", err)) + } + + ctx.Set(BearerAuthScopes, []string{}) + + ctx.Set(CookieAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params NewUploadContainerObjectParams + + headers := ctx.Request().Header + // ------------- Optional header parameter "X-Attributes" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("X-Attributes")]; found { + var XAttributes string + n := len(valueList) + if n != 1 { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Expected one value for X-Attributes, got %d", n)) + } + + err = runtime.BindStyledParameterWithOptions("simple", "X-Attributes", valueList[0], &XAttributes, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter X-Attributes: %s", err)) + } + + params.XAttributes = &XAttributes + } + // ------------- Optional header parameter "X-Neofs-EXPIRATION_RFC3339" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("X-Neofs-EXPIRATION_RFC3339")]; found { + var XNeofsEXPIRATIONRFC3339 string + n := len(valueList) + if n != 1 { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Expected one value for X-Neofs-EXPIRATION_RFC3339, got %d", n)) + } + + err = runtime.BindStyledParameterWithOptions("simple", "X-Neofs-EXPIRATION_RFC3339", valueList[0], &XNeofsEXPIRATIONRFC3339, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter X-Neofs-EXPIRATION_RFC3339: %s", err)) + } + + params.XNeofsEXPIRATIONRFC3339 = &XNeofsEXPIRATIONRFC3339 + } + // ------------- Optional header parameter "X-Neofs-EXPIRATION_TIMESTAMP" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("X-Neofs-EXPIRATION_TIMESTAMP")]; found { + var XNeofsEXPIRATIONTIMESTAMP string + n := len(valueList) + if n != 1 { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Expected one value for X-Neofs-EXPIRATION_TIMESTAMP, got %d", n)) + } + + err = runtime.BindStyledParameterWithOptions("simple", "X-Neofs-EXPIRATION_TIMESTAMP", valueList[0], &XNeofsEXPIRATIONTIMESTAMP, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter X-Neofs-EXPIRATION_TIMESTAMP: %s", err)) + } + + params.XNeofsEXPIRATIONTIMESTAMP = &XNeofsEXPIRATIONTIMESTAMP + } + // ------------- Optional header parameter "X-Neofs-EXPIRATION_DURATION" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("X-Neofs-EXPIRATION_DURATION")]; found { + var XNeofsEXPIRATIONDURATION string + n := len(valueList) + if n != 1 { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Expected one value for X-Neofs-EXPIRATION_DURATION, got %d", n)) + } + + err = runtime.BindStyledParameterWithOptions("simple", "X-Neofs-EXPIRATION_DURATION", valueList[0], &XNeofsEXPIRATIONDURATION, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter X-Neofs-EXPIRATION_DURATION: %s", err)) + } + + params.XNeofsEXPIRATIONDURATION = &XNeofsEXPIRATIONDURATION + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.NewUploadContainerObject(ctx, containerId, params) + return err +} + +// NewGetByAttribute converts echo context to params. +func (w *ServerInterfaceWrapper) NewGetByAttribute(ctx echo.Context) error { + var err error + // ------------- Path parameter "containerId" ------------- + var containerId ContainerId + + err = runtime.BindStyledParameterWithOptions("simple", "containerId", ctx.Param("containerId"), &containerId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter containerId: %s", err)) + } + + // ------------- Path parameter "attrKey" ------------- + var attrKey AttrKey + + err = runtime.BindStyledParameterWithOptions("simple", "attrKey", ctx.Param("attrKey"), &attrKey, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter attrKey: %s", err)) + } + + // ------------- Path parameter "attrVal" ------------- + var attrVal AttrVal + + err = runtime.BindStyledParameterWithOptions("simple", "attrVal", ctx.Param("attrVal"), &attrVal, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter attrVal: %s", err)) + } + + ctx.Set(BearerAuthScopes, []string{}) + + ctx.Set(CookieAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params NewGetByAttributeParams + // ------------- Optional query parameter "download" ------------- + + err = runtime.BindQueryParameter("form", true, false, "download", ctx.QueryParams(), ¶ms.Download) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter download: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.NewGetByAttribute(ctx, containerId, attrKey, attrVal, params) + return err +} + +// NewHeadByAttribute converts echo context to params. +func (w *ServerInterfaceWrapper) NewHeadByAttribute(ctx echo.Context) error { + var err error + // ------------- Path parameter "containerId" ------------- + var containerId ContainerId + + err = runtime.BindStyledParameterWithOptions("simple", "containerId", ctx.Param("containerId"), &containerId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter containerId: %s", err)) + } + + // ------------- Path parameter "attrKey" ------------- + var attrKey AttrKey + + err = runtime.BindStyledParameterWithOptions("simple", "attrKey", ctx.Param("attrKey"), &attrKey, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter attrKey: %s", err)) + } + + // ------------- Path parameter "attrVal" ------------- + var attrVal AttrVal + + err = runtime.BindStyledParameterWithOptions("simple", "attrVal", ctx.Param("attrVal"), &attrVal, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter attrVal: %s", err)) + } + + ctx.Set(BearerAuthScopes, []string{}) + + ctx.Set(CookieAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params NewHeadByAttributeParams + // ------------- Optional query parameter "download" ------------- + + err = runtime.BindQueryParameter("form", true, false, "download", ctx.QueryParams(), ¶ms.Download) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter download: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.NewHeadByAttribute(ctx, containerId, attrKey, attrVal, params) + return err +} + +// NewGetContainerObject converts echo context to params. +func (w *ServerInterfaceWrapper) NewGetContainerObject(ctx echo.Context) error { + var err error + // ------------- Path parameter "containerId" ------------- + var containerId ContainerId + + err = runtime.BindStyledParameterWithOptions("simple", "containerId", ctx.Param("containerId"), &containerId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter containerId: %s", err)) + } + + // ------------- Path parameter "objectId" ------------- + var objectId ObjectId + + err = runtime.BindStyledParameterWithOptions("simple", "objectId", ctx.Param("objectId"), &objectId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter objectId: %s", err)) + } + + ctx.Set(BearerAuthScopes, []string{}) + + ctx.Set(CookieAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params NewGetContainerObjectParams + // ------------- Optional query parameter "download" ------------- + + err = runtime.BindQueryParameter("form", true, false, "download", ctx.QueryParams(), ¶ms.Download) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter download: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.NewGetContainerObject(ctx, containerId, objectId, params) + return err +} + +// NewHeadContainerObject converts echo context to params. +func (w *ServerInterfaceWrapper) NewHeadContainerObject(ctx echo.Context) error { + var err error + // ------------- Path parameter "containerId" ------------- + var containerId ContainerId + + err = runtime.BindStyledParameterWithOptions("simple", "containerId", ctx.Param("containerId"), &containerId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter containerId: %s", err)) + } + + // ------------- Path parameter "objectId" ------------- + var objectId ObjectId + + err = runtime.BindStyledParameterWithOptions("simple", "objectId", ctx.Param("objectId"), &objectId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter objectId: %s", err)) + } + + ctx.Set(BearerAuthScopes, []string{}) + + ctx.Set(CookieAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params NewHeadContainerObjectParams + // ------------- Optional query parameter "download" ------------- + + err = runtime.BindQueryParameter("form", true, false, "download", ctx.QueryParams(), ¶ms.Download) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter download: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.NewHeadContainerObject(ctx, containerId, objectId, params) + return err +} + // OptionsObjectsSearch converts echo context to params. func (w *ServerInterfaceWrapper) OptionsObjectsSearch(ctx echo.Context) error { var err error @@ -1917,6 +2232,11 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.OPTIONS(baseURL+"/network-info", wrapper.OptionsNetworkInfo) router.OPTIONS(baseURL+"/objects", wrapper.OptionsObjectsPut) router.PUT(baseURL+"/objects", wrapper.PutObject) + router.POST(baseURL+"/objects/:containerId", wrapper.NewUploadContainerObject) + router.GET(baseURL+"/objects/:containerId/by_attribute/:attrKey/:attrVal", wrapper.NewGetByAttribute) + router.HEAD(baseURL+"/objects/:containerId/by_attribute/:attrKey/:attrVal", wrapper.NewHeadByAttribute) + router.GET(baseURL+"/objects/:containerId/by_id/:objectId", wrapper.NewGetContainerObject) + router.HEAD(baseURL+"/objects/:containerId/by_id/:objectId", wrapper.NewHeadContainerObject) router.OPTIONS(baseURL+"/objects/:containerId/search", wrapper.OptionsObjectsSearch) router.POST(baseURL+"/objects/:containerId/search", wrapper.SearchObjects) router.DELETE(baseURL+"/objects/:containerId/:objectId", wrapper.DeleteObject) @@ -1930,133 +2250,145 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/handlers/newObjects.go b/handlers/newObjects.go new file mode 100644 index 0000000..b4e03f0 --- /dev/null +++ b/handlers/newObjects.go @@ -0,0 +1,254 @@ +package handlers + +import ( + "errors" + "io" + "net/http" + "strconv" + "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" + "go.uber.org/zap" +) + +// NewUploadContainerObject handler that upload file as object with attributes to NeoFS. +func (a *RestAPI) NewUploadContainerObject(ctx echo.Context, containerID apiserver.ContainerId, params apiserver.NewUploadContainerObjectParams) error { + var ( + err error + idObj oid.ID + addr oid.Address + btoken *bearer.Token + ) + + var idCnr cid.ID + if err = idCnr.DecodeString(containerID); err != nil { + resp := a.logAndGetErrorResponse("invalid container id", err) + return ctx.JSON(http.StatusBadRequest, resp) + } + + principal, err := getPrincipal(ctx) + if err != nil { + return ctx.JSON(http.StatusBadRequest, util.NewErrorResponse(err)) + } + + if principal != "" { + btoken, err = getBearerTokenFromString(principal) + if err != nil { + resp := a.logAndGetErrorResponse("get bearer token", err) + return ctx.JSON(http.StatusBadRequest, resp) + } + } + + filtered, err := parseAndFilterAttributes(a.log, params.XAttributes) + if err != nil { + resp := a.logAndGetErrorResponse("could not process header "+userAttributesHeader, err) + return ctx.JSON(http.StatusBadRequest, resp) + } + + addExpirationHeaders(filtered, params) + if needParseExpiration(filtered) { + epochDuration, err := getEpochDurations(ctx.Request().Context(), a.pool) + if err != nil { + resp := a.logAndGetErrorResponse("could not get epoch durations from network info", err) + return ctx.JSON(http.StatusBadRequest, resp) + } + + if err = prepareExpirationHeader(filtered, epochDuration, time.Now()); err != nil { + resp := a.logAndGetErrorResponse("could not parse expiration header", err) + return ctx.JSON(http.StatusBadRequest, resp) + } + } + + attributes := make([]object.Attribute, 0, len(filtered)) + // prepares attributes from filtered headers + for key, val := range filtered { + attribute := object.NewAttribute(key, val) + attributes = append(attributes, *attribute) + } + + // sets Content-Type attribute if the attribute isn't already set + // and if the Content-Type header is present and non-empty + if _, ok := filtered[object.AttributeContentType]; !ok { + if ct := ctx.Request().Header.Get("Content-Type"); len(ct) > 0 { + attrContentType := object.NewAttribute(object.AttributeContentType, ct) + attributes = append(attributes, *attrContentType) + } + } + // sets Timestamp attribute if it wasn't set from header and enabled by settings + if _, ok := filtered[object.AttributeTimestamp]; !ok && a.defaultTimestamp { + timestamp := object.NewAttribute(object.AttributeTimestamp, strconv.FormatInt(time.Now().Unix(), 10)) + attributes = append(attributes, *timestamp) + } + + var obj object.Object + obj.SetContainerID(idCnr) + a.setOwner(&obj, btoken) + obj.SetAttributes(attributes...) + + var prmPutInit client.PrmObjectPutInit + if btoken != nil { + prmPutInit.WithBearerToken(*btoken) + } + + writer, err := a.pool.ObjectPutInit(ctx.Request().Context(), obj, a.signer, prmPutInit) + if err != nil { + resp := a.logAndGetErrorResponse("put object init", err) + return ctx.JSON(http.StatusBadRequest, resp) + } + + chunk := make([]byte, a.maxObjectSize) + _, err = io.CopyBuffer(writer, ctx.Request().Body, chunk) + if err != nil { + resp := a.logAndGetErrorResponse("write", err) + return ctx.JSON(http.StatusBadRequest, resp) + } + + if err = writer.Close(); err != nil { + resp := a.logAndGetErrorResponse("writer close", err) + return ctx.JSON(http.StatusBadRequest, resp) + } + + idObj = writer.GetResult().StoredObjectID() + addr.SetObject(idObj) + addr.SetContainer(idCnr) + + var resp apiserver.AddressForUpload + resp.ContainerId = containerID + resp.ObjectId = idObj.String() + + ctx.Response().Header().Set(accessControlAllowOriginHeader, "*") + return ctx.JSON(http.StatusOK, resp) +} + +// NewGetContainerObject handler that returns object (using container ID and object ID). +func (a *RestAPI) NewGetContainerObject(ctx echo.Context, containerID apiserver.ContainerId, objectID apiserver.ObjectId, params apiserver.NewGetContainerObjectParams) error { + principal, err := getPrincipal(ctx) + if err != nil { + return ctx.JSON(http.StatusBadRequest, util.NewErrorResponse(err)) + } + + addr, err := parseAddress(containerID, objectID) + if err != nil { + resp := a.logAndGetErrorResponse("invalid address", err) + return ctx.JSON(http.StatusBadRequest, resp) + } + + return a.getByAddress(ctx, addr, params.Download, principal, true) +} + +// NewHeadContainerObject handler that returns object info (using container ID and object ID). +func (a *RestAPI) NewHeadContainerObject(ctx echo.Context, containerID apiserver.ContainerId, objectID apiserver.ObjectId, params apiserver.NewHeadContainerObjectParams) error { + principal, err := getPrincipal(ctx) + if err != nil { + return ctx.JSON(http.StatusBadRequest, util.NewErrorResponse(err)) + } + + addr, err := parseAddress(containerID, objectID) + if err != nil { + resp := a.logAndGetErrorResponse("invalid address", err) + return ctx.JSON(http.StatusBadRequest, resp) + } + + ctx.Response().Header().Set(accessControlAllowOriginHeader, "*") + return a.headByAddress(ctx, addr, params.Download, principal, true) +} + +// NewGetByAttribute handler that returns object (payload and attributes) by a specific attribute. +func (a *RestAPI) NewGetByAttribute(ctx echo.Context, containerID apiserver.ContainerId, attrKey apiserver.AttrKey, attrVal apiserver.AttrVal, params apiserver.NewGetByAttributeParams) error { + principal, err := getPrincipal(ctx) + if err != nil { + return ctx.JSON(http.StatusBadRequest, util.NewErrorResponse(err)) + } + + var cnrID cid.ID + if err = cnrID.DecodeString(containerID); err != nil { + resp := a.logAndGetErrorResponse("invalid container id", err) + return ctx.JSON(http.StatusBadRequest, resp) + } + + res, err := a.search(ctx.Request().Context(), principal, cnrID, attrKey, attrVal, object.MatchStringEqual) + if err != nil { + resp := a.logAndGetErrorResponse("could not search for objects", err) + return ctx.JSON(http.StatusNotFound, resp) + } + + defer func() { + if err = res.Close(); err != nil { + zap.L().Error("failed to close resource", zap.Error(err)) + } + }() + + buf := make([]oid.ID, 1) + + n, _ := res.Read(buf) + if n == 0 { + err = res.Close() + + if err == nil || errors.Is(err, io.EOF) { + return ctx.JSON(http.StatusNotFound, util.NewErrorResponse(errors.New("object not found"))) + } + + resp := a.logAndGetErrorResponse("read object list failed", err) + return ctx.JSON(http.StatusNotFound, resp) + } + + var addrObj oid.Address + addrObj.SetContainer(cnrID) + addrObj.SetObject(buf[0]) + + return a.getByAddress(ctx, addrObj, params.Download, principal, true) +} + +// NewHeadByAttribute handler that returns object info (payload and attributes) by a specific attribute. +func (a *RestAPI) NewHeadByAttribute(ctx echo.Context, containerID apiserver.ContainerId, attrKey apiserver.AttrKey, attrVal apiserver.AttrVal, params apiserver.NewHeadByAttributeParams) error { + principal, err := getPrincipal(ctx) + if err != nil { + return ctx.JSON(http.StatusBadRequest, util.NewErrorResponse(err)) + } + + var cnrID cid.ID + if err = cnrID.DecodeString(containerID); err != nil { + resp := a.logAndGetErrorResponse("invalid container id", err) + return ctx.JSON(http.StatusBadRequest, resp) + } + + res, err := a.search(ctx.Request().Context(), principal, cnrID, attrKey, attrVal, object.MatchStringEqual) + if err != nil { + resp := a.logAndGetErrorResponse("could not search for objects", err) + return ctx.JSON(http.StatusNotFound, resp) + } + + defer func() { + if err = res.Close(); err != nil { + zap.L().Error("failed to close resource", zap.Error(err)) + } + }() + + buf := make([]oid.ID, 1) + + n, _ := res.Read(buf) + if n == 0 { + err = res.Close() + + if err == nil || errors.Is(err, io.EOF) { + return ctx.JSON(http.StatusNotFound, util.NewErrorResponse(errors.New("object not found"))) + } + + resp := a.logAndGetErrorResponse("read object list failed", err) + return ctx.JSON(http.StatusNotFound, resp) + } + + var addrObj oid.Address + addrObj.SetContainer(cnrID) + addrObj.SetObject(buf[0]) + + ctx.Response().Header().Set(accessControlAllowOriginHeader, "*") + return a.headByAddress(ctx, addrObj, params.Download, principal, true) +} diff --git a/handlers/objects.go b/handlers/objects.go index 7c42b3d..547c4c7 100644 --- a/handlers/objects.go +++ b/handlers/objects.go @@ -6,6 +6,7 @@ import ( "crypto/ecdsa" "encoding/base64" "encoding/hex" + "encoding/json" "errors" "fmt" "io" @@ -38,6 +39,7 @@ import ( const ( sizeToDetectType = 512 userAttributeHeaderPrefix = "X-Attribute-" + userAttributesHeader = "X-Attributes" attributeFilepathHTTP = "Filepath" attributeFilenameHTTP = "Filename" @@ -51,6 +53,15 @@ type readCloser struct { io.Closer } +type setAttributeParams struct { + cid string + oid string + payloadSize uint64 + download *string + useJSON bool + header object.Object +} + // PutObject handler that uploads object to NeoFS. func (a *RestAPI) PutObject(ctx echo.Context, params apiserver.PutObjectParams) error { principal, err := getPrincipal(ctx) @@ -395,6 +406,7 @@ func (a *RestAPI) SearchObjects(ctx echo.Context, containerID apiserver.Containe return ctx.JSON(http.StatusOK, list) } +// GetContainerObject handler that returns object (using container ID and object ID). func (a *RestAPI) GetContainerObject(ctx echo.Context, containerID apiserver.ContainerId, objectID apiserver.ObjectId, params apiserver.GetContainerObjectParams) error { principal, err := getPrincipal(ctx) if err != nil { @@ -407,11 +419,11 @@ func (a *RestAPI) GetContainerObject(ctx echo.Context, containerID apiserver.Con return ctx.JSON(http.StatusBadRequest, resp) } - return a.getByAddress(ctx, addr, params.Download, principal) + return a.getByAddress(ctx, addr, params.Download, principal, false) } // getByAddress returns object (using container ID and object ID). -func (a *RestAPI) getByAddress(ctx echo.Context, addr oid.Address, downloadParam *string, principal string) error { +func (a *RestAPI) getByAddress(ctx echo.Context, addr oid.Address, downloadParam *string, principal string, useJSON bool) error { var prm client.PrmObjectGet if principal != "" { btoken, err := getBearerTokenFromString(principal) @@ -432,11 +444,17 @@ func (a *RestAPI) getByAddress(ctx echo.Context, addr oid.Address, downloadParam return ctx.JSON(http.StatusBadRequest, resp) } - var ( - payloadSize = header.PayloadSize() - contentType = a.setAttributes(ctx, payloadSize, addr.Container().String(), addr.Object().String(), header, downloadParam) - payload io.ReadCloser = payloadReader - ) + payloadSize := header.PayloadSize() + param := setAttributeParams{ + cid: addr.Container().String(), + oid: addr.Object().String(), + payloadSize: payloadSize, + download: downloadParam, + useJSON: useJSON, + header: header, + } + contentType := a.setAttributes(ctx, param) + payload := io.ReadCloser(payloadReader) if len(contentType) == 0 { if payloadSize > 0 { @@ -484,11 +502,11 @@ func (a *RestAPI) HeadContainerObject(ctx echo.Context, containerID apiserver.Co } ctx.Response().Header().Set(accessControlAllowOriginHeader, "*") - return a.headByAddress(ctx, addr, params.Download, principal) + return a.headByAddress(ctx, addr, params.Download, principal, false) } // headByAddress returns object info (using container ID and object ID). -func (a *RestAPI) headByAddress(ctx echo.Context, addr oid.Address, downloadParam *string, principal string) error { +func (a *RestAPI) headByAddress(ctx echo.Context, addr oid.Address, downloadParam *string, principal string, useJSON bool) error { var ( prm client.PrmObjectHead btoken *bearer.Token @@ -515,7 +533,15 @@ func (a *RestAPI) headByAddress(ctx echo.Context, addr oid.Address, downloadPara } payloadSize := header.PayloadSize() - contentType := a.setAttributes(ctx, payloadSize, addr.Container().String(), addr.Object().String(), *header, downloadParam) + param := setAttributeParams{ + cid: addr.Container().String(), + oid: addr.Object().String(), + payloadSize: payloadSize, + download: downloadParam, + useJSON: useJSON, + header: *header, + } + contentType := a.setAttributes(ctx, param) if len(contentType) == 0 { if payloadSize > 0 { contentType, _, err = readContentType(payloadSize, func(sz uint64) (io.Reader, error) { @@ -548,16 +574,18 @@ func isNotFoundError(err error) bool { errors.Is(err, apistatus.ErrObjectAlreadyRemoved) } -func (a *RestAPI) setAttributes(ctx echo.Context, payloadSize uint64, cid string, oid string, header object.Object, download *string) string { - ctx.Response().Header().Set("Content-Length", strconv.FormatUint(payloadSize, 10)) - ctx.Response().Header().Set("X-Container-Id", cid) - ctx.Response().Header().Set("X-Object-Id", oid) - ctx.Response().Header().Set("X-Owner-Id", header.OwnerID().EncodeToString()) +func (a *RestAPI) setAttributes(ctx echo.Context, params setAttributeParams) string { + ctx.Response().Header().Set("Content-Length", strconv.FormatUint(params.payloadSize, 10)) + + attrJSON := make(map[string]string) + ctx.Response().Header().Set("X-Container-Id", params.cid) + ctx.Response().Header().Set("X-Object-Id", params.oid) + ctx.Response().Header().Set("X-Owner-Id", params.header.OwnerID().EncodeToString()) var ( contentType string dis = "inline" - attributes = header.Attributes() + attributes = params.header.Attributes() ) if len(attributes) > 0 { @@ -570,36 +598,57 @@ func (a *RestAPI) setAttributes(ctx echo.Context, payloadSize uint64, cid string switch key { case object.AttributeFileName: - if download != nil { - switch *download { - case "1", "t", "T", "true", "TRUE", "True", "y", "yes", "Y", "YES", "Yes": - dis = "attachment" - } + if paramIsPositive(params.download) { + dis = "attachment" } ctx.Response().Header().Set("Content-Disposition", dis+"; filename="+path.Base(val)) - ctx.Response().Header().Set("X-Attribute-FileName", val) + if params.useJSON { + attrJSON[key] = val + } else { + ctx.Response().Header().Set(userAttributeHeaderPrefix+key, val) + } case object.AttributeTimestamp: attrTimestamp, err := strconv.ParseInt(val, 10, 64) if err != nil { a.log.Info("attribute timestamp parsing error", - zap.String("container ID", cid), - zap.String("object ID", oid), + zap.String("container ID", params.cid), + zap.String("object ID", params.oid), zap.Error(err)) continue } - ctx.Response().Header().Set("X-Attribute-Timestamp", val) + if params.useJSON { + attrJSON[key] = val + } else { + ctx.Response().Header().Set(userAttributeHeaderPrefix+key, val) + } ctx.Response().Header().Set("Last-Modified", time.Unix(attrTimestamp, 0).UTC().Format(http.TimeFormat)) case object.AttributeContentType: contentType = val default: - if strings.HasPrefix(key, SystemAttributePrefix) { - key = systemBackwardTranslator(key) + if params.useJSON { + attrJSON[key] = attr.Value() + } else { + if strings.HasPrefix(key, SystemAttributePrefix) { + key = systemBackwardTranslator(key) + } + ctx.Response().Header().Set(userAttributeHeaderPrefix+key, attr.Value()) } - ctx.Response().Header().Set(userAttributeHeaderPrefix+key, attr.Value()) } } } + if params.useJSON { + // Marshal the map to a JSON string + s, err := json.Marshal(attrJSON) + if err != nil { + a.log.Info("marshal attributes error", + zap.String("container ID", params.cid), + zap.String("object ID", params.oid), + zap.Error(err)) + } + ctx.Response().Header().Set(userAttributesHeader, string(s)) + } + return contentType } @@ -1063,7 +1112,7 @@ func (a *RestAPI) GetByAttribute(ctx echo.Context, containerID apiserver.Contain addrObj.SetContainer(cnrID) addrObj.SetObject(buf[0]) - return a.getByAddress(ctx, addrObj, params.Download, principal) + return a.getByAddress(ctx, addrObj, params.Download, principal, false) } // HeadByAttribute handler that returns object info (payload and attributes) by a specific attribute. @@ -1110,7 +1159,7 @@ func (a *RestAPI) HeadByAttribute(ctx echo.Context, containerID apiserver.Contai addrObj.SetObject(buf[0]) ctx.Response().Header().Set(accessControlAllowOriginHeader, "*") - return a.headByAddress(ctx, addrObj, params.Download, principal) + return a.headByAddress(ctx, addrObj, params.Download, principal, false) } func (a *RestAPI) search(ctx context.Context, principal string, cid cid.ID, key, val string, op object.SearchMatchType) (*client.ObjectListReader, error) { diff --git a/handlers/util.go b/handlers/util.go index a1a756d..2e5de57 100644 --- a/handlers/util.go +++ b/handlers/util.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "encoding/json" "errors" "fmt" "math" @@ -350,3 +351,52 @@ func processKey(key string) string { } return clearKey } + +func parseAndFilterAttributes(logger *zap.Logger, jsonAttr *string) (map[string]string, error) { + parsed := make(map[string]string) + if jsonAttr == nil { + logger.Debug("JSON attribute pointer is nil") + return parsed, nil + } + + if err := json.Unmarshal([]byte(*jsonAttr), &parsed); err != nil { + return nil, err + } + + result := filterAttributes(logger, parsed) + return result, nil +} + +func filterAttributes(logger *zap.Logger, attributes map[string]string) map[string]string { + for key, value := range attributes { + if key == "" || value == "" { + delete(attributes, key) + continue + } + logger.Debug("Added attribute to result object", zap.String("key", key), zap.String("value", value)) + } + return attributes +} + +func paramIsPositive(s *string) bool { + if s != nil { + switch *s { + case "1", "t", "T", "true", "TRUE", "True", "y", "yes", "Y", "YES", "Yes": + return true + } + } + return false +} + +func addExpirationHeaders(headers map[string]string, params apiserver.NewUploadContainerObjectParams) { + // Add non-empty string pointer values to the map + if params.XNeofsEXPIRATIONDURATION != nil && *params.XNeofsEXPIRATIONDURATION != "" { + headers[ExpirationDurationAttr] = *params.XNeofsEXPIRATIONDURATION + } + if params.XNeofsEXPIRATIONTIMESTAMP != nil && *params.XNeofsEXPIRATIONTIMESTAMP != "" { + headers[ExpirationTimestampAttr] = *params.XNeofsEXPIRATIONTIMESTAMP + } + if params.XNeofsEXPIRATIONRFC3339 != nil && *params.XNeofsEXPIRATIONRFC3339 != "" { + headers[ExpirationRFC3339Attr] = *params.XNeofsEXPIRATIONRFC3339 + } +} diff --git a/spec/rest.yaml b/spec/rest.yaml index e48b9d3..1ffe5d1 100644 --- a/spec/rest.yaml +++ b/spec/rest.yaml @@ -745,6 +745,77 @@ paths: - { } - BearerAuth: [ ] - CookieAuth: [ ] + /objects/{containerId}/by_id/{objectId}: + get: + summary: "Get object by container ID and object ID. Also, returns custom users'\ + \ object attributes in header `X-Attributes`. It returns the MIME type based on headers\ + \ or object contents, so the actual Content-Type can differ from the list\ + \ in the \"Response content type\" section." + operationId: newGetContainerObject + parameters: + - $ref: '#/components/parameters/containerId' + - $ref: '#/components/parameters/objectId' + - name: download + in: query + description: Set the Content-Disposition header as attachment in response. + This make the browser to download object as file instead of showing it on + the page. + schema: + type: string + example: 1, t, T, true, TRUE, True, y, yes, Y, YES, Yes + responses: + "200": + $ref: '#/components/responses/NewObjectContentOK' + "400": + description: Bad request. + content: + application/octet-stream: + schema: + $ref: '#/components/schemas/ErrorResponse' + "404": + description: Not found. + content: + application/octet-stream: + schema: + $ref: '#/components/schemas/ErrorResponse' + security: + - { } + - BearerAuth: [ ] + - CookieAuth: [ ] + head: + summary: Get object info (head) by container ID and object ID. Also, returns custom users' + object attributes in header `X-Attributes`. + operationId: newHeadContainerObject + parameters: + - $ref: '#/components/parameters/containerId' + - $ref: '#/components/parameters/objectId' + - name: download + in: query + description: Set the Content-Disposition header as attachment in response. + This make the browser to download object as file instead of showing it on + the page. + schema: + type: string + example: 1, t, T, true, TRUE, True, y, yes, Y, YES, Yes + responses: + "200": + $ref: '#/components/responses/NewObjectHeadOK' + "400": + description: Bad request. + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + "404": + description: Not found. + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + security: + - { } + - BearerAuth: [ ] + - CookieAuth: [ ] /upload/{containerId}: post: summary: Upload object to NeoFS @@ -819,6 +890,78 @@ paths: type: string content: { } security: [ ] + /objects/{containerId}: + post: + summary: Upload object to NeoFS + operationId: newUploadContainerObject + parameters: + - $ref: '#/components/parameters/containerId' + - name: X-Attributes + in: header + description: | + All attributes are in a JSON-formatted map of key-value pairs, where the key is the + attribute name and the value is the attribute value. + You can also use the special attribute: + - `__NEOFS__EXPIRATION_EPOCH` - specifies the expiration epoch used by NeoFS. + This attribute should be used if you are familiar with the NeoFS epoch system. + More information can be found here: [NeoFS Specifications](https://github.com/nspcc-dev/neofs-spec/blob/master/01-arch/01-netmap.md). + Instead of this attribute you can use one of `X-Neofs-*` headers below. + schema: + type: string + - name: X-Neofs-EXPIRATION_RFC3339 + in: header + description: | + Specifies the expiration time in RFC3339 format. Examples: + - "2024-12-31T23:59:59Z" represents the last moment of 2024 in UTC. + - "2024-12-31T15:59:59-08:00" represents 3:59 PM on December 31, 2024, Pacific Time.\ + It will be formatted into the `__NEOFS__EXPIRATION_EPOCH` attribute in the created object. + schema: + type: string + - name: X-Neofs-EXPIRATION_TIMESTAMP + in: header + description: Specifies the exact timestamp of object expiration. It will be formatted + into the `__NEOFS__EXPIRATION_EPOCH` attribute in the created object. + schema: + type: string + - name: X-Neofs-EXPIRATION_DURATION + in: header + description: | + Specifies the duration until object expiration in Go's duration format. Examples: + - "300s" represents 5 minutes. + - "2h45m" represents 2 hours and 45 minutes. \ + It will be formatted into the `__NEOFS__EXPIRATION_EPOCH` attribute in the created object. + schema: + type: string + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + description: "The file to upload. If no file is present in this\ + \ field, any other field name will be accepted, except for an\ + \ empty one." + responses: + "200": + description: Address of uploaded objects. + headers: + Access-Control-Allow-Origin: + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/AddressForUpload' + "400": + description: Bad request. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + security: + - { } + - BearerAuth: [ ] + - CookieAuth: [ ] /get_by_attribute/{containerId}/{attrKey}/{attrVal}: get: summary: "Find and get an object (payload and attributes) by a specific attribute.\ @@ -913,6 +1056,81 @@ paths: - { } - BearerAuth: [ ] - CookieAuth: [ ] + /objects/{containerId}/by_attribute/{attrKey}/{attrVal}: + get: + summary: "Find and get an object (payload and attributes) by a specific attribute.\ + \ If more than one object is found, an arbitrary one will be returned. It\ + \ returns the MIME type based on headers or object contents, so the actual\ + \ Content-Type can differ from the list in the \"Response content type\" section.\ + \ Also, returns custom users' object attributes in header `X-Attributes`." + operationId: newGetByAttribute + parameters: + - $ref: '#/components/parameters/containerId' + - $ref: '#/components/parameters/attrKey' + - $ref: '#/components/parameters/attrVal' + - name: download + in: query + description: Set the Content-Disposition header as attachment in response. + This makes the browser to download object as file instead of showing it + on the page. + schema: + type: string + example: 1, t, T, true, TRUE, True, y, yes, Y, YES, Yes + responses: + "200": + $ref: '#/components/responses/NewObjectContentOK' + "400": + description: Bad request. + content: + application/octet-stream: + schema: + $ref: '#/components/schemas/ErrorResponse' + "404": + description: Not found. + content: + application/octet-stream: + schema: + $ref: '#/components/schemas/ErrorResponse' + security: + - { } + - BearerAuth: [ ] + - CookieAuth: [ ] + head: + summary: "Get object attributes by a specific attribute. If more than one object\ + \ is found, an arbitrary one will be used to get attributes.\ + \ Also, returns custom users' object attributes in header `X-Attributes`." + operationId: newHeadByAttribute + parameters: + - $ref: '#/components/parameters/containerId' + - $ref: '#/components/parameters/attrKey' + - $ref: '#/components/parameters/attrVal' + - name: download + in: query + description: Set the Content-Disposition header as attachment in response. + This makes the browser to download object as file instead of showing it + on the page. + schema: + type: string + example: 1, t, T, true, TRUE, True, y, yes, Y, YES, Yes + responses: + "200": + $ref: '#/components/responses/NewObjectHeadOK' + "400": + description: Bad request. + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + "404": + description: Not found. + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + security: + - { } + - BearerAuth: [ ] + - CookieAuth: [ ] /network-info: get: summary: "Get network settings" @@ -1693,6 +1911,43 @@ components: schema: type: string format: binary + NewObjectContentOK: + description: Object. + 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: + '*/*': + schema: + type: string + format: binary ObjectHeadOK: description: Object info. headers: @@ -1731,6 +1986,43 @@ components: schema: type: string format: binary + NewObjectHeadOK: + description: Object info. + 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: + '*/*': + schema: + type: string + format: binary BadRequest: description: Bad request. content: From 8a3d6c10b1ccb9553c99d74feab1ee68c599989f Mon Sep 17 00:00:00 2001 From: Tatiana Nesterenko Date: Thu, 16 May 2024 22:16:33 +0100 Subject: [PATCH 3/8] handlers: Add object attr `Timestamp` from `Date` header Signed-off-by: Tatiana Nesterenko --- handlers/newObjects.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/handlers/newObjects.go b/handlers/newObjects.go index b4e03f0..4e7323a 100644 --- a/handlers/newObjects.go +++ b/handlers/newObjects.go @@ -82,9 +82,20 @@ func (a *RestAPI) NewUploadContainerObject(ctx echo.Context, containerID apiserv } } // sets Timestamp attribute if it wasn't set from header and enabled by settings - if _, ok := filtered[object.AttributeTimestamp]; !ok && a.defaultTimestamp { - timestamp := object.NewAttribute(object.AttributeTimestamp, strconv.FormatInt(time.Now().Unix(), 10)) - attributes = append(attributes, *timestamp) + if _, ok := filtered[object.AttributeTimestamp]; !ok { + if a.defaultTimestamp { + timestamp := object.NewAttribute(object.AttributeTimestamp, strconv.FormatInt(time.Now().Unix(), 10)) + attributes = append(attributes, *timestamp) + } else if date := ctx.Request().Header.Get("Date"); len(date) > 0 { + parsedTime, err := time.Parse(time.RFC1123, date) + if err != nil { + resp := a.logAndGetErrorResponse("could not parse header Date", err) + return ctx.JSON(http.StatusBadRequest, resp) + } + + timestamp := object.NewAttribute(object.AttributeTimestamp, strconv.FormatInt(parsedTime.Unix(), 10)) + attributes = append(attributes, *timestamp) + } } var obj object.Object From bd364346337337c2d40a8320f518d387e68ac66b Mon Sep 17 00:00:00 2001 From: Tatiana Nesterenko Date: Thu, 16 May 2024 22:27:07 +0100 Subject: [PATCH 4/8] handlers: Add OPTIONS for all new requests Signed-off-by: Tatiana Nesterenko --- handlers/apiserver/rest-server.gen.go | 363 ++++++++++++++++---------- handlers/preflight.go | 24 ++ spec/rest.yaml | 60 +++++ 3 files changed, 308 insertions(+), 139 deletions(-) diff --git a/handlers/apiserver/rest-server.gen.go b/handlers/apiserver/rest-server.gen.go index b977eaa..3b56860 100644 --- a/handlers/apiserver/rest-server.gen.go +++ b/handlers/apiserver/rest-server.gen.go @@ -764,6 +764,9 @@ type ServerInterface interface { // Upload object to NeoFS // (PUT /objects) PutObject(ctx echo.Context, params PutObjectParams) error + + // (OPTIONS /objects/{containerId}) + NewOptionsUploadContainerObject(ctx echo.Context, containerId ContainerId) error // Upload object to NeoFS // (POST /objects/{containerId}) NewUploadContainerObject(ctx echo.Context, containerId ContainerId, params NewUploadContainerObjectParams) error @@ -773,6 +776,9 @@ type ServerInterface interface { // Get object attributes by a specific attribute. If more than one object is found, an arbitrary one will be used to get attributes. Also, returns custom users' object attributes in header `X-Attributes`. // (HEAD /objects/{containerId}/by_attribute/{attrKey}/{attrVal}) NewHeadByAttribute(ctx echo.Context, containerId ContainerId, attrKey AttrKey, attrVal AttrVal, params NewHeadByAttributeParams) error + + // (OPTIONS /objects/{containerId}/by_attribute/{attrKey}/{attrVal}) + NewOptionsByAttribute(ctx echo.Context, containerId ContainerId, attrKey AttrKey, attrVal AttrVal) error // Get object by container ID and object ID. Also, returns custom users' object attributes in header `X-Attributes`. It returns the MIME type based on headers or object contents, so the actual Content-Type can differ from the list in the "Response content type" section. // (GET /objects/{containerId}/by_id/{objectId}) NewGetContainerObject(ctx echo.Context, containerId ContainerId, objectId ObjectId, params NewGetContainerObjectParams) error @@ -780,6 +786,9 @@ type ServerInterface interface { // (HEAD /objects/{containerId}/by_id/{objectId}) NewHeadContainerObject(ctx echo.Context, containerId ContainerId, objectId ObjectId, params NewHeadContainerObjectParams) error + // (OPTIONS /objects/{containerId}/by_id/{objectId}) + NewOptionsContainerObject(ctx echo.Context, containerId ContainerId, objectId ObjectId) error + // (OPTIONS /objects/{containerId}/search) OptionsObjectsSearch(ctx echo.Context, containerId string) error // Search objects by filters @@ -1568,6 +1577,22 @@ func (w *ServerInterfaceWrapper) PutObject(ctx echo.Context) error { return err } +// NewOptionsUploadContainerObject converts echo context to params. +func (w *ServerInterfaceWrapper) NewOptionsUploadContainerObject(ctx echo.Context) error { + var err error + // ------------- Path parameter "containerId" ------------- + var containerId ContainerId + + err = runtime.BindStyledParameterWithOptions("simple", "containerId", ctx.Param("containerId"), &containerId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter containerId: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.NewOptionsUploadContainerObject(ctx, containerId) + return err +} + // NewUploadContainerObject converts echo context to params. func (w *ServerInterfaceWrapper) NewUploadContainerObject(ctx echo.Context) error { var err error @@ -1743,6 +1768,38 @@ func (w *ServerInterfaceWrapper) NewHeadByAttribute(ctx echo.Context) error { return err } +// NewOptionsByAttribute converts echo context to params. +func (w *ServerInterfaceWrapper) NewOptionsByAttribute(ctx echo.Context) error { + var err error + // ------------- Path parameter "containerId" ------------- + var containerId ContainerId + + err = runtime.BindStyledParameterWithOptions("simple", "containerId", ctx.Param("containerId"), &containerId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter containerId: %s", err)) + } + + // ------------- Path parameter "attrKey" ------------- + var attrKey AttrKey + + err = runtime.BindStyledParameterWithOptions("simple", "attrKey", ctx.Param("attrKey"), &attrKey, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter attrKey: %s", err)) + } + + // ------------- Path parameter "attrVal" ------------- + var attrVal AttrVal + + err = runtime.BindStyledParameterWithOptions("simple", "attrVal", ctx.Param("attrVal"), &attrVal, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter attrVal: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.NewOptionsByAttribute(ctx, containerId, attrKey, attrVal) + return err +} + // NewGetContainerObject converts echo context to params. func (w *ServerInterfaceWrapper) NewGetContainerObject(ctx echo.Context) error { var err error @@ -1817,6 +1874,30 @@ func (w *ServerInterfaceWrapper) NewHeadContainerObject(ctx echo.Context) error return err } +// NewOptionsContainerObject converts echo context to params. +func (w *ServerInterfaceWrapper) NewOptionsContainerObject(ctx echo.Context) error { + var err error + // ------------- Path parameter "containerId" ------------- + var containerId ContainerId + + err = runtime.BindStyledParameterWithOptions("simple", "containerId", ctx.Param("containerId"), &containerId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter containerId: %s", err)) + } + + // ------------- Path parameter "objectId" ------------- + var objectId ObjectId + + err = runtime.BindStyledParameterWithOptions("simple", "objectId", ctx.Param("objectId"), &objectId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter objectId: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.NewOptionsContainerObject(ctx, containerId, objectId) + return err +} + // OptionsObjectsSearch converts echo context to params. func (w *ServerInterfaceWrapper) OptionsObjectsSearch(ctx echo.Context) error { var err error @@ -2232,11 +2313,14 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.OPTIONS(baseURL+"/network-info", wrapper.OptionsNetworkInfo) router.OPTIONS(baseURL+"/objects", wrapper.OptionsObjectsPut) router.PUT(baseURL+"/objects", wrapper.PutObject) + router.OPTIONS(baseURL+"/objects/:containerId", wrapper.NewOptionsUploadContainerObject) router.POST(baseURL+"/objects/:containerId", wrapper.NewUploadContainerObject) router.GET(baseURL+"/objects/:containerId/by_attribute/:attrKey/:attrVal", wrapper.NewGetByAttribute) router.HEAD(baseURL+"/objects/:containerId/by_attribute/:attrKey/:attrVal", wrapper.NewHeadByAttribute) + router.OPTIONS(baseURL+"/objects/:containerId/by_attribute/:attrKey/:attrVal", wrapper.NewOptionsByAttribute) router.GET(baseURL+"/objects/:containerId/by_id/:objectId", wrapper.NewGetContainerObject) router.HEAD(baseURL+"/objects/:containerId/by_id/:objectId", wrapper.NewHeadContainerObject) + router.OPTIONS(baseURL+"/objects/:containerId/by_id/:objectId", wrapper.NewOptionsContainerObject) router.OPTIONS(baseURL+"/objects/:containerId/search", wrapper.OptionsObjectsSearch) router.POST(baseURL+"/objects/:containerId/search", wrapper.SearchObjects) router.DELETE(baseURL+"/objects/:containerId/:objectId", wrapper.DeleteObject) @@ -2250,145 +2334,146 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "8bpRv2saDx8f/i8AAP//rRK6wejPAAA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/handlers/preflight.go b/handlers/preflight.go index 95e8da6..e0dc993 100644 --- a/handlers/preflight.go +++ b/handlers/preflight.go @@ -133,3 +133,27 @@ func (a *RestAPI) OptionsNetworkInfo(ctx echo.Context) error { ctx.Response().Header().Set(accessControlAllowMethodsHeader, allowMethods(methodGet)) return ctx.NoContent(http.StatusOK) } + +// NewOptionsUploadContainerObject handler for the upload object options request. +func (a *RestAPI) NewOptionsUploadContainerObject(ctx echo.Context, _ apiserver.ContainerId) error { + ctx.Response().Header().Set(accessControlAllowOriginHeader, allOrigins) + ctx.Response().Header().Set(accessControlAllowHeadersHeader, allowUploadHeader) + ctx.Response().Header().Set(accessControlAllowMethodsHeader, allowMethods(methodPost)) + return ctx.NoContent(http.StatusOK) +} + +// NewOptionsContainerObject handler for the create object options request. +func (a *RestAPI) NewOptionsContainerObject(ctx echo.Context, _ apiserver.ContainerId, _ apiserver.ObjectId) error { + ctx.Response().Header().Set(accessControlAllowOriginHeader, allOrigins) + ctx.Response().Header().Set(accessControlAllowHeadersHeader, allowHeaders) + ctx.Response().Header().Set(accessControlAllowMethodsHeader, allowMethods(methodGet, methodHead)) + return ctx.NoContent(http.StatusOK) +} + +// NewOptionsByAttribute handler for the find by attribute options request. +func (a *RestAPI) NewOptionsByAttribute(ctx echo.Context, _ apiserver.ContainerId, _ apiserver.AttrKey, _ apiserver.AttrVal) error { + ctx.Response().Header().Set(accessControlAllowOriginHeader, allOrigins) + ctx.Response().Header().Set(accessControlAllowHeadersHeader, allowHeaders) + ctx.Response().Header().Set(accessControlAllowMethodsHeader, allowMethods(methodGet, methodHead)) + return ctx.NoContent(http.StatusOK) +} diff --git a/spec/rest.yaml b/spec/rest.yaml index 1ffe5d1..13900d8 100644 --- a/spec/rest.yaml +++ b/spec/rest.yaml @@ -782,6 +782,26 @@ paths: - { } - BearerAuth: [ ] - CookieAuth: [ ] + options: + operationId: newOptionsContainerObject + parameters: + - $ref: '#/components/parameters/containerId' + - $ref: '#/components/parameters/objectId' + responses: + "200": + description: CORS + headers: + Access-Control-Allow-Origin: + schema: + type: string + Access-Control-Allow-Methods: + schema: + type: string + Access-Control-Allow-Headers: + schema: + type: string + content: { } + security: [ ] head: summary: Get object info (head) by container ID and object ID. Also, returns custom users' object attributes in header `X-Attributes`. @@ -962,6 +982,25 @@ paths: - { } - BearerAuth: [ ] - CookieAuth: [ ] + options: + operationId: newOptionsUploadContainerObject + parameters: + - $ref: '#/components/parameters/containerId' + responses: + "200": + description: CORS + headers: + Access-Control-Allow-Origin: + schema: + type: string + Access-Control-Allow-Methods: + schema: + type: string + Access-Control-Allow-Headers: + schema: + type: string + content: { } + security: [ ] /get_by_attribute/{containerId}/{attrKey}/{attrVal}: get: summary: "Find and get an object (payload and attributes) by a specific attribute.\ @@ -1095,6 +1134,27 @@ paths: - { } - BearerAuth: [ ] - CookieAuth: [ ] + options: + operationId: newOptionsByAttribute + parameters: + - $ref: '#/components/parameters/containerId' + - $ref: '#/components/parameters/attrKey' + - $ref: '#/components/parameters/attrVal' + responses: + "200": + description: CORS + headers: + Access-Control-Allow-Origin: + schema: + type: string + Access-Control-Allow-Methods: + schema: + type: string + Access-Control-Allow-Headers: + schema: + type: string + content: { } + security: [ ] head: summary: "Get object attributes by a specific attribute. If more than one object\ \ is found, an arbitrary one will be used to get attributes.\ From e9cc77092d282730f1082471ea4889bf8006046d Mon Sep 17 00:00:00 2001 From: Tatiana Nesterenko Date: Fri, 17 May 2024 11:00:24 +0100 Subject: [PATCH 5/8] docs: Add note about new upload and download requests Signed-off-by: Tatiana Nesterenko --- docs/migration-new-upload.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/migration-new-upload.md diff --git a/docs/migration-new-upload.md b/docs/migration-new-upload.md new file mode 100644 index 0000000..a667ebb --- /dev/null +++ b/docs/migration-new-upload.md @@ -0,0 +1,34 @@ +# Migrating for using new upload and download requests + +Starting with the 0.9.0 release of the REST gateway, several new API calls for +object upload and download are provided. We highly recommend using them instead +of other existing upload/download requests due to their deprecation and +deletion in the future. + +### Upload + +POST request to `/objects/{containerId}` for uploading objects. This is quite +similar to `/upload/{containerId}`, but it accepts all custom object attributes +in the new header `X-Attributes`. All attributes, including well-known ones +like "FilePath", "FileName", and "Timestamp", can be passed in a JSON-formatted +key-value map. Thanks to the JSON format of this header, we no longer face +issues with the case-insensitivity of the gateway and the case-sensitivity of +NeoFS. All attributes are passed directly to NeoFS. Additionally, +`X-Neofs-EXPIRATION*` headers are available to set object expiration. Learn +more in the Swagger documentation (`/v1/docs`). + +Also, please note that the object attribute "Timestamp" can now be filled in +three ways: through the header `X-Attributes`, automatically if +"DefaultTimestamp" is enabled by settings, or in a new third way. The `Date` +header of the upload request is parsed and saved as the object attribute +"Timestamp." + +### Download + +There are two ways to download objects. The first one, if the object ID is +known, is a GET request to `/objects/{containerId}/by_id/{objectId}`. Another +approach is searching for an object by attribute with a GET request to +`/objects/{containerId}/by_attribute/{attrKey}/{attrVal}`. In the responses of +both requests, all custom object attributes will be placed in the +`X-Attributes` header. Additionally, you can send a HEAD request to both paths +to get object information without the object itself. From 3dd5e2a96b4d84ff1ddab82bab25f09344d54fab Mon Sep 17 00:00:00 2001 From: Tatiana Nesterenko Date: Fri, 17 May 2024 11:00:51 +0100 Subject: [PATCH 6/8] CHANGELOG: Add Updating from 0.8.3 information Signed-off-by: Tatiana Nesterenko --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d2f178..bc436c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ the help of the [gate-configuration.md](./docs/gate-configuration.md) and [config](./config/config.yaml). Also, flags in the command arguments were changed. +A new upload object request has been introduced: `/objects/{containerId}`. This +is a POST request that accepts the `X-Attributes` header, where all custom +object attributes can be included in a JSON-formatted key-value map. Also, new +GET and HEAD requests are added for object downloading: +`/objects/{containerId}/by_id/{objectId}` and +`/objects/{containerId}/by_attribute/{attrKey}/{attrVal}`. +For more information, see the [migration documentation](./docs/migration-new-upload.md). +In the future, we plan to use these requests as the only option for object +upload and download. We recommend starting to use them now. + ## [0.8.3] - 2024-03-25 ### Fixed From eb60bec65ea060b918ffa9ae94e0c922dc9bded8 Mon Sep 17 00:00:00 2001 From: Tatiana Nesterenko Date: Fri, 17 May 2024 16:21:22 +0100 Subject: [PATCH 7/8] handlers: Add tests for util functions of `/objects/{cId}` Signed-off-by: Tatiana Nesterenko --- handlers/util_test.go | 94 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/handlers/util_test.go b/handlers/util_test.go index 96436ab..836749a 100644 --- a/handlers/util_test.go +++ b/handlers/util_test.go @@ -3,6 +3,7 @@ package handlers import ( "math" "net/http" + "reflect" "strconv" "testing" "time" @@ -259,3 +260,96 @@ func Test_getOffsetAndLimit(t *testing.T) { func newInt(v int) *int { return &v } + +func stringPtr(s string) *string { + return &s +} +func Test_paramIsPositive(t *testing.T) { + type args struct { + s *string + } + tests := []struct { + name string + args args + want bool + }{ + {name: "empty string", args: args{stringPtr("")}, want: false}, + {name: "false string", args: args{stringPtr("false")}, want: false}, + {name: "random string", args: args{stringPtr("@$FC1*")}, want: false}, + {name: "0 number", args: args{stringPtr("0")}, want: false}, + {name: "2 number", args: args{stringPtr("2")}, want: false}, + {name: "1 number", args: args{stringPtr("1")}, want: true}, + {name: "true string", args: args{stringPtr("true")}, want: true}, + {name: "YES string", args: args{stringPtr("YES")}, want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := paramIsPositive(tt.args.s); got != tt.want { + t.Errorf("paramIsPositive() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_parseAndFilterAttributes(t *testing.T) { + type args struct { + logger *zap.Logger + jsonAttr *string + } + + l := zap.NewExample() + + var nilStr *string + errStr1, errStr2, errStr3 := "", "{", "JSON" + emptyStr1, emptyStr2, emptyStr3, emptyStr4 := `{}`, `{"":""}`, `{"key":""}`, `{"":"val"}` + str1 := `{ + "skip empty":"", + "":"skip empty", + "__NEOFS__EXPIRATION_DURATION":"1000s", + "file-N%me":"simple %bj filename", + "writer":"Leo Tolstoy", + "Chapter1":"pe@ce", + "chapter2":"war"}` + + emptyMap := make(map[string]string) + map1 := map[string]string{ + "__NEOFS__EXPIRATION_DURATION": "1000s", + "file-N%me": "simple %bj filename", + "writer": "Leo Tolstoy", + "Chapter1": "pe@ce", + "chapter2": "war", + } + + tests := []struct { + name string + args args + want map[string]string + wantErr bool + }{ + {name: "nil str pointer", args: args{l, nilStr}, want: emptyMap, wantErr: false}, + + {name: "wrong string 1", args: args{l, &errStr1}, want: nil, wantErr: true}, + {name: "wrong string 2", args: args{l, &errStr2}, want: nil, wantErr: true}, + {name: "wrong string 3", args: args{l, &errStr3}, want: nil, wantErr: true}, + + {name: "empty result map 1", args: args{l, &emptyStr1}, want: emptyMap, wantErr: false}, + {name: "empty result map 2", args: args{l, &emptyStr2}, want: emptyMap, wantErr: false}, + {name: "empty result map 3", args: args{l, &emptyStr3}, want: emptyMap, wantErr: false}, + {name: "empty result map 4", args: args{l, &emptyStr4}, want: emptyMap, wantErr: false}, + + {name: "correct", args: args{l, &str1}, want: map1, wantErr: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseAndFilterAttributes(tt.args.logger, tt.args.jsonAttr) + if (err != nil) != tt.wantErr { + t.Errorf("parseAndFilterAttributes() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseAndFilterAttributes() got = %v, want %v", got, tt.want) + } + }) + } +} From 3747724049735b159e236df7296ad571d2372c8b Mon Sep 17 00:00:00 2001 From: Tatiana Nesterenko Date: Sun, 26 May 2024 22:19:44 +0100 Subject: [PATCH 8/8] tests: Add integration tests for the new requests Signed-off-by: Tatiana Nesterenko --- cmd/neofs-rest-gw/integration_test.go | 487 ++++++++++++++++++++++++++ 1 file changed, 487 insertions(+) diff --git a/cmd/neofs-rest-gw/integration_test.go b/cmd/neofs-rest-gw/integration_test.go index 0deca6a..7bfd0ce 100644 --- a/cmd/neofs-rest-gw/integration_test.go +++ b/cmd/neofs-rest-gw/integration_test.go @@ -147,6 +147,12 @@ func runTests(ctx context.Context, t *testing.T, key *keys.PrivateKey, node stri t.Run("rest check mix tokens up", func(t *testing.T) { mixTokens(ctx, t, cnrID) }) t.Run("rest balance", func(t *testing.T) { restBalance(ctx, t) }) + + t.Run("rest new upload object", func(t *testing.T) { restNewObjectUpload(ctx, t, clientPool, cnrID, signer) }) + t.Run("rest new upload object with bearer in cookie", func(t *testing.T) { restNewObjectUploadCookie(ctx, t, clientPool, cnrID, signer) }) + t.Run("rest new head object", func(t *testing.T) { restNewObjectHead(ctx, t, clientPool, &owner, cnrID, signer) }) + t.Run("rest new head by attribute", func(t *testing.T) { restNewObjectHeadByAttribute(ctx, t, clientPool, &owner, cnrID, signer) }) + t.Run("rest new get by attribute", func(t *testing.T) { restNewObjectGetByAttribute(ctx, t, clientPool, &owner, cnrID, signer) }) } func createDockerContainer(ctx context.Context, t *testing.T, image, version string) testcontainers.Container { @@ -1863,3 +1869,484 @@ func restObjectUploadInt(ctx context.Context, t *testing.T, clientPool *pool.Poo require.Equal(t, attributes[attribute.Key()], attribute.Value(), attribute.Key()) } } + +func restNewObjectUpload(ctx context.Context, t *testing.T, clientPool *pool.Pool, cnrID cid.ID, signer user.Signer) { + restNewObjectUploadInt(ctx, t, clientPool, cnrID, signer, false) +} +func restNewObjectUploadCookie(ctx context.Context, t *testing.T, clientPool *pool.Pool, cnrID cid.ID, signer user.Signer) { + restNewObjectUploadInt(ctx, t, clientPool, cnrID, signer, true) +} +func restNewObjectUploadInt(ctx context.Context, t *testing.T, clientPool *pool.Pool, cnrID cid.ID, signer user.Signer, cookie bool) { + bt := apiserver.Bearer{ + Object: []apiserver.Record{{ + Operation: apiserver.OperationPUT, + Action: apiserver.ALLOW, + Filters: []apiserver.Filter{}, + Targets: []apiserver.Target{{ + Role: apiserver.OTHERS, + Keys: []string{}, + }}, + }}, + } + bt.Object = append(bt.Object, getRestrictBearerRecords()...) + + httpClient := defaultHTTPClient() + bearerTokens := makeAuthTokenRequest(ctx, t, []apiserver.Bearer{bt}, httpClient, false) + bearerToken := bearerTokens[0] + + query := make(url.Values) + query.Add(walletConnectQuery, strconv.FormatBool(useWalletConnect)) + + // check that object bearer token is valid + request, err := http.NewRequest(http.MethodGet, testHost+"/v1/auth/bearer?"+query.Encode(), nil) + require.NoError(t, err) + prepareCommonHeaders(request.Header, bearerToken) + resp := &apiserver.BinaryBearer{} + doRequest(t, httpClient, request, http.StatusOK, resp) + + actualTokenRaw, err := base64.StdEncoding.DecodeString(resp.Token) + require.NoError(t, err) + + content := "content of file" + attributes := map[string]string{ + object.AttributeFileName: "newFile.txt", + object.AttributeContentType: "application/octet-stream", + "User-Attribute": "user value", + "FREE-case-kEy": "other value", + } + attributesJSON, err := json.Marshal(attributes) + require.NoError(t, err) + + body := bytes.NewBufferString(content) + request, err = http.NewRequest(http.MethodPost, testHost+"/v1/objects/"+cnrID.String(), body) + require.NoError(t, err) + + request.Header.Set("Content-Type", "text/plain") + request.Header.Set("X-Attributes", string(attributesJSON)) + if cookie { + request.Header.Add("Cookie", "Bearer="+base64.StdEncoding.EncodeToString(actualTokenRaw)+";") + } else { + request.Header.Add("Authorization", "Bearer "+base64.StdEncoding.EncodeToString(actualTokenRaw)) + } + addr := &apiserver.AddressForUpload{} + doRequest(t, httpClient, request, http.StatusOK, addr) + + request.Header.Set("Content-Type", "text/plain") + + var CID cid.ID + err = CID.DecodeString(addr.ContainerId) + require.NoError(t, err) + + var id oid.ID + err = id.DecodeString(addr.ObjectId) + require.NoError(t, err) + + var prm client.PrmObjectGet + res, payloadReader, err := clientPool.ObjectGetInit(ctx, CID, id, signer, prm) + require.NoError(t, err) + + payload := bytes.NewBuffer(nil) + _, err = io.Copy(payload, payloadReader) + require.NoError(t, err) + require.Equal(t, content, payload.String()) + + for _, attribute := range res.Attributes() { + require.Equal(t, attributes[attribute.Key()], attribute.Value(), attribute.Key()) + } +} + +func restNewObjectHead(ctx context.Context, t *testing.T, p *pool.Pool, ownerID *user.ID, cnrID cid.ID, signer user.Signer) { + bearer := apiserver.Bearer{ + Object: []apiserver.Record{ + { + Operation: apiserver.OperationHEAD, + Action: apiserver.ALLOW, + Filters: []apiserver.Filter{}, + Targets: []apiserver.Target{{ + Role: apiserver.OTHERS, + Keys: []string{}, + }}, + }, + { + Operation: apiserver.OperationRANGE, + Action: apiserver.ALLOW, + Filters: []apiserver.Filter{}, + Targets: []apiserver.Target{{ + Role: apiserver.OTHERS, + Keys: []string{}, + }}, + }, + }, + } + bearer.Object = append(bearer.Object, getRestrictBearerRecords()...) + + httpClient := defaultHTTPClient() + bearerTokens := makeAuthTokenRequest(ctx, t, []apiserver.Bearer{bearer}, httpClient, false) + bearerToken := bearerTokens[0] + + query := make(url.Values) + query.Add(walletConnectQuery, strconv.FormatBool(useWalletConnect)) + + request, err := http.NewRequest(http.MethodGet, testHost+"/v1/auth/bearer?"+query.Encode(), nil) + require.NoError(t, err) + prepareCommonHeaders(request.Header, bearerToken) + resp := &apiserver.BinaryBearer{} + doRequest(t, httpClient, request, http.StatusOK, resp) + + var ( + content = []byte("some content") + fileNameAttr = "head-obj-name-echo" + attrKey = "user-attribute" + attrValue = "user value" + + attributes = map[string]string{ + object.AttributeFileName: fileNameAttr, + object.AttributeTimestamp: strconv.FormatInt(time.Now().Unix(), 10), + attrKey: attrValue, + } + ) + + t.Run("head", func(t *testing.T) { + objID := createObject(ctx, t, p, ownerID, cnrID, attributes, content, signer) + createTS := time.Now().Unix() + + request, err = http.NewRequest(http.MethodHead, testHost+"/v1/objects/"+cnrID.EncodeToString()+"/by_id/"+objID.EncodeToString()+"?"+query.Encode(), nil) + require.NoError(t, err) + prepareCommonHeaders(request.Header, bearerToken) + request.Header.Set("Authorization", "Bearer "+resp.Token) + + headers, _ := doRequest(t, httpClient, request, http.StatusOK, nil) + require.NotEmpty(t, headers) + + for key, vals := range headers { + require.Len(t, vals, 1) + + switch key { + case "X-Attributes": + var customAttr map[string]string + err := json.Unmarshal([]byte(vals[0]), &customAttr) + require.NoError(t, err) + require.Equal(t, fileNameAttr, customAttr[object.AttributeFileName]) + require.Equal(t, attrValue, customAttr[attrKey]) + require.Equal(t, strconv.FormatInt(createTS, 10), customAttr[object.AttributeTimestamp]) + case "Content-Disposition": + require.Equal(t, "inline; filename="+fileNameAttr, vals[0]) + case "X-Object-Id": + require.Equal(t, objID.String(), vals[0]) + case "Last-Modified": + require.Equal(t, time.Unix(createTS, 0).UTC().Format(http.TimeFormat), vals[0]) + case "X-Owner-Id": + require.Equal(t, signer.UserID().String(), vals[0]) + 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]) + case "Content-Type": + require.Equal(t, "text/plain; charset=utf-8", vals[0]) + case "Date": + require.Equal(t, time.Unix(createTS, 0).UTC().Format(http.TimeFormat), vals[0]) + case "Access-Control-Allow-Origin": + require.Equal(t, "*", vals[0]) + } + } + }) + + t.Run("custom content-type", func(t *testing.T) { + customContentType := "some/type" + attributes[object.AttributeContentType] = customContentType + + objID := createObject(ctx, t, p, ownerID, cnrID, attributes, content, signer) + createTS := time.Now().Unix() + + request, err = http.NewRequest(http.MethodHead, testHost+"/v1/objects/"+cnrID.EncodeToString()+"/by_id/"+objID.EncodeToString()+"?"+query.Encode(), nil) + require.NoError(t, err) + prepareCommonHeaders(request.Header, bearerToken) + request.Header.Set("Authorization", "Bearer "+resp.Token) + + headers, _ := doRequest(t, httpClient, request, http.StatusOK, nil) + require.NotEmpty(t, headers) + + for key, vals := range headers { + require.Len(t, vals, 1) + + switch key { + case "X-Attributes": + var customAttr map[string]string + err := json.Unmarshal([]byte(vals[0]), &customAttr) + require.NoError(t, err) + require.Equal(t, fileNameAttr, customAttr[object.AttributeFileName]) + require.Equal(t, attrValue, customAttr[attrKey]) + require.Equal(t, strconv.FormatInt(createTS, 10), customAttr[object.AttributeTimestamp]) + case "Content-Disposition": + require.Equal(t, "inline; filename="+fileNameAttr, vals[0]) + case "X-Object-Id": + require.Equal(t, objID.String(), vals[0]) + case "Last-Modified": + require.Equal(t, time.Unix(createTS, 0).UTC().Format(http.TimeFormat), vals[0]) + case "X-Owner-Id": + require.Equal(t, signer.UserID().String(), vals[0]) + 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]) + case "Content-Type": + require.Equal(t, customContentType, vals[0]) + case "Date": + require.Equal(t, time.Unix(createTS, 0).UTC().Format(http.TimeFormat), vals[0]) + case "Access-Control-Allow-Origin": + require.Equal(t, "*", vals[0]) + } + } + }) +} + +func restNewObjectHeadByAttribute(ctx context.Context, t *testing.T, p *pool.Pool, ownerID *user.ID, cnrID cid.ID, signer user.Signer) { + bearer := apiserver.Bearer{ + Object: []apiserver.Record{ + { + Operation: apiserver.OperationHEAD, + Action: apiserver.ALLOW, + Filters: []apiserver.Filter{}, + Targets: []apiserver.Target{{ + Role: apiserver.OTHERS, + Keys: []string{}, + }}, + }, + { + Operation: apiserver.OperationRANGE, + Action: apiserver.ALLOW, + Filters: []apiserver.Filter{}, + Targets: []apiserver.Target{{ + Role: apiserver.OTHERS, + Keys: []string{}, + }}, + }, + { + Operation: apiserver.OperationSEARCH, + Action: apiserver.ALLOW, + Filters: []apiserver.Filter{}, + Targets: []apiserver.Target{{ + Role: apiserver.OTHERS, + Keys: []string{}, + }}, + }, + }, + } + bearer.Object = append(bearer.Object, getRestrictBearerRecords()...) + + httpClient := defaultHTTPClient() + bearerTokens := makeAuthTokenRequest(ctx, t, []apiserver.Bearer{bearer}, httpClient, false) + bearerToken := bearerTokens[0] + + query := make(url.Values) + query.Add(walletConnectQuery, strconv.FormatBool(useWalletConnect)) + + request, err := http.NewRequest(http.MethodGet, testHost+"/v1/auth/bearer?"+query.Encode(), nil) + require.NoError(t, err) + prepareCommonHeaders(request.Header, bearerToken) + resp := &apiserver.BinaryBearer{} + doRequest(t, httpClient, request, http.StatusOK, resp) + + var ( + content = []byte("some content") + fileNameAttr = "new-head-obj-by-attr-name-echo" + attrKey = "soME-attribute" + attrValue = "user value" + attributes = map[string]string{ + object.AttributeFileName: fileNameAttr, + object.AttributeTimestamp: strconv.FormatInt(time.Now().Unix(), 10), + attrKey: attrValue, + } + ) + + t.Run("head", func(t *testing.T) { + objID := createObject(ctx, t, p, ownerID, cnrID, attributes, content, signer) + createTS := time.Now().Unix() + + request, err = http.NewRequest(http.MethodHead, testHost+"/v1/objects/"+cnrID.EncodeToString()+"/by_attribute/"+object.AttributeFileName+"/"+fileNameAttr+"?"+query.Encode(), nil) + require.NoError(t, err) + prepareCommonHeaders(request.Header, bearerToken) + request.Header.Set("Authorization", "Bearer "+resp.Token) + + headers, _ := doRequest(t, httpClient, request, http.StatusOK, nil) + require.NotEmpty(t, headers) + + for key, vals := range headers { + require.Len(t, vals, 1) + + switch key { + case "X-Attributes": + var customAttr map[string]string + err := json.Unmarshal([]byte(vals[0]), &customAttr) + require.NoError(t, err) + require.Equal(t, fileNameAttr, customAttr[object.AttributeFileName]) + require.Equal(t, attrValue, customAttr[attrKey]) + require.Equal(t, strconv.FormatInt(createTS, 10), customAttr[object.AttributeTimestamp]) + case "Content-Disposition": + require.Equal(t, "inline; filename="+fileNameAttr, vals[0]) + case "X-Object-Id": + require.Equal(t, objID.String(), vals[0]) + case "Last-Modified": + require.Equal(t, time.Unix(createTS, 0).UTC().Format(http.TimeFormat), vals[0]) + case "X-Owner-Id": + require.Equal(t, signer.UserID().String(), vals[0]) + 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]) + case "Content-Type": + require.Equal(t, "text/plain; charset=utf-8", vals[0]) + case "Date": + require.Equal(t, time.Unix(createTS, 0).UTC().Format(http.TimeFormat), vals[0]) + case "Access-Control-Allow-Origin": + require.Equal(t, "*", vals[0]) + } + } + }) + + t.Run("head multi-segment path attribute", func(t *testing.T) { + multiSegmentName := "path/" + fileNameAttr + attributes[object.AttributeFileName] = multiSegmentName + + objID := createObject(ctx, t, p, ownerID, cnrID, attributes, content, signer) + createTS := time.Now().Unix() + + request, err = http.NewRequest(http.MethodHead, testHost+"/v1/objects/"+cnrID.EncodeToString()+"/by_attribute/"+object.AttributeFileName+"/"+multiSegmentName+"?"+query.Encode(), nil) + require.NoError(t, err) + prepareCommonHeaders(request.Header, bearerToken) + request.Header.Set("Authorization", "Bearer "+resp.Token) + + headers, _ := doRequest(t, httpClient, request, http.StatusOK, nil) + require.NotEmpty(t, headers) + + for key, vals := range headers { + require.Len(t, vals, 1) + + switch key { + case "X-Attributes": + var customAttr map[string]string + err := json.Unmarshal([]byte(vals[0]), &customAttr) + require.NoError(t, err) + require.Equal(t, multiSegmentName, customAttr[object.AttributeFileName]) + require.Equal(t, attrValue, customAttr[attrKey]) + require.Equal(t, strconv.FormatInt(createTS, 10), customAttr[object.AttributeTimestamp]) + case "Content-Disposition": + require.Equal(t, "inline; filename="+fileNameAttr, vals[0]) + case "X-Object-Id": + require.Equal(t, objID.String(), vals[0]) + case "Last-Modified": + require.Equal(t, time.Unix(createTS, 0).UTC().Format(http.TimeFormat), vals[0]) + case "X-Owner-Id": + require.Equal(t, signer.UserID().String(), vals[0]) + 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]) + case "Content-Type": + require.Equal(t, "text/plain; charset=utf-8", vals[0]) + case "Date": + require.Equal(t, time.Unix(createTS, 0).UTC().Format(http.TimeFormat), vals[0]) + case "Access-Control-Allow-Origin": + require.Equal(t, "*", vals[0]) + } + } + }) +} + +func restNewObjectGetByAttribute(ctx context.Context, t *testing.T, p *pool.Pool, ownerID *user.ID, cnrID cid.ID, signer user.Signer) { + bearer := apiserver.Bearer{ + Object: []apiserver.Record{ + { + Operation: apiserver.OperationGET, + Action: apiserver.ALLOW, + Filters: []apiserver.Filter{}, + Targets: []apiserver.Target{{ + Role: apiserver.OTHERS, + Keys: []string{}, + }}, + }, + { + Operation: apiserver.OperationSEARCH, + Action: apiserver.ALLOW, + Filters: []apiserver.Filter{}, + Targets: []apiserver.Target{{ + Role: apiserver.OTHERS, + Keys: []string{}, + }}, + }, + }, + } + bearer.Object = append(bearer.Object, getRestrictBearerRecords()...) + + httpClient := defaultHTTPClient() + bearerTokens := makeAuthTokenRequest(ctx, t, []apiserver.Bearer{bearer}, httpClient, false) + bearerToken := bearerTokens[0] + + query := make(url.Values) + query.Add(walletConnectQuery, strconv.FormatBool(useWalletConnect)) + + request, err := http.NewRequest(http.MethodGet, testHost+"/v1/auth/bearer?"+query.Encode(), nil) + require.NoError(t, err) + prepareCommonHeaders(request.Header, bearerToken) + resp := &apiserver.BinaryBearer{} + doRequest(t, httpClient, request, http.StatusOK, resp) + + var ( + content = []byte("some content") + fileNameAttr = "new-get-obj-by-attr-name-echo" + createTS = time.Now().Unix() + attrKey = "user-attribute" + attrValue = "user value" + attributes = map[string]string{ + object.AttributeFileName: fileNameAttr, + object.AttributeTimestamp: strconv.FormatInt(createTS, 10), + attrKey: attrValue, + } + ) + + t.Run("get", func(t *testing.T) { + objID := createObject(ctx, t, p, ownerID, cnrID, attributes, content, signer) + + request, err = http.NewRequest(http.MethodGet, testHost+"/v1/objects/"+cnrID.EncodeToString()+"/by_attribute/"+object.AttributeFileName+"/"+fileNameAttr+"?"+query.Encode(), nil) + require.NoError(t, err) + prepareCommonHeaders(request.Header, bearerToken) + request.Header.Set("Authorization", "Bearer "+resp.Token) + + headers, rawPayload := doRequest(t, httpClient, request, http.StatusOK, nil) + require.NotEmpty(t, headers) + + for key, vals := range headers { + require.Len(t, vals, 1) + + switch key { + case "X-Attributes": + var customAttr map[string]string + err := json.Unmarshal([]byte(vals[0]), &customAttr) + require.NoError(t, err) + require.Equal(t, fileNameAttr, customAttr[object.AttributeFileName]) + require.Equal(t, attrValue, customAttr[attrKey]) + require.Equal(t, strconv.FormatInt(createTS, 10), customAttr[object.AttributeTimestamp]) + case "Content-Disposition": + require.Equal(t, "inline; filename="+fileNameAttr, vals[0]) + case "X-Object-Id": + require.Equal(t, objID.String(), vals[0]) + case "Last-Modified": + require.Equal(t, time.Unix(createTS, 0).UTC().Format(http.TimeFormat), vals[0]) + case "X-Owner-Id": + require.Equal(t, signer.UserID().String(), vals[0]) + 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]) + case "Content-Type": + require.Equal(t, "text/plain; charset=utf-8", vals[0]) + case "Date": + require.Equal(t, time.Unix(createTS, 0).UTC().Format(http.TimeFormat), vals[0]) + case "Access-Control-Allow-Origin": + require.Equal(t, "*", vals[0]) + } + } + + require.Equal(t, content, rawPayload) + }) +}