-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #9 from joshbeard/feat/image-upload
feat: image uploads
- Loading branch information
Showing
3 changed files
with
236 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,133 @@ | ||
package readme | ||
|
||
import ( | ||
"bytes" | ||
"fmt" | ||
"io" | ||
"mime/multipart" | ||
"net/http" | ||
"path/filepath" | ||
"strings" | ||
) | ||
|
||
// ImageAPIURL is the base URL for the images endpoint of the ReadMe API. | ||
// This endpoint is used for uploading images to ReadMe and is not part of the documented 'v1' API. | ||
const ImageAPIURL = "https://dash.readme.com/api/images" | ||
|
||
// ImagesService is an interface for using the docs endpoints of the ReadMe.com API. | ||
// | ||
// API Reference: https://docs.readme.com/main/reference/getdoc | ||
type ImageService interface { | ||
// Upload an image to ReadMe. | ||
Upload(source []byte, filename ...string) (Image, *APIResponse, error) | ||
} | ||
|
||
// ImageClient handles uploading images to ReadMe.com. | ||
type ImageClient struct { | ||
client *Client | ||
} | ||
|
||
// Ensure the implementation satisfies the expected interfaces. | ||
// This is a compile-time check. | ||
// See: https://golang.org/doc/faq#guarantee_satisfies_interface | ||
var _ ImageService = &ImageClient{} | ||
|
||
// Image represents an image uploaded to ReadMe. | ||
type Image struct { | ||
URL string | ||
Filename string | ||
Width int64 | ||
Height int64 | ||
Color string | ||
} | ||
|
||
// okContentType returns true if the content type is a valid image type. | ||
func okContentType(contentType string) bool { | ||
return contentType == "image/png" || contentType == "image/jpeg" || contentType == "image/gif" | ||
} | ||
|
||
// buildForm builds a multipart form for uploading an image to ReadMe. | ||
func buildForm(filename string, source []byte) ([]byte, string, error) { | ||
// Create a reader for the data. | ||
data := strings.NewReader(string(source)) | ||
|
||
// Create a new form. | ||
formData := &bytes.Buffer{} | ||
writer := multipart.NewWriter(formData) | ||
|
||
// Add the form fields. | ||
_ = writer.WriteField("name", "image") | ||
_ = writer.WriteField("filename", filename) | ||
|
||
// Add the file. | ||
part, err := writer.CreateFormFile("data", filepath.Base(filename)) | ||
if err != nil { | ||
return nil, "", fmt.Errorf("unable to create request form: %w", err) | ||
} | ||
|
||
// Copy the data into the form. | ||
_, err = io.Copy(part, data) | ||
if err != nil { | ||
return nil, "", fmt.Errorf("unable to copy data: %w", err) | ||
} | ||
|
||
// Close the writer. | ||
err = writer.Close() | ||
if err != nil { | ||
return nil, "", fmt.Errorf("unable to close writer: %w", err) | ||
} | ||
|
||
// Get the content type for the form. | ||
contentType := writer.FormDataContentType() | ||
|
||
return formData.Bytes(), contentType, nil | ||
} | ||
|
||
// Upload an image to ReadMe. | ||
func (c *ImageClient) Upload(source []byte, filename ...string) (Image, *APIResponse, error) { | ||
var image Image | ||
|
||
// Validate the image type. | ||
imageType := http.DetectContentType(source) | ||
if !okContentType(imageType) { | ||
return image, nil, fmt.Errorf("invalid image type: %s", imageType) | ||
} | ||
|
||
// Determine the filename to use. | ||
upload_filename := "image" | ||
if len(filename) > 0 { | ||
upload_filename = filepath.Base(filename[0]) | ||
} | ||
|
||
// Build the form. | ||
payload, contentType, err := buildForm(upload_filename, source) | ||
if err != nil { | ||
return image, nil, fmt.Errorf("unable to build form: %w", err) | ||
} | ||
|
||
// Make the request. | ||
var imageResponse []any | ||
apiResponse, err := c.client.APIRequest(&APIRequest{ | ||
Method: "POST", | ||
URL: fmt.Sprintf("%s/image-upload", ImageAPIURL), | ||
UseAuth: true, | ||
Headers: []RequestHeader{{"Content-Type": contentType}}, | ||
Payload: payload, | ||
OkStatusCode: []int{200}, | ||
Response: &imageResponse, | ||
}) | ||
if err != nil { | ||
return image, apiResponse, fmt.Errorf("unable to upload image: %w", err) | ||
} | ||
|
||
// Map the response to the struct with type assertion. | ||
image = Image{ | ||
URL: imageResponse[0].(string), | ||
Filename: imageResponse[1].(string), | ||
Width: int64(imageResponse[2].(float64)), | ||
Height: int64(imageResponse[3].(float64)), | ||
Color: imageResponse[4].(string), | ||
} | ||
|
||
return image, apiResponse, err | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
package readme_test | ||
|
||
import ( | ||
"encoding/base64" | ||
"fmt" | ||
"testing" | ||
|
||
"github.com/liveoaklabs/readme-api-go-client/internal/testutil" | ||
"github.com/liveoaklabs/readme-api-go-client/readme" | ||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
// imagesTestEndpoint is the endpoint for the images API. Note that this is different from the | ||
// other API endpoints - this one is undocumented and not part of the official API. | ||
const imagesTestEndpoint = "https://dash.readme.com/api/images/image-upload" | ||
|
||
func Test_Image_Upload(t *testing.T) { | ||
testCases := []struct { | ||
source string // base64 encoded image | ||
filename string | ||
}{ | ||
{ | ||
source: "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII", | ||
filename: "test-01.png", | ||
}, | ||
{ | ||
source: "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==", | ||
filename: "test-01.jpg", | ||
}, | ||
{ | ||
source: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII", | ||
filename: "test-01.gif", | ||
}, | ||
} | ||
|
||
for _, tc := range testCases { | ||
// mockImage represents a mock image response struct. | ||
var mockImage = readme.Image{ | ||
URL: fmt.Sprintf("https://files.readme.io/c6f07db-%s", tc.filename), | ||
Filename: tc.filename, | ||
Width: 512, | ||
Height: 512, | ||
Color: "#000000", | ||
} | ||
|
||
// mockImageResponse represents a mock image response body from the API when uploading an image. | ||
var mockImageResponse = []any{ | ||
mockImage.URL, | ||
mockImage.Filename, | ||
mockImage.Width, | ||
mockImage.Height, | ||
mockImage.Color, | ||
} | ||
|
||
// Arrange | ||
mockResponse := testutil.APITestResponse{ | ||
URL: imagesTestEndpoint, | ||
Status: 200, | ||
Body: testutil.StructToJson(t, mockImageResponse), | ||
} | ||
api := mockResponse.New(t) | ||
|
||
// Convert base64 image to byte array | ||
sampleImage, _ := base64.StdEncoding.DecodeString(tc.source) | ||
|
||
// Act | ||
got, _, err := api.Image.Upload(sampleImage, tc.filename) | ||
|
||
// Assert | ||
assert.NoError(t, err, "it does not return an error") | ||
assert.Equal(t, mockImage, got, "it returns the image struct") | ||
} | ||
} | ||
|
||
func Test_Image_Upload_Invalid(t *testing.T) { | ||
// Arrange | ||
mockResponse := testutil.APITestResponse{} | ||
api := mockResponse.New(t) | ||
|
||
sampleImage := []byte("not-an-image") | ||
|
||
// Act | ||
_, _, err := api.Image.Upload(sampleImage, "not-an-image.png") | ||
|
||
// Assert | ||
assert.Error(t, err, "it returns an error when the image type is invalid") | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters