Repository: cloudflare/cloudflare-go (go)

Issue:
Image direct upload V2
### Current cloudflare-go version

v0.63.0

### Description

https://developers.cloudflare.com/api/operations/cloudflare-images-create-authenticated-direct-upload-url-v-2

Cloudflare has a version 2 of image direct upload which supports additional parameters like signed urls, metadata...
If approved I can make the code changes and submit a PR.

### Use cases

This will implement the API

### Potential cloudflare-go usage

```go
Make a new function CreateImageDirectUploadURLV2 to keep backward compatibility
```


### References

_No response_

Pull request:
adds method to create image v2 direct upload url
This PR implements the missing feature to support `images/v2/direct_upload`
endpoint that lets client create direct upload url. The v2 version supports
metadata and requireSignedURLs as additional request parameters.

Closes #1248
Supersedes #1308

Patch:
diff --git a/.changelog/1322.txt b/.changelog/1322.txt
new file mode 100644
index 000000000..9f5c710fe
--- /dev/null
+++ b/.changelog/1322.txt
@@ -0,0 +1,31 @@
+```release-note:enhancement
+images: adds support for v2 when uploading images directly
+```
+
+```release-note:breaking-change
+images: updated method signatures of `UploadImage` to match newer conventions and standards
+```
+
+```release-note:breaking-change
+images: updated method signatures of `UpdateImage` to match newer conventions and standards
+```
+
+```release-note:breaking-change
+images: updated method signatures of `ListImages` to match newer conventions and standards
+```
+
+```release-note:breaking-change
+images: updated method signatures of `DeleteImage` to match newer conventions and standards
+```
+
+```release-note:breaking-change
+images: renamed `ImageDetails` to `GetImage` to match library conventions
+```
+
+```release-note:breaking-change
+images: renamed `BaseImage` to `GetBaseImage` to match library conventions
+```
+
+```release-note:breaking-change
+images: renamed `ImagesStats` to `GetImagesStats` to match library conventions
+```
diff --git a/images.go b/images.go
index 8cb9fd2f7..b93b2f46d 100644
--- a/images.go
+++ b/images.go
@@ -12,6 +12,18 @@ import (
 	"time"
 )
 
