Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/20250925093723.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:sparkles: [headers] support for [TUS](https://tus.io/protocols/resumable-upload) headers
1 change: 1 addition & 0 deletions changes/20250925122057.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:sparkles: `[collection]` Add a `MapWithError` function
17 changes: 17 additions & 0 deletions utils/collection/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ func Filter[S ~[]E, E any](s S, f FilterFunc[E]) (result S) {
}

type MapFunc[T1, T2 any] func(T1) T2
type MapWithErrorFunc[T1, T2 any] func(T1) (T2, error)

func IdentityMapFunc[T any]() MapFunc[T, T] {
return func(i T) T {
Expand All @@ -105,6 +106,22 @@ func Map[T1 any, T2 any](s []T1, f MapFunc[T1, T2]) (result []T2) {
return result
}

// MapWithError creates a new slice and populates it with the results of calling the provided function on every element in input slice. If an error happens, the mapping stops and the error returned.
func MapWithError[T1 any, T2 any](s []T1, f MapWithErrorFunc[T1, T2]) (result []T2, err error) {
result = make([]T2, len(s))

for i := range s {
var subErr error
result[i], subErr = f(s[i])
if subErr != nil {
err = subErr
return
}
}

return
}

// Reject is the opposite of Filter and returns the elements of collection for which the filtering function f returns false.
// This is functionally equivalent to slices.DeleteFunc but it returns a new slice.
func Reject[S ~[]E, E any](s S, f FilterFunc[E]) S {
Expand Down
16 changes: 13 additions & 3 deletions utils/collection/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import (

"github.com/go-faker/faker/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/ARM-software/golang-utils/utils/safecast"
)

func TestFind(t *testing.T) {
Expand Down Expand Up @@ -140,10 +143,17 @@ func TestMap(t *testing.T) {
return fmt.Sprintf("Hello world %v", i)
})
assert.ElementsMatch(t, []string{"Hello world 1", "Hello world 2"}, mapped)
mapped = Map([]int64{1, 2, 3, 4}, func(x int64) string {
return strconv.FormatInt(x, 10)
num := []int{1, 2, 3, 4}
numStr := []string{"1", "2", "3", "4"}
mapped = Map(num, func(x int) string {
return strconv.FormatInt(safecast.ToInt64(x), 10)
})
assert.ElementsMatch(t, []string{"1", "2", "3", "4"}, mapped)
assert.ElementsMatch(t, numStr, mapped)
m, err := MapWithError[string, int](numStr, strconv.Atoi)
require.NoError(t, err)
assert.ElementsMatch(t, num, m)
_, err = MapWithError[string, int](append(numStr, faker.Word(), "5"), strconv.Atoi)
require.Error(t, err)
}

func TestReduce(t *testing.T) {
Expand Down
41 changes: 38 additions & 3 deletions utils/hashing/hash.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"crypto/sha1" //nolint:gosec
"crypto/sha256"
"encoding/hex"
"fmt"
"hash"
"io"
"math"
Expand Down Expand Up @@ -76,6 +75,42 @@ func newHashingAlgorithm(htype string, algorithm hash.Hash) (IHash, error) {
}, nil
}

// DetermineHashingAlgorithmCanonicalReference determines the hashing algorithm reference from a string.
func DetermineHashingAlgorithmCanonicalReference(name string) (ref string, err error) {
n := strings.TrimSpace(strings.ReplaceAll(name, "-", ""))
if reflection.IsEmpty(n) {
err = commonerrors.UndefinedVariable("algorithm name")
return
}
switch {
case strings.EqualFold(HashMd5, n):
ref = HashMd5
case strings.EqualFold(HashSha1, n):

ref = HashSha1
case strings.EqualFold(HashSha256, n):
ref = HashSha256
case strings.EqualFold(HashMurmur, n):
ref = HashMurmur
case strings.EqualFold(HashXXHash, n):
ref = HashXXHash
case strings.EqualFold(HashBlake2256, n):
ref = HashBlake2256
default:
err = commonerrors.New(commonerrors.ErrNotFound, "could not find the corresponding hashing algorithm")
}
return
}

// DetermineHashingAlgorithm returns a hashing algorithm based on a string reference. Similar to NewHashingAlgorithm but more flexible.
func DetermineHashingAlgorithm(algorithm string) (IHash, error) {
htype, err := DetermineHashingAlgorithmCanonicalReference(algorithm)
if err != nil {
return nil, err
}
return NewHashingAlgorithm(htype)
}

func NewHashingAlgorithm(htype string) (IHash, error) {
var hash hash.Hash
var err error
Expand All @@ -94,11 +129,11 @@ func NewHashingAlgorithm(htype string) (IHash, error) {
hash, err = blake2b.New256(nil)
}
if err != nil {
return nil, fmt.Errorf("%w: failed loading the hashing algorithm: %v", commonerrors.ErrUnexpected, err.Error())
return nil, commonerrors.WrapError(commonerrors.ErrUnexpected, err, "failed loading the hashing algorithm")
}

if hash == nil {
return nil, fmt.Errorf("%w: could not find the corresponding hashing algorithm", commonerrors.ErrNotFound)
return nil, commonerrors.New(commonerrors.ErrNotFound, "could not find the corresponding hashing algorithm")
}
return newHashingAlgorithm(htype, hash)
}
Expand Down
6 changes: 4 additions & 2 deletions utils/http/header_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"slices"
"strings"

headers2 "github.com/go-http-utils/headers"

"github.com/ARM-software/golang-utils/utils/commonerrors"
"github.com/ARM-software/golang-utils/utils/http/headers"
)
Expand Down Expand Up @@ -76,13 +78,13 @@ func (c *ClientWithHeaders) Post(url, contentType string, rawBody interface{}) (
if err != nil {
return nil, err
}
req.Header.Set(headers.HeaderContentType, contentType) // make sure to overrwrite any in the headers
req.Header.Set(headers2.ContentType, contentType) // make sure to overwrite any in the headers
return c.client.Do(req)
}

func (c *ClientWithHeaders) PostForm(url string, data url.Values) (*http.Response, error) {
rawBody := strings.NewReader(data.Encode())
return c.Post(url, headers.HeaderXWWWFormURLEncoded, rawBody)
return c.Post(url, headers.MIMEXWWWFormURLEncoded, rawBody)
}

func (c *ClientWithHeaders) StandardClient() *http.Client {
Expand Down
26 changes: 20 additions & 6 deletions utils/http/headers/headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,16 @@ const (
HeaderTusExtension = "Tus-Extension"
HeaderTusMaxSize = "Tus-Max-Size"
HeaderXHTTPMethodOverride = "X-HTTP-Method-Override"
HeaderXWWWFormURLEncoded = "application/x-www-form-urlencoded"
HeaderContentType = "Content-Type"
// TUS extensions Headers
HeaderUploadMetadata = "Upload-Metadata" // See https://tus.io/protocols/resumable-upload#upload-metadata
HeaderUploadDeferLength = "Upload-Defer-Length" // See https://tus.io/protocols/resumable-upload#upload-defer-length
HeaderUploadExpires = "Upload-Expires" // See https://tus.io/protocols/resumable-upload#upload-expires
HeaderChecksumAlgorithm = "Tus-Checksum-Algorithm" // See https://tus.io/protocols/resumable-upload#tus-checksum-algorithm
HeaderChecksum = "Upload-Checksum" // See https://tus.io/protocols/resumable-upload#upload-checksum
HeaderUploadConcat = "Upload-Concat" // See https://tus.io/protocols/resumable-upload#upload-concat

MIMEXWWWFormURLEncoded = "application/x-www-form-urlencoded"
MIMETusUpload = "application/offset+octet-stream"
)

var (
Expand All @@ -62,6 +70,12 @@ var (
HeaderTusExtension,
HeaderTusMaxSize,
HeaderXHTTPMethodOverride,
HeaderUploadMetadata,
HeaderUploadDeferLength,
HeaderUploadExpires,
HeaderChecksumAlgorithm,
HeaderChecksum,
HeaderUploadConcat,
headers.Accept,
headers.AcceptCharset,
headers.AcceptEncoding,
Expand Down Expand Up @@ -198,12 +212,12 @@ func ParseAuthorizationHeader(r *http.Request) (string, string, error) {
// and makes sure it has 2 parts <scheme> <token>
func ParseAuthorisationValue(authHeader string) (scheme string, token string, err error) {
if reflection.IsEmpty(authHeader) {
err = commonerrors.New(commonerrors.ErrUndefined, "authorization header is not set")
err = commonerrors.Newf(commonerrors.ErrUndefined, "`%v` header is not set", headers.Authorization)
return
}
parts := strings.Fields(authHeader)
if len(parts) != 2 {
err = commonerrors.New(commonerrors.ErrInvalid, "`Authorization` header contains incorrect number of parts")
err = commonerrors.Newf(commonerrors.ErrInvalid, "`%v` header contains incorrect number of parts", headers.Authorization)
return
}
scheme = parts[0]
Expand Down Expand Up @@ -342,11 +356,11 @@ func GenerateAuthorizationHeaderValue(scheme string, token string) (value string
// AddToUserAgent adds some information to the `User Agent`.
func AddToUserAgent(r *http.Request, elements ...string) (err error) {
if r == nil {
err = fmt.Errorf("%w: missing request", commonerrors.ErrUndefined)
err = commonerrors.UndefinedVariable("request")
return
}
if reflection.IsEmpty(elements) {
err = fmt.Errorf("%w: empty elements to add", commonerrors.ErrUndefined)
err = commonerrors.New(commonerrors.ErrUndefined, "empty elements to add")
return
}
r.Header.Set(headers.UserAgent, useragent.AddValuesToUserAgent(FetchUserAgent(r), elements...))
Expand Down
123 changes: 123 additions & 0 deletions utils/http/headers/tus/tus.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package tus

import (
"context"
"net/url"
"regexp"
"strings"

"github.com/ARM-software/golang-utils/utils/collection"
"github.com/ARM-software/golang-utils/utils/commonerrors"
"github.com/ARM-software/golang-utils/utils/encoding/base64"
"github.com/ARM-software/golang-utils/utils/field"
"github.com/ARM-software/golang-utils/utils/hashing"
"github.com/ARM-software/golang-utils/utils/reflection"
)

const KeyTUSMetadata = "filename"

// ParseTUSHash parses the checksum header value and tries to determine the different elements it contains.
// See https://tus.io/protocols/resumable-upload#upload-checksum
func ParseTUSHash(checksum string) (hashAlgo, hash string, err error) {
if reflection.IsEmpty(checksum) {
err = commonerrors.UndefinedVariable("checksum")
return
}
match := regexp.MustCompile(`^\s*([a-zA-Z0-9-_]+)\s+([-A-Za-z0-9+/]*={0,3})$`).FindStringSubmatch(checksum)
if match == nil || len(match) != 3 {
err = commonerrors.Newf(commonerrors.ErrInvalid, "invalid checksum format")
return
}

hashAlgo, err = hashing.DetermineHashingAlgorithmCanonicalReference(match[1])
if err != nil {
err = commonerrors.WrapError(commonerrors.ErrUnsupported, err, "hashing algorithm is not supported")
return
}

h := strings.TrimSpace(match[2])
if !base64.IsEncoded(h) {
err = commonerrors.Newf(commonerrors.ErrInvalid, "checksum is not base64 encoded")
return
}
hash, err = base64.DecodeString(context.Background(), h)
if err != nil {
err = commonerrors.WrapError(commonerrors.ErrMarshalling, err, "failed decoding checksum")
}
return
}

// ParseTUSConcatHeader parses the `Concat` header value https://tus.io/protocols/resumable-upload#upload-concat
func ParseTUSConcatHeader(concat string) (isPartial bool, partials []*url.URL, err error) {
header := strings.TrimSpace(concat)
if reflection.IsEmpty(header) {
err = commonerrors.UndefinedVariable("concat header")
return
}
if strings.EqualFold(header, "partial") {
isPartial = true
return
}
h := strings.TrimPrefix(header, "final;")
if header == h {
err = commonerrors.New(commonerrors.ErrInvalid, "invalid header value")
return
}
partials, err = collection.MapWithError[string, *url.URL](collection.ParseListWithCleanup(h, " "), url.Parse)
if err != nil {
err = commonerrors.WrapError(commonerrors.ErrInvalid, commonerrors.New(commonerrors.ErrMarshalling, "invalid partial url value"), "invalid header value")
}
if len(partials) == 0 {
err = commonerrors.New(commonerrors.ErrInvalid, "no partial url found")
}
return
}

// ParseTUSMetadataHeader parses the `metadata` header value https://tus.io/protocols/resumable-upload#upload-metadata
func ParseTUSMetadataHeader(header string) (filename *string, elements map[string]any, err error) {
h := strings.TrimSpace(header)
if reflection.IsEmpty(h) {
err = commonerrors.UndefinedVariable("metadata header")
return
}
e := collection.ParseCommaSeparatedList(h)
if len(e) == 0 {
err = commonerrors.UndefinedVariable("metadata header")
return
}
elements = make(map[string]any, len(e)/2)
for i := range e {
subElem := collection.ParseListWithCleanup(e[i], " ")
switch len(subElem) {
case 1:
elements[subElem[0]] = true
case 2:
key := subElem[0]
value := subElem[1]
_, has := elements[key]
if has {
err = commonerrors.WrapError(commonerrors.ErrInvalid, commonerrors.Newf(commonerrors.ErrInvalid, "duplicated key [%v]", key), "invalid metadata element")
return
}
if !base64.IsEncoded(value) {
err = commonerrors.WrapError(commonerrors.ErrInvalid, commonerrors.New(commonerrors.ErrMarshalling, "value is not base64 encoded"), "invalid metadata element")
return
}
v, subErr := base64.DecodeString(context.Background(), value)
if subErr != nil {
err = commonerrors.WrapError(commonerrors.ErrInvalid, commonerrors.New(commonerrors.ErrMarshalling, "value is not base64 encoded"), "invalid metadata element")
return
}
elements[key] = v
if strings.EqualFold(key, KeyTUSMetadata) {
filename = field.ToOptionalString(v)
}

default:
err = commonerrors.New(commonerrors.ErrInvalid, "invalid metadata header element")
return
}
}

return
}
Loading
Loading