Skip to content

Commit d5d6582

Browse files
authored
add images api (#279)
* add images api * update upload image and images tests
1 parent d6624dd commit d5d6582

File tree

4 files changed

+304
-0
lines changed

4 files changed

+304
-0
lines changed

pkg/moov/image_api.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package moov
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
)
9+
10+
// UploadImage uploads a new PNG, JPEG, or WebP image with optional metadata.
11+
// Duplicate images, and requests larger than 16MB will be rejected.
12+
// https://docs.moov.io/api/tools/images/post/
13+
func (c Client) UploadImage(ctx context.Context, accountID string, file io.Reader, metadata *ImageMetadataRequest) (*ImageMetadata, error) {
14+
var multiParts []multipartFn
15+
multiParts = append(multiParts, MultipartFile("image", "image", file, "application/octet-stream"))
16+
17+
if metadata != nil {
18+
mdJson, err := json.Marshal(metadata)
19+
if err != nil {
20+
return nil, err
21+
}
22+
multiParts = append(multiParts, MultipartField("metadata", string(mdJson)))
23+
}
24+
25+
resp, err := c.CallHttp(ctx, Endpoint(http.MethodPost, pathImages, accountID), MultipartBody(multiParts...))
26+
if err != nil {
27+
return nil, err
28+
}
29+
30+
return StartedObjectOrError[ImageMetadata](resp)
31+
}
32+
33+
// ListImageMetadata lists metadata for all images in the specified account.
34+
// https://docs.moov.io/api/tools/images/list/
35+
func (c Client) ListImageMetadata(ctx context.Context, accountID string, filters ...ImageListFilter) ([]ImageMetadata, error) {
36+
args := prependArgs(filters, AcceptJson())
37+
resp, err := c.CallHttp(ctx, Endpoint(http.MethodGet, pathImages, accountID), args...)
38+
if err != nil {
39+
return nil, err
40+
}
41+
42+
return CompletedListOrError[ImageMetadata](resp)
43+
}
44+
45+
// GetImageMetadata retrieves metadata for a specific image by its ID.
46+
// https://docs.moov.io/api/tools/images/get/
47+
func (c Client) GetImageMetadata(ctx context.Context, accountID string, imageID string) (*ImageMetadata, error) {
48+
resp, err := c.CallHttp(ctx, Endpoint(http.MethodGet, pathImage, accountID, imageID), AcceptJson())
49+
if err != nil {
50+
return nil, err
51+
}
52+
53+
return CompletedObjectOrError[ImageMetadata](resp)
54+
}
55+
56+
// UpdateImage replaces an existing image and, optionally, its metadata.
57+
// This endpoint replaces the existing image with the new PNG, JPEG, or WebP. Omit
58+
// the metadata parameter to keep existing metadata. Duplicate images, and requests
59+
// larger than 16MB will be rejected.
60+
// https://docs.moov.io/api/tools/images/put-image/
61+
func (c Client) UpdateImage(ctx context.Context, accountID string, imageID string, file io.Reader, metadata *ImageMetadataRequest) (*ImageMetadata, error) {
62+
var multiParts []multipartFn
63+
multiParts = append(multiParts, MultipartFile("image", "image", file, "application/octet-stream"))
64+
65+
if metadata != nil {
66+
mdJson, err := json.Marshal(metadata)
67+
if err != nil {
68+
return nil, err
69+
}
70+
multiParts = append(multiParts, MultipartField("metadata", string(mdJson)))
71+
}
72+
73+
resp, err := c.CallHttp(ctx, Endpoint(http.MethodPut, pathImage, accountID, imageID), MultipartBody(multiParts...))
74+
if err != nil {
75+
return nil, err
76+
}
77+
78+
return CompletedObjectOrError[ImageMetadata](resp)
79+
}
80+
81+
// UpdateImageMetadata replaces the metadata for an existing image.
82+
// https://docs.moov.io/api/tools/images/put-metadata/
83+
func (c Client) UpdateImageMetadata(ctx context.Context, accountID string, imageID string, metadata ImageMetadataRequest) (*ImageMetadata, error) {
84+
resp, err := c.CallHttp(ctx, Endpoint(http.MethodPut, pathImageMetadata, accountID, imageID), AcceptJson(), JsonBody(metadata))
85+
if err != nil {
86+
return nil, err
87+
}
88+
89+
return CompletedObjectOrError[ImageMetadata](resp)
90+
}
91+
92+
// DeleteImage disables an image by its ID.
93+
// Disabled images are still accessible via their public URL, and cannot be assigned
94+
// to products or line-items.
95+
// https://docs.moov.io/api/tools/images/delete/
96+
func (c Client) DeleteImage(ctx context.Context, accountID string, imageID string) error {
97+
resp, err := c.CallHttp(ctx, Endpoint(http.MethodDelete, pathImage, accountID, imageID), AcceptJson())
98+
if err != nil {
99+
return err
100+
}
101+
102+
return CompletedNilOrError(resp)
103+
}

