Skip to content

Commit 8ed67bc

Browse files
committed
✨ [headers] support for TUS headers
1 parent 6e7cc5f commit 8ed67bc

File tree

8 files changed

+582
-12
lines changed

8 files changed

+582
-12
lines changed

changes/20250925093723.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:sparkles: [headers] support for [TUS](https://tus.io/protocols/resumable-upload) headers

changes/20250925122057.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:sparkles: `[collection]` Add a `MapWithError` function

utils/collection/search.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ func Filter[S ~[]E, E any](s S, f FilterFunc[E]) (result S) {
8787
}
8888

8989
type MapFunc[T1, T2 any] func(T1) T2
90+
type MapWithErrorFunc[T1, T2 any] func(T1) (T2, error)
9091

9192
func IdentityMapFunc[T any]() MapFunc[T, T] {
9293
return func(i T) T {
@@ -105,6 +106,22 @@ func Map[T1 any, T2 any](s []T1, f MapFunc[T1, T2]) (result []T2) {
105106
return result
106107
}
107108

109+
// 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.
110+
func MapWithError[T1 any, T2 any](s []T1, f MapWithErrorFunc[T1, T2]) (result []T2, err error) {
111+
result = make([]T2, len(s))
112+
113+
for i := range s {
114+
var subErr error
115+
result[i], subErr = f(s[i])
116+
if subErr != nil {
117+
err = subErr
118+
return
119+
}
120+
}
121+
122+
return
123+
}
124+
108125
// Reject is the opposite of Filter and returns the elements of collection for which the filtering function f returns false.
109126
// This is functionally equivalent to slices.DeleteFunc but it returns a new slice.
110127
func Reject[S ~[]E, E any](s S, f FilterFunc[E]) S {

utils/collection/search_test.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import (
1111

1212
"github.com/go-faker/faker/v4"
1313
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
16+
"github.com/ARM-software/golang-utils/utils/safecast"
1417
)
1518

1619
func TestFind(t *testing.T) {
@@ -140,10 +143,17 @@ func TestMap(t *testing.T) {
140143
return fmt.Sprintf("Hello world %v", i)
141144
})
142145
assert.ElementsMatch(t, []string{"Hello world 1", "Hello world 2"}, mapped)
143-
mapped = Map([]int64{1, 2, 3, 4}, func(x int64) string {
144-
return strconv.FormatInt(x, 10)
146+
num := []int{1, 2, 3, 4}
147+
numStr := []string{"1", "2", "3", "4"}
148+
mapped = Map(num, func(x int) string {
149+
return strconv.FormatInt(safecast.ToInt64(x), 10)
145150
})
146-
assert.ElementsMatch(t, []string{"1", "2", "3", "4"}, mapped)
151+
assert.ElementsMatch(t, numStr, mapped)
152+
m, err := MapWithError[string, int](numStr, strconv.Atoi)
153+
require.NoError(t, err)
154+
assert.ElementsMatch(t, num, m)
155+
m, err = MapWithError[string, int](append(numStr, faker.Word(), "5"), strconv.Atoi)
156+
require.Error(t, err)
147157
}
148158

149159
func TestReduce(t *testing.T) {

utils/hashing/hash.go

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"crypto/sha1" //nolint:gosec
1212
"crypto/sha256"
1313
"encoding/hex"
14-
"fmt"
1514
"hash"
1615
"io"
1716
"math"
@@ -76,6 +75,42 @@ func newHashingAlgorithm(htype string, algorithm hash.Hash) (IHash, error) {
7675
}, nil
7776
}
7877

78+
// DetermineHashingAlgorithmCanonicalReference determines the hashing algorithm reference from a string.
79+
func DetermineHashingAlgorithmCanonicalReference(name string) (ref string, err error) {
80+
n := strings.TrimSpace(strings.ReplaceAll(name, "-", ""))
81+
if reflection.IsEmpty(n) {
82+
err = commonerrors.UndefinedVariable("algorithm name")
83+
return
84+
}
85+
switch {
86+
case strings.EqualFold(HashMd5, n):
87+
ref = HashMd5
88+
case strings.EqualFold(HashSha1, n):
89+
90+
ref = HashSha1
91+
case strings.EqualFold(HashSha256, n):
92+
ref = HashSha256
93+
case strings.EqualFold(HashMurmur, n):
94+
ref = HashMurmur
95+
case strings.EqualFold(HashXXHash, n):
96+
ref = HashXXHash
97+
case strings.EqualFold(HashBlake2256, n):
98+
ref = HashBlake2256
99+
default:
100+
err = commonerrors.New(commonerrors.ErrNotFound, "could not find the corresponding hashing algorithm")
101+
}
102+
return
103+
}
104+
105+
// DetermineHashingAlgorithm returns a hashing algorithm based on a string reference. Similar to NewHashingAlgorithm but more flexible.
106+
func DetermineHashingAlgorithm(algorithm string) (IHash, error) {
107+
htype, err := DetermineHashingAlgorithmCanonicalReference(algorithm)
108+
if err != nil {
109+
return nil, err
110+
}
111+
return NewHashingAlgorithm(htype)
112+
}
113+
79114
func NewHashingAlgorithm(htype string) (IHash, error) {
80115
var hash hash.Hash
81116
var err error
@@ -94,11 +129,11 @@ func NewHashingAlgorithm(htype string) (IHash, error) {
94129
hash, err = blake2b.New256(nil)
95130
}
96131
if err != nil {
97-
return nil, fmt.Errorf("%w: failed loading the hashing algorithm: %v", commonerrors.ErrUnexpected, err.Error())
132+
return nil, commonerrors.WrapError(commonerrors.ErrUnexpected, err, "failed loading the hashing algorithm")
98133
}
99134

100135
if hash == nil {
101-
return nil, fmt.Errorf("%w: could not find the corresponding hashing algorithm", commonerrors.ErrNotFound)
136+
return nil, commonerrors.New(commonerrors.ErrNotFound, "could not find the corresponding hashing algorithm")
102137
}
103138
return newHashingAlgorithm(htype, hash)
104139
}

utils/http/headers/headers.go

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,16 @@ const (
3838
HeaderTusExtension = "Tus-Extension"
3939
HeaderTusMaxSize = "Tus-Max-Size"
4040
HeaderXHTTPMethodOverride = "X-HTTP-Method-Override"
41-
HeaderXWWWFormURLEncoded = "application/x-www-form-urlencoded"
42-
HeaderContentType = "Content-Type"
41+
// TUS extensions Headers
42+
HeaderUploadMetadata = "Upload-Metadata" // See https://tus.io/protocols/resumable-upload#upload-metadata
43+
HeaderUploadDeferLength = "Upload-Defer-Length" // See https://tus.io/protocols/resumable-upload#upload-defer-length
44+
HeaderUploadExpires = "Upload-Expires" // See https://tus.io/protocols/resumable-upload#upload-expires
45+
HeaderChecksumAlgorithm = "Tus-Checksum-Algorithm" // See https://tus.io/protocols/resumable-upload#tus-checksum-algorithm
46+
HeaderChecksum = "Upload-Checksum" // See https://tus.io/protocols/resumable-upload#upload-checksum
47+
HeaderUploadConcat = "Upload-Concat" // See https://tus.io/protocols/resumable-upload#upload-concat
48+
49+
MIMEXWWWFormURLEncoded = "application/x-www-form-urlencoded"
50+
MIMETusUpload = "application/offset+octet-stream"
4351
)
4452

4553
var (
@@ -62,6 +70,12 @@ var (
6270
HeaderTusExtension,
6371
HeaderTusMaxSize,
6472
HeaderXHTTPMethodOverride,
73+
HeaderUploadMetadata,
74+
HeaderUploadDeferLength,
75+
HeaderUploadExpires,
76+
HeaderChecksumAlgorithm,
77+
HeaderChecksum,
78+
HeaderUploadConcat,
6579
headers.Accept,
6680
headers.AcceptCharset,
6781
headers.AcceptEncoding,
@@ -198,12 +212,12 @@ func ParseAuthorizationHeader(r *http.Request) (string, string, error) {
198212
// and makes sure it has 2 parts <scheme> <token>
199213
func ParseAuthorisationValue(authHeader string) (scheme string, token string, err error) {
200214
if reflection.IsEmpty(authHeader) {
201-
err = commonerrors.New(commonerrors.ErrUndefined, "authorization header is not set")
215+
err = commonerrors.Newf(commonerrors.ErrUndefined, "`%v` header is not set", headers.Authorization)
202216
return
203217
}
204218
parts := strings.Fields(authHeader)
205219
if len(parts) != 2 {
206-
err = commonerrors.New(commonerrors.ErrInvalid, "`Authorization` header contains incorrect number of parts")
220+
err = commonerrors.Newf(commonerrors.ErrInvalid, "`%v` header contains incorrect number of parts", headers.Authorization)
207221
return
208222
}
209223
scheme = parts[0]
@@ -342,11 +356,11 @@ func GenerateAuthorizationHeaderValue(scheme string, token string) (value string
342356
// AddToUserAgent adds some information to the `User Agent`.
343357
func AddToUserAgent(r *http.Request, elements ...string) (err error) {
344358
if r == nil {
345-
err = fmt.Errorf("%w: missing request", commonerrors.ErrUndefined)
359+
err = commonerrors.UndefinedVariable("request")
346360
return
347361
}
348362
if reflection.IsEmpty(elements) {
349-
err = fmt.Errorf("%w: empty elements to add", commonerrors.ErrUndefined)
363+
err = commonerrors.New(commonerrors.ErrUndefined, "empty elements to add")
350364
return
351365
}
352366
r.Header.Set(headers.UserAgent, useragent.AddValuesToUserAgent(FetchUserAgent(r), elements...))

utils/http/headers/tus/tus.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package tus
2+
3+
import (
4+
"context"
5+
"net/url"
6+
"regexp"
7+
"strings"
8+
9+
"github.com/ARM-software/golang-utils/utils/collection"
10+
"github.com/ARM-software/golang-utils/utils/commonerrors"
11+
"github.com/ARM-software/golang-utils/utils/encoding/base64"
12+
"github.com/ARM-software/golang-utils/utils/field"
13+
"github.com/ARM-software/golang-utils/utils/hashing"
14+
"github.com/ARM-software/golang-utils/utils/reflection"
15+
)
16+
17+
const KeyTUSMetadata = "filename"
18+
19+
// ParseTUSHash parses the checksum header value and tries to determine the different elements it contains.
20+
// See https://tus.io/protocols/resumable-upload#upload-checksum
21+
func ParseTUSHash(checksum string) (hashAlgo, hash string, err error) {
22+
if reflection.IsEmpty(checksum) {
23+
err = commonerrors.UndefinedVariable("checksum")
24+
return
25+
}
26+
match := regexp.MustCompile(`^\s*([a-zA-Z0-9-_]+)\s+([-A-Za-z0-9+/]*={0,3})$`).FindStringSubmatch(checksum)
27+
if match == nil || len(match) != 3 {
28+
err = commonerrors.Newf(commonerrors.ErrInvalid, "invalid checksum format")
29+
return
30+
}
31+
32+
hashAlgo, err = hashing.DetermineHashingAlgorithmCanonicalReference(match[1])
33+
if err != nil {
34+
err = commonerrors.WrapError(commonerrors.ErrUnsupported, err, "hashing algorithm is not supported")
35+
return
36+
}
37+
38+
h := strings.TrimSpace(match[2])
39+
if !base64.IsEncoded(h) {
40+
err = commonerrors.Newf(commonerrors.ErrInvalid, "checksum is not base64 encoded")
41+
return
42+
}
43+
hash, err = base64.DecodeString(context.Background(), h)
44+
if err != nil {
45+
err = commonerrors.WrapError(commonerrors.ErrMarshalling, err, "failed decoding checksum")
46+
}
47+
return
48+
}
49+
50+
// ParseTUSConcatHeader parses the `Concat` header value https://tus.io/protocols/resumable-upload#upload-concat
51+
func ParseTUSConcatHeader(concat string) (isPartial bool, partials []*url.URL, err error) {
52+
header := strings.TrimSpace(concat)
53+
if reflection.IsEmpty(header) {
54+
err = commonerrors.UndefinedVariable("concat header")
55+
return
56+
}
57+
if strings.EqualFold(header, "partial") {
58+
isPartial = true
59+
return
60+
}
61+
h := strings.TrimPrefix(header, "final;")
62+
if header == h {
63+
err = commonerrors.New(commonerrors.ErrInvalid, "invalid header value")
64+
return
65+
}
66+
partials, err = collection.MapWithError[string, *url.URL](collection.ParseListWithCleanup(h, " "), url.Parse)
67+
if err != nil {
68+
err = commonerrors.WrapError(commonerrors.ErrInvalid, commonerrors.New(commonerrors.ErrMarshalling, "invalid partial url value"), "invalid header value")
69+
}
70+
if len(partials) == 0 {
71+
err = commonerrors.New(commonerrors.ErrInvalid, "no partial url found")
72+
}
73+
return
74+
}
75+
76+
// ParseTUSMetadataHeader parses the `metadata` header value https://tus.io/protocols/resumable-upload#upload-metadata
77+
func ParseTUSMetadataHeader(header string) (filename *string, elements map[string]any, err error) {
78+
h := strings.TrimSpace(header)
79+
if reflection.IsEmpty(h) {
80+
err = commonerrors.UndefinedVariable("metadata header")
81+
return
82+
}
83+
e := collection.ParseCommaSeparatedList(h)
84+
if len(e) == 0 {
85+
err = commonerrors.UndefinedVariable("metadata header")
86+
return
87+
}
88+
elements = make(map[string]any, len(e)/2)
89+
for i := range e {
90+
subElem := collection.ParseListWithCleanup(e[i], " ")
91+
switch len(subElem) {
92+
case 1:
93+
elements[subElem[0]] = true
94+
case 2:
95+
key := subElem[0]
96+
value := subElem[1]
97+
_, has := elements[key]
98+
if has {
99+
err = commonerrors.WrapError(commonerrors.ErrInvalid, commonerrors.Newf(commonerrors.ErrInvalid, "duplicated key [%v]", key), "invalid metadata element")
100+
return
101+
}
102+
if !base64.IsEncoded(value) {
103+
err = commonerrors.WrapError(commonerrors.ErrInvalid, commonerrors.New(commonerrors.ErrMarshalling, "value is not base64 encoded"), "invalid metadata element")
104+
return
105+
}
106+
v, subErr := base64.DecodeString(context.Background(), value)
107+
if subErr != nil {
108+
err = commonerrors.WrapError(commonerrors.ErrInvalid, commonerrors.New(commonerrors.ErrMarshalling, "value is not base64 encoded"), "invalid metadata element")
109+
return
110+
}
111+
elements[key] = v
112+
if strings.EqualFold(key, KeyTUSMetadata) {
113+
filename = field.ToOptionalString(v)
114+
}
115+
116+
default:
117+
err = commonerrors.New(commonerrors.ErrInvalid, "invalid metadata header element")
118+
return
119+
}
120+
}
121+
122+
return
123+
}

0 commit comments

Comments
 (0)