private/apigen: isolate TypeScript class generation

The code responsible for generating TypeScript classes has been
separated from the rest of the TypeScript generation code so that other
packages may take advantage of this functionality.

References #5494

Change-Id: I97eabd430bd6a5f748eafaf8b1d783977e75e660
This commit is contained in:
Jeremy Wharton 2023-02-22 04:08:34 -06:00 committed by Storj Robot
parent 4e94e6188c
commit 4d823e8166
6 changed files with 324 additions and 221 deletions

View File

@ -4,6 +4,10 @@
package apigen
import (
"fmt"
"reflect"
"strings"
"storj.io/storj/private/api"
)
@ -27,3 +31,42 @@ func (a *API) Group(name, prefix string) *EndpointGroup {
return group
}
// StringBuilder is an extension of strings.Builder that allows for writing formatted lines.
type StringBuilder struct{ strings.Builder }
// Writelnf formats arguments according to a format specifier
// and appends the resulting string to the StringBuilder's buffer.
func (s *StringBuilder) Writelnf(format string, a ...interface{}) {
s.WriteString(fmt.Sprintf(format+"\n", a...))
}
// getElementaryType simplifies a Go type.
func getElementaryType(t reflect.Type) reflect.Type {
switch t.Kind() {
case reflect.Array, reflect.Chan, reflect.Ptr, reflect.Slice:
return getElementaryType(t.Elem())
default:
return t
}
}
// filter returns a new slice of reflect.Type values that satisfy the given keep function.
func filter(types []reflect.Type, keep func(reflect.Type) bool) []reflect.Type {
filtered := make([]reflect.Type, 0, len(types))
for _, t := range types {
if keep(t) {
filtered = append(filtered, t)
}
}
return filtered
}
// isNillableType returns whether instances of the given type can be nil.
func isNillableType(t reflect.Type) bool {
switch t.Kind() {
case reflect.Chan, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
return true
}
return false
}

View File