pkg/moov/image_models.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package moov
2+
3+
import (
4+
"fmt"
5+
"time"
6+
)
7+
8+
// ImageMetadataRequest represents the request body for creating or updating image metadata.
9+
type ImageMetadataRequest struct {
10+
AltText string `json:"altText,omitempty"`
11+
}
12+
13+
// ImageMetadata represents metadata about an uploaded image.
14+
type ImageMetadata struct {
15+
ImageID string `json:"imageID,omitempty"`
16+
PublicID string `json:"publicID,omitempty"`
17+
AltText string `json:"altText,omitempty"`
18+
Link string `json:"link,omitempty"`
19+
CreatedOn time.Time `json:"createdOn,omitempty"`
20+
UpdatedOn time.Time `json:"updatedOn,omitempty"`
21+
DisabledOn *time.Time `json:"disabledOn,omitempty"`
22+
}
23+
24+
// ImageListFilter is used to filter the list of images.
25+
type ImageListFilter callArg
26+
27+
// WithImageSkip sets the skip parameter for pagination.
28+
func WithImageSkip(skip int) ImageListFilter {
29+
return callBuilderFn(func(call *callBuilder) error {
30+
call.params["skip"] = fmt.Sprintf("%d", skip)
31+
return nil
32+
})
33+
}
34+
35+
// WithImageCount sets the count parameter for pagination (max 200, default 20).
36+
func WithImageCount(count int) ImageListFilter {
37+
return callBuilderFn(func(call *callBuilder) error {
38+
call.params["count"] = fmt.Sprintf("%d", count)
39+
return nil
40+
})
41+
}

