/
readme.go
449 lines (380 loc) · 14.6 KB
/
readme.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
// 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"
"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.OkStatusCode)
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, okCodes []int) (APIErrorResponse, error) {
var apiErrorResponse APIErrorResponse
for _, okCode := range okCodes {
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("API responded with a non-OK status: %v", responseCode)
}
// 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
}
// 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)
}