private/apigen: Generate valid TypeScript code with anonymous types

The API generator didn't generate valid TypeScript code when using
Go anonymous types.

This commit fixes that issue creating names for anonymous types.

Change-Id: Ice0748d8650686e3d3979523b8f218dc20eade5a
This commit is contained in:
Ivan Fraixedes 2023-09-23 20:07:45 +02:00
parent a9901cc7d0
commit 00484429d6
No known key found for this signature in database
GPG Key ID: FB6101AFB5CB5AD5
6 changed files with 215 additions and 67 deletions

View File

@ -10,6 +10,9 @@ import (
"regexp" "regexp"
"strings" "strings"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"storj.io/storj/private/api" "storj.io/storj/private/api"
) )
@ -85,6 +88,17 @@ func (s *StringBuilder) Writelnf(format string, a ...interface{}) {
s.WriteString(fmt.Sprintf(format+"\n", a...)) s.WriteString(fmt.Sprintf(format+"\n", a...))
} }
// typeCustomName is a reflect.Type with a customized type's name.
type typeCustomName struct {
reflect.Type
name string
}
func (t typeCustomName) Name() string {
return t.name
}
// getElementaryType simplifies a Go type. // getElementaryType simplifies a Go type.
func getElementaryType(t reflect.Type) reflect.Type { func getElementaryType(t reflect.Type) reflect.Type {
switch t.Kind() { switch t.Kind() {
@ -114,3 +128,15 @@ func isNillableType(t reflect.Type) bool {
} }
return false return false
} }
// compoundTypeName create a name composed with base and parts, by joining base as it's and
// capitalizing each part.
func compoundTypeName(base string, parts ...string) string {
caser := cases.Title(language.Und)
titled := make([]string, len(parts))
for i := 0; i < len(parts); i++ {
titled[i] = caser.String(parts[i])
}
return base + strings.Join(titled, "")
}

View File

@ -57,6 +57,31 @@ type fullEndpoint struct {
Method string Method string
} }
// requestType guarantees to return a named Go type associated to the Endpoint.Request field.
func (fe fullEndpoint) requestType() reflect.Type {
t := reflect.TypeOf(fe.Request)
if t.Name() == "" {
name := fe.RequestName
if name == "" {
name = fe.MethodName
}
t = typeCustomName{Type: t, name: compoundTypeName(name, "Request")}
}
return t
}
// responseType guarantees to return a named Go type associated to the Endpoint.Response field.
func (fe fullEndpoint) responseType() reflect.Type {
t := reflect.TypeOf(fe.Response)
if t.Name() == "" {
t = typeCustomName{Type: t, name: compoundTypeName(fe.MethodName, "Response")}
}
return t
}
// EndpointGroup represents endpoints group. // EndpointGroup represents endpoints group.
// You should always create a group using API.Group because it validates the field values to // You should always create a group using API.Group because it validates the field values to
// guarantee correct code generation. // guarantee correct code generation.
@ -127,3 +152,16 @@ func NewParam(name string, instance interface{}) Param {
Type: reflect.TypeOf(instance), Type: reflect.TypeOf(instance),
} }
} }
// namedType guarantees to return a named Go type. where defines where the param is defined (e.g.
// path, query, etc.).
func (p Param) namedType(ep Endpoint, where string) reflect.Type {
if p.Type.Name() == "" {
return typeCustomName{
Type: p.Type,
name: compoundTypeName(ep.MethodName, where, "param", p.Name),
}
}
return p.Type
}

View File