+var (
+	ErrInvalidImagesAPIVersion = errors.New("invalid images API version")
+	ErrMissingImageID          = errors.New("required image ID missing")
+)
+
+type ImagesAPIVersion string
+
+const (
+	ImagesAPIVersionV1 ImagesAPIVersion = "v1"
+	ImagesAPIVersionV2 ImagesAPIVersion = "v2"
+)
+
 // Image represents a Cloudflare Image.
 type Image struct {
 	ID                string                 `json:"id"`
@@ -22,8 +34,8 @@ type Image struct {
 	Uploaded          time.Time              `json:"uploaded"`
 }
 
-// ImageUploadRequest is the data required for an Image Upload request.
-type ImageUploadRequest struct {
+// UploadImageParams is the data required for an Image Upload request.
+type UploadImageParams struct {
 	File              io.ReadCloser
 	Name              string
 	RequireSignedURLs bool
@@ -32,7 +44,7 @@ type ImageUploadRequest struct {
 
 // write writes the image upload data to a multipart writer, so
 // it can be used in an HTTP request.
-func (b ImageUploadRequest) write(mpw *multipart.Writer) error {
+func (b UploadImageParams) write(mpw *multipart.Writer) error {
 	if b.File == nil {
 		return errors.New("a file to upload must be specified")
 	}
@@ -72,15 +84,19 @@ func (b ImageUploadRequest) write(mpw *multipart.Writer) error {
 	return nil
 }
 
-// ImageUpdateRequest is the data required for an UpdateImage request.
-type ImageUpdateRequest struct {
+// UpdateImageParams is the data required for an UpdateImage request.
+type UpdateImageParams struct {
+	ID                string                 `json:"-"`
 	RequireSignedURLs bool                   `json:"requireSignedURLs"`
 	Metadata          map[string]interface{} `json:"metadata,omitempty"`
 }
 
-// ImageDirectUploadURLRequest is the data required for a CreateImageDirectUploadURL request.
-type ImageDirectUploadURLRequest struct {
-	Expiry time.Time `json:"expiry"`
+// CreateImageDirectUploadURLParams is the data required for a CreateImageDirectUploadURL request.
+type CreateImageDirectUploadURLParams struct {
+	Version           ImagesAPIVersion       `json:"-"`
+	Expiry            *time.Time             `json:"expiry,omitempty"`
+	Metadata          map[string]interface{} `json:"metadata,omitempty"`
+	RequireSignedURLs *bool                  `json:"requireSignedURLs,omitempty"`
 }
 
 // ImageDirectUploadURLResponse is the API response for a direct image upload url.
@@ -123,15 +139,23 @@ type ImagesStatsCount struct {
 	Allowed int64 `json:"allowed"`
 }
 
+type ListImagesParams struct {
+	ResultInfo
+}
+
 // UploadImage uploads a single image.
 //
 // API Reference: https://api.cloudflare.com/#cloudflare-images-upload-an-image-using-a-single-http-request
-func (api *API) UploadImage(ctx context.Context, accountID string, upload ImageUploadRequest) (Image, error) {
-	uri := fmt.Sprintf("/accounts/%s/images/v1", accountID)
+func (api *API) UploadImage(ctx context.Context, rc *ResourceContainer, params UploadImageParams) (Image, error) {
+	if rc.Level != AccountRouteLevel {
+		return Image{}, ErrRequiredAccountLevelResourceContainer
+	}
+
+	uri := fmt.Sprintf("/accounts/%s/images/v1", rc.Identifier)
 
 	body := &bytes.Buffer{}
 	w := multipart.NewWriter(body)
-	if err := upload.write(w); err != nil {
+	if err := params.write(w); err != nil {
 		_ = w.Close()
 		return Image{}, fmt.Errorf("error writing multipart body: %w", err)
 	}
@@ -162,10 +186,14 @@ func (api *API) UploadImage(ctx context.Context, accountID string, upload ImageU
 // UpdateImage updates an existing image's metadata.
 //
 // API Reference: https://api.cloudflare.com/#cloudflare-images-update-image
-func (api *API) UpdateImage(ctx context.Context, accountID string, id string, image ImageUpdateRequest) (Image, error) {
-	uri := fmt.Sprintf("/accounts/%s/images/v1/%s", accountID, id)
+func (api *API) UpdateImage(ctx context.Context, rc *ResourceContainer, params UpdateImageParams) (Image, error) {
+	if rc.Level != AccountRouteLevel {
+		return Image{}, ErrRequiredAccountLevelResourceContainer
+	}
 
-	res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, image)
+	uri := fmt.Sprintf("/accounts/%s/images/v1/%s", rc.Identifier, params.ID)
+
+	res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params)
 	if err != nil {
 		return Image{}, err
 	}
@@ -178,13 +206,73 @@ func (api *API) UpdateImage(ctx context.Context, accountID string, id string, im
 	return imageDetailsResponse.Result, nil
 }
 
+var imagesMultipartBoundary = "----CloudflareImagesGoClientBoundary"
+
 // CreateImageDirectUploadURL creates an authenticated direct upload url.
 //
 // API Reference: https://api.cloudflare.com/#cloudflare-images-create-authenticated-direct-upload-url
-func (api *API) CreateImageDirectUploadURL(ctx context.Context, accountID string, params ImageDirectUploadURLRequest) (ImageDirectUploadURL, error) {
-	uri := fmt.Sprintf("/accounts/%s/images/v1/direct_upload", accountID)
+func (api *API) CreateImageDirectUploadURL(ctx context.Context, rc *ResourceContainer, params CreateImageDirectUploadURLParams) (ImageDirectUploadURL, error) {
+	if rc.Level != AccountRouteLevel {
+		return ImageDirectUploadURL{}, ErrRequiredAccountLevelResourceContainer
+	}
+
+	if params.Version != "" && params.Version != ImagesAPIVersionV1 && params.Version != ImagesAPIVersionV2 {
+		return ImageDirectUploadURL{}, ErrInvalidImagesAPIVersion
+	}
+
+	var err error
+	var uri string
+	var res []byte
+	switch params.Version {
+	case ImagesAPIVersionV2:
+		uri = fmt.Sprintf("/%s/%s/images/%s/direct_upload", rc.Level, rc.Identifier, params.Version)
+		body := &bytes.Buffer{}
+		writer := multipart.NewWriter(body)
+		if err := writer.SetBoundary(imagesMultipartBoundary); err != nil {
+			return ImageDirectUploadURL{}, fmt.Errorf("error setting multipart boundary")
+		}
+
+		if *params.RequireSignedURLs {
+			if err = writer.WriteField("requireSignedURLs", "true"); err != nil {
+				return ImageDirectUploadURL{}, fmt.Errorf("error writing requireSignedURLs field: %w", err)
+			}
+		}
+		if !params.Expiry.IsZero() {
+			if err = writer.WriteField("expiry", params.Expiry.Format(time.RFC3339)); err != nil {
+				return ImageDirectUploadURL{}, fmt.Errorf("error writing expiry field: %w", err)
+			}
+		}
+		if params.Metadata != nil {
+			var metadataBytes []byte
+			if metadataBytes, err = json.Marshal(params.Metadata); err != nil {
+				return ImageDirectUploadURL{}, fmt.Errorf("error marshalling metadata to JSON: %w", err)
+			}
+			if err = writer.WriteField("metadata", string(metadataBytes)); err != nil {
+				return ImageDirectUploadURL{}, fmt.Errorf("error writing metadata field: %w", err)
+			}
+		}
+		if err = writer.Close(); err != nil {
+			return ImageDirectUploadURL{}, fmt.Errorf("error closing multipart writer: %w", err)
+		}
+
+		res, err = api.makeRequestContextWithHeaders(
+			ctx,
+			http.MethodPost,
+			uri,
+			body,
+			http.Header{
+				"Accept":       []string{"application/json"},
+				"Content-Type": []string{writer.FormDataContentType()},
+			},
+		)
+	case ImagesAPIVersionV1:
+	case "":
+		uri = fmt.Sprintf("/%s/%s/images/%s/direct_upload", rc.Level, rc.Identifier, ImagesAPIVersionV1)
+		res, err = api.makeRequestContext(ctx, http.MethodPost, uri, params)
+	default:
+		return ImageDirectUploadURL{}, ErrInvalidImagesAPIVersion
+	}
 
-	res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params)
 	if err != nil {
 		return ImageDirectUploadURL{}, err
 	}
@@ -200,8 +288,8 @@ func (api *API) CreateImageDirectUploadURL(ctx context.Context, accountID string
 // ListImages lists all images.
 //
 // API Reference: https://api.cloudflare.com/#cloudflare-images-list-images
-func (api *API) ListImages(ctx context.Context, accountID string, pageOpts PaginationOptions) ([]Image, error) {
-	uri := buildURI(fmt.Sprintf("/accounts/%s/images/v1", accountID), pageOpts)
+func (api *API) ListImages(ctx context.Context, rc *ResourceContainer, params ListImagesParams) ([]Image, error) {
+	uri := buildURI(fmt.Sprintf("/accounts/%s/images/v1", rc.Identifier), params)
 
 	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
 	if err != nil {
@@ -216,11 +304,15 @@ func (api *API) ListImages(ctx context.Context, accountID string, pageOpts Pagin
 	return imagesListResponse.Result.Images, nil
 }
 
-// ImageDetails gets the details of an uploaded image.
+// GetImage gets the details of an uploaded image.
 //
 // API Reference: https://api.cloudflare.com/#cloudflare-images-image-details
-func (api *API) ImageDetails(ctx context.Context, accountID string, id string) (Image, error) {
-	uri := fmt.Sprintf("/accounts/%s/images/v1/%s", accountID, id)
+func (api *API) GetImage(ctx context.Context, rc *ResourceContainer, id string) (Image, error) {
+	if rc.Level != AccountRouteLevel {
+		return Image{}, ErrRequiredAccountLevelResourceContainer
+	}
+
+	uri := fmt.Sprintf("/accounts/%s/images/v1/%s", rc.Identifier, id)
 
 	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
 	if err != nil {
@@ -235,11 +327,15 @@ func (api *API) ImageDetails(ctx context.Context, accountID string, id string) (
 	return imageDetailsResponse.Result, nil
 }
 
-// BaseImage gets the base image used to derive variants.
+// GetBaseImage gets the base image used to derive variants.
 //
 // API Reference: https://api.cloudflare.com/#cloudflare-images-base-image
-func (api *API) BaseImage(ctx context.Context, accountID string, id string) ([]byte, error) {
-	uri := fmt.Sprintf("/accounts/%s/images/v1/%s/blob", accountID, id)
+func (api *API) GetBaseImage(ctx context.Context, rc *ResourceContainer, id string) ([]byte, error) {
+	if rc.Level != AccountRouteLevel {
+		return []byte{}, ErrRequiredAccountLevelResourceContainer
+	}
+
+	uri := fmt.Sprintf("/accounts/%s/images/v1/%s/blob", rc.Identifier, id)
 
 	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
 	if err != nil {
@@ -251,8 +347,12 @@ func (api *API) BaseImage(ctx context.Context, accountID string, id string) ([]b
 // DeleteImage deletes an image.
 //
 // API Reference: https://api.cloudflare.com/#cloudflare-images-delete-image
-func (api *API) DeleteImage(ctx context.Context, accountID string, id string) error {
-	uri := fmt.Sprintf("/accounts/%s/images/v1/%s", accountID, id)
+func (api *API) DeleteImage(ctx context.Context, rc *ResourceContainer, id string) error {
+	if rc.Level != AccountRouteLevel {
+		return ErrRequiredAccountLevelResourceContainer
+	}
+
+	uri := fmt.Sprintf("/accounts/%s/images/v1/%s", rc.Identifier, id)
 
 	_, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil)
 	if err != nil {
@@ -261,11 +361,15 @@ func (api *API) DeleteImage(ctx context.Context, accountID string, id string) er
 	return nil
 }
 
-// ImagesStats gets an account's statistics for Cloudflare Images.
+// GetImagesStats gets an account's statistics for Cloudflare Images.
 //
 // API Reference: https://api.cloudflare.com/#cloudflare-images-images-usage-statistics
-func (api *API) ImagesStats(ctx context.Context, accountID string) (ImagesStatsCount, error) {
-	uri := fmt.Sprintf("/accounts/%s/images/v1/stats", accountID)
+func (api *API) GetImagesStats(ctx context.Context, rc *ResourceContainer) (ImagesStatsCount, error) {
+	if rc.Level != AccountRouteLevel {
+		return ImagesStatsCount{}, ErrRequiredAccountLevelResourceContainer
+	}
+
+	uri := fmt.Sprintf("/accounts/%s/images/v1/stats", rc.Identifier)
 
 	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
 	if err != nil {


Test patch:
diff --git a/images_test.go b/images_test.go
index 25ad505d9..3640d9f31 100644
--- a/images_test.go
+++ b/images_test.go
@@ -80,7 +80,7 @@ func TestUploadImage(t *testing.T) {
 	mux.HandleFunc("/accounts/"+testAccountID+"/images/v1", handler)
 	want := expectedImageStruct
 
-	actual, err := client.UploadImage(context.Background(), testAccountID, ImageUploadRequest{
+	actual, err := client.UploadImage(context.Background(), AccountIdentifier(testAccountID), UploadImageParams{
 		File: fakeFile{
 			Buffer: bytes.NewBufferString("this is definitely an image"),
 		},
@@ -100,7 +100,7 @@ func TestUpdateImage(t *testing.T) {
 	setup()
 	defer teardown()
 
-	input := ImageUpdateRequest{
+	input := UpdateImageParams{
 		RequireSignedURLs: true,
 		Metadata: map[string]interface{}{
 			"meta": "metaID",
@@ -110,7 +110,7 @@ func TestUpdateImage(t *testing.T) {
 	handler := func(w http.ResponseWriter, r *http.Request) {
 		assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method)
 
-		var v ImageUpdateRequest
+		var v UpdateImageParams
 		err := json.NewDecoder(r.Body).Decode(&v)
 		require.NoError(t, err)
 		assert.Equal(t, input, v)
@@ -141,7 +141,13 @@ func TestUpdateImage(t *testing.T) {
 	mux.HandleFunc("/accounts/"+testAccountID+"/images/v1/ZxR0pLaXRldlBtaFhhO2FiZGVnaA", handler)
 	want := expectedImageStruct
 
-	actual, err := client.UpdateImage(context.Background(), testAccountID, "ZxR0pLaXRldlBtaFhhO2FiZGVnaA", input)
+	actual, err := client.UpdateImage(context.Background(), AccountIdentifier(testAccountID), UpdateImageParams{
+		ID:                "ZxR0pLaXRldlBtaFhhO2FiZGVnaA",
+		RequireSignedURLs: true,
+		Metadata: map[string]interface{}{
+			"meta": "metaID",
+		},
+	})
 
 	if assert.NoError(t, err) {
 		assert.Equal(t, want, actual)
@@ -152,14 +158,15 @@ func TestCreateImageDirectUploadURL(t *testing.T) {
 	setup()
 	defer teardown()
 
-	input := ImageDirectUploadURLRequest{
-		Expiry: time.Now().UTC().Add(30 * time.Minute),
+	expiry := time.Now().UTC().Add(30 * time.Minute)
+	input := CreateImageDirectUploadURLParams{
+		Expiry: &expiry,
 	}
 
 	handler := func(w http.ResponseWriter, r *http.Request) {
 		assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method)
 
-		var v ImageDirectUploadURLRequest
+		var v CreateImageDirectUploadURLParams
 		err := json.NewDecoder(r.Body).Decode(&v)
 		require.NoError(t, err)
 		assert.Equal(t, input, v)
@@ -183,8 +190,62 @@ func TestCreateImageDirectUploadURL(t *testing.T) {
 		UploadURL: "https://upload.imagedelivery.net/fgr33htrthytjtyereifjewoi338272s7w1383",
 	}
 
-	actual, err := client.CreateImageDirectUploadURL(context.Background(), testAccountID, input)
+	actual, err := client.CreateImageDirectUploadURL(context.Background(), AccountIdentifier(testAccountID), input)
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, actual)
+	}
+}
+
+func TestCreateImageDirectUploadURLV2(t *testing.T) {
+	setup()
+	defer teardown()
+
+	exp := time.Now().UTC().Add(30 * time.Minute)
+	metadata := map[string]interface{}{
+		"metaKey1": "metaValue1",
+		"metaKey2": "metaValue2",
+	}
+	requireSignedURLs := true
+	input := CreateImageDirectUploadURLParams{
+		Version:           ImagesAPIVersionV2,
+		Expiry:            &exp,
+		Metadata:          metadata,
+		RequireSignedURLs: &requireSignedURLs,
+	}
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method)
+		require.Equal(t,
+			fmt.Sprintf("multipart/form-data; boundary=%s", imagesMultipartBoundary),
+			r.Header.Get("Content-Type"),
+		)
+		require.NoError(t, r.ParseMultipartForm(32<<20))
+		require.Equal(t, exp.Format(time.RFC3339), r.Form.Get("expiry"))
+		require.Equal(t, "true", r.Form.Get("requireSignedURLs"))
+		marshalledMetadata, err := json.Marshal(metadata)
+		require.NoError(t, err)
+		require.Equal(t, string(marshalledMetadata), r.Form.Get("metadata"))
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			"success": true,
+			"errors": [],
+			"messages": [],
+			"result": {
+				"id": "ZxR0pLaXRldlBtaFhhO2FiZGVnaA",
+				"uploadURL": "https://upload.imagedelivery.net/fgr33htrthytjtyereifjewoi338272s7w1383"
+			}
+		}
+		`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/images/v2/direct_upload", handler)
+	want := ImageDirectUploadURL{
+		ID:        "ZxR0pLaXRldlBtaFhhO2FiZGVnaA",
+		UploadURL: "https://upload.imagedelivery.net/fgr33htrthytjtyereifjewoi338272s7w1383",
+	}
 
+	actual, err := client.CreateImageDirectUploadURL(context.Background(), AccountIdentifier(testAccountID), input)
 	if assert.NoError(t, err) {
 		assert.Equal(t, want, actual)
 	}
@@ -226,7 +287,7 @@ func TestListImages(t *testing.T) {
 	mux.HandleFunc("/accounts/"+testAccountID+"/images/v1", handler)
 	want := []Image{expectedImageStruct}
 
-	actual, err := client.ListImages(context.Background(), testAccountID, PaginationOptions{})
+	actual, err := client.ListImages(context.Background(), AccountIdentifier(testAccountID), ListImagesParams{})
 
 	if assert.NoError(t, err) {
 		assert.Equal(t, want, actual)
@@ -265,7 +326,7 @@ func TestImageDetails(t *testing.T) {
 	mux.HandleFunc("/accounts/"+testAccountID+"/images/v1/ZxR0pLaXRldlBtaFhhO2FiZGVnaA", handler)
 	want := expectedImageStruct
 
-	actual, err := client.ImageDetails(context.Background(), testAccountID, "ZxR0pLaXRldlBtaFhhO2FiZGVnaA")
+	actual, err := client.GetImage(context.Background(), AccountIdentifier(testAccountID), "ZxR0pLaXRldlBtaFhhO2FiZGVnaA")
 
 	if assert.NoError(t, err) {
 		assert.Equal(t, want, actual)
@@ -285,7 +346,7 @@ func TestBaseImage(t *testing.T) {
 	mux.HandleFunc("/accounts/"+testAccountID+"/images/v1/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/blob", handler)
 	want := []byte{}
 
-	actual, err := client.BaseImage(context.Background(), testAccountID, "ZxR0pLaXRldlBtaFhhO2FiZGVnaA")
+	actual, err := client.GetBaseImage(context.Background(), AccountIdentifier(testAccountID), "ZxR0pLaXRldlBtaFhhO2FiZGVnaA")
 
 	if assert.NoError(t, err) {
 		assert.Equal(t, want, actual)
@@ -310,7 +371,7 @@ func TestDeleteImage(t *testing.T) {
 
 	mux.HandleFunc("/accounts/"+testAccountID+"/images/v1/ZxR0pLaXRldlBtaFhhO2FiZGVnaA", handler)
 
-	err := client.DeleteImage(context.Background(), testAccountID, "ZxR0pLaXRldlBtaFhhO2FiZGVnaA")
+	err := client.DeleteImage(context.Background(), AccountIdentifier(testAccountID), "ZxR0pLaXRldlBtaFhhO2FiZGVnaA")
 	require.NoError(t, err)
 }