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"
"strings"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"storj.io/storj/private/api"
)
@ -85,6 +88,17 @@ func (s *StringBuilder) Writelnf(format string, a ...interface{}) {
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.
func getElementaryType(t reflect.Type) reflect.Type {
switch t.Kind() {
@ -114,3 +128,15 @@ func isNillableType(t reflect.Type) bool {
}
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
}
// 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.
// You should always create a group using API.Group because it validates the field values to
// guarantee correct code generation.
@ -127,3 +152,16 @@ func NewParam(name string, instance interface{}) Param {
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 { Time, UUID } from '@/types/common';
export class {
export class UpdateContentRequest {
content: string;
}
export class {
export class UpdateContentResponse {
id: UUID;
date: Time;
pathParam: string;
@ -19,14 +19,14 @@ export class docsHttpApiV0 {
private readonly http: HttpClient = new HttpClient();
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}`);
u.searchParams.set('id', id);
u.searchParams.set('date', date);
const path = u.toString();
const response = await this.http.post(path, JSON.stringify(request));
if (response.ok) {
return response.json().then((body) => body as );
return response.json().then((body) => body as UpdateContentResponse);
}
const err = await response.json();
throw new Error(err.error);

View File

@ -65,17 +65,19 @@ func (f *tsGenFile) generateTS() {
}
func (f *tsGenFile) registerTypes() {
// TODO: what happen with path parameters?
for _, group := range f.api.EndpointGroups {
for _, method := range group.endpoints {
if method.Request != nil {
f.types.Register(reflect.TypeOf(method.Request))
f.types.Register(method.requestType())
}
if method.Response != nil {
f.types.Register(reflect.TypeOf(method.Response))
f.types.Register(method.responseType())
}
if len(method.QueryParams) > 0 {
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)
}
}
@ -95,15 +97,22 @@ func (f *tsGenFile) createAPIClient(group *EndpointGroup) {
returnStmt := "return"
returnType := "void"
if method.Response != nil {
returnType = TypescriptTypeName(getElementaryType(reflect.TypeOf(method.Response)))
if v := reflect.ValueOf(method.Response); v.Kind() == reflect.Array || v.Kind() == reflect.Slice {
respType := method.responseType()
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)
}
returnStmt += fmt.Sprintf(" response.json().then((body) => body as %s)", returnType)
}
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 {
f.pf("\t\tconst u = new URL(`%s`);", path)
for _, p := range method.QueryParams {
@ -140,17 +149,18 @@ func (f *tsGenFile) getArgsAndPath(method *fullEndpoint) (funcArgs, path string)
path = "${this.ROOT_PATH}" + path
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))
}
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)
}
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, "//", "/")

View File

@ -36,46 +36,76 @@ type Types struct {
// Register registers a type for generation.
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{}{}
}
// 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 {
seen := map[reflect.Type]struct{}{}
uniqueNames := map[string]struct{}{}
all := []reflect.Type{}
var walk func(t reflect.Type)
walk = func(t reflect.Type) {
var walk func(t reflect.Type, alternateTypeName string)
walk = func(t reflect.Type, altTypeName string) {
if _, ok := seen[t]; ok {
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 {
seen[t] = struct{}{}
uniqueNames[t.Name()] = struct{}{}
all = append(all, t)
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:
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:
for i := 0; i < t.NumField(); i++ {
walk(t.Field(i).Type)
if t.Name() == "" {
t = typeCustomName{Type: t, name: altTypeName}
}
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
seen[t] = struct{}{}
uniqueNames[t.Name()] = struct{}{}
all = append(all, t)
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:
panic(fmt.Sprintf("type '%s' is not supported", t.Kind().String()))
}
}
for t := range types.top {
walk(t)
walk(t, t.Name())
}
sort.Slice(all, func(i, j int) bool {
@ -96,6 +126,8 @@ func (types *Types) GenerateTypescriptDefinitions() string {
if _, ok := commonClasses[t]; ok {
return false
}
// TODO, we should be able to handle arrays and slices as defined types now
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.
// If the type is an anonymous struct, it returns an empty string.
func TypescriptTypeName(t reflect.Type) string {
if override, ok := commonClasses[t]; ok {
return override

View File

@ -10,7 +10,26 @@ import (
"github.com/stretchr/testify/require"
)
type testTypesValoration struct {
Points uint
}
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) {
typesList := []reflect.Type{
reflect.TypeOf(true),
@ -18,23 +37,7 @@ func TestTypes(t *testing.T) {
reflect.TypeOf(uint8(9)),
reflect.TypeOf(float64(99.9)),
reflect.TypeOf("this is a test"),
reflect.TypeOf(struct {
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
}{}),
reflect.TypeOf(testTypesValoration{}),
}
types := NewTypes()
@ -44,13 +47,55 @@ func TestTypes(t *testing.T) {
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")
})
t.Run("anonymous structs", func(t *testing.T) {
typesList := []reflect.Type{
reflect.TypeOf(struct {
t.Run("All nested structs and slices", 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",
})
allTypes := types.All()
require.Len(t, allTypes, 9, "total number of types")
typesNames := []string{}
for _, tp := range allTypes {
typesNames = append(typesNames, tp.Name())
}
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
@ -62,27 +107,23 @@ func TestTypes(t *testing.T) {
StartingYear uint
}
Documents []struct {
Path string
Content string
Path string
Content string
Valoration testTypesValoration
}
}{}),
}
name: "Response",
})
types := NewTypes()
for _, li := range typesList {
types.Register(li)
}
types.Register(typeCustomName{
Type: reflect.TypeOf(struct {
Reference string
}{}),
name: "Response",
})
allTypes := types.All()
require.Len(t, allTypes, 8, "total number of types")
require.Subset(t, allTypes, typesList, "all types contains at least the registered ones")
typesNames := []string{}
for _, tp := range allTypes {
typesNames = append(typesNames, tp.Name())
}
require.ElementsMatch(t, []string{"", "", "", "", "", "", "string", "uint"}, typesNames)
require.Panics(t, func() {
types.All()
})
})
}