Skip to content

feat: generate typescript types from codersdk structs #1047

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 24 commits into from
Apr 19, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
generate separate file
  • Loading branch information
f0ssel committed Apr 18, 2022
commit 3b11dc3af1dacf4ef2b5ffa6ece6ae46c0080a3a
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ coderd/database/generate: fmt/sql coderd/database/dump.sql $(wildcard coderd/dat
.PHONY: coderd/database/generate

apitypings/generate: site/src/api/types.ts
go run scripts/apitypings/main.go > site/src/api/types.ts
go run scripts/apitypings/main.go > site/src/api/types-generated.ts
cd site && yarn run format:types
.PHONY: coderts/generate

Expand Down
197 changes: 197 additions & 0 deletions scripts/apitypings/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package main

import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"log"
"os"
"path/filepath"
"strings"

"golang.org/x/xerrors"
)

const (
baseDir = "./codersdk"
)

func main() {
err := run()
if err != nil {
log.Fatal(err)
}
}

func run() error {
var (
astFiles []*ast.File
enums = make(map[string]string)
)
fset := token.NewFileSet()
entries, err := os.ReadDir(baseDir)
if err != nil {
return xerrors.Errorf("reading dir %s: %w", baseDir, err)
}

// loop each file in directory
for _, entry := range entries {
astFile, err := parser.ParseFile(fset, filepath.Join(baseDir, entry.Name()), nil, 0)
if err != nil {
return xerrors.Errorf("parsing file %s: %w", filepath.Join(baseDir, entry.Name()), err)
}

astFiles = append(astFiles, astFile)
}

loopSpecs(astFiles, func(spec ast.Spec) {
pos := fset.Position(spec.Pos())
switch s := spec.(type) {
// TypeSpec case for structs and type alias
case *ast.TypeSpec:
out, err := handleTypeSpec(s, pos, enums)
if err != nil {
break
}

_, _ = fmt.Printf(out)
}
})

loopSpecs(astFiles, func(spec ast.Spec) {
switch s := spec.(type) {
// ValueSpec case for const "enums"
case *ast.ValueSpec:
handleValueSpec(s, enums)
}
})

for _, v := range enums {
_, _ = fmt.Printf("%s\n", v)
}

return nil
}

func loopSpecs(astFiles []*ast.File, fn func(spec ast.Spec)) {
for _, astFile := range astFiles {
// loop each declaration in file
for _, node := range astFile.Decls {
genDecl, ok := node.(*ast.GenDecl)
if !ok {
continue
}
for _, spec := range genDecl.Specs {
fn(spec)
}
}
}
}

func handleTypeSpec(typeSpec *ast.TypeSpec, pos token.Position, enums map[string]string) (string, error) {
jsonFields := 0
s := fmt.Sprintf("// From %s.\n", pos.String())
switch t := typeSpec.Type.(type) {
// Struct declaration
case *ast.StructType:
s = fmt.Sprintf("%sexport interface %s {\n", s, typeSpec.Name.Name)
for _, field := range t.Fields.List {
i, optional, err := getIdent(field.Type)
if err != nil {
continue
}

fieldType := toTsType(i.Name)
if fieldType == "" {
continue
}

fieldName := toJSONField(field)
if fieldName == "" {
continue
}

s = fmt.Sprintf("%s %s%s: %s\n", s, fieldName, optional, fieldType)
jsonFields++
}

// Do not print struct if it has no json fields
if jsonFields == 0 {
return "", xerrors.New("no json fields")
}

return fmt.Sprintf("%s}\n\n", s), nil
// Type alias declaration
case *ast.Ident:
// save type declaration to map of types
// later we come back and add union types to this declaration
enums[typeSpec.Name.Name] = fmt.Sprintf("%stype %s = \n", s, typeSpec.Name.Name)
return "", xerrors.New("enums are not printed at this stage")
default:
return "", xerrors.New("not struct or alias")
}
}

func handleValueSpec(valueSpec *ast.ValueSpec, enums map[string]string) {
valueValue := ""
i, ok := valueSpec.Type.(*ast.Ident)
if !ok {
return
}
valueType := i.Name

for _, value := range valueSpec.Values {
bl, ok := value.(*ast.BasicLit)
if !ok {
return
}
valueValue = bl.Value
break
}

enums[valueType] = fmt.Sprintf("%s | %s\n", enums[valueType], valueValue)
}

