Skip to content

Commit

Permalink
feat: image uploads
Browse files Browse the repository at this point in the history
This adds support for the undocumented image upload API endpoint that's
not part of the official API.

The `APIRequest` type adds a `URL` field that can be set to explicitly
set a URL, as in the case with images since the endpoint is completely
different.

This only supports uploading images.
  • Loading branch information
joshbeard committed Mar 1, 2023
1 parent a85a415 commit 3d17e84
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 3d17e84

Please sign in to comment.