From 8acb1ee5bff1e7d0abb8d9f7138272ad3015530f Mon Sep 17 00:00:00 2001 From: Moby von Briesen Date: Wed, 17 May 2023 17:47:58 -0400 Subject: [PATCH] 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 --- private/apigen/docgen.go | 157 +++++++++ .../consoleweb/consoleapi/apidocs.gen.md | 313 ++++++++++++++++++ .../console/consoleweb/consoleapi/gen/main.go | 3 +- web/satellite/src/api/v0.gen.ts | 1 + 4 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 private/apigen/docgen.go create mode 100644 satellite/console/consoleweb/consoleapi/apidocs.gen.md diff --git a/private/apigen/docgen.go b/private/apigen/docgen.go new file mode 100644 index 000000000..0c98a3ce4 --- /dev/null +++ b/private/apigen/docgen.go @@ -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 + } +} diff --git a/satellite/console/consoleweb/consoleapi/apidocs.gen.md b/satellite/console/consoleweb/consoleapi/apidocs.gen.md new file mode 100644 index 000000000..654acfdec --- /dev/null +++ b/satellite/console/consoleweb/consoleapi/apidocs.gen.md @@ -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 +} + +``` + diff --git a/satellite/console/consoleweb/consoleapi/gen/main.go b/satellite/console/consoleweb/consoleapi/gen/main.go index b4307e400..7acb6376a 100644 --- a/satellite/console/consoleweb/consoleapi/gen/main.go +++ b/satellite/console/consoleweb/consoleapi/gen/main.go @@ -22,7 +22,7 @@ func main() { // definition for REST API a := &apigen.API{ Version: "v0", - Description: "", + Description: "Interacts with projects", PackageName: "consoleapi", } @@ -152,6 +152,7 @@ func main() { modroot := findModuleRootDir() 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.MustWriteDocs(filepath.Join(modroot, "satellite", "console", "consoleweb", "consoleapi", "apidocs.gen.md")) } func findModuleRootDir() string { diff --git a/web/satellite/src/api/v0.gen.ts b/web/satellite/src/api/v0.gen.ts index 92cf1f6c3..a453ba9f6 100644 --- a/web/satellite/src/api/v0.gen.ts +++ b/web/satellite/src/api/v0.gen.ts @@ -66,6 +66,7 @@ export class Project { userSpecifiedStorageLimit: MemorySize; userSpecifiedBandwidthLimit: MemorySize; segmentLimit: number; + defaultPlacement: number; } export class ProjectInfo {