diff --git a/README.md b/README.md index e6c2711..61f35c0 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,9 @@ # [bel](https://en.wikipedia.org/wiki/Bel_(mythology)) Generate TypeScript interfaces from Go structs/interfaces - useful for JSON RPC -[![Go Report Card](https://goreportcard.com/badge/github.com/32leaves/bel)](https://goreportcard.com/report/github.com/32leaves/bel) -[![GoDoc](https://godoc.org/github.com/32leaves/bel?status.svg)](https://godoc.org/github.com/32leaves/bel) -[![gocover.run](https://gocover.run/github.com/32leaves/bel.svg?style=flat&tag=1.10)](https://gocover.run?tag=1.10&repo=github.com%2F32leaves%2Fbel) -[![Stability: Active](https://masterminds.github.io/stability/active.svg)](https://masterminds.github.io/stability/active.html) +**This is a fork of https://github.com/csweichel/bel. Thanks go to him for most of the work.** -[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io#github.com/32leaves/bel) - -`bel` is used in production in https://gitpod.io. +`bel` is used in production in https://conclude.io. ## Getting started `bel` is easy to use. There are two steps involved: extract the Typescript information, and generate the Typescript code. @@ -16,7 +11,7 @@ Generate TypeScript interfaces from Go structs/interfaces - useful for JSON RPC package main import ( - "github.com/32leaves/bel" + "github.com/laknoll/bel" ) type Demo struct { @@ -61,7 +56,7 @@ package main import ( "os" - "github.com/32leaves/bel" + "github.com/laknoll/bel" ) type DemoService interface { @@ -90,7 +85,7 @@ export interface DemoService { ``` ## Advanced Usage -You can try all the examples mentioned below in [Gitpod](https://gitpod.io#github.com/32leaves/bel). +You can try all the examples mentioned below in [Gitpod](https://gitpod.io#github.com/laknoll/bel). ### FollowStructs Follow structs enable the transitive generation of types. See [examples/embed-structs.go](examples/follow-structs.go). @@ -137,4 +132,4 @@ You can configure the `io.Writer` that _bel_ uses using `bel.GenerateOutputTo`. # Contributing All contributions/PR/issue/beer are welcome ❤️. -It's easiest to work with _bel_ using Gitpod: [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io#github.com/32leaves/bel) +It's easiest to work with _bel_ using Gitpod: [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io#github.com/laknoll/bel) diff --git a/doc_test.go b/doc_test.go index 78858e6..3e8d7c2 100644 --- a/doc_test.go +++ b/doc_test.go @@ -1,6 +1,7 @@ package bel import ( + "sort" "testing" "github.com/go-test/deep" @@ -19,7 +20,7 @@ type DoSomethingReq struct { } func TestParsedSourceDocHandler(t *testing.T) { - handler, err := NewParsedSourceDocHandler(".", "github.com/32leaves/") + handler, err := NewParsedSourceDocHandler(".", "github.com/laknoll/") if err != nil { t.Error(err) return @@ -30,6 +31,7 @@ func TestParsedSourceDocHandler(t *testing.T) { t.Error(err) return } + sort.Slice(extract, func(ia, ib int) bool { return extract[ia].Name < extract[ib].Name }) expectation := []TypescriptType{ { diff --git a/enum_test.go b/enum_test.go index 8979c9a..e8379df 100644 --- a/enum_test.go +++ b/enum_test.go @@ -32,6 +32,10 @@ type StructWithEnum struct { Baz string } +type StructWithEnumMap struct { + EnumMap map[MyEnum]string +} + func TestParseStringEnum(t *testing.T) { handler, err := NewParsedSourceEnumHandler(".") if err != nil { @@ -171,8 +175,9 @@ func TestExtractIntEnum(t *testing.T) { TypedElement: TypedElement{ Name: "Bar", Type: TypescriptType{ - Name: "MyOtherEnum", - Kind: TypescriptKind("simple"), + Name: "MyOtherEnum", + Kind: TypescriptKind("simple"), + IsEnum: true, }, }, }, @@ -189,14 +194,84 @@ func TestExtractIntEnum(t *testing.T) { TypedElement: TypedElement{ Name: "Foo", Type: TypescriptType{ - Name: "MyEnum", - Kind: TypescriptKind("simple"), + Name: "MyEnum", + Kind: TypescriptKind("simple"), + IsEnum: true, + }, + }, + }, + }, + }, + } + diff := deep.Equal(expectation, extract) + for _, d := range diff { + t.Error(d) + } +} + +func TestEnumInMap(t *testing.T) { + handler, err := NewParsedSourceEnumHandler(".") + if err != nil { + t.Error(err) + return + } + + extract, err := Extract(StructWithEnumMap{}, WithEnumerations(handler)) + if err != nil { + t.Error(err) + return + } + sort.Slice(extract, func(ia, ib int) bool { return extract[ia].Name < extract[ib].Name }) + for i := range extract { + sort.Slice(extract[i].Members, func(ia, ib int) bool { return extract[i].Members[ia].Name < extract[i].Members[ib].Name }) + sort.Slice(extract[i].EnumMembers, func(ia, ib int) bool { return extract[i].EnumMembers[ia].Value < extract[i].EnumMembers[ib].Value }) + } + + expectation := []TypescriptType{ + { + Name: "MyEnum", + Kind: TypescriptKind("enum"), + EnumMembers: []TypescriptEnumMember{ + { + Name: "MemberOne", + Value: "\"member-one\"", + }, + { + Name: "MemberThree", + Value: "\"member-three\"", + }, + { + Name: "MemberTwo", + Value: "\"member-two\"", + }, + }, + }, + { + Name: "StructWithEnumMap", + Kind: TypescriptKind("iface"), + Members: []TypescriptMember{ + { + TypedElement: TypedElement{ + Name: "EnumMap", + Type: TypescriptType{ + Kind: TypescriptKind("map"), + Params: []TypescriptType{ + { + Name: "MyEnum", + Kind: TypescriptKind("simple"), + IsEnum: true, + }, { + Name: "string", + Kind: "simple", + }, + }, }, }, }, }, }, } + diff := deep.Equal(expectation, extract) for _, d := range diff { t.Error(d) diff --git a/examples/code-generation.go b/examples/code-generation.go index eba4ecd..4a3be1c 100644 --- a/examples/code-generation.go +++ b/examples/code-generation.go @@ -3,7 +3,7 @@ package main import ( "os" - "github.com/32leaves/bel" + "github.com/laknoll/bel" ) // HelloWorld is a simple example struct diff --git a/examples/custom-namer.go b/examples/custom-namer.go index b785e50..8a313d9 100644 --- a/examples/custom-namer.go +++ b/examples/custom-namer.go @@ -3,7 +3,7 @@ package main import ( "reflect" - "github.com/32leaves/bel" + "github.com/laknoll/bel" ) // ThisStructsName is an empty struct diff --git a/examples/embed-structs.go b/examples/embed-structs.go index f3504de..6f1f4d8 100644 --- a/examples/embed-structs.go +++ b/examples/embed-structs.go @@ -1,7 +1,7 @@ package main import ( - "github.com/32leaves/bel" + "github.com/laknoll/bel" ) // ReferencedStruct is a struct referenced by HasAReference diff --git a/examples/enums.go b/examples/enums.go index 6aec8a0..e4cfa3a 100644 --- a/examples/enums.go +++ b/examples/enums.go @@ -1,7 +1,7 @@ package main import ( - "github.com/32leaves/bel" + "github.com/laknoll/bel" ) // StringEnum is an enumeration with string values diff --git a/examples/follow-structs.go b/examples/follow-structs.go index 9843c57..29c02a3 100644 --- a/examples/follow-structs.go +++ b/examples/follow-structs.go @@ -1,7 +1,7 @@ package main import ( - "github.com/32leaves/bel" + "github.com/laknoll/bel" ) // User is a struct describing users diff --git a/examples/name-anon-structs.go b/examples/name-anon-structs.go index bc8a1d6..2fb98fa 100644 --- a/examples/name-anon-structs.go +++ b/examples/name-anon-structs.go @@ -4,7 +4,7 @@ import ( "fmt" "reflect" - "github.com/32leaves/bel" + "github.com/laknoll/bel" ) // NestedStuff contains a nested anonymous enum diff --git a/examples/sort-alphabetically.go b/examples/sort-alphabetically.go index 5c2abb3..00fd412 100644 --- a/examples/sort-alphabetically.go +++ b/examples/sort-alphabetically.go @@ -1,7 +1,7 @@ package main import ( - "github.com/32leaves/bel" + "github.com/laknoll/bel" ) // StructB is a structure with non-alphabetically sorted fields diff --git a/examples/with-documentation.go b/examples/with-documentation.go index 6bdfeb8..399e0a6 100644 --- a/examples/with-documentation.go +++ b/examples/with-documentation.go @@ -1,12 +1,12 @@ package main import ( - "github.com/32leaves/bel" + "github.com/laknoll/bel" ) // WithDocumentation demonstrates how to extract documentation func WithDocumentation() { - handler, err := bel.NewParsedSourceDocHandler(".", "github.com/32leaves") + handler, err := bel.NewParsedSourceDocHandler(".", "github.com/laknoll") if err != nil { panic(err) } diff --git a/extract.go b/extract.go index 5fb85db..316ae7f 100644 --- a/extract.go +++ b/extract.go @@ -5,8 +5,8 @@ import ( "reflect" "sort" "strings" - - "github.com/iancoleman/strcase" + "unicode" + "unicode/utf8" ) // ExtractOption is an option used with the Extract function @@ -27,6 +27,7 @@ type extractor struct { sorter func(a, b interface{}) bool anonStructNamer AnonStructNamer typeNamer TypeNamer + primitiveNamer TypeNamer enumHandler EnumHandler docHandler DocHandler @@ -66,6 +67,16 @@ func CustomNamer(namer TypeNamer) ExtractOption { } } +// PrimitiveNamer sets a custom function for translating Golang naming convention +// to Typescript naming convention for primitive types. This function does not have to translate +// the type names, just the way they are written. +// Returning an empty string will cause bel to use the underlying primitive type +func PrimitiveNamer(namer TypeNamer) ExtractOption { + return func(e *extractor) { + e.primitiveNamer = namer + } +} + // WithEnumerations configures an enum handler which detects and extracts enums from // types and constants. func WithEnumerations(handler EnumHandler) ExtractOption { @@ -113,13 +124,36 @@ func (e *extractor) addResult(t *TypescriptType) { e.result[t.Name] = *t } +// typeScriptTypeName turns a Go type name into a valid TypeScript identifier: it +// drops runes TypeScript does not allow in an identifier and upper-cases the first +// one. Dropping matters for instantiated generics, whose reflect name carries the +// type arguments, e.g. Pair[string,int] becomes Pairstringint. +// +// Unlike a general camel-casing this leaves the rest of the name alone, so acronyms +// survive: HTTPServer stays HTTPServer rather than becoming Httpserver. +func typeScriptTypeName(s string) string { + var name strings.Builder + for _, r := range s { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '$' { + name.WriteRune(r) + } + } + + res := name.String() + if res == "" { + return res + } + r, size := utf8.DecodeRuneInString(res) + return string(unicode.ToUpper(r)) + res[size:] +} + // Extract uses reflection to extract the information required to generate Typescript code func Extract(s interface{}, opts ...ExtractOption) ([]TypescriptType, error) { e := &extractor{ embedStructs: false, followStructs: false, typeNamer: func(t reflect.Type) string { - return strcase.ToCamel(t.Name()) + return typeScriptTypeName(t.Name()) }, docHandler: (*nullDocHandler)(nil), } @@ -285,7 +319,9 @@ func (e *extractor) extractStructField(t reflect.StructField) (*TypescriptMember segments = segments[1:] } for _, seg := range segments { - if seg == "omitempty" { + // omitzero is the Go 1.24+/encoding/json/v2 equivalent of omitempty: + // both make the field absent from the JSON output. + if seg == "omitempty" || seg == "omitzero" { optional = true } } @@ -353,7 +389,7 @@ func (e *extractor) getType(ttype reflect.Type, t *reflect.StructField) (*Typesc EnumMembers: em, } e.addResult(enum) - tstype = &TypescriptType{Name: e.typeNamer(ttype), Kind: TypescriptSimpleKind} + tstype = &TypescriptType{Name: e.typeNamer(ttype), Kind: TypescriptSimpleKind, IsEnum: true} } else { res, err := e.getPrimitiveType(ttype) if err != nil { @@ -373,6 +409,12 @@ func (e *extractor) getPrimitiveType(t reflect.Type) (*TypescriptType, error) { } } + if e.primitiveNamer != nil { + if name := e.primitiveNamer(t); name != "" { + return mktype(name), nil + } + } + kind := t.Kind() switch kind { case reflect.Bool: diff --git a/extract_test.go b/extract_test.go index c45cc2f..322e130 100644 --- a/extract_test.go +++ b/extract_test.go @@ -51,6 +51,13 @@ type NestedStruct struct { } } +type OmitZeroStruct struct { + StringField string + OptionalField string `json:",omitzero"` + NamedOptionalField int32 `json:"thisIsOptional,omitzero"` + BothField string `json:"both,omitempty,omitzero"` +} + // AnotherTestStruct is just yet another struct type AnotherTestStruct struct { // Foo has some documentation @@ -139,6 +146,93 @@ func TestExtractStruct(t *testing.T) { } } +func TestTypeScriptTypeName(t *testing.T) { + tests := []struct { + Input string + Expectation string + }{ + {"", ""}, + {"MyTestStruct", "MyTestStruct"}, + {"fooBar", "FooBar"}, + {"t", "T"}, + // acronyms must stay intact + {"ID", "ID"}, + {"HTTPServer", "HTTPServer"}, + {"ATypeStartingWithA", "ATypeStartingWithA"}, + // instantiated generics carry their type arguments in the reflect name + {"Pair[string,int]", "Pairstringint"}, + {"Pair[main.Inner,[]uint8]", "PairmainInneruint8"}, + // legal in a TypeScript identifier, hence kept + {"Under_Score", "Under_Score"}, + {"ünicode", "Ünicode"}, + } + for _, test := range tests { + if act := typeScriptTypeName(test.Input); act != test.Expectation { + t.Errorf("typeScriptTypeName(%q) == %q, expected %q", test.Input, act, test.Expectation) + } + } +} + +func TestExtractOmitZero(t *testing.T) { + extract, err := Extract(OmitZeroStruct{}) + if err != nil { + t.Error(err) + return + } + + expectation := []TypescriptType{ + { + Name: "OmitZeroStruct", + Kind: TypescriptKind("iface"), + Members: []TypescriptMember{ + { + TypedElement: TypedElement{ + Name: "StringField", + Type: TypescriptType{ + Name: "string", + Kind: TypescriptKind("simple"), + }, + }, + }, + { + TypedElement: TypedElement{ + Name: "OptionalField", + Type: TypescriptType{ + Name: "string", + Kind: TypescriptKind("simple"), + }, + }, + IsOptional: true, + }, + { + TypedElement: TypedElement{ + Name: "thisIsOptional", + Type: TypescriptType{ + Name: "number", + Kind: TypescriptKind("simple"), + }, + }, + IsOptional: true, + }, + { + TypedElement: TypedElement{ + Name: "both", + Type: TypescriptType{ + Name: "string", + Kind: TypescriptKind("simple"), + }, + }, + IsOptional: true, + }, + }, + }, + } + diff := deep.Equal(expectation, extract) + for _, d := range diff { + t.Error(d) + } +} + func TestNameAnonStructs(t *testing.T) { namer := func(t reflect.StructField) string { return t.Name diff --git a/generator.go b/generator.go index 24625d2..dc10639 100644 --- a/generator.go +++ b/generator.go @@ -23,13 +23,13 @@ const interfaceTemplate = ` { {{ range .Members -}} {{- template "comment" . -}} - {{ .Name }}{{ if .IsOptional }}?{{ end }}{{ if .IsFunction }}({{ template "args" . }}){{ end }}: {{ subt .Type | default "void" }} + {{ validTypeScriptName .Name }}{{ if .IsOptional }}?{{ end }}{{ if .IsFunction }}({{ template "args" . }}){{ end }}: {{ subt .Type | default "void" }} {{ end }} } {{ end -}} {{- define "args" }}{{ range $idx, $val := .Args }}{{ if eq $idx 0 }}{{ else }}, {{ end }}{{ .Name }}: {{ subt .Type }}{{ end }}{{ end -}} {{- define "simple" }}{{ .Name }}{{ end -}} -{{- define "map" }}{ [key: {{ subt (mapKeyType .) }}]: {{ subt (mapValType .) }} }{{ end -}} +{{- define "map" }}{ [{{ if ( isEnumKey . ) }}key in{{ else }}key:{{ end }} {{ subt (mapKeyType .) }}]: {{ subt (mapValType .) }} }{{ end -}} {{- define "array" }}{{ subt (arrType .) }}[]{{ end -}} {{- define "root-enum" }}{{- template "comment" . -}}export enum {{ .Name }} { {{ range .EnumMembers }}{{ .Name }} = {{ .Value }}, @@ -96,7 +96,7 @@ func GeneratePreamble(preamble string) GenerateOption { func Render(types []TypescriptType, cfg ...GenerateOption) error { opts := generateOptions{ out: os.Stdout, - Preamble: fmt.Sprintf("// generated using github.com/32leaves/bel on %s\n// DO NOT MODIFY\n", time.Now()), + Preamble: fmt.Sprintf("// generated using github.com/laknoll/bel on %s\n// DO NOT MODIFY\n", time.Now()), } for _, c := range cfg { c(&opts) @@ -132,7 +132,16 @@ func Render(types []TypescriptType, cfg ...GenerateOption) error { } } + isEnumKey := func(t TypescriptType) bool { + if len(t.Params) < 2 { + return false + } + keyType := t.Params[0] + return keyType.IsEnum + } + funcs := template.FuncMap{ + "isEnumKey": isEnumKey, "mapKeyType": getParam("map", 0, 2), "mapValType": getParam("map", 1, 2), "arrType": getParam("array", 0, 1), @@ -144,6 +153,12 @@ func Render(types []TypescriptType, cfg ...GenerateOption) error { return "root-" + string(t.Kind) }), + "validTypeScriptName": func(name string) string { + if strings.Contains(name, "-") { + name = "\"" + name + "\"" + } + return name + }, "default": func(def, val string) string { if val == "" { return def diff --git a/generator_test.go b/generator_test.go index d85ed60..443ff35 100644 --- a/generator_test.go +++ b/generator_test.go @@ -30,3 +30,31 @@ func TestGenerateStuff(t *testing.T) { return } } + +type TestStruct struct { + WithMinus int `json:"with-minus"` + NormalField string +} + +func TestGenerateValidTypeScriptNames(t *testing.T) { + handler, err := NewParsedSourceEnumHandler(".") + if err != nil { + t.Error(err) + return + } + + extract, err := Extract(TestStruct{}, + WithEnumerations(handler), + FollowStructs, + ) + if err != nil { + t.Error(err) + return + } + + err = Render(extract) + if err != nil { + t.Error(err) + return + } +} diff --git a/go.mod b/go.mod index 2b8d24c..9600544 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,8 @@ -module github.com/32leaves/bel +module github.com/laknoll/bel -go 1.12 +go 1.22 require ( - github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1 - github.com/go-test/deep v1.0.1 - github.com/iancoleman/strcase v0.0.0-20180726023541-3605ed457bf7 + github.com/alecthomas/repr v0.5.4 + github.com/go-test/deep v1.1.1 ) diff --git a/go.sum b/go.sum index 40956e1..8e818b7 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,4 @@ -github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1 h1:GDQdwm/gAcJcLAKQQZGOJ4knlw+7rfEQQcmwTbt4p5E= -github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= -github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg= -github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= -github.com/iancoleman/strcase v0.0.0-20180726023541-3605ed457bf7 h1:ux/56T2xqZO/3cP1I2F86qpeoYPCOzk+KF/UH/Ar+lk= -github.com/iancoleman/strcase v0.0.0-20180726023541-3605ed457bf7/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE= +github.com/alecthomas/repr v0.5.4 h1:OVP7JEcuzU9CCDsT6STCr3rg17oQfWILtPWd2EG0uN4= +github.com/alecthomas/repr v0.5.4/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= diff --git a/roundtrip_test.go b/roundtrip_test.go index f937dca..2c55eb5 100644 --- a/roundtrip_test.go +++ b/roundtrip_test.go @@ -21,7 +21,8 @@ func TestRoundtrip(t *testing.T) { return } - ws, err := ioutil.TempDir("", "") + ws, err := os.MkdirTemp("", "") + fmt.Printf("using workspace: %s\n", ws) if !installTsNode(t, ws) { return } diff --git a/typescript.go b/typescript.go index 972e4e6..607b0ba 100644 --- a/typescript.go +++ b/typescript.go @@ -21,6 +21,7 @@ type TypescriptType struct { Name string Comment string Kind TypescriptKind + IsEnum bool // this type is an enum, needed so we generate map keys correctly Members []TypescriptMember Params []TypescriptType EnumMembers []TypescriptEnumMember