# README
// ReadMe API Client for Go is for performing API operations with ReadMe.com. // // Refer to https://docs.readme.com/main/reference/intro/getting-started for more information about // the ReadMe API. package readme
import ( "bytes" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "reflect" "regexp" "strconv" "strings" "time" )
// Markdown documentation generation. //go:generate go run github.com/princjef/gomarkdoc/cmd/gomarkdoc --output ../docs/README.md ./...
const ( // PaginationHeader is the name of the HTTP response header with pagination links. PaginationHeader = "link"
// ReadmeAPIURL is the default base URL for the ReadMe API.
ReadmeAPIURL = "https://dash.readme.com/api/v1"
// TotalCountHeader is the name of the HTTP response header with the total count in results.
TotalCountHeader = "x-total-count"
// UserAgent is the name of the HTTP UserAgent when making requests.
UserAgent = "readme-api-go-client"
)
// IDValidCharacters is a compiled RegEx pattern that matches valid characters in an object ID or // API Registry UUID. var IDValidCharacters = regexp.MustCompile("^[0-9a-zA-Z]+$")
// Client sets up the API HTTP client with authentication and exposes the API interfaces. type Client struct { // APIURL is the base URL for the ReadMe API. APIURL string // HTTPClient is the initialized HTTP client. HTTPClient *http.Client // Token is the API token for authenticating with ReadMe. Token string
// APIRegistry implements the ReadMe API Registry API for managing API definitions.
APIRegistry APIRegistryService
// APISpecification implements the ReadMe API Specification API for managing API specifications.
APISpecification APISpecificationService
// Apply implements the ReadMe API Apply API for retrieving and applying for positions at ReadMe.
Apply ApplyService
// Category implements the ReadMe Category API for managing categories.
Category CategoryService
// Changelog implements the ReadMe Changelog API for managing changelogs.
Changelog ChangelogService
// CustomPage implements the ReadMe CustomPage API for managing custom pages.
CustomPage CustomPageService
// Doc implements the ReadMe Docs API for managing docs.
Doc DocService
// Image implements the ReadMe Image API for uploading images.
Image ImageService
// OutboundIP implements the ReadMe OutboundIP API for retrieving outbound IP addresses.
OutboundIP OutboundIPService
// Project implements the ReadMe Project API for retrieving metadata about the project.
Project ProjectService
// Version implements the ReadMe Version API for managing versions.
Version VersionService
}
// RequestHeader represents an HTTP header set on requests. type RequestHeader map[string]string
// APIRequest represents a request to the ReadMe.com API. type APIRequest struct { // Endpoint is the API endpoint (after the base URL) for the request. Endpoint string
// Headers lists HTTP headers to send in the request, in addition to the implicit headers.
Headers []RequestHeader
// Slice of HTTP status codes that are considered 'ok'.
// Any other status code in the response results in an error.
OkStatusCode []int
// Method is the HTTP method to use for the request.
Method string
// An optional payload, in bytes, for the request.
Payload []byte
// Optional options for a request, including headers, version and pagination options.
RequestOptions
// Interface of a struct to map the response body to.
Response interface{}
// 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. type APIResponse struct { // APIErrorResponse is a structured error from the ReadMe API when a request results in error. APIErrorResponse APIErrorResponse // Body is the response body in bytes. Body []byte // HTTPResponse is the stdlib http.Response type. HTTPResponse *http.Response // Request is the APIRequest struct used to create the request. Request *APIRequest }
// APIErrorResponse represents the response ReadMe provides in the body of requests that failed.
type APIErrorResponse struct {
// Docs is a ReadMe Metrics log URL where more information about the request can be retrieved.
// If metrics URLs are unavailable for the request, this URL will be a URL to the ReadMe API Reference.
Docs string json:"docs"
// Error is an error code unique to the error received.
Error string json:"error"
// Help is information on where additional assistance from the ReadMe support team can be obtained.
Help string json:"help"
// Message is the reason why the error occurred.
Message string json:"message"
// Poem is a short poem about the error.
Poem []string json:"poem"
// Suggestion is a helpful suggestion for how to alleviate the error.
Suggestion string json:"suggestion"
}
// RequestOptions is used for specifying options for requests, such as pagination options. type RequestOptions struct { // Headers is a list of additional headers to add to the request. Headers []RequestHeader // PerPage is the number of items to return in each request when using pagination. // The maximum and default is 100. PerPage int // Page is the page number to request when using pagination. Page int // ProductionDoc is used by readme.Docs.Get() to indicate whether the requested document is a // 'production' doc. ProductionDoc bool // Version number of a ReadMe project, for example, v3.0. By default the main project version is used. Version string }
// NewClient initializes the API client configuration and returns the HTTP client with an auth token and URL set. // // Optionally provide a custom API URL as a second parameter. func NewClient(token string, apiURL ...string) (*Client, error) { client := &Client{ HTTPClient: &http.Client{Timeout: 10 * time.Second}, } client.APIURL = ReadmeAPIURL client.Token = token
if apiURL != nil {
if len(apiURL) > 1 {
return nil, fmt.Errorf("unable to configure ReadMe API client: "+
"too many values specified for API URL (got: %v; expects 1)", len(apiURL))
}
client.APIURL = apiURL[0]
}
client.APIRegistry = &APIRegistryClient{client: client}
client.APISpecification = &APISpecificationClient{client: client}
client.Apply = &ApplyClient{client: client}
client.Category = &CategoryClient{client: client}
client.Changelog = &ChangelogClient{client: client}
client.CustomPage = &CustomPageClient{client: client}
client.Doc = &DocClient{client: client}
client.Image = &ImageClient{client: client}
client.OutboundIP = &OutboundIPClient{client: client}
client.Project = &ProjectClient{client: client}
client.Version = &VersionClient{client: client}
return client, nil
}
// APIRequest performs a request to the ReadMe API and handles parsing the response and API errors. // // This function is called directly by the receiver functions used to implement each endpoint. func (c *Client) APIRequest(request *APIRequest) (*APIResponse, error) { // Perform the request body, httpResponse, err := c.doRequest(request) if err != nil { return nil, err }
apiResponse := &APIResponse{
Body: body,
HTTPResponse: &httpResponse,
Request: request,
}
// Verify the HTTP response from the API.
apiErrorResponse, err := checkResponseStatus(body, httpResponse.StatusCode, request)
if err != nil {
apiResponse.APIErrorResponse = apiErrorResponse
return apiResponse, err
}
// Parse the response into the specified interface.
if request.Response != nil {
err = json.Unmarshal(body, &request.Response)
if err != nil {
return apiResponse, fmt.Errorf("unable to parse API response: %w", err)
}
}
err = httpResponse.Body.Close()
if err != nil {
return apiResponse, fmt.Errorf("problem closing HTTP response body")
}
return apiResponse, nil
}
// doRequest performs an API request and returns the response or error. func (c *Client) doRequest(request *APIRequest) ([]byte, http.Response, error) { req, err := c.prepareRequest(request) if err != nil { return nil, http.Response{}, err }
// Perform the request.
res, err := c.HTTPClient.Do(req)
if err != nil {
return nil, http.Response{}, fmt.Errorf("unable to make request: %w", err)
}
if res.Body == nil {
return nil, *res, fmt.Errorf("response body is nil in %s request to %s", req.Method, req.URL)
}
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, *res, fmt.Errorf("unable to read response: %w", err)
}
err = res.Body.Close()
if err != nil {
return nil, *res, fmt.Errorf("problem closing HTTP response body")
}
return body, *res, nil
}
// checkResponseStatus compares an HTTP response status code against a slice of 'OK' status codes. // // If the response code matches a provided code listed in okCodes, no error is returned. // If the response code doesn't match, an error and APIErrorResponse is returned. func checkResponseStatus(body []byte, responseCode int, req *APIRequest) (APIErrorResponse, error) { var apiErrorResponse APIErrorResponse for _, okCode := range req.OkStatusCode { if responseCode == okCode { return apiErrorResponse, nil } }
err := json.Unmarshal(body, &apiErrorResponse)
if err != nil {
return apiErrorResponse, fmt.Errorf("unable to decode API error response: %w", err)
}
return apiErrorResponse,
fmt.Errorf("ReadMe API Error: %v on %s %s: %s",
responseCode, req.Method, req.Endpoint, body)
}
// prepareRequest prepares an http.Request for the ReadMe API. // // This sets common headers and prepares an optional payload for the request. func (c *Client) prepareRequest(request *APIRequest) (*http.Request, error) { // Prepare the request. 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, request.URL, data)
}
if reqErr != nil {
return nil, fmt.Errorf("unable to prepare request: %w", reqErr)
}
for _, r := range request.Headers {
for header, value := range r {
req.Header.Set(header, value)
}
}
if request.UseAuth {
encodedToken := base64.StdEncoding.EncodeToString([]byte(c.Token))
authHeader := "Basic " + encodedToken
req.Header.Set("authorization", authHeader)
}
if request.RequestOptions.Version != "" {
req.Header.Set("x-readme-version", request.RequestOptions.Version)
}
req.Header.Set("accept", "application/json")
req.Header.Set("User-Agent", UserAgent)
return req, nil
}
// paginatedRequest makes a request to the ReadMe API with pagination query parameters set. // // An abbreviated *APIRequest struct should be passed, leaving the Headers and Version fields unset. // These are derived from the RequestOptions field. // // This function is intended to be called within a loop and returns the APIResponse struct and a // boolean indicating if there is a next page indicated in the pagination header. func (c *Client) paginatedRequest(apiRequest *APIRequest, page int) (*APIResponse, bool, error) { // Set default values perPage := 100
// Check for custom values in RequestOptions
if apiRequest.RequestOptions.PerPage != 0 {
perPage = apiRequest.RequestOptions.PerPage
}
if apiRequest.RequestOptions.Headers != nil {
apiRequest.Headers = apiRequest.RequestOptions.Headers
}
// Add pagination parameters to endpoint
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 {
return apiResponse, false, fmt.Errorf("unable to make request: %w", err)
}
// Check for next page
hasNextPage, err := HasNextPage(apiResponse.HTTPResponse.Header.Get(PaginationHeader))
if err != nil {
return apiResponse, false, fmt.Errorf(
"unable to check pagination link header '%s': %w; ",
PaginationHeader,
err,
)
}
if !hasNextPage {
return apiResponse, false, nil
}
// Get total count of items
totalCountHeader := apiResponse.HTTPResponse.Header.Get(TotalCountHeader)
totalCount, err := strconv.Atoi(totalCountHeader)
if err != nil {
return apiResponse, false, fmt.Errorf(
"unable to parse '%s' header: %w; Response: %v",
TotalCountHeader,
err,
apiResponse,
)
}
// Check if current page is last page
if page >= (totalCount / perPage) {
return apiResponse, false, nil
}
return apiResponse, true, nil
}
func (c *Client) fetchAllPages( endpoint string, options *RequestOptions, result interface{}, ) (*APIResponse, error) { hasNextPage := false page := 1 var apiResponse *APIResponse var err error
// Convert the result to a reflect.Value to manipulate the underlying slice
resultValue := reflect.ValueOf(result)
if resultValue.Kind() != reflect.Ptr || resultValue.Elem().Kind() != reflect.Slice {
return nil, fmt.Errorf("result argument must be a pointer to a slice")
}
if options != nil && options.Page != 0 {
page = options.Page
}
for {
pageResults := reflect.New(resultValue.Elem().Type()).Interface()
apiRequest := &APIRequest{
Method: "GET",
Endpoint: endpoint,
UseAuth: true,
OkStatusCode: []int{200},
Response: pageResults,
}
if options != nil {
apiRequest.RequestOptions = *options
}
apiResponse, hasNextPage, err = c.paginatedRequest(apiRequest, page)
if err != nil {
return apiResponse, fmt.Errorf("unable to retrieve data: %w", err)
}
// Append the current page's results to the original result slice
resultValue.Elem().Set(reflect.AppendSlice(resultValue.Elem(), reflect.ValueOf(pageResults).Elem()))
if !hasNextPage {
break
}
page++
}
return apiResponse, nil
}
// parseRequestOptions is a helper function to parse the RequestOptions slice // and return the first element as a *RequestOptions struct. func parseRequestOptions(options []RequestOptions) *RequestOptions { if len(options) > 0 { return &options[0] } return nil }
// HasNextPage checks if a "next" link is provided in the "links" response header for pagination, // indicating the request has a next page. // // This does a rudimentary parsing of the header value, splitting on the comma-separated links and // parsing the value of "rel". // // A link header looks like: // </api-specification?page=2>; rel="next", <>; rel="prev", <>; rel="last" func HasNextPage(links string) (bool, error) { // Split links by comma parts := strings.Split(links, ",")
// Return error if invalid format
if len(parts) < 3 {
return false, fmt.Errorf("unable to parse link header - invalid format: "+
"'%s'; expected "+`'<>; rel="next", <>; rel="prev", <>; rel="last"'`, links)
}
// Check for "rel=next" in parts
for _, part := range parts {
rel := strings.Split(part, ";")
if len(rel) != 2 {
return false, fmt.Errorf("unable to parse link header - invalid format: "+
"'%s'; expected "+`'<>; rel="next", <>; rel="prev", <>; rel="last"'`, links)
}
if rel[1] == " rel=\"next\"" && rel[0] != "<>" {
return true, nil
}
}
// Return false if "rel=next" is not found
return false, nil
}
// ValidateID is a helper script for parseID() and parseUUID() that checks a string to determine if // it appears to be a valid ReadMe API object ID or Registry UUID. func ValidateID(id, prefix string, min_len, max_len int) (bool, string) { if !strings.HasPrefix(id, prefix+":") { return false, "" }
parts := strings.Split(id, ":")
if len(parts[1]) < min_len || len(parts[1]) > max_len {
return false, ""
}
return IDValidCharacters.MatchString(parts[1]), parts[1]
}
// ParseUUID checks a string to determine if it appears to be a valid ReadMe API Registry UUID. // // The provided parameter should be a ReadMe API Registry UUID prefixed with "uuid:". // // NOTE: The min and max lengths aren't certain or documented in the API. The UUID length varies. func ParseUUID(uuid string) (bool, string) { return ValidateID(uuid, "uuid", 10, 24) }
// ParseID checks a string to determine if it appears to be a valid ReadMe API object ID. // // The provided parameter should be a ReadMe API object ID prefixed with "id:". // // NOTE: The min and max lengths aren't certain or documented in the API. func ParseID(id string) (bool, string) { return ValidateID(id, "id", 20, 24) }