@ -4,7 +4,6 @@
package apigen
import (
"fmt"
"go/format"
"os"
"reflect"
@ -37,11 +36,8 @@ func (a *API) MustWriteGo(path string) {
// generateGo generates api code and returns an output.
func (a *API) generateGo() ([]byte, error) {
var result string
pf := func(format string, a ...interface{}) {
result += fmt.Sprintf(format+"\n", a...)
}
result := &StringBuilder{}
pf := result.Writelnf
getPackageName := func(path string) string {
pathPackages := strings.Split(path, "/")
@ -81,13 +77,23 @@ func (a *API) generateGo() ([]byte, error) {
}
}
var getTypePackages func(t reflect.Type) []string
getTypePackages = func(t reflect.Type) []string {
t = getElementaryType(t)
if t.Kind() == reflect.Map {
pkgs := []string{getElementaryType(t.Key()).PkgPath()}
return append(pkgs, getTypePackages(t.Elem())...)
}
return []string{t.PkgPath()}
}
for _, group := range a.EndpointGroups {
for _, method := range group.endpoints {
if method.Request != nil {
i(getElementaryType(reflect.TypeOf(method.Request)).PkgPath())
i(getTypePackages(reflect.TypeOf(method.Request))...)
}
if method.Response != nil {
i(getElementaryType(reflect.TypeOf(method.Response)).PkgPath())
i(getTypePackages(reflect.TypeOf(method.Response))...)
}
}
}
@ -122,7 +128,7 @@ func (a *API) generateGo() ([]byte, error) {
if e.Response != nil {
responseType := reflect.TypeOf(e.Response)
returnParam := a.handleTypesPackage(responseType)
if responseType == getElementaryType(responseType) {
if !isNillableType(responseType) {
returnParam = "*" + returnParam
}
pf("%s(ctx context.Context, "+paramStr+") (%s, api.HTTPError)", e.MethodName, returnParam)
@ -186,7 +192,7 @@ func (a *API) generateGo() ([]byte, error) {
pf("w.Header().Set(\"Content-Type\", \"application/json\")")
pf("")
if err := handleParams(pf, i, endpoint.PathParams, endpoint.QueryParams); err != nil {
if err := handleParams(result, i, endpoint.PathParams, endpoint.QueryParams); err != nil {
return nil, err
}
@ -242,8 +248,9 @@ func (a *API) generateGo() ([]byte, error) {
}
}
fileBody := result
result = ""
fileBody := result.String()
result = &StringBuilder{}
pf = result.Writelnf
pf("// AUTOGENERATED BY private/apigen")
pf("// DO NOT EDIT.")
@ -271,9 +278,9 @@ func (a *API) generateGo() ([]byte, error) {
pf("")
}
result += fileBody
result.WriteString(fileBody)
output, err := format.Source([]byte(result))
output, err := format.Source([]byte(result.String()))
if err != nil {
return nil, err
}
@ -293,7 +300,8 @@ func (a *API) handleTypesPackage(t reflect.Type) string {
}
// handleParams handles parsing of URL path parameters or query parameters.
func handleParams(pf func(format string, a ...interface{}), i func(paths ...string), pathParams, queryParams []Param) error {
func handleParams(builder *StringBuilder, i func(paths ...string), pathParams, queryParams []Param) error {
pf := builder.Writelnf
pErrCheck := func() {
pf("if err != nil {")
pf("api.ServeError(h.log, w, http.StatusBadRequest, err)")
@ -373,13 +381,3 @@ func handleBody(pf func(format string, a ...interface{}), body interface{}) {
pf("}")
pf("")
}
// getElementaryType simplifies a Go type.
func getElementaryType(t reflect.Type) reflect.Type {
switch t.Kind() {
case reflect.Array, reflect.Chan, reflect.Map, reflect.Ptr, reflect.Slice:
return getElementaryType(t.Elem())
default:
return t
}
}

View File

@ -7,57 +7,11 @@ import (
"fmt"
"os"
"reflect"
"sort"
"strings"
"github.com/zeebo/errs"
)
// TODO maybe look at the Go structs MarshalJSON return type instead.
var tsTypeOverrides = map[string]string{
"uuid.UUID": "string",
"time.Time": "string",
"memory.Size": "string",
"[]uint8": "string", // e.g. []byte
}
// getBasicReflectType dereferences a pointer and gets the basic types from slices.
func getBasicReflectType(t reflect.Type) reflect.Type {
if t.Kind() == reflect.Ptr || t.Kind() == reflect.Slice {
t = t.Elem()
}
return t
}
// tsType gets the corresponding typescript type for a provided reflect.Type.
// Input is expected to be a (non pointer) struct or primitive.
func tsType(t reflect.Type) string {
override := tsTypeOverrides[t.String()]
if len(override) > 0 {
return override
}
switch t.Kind() {
case reflect.Ptr:
return tsType(t.Elem())
case reflect.Slice, reflect.Array:
return tsType(t.Elem()) + "[]"
case reflect.String:
return "string"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return "number"
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "number"
case reflect.Float32, reflect.Float64:
return "number"
case reflect.Bool:
return "boolean"
case reflect.Struct:
return t.Name()
default:
panic("unhandled type: " + t.Name())
}
}
// MustWriteTS writes generated TypeScript code into a file.
func (a *API) MustWriteTS(path string) {
f := newTSGenFile(path, a)
@ -73,31 +27,16 @@ func (a *API) MustWriteTS(path string) {
type tsGenFile struct {
result string
path string
// types is a map of struct types and their struct type dependencies.
// We use this to ensure all dependencies are written before their parent.
types map[reflect.Type][]reflect.Type
// typeList is a list of all struct types. We use this for sorting the types alphabetically
// to ensure that the diff is minimized when the file is regenerated.
typeList []reflect.Type
typesWritten map[reflect.Type]bool
api *API
api *API
types Types
}
func newTSGenFile(filepath string, api *API) *tsGenFile {
f := &tsGenFile{
path: filepath,
types: make(map[reflect.Type][]reflect.Type),
typesWritten: make(map[reflect.Type]bool),
api: api,
return &tsGenFile{
path: filepath,
api: api,
types: NewTypes(),
}
f.pf("// AUTOGENERATED BY private/apigen")
f.pf("// DO NOT EDIT.")
f.pf("")
f.pf("import { HttpClient } from '@/utils/httpClient'")
f.pf("")
return f
}
func (f *tsGenFile) pf(format string, a ...interface{}) {
@ -109,36 +48,14 @@ func (f *tsGenFile) write() error {
return os.WriteFile(f.path, []byte(content), 0644)
}
func (f *tsGenFile) getStructsFromType(t reflect.Type) {
t = getBasicReflectType(t)
override := tsTypeOverrides[t.String()]
if len(override) > 0 {
return
}
// if it is a struct, get any types needed from the fields
if t.Kind() == reflect.Struct {
if _, ok := f.types[t]; !ok {
f.types[t] = []reflect.Type{}
f.typeList = append(f.typeList, t)
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
f.getStructsFromType(field.Type)
if field.Type.Kind() == reflect.Struct {
deps := f.types[t]
deps = append(deps, getBasicReflectType(field.Type))
f.types[t] = deps
}
}
}
}
}
func (f *tsGenFile) generateTS() {
f.createClasses()
f.pf("// AUTOGENERATED BY private/apigen")
f.pf("// DO NOT EDIT.")
f.pf("")
f.pf("import { HttpClient } from '@/utils/httpClient';")
f.registerTypes()
f.result += f.types.GenerateTypescriptDefinitions()
for _, group := range f.api.EndpointGroups {
// Not sure if this is a good name
@ -146,87 +63,38 @@ func (f *tsGenFile) generateTS() {
}
}
func (f *tsGenFile) emitStruct(t reflect.Type) {
override := tsTypeOverrides[t.String()]
if len(override) > 0 {
return
}
if f.typesWritten[t] {
return
}
if t.Kind() != reflect.Struct {
// TODO: handle slices
// I'm not sure this is necessary. If it's not a struct then we don't need to create a TS class for it.
// We just use a JS array of the class.
return
}
for _, d := range f.types[t] {
if f.typesWritten[d] {
continue
}
f.emitStruct(d)
}
f.pf("class %s {", t.Name())
defer f.pf("}\n")
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
attributes := strings.Fields(field.Tag.Get("json"))
if len(attributes) == 0 || attributes[0] == "" {
panic(t.Name() + " missing json declaration")
}
if attributes[0] == "-" {
continue
}
f.pf("\t%s: %s;", attributes[0], tsType(field.Type))
}
f.typesWritten[t] = true
}
func (f *tsGenFile) createClasses() {
func (f *tsGenFile) registerTypes() {
for _, group := range f.api.EndpointGroups {
for _, method := range group.endpoints {
if method.Request != nil {
reqType := reflect.TypeOf(method.Request)
f.getStructsFromType(reqType)
f.types.Register(reflect.TypeOf(method.Request))
}
if method.Response != nil {
resType := reflect.TypeOf(method.Response)
f.getStructsFromType(resType)
f.types.Register(reflect.TypeOf(method.Response))
}
if len(method.QueryParams) > 0 {
for _, p := range method.QueryParams {
t := getBasicReflectType(p.Type)
f.getStructsFromType(t)
t := getElementaryType(p.Type)
f.types.Register(t)
}
}
}
}
sort.Slice(f.typeList, func(i, j int) bool {
return strings.Compare(f.typeList[i].Name(), f.typeList[j].Name()) < 0
})
for _, t := range f.typeList {
f.emitStruct(t)
}
}
func (f *tsGenFile) createAPIClient(group *EndpointGroup) {
f.pf("export class %sHttpApi%s {", group.Prefix, strings.ToUpper(f.api.Version))
f.pf("\nexport class %sHttpApi%s {", group.Prefix, strings.ToUpper(f.api.Version))
f.pf("\tprivate readonly http: HttpClient = new HttpClient();")
f.pf("\tprivate readonly ROOT_PATH: string = '/api/%s/%s';", f.api.Version, group.Prefix)
f.pf("")
for _, method := range group.endpoints {
f.pf("")
funcArgs, path := f.getArgsAndPath(method)
returnStmt := "return"
returnType := "void"
if method.Response != nil {
returnType = tsType(getBasicReflectType(reflect.TypeOf(method.Response)))
returnType = TypescriptTypeName(getElementaryType(reflect.TypeOf(method.Response)))
if v := reflect.ValueOf(method.Response); v.Kind() == reflect.Array || v.Kind() == reflect.Slice {
returnType = fmt.Sprintf("Array<%s>", returnType)
}
@ -246,9 +114,9 @@ func (f *tsGenFile) createAPIClient(group *EndpointGroup) {
f.pf("\t\tif (response.ok) {")
f.pf("\t\t\t%s", returnStmt)
f.pf("\t\t}")
f.pf("\t\tconst err = await response.json()")
f.pf("\t\tthrow new Error(err.error)")
f.pf("\t}\n")
f.pf("\t\tconst err = await response.json();")
f.pf("\t\tthrow new Error(err.error);")
f.pf("\t}")
}
f.pf("}")
}
@ -263,12 +131,12 @@ func (f *tsGenFile) getArgsAndPath(method *fullEndpoint) (funcArgs, path string)
path = "${this.ROOT_PATH}" + path
if method.Request != nil {
t := getBasicReflectType(reflect.TypeOf(method.Request))
funcArgs += fmt.Sprintf("request: %s, ", tsType(t))
t := getElementaryType(reflect.TypeOf(method.Request))
funcArgs += fmt.Sprintf("request: %s, ", TypescriptTypeName(t))
}
for _, p := range method.PathParams {
funcArgs += fmt.Sprintf("%s: %s, ", p.Name, tsType(p.Type))
funcArgs += fmt.Sprintf("%s: %s, ", p.Name, TypescriptTypeName(p.Type))
path += fmt.Sprintf("/${%s}", p.Name)
}
@ -279,7 +147,7 @@ func (f *tsGenFile) getArgsAndPath(method *fullEndpoint) (funcArgs, path string)
path += "&"
}
funcArgs += fmt.Sprintf("%s: %s, ", p.Name, tsType(p.Type))
funcArgs += fmt.Sprintf("%s: %s, ", p.Name, TypescriptTypeName(p.Type))
path += fmt.Sprintf("%s=${%s}", p.Name, p.Name)
}

189
private/apigen/tstypes.go Normal file
View File

@ -0,0 +1,189 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
package apigen
import (
"fmt"
"reflect"
"sort"
"strings"
"time"
"storj.io/common/memory"
"storj.io/common/uuid"
)
// commonPath is the path to the TypeScript module that common classes are imported from.
const commonPath = "@/types/common"
// commonClasses is a mapping of Go types to their corresponding TypeScript class names.
var commonClasses = map[reflect.Type]string{
reflect.TypeOf(memory.Size(0)): "MemorySize",
reflect.TypeOf(time.Time{}): "Time",
reflect.TypeOf(uuid.UUID{}): "UUID",
}
// NewTypes creates a new type definition generator.
func NewTypes() Types {
return Types{top: make(map[reflect.Type]struct{})}
}
// Types handles generating definitions from types.
type Types struct {
top map[reflect.Type]struct{}
}
// Register registers a type for generation.
func (types *Types) Register(t reflect.Type) {
types.top[t] = struct{}{}
}
// All returns a slice containing every top-level type and their dependencies.
func (types *Types) All() []reflect.Type {
seen := map[reflect.Type]struct{}{}
all := []reflect.Type{}
var walk func(t reflect.Type)
walk = func(t reflect.Type) {
if _, ok := seen[t]; ok {
return
}
seen[t] = struct{}{}
all = append(all, t)
if _, ok := commonClasses[t]; ok {
return
}
switch t.Kind() {
case reflect.Array, reflect.Ptr, reflect.Slice:
walk(t.Elem())
case reflect.Struct:
for i := 0; i < t.NumField(); i++ {
walk(t.Field(i).Type)
}
case reflect.Bool:
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
case reflect.Float32, reflect.Float64:
case reflect.String:
break
default:
panic(fmt.Sprintf("type '%s' is not supported", t.Kind().String()))
}
}
for t := range types.top {
walk(t)
}
sort.Slice(all, func(i, j int) bool {
return strings.Compare(all[i].Name(), all[j].Name()) < 0
})
return all
}
// GenerateTypescriptDefinitions returns the TypeScript class definitions corresponding to the registered Go types.
func (types *Types) GenerateTypescriptDefinitions() string {
var out StringBuilder
pf := out.Writelnf
pf(types.getTypescriptImports())
all := filter(types.All(), func(t reflect.Type) bool {
if _, ok := commonClasses[t]; ok {
return false
}
return t.Kind() == reflect.Struct
})
for _, t := range all {
func() {
pf("\nexport class %s {", t.Name())
defer pf("}")
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
attributes := strings.Fields(field.Tag.Get("json"))
if len(attributes) == 0 || attributes[0] == "" {
pathParts := strings.Split(t.PkgPath(), "/")
pkg := pathParts[len(pathParts)-1]
panic(fmt.Sprintf("(%s.%s).%s missing json declaration", pkg, t.Name(), field.Name))
}
jsonField := attributes[0]
if jsonField == "-" {
continue
}
isOptional := ""
if isNillableType(t) {
isOptional = "?"
}
pf("\t%s%s: %s;", jsonField, isOptional, TypescriptTypeName(field.Type))
}
}()
}
return out.String()
}
// getTypescriptImports returns the TypeScript import directive for the registered Go types.
func (types *Types) getTypescriptImports() string {
classes := []string{}
all := types.All()
for _, t := range all {
if tsClass, ok := commonClasses[t]; ok {
classes = append(classes, tsClass)
}
}
if len(classes) == 0 {
return ""
}
sort.Slice(classes, func(i, j int) bool {
return strings.Compare(classes[i], classes[j]) < 0
})
return fmt.Sprintf("import { %s } from '%s';", strings.Join(classes, ", "), commonPath)
}
// TypescriptTypeName gets the corresponding TypeScript type for a provided reflect.Type.
func TypescriptTypeName(t reflect.Type) string {
if override, ok := commonClasses[t]; ok {
return override
}
switch t.Kind() {
case reflect.Ptr:
return TypescriptTypeName(t.Elem())
case reflect.Slice:
// []byte ([]uint8) is marshaled as a base64 string
elem := t.Elem()
if elem.Kind() == reflect.Uint8 {
return "string"
}
fallthrough
case reflect.Array:
return TypescriptTypeName(t.Elem()) + "[]"
case reflect.String:
return "string"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return "number"
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "number"
case reflect.Float32, reflect.Float64:
return "number"
case reflect.Bool:
return "boolean"
case reflect.Struct:
return t.Name()
default:
panic("unhandled type: " + t.Name())
}
}

View File

@ -2,16 +2,17 @@
// DO NOT EDIT.
import { HttpClient } from '@/utils/httpClient';
import { MemorySize, Time, UUID } from '@/types/common';
class APIKeyInfo {
id: string;
projectId: string;
export class APIKeyInfo {
id: UUID;
projectId: UUID;
userAgent: string;
name: string;
createdAt: string;
createdAt: Time;
}
class APIKeyPage {
export class APIKeyPage {
apiKeys: APIKeyInfo[];
search: string;
limit: number;
@ -23,8 +24,8 @@ class APIKeyPage {
totalCount: number;
}
class BucketUsageRollup {
projectID: string;
export class BucketUsageRollup {
projectID: UUID;
bucketName: string;
totalStoredData: number;
totalSegments: number;
@ -33,49 +34,49 @@ class BucketUsageRollup {
repairEgress: number;
getEgress: number;
auditEgress: number;
since: string;
before: string;
since: Time;
before: Time;
}
class CreateAPIKeyRequest {
export class CreateAPIKeyRequest {
projectID: string;
name: string;
}
class CreateAPIKeyResponse {
export class CreateAPIKeyResponse {
key: string;
keyInfo: APIKeyInfo;
}
class Project {
id: string;
publicId: string;
export class Project {
id: UUID;
publicId: UUID;
name: string;
description: string;
userAgent: string;
ownerId: string;
ownerId: UUID;
rateLimit: number;
burstLimit: number;
maxBuckets: number;
createdAt: string;
createdAt: Time;
memberCount: number;
storageLimit: string;
bandwidthLimit: string;
userSpecifiedStorageLimit: string;
userSpecifiedBandwidthLimit: string;
storageLimit: MemorySize;
bandwidthLimit: MemorySize;
userSpecifiedStorageLimit: MemorySize;
userSpecifiedBandwidthLimit: MemorySize;
segmentLimit: number;
}
class ProjectInfo {
export class ProjectInfo {
name: string;
description: string;
storageLimit: string;
bandwidthLimit: string;
createdAt: string;
storageLimit: MemorySize;
bandwidthLimit: MemorySize;
createdAt: Time;
}
class ResponseUser {
id: string;
export class ResponseUser {
id: UUID;
fullName: string;
shortName: string;
email: string;
@ -105,7 +106,7 @@ export class projectsHttpApiV0 {
throw new Error(err.error);
}
public async updateProject(request: ProjectInfo, id: string): Promise<Project> {
public async updateProject(request: ProjectInfo, id: UUID): Promise<Project> {
const path = `${this.ROOT_PATH}/update/${id}`;
const response = await this.http.patch(path, JSON.stringify(request));
if (response.ok) {
@ -115,7 +116,7 @@ export class projectsHttpApiV0 {
throw new Error(err.error);
}
public async deleteProject(id: string): Promise<void> {
public async deleteProject(id: UUID): Promise<void> {
const path = `${this.ROOT_PATH}/delete/${id}`;
const response = await this.http.delete(path);
if (response.ok) {
@ -135,7 +136,7 @@ export class projectsHttpApiV0 {
throw new Error(err.error);
}
public async getBucketRollup(projectID: string, bucket: string, since: string, before: string): Promise<BucketUsageRollup> {
public async getBucketRollup(projectID: UUID, bucket: string, since: Time, before: Time): Promise<BucketUsageRollup> {
const path = `${this.ROOT_PATH}/bucket-rollup?projectID=${projectID}&bucket=${bucket}&since=${since}&before=${before}`;
const response = await this.http.get(path);
if (response.ok) {
@ -145,7 +146,7 @@ export class projectsHttpApiV0 {
throw new Error(err.error);
}
public async getBucketRollups(projectID: string, since: string, before: string): Promise<Array<BucketUsageRollup>> {
public async getBucketRollups(projectID: UUID, since: Time, before: Time): Promise<Array<BucketUsageRollup>> {
const path = `${this.ROOT_PATH}/bucket-rollups?projectID=${projectID}&since=${since}&before=${before}`;
const response = await this.http.get(path);
if (response.ok) {
@ -155,7 +156,7 @@ export class projectsHttpApiV0 {
throw new Error(err.error);
}
public async getAPIKeys(projectID: string, search: string, limit: number, page: number, order: number, orderDirection: number): Promise<APIKeyPage> {
public async getAPIKeys(projectID: UUID, search: string, limit: number, page: number, order: number, orderDirection: number): Promise<APIKeyPage> {
const path = `${this.ROOT_PATH}/apikeys/${projectID}?search=${search}&limit=${limit}&page=${page}&order=${order}&orderDirection=${orderDirection}`;
const response = await this.http.get(path);
if (response.ok) {
@ -164,8 +165,8 @@ export class projectsHttpApiV0 {
const err = await response.json();
throw new Error(err.error);
}
}
export class apikeysHttpApiV0 {
private readonly http: HttpClient = new HttpClient();
private readonly ROOT_PATH: string = '/api/v0/apikeys';
@ -180,7 +181,7 @@ export class apikeysHttpApiV0 {
throw new Error(err.error);
}
public async deleteAPIKey(id: string): Promise<void> {
public async deleteAPIKey(id: UUID): Promise<void> {
const path = `${this.ROOT_PATH}/delete/${id}`;
const response = await this.http.delete(path);
if (response.ok) {
@ -189,8 +190,8 @@ export class apikeysHttpApiV0 {
const err = await response.json();
throw new Error(err.error);
}
}
export class usersHttpApiV0 {
private readonly http: HttpClient = new HttpClient();
private readonly ROOT_PATH: string = '/api/v0/users';
@ -204,5 +205,4 @@ export class usersHttpApiV0 {
const err = await response.json();
throw new Error(err.error);
}
}

View File

@ -42,3 +42,8 @@ export enum PricingPlanType {
PARTNER = 'partner',
PRO = 'pro',
}
// TODO: fully implement these types and their methods according to their Go counterparts
export type UUID = string
export type MemorySize = string
export type Time = string