func getIdent(e ast.Expr) (*ast.Ident, string, error) {
switch t := e.(type) {
case *ast.Ident:
return t, "", nil
case *ast.StarExpr:
i, ok := t.X.(*ast.Ident)
if !ok {
return nil, "", xerrors.New("failed to cast star expr to indent")
}
return i, "?", nil
default:
return nil, "", xerrors.New("unknown expr type")
}
}

func toTsType(fieldType string) string {
switch fieldType {
case "bool":
return "boolean"
case "uint64", "uint32", "float64":
return "number"
}

return fieldType
}

func toJSONField(field *ast.Field) string {
if field.Tag != nil && field.Tag.Value != "" {
fieldName := strings.Trim(field.Tag.Value, "`")
for _, pair := range strings.Split(fieldName, " ") {
if strings.Contains(pair, `json:`) {
fieldName := strings.TrimPrefix(pair, `json:`)
fieldName = strings.Trim(fieldName, `"`)
fieldName = strings.Split(fieldName, ",")[0]

return fieldName
}
}
}

return ""
}
8 changes: 4 additions & 4 deletions site/src/api/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import axios from "axios"
import { getApiKey, login, logout } from "."
import { APIKeyResponse, LoginResponse } from "./types"
import { GenerateAPIKeyResponse, LoginWithPasswordResponse } from "./types"

// Mock the axios module so that no real network requests are made, but rather
// we swap in a resolved or rejected value
Expand All @@ -10,9 +10,9 @@ jest.mock("axios")

describe("api.ts", () => {
describe("login", () => {
it("should return LoginResponse", async () => {
it("should return LoginWithPasswordResponse", async () => {
// given
const loginResponse: LoginResponse = {
const loginResponse: LoginWithPasswordResponse = {
session_token: "abc_123_test",
}
const axiosMockPost = jest.fn().mockImplementationOnce(() => {
Expand Down Expand Up @@ -87,7 +87,7 @@ describe("api.ts", () => {
describe("getApiKey", () => {
it("should return APIKeyResponse", async () => {
// given
const apiKeyResponse: APIKeyResponse = {
const apiKeyResponse: GenerateAPIKeyResponse = {
key: "abc_123_test",
}
const axiosMockPost = jest.fn().mockImplementationOnce(() => {
Expand Down
12 changes: 6 additions & 6 deletions site/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@ export namespace Workspace {
}
}

export const login = async (email: string, password: string): Promise<Types.LoginResponse> => {
export const login = async (email: string, password: string): Promise<Types.LoginWithPasswordResponse> => {
const payload = JSON.stringify({
email,
password,
})

const response = await axios.post<Types.LoginResponse>("/api/v2/users/login", payload, {
const response = await axios.post<Types.LoginWithPasswordResponse>("/api/v2/users/login", payload, {
headers: { ...CONTENT_TYPE_JSON },
})

Expand All @@ -60,8 +60,8 @@ export const logout = async (): Promise<void> => {
await axios.post("/api/v2/users/logout")
}

export const getUser = async (): Promise<Types.UserResponse> => {
const response = await axios.get<Types.UserResponse>("/api/v2/users/me")
export const getUser = async (): Promise<Types.User> => {
const response = await axios.get<Types.User>("/api/v2/users/me")
return response.data
}

Expand All @@ -71,7 +71,7 @@ export const getApiKey = async (): Promise<Types.APIKeyResponse> => {
}

export const getUsers = async (): Promise<Types.PagedUsers> => {
// const response = await axios.get<Types.UserResponse[]>("/api/v2/users")
// const response = await axios.get<Types.User[]>("/api/v2/users")
// return response.data
return Promise.resolve({
page: [MockUser, MockUser2],
Expand Down Expand Up @@ -104,7 +104,7 @@ export const putWorkspaceAutostop = async (
})
}

export const updateProfile = async (userId: string, data: Types.UpdateProfileRequest): Promise<Types.UserResponse> => {
export const updateProfile = async (userId: string, data: Types.UpdateProfileRequest): Promise<Types.User> => {
const response = await axios.put(`/api/v2/users/${userId}/profile`, data)
return response.data
}
Loading