diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b01aca3..48ea5ff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,8 @@ - Added `onlyVerified` auth collection option to globally disallow authentication requests for unverified users. +- Added `filesystem.NewFileFromUrl(ctx, url)` helper method to construct a `*filesystem.BytesReader` file from the specified url. + ## v0.20.0-rc3 diff --git a/tools/filesystem/file.go b/tools/filesystem/file.go index 912ef882..936bdb50 100644 --- a/tools/filesystem/file.go +++ b/tools/filesystem/file.go @@ -2,11 +2,14 @@ package filesystem import ( "bytes" + "context" "errors" "fmt" "io" "mime/multipart" + "net/http" "os" + "path" "path/filepath" "regexp" "strings" @@ -25,10 +28,10 @@ type FileReader interface { // // The file could be from a local path, multipipart/formdata header, etc. type File struct { + Reader FileReader Name string OriginalName string Size int64 - Reader FileReader } // NewFileFromPath creates a new File instance from the provided local file path. @@ -65,7 +68,7 @@ func NewFileFromBytes(b []byte, name string) (*File, error) { return f, nil } -// NewFileFromMultipart creates a new File instace from the provided multipart header. +// NewFileFromMultipart creates a new File from the provided multipart header. func NewFileFromMultipart(mh *multipart.FileHeader) (*File, error) { f := &File{} @@ -77,6 +80,45 @@ func NewFileFromMultipart(mh *multipart.FileHeader) (*File, error) { return f, nil } +type UrlOptions struct { + Context context.Context + Url string +} + +// NewFileFromUrl creates a new File from the provided url by +// downloading the resource and load it as BytesReader. +// +// Example +// +// ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) +// defer cancel() +// +// file, err := filesystem.NewFileFromUrl(ctx, "https://example.com/image.png") +func NewFileFromUrl(ctx context.Context, url string) (*File, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode > 399 { + return nil, fmt.Errorf("failed to download url %s (%d)", url, res.StatusCode) + } + + var buf bytes.Buffer + + if _, err = io.Copy(&buf, res.Body); err != nil { + return nil, err + } + + return NewFileFromBytes(buf.Bytes(), path.Base(url)) +} + // ------------------------------------------------------------------- var _ FileReader = (*MultipartReader)(nil) diff --git a/tools/filesystem/file_test.go b/tools/filesystem/file_test.go index 15c7dfd2..f653aa96 100644 --- a/tools/filesystem/file_test.go +++ b/tools/filesystem/file_test.go @@ -1,6 +1,9 @@ package filesystem_test import ( + "context" + "fmt" + "net/http" "net/http/httptest" "os" "path/filepath" @@ -109,3 +112,70 @@ func TestNewFileFromMultipart(t *testing.T) { t.Fatalf("Expected Reader to be MultipartReader, got %v", f.Reader) } } + +func TestNewFileFromUrlTimeout(t *testing.T) { + // timeout + // invalid response + // valid response + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/error" { + w.WriteHeader(http.StatusInternalServerError) + } + + fmt.Fprintf(w, "test") + })) + defer srv.Close() + + // cancelled context + { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + f, err := filesystem.NewFileFromUrl(ctx, srv.URL+"/cancel") + if err == nil { + t.Fatal("[ctx_cancel] Expected error, got nil") + } + if f != nil { + t.Fatalf("[ctx_cancel] Expected file to be nil, got %v", f) + } + } + + // error response + { + f, err := filesystem.NewFileFromUrl(context.Background(), srv.URL+"/error") + if err == nil { + t.Fatal("[error_status] Expected error, got nil") + } + if f != nil { + t.Fatalf("[error_status] Expected file to be nil, got %v", f) + } + } + + // valid response + { + originalName := "image_! noext" + normalizedNamePattern := regexp.QuoteMeta("image_noext_") + `\w{10}` + regexp.QuoteMeta(".txt") + + f, err := filesystem.NewFileFromUrl(context.Background(), srv.URL+"/"+originalName) + if err != nil { + t.Fatalf("[valid] Unexpected error %v", err) + } + if f == nil { + t.Fatal("[valid] Expected non-nil file") + } + + // check the created file fields + if f.OriginalName != originalName { + t.Fatalf("Expected OriginalName %q, got %q", originalName, f.OriginalName) + } + if match, _ := regexp.Match(normalizedNamePattern, []byte(f.Name)); !match { + t.Fatalf("Expected Name to match %v, got %q (%v)", normalizedNamePattern, f.Name, err) + } + if f.Size != 4 { + t.Fatalf("Expected Size %v, got %v", 4, f.Size) + } + if _, ok := f.Reader.(*filesystem.BytesReader); !ok { + t.Fatalf("Expected Reader to be BytesReader, got %v", f.Reader) + } + } +}