pkg/moov/image_test.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package moov_test
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"image"
8+
"image/color"
9+
"image/jpeg"
10+
"image/png"
11+
"io"
12+
"math/rand"
13+
"testing"
14+
"time"
15+
16+
"github.com/moovfinancial/moov-go/pkg/moov"
17+
"github.com/stretchr/testify/require"
18+
)
19+
20+
func TestImageMetadataMarshal(t *testing.T) {
21+
input := []byte(`{
22+
"imageID": "ec7e1848-dc80-4ab0-8827-dd7fc0737b43",
23+
"publicID": "qJRAaAwwF5hmfeAFdHjIb",
24+
"altText": "Test image",
25+
"link": "https://api.moov.io/images/qJRAaAwwF5hmfeAFdHjIb",
26+
"createdOn": "2024-01-15T10:30:00Z",
27+
"updatedOn": "2024-01-15T10:30:00Z",
28+
"disabledOn": "2024-01-15T10:30:00Z"
29+
}`)
30+
31+
metadata := new(moov.ImageMetadata)
32+
33+
dec := json.NewDecoder(bytes.NewReader(input))
34+
dec.DisallowUnknownFields()
35+
36+
err := dec.Decode(&metadata)
37+
require.NoError(t, err)
38+
require.Equal(t, "ec7e1848-dc80-4ab0-8827-dd7fc0737b43", metadata.ImageID)
39+
require.Equal(t, "qJRAaAwwF5hmfeAFdHjIb", metadata.PublicID)
40+
require.Equal(t, "Test image", metadata.AltText)
41+
require.Equal(t, "https://api.moov.io/images/qJRAaAwwF5hmfeAFdHjIb", metadata.Link)
42+
require.NotNil(t, metadata.CreatedOn)
43+
require.NotNil(t, metadata.UpdatedOn)
44+
require.NotNil(t, metadata.DisabledOn)
45+
}
46+
47+
func Test_Images(t *testing.T) {
48+
mc := NewTestClient(t)
49+
ctx := context.Background()
50+
accountID := MERCHANT_ID
51+
52+
uploadedImageID := ""
53+
54+
t.Run("upload image", func(t *testing.T) {
55+
_, imgReader := randomImage(t, 100, 100, encodePNG)
56+
metadata := &moov.ImageMetadataRequest{
57+
AltText: "Test image from moov-go SDK",
58+
}
59+
60+
uploaded, err := mc.UploadImage(ctx, accountID, imgReader, metadata)
61+
require.NoError(t, err)
62+
require.NotNil(t, uploaded)
63+
require.NotEmpty(t, uploaded.ImageID)
64+
require.NotEmpty(t, uploaded.PublicID)
65+
require.NotEmpty(t, uploaded.Link)
66+
require.Equal(t, metadata.AltText, uploaded.AltText)
67+
68+
uploadedImageID = uploaded.ImageID
69+
})
70+
71+
t.Run("list images", func(t *testing.T) {
72+
gotImages, err := mc.ListImageMetadata(ctx, accountID)
73+
require.NoError(t, err)
74+
require.Greater(t, len(gotImages), 0)
75+
})
76+
77+
t.Run("get image", func(t *testing.T) {
78+
got, err := mc.GetImageMetadata(ctx, accountID, uploadedImageID)
79+
require.NoError(t, err)
80+
require.NotNil(t, got)
81+
})
82+
83+
t.Run("update image", func(t *testing.T) {
84+
_, imgReader := randomImage(t, 100, 100, encodeJPEG)
85+
metadata := &moov.ImageMetadataRequest{
86+
AltText: "Updated test image",
87+
}
88+
89+
updated, err := mc.UpdateImage(ctx, accountID, uploadedImageID, imgReader, metadata)
90+
require.NoError(t, err)
91+
require.NotNil(t, updated)
92+
require.Equal(t, metadata.AltText, updated.AltText)
93+
})
94+
95+
t.Run("update image metadata", func(t *testing.T) {
96+
metadata := moov.ImageMetadataRequest{
97+
AltText: "Updated metadata only",
98+
}
99+
100+
updated, err := mc.UpdateImageMetadata(ctx, accountID, uploadedImageID, metadata)
101+
require.NoError(t, err)
102+
require.NotNil(t, updated)
103+
require.Equal(t, metadata.AltText, updated.AltText)
104+
})
105+
106+
t.Run("delete image", func(t *testing.T) {
107+
err := mc.DeleteImage(ctx, accountID, uploadedImageID)
108+
require.NoError(t, err)
109+
})
110+
}
111+
112+
type encoderFunc func(img image.Image) (io.Reader, error)
113+
114+
func encodePNG(img image.Image) (io.Reader, error) {
115+
var buf bytes.Buffer
116+
err := png.Encode(&buf, img)
117+
return &buf, err
118+
}
119+
120+
func encodeJPEG(img image.Image) (io.Reader, error) {
121+
var buf bytes.Buffer
122+
err := jpeg.Encode(&buf, img, nil)
123+
return &buf, err
124+
}
125+
126+
func randomImage(t *testing.T, w, h int, enc encoderFunc) (image.Image, io.Reader) {
127+
t.Helper()
128+
129+
if w <= 0 || h <= 0 {
130+
t.Fatalf("invalid dimensions: %dx%d", w, h)
131+
}
132+
133+
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
134+
135+
img := image.NewRGBA(image.Rect(0, 0, w, h))
136+
for y := 0; y < h; y++ {
137+
for x := 0; x < w; x++ {
138+
img.Set(x, y, color.RGBA{
139+
R: uint8(rnd.Intn(256)),
140+
G: uint8(rnd.Intn(256)),
141+
B: uint8(rnd.Intn(256)),
142+
A: 255,
143+
})
144+
}
145+
}
146+
147+
var out io.Reader
148+
if enc != nil {
149+
var err error
150+
out, err = enc(img)
151+
require.NoError(t, err)
152+
}
153+
154+
return img, out
155+
}

pkg/moov/paths.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,9 @@ const (
129129
pathIssuingAuthorizationEvents = "/issuing/%s/authorizations/%s/events"
130130
pathIssuingTransactions = "/issuing/%s/card-transactions"
131131
pathIssuingTransaction = "/issuing/%s/card-transactions/%s"
132+
133+
pathImages = "/accounts/%s/images"
134+
pathImage = "/accounts/%s/images/%s"
135+
pathImageMetadata = "/accounts/%s/images/%s/metadata"
136+
pathPublicImage = "/images/%s"
132137
)

0 commit comments

Comments
 (0)