diff --git a/cmd/bursa/api.go b/cmd/bursa/api.go index e0ea3fa..4755334 100644 --- a/cmd/bursa/api.go +++ b/cmd/bursa/api.go @@ -15,7 +15,10 @@ package main import ( + "context" "os" + "os/signal" + "syscall" "github.com/blinklabs-io/bursa/internal/api" "github.com/blinklabs-io/bursa/internal/config" @@ -29,17 +32,25 @@ func apiCommand() *cobra.Command { Short: "Runs the api", Run: func(cmd *cobra.Command, args []string) { cfg := config.GetConfig() + + // Create a context that can be canceled for graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Handle interrupt signals for graceful shutdown + go func() { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + cancel() + }() + // Start API listener logger := logging.GetLogger() - // Start API listener - logger.Info("starting API listener on", "address", cfg.Api.ListenAddress, "port", cfg.Api.ListenPort) - if err := api.Start(cfg); err != nil { + if err := api.Start(ctx, cfg, nil, nil); err != nil { logger.Error("failed to start API:", "error", err) os.Exit(1) } - - // Wait forever - select {} }, } return &apiCommand diff --git a/docs/docs.go b/docs/docs.go index 2a00515..0b029e7 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -29,7 +29,7 @@ const docTemplate = `{ "produces": [ "application/json" ], - "summary": "CreateWallet", + "summary": "Create a wallet", "responses": { "200": { "description": "Ok", @@ -119,6 +119,9 @@ const docTemplate = `{ "payment_address": { "type": "string" }, + "payment_extended_skey": { + "$ref": "#/definitions/bursa.KeyFile" + }, "payment_kvey": { "$ref": "#/definitions/bursa.KeyFile" }, @@ -128,6 +131,9 @@ const docTemplate = `{ "stake_address": { "type": "string" }, + "stake_extended_skey": { + "$ref": "#/definitions/bursa.KeyFile" + }, "stake_skey": { "$ref": "#/definitions/bursa.KeyFile" }, diff --git a/docs/swagger.json b/docs/swagger.json index f22b375..1e67e17 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -25,7 +25,7 @@ "produces": [ "application/json" ], - "summary": "CreateWallet", + "summary": "Create a wallet", "responses": { "200": { "description": "Ok", @@ -115,6 +115,9 @@ "payment_address": { "type": "string" }, + "payment_extended_skey": { + "$ref": "#/definitions/bursa.KeyFile" + }, "payment_kvey": { "$ref": "#/definitions/bursa.KeyFile" }, @@ -124,6 +127,9 @@ "stake_address": { "type": "string" }, + "stake_extended_skey": { + "$ref": "#/definitions/bursa.KeyFile" + }, "stake_skey": { "$ref": "#/definitions/bursa.KeyFile" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b8141d9..2f53aa5 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -22,12 +22,16 @@ definitions: type: string payment_address: type: string + payment_extended_skey: + $ref: '#/definitions/bursa.KeyFile' payment_kvey: $ref: '#/definitions/bursa.KeyFile' payment_skey: $ref: '#/definitions/bursa.KeyFile' stake_address: type: string + stake_extended_skey: + $ref: '#/definitions/bursa.KeyFile' stake_skey: $ref: '#/definitions/bursa.KeyFile' stake_vkey: @@ -55,7 +59,7 @@ paths: description: Ok schema: $ref: '#/definitions/bursa.Wallet' - summary: CreateWallet + summary: Create a wallet /api/wallet/restore: post: consumes: diff --git a/go.mod b/go.mod index 44f555e..3229971 100644 --- a/go.mod +++ b/go.mod @@ -32,11 +32,12 @@ require ( github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.5 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect - github.com/go-openapi/jsonreference v0.19.6 // indirect - github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/jsonreference v0.20.0 // indirect + github.com/go-openapi/spec v0.20.6 // indirect github.com/go-openapi/swag v0.19.15 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect @@ -55,11 +56,14 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.12.0 // indirect github.com/prometheus/client_model v0.2.0 // indirect github.com/prometheus/common v0.32.1 // indirect github.com/prometheus/procfs v0.7.3 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/swaggo/http-swagger v1.3.4 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect github.com/x448/float16 v0.8.4 // indirect diff --git a/go.sum b/go.sum index 51b47c1..b1b7e4e 100644 --- a/go.sum +++ b/go.sum @@ -118,8 +118,12 @@ github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUe github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= +github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= +github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= @@ -306,10 +310,14 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= +github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww= +github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ= github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= diff --git a/internal/api/api.go b/internal/api/api.go index 062b532..4788268 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -15,13 +15,16 @@ package api import ( + "context" + "encoding/json" "fmt" + "log/slog" + "net" "net/http" - "github.com/gin-gonic/gin" - "github.com/penglongli/gin-metrics/ginmetrics" - swaggerFiles "github.com/swaggo/files" // swagger embed files - ginSwagger "github.com/swaggo/gin-swagger" // gin-swagger middleware + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + httpSwagger "github.com/swaggo/http-swagger" "github.com/blinklabs-io/bursa" "github.com/blinklabs-io/bursa/internal/config" @@ -47,122 +50,189 @@ type WalletRestoreRequest struct { // @license.name Apache 2.0 // @license.url http://www.apache.org/licenses/LICENSE-2.0.html -func Start(cfg *config.Config) error { - // Disable gin debug and color output - gin.SetMode(gin.ReleaseMode) - gin.DisableConsoleColor() - // Configure API router - router := gin.New() - // Catch panics and return a 500 - router.Use(gin.Recovery()) - // Standard logging + +// Define Prometheus metrics +var ( + walletsCreatedCounter = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "bursa_wallets_created_count", + Help: "Total number of wallets created", + }) + walletsFailCounter = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "bursa_wallets_fail_count", + Help: "Total number of wallet creation or restoration failures", + }) + walletsRestoreCounter = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "bursa_wallets_restore_count", + Help: "Total number of wallets restored", + }) +) + +// Register Prometheus metrics +func init() { + prometheus.MustRegister(walletsCreatedCounter) + prometheus.MustRegister(walletsFailCounter) + prometheus.MustRegister(walletsRestoreCounter) +} + +// Start initializes and starts the HTTP servers for the API and metrics +// Listeners can be passed in for testing purposes to provide ephermeral ports +func Start(ctx context.Context, cfg *config.Config, apiListener, metricsListener net.Listener) error { logger := logging.GetLogger() - // Access logging accessLogger := logging.GetAccessLogger() - accessMiddleware := func(c *gin.Context) { - accessLogger.Info("request received", "method", c.Request.Method, "path", c.Request.URL.Path, "remote_addr", c.ClientIP()) - c.Next() - statusCode := c.Writer.Status() - accessLogger.Info("response sent", "status", statusCode, "method", c.Request.Method, "path", c.Request.URL.Path, "remote_addr", c.ClientIP()) - } - router.Use(accessMiddleware) - - // Create a healthcheck - router.GET("/healthcheck", handleHealthcheck) - // Create a swagger endpoint - router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) - - // Metrics - metricsRouter := gin.New() - metrics := ginmetrics.GetMonitor() - // Set metrics path - metrics.SetMetricPath("/") - // Set metrics router - metrics.Expose(metricsRouter) - // Use metrics middleware without exposing path in main app router - metrics.UseWithoutExposingEndpoint(router) - - // Custom metrics - createdMetric := &ginmetrics.Metric{ - Type: ginmetrics.Counter, - Name: "bursa_wallets_created_count", - Description: "total number of wallets created", - Labels: nil, - } - failureMetric := &ginmetrics.Metric{ - Type: ginmetrics.Counter, - Name: "bursa_wallets_fail_count", - Description: "total number of wallet failures", - Labels: nil, - } - restoreMetric := &ginmetrics.Metric{ - Type: ginmetrics.Counter, - Name: "bursa_wallets_restore_count", - Description: "total number of wallets restored", - Labels: nil, - } - // Add to global monitor object - _ = ginmetrics.GetMonitor().AddMetric(createdMetric) - _ = ginmetrics.GetMonitor().AddMetric(failureMetric) - _ = ginmetrics.GetMonitor().AddMetric(restoreMetric) - // Start metrics listener + logger.Info("initializing API server") + + // + // Main HTTP server for API endpoints + // + mainMux := http.NewServeMux() + + // Healthcheck + mainMux.HandleFunc("/healthcheck", handleHealthcheck) + + // Swagger endpoint + mainMux.HandleFunc("/swagger/", httpSwagger.WrapHandler) + + // API routes + mainMux.HandleFunc("/api/wallet/create", handleWalletCreate) + mainMux.HandleFunc("/api/wallet/restore", handleWalletRestore) + + // Wrap the mainMux with an access-logging middleware + mainHandler := logMiddleware(mainMux, accessLogger) + + // + // Metrics HTTP server + // + metricsMux := http.NewServeMux() + metricsMux.Handle("/metrics", promhttp.Handler()) + + // Start metrics server go func() { - // TODO: return error if we cannot initialize metrics - logger.Info("starting metrics listener", "address", cfg.Metrics.ListenAddress, ":", cfg.Metrics.ListenPort) - _ = metricsRouter.Run(fmt.Sprintf("%s:%d", - cfg.Metrics.ListenAddress, - cfg.Metrics.ListenPort, - )) + logger.Info("starting metrics listener", + "address", cfg.Metrics.ListenAddress, + "port", cfg.Metrics.ListenPort, + ) + var err error + if metricsListener == nil { + err = http.ListenAndServe( + fmt.Sprintf("%s:%d", cfg.Metrics.ListenAddress, cfg.Metrics.ListenPort), + metricsMux, + ) + } else { + server := &http.Server{ + Handler: metricsMux, + } + err = server.Serve(metricsListener) + } + if err != nil && err != http.ErrServerClosed { + logger.Error("metrics listener failed to start", "error", err) + } }() - // Configure API routes - router.GET("/api/wallet/create", handleWalletCreate) - router.POST("/api/wallet/restore", handleWalletRestore) - - // Start API listener - err := router.Run(fmt.Sprintf("%s:%d", - cfg.Api.ListenAddress, - cfg.Api.ListenPort, - )) + // Start API server + logger.Info("starting API listener", + "address", cfg.Api.ListenAddress, + "port", cfg.Api.ListenPort, + ) + var err error + if apiListener == nil { + err = http.ListenAndServe( + fmt.Sprintf("%s:%d", cfg.Api.ListenAddress, cfg.Api.ListenPort), + mainHandler, + ) + } else { + server := &http.Server{ + Handler: mainHandler, + } + err = server.Serve(apiListener) + } return err } -func handleHealthcheck(c *gin.Context) { - c.JSON(200, gin.H{"healthy": true}) +func logMiddleware(next http.Handler, accessLogger *slog.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + accessLogger.Info("request received", + "method", r.Method, + "path", r.URL.Path, + "remote_addr", r.RemoteAddr, + ) + + // Wrap the ResponseWriter to capture status code + rec := &statusRecorder{ + ResponseWriter: w, + statusCode: http.StatusOK, + } + next.ServeHTTP(rec, r) + + accessLogger.Info("response sent", + "status", rec.statusCode, + "method", r.Method, + "path", r.URL.Path, + "remote_addr", r.RemoteAddr, + ) + }) +} + +// statusRecorder helps to record the response status code +type statusRecorder struct { + http.ResponseWriter + statusCode int +} + +func (r *statusRecorder) WriteHeader(code int) { + r.statusCode = code + r.ResponseWriter.WriteHeader(code) +} + +// handleHealthcheck responds to GET /healthcheck +func handleHealthcheck(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"healthy": true}`)) } -// handleCreateWallet godoc +// handleWalletCreate godoc // -// @Summary CreateWallet +// @Summary Create a wallet // @Description Create a wallet and return details // @Produce json // @Success 200 {object} bursa.Wallet "Ok" // @Router /api/wallet/create [get] -func handleWalletCreate(c *gin.Context) { +func handleWalletCreate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + logger := logging.GetLogger() mnemonic, err := bursa.NewMnemonic() if err != nil { logger.Error("failed to load mnemonic", "error", err) - c.JSON(500, fmt.Sprintf("failed to load mnemonic: %s", err)) - _ = ginmetrics.GetMonitor(). - GetMetric("bursa_wallets_fail_count"). - Inc(nil) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(fmt.Sprintf("failed to load mnemonic: %s", err))) + walletsFailCounter.Inc() return } - w, err := bursa.NewDefaultWallet(mnemonic) + wallet, err := bursa.NewDefaultWallet(mnemonic) if err != nil { logger.Error("failed to initialize wallet", "error", err) - c.JSON(500, fmt.Sprintf("failed to initialize wallet: %s", err)) - _ = ginmetrics.GetMonitor(). - GetMetric("bursa_wallets_fail_count"). - Inc(nil) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(fmt.Sprintf("failed to initialize wallet: %s", err))) + // Increment fail counter + walletsFailCounter.Inc() return } - c.JSON(200, w) - _ = ginmetrics.GetMonitor().GetMetric("bursa_wallets_create_count").Inc(nil) + + w.Header().Set("Content-Type", "application/json") + resp, _ := json.Marshal(wallet) + _, _ = w.Write(resp) + // Increment creation counter + walletsCreatedCounter.Inc() } // handleWalletRestore handles the wallet restoration request. @@ -176,32 +246,32 @@ func handleWalletCreate(c *gin.Context) { // @Failure 400 {string} string "Invalid request" // @Failure 500 {string} string "Internal server error" // @Router /api/wallet/restore [post] -func handleWalletRestore(c *gin.Context) { - var request WalletRestoreRequest - if err := c.BindJSON(&request); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) - _ = ginmetrics.GetMonitor(). - GetMetric("bursa_wallets_fail_count"). - Inc(nil) +func handleWalletRestore(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - // Restore the wallet using the mnemonic - wallet, err := bursa.NewDefaultWallet(request.Mnemonic) + var req WalletRestoreRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"Invalid request"}`)) + // Increment fail counter + walletsFailCounter.Inc() + return + } + + wallet, err := bursa.NewDefaultWallet(req.Mnemonic) if err != nil { - c.JSON( - http.StatusInternalServerError, - gin.H{"error": "Internal server error"}, - ) - _ = ginmetrics.GetMonitor(). - GetMetric("bursa_wallets_fail_count"). - Inc(nil) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"Internal server error"}`)) + walletsFailCounter.Inc() return } - // Return the wallet details - c.JSON(http.StatusOK, wallet) - _ = ginmetrics.GetMonitor(). - GetMetric("bursa_wallets_restore_count"). - Inc(nil) + w.Header().Set("Content-Type", "application/json") + resp, _ := json.Marshal(wallet) + _, _ = w.Write(resp) + // Increment restore counter + walletsRestoreCounter.Inc() } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 6183d9d..edae517 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -16,12 +16,22 @@ package api import ( "bytes" + "context" "encoding/json" + "fmt" "io" + "net" "net/http" "net/http/httptest" "reflect" + "strconv" + "strings" "testing" + "time" + + "github.com/blinklabs-io/bursa/internal/config" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" ) // Mock JSON data for successful wallet restoration @@ -51,6 +61,122 @@ var mockWalletResponseJSON = `{ } }` +func startAPI(t *testing.T) (apiBaseURL, metricsBaseURL string, cleanup func()) { + t.Helper() + + // Create listeners for API and metrics with ephemeral ports + apiListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to create API listener: %v", err) + } + metricsListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to create metrics listener: %v", err) + } + + // Retrieve the dynamically assigned ports + apiPort := apiListener.Addr().(*net.TCPAddr).Port + metricsPort := metricsListener.Addr().(*net.TCPAddr).Port + + // Create the configuration + cfg := &config.Config{ + Network: "testnet", + Api: config.ApiConfig{ + ListenAddress: "127.0.0.1", + ListenPort: uint(apiPort), + }, + Metrics: config.MetricsConfig{ + ListenAddress: "127.0.0.1", + ListenPort: uint(metricsPort), + }, + } + + // Start the API in a separate goroutine + ctx, cancel := context.WithCancel(context.Background()) + + // Define cleanup to be called after the test + cleanup = func() { + cancel() + } + + go func() { + if err := Start(ctx, cfg, apiListener, metricsListener); err != nil { + // NOTE: This logs the error but does not fail the entire test + t.Errorf("failed to start API: %v", err) + } + }() + + // Construct base URLs + apiBaseURL = fmt.Sprintf("http://127.0.0.1:%d", apiPort) + metricsBaseURL = fmt.Sprintf("http://127.0.0.1:%d", metricsPort) + + // Verify server readiness by checking the /healthcheck endpoint + waitForServer(apiBaseURL) + + return apiBaseURL, metricsBaseURL, cleanup +} + +// waitForServer actively waits for the server to be ready with retries. +func waitForServer(apiBaseURL string) { + const maxRetries = 50 + const retryInterval = 10 * time.Millisecond + + for i := 0; i < maxRetries; i++ { + resp, err := http.Get(fmt.Sprintf("%s/healthcheck", apiBaseURL)) + if err == nil && resp.StatusCode == http.StatusOK { + return // Server is ready + } + time.Sleep(retryInterval) + } + + panic("server did not start within the expected time") +} + +func TestMetricsEndpoint(t *testing.T) { + // Start the API + _, metricsBaseURL, cleanup := startAPI(t) + defer cleanup() + + // Test the /metrics endpoint + resp, err := http.Get(fmt.Sprintf("%s/metrics", metricsBaseURL)) + if err != nil { + t.Fatalf("failed to call metrics endpoint: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %v", resp.StatusCode) + } +} + +func TestMetricsRegistered(t *testing.T) { + // api.init() allows to not start the metrics server + metricFamilies, err := prometheus.DefaultGatherer.Gather() + assert.NoError(t, err, "Unable to gather metrics from default registry") + + // Flags to check if our metrics exist + foundWalletsCreated := false + foundWalletsFail := false + foundWalletsRestore := false + + for _, mf := range metricFamilies { + switch *mf.Name { + case "bursa_wallets_created_count": + foundWalletsCreated = true + case "bursa_wallets_fail_count": + foundWalletsFail = true + case "bursa_wallets_restore_count": + foundWalletsRestore = true + } + } + + // Verify that all expected metrics are present + assert.True(t, foundWalletsCreated, + "Expected metric `bursa_wallets_created_count` was not registered") + assert.True(t, foundWalletsFail, + "Expected metric `bursa_wallets_fail_count` was not registered") + assert.True(t, foundWalletsRestore, + "Expected metric `bursa_wallets_restore_count` was not registered") +} + func TestRestoreWallet(t *testing.T) { t.Run("Successful Wallet Restoration", func(t *testing.T) { t.Parallel() @@ -118,3 +244,108 @@ func TestRestoreWallet(t *testing.T) { }) } + +func parseMetric(metricsData []byte, metricName string) float64 { + lines := strings.Split(string(metricsData), "\n") + for _, line := range lines { + // Skip empty lines or comment lines that start with "#" + if len(line) == 0 || strings.HasPrefix(line, "#") { + continue + } + + // Example lines we want to match: + // bursa_wallets_created_count 0 + + if strings.HasPrefix(line, metricName) { + parts := strings.Fields(line) + if len(parts) == 2 { + // parts[0] could be "bursa_wallets_created_count" + // parts[1] is the numeric value string + val, err := strconv.ParseFloat(parts[1], 64) + if err == nil { + return val + } + } + } + } + // If the metric wasn't found + return -1 +} + +func TestWalletCreateIncrementsCounter(t *testing.T) { + // Start the API (includes metrics) + apiBaseURL, metricsBaseURL, cleanup := startAPI(t) + defer cleanup() + + // Fetch the metrics once to get the current count + resp, err := http.Get(fmt.Sprintf("%s/metrics", metricsBaseURL)) + assert.NoError(t, err, "failed to call initial metrics endpoint") + assert.Equal(t, http.StatusOK, resp.StatusCode) + + initialBody, err := io.ReadAll(resp.Body) + resp.Body.Close() + assert.NoError(t, err, "failed to read initial metrics response") + + initialCount := parseMetric(initialBody, "bursa_wallets_created_count") + // If parseMetric returned -1, the metric wasn't found at all. + assert.NotEqual(t, float64(-1), initialCount, + "Expected `bursa_wallets_created_count` to be registered initially") + + // Call /api/wallet/create to create a wallet + createWalletResp, err := http.Get(fmt.Sprintf("%s/api/wallet/create", apiBaseURL)) + assert.NoError(t, err, "failed to call /api/wallet/create endpoint") + assert.Equal(t, http.StatusOK, createWalletResp.StatusCode, + "expected /api/wallet/create to return 200 on success") + createWalletResp.Body.Close() + + // Fetch the metrics again + resp2, err := http.Get(fmt.Sprintf("%s/metrics", metricsBaseURL)) + assert.NoError(t, err, "failed to call second metrics endpoint") + assert.Equal(t, http.StatusOK, resp2.StatusCode) + + secondBody, err := io.ReadAll(resp2.Body) + resp2.Body.Close() + assert.NoError(t, err, "failed to read second metrics response") + + newCount := parseMetric(secondBody, "bursa_wallets_created_count") + + // Verify that the counter incremented by 1 + expected := initialCount + 1 + assert.Equal(t, expected, newCount, + "bursa_wallets_created_count should have incremented by 1 after creating a wallet") +} + +func TestCreateWalletReturnsMnemonic(t *testing.T) { + apiBaseURL, _, cleanup := startAPI(t) + defer cleanup() + + resp, err := http.Get(fmt.Sprintf("%s/api/wallet/create", apiBaseURL)) + if err != nil { + t.Fatalf("Failed to call /api/wallet/create: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Failed to read create wallet response body: %v", err) + } + + if resp.StatusCode != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", resp.StatusCode, string(body)) + } + + var createWalletResponse map[string]interface{} + if err := json.Unmarshal(body, &createWalletResponse); err != nil { + t.Fatalf("Failed to unmarshal create wallet response: %v", err) + } + + mnemonicVal, ok := createWalletResponse["mnemonic"] + if !ok { + t.Errorf("Expected key 'mnemonic' in createWalletResponse, but it was missing") + } else { + mnemonicStr, isString := mnemonicVal.(string) + if !isString || mnemonicStr == "" { + t.Errorf("Expected 'mnemonic' to be a non-empty string, got %v", mnemonicVal) + } + } +}