Skip to content

Commit f46b4cf

Browse files
authored
feat: generate typescript types from codersdk structs (coder#1047)
1 parent 1df943e commit f46b4cf

File tree

5 files changed

+442
-1
lines changed

5 files changed

+442
-1
lines changed

.github/workflows/coder.yaml

+15
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,21 @@ jobs:
7676
runs-on: ubuntu-latest
7777
steps:
7878
- uses: actions/checkout@v3
79+
80+
- name: Cache Node
81+
id: cache-node
82+
uses: actions/cache@v3
83+
with:
84+
path: |
85+
**/node_modules
86+
.eslintcache
87+
key: js-${{ runner.os }}-test-${{ hashFiles('**/yarn.lock') }}
88+
restore-keys: |
89+
js-${{ runner.os }}-
90+
91+
- name: Install node_modules
92+
run: ./scripts/yarn_install.sh
93+
7994
- name: Install Protoc
8095
uses: arduino/setup-protoc@v1
8196
with:

Makefile

+6-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ coderd/database/generate: fmt/sql coderd/database/dump.sql $(wildcard coderd/dat
1919
coderd/database/generate.sh
2020
.PHONY: coderd/database/generate
2121

22+
apitypings/generate: site/src/api/types.ts
23+
go run scripts/apitypings/main.go > site/src/api/types-generated.ts
24+
cd site && yarn run format:types
25+
.PHONY: apitypings/generate
26+
2227
fmt/prettier:
2328
@echo "--- prettier"
2429
# Avoid writing files in CI to reduce file write activity
@@ -48,7 +53,7 @@ fmt/terraform: $(wildcard *.tf)
4853
fmt: fmt/prettier fmt/sql fmt/terraform
4954
.PHONY: fmt
5055

51-
gen: coderd/database/generate peerbroker/proto provisionersdk/proto provisionerd/proto
56+
gen: coderd/database/generate peerbroker/proto provisionersdk/proto provisionerd/proto apitypings/generate
5257
.PHONY: gen
5358

5459
install: bin

scripts/apitypings/main.go

+200
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"go/ast"
6+
"go/parser"
7+
"go/token"
8+
"log"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
13+
"golang.org/x/xerrors"
14+
)
15+
16+
const (
17+
baseDir = "./codersdk"
18+
)
19+
20+
func main() {
21+
err := run()
22+
if err != nil {
23+
log.Fatal(err)
24+
}
25+
}
26+
27+
func run() error {
28+
var (
29+
astFiles []*ast.File
30+
enums = make(map[string]string)
31+
)
32+
fset := token.NewFileSet()
33+
entries, err := os.ReadDir(baseDir)
34+
if err != nil {
35+
return xerrors.Errorf("reading dir %s: %w", baseDir, err)
36+
}
37+
38+
// loop each file in directory
39+
for _, entry := range entries {
40+
astFile, err := parser.ParseFile(fset, filepath.Join(baseDir, entry.Name()), nil, 0)
41+
if err != nil {
42+
return xerrors.Errorf("parsing file %s: %w", filepath.Join(baseDir, entry.Name()), err)
43+
}
44+
45+
astFiles = append(astFiles, astFile)
46+
}
47+
48+
// TypeSpec case for structs and type alias
49+
loopSpecs(astFiles, func(spec ast.Spec) {
50+
pos := fset.Position(spec.Pos())
51+
s, ok := spec.(*ast.TypeSpec)
52+
if !ok {
53+
return
54+
}
55+
out, err := handleTypeSpec(s, pos, enums)
56+
if err != nil {
57+
return
58+
}
59+
60+
_, _ = fmt.Printf(out)
61+
})
62+
63+
// ValueSpec case for loading type alias values into the enum map
64+
loopSpecs(astFiles, func(spec ast.Spec) {
65+
s, ok := spec.(*ast.ValueSpec)
66+
if !ok {
67+
return
68+
}
69+
handleValueSpec(s, enums)
70+
})
71+
72+
// write each type alias declaration with possible values
73+
for _, v := range enums {
74+
_, _ = fmt.Printf("%s\n", v)
75+
}
76+
77+
return nil
78+
}
79+
80+
func loopSpecs(astFiles []*ast.File, fn func(spec ast.Spec)) {
81+
for _, astFile := range astFiles {
82+
// loop each declaration in file
83+
for _, node := range astFile.Decls {
84+
genDecl, ok := node.(*ast.GenDecl)
85+
if !ok {
86+
continue
87+
}
88+
for _, spec := range genDecl.Specs {
89+
fn(spec)
90+
}
91+
}
92+
}
93+
}
94+
95+
func handleTypeSpec(typeSpec *ast.TypeSpec, pos token.Position, enums map[string]string) (string, error) {
96+
jsonFields := 0
97+
s := fmt.Sprintf("// From %s.\n", pos.String())
98+
switch t := typeSpec.Type.(type) {
99+
// Struct declaration
100+
case *ast.StructType:
101+
s = fmt.Sprintf("%sexport interface %s {\n", s, typeSpec.Name.Name)
102+
for _, field := range t.Fields.List {
103+
i, optional, err := getIdent(field.Type)
104+
if err != nil {
105+
continue
106+
}
107+
108+
fieldType := toTsType(i.Name)
109+
if fieldType == "" {
110+
continue
111+
}
112+
113+
fieldName := toJSONField(field)
114+
if fieldName == "" {
115+
continue
116+
}
117+
118+
s = fmt.Sprintf("%s readonly %s%s: %s\n", s, fieldName, optional, fieldType)
119+
jsonFields++
120+
}
121+
122+
// Do not print struct if it has no json fields
123+
if jsonFields == 0 {
124+
return "", xerrors.New("no json fields")
125+
}
126+
127+
return fmt.Sprintf("%s}\n\n", s), nil
128+
// Type alias declaration
129+
case *ast.Ident:
130+
// save type declaration to map of types
131+
// later we come back and add union types to this declaration
132+
enums[typeSpec.Name.Name] = fmt.Sprintf("%sexport type %s = \n", s, typeSpec.Name.Name)
133+
return "", xerrors.New("enums are not printed at this stage")
134+
default:
135+
return "", xerrors.New("not struct or alias")
136+
}
137+
}
138+
139+
func handleValueSpec(valueSpec *ast.ValueSpec, enums map[string]string) {
140+
valueValue := ""
141+
i, ok := valueSpec.Type.(*ast.Ident)
142+
if !ok {
143+
return
144+
}
145+
valueType := i.Name
146+
147+
for _, value := range valueSpec.Values {
148+
bl, ok := value.(*ast.BasicLit)
149+
if !ok {
150+
return
151+
}
152+
valueValue = bl.Value
153+
break
154+
}
155+
156+
enums[valueType] = fmt.Sprintf("%s | %s\n", enums[valueType], valueValue)
157+
}
158+
159+
func getIdent(e ast.Expr) (*ast.Ident, string, error) {
160+
switch t := e.(type) {
161+
case *ast.Ident:
162+
return t, "", nil
163+
case *ast.StarExpr:
164+
i, ok := t.X.(*ast.Ident)
165+
if !ok {
166+
return nil, "", xerrors.New("failed to cast star expr to indent")
167+
}
168+
return i, "?", nil
169+
default:
170+
return nil, "", xerrors.New("unknown expr type")
171+
}
172+
}
173+
174+
func toTsType(fieldType string) string {
175+
switch fieldType {
176+
case "bool":
177+
return "boolean"
178+
case "uint64", "uint32", "float64":
179+
return "number"
180+
}
181+
182+
return fieldType
183+
}
184+
185+
func toJSONField(field *ast.Field) string {
186+
if field.Tag != nil && field.Tag.Value != "" {
187+
fieldName := strings.Trim(field.Tag.Value, "`")
188+
for _, pair := range strings.Split(fieldName, " ") {
189+
if strings.Contains(pair, `json:`) {
190+
fieldName := strings.TrimPrefix(pair, `json:`)
191+
fieldName = strings.Trim(fieldName, `"`)
192+
fieldName = strings.Split(fieldName, ",")[0]
193+
194+
return fieldName
195+
}
196+
}
197+
}
198+
199+
return ""
200+
}

site/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"chromatic": "chromatic",
1111
"dev": "webpack-dev-server --config=webpack.dev.ts",
1212
"format:check": "prettier --check '**/*.{css,html,js,json,jsx,md,ts,tsx,yaml,yml}'",
13+
"format:types": "prettier --write 'src/api/types-generated.ts'",
1314
"format:write": "prettier --write '**/*.{css,html,js,json,jsx,md,ts,tsx,yaml,yml}'",
1415
"lint": "jest --selectProjects lint",
1516
"lint:fix": "FIX=true yarn lint",

0 commit comments

Comments
 (0)