Repository: cloudflare/cloudflare-go
Pull request #1335 (2023-07-15 09:52:34)

Add image upload by url
## Description

The Cloudflare image API provides the ability to upload an image via a url, rather than as bytes. e.g. you might want to copy the image from some bucket to Cloudflare without having to manage the data yourself. This PR adds that functionality, heavily based on the existing file upload methodology.

## Has your change been tested?

Added test in the same fashion as the file based upload method.

## Types of changes

What sort of change does your code introduce/modify?

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)

## Checklist:

- [x] My code follows the code style of this project.
- [ ] My change requires a change to the documentation.
- [ ] I have updated the documentation accordingly.
- [x] I have added tests to cover my changes.
- [x] All new and existing tests passed.
- [x] This change is using publicly documented in [cloudflare/api-schemas](https://github.com/cloudflare/api-schemas) 
      and relies on stable APIs.

[1]: https://help.github.com/articles/closing-issues-using-keywords/



Patch:
diff --git a/.changelog/1335.txt b/.changelog/1335.txt
new file mode 100644
index 000000000..13b520854
--- /dev/null
+++ b/.changelog/1335.txt
@@ -0,0 +1,3 @@
+```release-note:enhancement
+images: adds ability to upload image by url
+```
diff --git a/images.go b/images.go
index b93b2f46d..59209ad1f 100644
--- a/images.go
+++ b/images.go
@@ -37,6 +37,7 @@ type Image struct {
 // UploadImageParams is the data required for an Image Upload request.
 type UploadImageParams struct {
 	File              io.ReadCloser
+	URL               string
 	Name              string
 	RequireSignedURLs bool
 	Metadata          map[string]interface{}
@@ -45,33 +46,43 @@ type UploadImageParams struct {
 // write writes the image upload data to a multipart writer, so
 // it can be used in an HTTP request.
 func (b UploadImageParams) write(mpw *multipart.Writer) error {
-	if b.File == nil {
-		return errors.New("a file to upload must be specified")
+	if b.File == nil && b.URL == "" {
+		return errors.New("a file or url to upload must be specified")
 	}
-	name := b.Name
-	part, err := mpw.CreateFormFile("file", name)
-	if err != nil {
-		return err
-	}
-	_, err = io.Copy(part, b.File)
-	if err != nil {
+
+	if b.File != nil {
+		name := b.Name
+		part, err := mpw.CreateFormFile("file", name)
+		if err != nil {
+			return err
+		}
+		_, err = io.Copy(part, b.File)
+		if err != nil {
+			_ = b.File.Close()
+			return err
+		}
 		_ = b.File.Close()
-		return err
 	}
-	_ = b.File.Close()
+
+	if b.URL != "" {
+		err := mpw.WriteField("url", b.URL)
+		if err != nil {
+			return err
+		}
+	}
 
 	// According to the Cloudflare docs, this field defaults to false.
 	// For simplicity, we will only send it if the value is true, however
 	// if the default is changed to true, this logic will need to be updated.
 	if b.RequireSignedURLs {
-		err = mpw.WriteField("requireSignedURLs", "true")
+		err := mpw.WriteField("requireSignedURLs", "true")
 		if err != nil {
 			return err
 		}
 	}
 
 	if b.Metadata != nil {
-		part, err = mpw.CreateFormField("metadata")
+		part, err := mpw.CreateFormField("metadata")
 		if err != nil {
 			return err
 		}
@@ -151,6 +162,10 @@ func (api *API) UploadImage(ctx context.Context, rc *ResourceContainer, params U
 		return Image{}, ErrRequiredAccountLevelResourceContainer
 	}
 
+	if params.File != nil && params.URL != "" {
+		return Image{}, errors.New("file and url uploads are mutually exclusive and can only be performed individually")
+	}
+
 	uri := fmt.Sprintf("/accounts/%s/images/v1", rc.Identifier)
 
 	body := &bytes.Buffer{}


Test patch:
diff --git a/images_test.go b/images_test.go
index 3640d9f31..43cd8ad34 100644
--- a/images_test.go
+++ b/images_test.go
@@ -96,6 +96,61 @@ func TestUploadImage(t *testing.T) {
 	}
 }
 
+func TestUploadImageByUrl(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method)
+
+		u, err := parseImageMultipartUpload(r)
+		if !assert.NoError(t, err) {
+			w.WriteHeader(http.StatusBadRequest)
+			return
+		}
+		assert.Equal(t, u.RequireSignedURLs, true)
+		assert.Equal(t, u.Metadata, map[string]interface{}{"meta": "metaID"})
+		assert.Equal(t, u.Url, "https://www.images-elsewhere.com/avatar.png")
+
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			"success": true,
+			"errors": [],
+			"messages": [],
+			"result": {
+				"id": "ZxR0pLaXRldlBtaFhhO2FiZGVnaA",
+				"filename": "avatar.png",
+				"metadata": {
+					"meta": "metaID"
+				},
+				"requireSignedURLs": true,
+				"variants": [
+					"https://imagedelivery.net/MTt4OTd0b0w5aj/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/hero",
+					"https://imagedelivery.net/MTt4OTd0b0w5aj/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/original",
+					"https://imagedelivery.net/MTt4OTd0b0w5aj/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/thumbnail"
+				],
+				"uploaded": "2014-01-02T02:20:00Z"
+			}
+		}
+		`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/images/v1", handler)
+	want := expectedImageStruct
+
+	actual, err := client.UploadImage(context.Background(), AccountIdentifier(testAccountID), UploadImageParams{
+		URL:               "https://www.images-elsewhere.com/avatar.png",
+		RequireSignedURLs: true,
+		Metadata: map[string]interface{}{
+			"meta": "metaID",
+		},
+	})
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, actual)
+	}
+}
+
 func TestUpdateImage(t *testing.T) {
 	setup()
 	defer teardown()
@@ -197,6 +252,20 @@ func TestCreateImageDirectUploadURL(t *testing.T) {
 	}
 }
 
+func TestCreateImageConflictingTypes(t *testing.T) {
+	setup()
+	defer teardown()
+
+	_, err := client.UploadImage(context.Background(), AccountIdentifier(testAccountID), UploadImageParams{
+		URL: "https://example.com/foo.jpg",
+		File: fakeFile{
+			Buffer: bytes.NewBufferString("this is definitely an image"),
+		},
+	})
+
+	assert.Error(t, err)
+}
+
 func TestCreateImageDirectUploadURLV2(t *testing.T) {
 	setup()
 	defer teardown()
@@ -387,6 +456,7 @@ type imageMultipartUpload struct {
 	// this is for testing, never read an entire file into memory,
 	// especially when being done on a per-http request basis.
 	File              []byte
+	Url               string
 	RequireSignedURLs bool
 	Metadata          map[string]interface{}
 }
@@ -418,15 +488,27 @@ func parseImageMultipartUpload(r *http.Request) (imageMultipartUpload, error) {
 		}
 	}
 
-	f, _, err := r.FormFile("file")
-	if err != nil {
-		return u, err
-	}
-	defer f.Close()
+	if _, ok := r.MultipartForm.Value["url"]; ok {
+		urlBytes, err := getImageFormValue(r, "url")
+		if err != nil {
+			if !strings.HasPrefix(err.Error(), "no value found for key") {
+				return u, err
+			}
+		}
+		if urlBytes != nil {
+			u.Url = string(urlBytes)
+		}
+	} else {
+		f, _, err := r.FormFile("file")
+		if err != nil {
+			return u, err
+		}
+		defer f.Close()
 
-	u.File, err = io.ReadAll(f)
-	if err != nil {
-		return u, err
+		u.File, err = io.ReadAll(f)
+		if err != nil {
+			return u, err
+		}
 	}
 
 	return u, nil