private/apigen: Support basic doc generation

Add some code to generate a basic markdown file documenting a generated
API. Generate this document for the API in
satellite/console/consoleweb/consoleapi/gen.

The documentation is not completely correct, as it may include some
values in the request body that are not actually usable by the
requester. This can be fixed by making sure all types used within the
generated API are properly annotated with `json` tags.

Issue: https://github.com/storj/storj-private/issues/244

Change-Id: I57b259967fb0db8f548b6598a10c825da15ba723
This commit is contained in:
Moby von Briesen 2023-05-17 17:47:58 -04:00 committed by Storj Robot
parent 5b1c22a1e7
commit 8acb1ee5bf
4 changed files with 473 additions and 1 deletions

157
private/apigen/docgen.go Normal file
View File

@ -0,0 +1,157 @@
// Copyright (C) 2022 Storj Labs, Inc.
// See LICENSE for copying information.
package apigen
import (
"fmt"
"os"
"reflect"
"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)
for _, group := range api.EndpointGroups {
for _, endpoint := range group.endpoints {
wf("## %s\n\n", endpoint.Name)
wf("%s\n\n", endpoint.Description)
wf("`%s /%s%s`\n\n", endpoint.Method, 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("```json\n%s\n```\n\n", getTypeNameRecursively(requestType, 0))
}
responseType := reflect.TypeOf(endpoint.Response)
if responseType != nil {
wf("**Response body:**\n\n")
wf("```json\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
}
}

View File

@ -0,0 +1,313 @@
# API Docs
**Description:** Interacts with projects
**Version:** `v0`
## Create new Project
Creates new Project with given info
`POST /projects/create`
**Request body:**
```json
{
name: string
description: string
storageLimit: string (Amount of memory formatted as `15 GB`)
bandwidthLimit: string (Amount of memory formatted as `15 GB`)
createdAt: string (Date timestamp formatted as `2006-01-02T15:00:00Z`)
}
```
**Response body:**
```json
unknown
```
## Update Project
Updates project with given info
`PATCH /projects/update/{id}`
**Path Params:**
| name | type | elaboration |
|---|---|---|
| `id` | `string` | UUID formatted as `00000000-0000-0000-0000-000000000000` |
**Request body:**
```json
{
name: string
description: string
storageLimit: string (Amount of memory formatted as `15 GB`)
bandwidthLimit: string (Amount of memory formatted as `15 GB`)
createdAt: string (Date timestamp formatted as `2006-01-02T15:00:00Z`)
}
```
**Response body:**
```json
{
id: string (UUID formatted as `00000000-0000-0000-0000-000000000000`)
publicId: string (UUID formatted as `00000000-0000-0000-0000-000000000000`)
name: string
description: string
userAgent: string
ownerId: string (UUID formatted as `00000000-0000-0000-0000-000000000000`)
rateLimit: number
burstLimit: number
maxBuckets: number
createdAt: string (Date timestamp formatted as `2006-01-02T15:00:00Z`)
memberCount: number
storageLimit: string (Amount of memory formatted as `15 GB`)
bandwidthLimit: string (Amount of memory formatted as `15 GB`)
userSpecifiedStorageLimit: string (Amount of memory formatted as `15 GB`)
userSpecifiedBandwidthLimit: string (Amount of memory formatted as `15 GB`)
segmentLimit: number
defaultPlacement: number
}
```
## Delete Project
Deletes project by id
`DELETE /projects/delete/{id}`
**Path Params:**
| name | type | elaboration |
|---|---|---|
| `id` | `string` | UUID formatted as `00000000-0000-0000-0000-000000000000` |
## Get Projects
Gets all projects user has
`GET /projects/`
**Response body:**
```json
[
{
id: string (UUID formatted as `00000000-0000-0000-0000-000000000000`)
publicId: string (UUID formatted as `00000000-0000-0000-0000-000000000000`)
name: string
description: string
userAgent: string
ownerId: string (UUID formatted as `00000000-0000-0000-0000-000000000000`)
rateLimit: number
burstLimit: number
maxBuckets: number
createdAt: string (Date timestamp formatted as `2006-01-02T15:00:00Z`)
memberCount: number
storageLimit: string (Amount of memory formatted as `15 GB`)
bandwidthLimit: string (Amount of memory formatted as `15 GB`)
userSpecifiedStorageLimit: string (Amount of memory formatted as `15 GB`)
userSpecifiedBandwidthLimit: string (Amount of memory formatted as `15 GB`)
segmentLimit: number
defaultPlacement: number
}
]
```
## Get Project's Single Bucket Usage
Gets project's single bucket usage by bucket ID
`GET /projects/bucket-rollup`
**Query Params:**
| name | type | elaboration |
|---|---|---|
| `projectID` | `string` | UUID formatted as `00000000-0000-0000-0000-000000000000` |
| `bucket` | `string` | |
| `since` | `string` | Date timestamp formatted as `2006-01-02T15:00:00Z` |
| `before` | `string` | Date timestamp formatted as `2006-01-02T15:00:00Z` |
**Response body:**
```json
{
projectID: string (UUID formatted as `00000000-0000-0000-0000-000000000000`)
bucketName: string
totalStoredData: number
totalSegments: number
objectCount: number
metadataSize: number
repairEgress: number
getEgress: number
auditEgress: number
since: string (Date timestamp formatted as `2006-01-02T15:00:00Z`)
before: string (Date timestamp formatted as `2006-01-02T15:00:00Z`)
}
```
## Get Project's All Buckets Usage
Gets project's all buckets usage
`GET /projects/bucket-rollups`
**Query Params:**
| name | type | elaboration |
|---|---|---|
| `projectID` | `string` | UUID formatted as `00000000-0000-0000-0000-000000000000` |
| `since` | `string` | Date timestamp formatted as `2006-01-02T15:00:00Z` |
| `before` | `string` | Date timestamp formatted as `2006-01-02T15:00:00Z` |
**Response body:**
```json
[
{
projectID: string (UUID formatted as `00000000-0000-0000-0000-000000000000`)
bucketName: string
totalStoredData: number
totalSegments: number
objectCount: number
metadataSize: number
repairEgress: number
getEgress: number
auditEgress: number
since: string (Date timestamp formatted as `2006-01-02T15:00:00Z`)
before: string (Date timestamp formatted as `2006-01-02T15:00:00Z`)
}
]
```
## Get Project's API Keys
Gets API keys by project ID
`GET /projects/apikeys/{projectID}`
**Query Params:**
| name | type | elaboration |
|---|---|---|
| `search` | `string` | |
| `limit` | `number` | |
| `page` | `number` | |
| `order` | `number` | |
| `orderDirection` | `number` | |
**Path Params:**
| name | type | elaboration |
|---|---|---|
| `projectID` | `string` | UUID formatted as `00000000-0000-0000-0000-000000000000` |
**Response body:**
```json
{
apiKeys: [
{
id: string (UUID formatted as `00000000-0000-0000-0000-000000000000`)
projectId: string (UUID formatted as `00000000-0000-0000-0000-000000000000`)
projectPublicId: string (UUID formatted as `00000000-0000-0000-0000-000000000000`)
userAgent: string
name: string
createdAt: string (Date timestamp formatted as `2006-01-02T15:00:00Z`)
}
]
search: string
limit: number
order: number
orderDirection: number
offset: number
pageCount: number
currentPage: number
totalCount: number
}
```
## Create new macaroon API key
Creates new macaroon API key with given info
`POST /apikeys/create`
**Request body:**
```json
{
projectID: string
name: string
}
```
**Response body:**
```json
{
key: string
keyInfo: unknown
}
```
## Delete API Key
Deletes macaroon API key by id
`DELETE /apikeys/delete/{id}`
**Path Params:**
| name | type | elaboration |
|---|---|---|
| `id` | `string` | UUID formatted as `00000000-0000-0000-0000-000000000000` |
## Get User
Gets User by request context
`GET /users/`
**Response body:**
```json
{
id: string (UUID formatted as `00000000-0000-0000-0000-000000000000`)
fullName: string
shortName: string
email: string
userAgent: string
projectLimit: number
isProfessional: boolean
position: string
companyName: string
employeeCount: string
haveSalesContact: boolean
paidTier: boolean
isMFAEnabled: boolean
mfaRecoveryCodeCount: number
}
```

View File

@ -22,7 +22,7 @@ func main() {
// definition for REST API // definition for REST API
a := &apigen.API{ a := &apigen.API{
Version: "v0", Version: "v0",
Description: "", Description: "Interacts with projects",
PackageName: "consoleapi", PackageName: "consoleapi",
} }
@ -152,6 +152,7 @@ func main() {
modroot := findModuleRootDir() modroot := findModuleRootDir()
a.MustWriteGo(filepath.Join(modroot, "satellite", "console", "consoleweb", "consoleapi", "api.gen.go")) a.MustWriteGo(filepath.Join(modroot, "satellite", "console", "consoleweb", "consoleapi", "api.gen.go"))
a.MustWriteTS(filepath.Join(modroot, "web", "satellite", "src", "api", a.Version+".gen.ts")) a.MustWriteTS(filepath.Join(modroot, "web", "satellite", "src", "api", a.Version+".gen.ts"))
a.MustWriteDocs(filepath.Join(modroot, "satellite", "console", "consoleweb", "consoleapi", "apidocs.gen.md"))
} }
func findModuleRootDir() string { func findModuleRootDir() string {

View File

@ -66,6 +66,7 @@ export class Project {
userSpecifiedStorageLimit: MemorySize; userSpecifiedStorageLimit: MemorySize;
userSpecifiedBandwidthLimit: MemorySize; userSpecifiedBandwidthLimit: MemorySize;
segmentLimit: number; segmentLimit: number;
defaultPlacement: number;
} }
export class ProjectInfo { export class ProjectInfo {