@ -4,11 +4,11 @@
import { HttpClient } from '@/utils/httpClient'; import { HttpClient } from '@/utils/httpClient';
import { Time, UUID } from '@/types/common'; import { Time, UUID } from '@/types/common';
export class { export class UpdateContentRequest {
content: string; content: string;
} }
export class { export class UpdateContentResponse {
id: UUID; id: UUID;
date: Time; date: Time;
pathParam: string; pathParam: string;
@ -19,14 +19,14 @@ export class docsHttpApiV0 {
private readonly http: HttpClient = new HttpClient(); private readonly http: HttpClient = new HttpClient();
private readonly ROOT_PATH: string = '/api/v0/docs'; private readonly ROOT_PATH: string = '/api/v0/docs';
public async (request: , path: string, id: UUID, date: Time): Promise<> { public async UpdateContent(request: UpdateContentRequest, path: string, id: UUID, date: Time): Promise<UpdateContentResponse> {
const u = new URL(`${this.ROOT_PATH}/${path}`); const u = new URL(`${this.ROOT_PATH}/${path}`);
u.searchParams.set('id', id); u.searchParams.set('id', id);
u.searchParams.set('date', date); u.searchParams.set('date', date);
const path = u.toString(); const path = u.toString();
const response = await this.http.post(path, JSON.stringify(request)); const response = await this.http.post(path, JSON.stringify(request));
if (response.ok) { if (response.ok) {
return response.json().then((body) => body as ); return response.json().then((body) => body as UpdateContentResponse);
} }
const err = await response.json(); const err = await response.json();
throw new Error(err.error); throw new Error(err.error);

View File

@ -65,17 +65,19 @@ func (f *tsGenFile) generateTS() {
} }
func (f *tsGenFile) registerTypes() { func (f *tsGenFile) registerTypes() {
// TODO: what happen with path parameters?
for _, group := range f.api.EndpointGroups { for _, group := range f.api.EndpointGroups {
for _, method := range group.endpoints { for _, method := range group.endpoints {
if method.Request != nil { if method.Request != nil {
f.types.Register(reflect.TypeOf(method.Request)) f.types.Register(method.requestType())
} }
if method.Response != nil { if method.Response != nil {
f.types.Register(reflect.TypeOf(method.Response)) f.types.Register(method.responseType())
} }
if len(method.QueryParams) > 0 { if len(method.QueryParams) > 0 {
for _, p := range method.QueryParams { for _, p := range method.QueryParams {
t := getElementaryType(p.Type) // TODO: Is this call needed? this breaks the named type for slices and arrays and pointers.
t := getElementaryType(p.namedType(method.Endpoint, "query"))
f.types.Register(t) f.types.Register(t)
} }
} }
@ -95,15 +97,22 @@ func (f *tsGenFile) createAPIClient(group *EndpointGroup) {
returnStmt := "return" returnStmt := "return"
returnType := "void" returnType := "void"
if method.Response != nil { if method.Response != nil {
returnType = TypescriptTypeName(getElementaryType(reflect.TypeOf(method.Response))) respType := method.responseType()
if v := reflect.ValueOf(method.Response); v.Kind() == reflect.Array || v.Kind() == reflect.Slice { returnType = TypescriptTypeName(getElementaryType(respType))
// TODO: see if this is needed after we are creating types for array and slices
if respType.Kind() == reflect.Array || respType.Kind() == reflect.Slice {
returnType = fmt.Sprintf("Array<%s>", returnType) returnType = fmt.Sprintf("Array<%s>", returnType)
} }
returnStmt += fmt.Sprintf(" response.json().then((body) => body as %s)", returnType) returnStmt += fmt.Sprintf(" response.json().then((body) => body as %s)", returnType)
} }
returnStmt += ";" returnStmt += ";"
f.pf("\tpublic async %s(%s): Promise<%s> {", method.RequestName, funcArgs, returnType) methodName := method.RequestName
if methodName == "" {
methodName = method.MethodName
}
f.pf("\tpublic async %s(%s): Promise<%s> {", methodName, funcArgs, returnType)
if len(method.QueryParams) > 0 { if len(method.QueryParams) > 0 {
f.pf("\t\tconst u = new URL(`%s`);", path) f.pf("\t\tconst u = new URL(`%s`);", path)
for _, p := range method.QueryParams { for _, p := range method.QueryParams {
@ -140,17 +149,18 @@ func (f *tsGenFile) getArgsAndPath(method *fullEndpoint) (funcArgs, path string)
path = "${this.ROOT_PATH}" + path path = "${this.ROOT_PATH}" + path
if method.Request != nil { if method.Request != nil {
t := getElementaryType(reflect.TypeOf(method.Request)) // TODO: This should map slices and arrays because a request could be one of them.
t := getElementaryType(method.requestType())
funcArgs += fmt.Sprintf("request: %s, ", TypescriptTypeName(t)) funcArgs += fmt.Sprintf("request: %s, ", TypescriptTypeName(t))
} }
for _, p := range method.PathParams { for _, p := range method.PathParams {
funcArgs += fmt.Sprintf("%s: %s, ", p.Name, TypescriptTypeName(p.Type)) funcArgs += fmt.Sprintf("%s: %s, ", p.Name, TypescriptTypeName(p.namedType(method.Endpoint, "path")))
path += fmt.Sprintf("/${%s}", p.Name) path += fmt.Sprintf("/${%s}", p.Name)
} }
for _, p := range method.QueryParams { for _, p := range method.QueryParams {
funcArgs += fmt.Sprintf("%s: %s, ", p.Name, TypescriptTypeName(p.Type)) funcArgs += fmt.Sprintf("%s: %s, ", p.Name, TypescriptTypeName(p.namedType(method.Endpoint, "query")))
} }
path = strings.ReplaceAll(path, "//", "/") path = strings.ReplaceAll(path, "//", "/")

View File

@ -36,46 +36,76 @@ type Types struct {
// Register registers a type for generation. // Register registers a type for generation.
func (types *Types) Register(t reflect.Type) { func (types *Types) Register(t reflect.Type) {
if t.Name() == "" {
panic("register an anonymous type is not supported. All the types must have a name")
}
types.top[t] = struct{}{} types.top[t] = struct{}{}
} }
// All returns a slice containing every top-level type and their dependencies. // All returns a slice containing every top-level type and their dependencies.
//
// TODO: see how to have a better implementation for adding to seen, uniqueNames, and all.
func (types *Types) All() []reflect.Type { func (types *Types) All() []reflect.Type {
seen := map[reflect.Type]struct{}{} seen := map[reflect.Type]struct{}{}
uniqueNames := map[string]struct{}{}
all := []reflect.Type{} all := []reflect.Type{}
var walk func(t reflect.Type) var walk func(t reflect.Type, alternateTypeName string)
walk = func(t reflect.Type) { walk = func(t reflect.Type, altTypeName string) {
if _, ok := seen[t]; ok { if _, ok := seen[t]; ok {
return return
} }
seen[t] = struct{}{}
all = append(all, t) // Type isn't seen it but it has the same name than a seen it one.
// This cannot be because we would generate more than one TypeScript type with the same name.
if _, ok := uniqueNames[t.Name()]; ok {
panic(fmt.Sprintf("Found different types with the same name (%s)", t.Name()))
}
if _, ok := commonClasses[t]; ok { if _, ok := commonClasses[t]; ok {
seen[t] = struct{}{}
uniqueNames[t.Name()] = struct{}{}
all = append(all, t)
return return
} }
switch t.Kind() { switch k := t.Kind(); k {
// TODO: Does reflect.Ptr to be registered?, I believe that could skip it and only register
// the type that points to.
case reflect.Array, reflect.Ptr, reflect.Slice: case reflect.Array, reflect.Ptr, reflect.Slice:
walk(t.Elem()) t = typeCustomName{Type: t, name: compoundTypeName(altTypeName, k.String())}
seen[t] = struct{}{}
uniqueNames[t.Name()] = struct{}{}
all = append(all, t)
walk(t.Elem(), altTypeName)
case reflect.Struct: case reflect.Struct:
for i := 0; i < t.NumField(); i++ { if t.Name() == "" {
walk(t.Field(i).Type) t = typeCustomName{Type: t, name: altTypeName}
} }
case reflect.Bool:
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: seen[t] = struct{}{}
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: uniqueNames[t.Name()] = struct{}{}
case reflect.Float32, reflect.Float64: all = append(all, t)
case reflect.String:
break for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
walk(field.Type, compoundTypeName(altTypeName, field.Name))
}
case reflect.Bool,
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64,
reflect.String:
seen[t] = struct{}{}
uniqueNames[t.Name()] = struct{}{}
all = append(all, t)
default: default:
panic(fmt.Sprintf("type '%s' is not supported", t.Kind().String())) panic(fmt.Sprintf("type '%s' is not supported", t.Kind().String()))
} }
} }
for t := range types.top { for t := range types.top {
walk(t) walk(t, t.Name())
} }
sort.Slice(all, func(i, j int) bool { sort.Slice(all, func(i, j int) bool {
@ -96,6 +126,8 @@ func (types *Types) GenerateTypescriptDefinitions() string {
if _, ok := commonClasses[t]; ok { if _, ok := commonClasses[t]; ok {
return false return false
} }
// TODO, we should be able to handle arrays and slices as defined types now
return t.Kind() == reflect.Struct return t.Kind() == reflect.Struct
}) })
@ -154,6 +186,7 @@ func (types *Types) getTypescriptImports() string {
} }
// TypescriptTypeName gets the corresponding TypeScript type for a provided reflect.Type. // TypescriptTypeName gets the corresponding TypeScript type for a provided reflect.Type.
// If the type is an anonymous struct, it returns an empty string.
func TypescriptTypeName(t reflect.Type) string { func TypescriptTypeName(t reflect.Type) string {
if override, ok := commonClasses[t]; ok { if override, ok := commonClasses[t]; ok {
return override return override

View File

@ -10,7 +10,26 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
type testTypesValoration struct {
Points uint
}
func TestTypes(t *testing.T) { func TestTypes(t *testing.T) {
t.Run("Register panics with anonymous types", func(t *testing.T) {
types := NewTypes()
require.Panics(t, func() {
types.Register(reflect.TypeOf([2]int{}))
}, "array")
require.Panics(t, func() {
types.Register(reflect.TypeOf([]float64{}))
}, "slice")
require.Panics(t, func() {
types.Register(reflect.TypeOf(struct{}{}))
}, "struct")
})
t.Run("All returns nested types", func(t *testing.T) { t.Run("All returns nested types", func(t *testing.T) {
typesList := []reflect.Type{ typesList := []reflect.Type{
reflect.TypeOf(true), reflect.TypeOf(true),
@ -18,23 +37,7 @@ func TestTypes(t *testing.T) {
reflect.TypeOf(uint8(9)), reflect.TypeOf(uint8(9)),
reflect.TypeOf(float64(99.9)), reflect.TypeOf(float64(99.9)),
reflect.TypeOf("this is a test"), reflect.TypeOf("this is a test"),
reflect.TypeOf(struct { reflect.TypeOf(testTypesValoration{}),
Name string
Addresses []struct {
Address string
PO string
}
Job struct {
Company string
Position string
StartingYear uint
}
}{}),
reflect.TypeOf([]string{}),
reflect.TypeOf([]struct {
Path string
content string
}{}),
} }
types := NewTypes() types := NewTypes()
@ -44,13 +47,15 @@ func TestTypes(t *testing.T) {
allTypes := types.All() allTypes := types.All()
require.Len(t, allTypes, 13, "total number of types") require.Len(t, allTypes, 7, "total number of types")
require.Subset(t, allTypes, typesList, "all types contains at least the registered ones") require.Subset(t, allTypes, typesList, "all types contains at least the registered ones")
}) })
t.Run("anonymous structs", func(t *testing.T) { t.Run("All nested structs and slices", func(t *testing.T) {
typesList := []reflect.Type{ types := NewTypes()
reflect.TypeOf(struct { types.Register(
typeCustomName{
Type: reflect.TypeOf(struct {
Name string Name string
Addresses []struct { Addresses []struct {
Address string Address string
@ -64,25 +69,61 @@ func TestTypes(t *testing.T) {
Documents []struct { Documents []struct {
Path string Path string
Content string Content string
Valoration testTypesValoration
} }
}{}), }{}),
} name: "Response",
})
types := NewTypes()
for _, li := range typesList {
types.Register(li)
}
allTypes := types.All() allTypes := types.All()
require.Len(t, allTypes, 9, "total number of types")
require.Len(t, allTypes, 8, "total number of types")
require.Subset(t, allTypes, typesList, "all types contains at least the registered ones")
typesNames := []string{} typesNames := []string{}
for _, tp := range allTypes { for _, tp := range allTypes {
typesNames = append(typesNames, tp.Name()) typesNames = append(typesNames, tp.Name())
} }
require.ElementsMatch(t, []string{"", "", "", "", "", "", "string", "uint"}, typesNames) require.ElementsMatch(t, []string{
"string", "uint",
"Response",
"ResponseAddressesSlice", "ResponseAddresses",
"ResponseJob",
"ResponseDocumentsSlice", "ResponseDocuments", "testTypesValoration",
}, typesNames)
})
t.Run("All panic types without unique names", func(t *testing.T) {
types := NewTypes()
types.Register(typeCustomName{
Type: reflect.TypeOf(struct {
Name string
Addresses []struct {
Address string
PO string
}
Job struct {
Company string
Position string
StartingYear uint
}
Documents []struct {
Path string
Content string
Valoration testTypesValoration
}
}{}),
name: "Response",
})
types.Register(typeCustomName{
Type: reflect.TypeOf(struct {
Reference string
}{}),
name: "Response",
})
require.Panics(t, func() {
types.All()
})
}) })
} }