From f8c2a95381c13cc82a1d675f0a7bb69b361057a7 Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Thu, 11 Sep 2025 01:40:42 +0200 Subject: [PATCH 1/2] feat: generate ts typings from golang code --- jsonrpc.go | 18 ++ scripts/jsonrpc_typings/constants.go | 334 +++++++++++++++++++++ scripts/jsonrpc_typings/main.go | 45 +++ scripts/jsonrpc_typings/schema.go | 406 ++++++++++++++++++++++++++ scripts/jsonrpc_typings/types.go | 81 +++++ scripts/jsonrpc_typings/typescript.go | 309 ++++++++++++++++++++ 6 files changed, 1193 insertions(+) create mode 100644 scripts/jsonrpc_typings/constants.go create mode 100644 scripts/jsonrpc_typings/main.go create mode 100644 scripts/jsonrpc_typings/schema.go create mode 100644 scripts/jsonrpc_typings/types.go create mode 100644 scripts/jsonrpc_typings/typescript.go diff --git a/jsonrpc.go b/jsonrpc.go index ff3a4b12..2b77767b 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1049,6 +1049,24 @@ func rpcSetLocalLoopbackOnly(enabled bool) error { return nil } +// JSONRPCHandler represents a JSON-RPC handler +type JSONRPCHandler struct { + Type reflect.Type + Params []string +} + +// GetJSONRPCHandlers returns the JSON-RPC handlers +func GetJSONRPCHandlers() map[string]JSONRPCHandler { + ret := make(map[string]JSONRPCHandler) + for name, handler := range rpcHandlers { + ret[name] = JSONRPCHandler{ + Type: reflect.ValueOf(handler.Func).Type(), + Params: handler.Params, + } + } + return ret +} + var rpcHandlers = map[string]RPCHandler{ "ping": {Func: rpcPing}, "reboot": {Func: rpcReboot, Params: []string{"force"}}, diff --git a/scripts/jsonrpc_typings/constants.go b/scripts/jsonrpc_typings/constants.go new file mode 100644 index 00000000..2c03e06e --- /dev/null +++ b/scripts/jsonrpc_typings/constants.go @@ -0,0 +1,334 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + + "github.com/rs/zerolog/log" +) + +// getStringAliasInfoWithReflection uses reflection to automatically detect constants +// This approach tries to find constants by examining the actual values +func getStringAliasInfoWithReflection(searchPath string) []StringAliasInfo { + log.Debug().Str("searchPath", searchPath).Msg("Detecting string aliases and constants in single pass") + + // Detect both string aliases and their constants in a single file system walk + result := detectStringAliasesWithConstants(searchPath) + + // If reflection didn't work, throw an error + if len(result) == 0 { + log.Fatal().Msg("No string aliases with constants could be detected. Make sure the types are defined with constants in Go files.") + } + + log.Debug().Int("detected", len(result)).Msg("String alias detection completed") + return result +} + +// detectStringAliasesWithConstants detects both string aliases and their constants in a single file system walk +func detectStringAliasesWithConstants(searchPath string) []StringAliasInfo { + var result []StringAliasInfo + + // Walk the specified directory to find Go files + err := filepath.Walk(searchPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories and non-Go files + if info.IsDir() || !strings.HasSuffix(path, ".go") { + return nil + } + + // Skip test files and our own tool files + if strings.Contains(path, "_test.go") || strings.Contains(path, "scripts/jsonrpc_typings") { + return nil + } + + // Parse the file to find both string aliases and their constants + aliases := findStringAliasesWithConstantsInFile(path) + result = append(result, aliases...) + + return nil + }) + + if err != nil { + log.Fatal().Err(err).Msg("Error walking directory for string alias detection") + } + + // Remove duplicates based on type name + uniqueAliases := make([]StringAliasInfo, 0) + seen := make(map[string]bool) + for _, alias := range result { + if !seen[alias.Name] { + seen[alias.Name] = true + uniqueAliases = append(uniqueAliases, alias) + } + } + + return uniqueAliases +} + +// findStringAliasesWithConstantsInFile finds both string aliases and their constants in a single Go file +func findStringAliasesWithConstantsInFile(filePath string) []StringAliasInfo { + var result []StringAliasInfo + + // Parse the Go file + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments) + if err != nil { + log.Debug().Err(err).Str("file", filePath).Msg("Failed to parse file") + return result + } + + // First pass: collect all string alias type names + stringAliases := make(map[string]bool) + ast.Inspect(node, func(n ast.Node) bool { + genDecl, ok := n.(*ast.GenDecl) + if !ok || genDecl.Tok != token.TYPE { + return true + } + + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + + // Check if this is a string alias (type Name string) + if ident, ok := typeSpec.Type.(*ast.Ident); ok && ident.Name == "string" { + stringAliases[typeSpec.Name.Name] = true + } + } + + return true + }) + + // Second pass: find constants for the string aliases we found + ast.Inspect(node, func(n ast.Node) bool { + genDecl, ok := n.(*ast.GenDecl) + if !ok || genDecl.Tok != token.CONST { + return true + } + + // Process each constant specification in the declaration + for _, spec := range genDecl.Specs { + valueSpec, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + + // Check if this constant is typed with one of our string aliases + if valueSpec.Type == nil { + continue + } + + ident, ok := valueSpec.Type.(*ast.Ident) + if !ok { + continue + } + typeName := ident.Name + + // Check if this type is one of our string aliases + if _, ok := stringAliases[typeName]; !ok { + continue + } + + // Extract string literal values + for _, value := range valueSpec.Values { + basicLit, ok := value.(*ast.BasicLit) + if !ok || basicLit.Kind != token.STRING { + continue + } + + // Remove quotes from string literal + constantValue := strings.Trim(basicLit.Value, "\"") + + // Find or create the StringAliasInfo for this type + var aliasInfo *StringAliasInfo + for i := range result { + if result[i].Name == typeName { + aliasInfo = &result[i] + break + } + } + + if aliasInfo == nil { + result = append(result, StringAliasInfo{ + Name: typeName, + Constants: []string{}, + }) + aliasInfo = &result[len(result)-1] + } + + aliasInfo.Constants = append(aliasInfo.Constants, constantValue) + } + } + + return true + }) + + return result +} + +// batchDetectConstantsForTypes efficiently detects constants for multiple types in a single file system walk +func batchDetectConstantsForTypes(typeNames []string, searchPath string) map[string][]string { + result := make(map[string][]string) + + // Initialize result map + for _, typeName := range typeNames { + result[typeName] = []string{} + } + + // Walk the specified directory to find Go files + err := filepath.Walk(searchPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories and non-Go files + if info.IsDir() || !strings.HasSuffix(path, ".go") { + return nil + } + + // Skip test files and our own tool files + if strings.Contains(path, "_test.go") || strings.Contains(path, "scripts/jsonrpc_typings") { + return nil + } + + // Check if this file contains any of the types we're looking for + fileContainsAnyType := false + for _, typeName := range typeNames { + if fileContainsType(path, typeName) { + fileContainsAnyType = true + break + } + } + + if !fileContainsAnyType { + return nil + } + + log.Debug().Str("file", path).Strs("types", typeNames).Msg("Parsing file for constants") + + // Parse constants for all types from this file + fileConstants := batchParseConstantsFromFile(path, typeNames) + + // Merge results + for typeName, constants := range fileConstants { + if len(constants) > 0 { + result[typeName] = constants + log.Debug().Str("type", typeName).Strs("constants", constants).Str("file", path).Msg("Found constants") + } + } + + return nil + }) + + if err != nil { + log.Fatal().Err(err).Msg("Error searching for constants") + } + + return result +} + +// batchParseConstantsFromFile parses constants for multiple types from a single Go file +func batchParseConstantsFromFile(filePath string, typeNames []string) map[string][]string { + result := make(map[string][]string) + + // Initialize result map + for _, typeName := range typeNames { + result[typeName] = []string{} + } + + // Parse the Go file + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments) + if err != nil { + log.Debug().Err(err).Str("file", filePath).Msg("Failed to parse file") + return result + } + + // Walk the AST to find constant declarations + ast.Inspect(node, func(n ast.Node) bool { + // Look for GenDecl nodes (const declarations) + genDecl, ok := n.(*ast.GenDecl) + if !ok || genDecl.Tok != token.CONST { + return true + } + + // Process each constant specification in the declaration + for _, spec := range genDecl.Specs { + valueSpec, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + + // Check if this constant is typed with one of our target types + if valueSpec.Type != nil { + if ident, ok := valueSpec.Type.(*ast.Ident); ok { + typeName := ident.Name + + // Check if this type is one we're looking for + if contains(typeNames, typeName) { + // Extract string literal values + for _, value := range valueSpec.Values { + if basicLit, ok := value.(*ast.BasicLit); ok && basicLit.Kind == token.STRING { + // Remove quotes from string literal + constantValue := strings.Trim(basicLit.Value, "\"") + result[typeName] = append(result[typeName], constantValue) + } + } + } + } + } + } + + return true + }) + + return result +} + +// contains checks if a slice contains a string +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +// fileContainsType checks if a Go file contains a type definition for the given type name +func fileContainsType(filePath, typeName string) bool { + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments) + if err != nil { + return false + } + + // Walk the AST to find type definitions + found := false + ast.Inspect(node, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.GenDecl: + if x.Tok == token.TYPE { + for _, spec := range x.Specs { + if typeSpec, ok := spec.(*ast.TypeSpec); ok { + if typeSpec.Name.Name == typeName { + found = true + return false // Stop searching + } + } + } + } + } + return !found // Continue searching if not found yet + }) + + return found +} diff --git a/scripts/jsonrpc_typings/main.go b/scripts/jsonrpc_typings/main.go new file mode 100644 index 00000000..f4c550e9 --- /dev/null +++ b/scripts/jsonrpc_typings/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "flag" + "os" + "strings" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +func main() { + // Parse command line flags + logLevel := flag.String("log-level", "info", "Log level (trace, debug, info, warn, error, fatal, panic)") + searchPath := flag.String("search-path", ".", "Path to search for Go files containing type definitions") + outputPath := flag.String("output", "jsonrpc.ts", "Output path for the generated TypeScript file") + flag.Parse() + + // Set log level + level, err := zerolog.ParseLevel(strings.ToLower(*logLevel)) + if err != nil { + log.Fatal().Err(err).Str("level", *logLevel).Msg("Invalid log level") + } + zerolog.SetGlobalLevel(level) + + // Configure zerolog for pretty console output + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) + + // Create API schema + log.Info().Str("search-path", *searchPath).Msg("Creating API schema from JSON-RPC handlers") + schema := NewAPISchema(*searchPath) + + // Generate TypeScript typings + log.Info().Msg("Generating TypeScript typings") + typings := generateTypeScriptTypings(schema, *searchPath) + + // Write to output file + log.Info().Str("file", *outputPath).Msg("Writing TypeScript definitions to file") + err = os.WriteFile(*outputPath, []byte(typings), 0644) + if err != nil { + log.Fatal().Err(err).Str("file", *outputPath).Msg("Failed to write TypeScript definitions") + } + + log.Info().Str("file", *outputPath).Msg("TypeScript typings generated successfully") +} diff --git a/scripts/jsonrpc_typings/schema.go b/scripts/jsonrpc_typings/schema.go new file mode 100644 index 00000000..5d602ce3 --- /dev/null +++ b/scripts/jsonrpc_typings/schema.go @@ -0,0 +1,406 @@ +package main + +import ( + "fmt" + "reflect" + "sort" + "strings" + + "github.com/jetkvm/kvm" + "github.com/rs/zerolog/log" +) + +// NewAPISchema creates a new API schema from the JSON-RPC handlers +func NewAPISchema(searchPath string) *APISchema { + schema := &APISchema{ + Handlers: make(map[string]APIHandler), + Types: make(map[string]APIType), + } + + handlers := kvm.GetJSONRPCHandlers() + log.Info().Int("count", len(handlers)).Msg("Processing JSON-RPC handlers") + + for name, handler := range handlers { + log.Debug().Str("handler", name).Msg("Building API handler") + apiHandler := buildAPIHandler(name, handler, schema) + schema.Handlers[name] = apiHandler + } + + schema.HandlerCount = len(schema.Handlers) + schema.TypeCount = len(schema.Types) + + log.Info(). + Int("handlers", schema.HandlerCount). + Int("types", schema.TypeCount). + Msg("API schema created successfully") + + return schema +} + +// buildAPIHandler constructs an APIHandler from a JSON-RPC handler +func buildAPIHandler(name string, handler kvm.JSONRPCHandler, schema *APISchema) APIHandler { + apiHandler := APIHandler{ + Name: name, + FunctionType: handler.Type.String(), + ParameterNames: handler.Params, + Parameters: make([]APIParameter, 0, handler.Type.NumIn()), + ReturnValues: make([]APIReturnValue, 0, handler.Type.NumOut()), + } + + // Process parameters + for i := 0; i < handler.Type.NumIn(); i++ { + paramType := handler.Type.In(i) + paramName := getParameterName(i, handler.Params) + + apiParam := APIParameter{ + Name: paramName, + Type: paramType.String(), + } + + if apiType := extractAPIType(paramType, schema); apiType != nil { + apiParam.APIType = apiType + schema.Types[apiType.Name] = *apiType + } + + apiHandler.Parameters = append(apiHandler.Parameters, apiParam) + } + + // Process return values + for i := 0; i < handler.Type.NumOut(); i++ { + returnType := handler.Type.Out(i) + + apiReturn := APIReturnValue{ + Index: i, + Type: returnType.String(), + } + + if apiType := extractAPIType(returnType, schema); apiType != nil { + apiReturn.APIType = apiType + schema.Types[apiType.Name] = *apiType + } + + apiHandler.ReturnValues = append(apiHandler.ReturnValues, apiReturn) + } + + return apiHandler +} + +// getParameterName safely retrieves a parameter name by index +func getParameterName(index int, paramNames []string) string { + if index < len(paramNames) { + return paramNames[index] + } + return "" +} + +// extractAPIType extracts API type information from a reflect.Type +// It recursively finds and adds nested struct types to the schema +func extractAPIType(t reflect.Type, schema *APISchema) *APIType { + if t == nil { + return nil + } + + switch t.Kind() { + case reflect.Ptr: + return extractPointerType(t, schema) + case reflect.Slice: + return extractSliceType(t, schema) + case reflect.Array: + return extractArrayType(t, schema) + case reflect.Map: + return extractMapType(t, schema) + case reflect.Struct: + return extractStructType(t, schema) + case reflect.Interface: + return extractInterfaceType(t) + default: + return extractBasicType(t) + } +} + +// extractPointerType handles pointer types +func extractPointerType(t reflect.Type, schema *APISchema) *APIType { + elemType := extractAPIType(t.Elem(), schema) + if elemType == nil { + return nil + } + + elemType.IsPointer = true + elemType.Name = "*" + elemType.Name + return elemType +} + +// extractSliceType handles slice types +func extractSliceType(t reflect.Type, schema *APISchema) *APIType { + elemType := extractAPIType(t.Elem(), schema) + if elemType == nil { + return nil + } + + elemType.IsSlice = true + elemType.SliceType = elemType.Name + elemType.Name = "[]" + elemType.Name + return elemType +} + +// extractArrayType handles array types +func extractArrayType(t reflect.Type, schema *APISchema) *APIType { + elemType := extractAPIType(t.Elem(), schema) + if elemType == nil { + return nil + } + + elemType.Name = fmt.Sprintf("[%d]%s", t.Len(), elemType.Name) + return elemType +} + +// extractMapType handles map types +func extractMapType(t reflect.Type, schema *APISchema) *APIType { + keyType := extractAPIType(t.Key(), schema) + valueType := extractAPIType(t.Elem(), schema) + + if keyType == nil || valueType == nil { + return nil + } + + return &APIType{ + Name: fmt.Sprintf("map[%s]%s", keyType.Name, valueType.Name), + Kind: TypeKindMap, + IsMap: true, + MapKeyType: keyType.Name, + MapValueType: valueType.Name, + } +} + +// extractStructType handles struct types +func extractStructType(t reflect.Type, schema *APISchema) *APIType { + apiType := &APIType{ + Name: t.String(), + Kind: TypeKindStruct, + Fields: make([]APIField, 0, t.NumField()), + } + + // Extract package name + if pkgPath := t.PkgPath(); pkgPath != "" { + apiType.Package = extractPackageName(pkgPath) + } + + // Extract fields + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + + // Skip unexported fields + if !field.IsExported() { + continue + } + + apiField := buildAPIField(field) + apiType.Fields = append(apiType.Fields, apiField) + + // Recursively extract nested struct types from field types + if nestedType := extractAPIType(field.Type, schema); nestedType != nil { + if nestedType.Kind == TypeKindStruct { + schema.Types[nestedType.Name] = *nestedType + } + } + } + + return apiType +} + +// extractInterfaceType handles interface types +func extractInterfaceType(t reflect.Type) *APIType { + return &APIType{ + Name: t.String(), + Kind: TypeKindInterface, + } +} + +// extractBasicType handles basic Go types +func extractBasicType(t reflect.Type) *APIType { + if isBasicType(t.String()) { + return &APIType{ + Name: t.String(), + Kind: TypeKindBasic, + } + } + return nil +} + +// buildAPIField constructs an APIField from a reflect.StructField +func buildAPIField(field reflect.StructField) APIField { + apiField := APIField{ + Name: field.Name, + JSONName: field.Name, // Default to field name + Type: field.Type.String(), + IsExported: field.IsExported(), + Tag: string(field.Tag), + } + + // Parse JSON tag + if jsonTag := field.Tag.Get("json"); jsonTag != "" { + if jsonName := parseJSONTag(jsonTag); jsonName != "" { + apiField.JSONName = jsonName + } + } + + return apiField +} + +// parseJSONTag extracts the JSON field name from a JSON tag +func parseJSONTag(jsonTag string) string { + parts := strings.Split(jsonTag, ",") + if len(parts) > 0 && parts[0] != "" && parts[0] != "-" { + return parts[0] + } + return "" +} + +// extractPackageName extracts the package name from a package path +func extractPackageName(pkgPath string) string { + parts := strings.Split(pkgPath, "/") + if len(parts) > 0 { + return parts[len(parts)-1] + } + return "" +} + +// GetStructTypes returns all struct types from the schema +func (s *APISchema) GetStructTypes() []APIType { + structs := make([]APIType, 0) + for _, apiType := range s.Types { + if apiType.Kind == TypeKindStruct { + structs = append(structs, apiType) + } + } + return structs +} + +// getSortedHandlers returns handlers sorted by name +func getSortedHandlers(schema *APISchema) []APIHandler { + var handlers []APIHandler + for _, handler := range schema.Handlers { + handlers = append(handlers, handler) + } + sort.Slice(handlers, func(i, j int) bool { + return handlers[i].Name < handlers[j].Name + }) + return handlers +} + +// getSortedMethods returns method names sorted alphabetically +func getSortedMethods(schema *APISchema) []string { + var methods []string + for name := range schema.Handlers { + methods = append(methods, name) + } + sort.Strings(methods) + return methods +} + +// getAllReferencedStructs recursively finds all structs referenced in the API +func getAllReferencedStructs(schema *APISchema) []APIType { + // Start with all structs found in handlers + allStructs := make(map[string]APIType) + + // Add all structs from handlers + for _, apiType := range schema.GetStructTypes() { + allStructs[apiType.Name] = apiType + } + + // Also add all structs from the complete schema that might be referenced + for name, apiType := range schema.Types { + if apiType.Kind == TypeKindStruct { + allStructs[name] = apiType + } + } + + // Recursively find all referenced structs + changed := true + for changed { + changed = false + for _, apiType := range allStructs { + referencedStructs := findReferencedStructs(apiType, schema) + for _, refStruct := range referencedStructs { + if _, exists := allStructs[refStruct.Name]; !exists { + allStructs[refStruct.Name] = refStruct + changed = true + } + } + } + } + + // Convert map to slice + var result []APIType + for _, apiType := range allStructs { + result = append(result, apiType) + } + + return result +} + +// findReferencedStructs finds structs referenced in a given API type +func findReferencedStructs(apiType APIType, schema *APISchema) []APIType { + var referenced []APIType + + for _, field := range apiType.Fields { + if isStructType(field.Type) { + structName := extractStructName(field.Type) + if structType, exists := schema.Types[structName]; exists { + referenced = append(referenced, structType) + } + } + } + + return referenced +} + +// isStructType checks if a type string represents a struct +func isStructType(typeStr string) bool { + // Check if it's a custom type (not basic Go types) + return !isBasicType(typeStr) && + !strings.HasPrefix(typeStr, "[]") && + !strings.HasPrefix(typeStr, "map[") && + !strings.HasPrefix(typeStr, "*") && + !strings.HasPrefix(typeStr, "[") +} + +// extractStructName extracts the struct name from a type string +func extractStructName(typeStr string) string { + // Remove array/slice prefixes + if strings.HasPrefix(typeStr, "[]") { + return typeStr[2:] + } + if strings.HasPrefix(typeStr, "*") { + return typeStr[1:] + } + return typeStr +} + +// isBasicType checks if a type is a basic Go type +func isBasicType(typeName string) bool { + basicTypes := map[string]bool{ + "bool": true, + "string": true, + "int": true, + "int8": true, + "int16": true, + "int32": true, + "int64": true, + "uint": true, + "uint8": true, + "uint16": true, + "uint32": true, + "uint64": true, + "uintptr": true, + "float32": true, + "float64": true, + "complex64": true, + "complex128": true, + "byte": true, + "rune": true, + "error": true, + } + + return basicTypes[typeName] +} diff --git a/scripts/jsonrpc_typings/types.go b/scripts/jsonrpc_typings/types.go new file mode 100644 index 00000000..ea64d781 --- /dev/null +++ b/scripts/jsonrpc_typings/types.go @@ -0,0 +1,81 @@ +package main + +// TypeKind represents the kind of API type +type TypeKind string + +const ( + // TypeKindStruct represents a struct type + TypeKindStruct TypeKind = "struct" + // TypeKindInterface represents an interface type + TypeKindInterface TypeKind = "interface" + // TypeKindBasic represents a basic Go type + TypeKindBasic TypeKind = "basic" + // TypeKindMap represents a map type + TypeKindMap TypeKind = "map" + // TypeKindSlice represents a slice type + TypeKindSlice TypeKind = "slice" + // TypeKindArray represents an array type + TypeKindArray TypeKind = "array" + // TypeKindPointer represents a pointer type + TypeKindPointer TypeKind = "pointer" +) + +// APIType represents a type used in the JSON-RPC API +type APIType struct { + Name string `json:"name"` + Package string `json:"package"` + Kind TypeKind `json:"kind"` + Fields []APIField `json:"fields,omitempty"` + IsPointer bool `json:"is_pointer"` + IsSlice bool `json:"is_slice"` + IsMap bool `json:"is_map"` + MapKeyType string `json:"map_key_type,omitempty"` + MapValueType string `json:"map_value_type,omitempty"` + SliceType string `json:"slice_type,omitempty"` +} + +// APIField represents a field in a struct +type APIField struct { + Name string `json:"name"` + JSONName string `json:"json_name"` + Type string `json:"type"` + IsExported bool `json:"is_exported"` + Tag string `json:"tag"` +} + +// APIParameter represents a parameter in a JSON-RPC handler +type APIParameter struct { + Name string `json:"name"` + Type string `json:"type"` + APIType *APIType `json:"api_type,omitempty"` +} + +// APIReturnValue represents a return value from a JSON-RPC handler +type APIReturnValue struct { + Index int `json:"index"` + Type string `json:"type"` + APIType *APIType `json:"api_type,omitempty"` +} + +// APIHandler represents a complete JSON-RPC handler +type APIHandler struct { + Name string `json:"name"` + FunctionType string `json:"function_type"` + Parameters []APIParameter `json:"parameters"` + ReturnValues []APIReturnValue `json:"return_values"` + ParameterNames []string `json:"parameter_names"` +} + +// APISchema represents the complete JSON-RPC API schema +type APISchema struct { + Handlers map[string]APIHandler `json:"handlers"` + Types map[string]APIType `json:"types"` + TypeCount int `json:"type_count"` + HandlerCount int `json:"handler_count"` +} + +// StringAliasInfo represents information about a string alias and its constants +type StringAliasInfo struct { + Name string + Constants []string +} diff --git a/scripts/jsonrpc_typings/typescript.go b/scripts/jsonrpc_typings/typescript.go new file mode 100644 index 00000000..8c986c57 --- /dev/null +++ b/scripts/jsonrpc_typings/typescript.go @@ -0,0 +1,309 @@ +package main + +import ( + "fmt" + "strings" + "text/template" + + "github.com/rs/zerolog/log" +) + +// generateTypeScriptTypings generates complete TypeScript definitions +func generateTypeScriptTypings(schema *APISchema, searchPath string) string { + // Create template functions + funcMap := template.FuncMap{ + "cleanTypeName": cleanTypeName, + "goToTypeScriptType": goToTypeScriptType, + "getAllStructs": func() []APIType { return getAllReferencedStructs(schema) }, + "getSortedHandlers": func() []APIHandler { return getSortedHandlers(schema) }, + "getSortedMethods": func() []string { return getSortedMethods(schema) }, + "getReturnType": getReturnType, + "getParameterList": getParameterList, + "hasParameters": hasParameters, + "getStringAliasInfo": func() []StringAliasInfo { return getStringAliasInfoWithReflection(searchPath) }, + "sub": func(a, b int) int { return a - b }, + "pad": func(s string, width int) string { return padString(s, width) }, + "padComment": func(fieldName, fieldType string) string { return padComment(fieldName, fieldType) }, + } + + // Parse the main template + tmpl, err := template.New("typescript").Funcs(funcMap).Parse(typescriptTemplate) + if err != nil { + log.Fatal().Err(err).Msg("Failed to parse TypeScript template") + } + + // Execute template + var output strings.Builder + err = tmpl.Execute(&output, schema) + if err != nil { + log.Fatal().Err(err).Msg("Failed to execute TypeScript template") + } + + return output.String() +} + +// padString pads a string to the specified width +func padString(s string, width int) string { + if len(s) >= width { + return s + } + return s + strings.Repeat(" ", width-len(s)) +} + +// padComment calculates the proper padding for field comments +func padComment(fieldName, fieldType string) string { + // Calculate the length of the field declaration part + // Format: " fieldName: fieldType;" + declarationLength := 2 + len(fieldName) + 2 + len(fieldType) + 1 // " " + fieldName + ": " + fieldType + ";" + + // Target alignment at column 40 + targetColumn := 40 + if declarationLength >= targetColumn { + return " " // Just one space if already past target + } + + return strings.Repeat(" ", targetColumn-declarationLength) +} + +// cleanTypeName cleans up Go type names for TypeScript +func cleanTypeName(typeName string) string { + // Remove package prefixes + if strings.Contains(typeName, ".") { + parts := strings.Split(typeName, ".") + return parts[len(parts)-1] + } + return typeName +} + +// goToTypeScriptType converts Go types to TypeScript types with recursive parsing +func goToTypeScriptType(goType string) string { + return parseTypeRecursively(goType) +} + +// parseTypeRecursively parses Go types recursively to handle complex nested types +func parseTypeRecursively(goType string) string { + // Remove any leading/trailing whitespace + goType = strings.TrimSpace(goType) + + // Handle basic types first + switch goType { + case "bool": + return "boolean" + case "string": + return "string" + case "int", "int8", "int16", "int32", "int64": + return "number" + case "uint", "uint8", "uint16", "uint32", "uint64": + return "number" + case "float32", "float64": + return "number" + case "byte": + return "number" + case "rune": + return "string" + case "error": + return "string" + case "usbgadget.ByteSlice": + return "number[]" + case "interface {}": + return "any" + case "time.Duration": + return "number" + case "time.Time": + return "string" + case "net.IP": + return "string" + case "net.IPNet": + return "string" + case "net.HardwareAddr": + return "string" + } + + // Handle pointer types + if strings.HasPrefix(goType, "*") { + innerType := parseTypeRecursively(goType[1:]) + return innerType + " | null" + } + + // Handle slice types + if strings.HasPrefix(goType, "[]") { + elementType := parseTypeRecursively(goType[2:]) + return elementType + "[]" + } + + // Handle map types with proper bracket matching + if strings.HasPrefix(goType, "map[") { + return parseMapType(goType) + } + + // Handle any remaining interface {} in complex types + if strings.Contains(goType, "interface {}") { + return strings.ReplaceAll(goType, "interface {}", "any") + } + + // Check if this is a string alias (type name != underlying type) + if isStringAlias(goType) { + return cleanTypeName(goType) + } + + // Return cleaned custom type name + return cleanTypeName(goType) +} + +// parseMapType parses map types with proper bracket matching +func parseMapType(goType string) string { + if !strings.HasPrefix(goType, "map[") { + return goType + } + + // Find the key type and value type + start := 4 // After "map[" + bracketCount := 0 + keyEnd := -1 + + // Find the end of the key type by looking for the first ']' at bracket level 0 + for i := start; i < len(goType); i++ { + char := goType[i] + if char == '[' { + bracketCount++ + } else if char == ']' { + if bracketCount == 0 { + keyEnd = i + break + } + bracketCount-- + } + } + + if keyEnd == -1 || keyEnd >= len(goType)-1 { + return goType // Invalid map type + } + + keyType := goType[start:keyEnd] + valueType := goType[keyEnd+1:] + + // Parse key and value types recursively + tsKeyType := parseTypeRecursively(keyType) + tsValueType := parseTypeRecursively(valueType) + + return fmt.Sprintf("Record<%s, %s>", tsKeyType, tsValueType) +} + +// isStringAlias checks if a type is a string alias +func isStringAlias(typeName string) bool { + // Known string aliases in the codebase + stringAliases := map[string]bool{ + "VirtualMediaMode": true, + "VirtualMediaSource": true, + } + return stringAliases[typeName] +} + +// getReturnType returns the TypeScript return type for a handler +func getReturnType(handler APIHandler) string { + if len(handler.ReturnValues) == 0 { + return "void" + } else if len(handler.ReturnValues) == 1 { + return goToTypeScriptType(handler.ReturnValues[0].Type) + } else { + // Multiple return values - use tuple type + var returnTypes []string + for _, retVal := range handler.ReturnValues { + returnTypes = append(returnTypes, goToTypeScriptType(retVal.Type)) + } + return fmt.Sprintf("[%s]", strings.Join(returnTypes, ", ")) + } +} + +// getParameterList returns the TypeScript parameter list for a handler +func getParameterList(handler APIHandler) string { + if len(handler.Parameters) == 0 { + return "" + } + + var paramList []string + for _, param := range handler.Parameters { + tsType := goToTypeScriptType(param.Type) + paramList = append(paramList, fmt.Sprintf("%s: %s", param.Name, tsType)) + } + return strings.Join(paramList, ", ") +} + +// hasParameters returns true if the handler has parameters +func hasParameters(handler APIHandler) bool { + return len(handler.Parameters) > 0 +} + +// typescriptTemplate is the main template for generating TypeScript definitions +const typescriptTemplate = `// Code generated by generate_typings.go. DO NOT EDIT. +{{range $struct := getAllStructs}} +export interface {{cleanTypeName $struct.Name}} { +{{range $field := $struct.Fields}} {{$field.JSONName}}: {{goToTypeScriptType $field.Type}};{{padComment $field.JSONName (goToTypeScriptType $field.Type)}}// {{$field.Name}} {{$field.Type}} +{{end}}} + +{{end}} + +// String aliases with constants +{{range $alias := getStringAliasInfo}} +export type {{$alias.Name}} = {{range $i, $const := $alias.Constants}}"{{$const}}"{{if lt $i (sub (len $alias.Constants) 1)}} | {{end}}{{end}}; +{{end}} + +// JSON-RPC Types +export interface JsonRpcRequest { + jsonrpc: "2.0"; + method: string; + params?: unknown; + id: number | string; +} + +export interface JsonRpcError { + code: number; + data?: string; + message: string; +} + +export interface JsonRpcSuccessResponse { + jsonrpc: "2.0"; + result: unknown; + id: number | string; +} + +export interface JsonRpcErrorResponse { + jsonrpc: "2.0"; + error: JsonRpcError; + id: number | string; +} + +export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse; + +// Handler method types (generated from actual handlers) +export type JsonRpcMethod = +{{range $i, $method := getSortedMethods}}{{if $i}} | {{else}} | {{end}}"{{$method}}" +{{end}}; + +// RPC Functions +export class JsonRpcClient { + constructor(private send: (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => void) {} + + private async sendAsync(method: string, params?: unknown): Promise { + return new Promise((resolve, reject) => { + this.send(method, params, (response: JsonRpcResponse) => { + if ('error' in response) { + reject(new Error('RPC error: ' + response.error.message)); + return; + } + resolve(response.result as T); + }); + }); + } + +{{range $handler := getSortedHandlers}} + async {{$handler.Name}}({{getParameterList $handler}}) { +{{if eq (len $handler.Parameters) 0}} return this.sendAsync<{{getReturnType $handler}}>('{{$handler.Name}}'); +{{else}} return this.sendAsync<{{getReturnType $handler}}>('{{$handler.Name}}', { +{{range $param := $handler.Parameters}} {{$param.Name}}, +{{end}} }); +{{end}} } + +{{end}}} +` From 3e2df4e6510a2c671da39ac7c82f136cacec6dd1 Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Thu, 11 Sep 2025 01:57:46 +0200 Subject: [PATCH 2/2] fix: handle nullable properly --- scripts/jsonrpc_typings/schema.go | 43 ++++++++++++++++++++++----- scripts/jsonrpc_typings/types.go | 3 ++ scripts/jsonrpc_typings/typescript.go | 26 +++++++++++----- 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/scripts/jsonrpc_typings/schema.go b/scripts/jsonrpc_typings/schema.go index 5d602ce3..600bb620 100644 --- a/scripts/jsonrpc_typings/schema.go +++ b/scripts/jsonrpc_typings/schema.go @@ -174,6 +174,11 @@ func extractMapType(t reflect.Type, schema *APISchema) *APIType { // extractStructType handles struct types func extractStructType(t reflect.Type, schema *APISchema) *APIType { + // Skip null.* structs as they are handled as optional properties + if strings.HasPrefix(t.String(), "null.") { + return nil + } + apiType := &APIType{ Name: t.String(), Kind: TypeKindStruct, @@ -186,6 +191,9 @@ func extractStructType(t reflect.Type, schema *APISchema) *APIType { } // Extract fields + embeddedStructs := make([]string, 0) + regularFields := make([]APIField, 0) + for i := 0; i < t.NumField(); i++ { field := t.Field(i) @@ -194,17 +202,36 @@ func extractStructType(t reflect.Type, schema *APISchema) *APIType { continue } - apiField := buildAPIField(field) - apiType.Fields = append(apiType.Fields, apiField) - - // Recursively extract nested struct types from field types - if nestedType := extractAPIType(field.Type, schema); nestedType != nil { - if nestedType.Kind == TypeKindStruct { - schema.Types[nestedType.Name] = *nestedType + // Check if this is an embedded struct (anonymous field) + if field.Anonymous && field.Type.Kind() == reflect.Struct { + embeddedStructs = append(embeddedStructs, field.Type.String()) + // Recursively extract nested struct types from embedded structs + if nestedType := extractAPIType(field.Type, schema); nestedType != nil { + if nestedType.Kind == TypeKindStruct { + schema.Types[nestedType.Name] = *nestedType + } + } + } else { + apiField := buildAPIField(field) + regularFields = append(regularFields, apiField) + + // Recursively extract nested struct types from field types + if nestedType := extractAPIType(field.Type, schema); nestedType != nil { + if nestedType.Kind == TypeKindStruct { + schema.Types[nestedType.Name] = *nestedType + } } } } + // If we have exactly one embedded struct and no regular fields, mark it as an extension + if len(embeddedStructs) == 1 && len(regularFields) == 0 { + apiType.Kind = TypeKindExtension + apiType.Extends = embeddedStructs[0] + } else { + apiType.Fields = regularFields + } + return apiType } @@ -310,7 +337,7 @@ func getAllReferencedStructs(schema *APISchema) []APIType { // Also add all structs from the complete schema that might be referenced for name, apiType := range schema.Types { - if apiType.Kind == TypeKindStruct { + if apiType.Kind == TypeKindStruct || apiType.Kind == TypeKindExtension { allStructs[name] = apiType } } diff --git a/scripts/jsonrpc_typings/types.go b/scripts/jsonrpc_typings/types.go index ea64d781..1242f97d 100644 --- a/scripts/jsonrpc_typings/types.go +++ b/scripts/jsonrpc_typings/types.go @@ -18,6 +18,8 @@ const ( TypeKindArray TypeKind = "array" // TypeKindPointer represents a pointer type TypeKindPointer TypeKind = "pointer" + // TypeKindExtension represents a struct that extends another struct + TypeKindExtension TypeKind = "extension" ) // APIType represents a type used in the JSON-RPC API @@ -26,6 +28,7 @@ type APIType struct { Package string `json:"package"` Kind TypeKind `json:"kind"` Fields []APIField `json:"fields,omitempty"` + Extends string `json:"extends,omitempty"` IsPointer bool `json:"is_pointer"` IsSlice bool `json:"is_slice"` IsMap bool `json:"is_map"` diff --git a/scripts/jsonrpc_typings/typescript.go b/scripts/jsonrpc_typings/typescript.go index 8c986c57..32b5b22b 100644 --- a/scripts/jsonrpc_typings/typescript.go +++ b/scripts/jsonrpc_typings/typescript.go @@ -24,6 +24,7 @@ func generateTypeScriptTypings(schema *APISchema, searchPath string) string { "sub": func(a, b int) int { return a - b }, "pad": func(s string, width int) string { return padString(s, width) }, "padComment": func(fieldName, fieldType string) string { return padComment(fieldName, fieldType) }, + "isOptionalType": func(goType string) bool { return isOptionalType(goType) }, } // Parse the main template @@ -65,6 +66,11 @@ func padComment(fieldName, fieldType string) string { return strings.Repeat(" ", targetColumn-declarationLength) } +// isOptionalType determines if a Go type should be rendered as an optional TypeScript property +func isOptionalType(goType string) bool { + return goType == "null.String" || goType == "null.Bool" || goType == "null.Int" +} + // cleanTypeName cleans up Go type names for TypeScript func cleanTypeName(typeName string) string { // Remove package prefixes @@ -105,6 +111,12 @@ func parseTypeRecursively(goType string) string { return "string" case "usbgadget.ByteSlice": return "number[]" + case "null.String": + return "string" + case "null.Bool": + return "boolean" + case "null.Int": + return "number" case "interface {}": return "any" case "time.Duration": @@ -237,12 +249,15 @@ func hasParameters(handler APIHandler) bool { // typescriptTemplate is the main template for generating TypeScript definitions const typescriptTemplate = `// Code generated by generate_typings.go. DO NOT EDIT. {{range $struct := getAllStructs}} +{{if eq $struct.Kind "extension"}} +export interface {{cleanTypeName $struct.Name}} extends {{cleanTypeName $struct.Extends}} { +} +{{else}} export interface {{cleanTypeName $struct.Name}} { -{{range $field := $struct.Fields}} {{$field.JSONName}}: {{goToTypeScriptType $field.Type}};{{padComment $field.JSONName (goToTypeScriptType $field.Type)}}// {{$field.Name}} {{$field.Type}} +{{range $field := $struct.Fields}} {{$field.JSONName}}{{if isOptionalType $field.Type}}?{{end}}: {{goToTypeScriptType $field.Type}};{{padComment $field.JSONName (goToTypeScriptType $field.Type)}}// {{$field.Name}} {{$field.Type}} {{end}}} - {{end}} - +{{end}} // String aliases with constants {{range $alias := getStringAliasInfo}} export type {{$alias.Name}} = {{range $i, $const := $alias.Constants}}"{{$const}}"{{if lt $i (sub (len $alias.Constants) 1)}} | {{end}}{{end}}; @@ -276,11 +291,6 @@ export interface JsonRpcErrorResponse { export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse; -// Handler method types (generated from actual handlers) -export type JsonRpcMethod = -{{range $i, $method := getSortedMethods}}{{if $i}} | {{else}} | {{end}}"{{$method}}" -{{end}}; - // RPC Functions export class JsonRpcClient { constructor(private send: (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => void) {}