diff --git a/readme/images.go b/readme/images.go new file mode 100644 index 0000000..acbe761 --- /dev/null +++ b/readme/images.go @@ -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 +} diff --git a/readme/images_test.go b/readme/images_test.go new file mode 100644 index 0000000..f3d86d4 --- /dev/null +++ b/readme/images_test.go @@ -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") +} diff --git a/readme/readme.go b/readme/readme.go index 9df9fd4..fd51f05 100644 --- a/readme/readme.go +++ b/readme/readme.go @@ -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. @@ -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. @@ -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} @@ -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) @@ -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 { @@ -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 {