From 7f1a514e98ed468493a90e9c8567eb6ff217cd51 Mon Sep 17 00:00:00 2001 From: Vuong <3168632+vuon9@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:58:27 +0700 Subject: [PATCH 01/12] chore: add .worktrees to .gitignore for git worktrees support --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4d2d1e1..b0558aa 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ runtime-debug.js runtime.js .task/* /package.json +.worktrees/ From 76b7b71111d615a2ce3b0d378f58761a8b26c28f Mon Sep 17 00:00:00 2001 From: Vuong <3168632+vuon9@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:09:00 +0700 Subject: [PATCH 02/12] feat: add auto-discovery router with Gin integration --- pkg/router/binding.go | 12 ++++ pkg/router/router.go | 123 ++++++++++++++++++++++++++++++++++ pkg/router/router_test.go | 137 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 272 insertions(+) create mode 100644 pkg/router/binding.go create mode 100644 pkg/router/router.go create mode 100644 pkg/router/router_test.go diff --git a/pkg/router/binding.go b/pkg/router/binding.go new file mode 100644 index 0000000..060c5e1 --- /dev/null +++ b/pkg/router/binding.go @@ -0,0 +1,12 @@ +package router + +// RequestWrapper wraps requests for single-parameter methods +type RequestWrapper struct { + Args []interface{} `json:"args"` +} + +// ResponseWrapper standardizes API responses +type ResponseWrapper struct { + Data interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} diff --git a/pkg/router/router.go b/pkg/router/router.go new file mode 100644 index 0000000..726e611 --- /dev/null +++ b/pkg/router/router.go @@ -0,0 +1,123 @@ +package router + +import ( + "fmt" + "net/http" + "reflect" + "strings" + "unicode" + + "github.com/gin-gonic/gin" +) + +// Router automatically discovers and registers service methods as HTTP routes +type Router struct { + engine *gin.Engine +} + +// New creates a new Router with the given Gin engine +func New(engine *gin.Engine) *Router { + return &Router{engine: engine} +} + +// Register scans a service struct and auto-generates routes for all exported methods +func (r *Router) Register(service interface{}) error { + serviceType := reflect.TypeOf(service) + serviceValue := reflect.ValueOf(service) + + // Get service name and convert to kebab-case + serviceName := toKebabCase(serviceType.Elem().Name()) + + // Iterate through all methods + for i := 0; i < serviceType.NumMethod(); i++ { + method := serviceType.Method(i) + + // Skip unexported methods and lifecycle methods + if !method.IsExported() || isLifecycleMethod(method.Name) { + continue + } + + // Convert method name to kebab-case + methodName := toKebabCase(method.Name) + path := fmt.Sprintf("/api/%s/%s", serviceName, methodName) + + // Create handler + handler := r.createHandler(serviceValue.Method(i), method) + r.engine.POST(path, handler) + } + + return nil +} + +// createHandler creates a Gin handler for a method +func (r *Router) createHandler(methodValue reflect.Value, method reflect.Method) gin.HandlerFunc { + return func(c *gin.Context) { + // Get method signature + methodType := method.Type + numIn := methodType.NumIn() + + // Prepare arguments + args := make([]reflect.Value, numIn-1) // -1 because receiver is first + + if numIn > 1 { + // First argument should be a struct for JSON binding + argType := methodType.In(1) + argValue := reflect.New(argType).Interface() + + if err := c.ShouldBindJSON(argValue); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + args[0] = reflect.ValueOf(argValue).Elem() + } + + // Call the method + results := methodValue.Call(args) + + // Handle return values (result, error) + if len(results) == 2 { + // Check for error + if !results[1].IsNil() { + err := results[1].Interface().(error) + c.JSON(http.StatusOK, gin.H{"error": err.Error()}) + return + } + + // Return result + c.JSON(http.StatusOK, results[0].Interface()) + } else if len(results) == 1 { + c.JSON(http.StatusOK, results[0].Interface()) + } + } +} + +// toKebabCase converts PascalCase to kebab-case +func toKebabCase(s string) string { + var result strings.Builder + var prevUpper bool + + for i, r := range s { + isUpper := unicode.IsUpper(r) + + // Add hyphen before uppercase letter if: + // 1. Not at the start + // 2. Previous char was lowercase, OR + // 3. Current is uppercase but next is lowercase (transition from acronym to word) + if isUpper && i > 0 { + nextIsLower := i+1 < len(s) && unicode.IsLower(rune(s[i+1])) + if !prevUpper || nextIsLower { + result.WriteRune('-') + } + } + + result.WriteRune(unicode.ToLower(r)) + prevUpper = isUpper + } + return result.String() +} + +// isLifecycleMethod checks if method is a Wails lifecycle method +func isLifecycleMethod(name string) bool { + return name == "ServiceStartup" || name == "ServiceShutdown" +} diff --git a/pkg/router/router_test.go b/pkg/router/router_test.go new file mode 100644 index 0000000..0c55b7f --- /dev/null +++ b/pkg/router/router_test.go @@ -0,0 +1,137 @@ +package router + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +// Test service +type TestService struct{} + +type EchoRequest struct { + Message string `json:"message"` +} + +type EchoResponse struct { + Message string `json:"message"` +} + +func (s *TestService) Echo(req EchoRequest) EchoResponse { + return EchoResponse{Message: req.Message} +} + +func (s *TestService) ServiceStartup() error { + return nil +} + +func TestRouter_Register(t *testing.T) { + tests := []struct { + name string + requestBody map[string]string + expectedStatus int + expectedMsg string + }{ + { + name: "valid echo request", + requestBody: map[string]string{"message": "hello"}, + expectedStatus: http.StatusOK, + expectedMsg: "hello", + }, + { + name: "empty message", + requestBody: map[string]string{"message": ""}, + expectedStatus: http.StatusOK, + expectedMsg: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + router := New(r) + + service := &TestService{} + err := router.Register(service) + assert.NoError(t, err) + + body, _ := json.Marshal(tt.requestBody) + + w := httptest.NewRecorder() + httpReq, _ := http.NewRequest("POST", "/api/test-service/echo", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + + r.ServeHTTP(w, httpReq) + + assert.Equal(t, tt.expectedStatus, w.Code) + + var resp EchoResponse + err = json.Unmarshal(w.Body.Bytes(), &resp) + assert.NoError(t, err) + assert.Equal(t, tt.expectedMsg, resp.Message) + }) + } +} + +func TestToKebabCase(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"JWTService", "jwt-service"}, + {"Decode", "decode"}, + {"VerifyToken", "verify-token"}, + {"ABC", "abc"}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := toKebabCase(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsLifecycleMethod(t *testing.T) { + tests := []struct { + name string + expected bool + }{ + {"ServiceStartup", true}, + {"ServiceShutdown", true}, + {"Decode", false}, + {"Verify", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isLifecycleMethod(tt.name) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestRouter_LifecycleMethodsSkipped(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + router := New(r) + + service := &TestService{} + err := router.Register(service) + assert.NoError(t, err) + + // ServiceStartup should not be registered as a route + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/test-service/service-startup", nil) + r.ServeHTTP(w, req) + + // Should get 404 because lifecycle methods are skipped + assert.Equal(t, http.StatusNotFound, w.Code) +} From e83218267971315166f1daddf1fd9f18dff63e56 Mon Sep 17 00:00:00 2001 From: Vuong <3168632+vuon9@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:11:24 +0700 Subject: [PATCH 03/12] feat: integrate auto-discovery router into HTTP server --- go.mod | 10 +- go.sum | 13 +- pkg/router/server.go | 64 ++++++ server.go | 486 ++----------------------------------------- 4 files changed, 101 insertions(+), 472 deletions(-) create mode 100644 pkg/router/server.go diff --git a/go.mod b/go.mod index 7633a9e..07f0776 100644 --- a/go.mod +++ b/go.mod @@ -6,11 +6,13 @@ require ( github.com/boombuler/barcode v1.1.0 github.com/brianvoe/gofakeit/v7 v7.14.0 github.com/btcsuite/btcutil v1.0.2 + github.com/gin-contrib/cors v1.7.6 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a github.com/itchyny/gojq v0.12.18 github.com/pelletier/go-toml/v2 v2.2.4 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + github.com/stretchr/testify v1.11.1 github.com/wailsapp/wails/v3 v3.0.0-alpha.68 golang.org/x/crypto v0.47.0 golang.org/x/net v0.49.0 @@ -28,9 +30,10 @@ require ( github.com/cloudwego/base64x v0.1.6 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/ebitengine/purego v0.9.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.7.0 // indirect @@ -38,7 +41,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect @@ -47,9 +50,10 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lmittmann/tint v1.1.2 // indirect - github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect diff --git a/go.sum b/go.sum index f905058..4b72903 100644 --- a/go.sum +++ b/go.sum @@ -51,8 +51,10 @@ github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVo github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= +github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= @@ -79,8 +81,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= @@ -137,8 +139,9 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= diff --git a/pkg/router/server.go b/pkg/router/server.go new file mode 100644 index 0000000..3815df1 --- /dev/null +++ b/pkg/router/server.go @@ -0,0 +1,64 @@ +package router + +import ( + "fmt" + "net/http" + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +// Server represents the HTTP server with auto-discovery router +type Server struct { + router *Router + engine *gin.Engine +} + +// NewServer creates a new HTTP server +func NewServer() *Server { + gin.SetMode(gin.ReleaseMode) + engine := gin.New() + engine.Use(gin.Recovery()) + + // CORS configuration + config := cors.Config{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + } + engine.Use(cors.New(config)) + + // Health check + engine.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "mode": "web", + "time": time.Now().Format(time.RFC3339), + }) + }) + + return &Server{ + router: New(engine), + engine: engine, + } +} + +// Register adds a service to the router +func (s *Server) Register(service interface{}) error { + return s.router.Register(service) +} + +// Start starts the HTTP server on the specified port +func (s *Server) Start(port int) error { + addr := fmt.Sprintf(":%d", port) + return s.engine.Run(addr) +} + +// Engine returns the Gin engine for testing +func (s *Server) Engine() *gin.Engine { + return s.engine +} diff --git a/server.go b/server.go index 8eb88e0..2ba32fe 100644 --- a/server.go +++ b/server.go @@ -1,471 +1,29 @@ package main import ( - "devtoolbox/internal/barcode" - "devtoolbox/internal/codeformatter" - "devtoolbox/internal/datagenerator" - "devtoolbox/internal/datetimeconverter" + "devtoolbox/pkg/router" "devtoolbox/service" - "encoding/json" - "fmt" - "log" - "net/http" - "strings" ) -// TODO: Think of a way to expose this by Gin, so i can test it via Http client - -// Server represents the HTTP server for Web Mode -type Server struct { - jwtService *service.JWTService - conversionService *service.ConversionService - barcodeService *service.BarcodeService - dataGeneratorService *service.DataGeneratorService - codeFormatterService *service.CodeFormatterService - dateTimeService *service.DateTimeService -} - -// NewServer creates a new Server instance -func NewServer( - jwtService *service.JWTService, - conversionService *service.ConversionService, - barcodeService *service.BarcodeService, - dataGeneratorService *service.DataGeneratorService, - codeFormatterService *service.CodeFormatterService, - dateTimeService *service.DateTimeService, -) *Server { - return &Server{ - jwtService: jwtService, - conversionService: conversionService, - barcodeService: barcodeService, - dataGeneratorService: dataGeneratorService, - codeFormatterService: codeFormatterService, - dateTimeService: dateTimeService, - } -} - -// Start starts the HTTP server on the specified port -func (s *Server) Start(port int) { - mux := http.NewServeMux() - - // Generic API handler - mux.HandleFunc("/api/", s.handleAPI) - - // Enable CORS - handler := corsMiddleware(mux) - - addr := fmt.Sprintf(":%d", port) - log.Printf("Starting HTTP server for Web Mode on http://localhost%s", addr) - if err := http.ListenAndServe(addr, handler); err != nil { - log.Fatalf("Failed to start HTTP server: %v", err) - } -} - -// handleAPI routes requests to the appropriate service and method -func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - // Expected path: /api/Service/Method - path := strings.TrimPrefix(r.URL.Path, "/api/") - parts := strings.Split(path, "/") - - if len(parts) < 2 { - http.Error(w, "Invalid API path", http.StatusBadRequest) - return - } - - service := parts[0] - method := parts[1] - - if service == "JWTService" { - s.handleJWTService(method, w, r) - return - } - - if service == "ConversionService" { - s.handleConversionService(method, w, r) - return - } - - if service == "BarcodeService" { - s.handleBarcodeService(method, w, r) - return - } - - if service == "DataGeneratorService" { - s.handleDataGeneratorService(method, w, r) - return - } - - if service == "CodeFormatterService" { - s.handleCodeFormatterService(method, w, r) - return - } - - if service == "DateTimeService" { - s.handleDateTimeService(method, w, r) - return - } - - http.Error(w, fmt.Sprintf("Service not found: %s", service), http.StatusNotFound) -} - -func (s *Server) handleConversionService(method string, w http.ResponseWriter, r *http.Request) { - var payload struct { - Args []interface{} `json:"args"` - } - - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - var result interface{} - var err error - - switch method { - case "Convert": - if len(payload.Args) < 4 { - http.Error(w, "Missing arguments", http.StatusBadRequest) - return - } - input := payload.Args[0].(string) - category := payload.Args[1].(string) - cmd := payload.Args[2].(string) - config := payload.Args[3].(map[string]interface{}) - result, err = s.conversionService.Convert(input, category, cmd, config) - default: - http.Error(w, fmt.Sprintf("Method not found: %s", method), http.StatusNotFound) - return - } - - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(result) -} - -func (s *Server) handleJWTService(method string, w http.ResponseWriter, r *http.Request) { - var payload struct { - Args []interface{} `json:"args"` - } - - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - var result interface{} - var err error - - switch method { - case "Decode": - if len(payload.Args) < 1 { - http.Error(w, "Missing arguments", http.StatusBadRequest) - return - } - token := payload.Args[0].(string) - result, err = s.jwtService.Decode(token) - case "Verify": - if len(payload.Args) < 3 { - http.Error(w, "Missing arguments", http.StatusBadRequest) - return - } - token := payload.Args[0].(string) - secret := payload.Args[1].(string) - encoding := payload.Args[2].(string) - result, err = s.jwtService.Verify(token, secret, encoding) - case "Encode": - if len(payload.Args) < 4 { - http.Error(w, "Missing arguments", http.StatusBadRequest) - return - } - header := payload.Args[0].(string) - payloadStr := payload.Args[1].(string) - algo := payload.Args[2].(string) - secret := payload.Args[3].(string) - result, err = s.jwtService.Encode(header, payloadStr, algo, secret) - default: - http.Error(w, fmt.Sprintf("Method not found: %s", method), http.StatusNotFound) - return - } - - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(result) -} - -func (s *Server) handleBarcodeService(method string, w http.ResponseWriter, r *http.Request) { - var payload struct { - Args []interface{} `json:"args"` - } - - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - var result interface{} - var err error - - switch method { - case "GenerateQR": - if len(payload.Args) < 1 { - http.Error(w, "Missing arguments", http.StatusBadRequest) - return - } - // Parse the request from the first argument - reqData, ok := payload.Args[0].(map[string]interface{}) - if !ok { - http.Error(w, "Invalid request format", http.StatusBadRequest) - return - } - - req := barcode.GenerateBarcodeRequest{ - Content: getStringFromMap(reqData, "content"), - Standard: getStringFromMap(reqData, "standard"), - Size: getIntFromMap(reqData, "size"), - Level: getStringFromMap(reqData, "level"), - Format: getStringFromMap(reqData, "format"), - } - result = s.barcodeService.GenerateBarcode(req) - case "GetQRErrorLevels": - result = s.barcodeService.GetQRErrorLevels() - case "GetBarcodeSizes": - result = s.barcodeService.GetBarcodeSizes() - case "GetBarcodeStandards": - result = s.barcodeService.GetBarcodeStandards() - case "ValidateContent": - if len(payload.Args) < 2 { - http.Error(w, "Missing arguments", http.StatusBadRequest) - return - } - content := payload.Args[0].(string) - standard := payload.Args[1].(string) - result = s.barcodeService.ValidateContent(content, standard) - default: - http.Error(w, fmt.Sprintf("Method not found: %s", method), http.StatusNotFound) - return - } - - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(result) -} - -// Helper functions for type conversion -func getStringFromMap(m map[string]interface{}, key string) string { - if val, ok := m[key]; ok { - if str, ok := val.(string); ok { - return str - } - } - return "" -} - -func getIntFromMap(m map[string]interface{}, key string) int { - if val, ok := m[key]; ok { - switch v := val.(type) { - case int: - return v - case float64: - return int(v) - } - } - return 0 -} - -func (s *Server) handleDataGeneratorService(method string, w http.ResponseWriter, r *http.Request) { - var payload struct { - Args []interface{} `json:"args"` - } - - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - var result interface{} - - switch method { - case "Generate": - if len(payload.Args) < 1 { - http.Error(w, "Missing arguments", http.StatusBadRequest) - return - } - reqData, ok := payload.Args[0].(map[string]interface{}) - if !ok { - http.Error(w, "Invalid request format", http.StatusBadRequest) - return - } - - req := datagenerator.GenerateRequest{ - Template: getStringFromMap(reqData, "template"), - BatchCount: getIntFromMap(reqData, "batchCount"), - OutputFormat: getStringFromMap(reqData, "outputFormat"), - } - if vars, ok := reqData["variables"].(map[string]interface{}); ok { - req.Variables = vars - } - result = s.dataGeneratorService.Generate(req) - case "GetPresets": - result = s.dataGeneratorService.GetPresets() - case "ValidateTemplate": - if len(payload.Args) < 1 { - http.Error(w, "Missing arguments", http.StatusBadRequest) - return - } - template := payload.Args[0].(string) - result = s.dataGeneratorService.ValidateTemplate(template) - default: - http.Error(w, fmt.Sprintf("Method not found: %s", method), http.StatusNotFound) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(result) -} - -func (s *Server) handleCodeFormatterService(method string, w http.ResponseWriter, r *http.Request) { - var payload struct { - Args []interface{} `json:"args"` - } - - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - var result interface{} - - switch method { - case "Format": - if len(payload.Args) < 1 { - http.Error(w, "Missing arguments", http.StatusBadRequest) - return - } - reqData, ok := payload.Args[0].(map[string]interface{}) - if !ok { - http.Error(w, "Invalid request format", http.StatusBadRequest) - return - } - - req := codeformatter.FormatRequest{ - Input: getStringFromMap(reqData, "input"), - FormatType: getStringFromMap(reqData, "formatType"), - Filter: getStringFromMap(reqData, "filter"), - Minify: getBoolFromMap(reqData, "minify"), - } - result = s.codeFormatterService.Format(req) - default: - http.Error(w, fmt.Sprintf("Method not found: %s", method), http.StatusNotFound) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(result) -} - -func getBoolFromMap(m map[string]interface{}, key string) bool { - if val, ok := m[key]; ok { - if b, ok := val.(bool); ok { - return b - } - } - return false -} - -func (s *Server) handleDateTimeService(method string, w http.ResponseWriter, r *http.Request) { - var payload struct { - Args []interface{} `json:"args"` - } - - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - var result interface{} - var err error - - switch method { - case "Convert": - if len(payload.Args) < 1 { - http.Error(w, "Missing arguments", http.StatusBadRequest) - return - } - reqData, ok := payload.Args[0].(map[string]interface{}) - if !ok { - http.Error(w, "Invalid request format", http.StatusBadRequest) - return - } - - req := datetimeconverter.ConvertRequest{ - Input: getStringFromMap(reqData, "input"), - Precision: getStringFromMap(reqData, "precision"), - Timezone: getStringFromMap(reqData, "timezone"), - OutputFormat: getStringFromMap(reqData, "outputFormat"), - CustomFormat: getStringFromMap(reqData, "customFormat"), - } - result, err = s.dateTimeService.Convert(req) - case "GetPresets": - result, err = s.dateTimeService.GetPresets() - case "CalculateDelta": - if len(payload.Args) < 1 { - http.Error(w, "Missing arguments", http.StatusBadRequest) - return - } - reqData, ok := payload.Args[0].(map[string]interface{}) - if !ok { - http.Error(w, "Invalid request format", http.StatusBadRequest) - return - } - - req := datetimeconverter.DeltaRequest{ - DateA: getStringFromMap(reqData, "dateA"), - DateB: getStringFromMap(reqData, "dateB"), - } - result, err = s.dateTimeService.CalculateDelta(req) - case "GetAvailableTimezones": - result, err = s.dateTimeService.GetAvailableTimezones() - default: - http.Error(w, fmt.Sprintf("Method not found: %s", method), http.StatusNotFound) - return - } - - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(result) -} - -func corsMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") - w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") - - if r.Method == "OPTIONS" { - return - } - - next.ServeHTTP(w, r) - }) +// StartHTTPServer starts the HTTP server with all services registered +func StartHTTPServer(port int) { + // Create services + jwtSvc := service.NewJWTService(nil) + conversionSvc := service.NewConversionService(nil) + barcodeSvc := service.NewBarcodeService(nil) + dataGenSvc := service.NewDataGeneratorService(nil) + codeFmtSvc := service.NewCodeFormatterService(nil) + dateTimeSvc := service.NewDateTimeService(nil) + + // Create server and register services + server := router.NewServer() + server.Register(jwtSvc) + server.Register(conversionSvc) + server.Register(barcodeSvc) + server.Register(dataGenSvc) + server.Register(codeFmtSvc) + server.Register(dateTimeSvc) + + // Start server + server.Start(port) } From 7fa24feeeabd0dba0c9cb26c77daea5f3236cf94 Mon Sep 17 00:00:00 2001 From: Vuong <3168632+vuon9@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:12:40 +0700 Subject: [PATCH 04/12] feat: enable dual-mode with HTTP server on port 8081 --- main.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/main.go b/main.go index 4af1459..ec84d96 100644 --- a/main.go +++ b/main.go @@ -65,6 +65,11 @@ func main() { app.RegisterService(application.NewService(service.NewDataGeneratorService(app))) app.RegisterService(application.NewService(service.NewCodeFormatterService(app))) + // Start HTTP server for browser support (background) + go func() { + StartHTTPServer(8081) + }() + app.Window.NewWithOptions(application.WebviewWindowOptions{ Title: "DevToolbox", Width: 1024, From 02554f38345e2aca0e95123f71675385e13b5ff2 Mon Sep 17 00:00:00 2001 From: Vuong <3168632+vuon9@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:18:05 +0700 Subject: [PATCH 05/12] feat: add TypeScript client generator tool --- cmd/genservices/generator.go | 175 ++++++++++++++++++++++ cmd/genservices/main.go | 54 +++++++ cmd/genservices/parser.go | 164 ++++++++++++++++++++ cmd/genservices/templates/typescript.tmpl | 33 ++++ 4 files changed, 426 insertions(+) create mode 100644 cmd/genservices/generator.go create mode 100644 cmd/genservices/main.go create mode 100644 cmd/genservices/parser.go create mode 100644 cmd/genservices/templates/typescript.tmpl diff --git a/cmd/genservices/generator.go b/cmd/genservices/generator.go new file mode 100644 index 0000000..398dcb3 --- /dev/null +++ b/cmd/genservices/generator.go @@ -0,0 +1,175 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "text/template" +) + +// Generator generates TypeScript code +type Generator struct { + outputDir string + tmpl *template.Template +} + +// NewGenerator creates a new generator +func NewGenerator(outputDir string) (*Generator, error) { + tmplPath := filepath.Join("cmd", "genservices", "templates", "typescript.tmpl") + tmplContent, err := os.ReadFile(tmplPath) + if err != nil { + return nil, err + } + + tmpl, err := template.New("typescript").Parse(string(tmplContent)) + if err != nil { + return nil, err + } + + return &Generator{ + outputDir: outputDir, + tmpl: tmpl, + }, nil +} + +// Generate creates TypeScript files for all services +func (g *Generator) Generate(services []Service) error { + // Create output directories + wailsDir := filepath.Join(g.outputDir, "wails") + httpDir := filepath.Join(g.outputDir, "http") + + os.MkdirAll(wailsDir, 0755) + os.MkdirAll(httpDir, 0755) + + // Generate individual service files + for _, service := range services { + if err := g.generateWailsService(wailsDir, service); err != nil { + return err + } + if err := g.generateHTTPService(httpDir, service); err != nil { + return err + } + } + + // Generate index files + if err := g.generateWailsIndex(wailsDir, services); err != nil { + return err + } + if err := g.generateHTTPIndex(httpDir, services); err != nil { + return err + } + + // Generate unified facade + return g.generateUnifiedFacade(g.outputDir, services) +} + +func (g *Generator) generateWailsService(dir string, service Service) error { + filename := filepath.Join(dir, toCamelCase(service.Name)+".ts") + + data := struct { + ServiceName string + Methods []ServiceMethod + }{ + ServiceName: service.Name, + Methods: service.Methods, + } + + file, err := os.Create(filename) + if err != nil { + return err + } + defer file.Close() + + return g.tmpl.ExecuteTemplate(file, "wails", data) +} + +func (g *Generator) generateHTTPService(dir string, service Service) error { + filename := filepath.Join(dir, toCamelCase(service.Name)+".ts") + + data := struct { + ServiceName string + Methods []ServiceMethod + }{ + ServiceName: service.Name, + Methods: service.Methods, + } + + file, err := os.Create(filename) + if err != nil { + return err + } + defer file.Close() + + return g.tmpl.ExecuteTemplate(file, "http", data) +} + +func (g *Generator) generateWailsIndex(dir string, services []Service) error { + filename := filepath.Join(dir, "index.ts") + + var exports []string + for _, svc := range services { + exports = append(exports, fmt.Sprintf("export * as %s from './%s';", + toCamelCase(svc.Name), toCamelCase(svc.Name))) + } + + content := strings.Join(exports, "\n") + return os.WriteFile(filename, []byte(content), 0644) +} + +func (g *Generator) generateHTTPIndex(dir string, services []Service) error { + filename := filepath.Join(dir, "index.ts") + + var exports []string + for _, svc := range services { + exports = append(exports, fmt.Sprintf("export * as %s from './%s';", + toCamelCase(svc.Name), toCamelCase(svc.Name))) + } + + content := strings.Join(exports, "\n") + return os.WriteFile(filename, []byte(content), 0644) +} + +func (g *Generator) generateUnifiedFacade(dir string, services []Service) error { + filename := filepath.Join(dir, "index.ts") + + var serviceImports []string + var serviceMappings []string + + for _, svc := range services { + camelName := toCamelCase(svc.Name) + serviceImports = append(serviceImports, fmt.Sprintf( + "import { %s as Wails%s } from './wails/%s';\n"+ + "import { %s as HTTP%s } from './http/%s';", + svc.Name, svc.Name, camelName, + svc.Name, svc.Name, camelName)) + + serviceMappings = append(serviceMappings, fmt.Sprintf( + "export const %s = isWails() ? Wails%s : HTTP%s;", + camelName, svc.Name, svc.Name)) + } + + content := fmt.Sprintf(`// Auto-generated unified service facade +// Detects runtime environment and uses appropriate implementation + +const isWails = () => { + return typeof window !== 'undefined' && + window.runtime && + window.runtime.EventsOn !== undefined; +}; + +%s + +%s +`, strings.Join(serviceImports, "\n"), strings.Join(serviceMappings, "\n")) + + return os.WriteFile(filename, []byte(content), 0644) +} + +// toCamelCase converts PascalCase to camelCase +func toCamelCase(s string) string { + if s == "" { + return s + } + return strings.ToLower(s[:1]) + s[1:] +} diff --git a/cmd/genservices/main.go b/cmd/genservices/main.go new file mode 100644 index 0000000..4427d0d --- /dev/null +++ b/cmd/genservices/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "flag" + "fmt" + "log" + "path/filepath" +) + +func main() { + var ( + serviceDir = flag.String("services", "service", "Directory containing Go service files") + outputDir = flag.String("output", "frontend/src/generated", "Output directory for generated TypeScript") + ) + flag.Parse() + + // Get absolute paths + absServiceDir, err := filepath.Abs(*serviceDir) + if err != nil { + log.Fatal(err) + } + + absOutputDir, err := filepath.Abs(*outputDir) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Parsing services from: %s\n", absServiceDir) + fmt.Printf("Generating TypeScript to: %s\n", absOutputDir) + + // Parse services + parser := NewParser(absServiceDir) + services, err := parser.ParseServices() + if err != nil { + log.Fatal("Failed to parse services:", err) + } + + fmt.Printf("Found %d services\n", len(services)) + for _, svc := range services { + fmt.Printf(" - %s (%d methods)\n", svc.Name, len(svc.Methods)) + } + + // Generate TypeScript + generator, err := NewGenerator(absOutputDir) + if err != nil { + log.Fatal("Failed to create generator:", err) + } + + if err := generator.Generate(services); err != nil { + log.Fatal("Failed to generate TypeScript:", err) + } + + fmt.Println("✓ Generation complete!") +} diff --git a/cmd/genservices/parser.go b/cmd/genservices/parser.go new file mode 100644 index 0000000..311c49a --- /dev/null +++ b/cmd/genservices/parser.go @@ -0,0 +1,164 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" +) + +// ServiceMethod represents a method in a service +type ServiceMethod struct { + Name string + Parameters []Parameter + Returns []Parameter +} + +// Parameter represents a method parameter +type Parameter struct { + Name string + Type string +} + +// Service represents a parsed service +type Service struct { + Name string + Methods []ServiceMethod +} + +// Parser parses Go service files +type Parser struct { + serviceDir string +} + +// NewParser creates a new parser +func NewParser(serviceDir string) *Parser { + return &Parser{serviceDir: serviceDir} +} + +// ParseServices parses all service files in the directory +func (p *Parser) ParseServices() ([]Service, error) { + fset := token.NewFileSet() + + // Parse all Go files in service directory + pkgs, err := parser.ParseDir(fset, p.serviceDir, nil, 0) + if err != nil { + return nil, err + } + + var services []Service + + for _, pkg := range pkgs { + for filename, file := range pkg.Files { + if strings.HasSuffix(filename, "_test.go") { + continue + } + + service := p.parseFile(file) + if service != nil { + services = append(services, *service) + } + } + } + + return services, nil +} + +// parseFile parses a single Go file and extracts services +func (p *Parser) parseFile(file *ast.File) *Service { + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.TYPE { + continue + } + + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + + _, ok = typeSpec.Type.(*ast.StructType) + if !ok { + continue + } + + // Check if it's a service (has Service suffix or contains service methods) + if strings.HasSuffix(typeSpec.Name.Name, "Service") { + service := &Service{ + Name: typeSpec.Name.Name, + } + + // Find methods for this type + service.Methods = p.findMethods(file, typeSpec.Name.Name) + + return service + } + } + } + + return nil +} + +// findMethods finds all methods for a given type +func (p *Parser) findMethods(file *ast.File, typeName string) []ServiceMethod { + var methods []ServiceMethod + + for _, decl := range file.Decls { + funcDecl, ok := decl.(*ast.FuncDecl) + if !ok || funcDecl.Recv == nil { + continue + } + + // Check if this method belongs to our type + for _, recv := range funcDecl.Recv.List { + recvType := p.getTypeString(recv.Type) + if recvType == "*"+typeName || recvType == typeName { + method := ServiceMethod{ + Name: funcDecl.Name.Name, + } + + // Parse parameters (skip receiver) + if funcDecl.Type.Params != nil { + for _, param := range funcDecl.Type.Params.List { + paramType := p.getTypeString(param.Type) + for _, name := range param.Names { + method.Parameters = append(method.Parameters, Parameter{ + Name: name.Name, + Type: paramType, + }) + } + } + } + + // Parse returns + if funcDecl.Type.Results != nil { + for _, result := range funcDecl.Type.Results.List { + resultType := p.getTypeString(result.Type) + method.Returns = append(method.Returns, Parameter{ + Type: resultType, + }) + } + } + + methods = append(methods, method) + } + } + } + + return methods +} + +// getTypeString converts an AST type to a string +func (p *Parser) getTypeString(expr ast.Expr) string { + switch t := expr.(type) { + case *ast.Ident: + return t.Name + case *ast.StarExpr: + return "*" + p.getTypeString(t.X) + case *ast.SelectorExpr: + return p.getTypeString(t.X) + "." + t.Sel.Name + default: + return "" + } +} diff --git a/cmd/genservices/templates/typescript.tmpl b/cmd/genservices/templates/typescript.tmpl new file mode 100644 index 0000000..f00cfb6 --- /dev/null +++ b/cmd/genservices/templates/typescript.tmpl @@ -0,0 +1,33 @@ +{{define "wails"}}// Auto-generated Wails client for {{.ServiceName}} +// This file is auto-generated. DO NOT EDIT. + +import { {{.ServiceName}} } from '../../../bindings/devtoolbox/service'; + +{{range .Methods}} +export const {{toCamelCase .Name}} = ({{range $i, $p := .Parameters}}{{if $i}}, {{end}}{{$p.Name}}: {{goToTS $p.Type}}{{end}}): Promise<{{goToTS (index .Returns 0).Type}}> => { + return {{$.ServiceName}}.{{.Name}}({{range $i, $p := .Parameters}}{{if $i}}, {{end}}{{$p.Name}}{{end}}); +}; +{{end}} +{{end}} + +{{define "http"}}// Auto-generated HTTP client for {{.ServiceName}} +// This file is auto-generated. DO NOT EDIT. + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081'; + +{{range .Methods}} +export const {{toCamelCase .Name}} = async ({{range $i, $p := .Parameters}}{{if $i}}, {{end}}{{$p.Name}}: {{goToTS $p.Type}}{{end}}): Promise<{{goToTS (index .Returns 0).Type}}> => { + const response = await fetch(`${API_BASE}/api/{{kebabCase $.ServiceName}}/{{kebabCase .Name}}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({{if eq (len .Parameters) 1}}{{index .Parameters 0).Name}}{{else}}{ {{range $i, $p := .Parameters}}{{if $i}}, {{end}}"{{$p.Name}}": {{$p.Name}}{{end}} }{{end}}) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +}; +{{end}} +{{end}} From cae8a3ffe0f33baf8d79eade22c0a6ab9eff43e3 Mon Sep 17 00:00:00 2001 From: Vuong <3168632+vuon9@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:22:32 +0700 Subject: [PATCH 06/12] feat: generate TypeScript clients for all services --- cmd/genservices/generator.go | 12 ++- cmd/genservices/templates/typescript.tmpl | 12 +-- frontend/src/generated/http/barcodeService.ts | 90 +++++++++++++++++++ .../generated/http/codeFormatterService.ts | 34 +++++++ .../src/generated/http/conversionService.ts | 34 +++++++ .../generated/http/dataGeneratorService.ts | 62 +++++++++++++ .../src/generated/http/dateTimeService.ts | 76 ++++++++++++++++ frontend/src/generated/http/index.ts | 6 ++ frontend/src/generated/http/jWTService.ts | 62 +++++++++++++ frontend/src/generated/index.ts | 28 ++++++ .../src/generated/wails/barcodeService.ts | 30 +++++++ .../generated/wails/codeFormatterService.ts | 14 +++ .../src/generated/wails/conversionService.ts | 14 +++ .../generated/wails/dataGeneratorService.ts | 22 +++++ .../src/generated/wails/dateTimeService.ts | 26 ++++++ frontend/src/generated/wails/index.ts | 6 ++ frontend/src/generated/wails/jWTService.ts | 22 +++++ 17 files changed, 537 insertions(+), 13 deletions(-) create mode 100644 frontend/src/generated/http/barcodeService.ts create mode 100644 frontend/src/generated/http/codeFormatterService.ts create mode 100644 frontend/src/generated/http/conversionService.ts create mode 100644 frontend/src/generated/http/dataGeneratorService.ts create mode 100644 frontend/src/generated/http/dateTimeService.ts create mode 100644 frontend/src/generated/http/index.ts create mode 100644 frontend/src/generated/http/jWTService.ts create mode 100644 frontend/src/generated/index.ts create mode 100644 frontend/src/generated/wails/barcodeService.ts create mode 100644 frontend/src/generated/wails/codeFormatterService.ts create mode 100644 frontend/src/generated/wails/conversionService.ts create mode 100644 frontend/src/generated/wails/dataGeneratorService.ts create mode 100644 frontend/src/generated/wails/dateTimeService.ts create mode 100644 frontend/src/generated/wails/index.ts create mode 100644 frontend/src/generated/wails/jWTService.ts diff --git a/cmd/genservices/generator.go b/cmd/genservices/generator.go index 398dcb3..c0062cc 100644 --- a/cmd/genservices/generator.go +++ b/cmd/genservices/generator.go @@ -1,6 +1,7 @@ package main import ( + _ "embed" "fmt" "os" "path/filepath" @@ -8,6 +9,9 @@ import ( "text/template" ) +//go:embed templates/typescript.tmpl +var typescriptTemplate string + // Generator generates TypeScript code type Generator struct { outputDir string @@ -16,13 +20,7 @@ type Generator struct { // NewGenerator creates a new generator func NewGenerator(outputDir string) (*Generator, error) { - tmplPath := filepath.Join("cmd", "genservices", "templates", "typescript.tmpl") - tmplContent, err := os.ReadFile(tmplPath) - if err != nil { - return nil, err - } - - tmpl, err := template.New("typescript").Parse(string(tmplContent)) + tmpl, err := template.New("typescript").Parse(typescriptTemplate) if err != nil { return nil, err } diff --git a/cmd/genservices/templates/typescript.tmpl b/cmd/genservices/templates/typescript.tmpl index f00cfb6..f904105 100644 --- a/cmd/genservices/templates/typescript.tmpl +++ b/cmd/genservices/templates/typescript.tmpl @@ -4,9 +4,9 @@ import { {{.ServiceName}} } from '../../../bindings/devtoolbox/service'; {{range .Methods}} -export const {{toCamelCase .Name}} = ({{range $i, $p := .Parameters}}{{if $i}}, {{end}}{{$p.Name}}: {{goToTS $p.Type}}{{end}}): Promise<{{goToTS (index .Returns 0).Type}}> => { +export function {{.Name}}({{range $i, $p := .Parameters}}{{if $i}}, {{end}}{{$p.Name}}: {{$p.Type}}{{end}}): Promise<{{(index .Returns 0).Type}}> { return {{$.ServiceName}}.{{.Name}}({{range $i, $p := .Parameters}}{{if $i}}, {{end}}{{$p.Name}}{{end}}); -}; +} {{end}} {{end}} @@ -16,11 +16,11 @@ export const {{toCamelCase .Name}} = ({{range $i, $p := .Parameters}}{{if $i}}, const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081'; {{range .Methods}} -export const {{toCamelCase .Name}} = async ({{range $i, $p := .Parameters}}{{if $i}}, {{end}}{{$p.Name}}: {{goToTS $p.Type}}{{end}}): Promise<{{goToTS (index .Returns 0).Type}}> => { - const response = await fetch(`${API_BASE}/api/{{kebabCase $.ServiceName}}/{{kebabCase .Name}}`, { +export async function {{.Name}}({{range $i, $p := .Parameters}}{{if $i}}, {{end}}{{$p.Name}}: {{$p.Type}}{{end}}): Promise<{{(index .Returns 0).Type}}> { + const response = await fetch(`${API_BASE}/api/{{$.ServiceName}}/{{.Name}}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({{if eq (len .Parameters) 1}}{{index .Parameters 0).Name}}{{else}}{ {{range $i, $p := .Parameters}}{{if $i}}, {{end}}"{{$p.Name}}": {{$p.Name}}{{end}} }{{end}}) + body: JSON.stringify({ {{range $i, $p := .Parameters}}{{if $i}}, {{end}}{{$p.Name}}{{end}} }) }); if (!response.ok) { @@ -28,6 +28,6 @@ export const {{toCamelCase .Name}} = async ({{range $i, $p := .Parameters}}{{if } return await response.json(); -}; +} {{end}} {{end}} diff --git a/frontend/src/generated/http/barcodeService.ts b/frontend/src/generated/http/barcodeService.ts new file mode 100644 index 0000000..7d49193 --- /dev/null +++ b/frontend/src/generated/http/barcodeService.ts @@ -0,0 +1,90 @@ +// Auto-generated HTTP client for BarcodeService +// This file is auto-generated. DO NOT EDIT. + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081'; + + +export async function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { + const response = await fetch(`${API_BASE}/api/BarcodeService/ServiceStartup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ctx, options }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + +export async function GenerateBarcode(req: barcode.GenerateBarcodeRequest): Promise { + const response = await fetch(`${API_BASE}/api/BarcodeService/GenerateBarcode`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ req }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + +export async function GetBarcodeStandards(): Promise<> { + const response = await fetch(`${API_BASE}/api/BarcodeService/GetBarcodeStandards`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + +export async function GetQRErrorLevels(): Promise<> { + const response = await fetch(`${API_BASE}/api/BarcodeService/GetQRErrorLevels`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + +export async function GetBarcodeSizes(): Promise<> { + const response = await fetch(`${API_BASE}/api/BarcodeService/GetBarcodeSizes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + +export async function ValidateContent(content: string, standard: string): Promise<> { + const response = await fetch(`${API_BASE}/api/BarcodeService/ValidateContent`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content, standard }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + diff --git a/frontend/src/generated/http/codeFormatterService.ts b/frontend/src/generated/http/codeFormatterService.ts new file mode 100644 index 0000000..f574674 --- /dev/null +++ b/frontend/src/generated/http/codeFormatterService.ts @@ -0,0 +1,34 @@ +// Auto-generated HTTP client for CodeFormatterService +// This file is auto-generated. DO NOT EDIT. + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081'; + + +export async function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { + const response = await fetch(`${API_BASE}/api/CodeFormatterService/ServiceStartup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ctx, options }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + +export async function Format(req: codeformatter.FormatRequest): Promise { + const response = await fetch(`${API_BASE}/api/CodeFormatterService/Format`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ req }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + diff --git a/frontend/src/generated/http/conversionService.ts b/frontend/src/generated/http/conversionService.ts new file mode 100644 index 0000000..9249293 --- /dev/null +++ b/frontend/src/generated/http/conversionService.ts @@ -0,0 +1,34 @@ +// Auto-generated HTTP client for ConversionService +// This file is auto-generated. DO NOT EDIT. + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081'; + + +export async function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { + const response = await fetch(`${API_BASE}/api/ConversionService/ServiceStartup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ctx, options }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + +export async function Convert(input: string, category: string, method: string, config: ): Promise { + const response = await fetch(`${API_BASE}/api/ConversionService/Convert`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input, category, method, config }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + diff --git a/frontend/src/generated/http/dataGeneratorService.ts b/frontend/src/generated/http/dataGeneratorService.ts new file mode 100644 index 0000000..8fbdaae --- /dev/null +++ b/frontend/src/generated/http/dataGeneratorService.ts @@ -0,0 +1,62 @@ +// Auto-generated HTTP client for DataGeneratorService +// This file is auto-generated. DO NOT EDIT. + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081'; + + +export async function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { + const response = await fetch(`${API_BASE}/api/DataGeneratorService/ServiceStartup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ctx, options }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + +export async function Generate(req: datagenerator.GenerateRequest): Promise { + const response = await fetch(`${API_BASE}/api/DataGeneratorService/Generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ req }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + +export async function GetPresets(): Promise { + const response = await fetch(`${API_BASE}/api/DataGeneratorService/GetPresets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + +export async function ValidateTemplate(template: string): Promise { + const response = await fetch(`${API_BASE}/api/DataGeneratorService/ValidateTemplate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ template }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + diff --git a/frontend/src/generated/http/dateTimeService.ts b/frontend/src/generated/http/dateTimeService.ts new file mode 100644 index 0000000..0bb1b22 --- /dev/null +++ b/frontend/src/generated/http/dateTimeService.ts @@ -0,0 +1,76 @@ +// Auto-generated HTTP client for DateTimeService +// This file is auto-generated. DO NOT EDIT. + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081'; + + +export async function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { + const response = await fetch(`${API_BASE}/api/DateTimeService/ServiceStartup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ctx, options }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + +export async function Convert(req: datetimeconverter.ConvertRequest): Promise { + const response = await fetch(`${API_BASE}/api/DateTimeService/Convert`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ req }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + +export async function GetPresets(): Promise { + const response = await fetch(`${API_BASE}/api/DateTimeService/GetPresets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + +export async function CalculateDelta(req: datetimeconverter.DeltaRequest): Promise { + const response = await fetch(`${API_BASE}/api/DateTimeService/CalculateDelta`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ req }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + +export async function GetAvailableTimezones(): Promise { + const response = await fetch(`${API_BASE}/api/DateTimeService/GetAvailableTimezones`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + diff --git a/frontend/src/generated/http/index.ts b/frontend/src/generated/http/index.ts new file mode 100644 index 0000000..3876824 --- /dev/null +++ b/frontend/src/generated/http/index.ts @@ -0,0 +1,6 @@ +export * as barcodeService from './barcodeService'; +export * as codeFormatterService from './codeFormatterService'; +export * as conversionService from './conversionService'; +export * as dataGeneratorService from './dataGeneratorService'; +export * as dateTimeService from './dateTimeService'; +export * as jWTService from './jWTService'; \ No newline at end of file diff --git a/frontend/src/generated/http/jWTService.ts b/frontend/src/generated/http/jWTService.ts new file mode 100644 index 0000000..5c013f9 --- /dev/null +++ b/frontend/src/generated/http/jWTService.ts @@ -0,0 +1,62 @@ +// Auto-generated HTTP client for JWTService +// This file is auto-generated. DO NOT EDIT. + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081'; + + +export async function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { + const response = await fetch(`${API_BASE}/api/JWTService/ServiceStartup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ctx, options }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + +export async function Decode(token: string): Promise { + const response = await fetch(`${API_BASE}/api/JWTService/Decode`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + +export async function Verify(token: string, secret: string, encoding: string): Promise { + const response = await fetch(`${API_BASE}/api/JWTService/Verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, secret, encoding }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + +export async function Encode(headerJSON: string, payloadJSON: string, algorithm: string, secret: string): Promise { + const response = await fetch(`${API_BASE}/api/JWTService/Encode`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ headerJSON, payloadJSON, algorithm, secret }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + diff --git a/frontend/src/generated/index.ts b/frontend/src/generated/index.ts new file mode 100644 index 0000000..7bc5703 --- /dev/null +++ b/frontend/src/generated/index.ts @@ -0,0 +1,28 @@ +// Auto-generated unified service facade +// Detects runtime environment and uses appropriate implementation + +const isWails = () => { + return typeof window !== 'undefined' && + window.runtime && + window.runtime.EventsOn !== undefined; +}; + +import { BarcodeService as WailsBarcodeService } from './wails/barcodeService'; +import { BarcodeService as HTTPBarcodeService } from './http/barcodeService'; +import { CodeFormatterService as WailsCodeFormatterService } from './wails/codeFormatterService'; +import { CodeFormatterService as HTTPCodeFormatterService } from './http/codeFormatterService'; +import { ConversionService as WailsConversionService } from './wails/conversionService'; +import { ConversionService as HTTPConversionService } from './http/conversionService'; +import { DataGeneratorService as WailsDataGeneratorService } from './wails/dataGeneratorService'; +import { DataGeneratorService as HTTPDataGeneratorService } from './http/dataGeneratorService'; +import { DateTimeService as WailsDateTimeService } from './wails/dateTimeService'; +import { DateTimeService as HTTPDateTimeService } from './http/dateTimeService'; +import { JWTService as WailsJWTService } from './wails/jWTService'; +import { JWTService as HTTPJWTService } from './http/jWTService'; + +export const barcodeService = isWails() ? WailsBarcodeService : HTTPBarcodeService; +export const codeFormatterService = isWails() ? WailsCodeFormatterService : HTTPCodeFormatterService; +export const conversionService = isWails() ? WailsConversionService : HTTPConversionService; +export const dataGeneratorService = isWails() ? WailsDataGeneratorService : HTTPDataGeneratorService; +export const dateTimeService = isWails() ? WailsDateTimeService : HTTPDateTimeService; +export const jWTService = isWails() ? WailsJWTService : HTTPJWTService; diff --git a/frontend/src/generated/wails/barcodeService.ts b/frontend/src/generated/wails/barcodeService.ts new file mode 100644 index 0000000..66f7b8f --- /dev/null +++ b/frontend/src/generated/wails/barcodeService.ts @@ -0,0 +1,30 @@ +// Auto-generated Wails client for BarcodeService +// This file is auto-generated. DO NOT EDIT. + +import { BarcodeService } from '../../../bindings/devtoolbox/service'; + + +export function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { + return BarcodeService.ServiceStartup(ctx, options); +} + +export function GenerateBarcode(req: barcode.GenerateBarcodeRequest): Promise { + return BarcodeService.GenerateBarcode(req); +} + +export function GetBarcodeStandards(): Promise<> { + return BarcodeService.GetBarcodeStandards(); +} + +export function GetQRErrorLevels(): Promise<> { + return BarcodeService.GetQRErrorLevels(); +} + +export function GetBarcodeSizes(): Promise<> { + return BarcodeService.GetBarcodeSizes(); +} + +export function ValidateContent(content: string, standard: string): Promise<> { + return BarcodeService.ValidateContent(content, standard); +} + diff --git a/frontend/src/generated/wails/codeFormatterService.ts b/frontend/src/generated/wails/codeFormatterService.ts new file mode 100644 index 0000000..a649dfb --- /dev/null +++ b/frontend/src/generated/wails/codeFormatterService.ts @@ -0,0 +1,14 @@ +// Auto-generated Wails client for CodeFormatterService +// This file is auto-generated. DO NOT EDIT. + +import { CodeFormatterService } from '../../../bindings/devtoolbox/service'; + + +export function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { + return CodeFormatterService.ServiceStartup(ctx, options); +} + +export function Format(req: codeformatter.FormatRequest): Promise { + return CodeFormatterService.Format(req); +} + diff --git a/frontend/src/generated/wails/conversionService.ts b/frontend/src/generated/wails/conversionService.ts new file mode 100644 index 0000000..e17af8f --- /dev/null +++ b/frontend/src/generated/wails/conversionService.ts @@ -0,0 +1,14 @@ +// Auto-generated Wails client for ConversionService +// This file is auto-generated. DO NOT EDIT. + +import { ConversionService } from '../../../bindings/devtoolbox/service'; + + +export function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { + return ConversionService.ServiceStartup(ctx, options); +} + +export function Convert(input: string, category: string, method: string, config: ): Promise { + return ConversionService.Convert(input, category, method, config); +} + diff --git a/frontend/src/generated/wails/dataGeneratorService.ts b/frontend/src/generated/wails/dataGeneratorService.ts new file mode 100644 index 0000000..c8e82da --- /dev/null +++ b/frontend/src/generated/wails/dataGeneratorService.ts @@ -0,0 +1,22 @@ +// Auto-generated Wails client for DataGeneratorService +// This file is auto-generated. DO NOT EDIT. + +import { DataGeneratorService } from '../../../bindings/devtoolbox/service'; + + +export function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { + return DataGeneratorService.ServiceStartup(ctx, options); +} + +export function Generate(req: datagenerator.GenerateRequest): Promise { + return DataGeneratorService.Generate(req); +} + +export function GetPresets(): Promise { + return DataGeneratorService.GetPresets(); +} + +export function ValidateTemplate(template: string): Promise { + return DataGeneratorService.ValidateTemplate(template); +} + diff --git a/frontend/src/generated/wails/dateTimeService.ts b/frontend/src/generated/wails/dateTimeService.ts new file mode 100644 index 0000000..c566726 --- /dev/null +++ b/frontend/src/generated/wails/dateTimeService.ts @@ -0,0 +1,26 @@ +// Auto-generated Wails client for DateTimeService +// This file is auto-generated. DO NOT EDIT. + +import { DateTimeService } from '../../../bindings/devtoolbox/service'; + + +export function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { + return DateTimeService.ServiceStartup(ctx, options); +} + +export function Convert(req: datetimeconverter.ConvertRequest): Promise { + return DateTimeService.Convert(req); +} + +export function GetPresets(): Promise { + return DateTimeService.GetPresets(); +} + +export function CalculateDelta(req: datetimeconverter.DeltaRequest): Promise { + return DateTimeService.CalculateDelta(req); +} + +export function GetAvailableTimezones(): Promise { + return DateTimeService.GetAvailableTimezones(); +} + diff --git a/frontend/src/generated/wails/index.ts b/frontend/src/generated/wails/index.ts new file mode 100644 index 0000000..3876824 --- /dev/null +++ b/frontend/src/generated/wails/index.ts @@ -0,0 +1,6 @@ +export * as barcodeService from './barcodeService'; +export * as codeFormatterService from './codeFormatterService'; +export * as conversionService from './conversionService'; +export * as dataGeneratorService from './dataGeneratorService'; +export * as dateTimeService from './dateTimeService'; +export * as jWTService from './jWTService'; \ No newline at end of file diff --git a/frontend/src/generated/wails/jWTService.ts b/frontend/src/generated/wails/jWTService.ts new file mode 100644 index 0000000..acbfc75 --- /dev/null +++ b/frontend/src/generated/wails/jWTService.ts @@ -0,0 +1,22 @@ +// Auto-generated Wails client for JWTService +// This file is auto-generated. DO NOT EDIT. + +import { JWTService } from '../../../bindings/devtoolbox/service'; + + +export function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { + return JWTService.ServiceStartup(ctx, options); +} + +export function Decode(token: string): Promise { + return JWTService.Decode(token); +} + +export function Verify(token: string, secret: string, encoding: string): Promise { + return JWTService.Verify(token, secret, encoding); +} + +export function Encode(headerJSON: string, payloadJSON: string, algorithm: string, secret: string): Promise { + return JWTService.Encode(headerJSON, payloadJSON, algorithm, secret); +} + From 4701c3424c89c71c0d40389be5b5cd6ce8551ed6 Mon Sep 17 00:00:00 2001 From: Vuong <3168632+vuon9@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:26:05 +0700 Subject: [PATCH 07/12] feat: migrate JwtDebugger to use generated API clients --- frontend/src/pages/JwtDebugger/index.jsx | 8 ++++---- frontend/src/services/api.ts | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 frontend/src/services/api.ts diff --git a/frontend/src/pages/JwtDebugger/index.jsx b/frontend/src/pages/JwtDebugger/index.jsx index b848c43..1ceaece 100644 --- a/frontend/src/pages/JwtDebugger/index.jsx +++ b/frontend/src/pages/JwtDebugger/index.jsx @@ -6,7 +6,7 @@ import { jwtReducer, initialState, actions } from './jwtReducer'; import ModeTabBar from './components/ModeTabBar'; import JwtDecode from './components/JwtDecode'; import JwtEncode from './components/JwtEncode'; -import { JWTService } from '../../../bindings/devtoolbox/service'; +import { Decode, Verify, Encode } from '../../services/api'; export default function JwtDebugger() { const [state, dispatch] = useReducer(jwtReducer, initialState); @@ -29,7 +29,7 @@ export default function JwtDebugger() { // Call Go backend for decoding const decodeToken = async () => { try { - const response = await JWTService.Decode(state.token); + const response = await Decode(state.token); dispatch(actions.setDecoded({ header: response.header, @@ -66,7 +66,7 @@ export default function JwtDebugger() { try { // Call Go backend for verification - const response = await JWTService.Verify(state.token, state.secret, state.encoding); + const response = await Verify(state.token, state.secret, state.encoding); dispatch(actions.setValidation( response.error ? response.error : response.validationMessage, @@ -85,7 +85,7 @@ export default function JwtDebugger() { } try { - const response = await JWTService.Encode( + const response = await Encode( state.headerInput, state.payloadInput, state.algorithm, diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..eeb75e9 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,2 @@ +// Re-export generated services for convenience +export * from '../generated'; From 57404e9d65583eba7378d22ba2ea4d769cc798eb Mon Sep 17 00:00:00 2001 From: Vuong <3168632+vuon9@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:32:59 +0700 Subject: [PATCH 08/12] test: add integration tests for HTTP API --- pkg/router/integration_test.go | 162 +++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 pkg/router/integration_test.go diff --git a/pkg/router/integration_test.go b/pkg/router/integration_test.go new file mode 100644 index 0000000..3a65d8c --- /dev/null +++ b/pkg/router/integration_test.go @@ -0,0 +1,162 @@ +package router + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +// Integration test with real services +func TestIntegration_AllServices(t *testing.T) { + tests := []struct { + name string + method string + path string + body map[string]interface{} + expectedStatus int + }{ + { + name: "health endpoint", + method: "GET", + path: "/health", + body: nil, + expectedStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + server := NewServer() + + // Register all services (using mock for this test) + jwtSvc := &mockJWTService{} + server.Register(jwtSvc) + + var bodyBytes []byte + if tt.body != nil { + bodyBytes, _ = json.Marshal(tt.body) + } + + w := httptest.NewRecorder() + var req *http.Request + if tt.body != nil { + req, _ = http.NewRequest(tt.method, tt.path, bytes.NewBuffer(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + } else { + req, _ = http.NewRequest(tt.method, tt.path, nil) + } + + server.Engine().ServeHTTP(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + + if tt.path == "/health" { + var health map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &health) + assert.NoError(t, err) + assert.Equal(t, "ok", health["status"]) + assert.Equal(t, "web", health["mode"]) + } + }) + } +} + +// Mock services for testing +type mockJWTService struct{} + +type mockDecodeRequest struct { + Token string `json:"token"` +} + +type mockDecodeResponse struct { + Valid bool `json:"valid"` + Error string `json:"error,omitempty"` +} + +func (s *mockJWTService) Decode(req mockDecodeRequest) mockDecodeResponse { + if req.Token == "" { + return mockDecodeResponse{Valid: false, Error: "empty token"} + } + return mockDecodeResponse{Valid: true} +} + +func TestIntegration_JWTDecode(t *testing.T) { + tests := []struct { + name string + token string + expectValid bool + }{ + { + name: "valid token", + token: "test.jwt.token", + expectValid: true, + }, + { + name: "empty token", + token: "", + expectValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + server := NewServer() + jwtSvc := &mockJWTService{} + server.Register(jwtSvc) + + reqBody := map[string]string{"token": tt.token} + body, _ := json.Marshal(reqBody) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/mock-jwt-service/decode", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + server.Engine().ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp mockDecodeResponse + err := json.Unmarshal(w.Body.Bytes(), &resp) + assert.NoError(t, err) + assert.Equal(t, tt.expectValid, resp.Valid) + }) + } +} + +func TestIntegration_CORSHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + server := NewServer() + + w := httptest.NewRecorder() + req, _ := http.NewRequest("OPTIONS", "/health", nil) + req.Header.Set("Origin", "http://localhost:3000") + req.Header.Set("Access-Control-Request-Method", "POST") + + server.Engine().ServeHTTP(w, req) + + // Check CORS headers are present + assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) + assert.Contains(t, w.Header().Get("Access-Control-Allow-Methods"), "POST") +} + +func TestIntegration_InvalidJSON(t *testing.T) { + gin.SetMode(gin.TestMode) + server := NewServer() + jwtSvc := &mockJWTService{} + server.Register(jwtSvc) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/mock-jwt-service/decode", bytes.NewBufferString("invalid json")) + req.Header.Set("Content-Type", "application/json") + + server.Engine().ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} From f9c4ebd33efe801ca4b3f43167c46a37a1890613 Mon Sep 17 00:00:00 2001 From: Vuong <3168632+vuon9@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:34:53 +0700 Subject: [PATCH 09/12] docs: add browser mode documentation --- README.md | 9 +++ docs/BROWSER_MODE.md | 129 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 docs/BROWSER_MODE.md diff --git a/README.md b/README.md index 8ec3dd2..9ec42ac 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,15 @@ Essential software development tools for everyday tasks. ## Features +### **Browser Support** + +DevToolbox now works in both desktop and browser modes: + +- **Desktop**: Native Wails application with native performance (default) +- **Browser**: Access via `http://localhost:8081` when the desktop app is running + +The frontend automatically detects the environment and uses the appropriate API (Wails runtime for desktop, HTTP for browser). See [docs/BROWSER_MODE.md](docs/BROWSER_MODE.md) for details. + ### **Text Based Converter** (Unified Tool) The central hub with 45+ algorithms across 5 categories: diff --git a/docs/BROWSER_MODE.md b/docs/BROWSER_MODE.md new file mode 100644 index 0000000..b889e2f --- /dev/null +++ b/docs/BROWSER_MODE.md @@ -0,0 +1,129 @@ +# Browser Mode + +DevToolbox can run in web browsers alongside the desktop application. + +## How It Works + +When you start the desktop app, it also starts an HTTP server on port 8081. You can open `http://localhost:8081` in any browser to use the tools. + +## Architecture + +- **Desktop Mode**: Uses Wails runtime bindings +- **Browser Mode**: Uses HTTP API with Gin server +- **Auto-Discovery**: New services are automatically exposed via HTTP +- **Code Generation**: TypeScript clients are auto-generated from Go code + +## API Endpoints + +All services are available at `/api/{service-name}/{method-name}`: + +- `POST /api/jwt-service/decode` - Decode JWT tokens +- `POST /api/conversion-service/convert` - Convert between formats +- `POST /api/barcode-service/generate-barcode` - Generate barcodes +- `POST /api/data-generator-service/generate` - Generate mock data +- `POST /api/code-formatter-service/format` - Format code +- `POST /api/date-time-service/convert` - Convert dates + +### Health Check + +```bash +curl http://localhost:8081/health +``` + +Response: +```json +{ + "status": "ok", + "mode": "web", + "time": "2026-02-09T21:20:00Z" +} +``` + +## Development + +### Adding a New Service + +1. Create your service in `service/` directory +2. Register it in `main.go`: `server.Register(&MyService{})` +3. Run the generator: `go run cmd/genservices/main.go` +4. Import the generated client: `import { myService } from '../generated'` + +### Regenerating Clients + +```bash +cd cmd/genservices +go run . -services ../../service -output ../../frontend/src/generated +``` + +This updates `frontend/src/generated/` with the latest TypeScript clients. + +### Testing Browser Mode + +1. Start the app: `go run .` +2. Open browser: `http://localhost:8081` +3. The same frontend works in both modes! + +## Generated Client Structure + +``` +frontend/src/generated/ +├── wails/ # Wails runtime wrappers +│ ├── jWTService.ts +│ ├── barcodeService.ts +│ └── ... +├── http/ # HTTP fetch clients +│ ├── jWTService.ts +│ ├── barcodeService.ts +│ └── ... +└── index.ts # Unified facade +``` + +### Usage Example + +```typescript +import { jWTService } from '../generated'; + +// Works in both Wails (desktop) and Browser mode! +const response = await jWTService.decode(token); +``` + +The unified facade automatically detects the runtime environment and uses the appropriate implementation. + +## Configuration + +### Environment Variables + +- `VITE_API_URL` - Base URL for HTTP API (default: `http://localhost:8081`) + +### Port Configuration + +The HTTP server runs on port 8081 by default. You can change this in `main.go`: + +```go +StartHTTPServer(8081) // Change to your desired port +``` + +## Troubleshooting + +### Port Already in Use + +If port 8081 is taken, change it in `main.go` and restart. + +### CORS Issues + +The server includes CORS middleware allowing all origins. If you encounter issues, check: +1. Browser console for CORS errors +2. Server is running (`curl http://localhost:8081/health`) + +### Generated Clients Out of Sync + +Run the generator again after modifying service methods: +```bash +go run cmd/genservices/main.go +``` + +## Security Notes + +- The HTTP server binds to all interfaces (0.0.0.0:8081) by default +- In production, consider adding authentication +- CORS is configured to allow all origins - restrict this for production use From eff7d46911cf73022a42f22da8487e46fce02c76 Mon Sep 17 00:00:00 2001 From: Vuong <3168632+vuon9@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:02:55 +0700 Subject: [PATCH 10/12] fix: handle primitive and multi-parameter methods in router --- cmd/testserver/main.go | 50 +++++++++++++++++ pkg/router/router.go | 111 +++++++++++++++++++++++++++++++++++--- pkg/router/router_test.go | 64 ++++++++++++++++++++++ pkg/router/server_test.go | 66 +++++++++++++++++++++++ 4 files changed, 284 insertions(+), 7 deletions(-) create mode 100644 cmd/testserver/main.go create mode 100644 pkg/router/server_test.go diff --git a/cmd/testserver/main.go b/cmd/testserver/main.go new file mode 100644 index 0000000..ed2b2c9 --- /dev/null +++ b/cmd/testserver/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "devtoolbox/pkg/router" + "devtoolbox/service" + "fmt" + "time" +) + +func main() { + fmt.Println("Starting HTTP server test...") + + // Create services + jwtSvc := service.NewJWTService(nil) + conversionSvc := service.NewConversionService(nil) + barcodeSvc := service.NewBarcodeService(nil) + dataGenSvc := service.NewDataGeneratorService(nil) + codeFmtSvc := service.NewCodeFormatterService(nil) + dateTimeSvc := service.NewDateTimeService(nil) + + // Create server + server := router.NewServer() + + // Register services + fmt.Println("Registering services...") + server.Register(jwtSvc) + server.Register(conversionSvc) + server.Register(barcodeSvc) + server.Register(dataGenSvc) + server.Register(codeFmtSvc) + server.Register(dateTimeSvc) + fmt.Println("Services registered successfully!") + + // Start server + fmt.Println("Starting HTTP server on port 8081...") + go func() { + if err := server.Start(8081); err != nil { + fmt.Printf("Server error: %v\n", err) + } + }() + + // Wait for server to start + time.Sleep(2 * time.Second) + fmt.Println("Server should be running on http://localhost:8081") + fmt.Println("Test with: curl http://localhost:8081/health") + fmt.Println("Press Ctrl+C to stop") + + // Keep running + select {} +} diff --git a/pkg/router/router.go b/pkg/router/router.go index 726e611..87997c5 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -60,17 +60,71 @@ func (r *Router) createHandler(methodValue reflect.Value, method reflect.Method) args := make([]reflect.Value, numIn-1) // -1 because receiver is first if numIn > 1 { - // First argument should be a struct for JSON binding - argType := methodType.In(1) - argValue := reflect.New(argType).Interface() + // Method has parameters - create a wrapper struct to hold all params + type paramInfo struct { + name string + argType reflect.Type + index int + } - if err := c.ShouldBindJSON(argValue); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return + var params []paramInfo + for i := 1; i < numIn; i++ { + params = append(params, paramInfo{ + name: methodType.In(i).Name(), + argType: methodType.In(i), + index: i - 1, + }) } - args[0] = reflect.ValueOf(argValue).Elem() + // Single parameter handling + if len(params) == 1 { + param := params[0] + + if param.argType.Kind() == reflect.Struct { + // Single struct parameter - bind directly + argValue := reflect.New(param.argType).Interface() + if err := c.ShouldBindJSON(argValue); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + args[0] = reflect.ValueOf(argValue).Elem() + } else { + // Single primitive parameter - use "value" field convention + var wrapper struct { + Value interface{} `json:"value"` + } + if err := c.ShouldBindJSON(&wrapper); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + args[0] = convertToType(wrapper.Value, param.argType) + } + } else { + // Multiple parameters - bind to a map and use "arg0", "arg1", etc. + var requestMap map[string]interface{} + if err := c.ShouldBindJSON(&requestMap); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Convert each parameter + for _, param := range params { + // Use "arg0", "arg1", etc. as keys for multiple parameters + key := fmt.Sprintf("arg%d", param.index) + value, exists := requestMap[key] + if !exists { + // Use zero value if not provided + args[param.index] = reflect.Zero(param.argType) + continue + } + + // Convert value to expected type + argValue := convertToType(value, param.argType) + args[param.index] = argValue + } + } } + // If no parameters, args is already empty // Call the method results := methodValue.Call(args) @@ -92,6 +146,49 @@ func (r *Router) createHandler(methodValue reflect.Value, method reflect.Method) } } +// convertToType converts an interface{} value to the expected reflect.Type +func convertToType(value interface{}, targetType reflect.Type) reflect.Value { + switch targetType.Kind() { + case reflect.String: + if s, ok := value.(string); ok { + return reflect.ValueOf(s) + } + return reflect.ValueOf("") + case reflect.Int: + if f, ok := value.(float64); ok { + return reflect.ValueOf(int(f)) + } + return reflect.ValueOf(0) + case reflect.Int64: + if f, ok := value.(float64); ok { + return reflect.ValueOf(int64(f)) + } + return reflect.ValueOf(int64(0)) + case reflect.Float64: + if f, ok := value.(float64); ok { + return reflect.ValueOf(f) + } + return reflect.ValueOf(float64(0)) + case reflect.Bool: + if b, ok := value.(bool); ok { + return reflect.ValueOf(b) + } + return reflect.ValueOf(false) + case reflect.Map: + if m, ok := value.(map[string]interface{}); ok { + return reflect.ValueOf(m) + } + return reflect.ValueOf(map[string]interface{}{}) + case reflect.Slice: + if s, ok := value.([]interface{}); ok { + return reflect.ValueOf(s) + } + return reflect.ValueOf([]interface{}{}) + default: + return reflect.Zero(targetType) + } +} + // toKebabCase converts PascalCase to kebab-case func toKebabCase(s string) string { var result strings.Builder diff --git a/pkg/router/router_test.go b/pkg/router/router_test.go index 0c55b7f..fd4289c 100644 --- a/pkg/router/router_test.go +++ b/pkg/router/router_test.go @@ -135,3 +135,67 @@ func TestRouter_LifecycleMethodsSkipped(t *testing.T) { // Should get 404 because lifecycle methods are skipped assert.Equal(t, http.StatusNotFound, w.Code) } + +// Test service with primitive parameter +type PrimitiveService struct{} + +func (s *PrimitiveService) Process(value string) string { + return "processed: " + value +} + +func TestRouter_PrimitiveParameter(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + router := New(r) + + service := &PrimitiveService{} + err := router.Register(service) + assert.NoError(t, err) + + // Test with "value" field convention + reqBody := map[string]string{"value": "hello"} + body, _ := json.Marshal(reqBody) + + w := httptest.NewRecorder() + httpReq, _ := http.NewRequest("POST", "/api/primitive-service/process", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + + r.ServeHTTP(w, httpReq) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "processed: hello") +} + +// Test service with multiple parameters +type MultiParamService struct{} + +func (s *MultiParamService) Combine(a, b, c string) string { + return a + "-" + b + "-" + c +} + +func TestRouter_MultipleParameters(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + router := New(r) + + service := &MultiParamService{} + err := router.Register(service) + assert.NoError(t, err) + + // Test with "arg0", "arg1", "arg2" convention + reqBody := map[string]string{ + "arg0": "first", + "arg1": "second", + "arg2": "third", + } + body, _ := json.Marshal(reqBody) + + w := httptest.NewRecorder() + httpReq, _ := http.NewRequest("POST", "/api/multi-param-service/combine", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + + r.ServeHTTP(w, httpReq) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "first-second-third") +} diff --git a/pkg/router/server_test.go b/pkg/router/server_test.go new file mode 100644 index 0000000..2be9ccd --- /dev/null +++ b/pkg/router/server_test.go @@ -0,0 +1,66 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestServer_Start(t *testing.T) { + gin.SetMode(gin.TestMode) + server := NewServer() + + // Register a simple service + svc := &testServiceForServer{} + err := server.Register(svc) + assert.NoError(t, err) + + // Test health endpoint + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/health", nil) + server.Engine().ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "ok") + assert.Contains(t, w.Body.String(), "web") +} + +type testServiceForServer struct{} + +type testRequest struct { + Input string `json:"input"` +} + +type testResponse struct { + Output string `json:"output"` +} + +func (s *testServiceForServer) Process(req testRequest) testResponse { + return testResponse{Output: req.Input} +} + +func TestServer_EndToEnd(t *testing.T) { + gin.SetMode(gin.TestMode) + server := NewServer() + + // Register service + svc := &testServiceForServer{} + err := server.Register(svc) + assert.NoError(t, err) + + // Test actual endpoint + w := httptest.NewRecorder() + reqBody := `{"input": "hello"}` + req, _ := http.NewRequest("POST", "/api/test-service-for-server/process", + strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + + server.Engine().ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "hello") +} From 7d715d931fdf58eff845c4cba90129cd547bce55 Mon Sep 17 00:00:00 2001 From: Vuong <3168632+vuon9@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:18:37 +0700 Subject: [PATCH 11/12] feat: browser api support --- cmd/testserver/main.go | 29 +- docs/plans/2026-02-09-browser-api-design.md | 1288 +++++++++++++++++ frontend/src/generated/http/barcodeService.ts | 49 +- .../generated/http/codeFormatterService.ts | 22 +- .../src/generated/http/conversionService.ts | 22 +- .../generated/http/dataGeneratorService.ts | 35 +- .../src/generated/http/dateTimeService.ts | 42 +- frontend/src/generated/http/jWTService.ts | 34 +- frontend/src/generated/index.ts | 32 +- .../src/generated/wails/barcodeService.ts | 16 +- .../generated/wails/codeFormatterService.ts | 8 +- .../src/generated/wails/conversionService.ts | 8 +- .../generated/wails/dataGeneratorService.ts | 12 +- .../src/generated/wails/dateTimeService.ts | 14 +- frontend/src/generated/wails/jWTService.ts | 12 +- frontend/src/pages/BarcodeGenerator.jsx | 4 +- frontend/src/pages/CodeFormatter/index.jsx | 12 +- frontend/src/pages/DataGenerator/index.jsx | 6 +- .../JwtDebugger/components/JwtDecode.jsx | 4 +- frontend/src/pages/TextConverter/index.jsx | 4 +- frontend/src/services/api.ts | 2 +- 21 files changed, 1394 insertions(+), 261 deletions(-) create mode 100644 docs/plans/2026-02-09-browser-api-design.md diff --git a/cmd/testserver/main.go b/cmd/testserver/main.go index ed2b2c9..e9984bc 100644 --- a/cmd/testserver/main.go +++ b/cmd/testserver/main.go @@ -4,11 +4,11 @@ import ( "devtoolbox/pkg/router" "devtoolbox/service" "fmt" - "time" + "net/http" ) func main() { - fmt.Println("Starting HTTP server test...") + fmt.Println("Starting HTTP server with frontend...") // Create services jwtSvc := service.NewJWTService(nil) @@ -31,20 +31,21 @@ func main() { server.Register(dateTimeSvc) fmt.Println("Services registered successfully!") + // Get the gin engine and add static file serving + engine := server.Engine() + + // Serve static files from the dist directory + engine.StaticFS("/", http.Dir("frontend/dist")) + + // Also serve assets + engine.StaticFS("/assets", http.Dir("frontend/dist/assets")) + // Start server fmt.Println("Starting HTTP server on port 8081...") - go func() { - if err := server.Start(8081); err != nil { - fmt.Printf("Server error: %v\n", err) - } - }() - - // Wait for server to start - time.Sleep(2 * time.Second) - fmt.Println("Server should be running on http://localhost:8081") - fmt.Println("Test with: curl http://localhost:8081/health") + fmt.Println("Open browser: http://localhost:8081") fmt.Println("Press Ctrl+C to stop") - // Keep running - select {} + if err := engine.Run(":8081"); err != nil { + fmt.Printf("Server error: %v\n", err) + } } diff --git a/docs/plans/2026-02-09-browser-api-design.md b/docs/plans/2026-02-09-browser-api-design.md new file mode 100644 index 0000000..c8a4a7f --- /dev/null +++ b/docs/plans/2026-02-09-browser-api-design.md @@ -0,0 +1,1288 @@ +# Browser API Support Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Enable DevToolbox to work in web browsers by adding a Gin HTTP API alongside the existing Wails desktop app, with auto-discovery of service methods and auto-generated TypeScript clients. + +**Architecture:** A dual-mode system where Go services are registered once and exposed via both Wails runtime (desktop) and Gin HTTP server (browser). An auto-discovery router uses reflection to generate RESTful endpoints, and a code generator creates TypeScript clients for both modes with a unified facade that auto-detects the runtime environment. + +**Tech Stack:** Go 1.22+, Gin Gonic, Go AST (codegen), TypeScript, Wails v3 + +--- + +## Task 1: Create Auto-Discovery Router Package + +**Files:** +- Create: `pkg/router/router.go` +- Create: `pkg/router/binding.go` +- Test: `pkg/router/router_test.go` + +**Step 1: Create router.go with service registration and auto-discovery** + +Write file: `pkg/router/router.go` +```go +package router + +import ( + "fmt" + "net/http" + "reflect" + "strings" + "unicode" + + "github.com/gin-gonic/gin" +) + +// Router automatically discovers and registers service methods as HTTP routes +type Router struct { + engine *gin.Engine +} + +// New creates a new Router with the given Gin engine +func New(engine *gin.Engine) *Router { + return &Router{engine: engine} +} + +// Register scans a service struct and auto-generates routes for all exported methods +func (r *Router) Register(service interface{}) error { + serviceType := reflect.TypeOf(service) + serviceValue := reflect.ValueOf(service) + + // Get service name and convert to kebab-case + serviceName := toKebabCase(serviceType.Elem().Name()) + + // Iterate through all methods + for i := 0; i < serviceType.NumMethod(); i++ { + method := serviceType.Method(i) + + // Skip unexported methods and lifecycle methods + if !method.IsExported() || isLifecycleMethod(method.Name) { + continue + } + + // Convert method name to kebab-case + methodName := toKebabCase(method.Name) + path := fmt.Sprintf("/api/%s/%s", serviceName, methodName) + + // Create handler + handler := r.createHandler(serviceValue.Method(i), method) + r.engine.POST(path, handler) + } + + return nil +} + +// createHandler creates a Gin handler for a method +func (r *Router) createHandler(methodValue reflect.Value, method reflect.Method) gin.HandlerFunc { + return func(c *gin.Context) { + // Get method signature + methodType := method.Type + numIn := methodType.NumIn() + + // Prepare arguments + args := make([]reflect.Value, numIn-1) // -1 because receiver is first + + if numIn > 1 { + // First argument should be a struct for JSON binding + argType := methodType.In(1) + argValue := reflect.New(argType).Interface() + + if err := c.ShouldBindJSON(argValue); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + args[0] = reflect.ValueOf(argValue).Elem() + } + + // Call the method + results := methodValue.Call(args) + + // Handle return values (result, error) + if len(results) == 2 { + // Check for error + if !results[1].IsNil() { + err := results[1].Interface().(error) + c.JSON(http.StatusOK, gin.H{"error": err.Error()}) + return + } + + // Return result + c.JSON(http.StatusOK, results[0].Interface()) + } else if len(results) == 1 { + c.JSON(http.StatusOK, results[0].Interface()) + } + } +} + +// toKebabCase converts PascalCase to kebab-case +func toKebabCase(s string) string { + var result strings.Builder + for i, r := range s { + if unicode.IsUpper(r) { + if i > 0 { + result.WriteRune('-') + } + result.WriteRune(unicode.ToLower(r)) + } else { + result.WriteRune(r) + } + } + return result.String() +} + +// isLifecycleMethod checks if method is a Wails lifecycle method +func isLifecycleMethod(name string) bool { + return name == "ServiceStartup" || name == "ServiceShutdown" +} +``` + +**Step 2: Create binding.go for request/response utilities** + +Write file: `pkg/router/binding.go` +```go +package router + +// RequestWrapper wraps requests for single-parameter methods +type RequestWrapper struct { + Args []interface{} `json:"args"` +} + +// ResponseWrapper standardizes API responses +type ResponseWrapper struct { + Data interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} +``` + +**Step 3: Write router tests** + +Write file: `pkg/router/router_test.go` +```go +package router + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +// Test service +type TestService struct{} + +type EchoRequest struct { + Message string `json:"message"` +} + +type EchoResponse struct { + Message string `json:"message"` +} + +func (s *TestService) Echo(req EchoRequest) EchoResponse { + return EchoResponse{Message: req.Message} +} + +func (s *TestService) ServiceStartup() error { + return nil +} + +func TestRouter_Register(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + router := New(r) + + service := &TestService{} + err := router.Register(service) + assert.NoError(t, err) + + // Test echo endpoint + req := EchoRequest{Message: "hello"} + body, _ := json.Marshal(req) + + w := httptest.NewRecorder() + httpReq, _ := http.NewRequest("POST", "/api/test-service/echo", bytes.NewBuffer(body)) + httpReq.Header.Set("Content-Type", "application/json") + + r.ServeHTTP(w, httpReq) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp EchoResponse + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, "hello", resp.Message) +} + +func TestToKebabCase(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"JWTService", "jwt-service"}, + {"Decode", "decode"}, + {"VerifyToken", "verify-token"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) stretchr/testify { + result := toKebabCase(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} +``` + +**Step 4: Run tests** + +```bash +cd pkg/router && go test -v +``` + +Expected: Tests pass + +**Step 5: Commit** + +```bash +git add pkg/router/ +git commit -m "feat: add auto-discovery router with Gin integration" +``` + +--- + +## Task 2: Create HTTP Server Integration + +**Files:** +- Create: `pkg/router/server.go` +- Modify: `server.go` (replace existing implementation) + +**Step 1: Create server.go with Gin-based HTTP server** + +Write file: `pkg/router/server.go` +```go +package router + +import ( + "fmt" + "net/http" + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +// Server represents the HTTP server with auto-discovery router +type Server struct { + router *Router + engine *gin.Engine +} + +// NewServer creates a new HTTP server +func NewServer() *Server { + gin.SetMode(gin.ReleaseMode) + engine := gin.New() + engine.Use(gin.Recovery()) + + // CORS configuration + config := cors.Config{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + } + engine.Use(cors.New(config)) + + // Health check + engine.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "mode": "web", + "time": time.Now().Format(time.RFC3339), + }) + }) + + return &Server{ + router: New(engine), + engine: engine, + } +} + +// Register adds a service to the router +func (s *Server) Register(service interface{}) error { + return s.router.Register(service) +} + +// Start starts the HTTP server on the specified port +func (s *Server) Start(port int) error { + addr := fmt.Sprintf(":%d", port) + return s.engine.Run(addr) +} + +// Engine returns the Gin engine for testing +func (s *Server) Engine() *gin.Engine { + return s.engine +} +``` + +**Step 2: Update main server.go to use the new router** + +Read existing: `server.go` (lines 1-50) + +Replace entire file content with: +```go +package main + +import ( + "devtoolbox/pkg/router" + "devtoolbox/service" +) + +// StartHTTPServer starts the HTTP server with all services registered +func StartHTTPServer(port int) { + // Create services + jwtSvc := service.NewJWTService(nil) + conversionSvc := service.NewConversionService(nil) + barcodeSvc := service.NewBarcodeService(nil) + dataGenSvc := service.NewDataGeneratorService(nil) + codeFmtSvc := service.NewCodeFormatterService(nil) + dateTimeSvc := service.NewDateTimeService(nil) + + // Create server and register services + server := router.NewServer() + server.Register(jwtSvc) + server.Register(conversionSvc) + server.Register(barcodeSvc) + server.Register(dataGenSvc) + server.Register(codeFmtSvc) + server.Register(dateTimeSvc) + + // Start server + server.Start(port) +} +``` + +**Step 3: Commit** + +```bash +git add pkg/router/server.go server.go +git commit -m "feat: integrate auto-discovery router into HTTP server" +``` + +--- + +## Task 3: Update Main.go for Dual-Mode Operation + +**Files:** +- Modify: `main.go` + +**Step 1: Read current main.go** + +Read: `main.go` + +**Step 2: Modify main.go to start HTTP server in background** + +Add after service creation (around line 61): +```go + // Start HTTP server for browser support (background) + go func() { + StartHTTPServer(8081) + }() +``` + +This should be placed right before the `app.Window.NewWithOptions()` call. + +**Step 3: Commit** + +```bash +git add main.go +git commit -m "feat: enable dual-mode with HTTP server on port 8081" +``` + +--- + +## Task 4: Create Code Generator Tool + +**Files:** +- Create: `cmd/genservices/main.go` +- Create: `cmd/genservices/parser.go` +- Create: `cmd/genservices/generator.go` +- Create: `cmd/genservices/templates/typescript.tmpl` + +**Step 1: Create parser.go to parse Go service files** + +Write file: `cmd/genservices/parser.go` +```go +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "strings" +) + +// ServiceMethod represents a method in a service +type ServiceMethod struct { + Name string + Parameters []Parameter + Returns []Parameter +} + +// Parameter represents a method parameter +type Parameter struct { + Name string + Type string +} + +// Service represents a parsed service +type Service struct { + Name string + Methods []ServiceMethod +} + +// Parser parses Go service files +type Parser struct { + serviceDir string +} + +// NewParser creates a new parser +func NewParser(serviceDir string) *Parser { + return &Parser{serviceDir: serviceDir} +} + +// ParseServices parses all service files in the directory +func (p *Parser) ParseServices() ([]Service, error) { + fset := token.NewFileSet() + + // Parse all Go files in service directory + pkgs, err := parser.ParseDir(fset, p.serviceDir, nil, 0) + if err != nil { + return nil, err + } + + var services []Service + + for _, pkg := range pkgs { + for filename, file := range pkg.Files { + if strings.HasSuffix(filename, "_test.go") { + continue + } + + service := p.parseFile(file) + if service != nil { + services = append(services, *service) + } + } + } + + return services, nil +} + +// parseFile parses a single Go file and extracts services +func (p *Parser) parseFile(file *ast.File) *Service { + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.TYPE { + continue + } + + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + continue + } + + // Check if it's a service (has Service suffix or contains service methods) + if strings.HasSuffix(typeSpec.Name.Name, "Service") { + service := &Service{ + Name: typeSpec.Name.Name, + } + + // Find methods for this type + service.Methods = p.findMethods(file, typeSpec.Name.Name) + + return service + } + + _ = structType // Use the variable to avoid unused warning + } + } + + return nil +} + +// findMethods finds all methods for a given type +func (p *Parser) findMethods(file *ast.File, typeName string) []ServiceMethod { + var methods []ServiceMethod + + for _, decl := range file.Decls { + funcDecl, ok := decl.(*ast.FuncDecl) + if !ok || funcDecl.Recv == nil { + continue + } + + // Check if this method belongs to our type + for _, recv := range funcDecl.Recv.List { + recvType := p.getTypeString(recv.Type) + if recvType == "*"+typeName || recvType == typeName { + method := ServiceMethod{ + Name: funcDecl.Name.Name, + } + + // Parse parameters (skip receiver) + if funcDecl.Type.Params != nil { + for _, param := range funcDecl.Type.Params.List { + paramType := p.getTypeString(param.Type) + for _, name := range param.Names { + method.Parameters = append(method.Parameters, Parameter{ + Name: name.Name, + Type: paramType, + }) + } + } + } + + // Parse returns + if funcDecl.Type.Results != nil { + for _, result := range funcDecl.Type.Results.List { + resultType := p.getTypeString(result.Type) + method.Returns = append(method.Returns, Parameter{ + Type: resultType, + }) + } + } + + methods = append(methods, method) + } + } + } + + return methods +} + +// getTypeString converts an AST type to a string +func (p *Parser) getTypeString(expr ast.Expr) string { + switch t := expr.(type) { + case *ast.Ident: + return t.Name + case *ast.StarExpr: + return "*" + p.getTypeString(t.X) + case *ast.SelectorExpr: + return p.getTypeString(t.X) + "." + t.Sel.Name + default: + return "" + } +} +``` + +**Step 2: Create generator.go for TypeScript generation** + +Write file: `cmd/genservices/generator.go` +```go +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "text/template" +) + +// Generator generates TypeScript code +type Generator struct { + outputDir string + tmpl *template.Template +} + +// NewGenerator creates a new generator +func NewGenerator(outputDir string) (*Generator, error) { + tmplPath := filepath.Join("cmd", "genservices", "templates", "typescript.tmpl") + tmplContent, err := os.ReadFile(tmplPath) + if err != nil { + return nil, err + } + + tmpl, err := template.New("typescript").Parse(string(tmplContent)) + if err != nil { + return nil, err + } + + return &Generator{ + outputDir: outputDir, + tmpl: tmpl, + }, nil +} + +// Generate creates TypeScript files for all services +func (g *Generator) Generate(services []Service) error { + // Create output directories + wailsDir := filepath.Join(g.outputDir, "wails") + httpDir := filepath.Join(g.outputDir, "http") + + os.MkdirAll(wailsDir, 0755) + os.MkdirAll(httpDir, 0755) + + // Generate individual service files + for _, service := range services { + if err := g.generateWailsService(wailsDir, service); err != nil { + return err + } + if err := g.generateHTTPService(httpDir, service); err != nil { + return err + } + } + + // Generate index files + if err := g.generateWailsIndex(wailsDir, services); err != nil { + return err + } + if err := g.generateHTTPIndex(httpDir, services); err != nil { + return err + } + + // Generate unified facade + return g.generateUnifiedFacade(g.outputDir, services) +} + +func (g *Generator) generateWailsService(dir string, service Service) error { + filename := filepath.Join(dir, toCamelCase(service.Name)+".ts") + + data := struct { + ServiceName string + Methods []ServiceMethod + }{ + ServiceName: service.Name, + Methods: service.Methods, + } + + file, err := os.Create(filename) + if err != nil { + return err + } + defer file.Close() + + return g.tmpl.ExecuteTemplate(file, "wails", data) +} + +func (g *Generator) generateHTTPService(dir string, service Service) error { + filename := filepath.Join(dir, toCamelCase(service.Name)+".ts") + + data := struct { + ServiceName string + Methods []ServiceMethod + }{ + ServiceName: service.Name, + Methods: service.Methods, + } + + file, err := os.Create(filename) + if err != nil { + return err + } + defer file.Close() + + return g.tmpl.ExecuteTemplate(file, "http", data) +} + +func (g *Generator) generateWailsIndex(dir string, services []Service) error { + filename := filepath.Join(dir, "index.ts") + + var exports []string + for _, svc := range services { + exports = append(exports, fmt.Sprintf("export * as %s from './%s';", + toCamelCase(svc.Name), toCamelCase(svc.Name))) + } + + content := strings.Join(exports, "\n") + return os.WriteFile(filename, []byte(content), 0644) +} + +func (g *Generator) generateHTTPIndex(dir string, services []Service) error { + filename := filepath.Join(dir, "index.ts") + + var exports []string + for _, svc := range services { + exports = append(exports, fmt.Sprintf("export * as %s from './%s';", + toCamelCase(svc.Name), toCamelCase(svc.Name))) + } + + content := strings.Join(exports, "\n") + return os.WriteFile(filename, []byte(content), 0644) +} + +func (g *Generator) generateUnifiedFacade(dir string, services []Service) error { + filename := filepath.Join(dir, "index.ts") + + var serviceImports []string + var serviceMappings []string + + for _, svc := range services { + camelName := toCamelCase(svc.Name) + serviceImports = append(serviceImports, fmt.Sprintf( + "import { %s as Wails%s } from './wails/%s';\n"+ + "import { %s as HTTP%s } from './http/%s';", + svc.Name, svc.Name, camelName, + svc.Name, svc.Name, camelName)) + + serviceMappings = append(serviceMappings, fmt.Sprintf( + "export const %s = isWails() ? Wails%s : HTTP%s;", + camelName, svc.Name, svc.Name)) + } + + content := fmt.Sprintf(`// Auto-generated unified service facade +// Detects runtime environment and uses appropriate implementation + +const isWails = () => { + return typeof window !== 'undefined' && + window.runtime && + window.runtime.EventsOn !== undefined; +}; + +%s + +%s +`, strings.Join(serviceImports, "\n"), strings.Join(serviceMappings, "\n")) + + return os.WriteFile(filename, []byte(content), 0644) +} + +// toCamelCase converts PascalCase to camelCase +func toCamelCase(s string) string { + if s == "" { + return s + } + return strings.ToLower(s[:1]) + s[1:] +} +``` + +**Step 3: Create TypeScript template** + +Write file: `cmd/genservices/templates/typescript.tmpl` +``` +{{define "wails"}}// Auto-generated Wails client for {{.ServiceName}} +// This file is auto-generated. DO NOT EDIT. + +import { {{.ServiceName}} } from '../../../bindings/devtoolbox/service'; + +{{range .Methods}} +export const {{toCamelCase .Name}} = ({{range $i, $p := .Parameters}}{{if $i}}, {{end}}{{$p.Name}}: {{goToTS $p.Type}}{{end}}): Promise<{{goToTS (index .Returns 0).Type}}> => { + return {{$.ServiceName}}.{{.Name}}({{range $i, $p := .Parameters}}{{if $i}}, {{end}}{{$p.Name}}{{end}}); +}; +{{end}} +{{end}} + +{{define "http"}}// Auto-generated HTTP client for {{.ServiceName}} +// This file is auto-generated. DO NOT EDIT. + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081'; + +{{range .Methods}} +export const {{toCamelCase .Name}} = async ({{range $i, $p := .Parameters}}{{if $i}}, {{end}}{{$p.Name}}: {{goToTS $p.Type}}{{end}}): Promise<{{goToTS (index .Returns 0).Type}}> => { + const response = await fetch(`${API_BASE}/api/{{kebabCase $.ServiceName}}/{{kebabCase .Name}}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({{if eq (len .Parameters) 1}}{{index .Parameters 0).Name}}{{else}}{ {{range $i, $p := .Parameters}}{{if $i}}, {{end}}"{{$p.Name}}": {{$p.Name}}{{end}} }{{end}}) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +}; +{{end}} +{{end}} +``` + +**Step 4: Create main.go for the generator tool** + +Write file: `cmd/genservices/main.go` +```go +package main + +import ( + "flag" + "fmt" + "log" + "os" + "path/filepath" + "strings" +) + +func main() { + var ( + serviceDir = flag.String("services", "service", "Directory containing Go service files") + outputDir = flag.String("output", "frontend/src/generated", "Output directory for generated TypeScript") + ) + flag.Parse() + + // Get absolute paths + absServiceDir, err := filepath.Abs(*serviceDir) + if err != nil { + log.Fatal(err) + } + + absOutputDir, err := filepath.Abs(*outputDir) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Parsing services from: %s\n", absServiceDir) + fmt.Printf("Generating TypeScript to: %s\n", absOutputDir) + + // Parse services + parser := NewParser(absServiceDir) + services, err := parser.ParseServices() + if err != nil { + log.Fatal("Failed to parse services:", err) + } + + fmt.Printf("Found %d services\n", len(services)) + for _, svc := range services { + fmt.Printf(" - %s (%d methods)\n", svc.Name, len(svc.Methods)) + } + + // Generate TypeScript + generator, err := NewGenerator(absOutputDir) + if err != nil { + log.Fatal("Failed to create generator:", err) + } + + if err := generator.Generate(services); err != nil { + log.Fatal("Failed to generate TypeScript:", err) + } + + fmt.Println("✓ Generation complete!") +} + +// Helper functions for templates +func init() { + // These would be registered as template functions + _ = toCamelCase + _ = kebabCase + _ = goToTS +} + +func toCamelCase(s string) string { + if s == "" { + return s + } + return strings.ToLower(s[:1]) + s[1:] +} + +func kebabCase(s string) string { + var result strings.Builder + for i, r := range s { + if i > 0 && r >= 'A' && r <= 'Z' { + result.WriteRune('-') + } + result.WriteRune(r) + } + return strings.ToLower(result.String()) +} + +func goToTS(goType string) string { + // Simple type mappings + switch goType { + case "string": + return "string" + case "int", "int64", "float64": + return "number" + case "bool": + return "boolean" + case "error": + return "Error" + default: + // For complex types, return as-is (would need proper type imports) + return goType + } +} +``` + +**Step 5: Commit** + +```bash +git add cmd/genservices/ +git commit -m "feat: add TypeScript client generator tool" +``` + +--- + +## Task 5: Run Generator and Create Frontend Clients + +**Files:** +- Create: `frontend/src/generated/wails/*.ts` +- Create: `frontend/src/generated/http/*.ts` +- Create: `frontend/src/generated/index.ts` + +**Step 1: Run the generator** + +```bash +go run cmd/genservices/main.go -services service -output frontend/src/generated +``` + +Expected output: +``` +Parsing services from: /Users/vuong/workspace/vuon9/devtoolbox/.worktrees/browser-api/service +Generating TypeScript to: /Users/vuong/workspace/vuon9/devtoolbox/.worktrees/browser-api/frontend/src/generated +Found 6 services + - JWTService (3 methods) + - ConversionService (1 methods) + - BarcodeService (5 methods) + - DataGeneratorService (3 methods) + - CodeFormatterService (1 methods) + - DateTimeService (4 methods) +✓ Generation complete! +``` + +**Step 2: Verify generated files exist** + +```bash +ls -la frontend/src/generated/ +ls -la frontend/src/generated/wails/ +ls -la frontend/src/generated/http/ +``` + +Expected: Files should exist + +**Step 3: Commit generated files** + +```bash +git add frontend/src/generated/ +git commit -m "feat: generate TypeScript clients for all services" +``` + +--- + +## Task 6: Update Frontend Components to Use Generated Clients + +**Files:** +- Modify: `frontend/src/pages/JwtDebugger/index.jsx` (as example) +- Create: `frontend/src/services/api.ts` (migration helper) + +**Step 1: Create migration helper** + +Write file: `frontend/src/services/api.ts` +```typescript +// Re-export generated services for convenience +export * from '../generated'; +``` + +**Step 2: Update one component as proof of concept** + +Modify: `frontend/src/pages/JwtDebugger/index.jsx` + +Change line 9 from: +```javascript +import { JWTService } from '../../../bindings/devtoolbox/service'; +``` + +To: +```javascript +import { jwtService } from '../../services/api'; +``` + +Change line 32 from: +```javascript +const response = await JWTService.Decode(state.token); +``` + +To: +```javascript +const response = await jwtService.decode(state.token); +``` + +And line 69 from: +```javascript +const response = await JWTService.Verify(state.token, state.secret, state.encoding); +``` + +To: +```javascript +const response = await jwtService.verify(state.token, state.secret, state.encoding); +``` + +**Step 3: Test the component** + +```bash +cd frontend && npm run build +``` + +Expected: Build succeeds + +**Step 4: Commit** + +```bash +git add frontend/src/services/api.ts frontend/src/pages/JwtDebugger/index.jsx +git commit -m "feat: migrate JwtDebugger to use generated API clients" +``` + +--- + +## Task 7: Testing and Validation + +**Files:** +- Create: `pkg/router/integration_test.go` + +**Step 1: Create integration tests** + +Write file: `pkg/router/integration_test.go` +```go +package router + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +// Integration test with real services +func TestIntegration_AllServices(t *testing.T) { + gin.SetMode(gin.TestMode) + + server := NewServer() + + // Register all services + jwtSvc := &mockJWTService{} + server.Register(jwtSvc) + + // Test health endpoint + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/health", nil) + server.Engine().ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var health map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &health) + assert.Equal(t, "ok", health["status"]) + assert.Equal(t, "web", health["mode"]) +} + +// Mock services for testing +type mockJWTService struct{} + +type mockDecodeRequest struct { + Token string `json:"token"` +} + +type mockDecodeResponse struct { + Valid bool `json:"valid"` + Error string `json:"error,omitempty"` +} + +func (s *mockJWTService) Decode(req mockDecodeRequest) mockDecodeResponse { + if req.Token == "" { + return mockDecodeResponse{Valid: false, Error: "empty token"} + } + return mockDecodeResponse{Valid: true} +} + +func TestIntegration_JWTDecode(t *testing.T) { + gin.SetMode(gin.TestMode) + + server := NewServer() + jwtSvc := &mockJWTService{} + server.Register(jwtSvc) + + // Test decode endpoint + reqBody := map[string]string{"token": "test.jwt.token"} + body, _ := json.Marshal(reqBody) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/mock-jwt-service/decode", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + server.Engine().ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp mockDecodeResponse + json.Unmarshal(w.Body.Bytes(), &resp) + assert.True(t, resp.Valid) +} +``` + +**Step 2: Run integration tests** + +```bash +cd pkg/router && go test -v -run Integration +``` + +Expected: Tests pass + +**Step 3: Test HTTP server startup** + +```bash +go run . & +sleep 2 +curl http://localhost:8081/health +``` + +Expected: +```json +{"mode":"web","status":"ok","time":"2026-02-09T..."} +``` + +**Step 4: Test API endpoint** + +```bash +curl -X POST http://localhost:8081/api/jwt-service/decode \ + -H "Content-Type: application/json" \ + -d '{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"}' +``` + +Expected: Valid JWT response + +**Step 5: Kill background server and commit** + +```bash +pkill -f "go run ." +git add pkg/router/integration_test.go +git commit -m "test: add integration tests for HTTP API" +``` + +--- + +## Task 8: Documentation and Final Steps + +**Files:** +- Create: `docs/BROWSER_MODE.md` +- Modify: `README.md` + +**Step 1: Create browser mode documentation** + +Write file: `docs/BROWSER_MODE.md` +```markdown +# Browser Mode + +DevToolbox can run in web browsers alongside the desktop application. + +## How It Works + +When you start the desktop app, it also starts an HTTP server on port 8081. You can open `http://localhost:8081` in any browser to use the tools. + +## Architecture + +- **Desktop Mode**: Uses Wails runtime bindings +- **Browser Mode**: Uses HTTP API with Gin server +- **Auto-Discovery**: New services are automatically exposed via HTTP +- **Code Generation**: TypeScript clients are auto-generated from Go code + +## API Endpoints + +All services are available at `/api/{service-name}/{method-name}`: + +- `POST /api/jwt-service/decode` - Decode JWT tokens +- `POST /api/conversion-service/convert` - Convert between formats +- `POST /api/barcode-service/generate-barcode` - Generate barcodes +- etc. + +## Development + +### Adding a New Service + +1. Create your service in `service/` directory +2. Register it in `main.go`: `server.Register(&MyService{})` +3. Run the generator: `go run cmd/genservices/main.go` +4. Import the generated client: `import { myService } from '../generated'` + +### Regenerating Clients + +```bash +go run cmd/genservices/main.go +``` + +This updates `frontend/src/generated/` with the latest TypeScript clients. + +### Testing Browser Mode + +1. Start the app: `go run .` +2. Open browser: `http://localhost:8081` +3. The same frontend works in both modes! +``` + +**Step 2: Update README.md** + +Add to README.md: +```markdown +## Browser Support + +DevToolbox works in both desktop and browser modes: + +- **Desktop**: Native Wails application with native performance +- **Browser**: Access via `http://localhost:8081` when the app is running + +The frontend automatically detects the environment and uses the appropriate API (Wails runtime for desktop, HTTP for browser). +``` + +**Step 3: Commit documentation** + +```bash +git add docs/BROWSER_MODE.md README.md +git commit -m "docs: add browser mode documentation" +``` + +**Step 4: Final verification** + +```bash +go test ./pkg/router/... +go build . +``` + +Expected: All tests pass, build succeeds + +**Step 5: Final commit and summary** + +```bash +git log --oneline -10 +``` + +Expected: All commits visible + +--- + +## Summary of Changes + +1. **pkg/router/** - Auto-discovery Gin router with reflection +2. **cmd/genservices/** - TypeScript client generator tool +3. **server.go** - Updated to use new router +4. **main.go** - Dual-mode startup (Wails + HTTP) +5. **frontend/src/generated/** - Auto-generated TypeScript clients +6. **frontend/src/services/api.ts** - Unified facade + +## Next Steps (Optional) + +1. Update remaining frontend components to use generated clients +2. Add OpenAPI spec generation +3. Add authentication for HTTP API +4. Serve static frontend files from Gin for standalone web deployment + +## Testing Checklist + +- [ ] HTTP server starts on port 8081 +- [ ] Health endpoint returns 200 +- [ ] JWT decode endpoint works +- [ ] All service endpoints accessible +- [ ] Frontend builds successfully +- [ ] Generated clients compile +- [ ] Wails mode still works +- [ ] Browser mode works +``` \ No newline at end of file diff --git a/frontend/src/generated/http/barcodeService.ts b/frontend/src/generated/http/barcodeService.ts index 7d49193..556520f 100644 --- a/frontend/src/generated/http/barcodeService.ts +++ b/frontend/src/generated/http/barcodeService.ts @@ -3,26 +3,11 @@ const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081'; - -export async function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { - const response = await fetch(`${API_BASE}/api/BarcodeService/ServiceStartup`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ctx, options }) - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return await response.json(); -} - -export async function GenerateBarcode(req: barcode.GenerateBarcodeRequest): Promise { - const response = await fetch(`${API_BASE}/api/BarcodeService/GenerateBarcode`, { +export async function GenerateBarcode(req: any): Promise { + const response = await fetch(`${API_BASE}/api/barcode-service/generate-barcode`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ req }) + body: JSON.stringify(req) }); if (!response.ok) { @@ -32,11 +17,10 @@ export async function GenerateBarcode(req: barcode.GenerateBarcodeRequest): Prom return await response.json(); } -export async function GetBarcodeStandards(): Promise<> { - const response = await fetch(`${API_BASE}/api/BarcodeService/GetBarcodeStandards`, { +export async function GetBarcodeStandards(): Promise { + const response = await fetch(`${API_BASE}/api/barcode-service/get-barcode-standards`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ }) + headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { @@ -46,11 +30,10 @@ export async function GetBarcodeStandards(): Promise<> { return await response.json(); } -export async function GetQRErrorLevels(): Promise<> { - const response = await fetch(`${API_BASE}/api/BarcodeService/GetQRErrorLevels`, { +export async function GetQRErrorLevels(): Promise { + const response = await fetch(`${API_BASE}/api/barcode-service/get-qr-error-levels`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ }) + headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { @@ -60,11 +43,10 @@ export async function GetQRErrorLevels(): Promise<> { return await response.json(); } -export async function GetBarcodeSizes(): Promise<> { - const response = await fetch(`${API_BASE}/api/BarcodeService/GetBarcodeSizes`, { +export async function GetBarcodeSizes(): Promise { + const response = await fetch(`${API_BASE}/api/barcode-service/get-barcode-sizes`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ }) + headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { @@ -74,11 +56,11 @@ export async function GetBarcodeSizes(): Promise<> { return await response.json(); } -export async function ValidateContent(content: string, standard: string): Promise<> { - const response = await fetch(`${API_BASE}/api/BarcodeService/ValidateContent`, { +export async function ValidateContent(content: string, standard: string): Promise { + const response = await fetch(`${API_BASE}/api/barcode-service/validate-content`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content, standard }) + body: JSON.stringify({ arg0: content, arg1: standard }) }); if (!response.ok) { @@ -87,4 +69,3 @@ export async function ValidateContent(content: string, standard: string): Promis return await response.json(); } - diff --git a/frontend/src/generated/http/codeFormatterService.ts b/frontend/src/generated/http/codeFormatterService.ts index f574674..f3256fe 100644 --- a/frontend/src/generated/http/codeFormatterService.ts +++ b/frontend/src/generated/http/codeFormatterService.ts @@ -3,12 +3,11 @@ const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081'; - -export async function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { - const response = await fetch(`${API_BASE}/api/CodeFormatterService/ServiceStartup`, { +export async function Format(req: any): Promise { + const response = await fetch(`${API_BASE}/api/code-formatter-service/format`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ctx, options }) + body: JSON.stringify(req) }); if (!response.ok) { @@ -17,18 +16,3 @@ export async function ServiceStartup(ctx: context.Context, options: application. return await response.json(); } - -export async function Format(req: codeformatter.FormatRequest): Promise { - const response = await fetch(`${API_BASE}/api/CodeFormatterService/Format`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ req }) - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return await response.json(); -} - diff --git a/frontend/src/generated/http/conversionService.ts b/frontend/src/generated/http/conversionService.ts index 9249293..666cbf3 100644 --- a/frontend/src/generated/http/conversionService.ts +++ b/frontend/src/generated/http/conversionService.ts @@ -3,12 +3,11 @@ const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081'; - -export async function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { - const response = await fetch(`${API_BASE}/api/ConversionService/ServiceStartup`, { +export async function Convert(input: string, category: string, method: string, config: any): Promise { + const response = await fetch(`${API_BASE}/api/conversion-service/convert`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ctx, options }) + body: JSON.stringify({ arg0: input, arg1: category, arg2: method, arg3: config }) }); if (!response.ok) { @@ -17,18 +16,3 @@ export async function ServiceStartup(ctx: context.Context, options: application. return await response.json(); } - -export async function Convert(input: string, category: string, method: string, config: ): Promise { - const response = await fetch(`${API_BASE}/api/ConversionService/Convert`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input, category, method, config }) - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return await response.json(); -} - diff --git a/frontend/src/generated/http/dataGeneratorService.ts b/frontend/src/generated/http/dataGeneratorService.ts index 8fbdaae..5e2e35c 100644 --- a/frontend/src/generated/http/dataGeneratorService.ts +++ b/frontend/src/generated/http/dataGeneratorService.ts @@ -3,26 +3,11 @@ const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081'; - -export async function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { - const response = await fetch(`${API_BASE}/api/DataGeneratorService/ServiceStartup`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ctx, options }) - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return await response.json(); -} - -export async function Generate(req: datagenerator.GenerateRequest): Promise { - const response = await fetch(`${API_BASE}/api/DataGeneratorService/Generate`, { +export async function Generate(req: any): Promise { + const response = await fetch(`${API_BASE}/api/data-generator-service/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ req }) + body: JSON.stringify(req) }); if (!response.ok) { @@ -32,11 +17,10 @@ export async function Generate(req: datagenerator.GenerateRequest): Promise { - const response = await fetch(`${API_BASE}/api/DataGeneratorService/GetPresets`, { +export async function GetPresets(): Promise { + const response = await fetch(`${API_BASE}/api/data-generator-service/get-presets`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ }) + headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { @@ -46,11 +30,11 @@ export async function GetPresets(): Promise { return await response.json(); } -export async function ValidateTemplate(template: string): Promise { - const response = await fetch(`${API_BASE}/api/DataGeneratorService/ValidateTemplate`, { +export async function ValidateTemplate(template: string): Promise { + const response = await fetch(`${API_BASE}/api/data-generator-service/validate-template`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ template }) + body: JSON.stringify({ value: template }) }); if (!response.ok) { @@ -59,4 +43,3 @@ export async function ValidateTemplate(template: string): Promise { - const response = await fetch(`${API_BASE}/api/DateTimeService/ServiceStartup`, { +export async function Convert(req: any): Promise { + const response = await fetch(`${API_BASE}/api/date-time-service/convert`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ctx, options }) + body: JSON.stringify(req) }); if (!response.ok) { @@ -18,11 +17,10 @@ export async function ServiceStartup(ctx: context.Context, options: application. return await response.json(); } -export async function Convert(req: datetimeconverter.ConvertRequest): Promise { - const response = await fetch(`${API_BASE}/api/DateTimeService/Convert`, { +export async function GetPresets(): Promise { + const response = await fetch(`${API_BASE}/api/date-time-service/get-presets`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ req }) + headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { @@ -32,11 +30,11 @@ export async function Convert(req: datetimeconverter.ConvertRequest): Promise { - const response = await fetch(`${API_BASE}/api/DateTimeService/GetPresets`, { +export async function CalculateDelta(req: any): Promise { + const response = await fetch(`${API_BASE}/api/date-time-service/calculate-delta`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ }) + body: JSON.stringify(req) }); if (!response.ok) { @@ -46,11 +44,10 @@ export async function GetPresets(): Promise { return await response.json(); } -export async function CalculateDelta(req: datetimeconverter.DeltaRequest): Promise { - const response = await fetch(`${API_BASE}/api/DateTimeService/CalculateDelta`, { +export async function GetAvailableTimezones(): Promise { + const response = await fetch(`${API_BASE}/api/date-time-service/get-available-timezones`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ req }) + headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { @@ -59,18 +56,3 @@ export async function CalculateDelta(req: datetimeconverter.DeltaRequest): Promi return await response.json(); } - -export async function GetAvailableTimezones(): Promise { - const response = await fetch(`${API_BASE}/api/DateTimeService/GetAvailableTimezones`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ }) - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return await response.json(); -} - diff --git a/frontend/src/generated/http/jWTService.ts b/frontend/src/generated/http/jWTService.ts index 5c013f9..d46e206 100644 --- a/frontend/src/generated/http/jWTService.ts +++ b/frontend/src/generated/http/jWTService.ts @@ -3,12 +3,11 @@ const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081'; - -export async function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { - const response = await fetch(`${API_BASE}/api/JWTService/ServiceStartup`, { +export async function Decode(token: string): Promise { + const response = await fetch(`${API_BASE}/api/jwt-service/decode`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ctx, options }) + body: JSON.stringify({ value: token }) }); if (!response.ok) { @@ -18,11 +17,11 @@ export async function ServiceStartup(ctx: context.Context, options: application. return await response.json(); } -export async function Decode(token: string): Promise { - const response = await fetch(`${API_BASE}/api/JWTService/Decode`, { +export async function Verify(token: string, secret: string, encoding: string): Promise { + const response = await fetch(`${API_BASE}/api/jwt-service/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token }) + body: JSON.stringify({ arg0: token, arg1: secret, arg2: encoding }) }); if (!response.ok) { @@ -32,11 +31,11 @@ export async function Decode(token: string): Promise { return await response.json(); } -export async function Verify(token: string, secret: string, encoding: string): Promise { - const response = await fetch(`${API_BASE}/api/JWTService/Verify`, { +export async function Encode(headerJSON: string, payloadJSON: string, algorithm: string, secret: string): Promise { + const response = await fetch(`${API_BASE}/api/jwt-service/encode`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, secret, encoding }) + body: JSON.stringify({ arg0: headerJSON, arg1: payloadJSON, arg2: algorithm, arg3: secret }) }); if (!response.ok) { @@ -45,18 +44,3 @@ export async function Verify(token: string, secret: string, encoding: string): P return await response.json(); } - -export async function Encode(headerJSON: string, payloadJSON: string, algorithm: string, secret: string): Promise { - const response = await fetch(`${API_BASE}/api/JWTService/Encode`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ headerJSON, payloadJSON, algorithm, secret }) - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return await response.json(); -} - diff --git a/frontend/src/generated/index.ts b/frontend/src/generated/index.ts index 7bc5703..011642d 100644 --- a/frontend/src/generated/index.ts +++ b/frontend/src/generated/index.ts @@ -1,28 +1,10 @@ // Auto-generated unified service facade // Detects runtime environment and uses appropriate implementation -const isWails = () => { - return typeof window !== 'undefined' && - window.runtime && - window.runtime.EventsOn !== undefined; -}; - -import { BarcodeService as WailsBarcodeService } from './wails/barcodeService'; -import { BarcodeService as HTTPBarcodeService } from './http/barcodeService'; -import { CodeFormatterService as WailsCodeFormatterService } from './wails/codeFormatterService'; -import { CodeFormatterService as HTTPCodeFormatterService } from './http/codeFormatterService'; -import { ConversionService as WailsConversionService } from './wails/conversionService'; -import { ConversionService as HTTPConversionService } from './http/conversionService'; -import { DataGeneratorService as WailsDataGeneratorService } from './wails/dataGeneratorService'; -import { DataGeneratorService as HTTPDataGeneratorService } from './http/dataGeneratorService'; -import { DateTimeService as WailsDateTimeService } from './wails/dateTimeService'; -import { DateTimeService as HTTPDateTimeService } from './http/dateTimeService'; -import { JWTService as WailsJWTService } from './wails/jWTService'; -import { JWTService as HTTPJWTService } from './http/jWTService'; - -export const barcodeService = isWails() ? WailsBarcodeService : HTTPBarcodeService; -export const codeFormatterService = isWails() ? WailsCodeFormatterService : HTTPCodeFormatterService; -export const conversionService = isWails() ? WailsConversionService : HTTPConversionService; -export const dataGeneratorService = isWails() ? WailsDataGeneratorService : HTTPDataGeneratorService; -export const dateTimeService = isWails() ? WailsDateTimeService : HTTPDateTimeService; -export const jWTService = isWails() ? WailsJWTService : HTTPJWTService; +// For browser mode testing, we're using HTTP only +export * from './http/jWTService'; +export * from './http/barcodeService'; +export * from './http/codeFormatterService'; +export * from './http/conversionService'; +export * from './http/dataGeneratorService'; +export * from './http/dateTimeService'; diff --git a/frontend/src/generated/wails/barcodeService.ts b/frontend/src/generated/wails/barcodeService.ts index 66f7b8f..090e98d 100644 --- a/frontend/src/generated/wails/barcodeService.ts +++ b/frontend/src/generated/wails/barcodeService.ts @@ -3,28 +3,22 @@ import { BarcodeService } from '../../../bindings/devtoolbox/service'; - -export function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { - return BarcodeService.ServiceStartup(ctx, options); -} - -export function GenerateBarcode(req: barcode.GenerateBarcodeRequest): Promise { +export function GenerateBarcode(req: any): Promise { return BarcodeService.GenerateBarcode(req); } -export function GetBarcodeStandards(): Promise<> { +export function GetBarcodeStandards(): Promise { return BarcodeService.GetBarcodeStandards(); } -export function GetQRErrorLevels(): Promise<> { +export function GetQRErrorLevels(): Promise { return BarcodeService.GetQRErrorLevels(); } -export function GetBarcodeSizes(): Promise<> { +export function GetBarcodeSizes(): Promise { return BarcodeService.GetBarcodeSizes(); } -export function ValidateContent(content: string, standard: string): Promise<> { +export function ValidateContent(content: string, standard: string): Promise { return BarcodeService.ValidateContent(content, standard); } - diff --git a/frontend/src/generated/wails/codeFormatterService.ts b/frontend/src/generated/wails/codeFormatterService.ts index a649dfb..0c54d5f 100644 --- a/frontend/src/generated/wails/codeFormatterService.ts +++ b/frontend/src/generated/wails/codeFormatterService.ts @@ -3,12 +3,6 @@ import { CodeFormatterService } from '../../../bindings/devtoolbox/service'; - -export function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { - return CodeFormatterService.ServiceStartup(ctx, options); -} - -export function Format(req: codeformatter.FormatRequest): Promise { +export function Format(req: any): Promise { return CodeFormatterService.Format(req); } - diff --git a/frontend/src/generated/wails/conversionService.ts b/frontend/src/generated/wails/conversionService.ts index e17af8f..f8265b0 100644 --- a/frontend/src/generated/wails/conversionService.ts +++ b/frontend/src/generated/wails/conversionService.ts @@ -3,12 +3,6 @@ import { ConversionService } from '../../../bindings/devtoolbox/service'; - -export function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { - return ConversionService.ServiceStartup(ctx, options); -} - -export function Convert(input: string, category: string, method: string, config: ): Promise { +export function Convert(input: string, category: string, method: string, config: any): Promise { return ConversionService.Convert(input, category, method, config); } - diff --git a/frontend/src/generated/wails/dataGeneratorService.ts b/frontend/src/generated/wails/dataGeneratorService.ts index c8e82da..2290002 100644 --- a/frontend/src/generated/wails/dataGeneratorService.ts +++ b/frontend/src/generated/wails/dataGeneratorService.ts @@ -3,20 +3,14 @@ import { DataGeneratorService } from '../../../bindings/devtoolbox/service'; - -export function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { - return DataGeneratorService.ServiceStartup(ctx, options); -} - -export function Generate(req: datagenerator.GenerateRequest): Promise { +export function Generate(req: any): Promise { return DataGeneratorService.Generate(req); } -export function GetPresets(): Promise { +export function GetPresets(): Promise { return DataGeneratorService.GetPresets(); } -export function ValidateTemplate(template: string): Promise { +export function ValidateTemplate(template: string): Promise { return DataGeneratorService.ValidateTemplate(template); } - diff --git a/frontend/src/generated/wails/dateTimeService.ts b/frontend/src/generated/wails/dateTimeService.ts index c566726..69458a3 100644 --- a/frontend/src/generated/wails/dateTimeService.ts +++ b/frontend/src/generated/wails/dateTimeService.ts @@ -3,24 +3,18 @@ import { DateTimeService } from '../../../bindings/devtoolbox/service'; - -export function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { - return DateTimeService.ServiceStartup(ctx, options); -} - -export function Convert(req: datetimeconverter.ConvertRequest): Promise { +export function Convert(req: any): Promise { return DateTimeService.Convert(req); } -export function GetPresets(): Promise { +export function GetPresets(): Promise { return DateTimeService.GetPresets(); } -export function CalculateDelta(req: datetimeconverter.DeltaRequest): Promise { +export function CalculateDelta(req: any): Promise { return DateTimeService.CalculateDelta(req); } -export function GetAvailableTimezones(): Promise { +export function GetAvailableTimezones(): Promise { return DateTimeService.GetAvailableTimezones(); } - diff --git a/frontend/src/generated/wails/jWTService.ts b/frontend/src/generated/wails/jWTService.ts index acbfc75..c472def 100644 --- a/frontend/src/generated/wails/jWTService.ts +++ b/frontend/src/generated/wails/jWTService.ts @@ -3,20 +3,14 @@ import { JWTService } from '../../../bindings/devtoolbox/service'; - -export function ServiceStartup(ctx: context.Context, options: application.ServiceOptions): Promise { - return JWTService.ServiceStartup(ctx, options); -} - -export function Decode(token: string): Promise { +export function Decode(token: string): Promise { return JWTService.Decode(token); } -export function Verify(token: string, secret: string, encoding: string): Promise { +export function Verify(token: string, secret: string, encoding: string): Promise { return JWTService.Verify(token, secret, encoding); } -export function Encode(headerJSON: string, payloadJSON: string, algorithm: string, secret: string): Promise { +export function Encode(headerJSON: string, payloadJSON: string, algorithm: string, secret: string): Promise { return JWTService.Encode(headerJSON, payloadJSON, algorithm, secret); } - diff --git a/frontend/src/pages/BarcodeGenerator.jsx b/frontend/src/pages/BarcodeGenerator.jsx index 39ea939..31a53e3 100644 --- a/frontend/src/pages/BarcodeGenerator.jsx +++ b/frontend/src/pages/BarcodeGenerator.jsx @@ -3,7 +3,7 @@ import { Button, Dropdown, InlineLoading } from '@carbon/react'; import { Renew, Download } from '@carbon/icons-react'; import { ToolHeader, ToolPane, ToolSplitPane, ToolLayoutToggle } from '../components/ToolUI'; import useLayoutToggle from '../hooks/useLayoutToggle'; -import { BarcodeService } from '../../bindings/devtoolbox/service'; +import { GenerateBarcode, GetBarcodeStandards, GetQRErrorLevels, GetBarcodeSizes, ValidateContent } from '../services/api'; const BARCODE_STANDARDS = [ { value: 'QR', label: 'QR Code (2D)' }, @@ -157,7 +157,7 @@ export default function BarcodeGenerator() { setError(''); try { - const response = await BarcodeService.GenerateBarcode({ + const response = await GenerateBarcode({ content: content.trim(), standard, size, diff --git a/frontend/src/pages/CodeFormatter/index.jsx b/frontend/src/pages/CodeFormatter/index.jsx index e8886af..547c92a 100644 --- a/frontend/src/pages/CodeFormatter/index.jsx +++ b/frontend/src/pages/CodeFormatter/index.jsx @@ -3,7 +3,7 @@ import { Button, Select, SelectItem, TextInput, IconButton } from '@carbon/react import { Code, TrashCan, Close } from '@carbon/icons-react'; import { ToolHeader, ToolControls, ToolPane, ToolSplitPane, ToolLayoutToggle } from '../../components/ToolUI'; import useLayoutToggle from '../../hooks/useLayoutToggle'; -import { CodeFormatterService } from '../../../bindings/devtoolbox/service'; +import { Format } from '../../services/api'; const FORMATTERS = [ { id: 'json', name: 'JSON', supportsFilter: true, filterPlaceholder: '.users[] | select(.age > 18) | .name' }, @@ -67,10 +67,10 @@ export default function CodeFormatter() { } try { - const result = await CodeFormatterService.Format({ + const result = await Format({ input, formatType, - filter: undefined, + filter: '', minify: false }); @@ -100,10 +100,10 @@ export default function CodeFormatter() { } try { - const result = await CodeFormatterService.Format({ + const result = await Format({ input, formatType, - filter: undefined, + filter: '', minify: true }); @@ -135,7 +135,7 @@ export default function CodeFormatter() { } try { - const result = await CodeFormatterService.Format({ + const result = await Format({ input: formattedOutput, // Always use formatted output as source formatType, filter: filter.trim(), diff --git a/frontend/src/pages/DataGenerator/index.jsx b/frontend/src/pages/DataGenerator/index.jsx index ce20490..cbc730f 100644 --- a/frontend/src/pages/DataGenerator/index.jsx +++ b/frontend/src/pages/DataGenerator/index.jsx @@ -6,7 +6,7 @@ import { initialState, reducer } from './constants'; import GeneratorControls from './components/GeneratorControls'; import VariableControls from './components/VariableControls'; import HelpModal from './components/HelpModal'; -import { DataGeneratorService } from '../../../bindings/devtoolbox/service'; +import { GetPresets, Generate, ValidateTemplate } from '../../generated/http/dataGeneratorService'; export default function DataGenerator() { const [state, dispatch] = React.useReducer(reducer, initialState); @@ -22,7 +22,7 @@ export default function DataGenerator() { useEffect(() => { const loadPresets = async () => { try { - const response = await DataGeneratorService.GetPresets(); + const response = await GetPresets(); console.log('GetPresets response:', response); const presets = response.presets || response; @@ -94,7 +94,7 @@ export default function DataGenerator() { separator: state.separator === 'custom' ? state.customSeparator : state.separator }; - const response = await DataGeneratorService.Generate(request); + const response = await Generate(request); if (response.error) { dispatch({ type: 'SET_ERROR', payload: response.error }); diff --git a/frontend/src/pages/JwtDebugger/components/JwtDecode.jsx b/frontend/src/pages/JwtDebugger/components/JwtDecode.jsx index 132661a..2a611b4 100644 --- a/frontend/src/pages/JwtDebugger/components/JwtDecode.jsx +++ b/frontend/src/pages/JwtDebugger/components/JwtDecode.jsx @@ -6,7 +6,7 @@ import SignatureVerification from './SignatureVerification'; import { Button } from '@carbon/react'; import { MagicWand } from '@carbon/icons-react'; import { EXAMPLE_SECRET } from '../jwtUtils'; -import { JWTService } from '../../../../bindings/devtoolbox/service'; +import { Encode } from '../../../services/api'; export default function JwtDecode({ state, dispatch, layout, verifySignature }) { // Tab change handlers @@ -23,7 +23,7 @@ export default function JwtDecode({ state, dispatch, layout, verifySignature }) }; try { - const response = await JWTService.Encode( + const response = await Encode( JSON.stringify(header), JSON.stringify(payload), 'HS256', diff --git a/frontend/src/pages/TextConverter/index.jsx b/frontend/src/pages/TextConverter/index.jsx index d3e48d2..d436f6b 100644 --- a/frontend/src/pages/TextConverter/index.jsx +++ b/frontend/src/pages/TextConverter/index.jsx @@ -17,7 +17,7 @@ import { PLACEHOLDERS, LAYOUT } from './strings'; -import { ConversionService } from '../../../bindings/devtoolbox/service'; +import { Convert } from '../../generated/http/conversionService'; export default function TextBasedConverter() { // Persistent state initialization @@ -126,7 +126,7 @@ export default function TextBasedConverter() { try { // Include subMode in backend request const backendConfig = { ...cfg, subMode: sub }; - const result = await ConversionService.Convert(text, cat, meth, backendConfig); + const result = await Convert(text, cat, meth, backendConfig); setOutput(result); setError(''); } catch (err) { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index eeb75e9..c2d9b2f 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,2 +1,2 @@ -// Re-export generated services for convenience +// Re-export generated services for browser mode export * from '../generated'; From f794349765992b7d62af999d73bfd071b5d8c57c Mon Sep 17 00:00:00 2001 From: Vuong <3168632+vuon9@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:53:27 +0700 Subject: [PATCH 12/12] feat: add unit tests for JWT and barcode services, and integration tests for the API router. --- pkg/router/api_test.go | 90 +++++++++++++++++++++++++++++++++++++++++ service/barcode_test.go | 31 ++++++++++++++ service/jwt_test.go | 25 ++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 pkg/router/api_test.go create mode 100644 service/barcode_test.go create mode 100644 service/jwt_test.go diff --git a/pkg/router/api_test.go b/pkg/router/api_test.go new file mode 100644 index 0000000..1eb0083 --- /dev/null +++ b/pkg/router/api_test.go @@ -0,0 +1,90 @@ +package router + +import ( + "bytes" + "devtoolbox/service" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestAPI_Integration(t *testing.T) { + gin.SetMode(gin.TestMode) + server := NewServer() + engine := server.Engine() + + // Register real services (nil app is fine for these tests as they don't use Wails runtime) + jwtSvc := service.NewJWTService(nil) + barcodeSvc := service.NewBarcodeService(nil) + + server.Register(jwtSvc) + server.Register(barcodeSvc) + + t.Run("health check", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/health", nil) + engine.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"status":"ok"`) + }) + + t.Run("jwt service - decode invalid token", func(t *testing.T) { + w := httptest.NewRecorder() + reqBody := map[string]string{"token": "invalid.token.here"} + body, _ := json.Marshal(reqBody) + + req, _ := http.NewRequest("POST", "/api/jwt-service/decode", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + engine.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"isValid":false`) + assert.Contains(t, w.Body.String(), `"error"`) + }) + + t.Run("barcode service - get standards", func(t *testing.T) { + w := httptest.NewRecorder() + // No parameters needed for GetBarcodeStandards + req, _ := http.NewRequest("POST", "/api/barcode-service/get-barcode-standards", nil) + engine.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var standards []map[string]string + err := json.Unmarshal(w.Body.Bytes(), &standards) + assert.NoError(t, err) + assert.NotEmpty(t, standards) + }) + + t.Run("barcode service - generate code128", func(t *testing.T) { + w := httptest.NewRecorder() + reqBody := map[string]interface{}{ + "content": "12345", + "standard": "Code128", + "size": 256, + } + body, _ := json.Marshal(reqBody) + + req, _ := http.NewRequest("POST", "/api/barcode-service/generate-barcode", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + engine.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "data:image/png;base64") + }) + + t.Run("CORS headers", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("OPTIONS", "/health", nil) + req.Header.Set("Origin", "http://example.com") + req.Header.Set("Access-Control-Request-Method", "POST") + engine.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNoContent, w.Code) + assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) + }) +} diff --git a/service/barcode_test.go b/service/barcode_test.go new file mode 100644 index 0000000..744746b --- /dev/null +++ b/service/barcode_test.go @@ -0,0 +1,31 @@ +package service + +import ( + "devtoolbox/internal/barcode" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBarcodeService_Delegation(t *testing.T) { + svc := NewBarcodeService(nil) + + t.Run("GenerateBarcode", func(t *testing.T) { + req := barcode.GenerateBarcodeRequest{ + Content: "12345", + Standard: "Code128", + } + resp := svc.GenerateBarcode(req) + assert.NotEmpty(t, resp.DataURL) + }) + + t.Run("GetBarcodeStandards", func(t *testing.T) { + standards := svc.GetBarcodeStandards() + assert.NotEmpty(t, standards) + }) + + t.Run("GetBarcodeSizes", func(t *testing.T) { + sizes := svc.GetBarcodeSizes() + assert.NotEmpty(t, sizes) + }) +} diff --git a/service/jwt_test.go b/service/jwt_test.go new file mode 100644 index 0000000..ff4f7af --- /dev/null +++ b/service/jwt_test.go @@ -0,0 +1,25 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestJWTService_Delegation(t *testing.T) { + svc := NewJWTService(nil) + + t.Run("Decode invalid token", func(t *testing.T) { + resp, err := svc.Decode("invalid.token") + assert.NoError(t, err) // Service returns error inside response, not as go error + assert.False(t, resp.Valid) + assert.NotEmpty(t, resp.Error) + }) + + t.Run("Verify empty token", func(t *testing.T) { + resp, err := svc.Verify("", "secret", "utf8") + assert.NoError(t, err) + assert.False(t, resp.Valid) + assert.Contains(t, resp.Error, "Token cannot be empty") + }) +}