Skip to content

Commit

Permalink
Merge pull request #9 from joshbeard/feat/image-upload
Browse files Browse the repository at this point in the history
feat: image uploads
  • Loading branch information
joshbeard authored Mar 1, 2023
2 parents 8f8c72e + 3d17e84 commit 4019817
Show file tree
Hide file tree
Showing 3 changed files with 236 additions and 3 deletions.
133 changes: 133 additions & 0 deletions readme/images.go
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
}
87 changes: 87 additions & 0 deletions readme/images_test.go
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")
}
19 changes: 16 additions & 3 deletions readme/readme.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ type Client struct {
CustomPage CustomPageService
// Doc implements the ReadMe Docs API for managing docs.
Doc DocService
// Image implements the ReadMe Image API for uploading images.
Image ImageService
// Project implements the ReadMe Project API for retrieving metadata about the project.
Project ProjectService
// Version implements the ReadMe Version API for managing versions.
Expand Down Expand Up @@ -96,6 +98,9 @@ type APIRequest struct {

// UseAuth toggles whether the request should use authentication or not.
UseAuth bool

// URL is a full URL string to use for the request as an alternative to Endpoint.
URL string
}

// APIResponse represents the response from a request to the ReadMe API.
Expand Down Expand Up @@ -168,6 +173,7 @@ func NewClient(token string, apiURL ...string) (*Client, error) {
client.Changelog = &ChangelogClient{client: client}
client.CustomPage = &CustomPageClient{client: client}
client.Doc = &DocClient{client: client}
client.Image = &ImageClient{client: client}
client.Project = &ProjectClient{client: client}
client.Version = &VersionClient{client: client}

Expand Down Expand Up @@ -228,7 +234,7 @@ func (c *Client) doRequest(request *APIRequest) ([]byte, http.Response, error) {
}

if res.Body == nil {
return nil, *res, fmt.Errorf("response body is nil in %s request to %s", request.Method, request.Endpoint)
return nil, *res, fmt.Errorf("response body is nil in %s request to %s", req.Method, req.URL)
}

body, err := io.ReadAll(res.Body)
Expand Down Expand Up @@ -269,11 +275,14 @@ func checkResponseStatus(body []byte, responseCode int, okCodes []int) (APIError
// This sets common headers and prepares an optional payload for the request.
func (c *Client) prepareRequest(request *APIRequest) (*http.Request, error) {
// Prepare the request.
req, reqErr := http.NewRequest(request.Method, c.APIURL+request.Endpoint, nil)
if request.URL == "" {
request.URL = c.APIURL + request.Endpoint
}
req, reqErr := http.NewRequest(request.Method, request.URL, nil)

if request.Payload != nil {
data := bytes.NewBuffer(request.Payload)
req, reqErr = http.NewRequest(request.Method, c.APIURL+request.Endpoint, data)
req, reqErr = http.NewRequest(request.Method, request.URL, data)
}

if reqErr != nil {
Expand Down Expand Up @@ -329,6 +338,10 @@ func (c *Client) paginatedRequest(apiRequest *APIRequest, page int) (*APIRespons
baseEndpoint := apiRequest.Endpoint
apiRequest.Endpoint = fmt.Sprintf("%s?perPage=%d&page=%d", baseEndpoint, perPage, page)

if apiRequest.URL == "" {
apiRequest.URL = c.APIURL + apiRequest.Endpoint
}

// Make API request
apiResponse, err := c.APIRequest(apiRequest)
if err != nil {
Expand Down

0 comments on commit 4019817

Please sign in to comment.