storj/private/apigen/docgen.go
Ivan Fraixedes 2d8f396eeb private/apigen: Make API base path configurable
Previously the base path for the API was hardcoded to `/api` and the
specified version.

This was not obvious that the generated code was setting that base path
and it was not flexible for serving the API under a different path than
`/api`.

We will likely need to set a different base path if we pretend to serve
the new back office API that we are going to implement alongside the
current admin API until the new back office is fully implemented and
verified that works properly.

This commit also fix add the base path of the endpoints to the
documentation because it was even more confusing for somebody that wants
to use the API having to find out them through looking to the generated
code.

Change-Id: I6efab6b6f3d295129d6f42f7fbba8c2dc19725f4
2023-08-28 14:35:01 +00:00

174 lines
5.1 KiB
Go

// Copyright (C) 2022 Storj Labs, Inc.
// See LICENSE for copying information.
package apigen
import (
"fmt"
"os"
"reflect"
"regexp"
"strings"
"time"
"github.com/zeebo/errs"
"storj.io/common/memory"
"storj.io/common/uuid"
)
// MustWriteDocs generates API documentation and writes it to the specified file path.
// If an error occurs, it panics.
func (api *API) MustWriteDocs(path string) {
docs := api.generateDocumentation()
err := os.WriteFile(path, []byte(docs), 0644)
if err != nil {
panic(errs.Wrap(err))
}
}
// generateDocumentation generates a string containing the API documentation.
func (api *API) generateDocumentation() string {
var doc strings.Builder
wf := func(format string, args ...any) { _, _ = fmt.Fprintf(&doc, format, args...) }
wf("# API Docs\n\n")
wf("**Description:** %s\n\n", api.Description)
wf("**Version:** `%s`\n\n", api.Version)
wf("<h2 id='list-of-endpoints'>List of Endpoints</h2>\n\n")
getEndpointLink := func(group, endpoint string) string {
fullName := group + "-" + endpoint
fullName = strings.ReplaceAll(fullName, " ", "-")
var nonAlphanumericRegex = regexp.MustCompile(`[^a-zA-Z0-9-]+`)
fullName = nonAlphanumericRegex.ReplaceAllString(fullName, "")
return strings.ToLower(fullName)
}
for _, group := range api.EndpointGroups {
wf("* %s\n", group.Name)
for _, endpoint := range group.endpoints {
wf(" * [%s](#%s)\n", endpoint.Name, getEndpointLink(group.Name, endpoint.Name))
}
}
wf("\n")
for _, group := range api.EndpointGroups {
for _, endpoint := range group.endpoints {
wf("<h3 id='%s'>%s (<a href='#list-of-endpoints'>go to full list</a>)</h3>\n\n", getEndpointLink(group.Name, endpoint.Name), endpoint.Name)
wf("%s\n\n", endpoint.Description)
wf("`%s %s/%s%s`\n\n", endpoint.Method, api.endpointBasePath(), group.Prefix, endpoint.Path)
if len(endpoint.QueryParams) > 0 {
wf("**Query Params:**\n\n")
wf("| name | type | elaboration |\n")
wf("|---|---|---|\n")
for _, param := range endpoint.QueryParams {
typeStr, elaboration := getDocType(param.Type)
wf("| `%s` | `%s` | %s |\n", param.Name, typeStr, elaboration)
}
wf("\n")
}
if len(endpoint.PathParams) > 0 {
wf("**Path Params:**\n\n")
wf("| name | type | elaboration |\n")
wf("|---|---|---|\n")
for _, param := range endpoint.PathParams {
typeStr, elaboration := getDocType(param.Type)
wf("| `%s` | `%s` | %s |\n", param.Name, typeStr, elaboration)
}
wf("\n")
}
requestType := reflect.TypeOf(endpoint.Request)
if requestType != nil {
wf("**Request body:**\n\n")
wf("```typescript\n%s\n```\n\n", getTypeNameRecursively(requestType, 0))
}
responseType := reflect.TypeOf(endpoint.Response)
if responseType != nil {
wf("**Response body:**\n\n")
wf("```typescript\n%s\n```\n\n", getTypeNameRecursively(responseType, 0))
}
}
}
return doc.String()
}
// getDocType returns the "basic" type to use in JSON, as well as examples for specific types that may require elaboration.
func getDocType(t reflect.Type) (typeStr, elaboration string) {
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
switch t {
case reflect.TypeOf(uuid.UUID{}):
return "string", "UUID formatted as `00000000-0000-0000-0000-000000000000`"
case reflect.TypeOf(time.Time{}):
return "string", "Date timestamp formatted as `2006-01-02T15:00:00Z`"
case reflect.TypeOf(memory.Size(0)):
return "string", "Amount of memory formatted as `15 GB`"
default:
switch t.Kind() {
case reflect.String:
return "string", ""
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "number", ""
case reflect.Float32, reflect.Float64:
return "number", ""
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return "number", ""
case reflect.Bool:
return "boolean", ""
}
}
return "unknown", ""
}
// getTypeNameRecursively gets a "full type" to document a struct or slice, including proper indentation of child properties.
func getTypeNameRecursively(t reflect.Type, level int) string {
prefix := ""
for i := 0; i < level; i++ {
prefix += "\t"
}
switch t.Kind() {
case reflect.Slice:
elemType := t.Elem()
if elemType.Kind() == reflect.Uint8 { // treat []byte as string in docs
return prefix + "string"
}
return fmt.Sprintf("%s[\n%s\n%s]\n", prefix, getTypeNameRecursively(elemType, level+1), prefix)
case reflect.Struct:
// some struct types may actually be documented as elementary types; check first
typeName, elaboration := getDocType(t)
if typeName != "unknown" {
toReturn := typeName
if len(elaboration) > 0 {
toReturn += " // " + elaboration
}
return toReturn
}
var fields []string
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
jsonTag := field.Tag.Get("json")
if jsonTag != "" && jsonTag != "-" {
fields = append(fields, prefix+"\t"+jsonTag+": "+getTypeNameRecursively(field.Type, level+1))
}
}
return fmt.Sprintf("%s{\n%s\n%s}\n", prefix, strings.Join(fields, "\n"), prefix)
default:
typeName, elaboration := getDocType(t)
toReturn := typeName
if len(elaboration) > 0 {
toReturn += " // " + elaboration
}
return toReturn
}
}