Files
bc-netts-energy/internal/http/server.go
2025-11-03 19:26:48 +08:00

224 lines
6.5 KiB
Go

package http
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
appconfig "github.com/D36u99er/bc-netts-energy/internal/config"
"github.com/D36u99er/bc-netts-energy/internal/netts"
"github.com/D36u99er/bc-netts-energy/internal/service"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"log/slog"
)
// Server wraps the HTTP server.
type Server struct {
cfg appconfig.ServerConfig
httpServer *http.Server
energySvc *service.EnergyService
logger *slog.Logger
shutdownDur time.Duration
}
// NewServer constructs a Server with routes wired.
func NewServer(cfg appconfig.ServerConfig, energySvc *service.EnergyService, logger *slog.Logger) *Server {
router := chi.NewRouter()
router.Use(middleware.RequestID)
router.Use(middleware.RealIP)
router.Use(middleware.Recoverer)
s := &Server{
cfg: cfg,
energySvc: energySvc,
logger: logger,
shutdownDur: cfg.ShutdownTimeout.Duration(),
}
router.Use(s.loggingMiddleware)
router.Get("/healthz", s.handleHealth)
router.Post("/api/v1/energy/rent", s.handleRentEnergy)
httpServer := &http.Server{
Addr: cfg.Address,
Handler: router,
ReadTimeout: cfg.ReadTimeout.Duration(),
WriteTimeout: cfg.WriteTimeout.Duration(),
IdleTimeout: cfg.IdleTimeout.Duration(),
}
s.httpServer = httpServer
return s
}
// Run starts the HTTP server and listens for shutdown from context.
func (s *Server) Run(ctx context.Context) error {
errCh := make(chan error, 1)
go func() {
s.logger.Info("http server listening", "address", s.cfg.Address)
if err := s.httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case <-ctx.Done():
return s.shutdown(context.Background())
case err := <-errCh:
return err
}
}
func (s *Server) shutdown(ctx context.Context) error {
timeout := s.shutdownDur
if timeout <= 0 {
timeout = 15 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
s.logger.Info("shutting down http server", "timeout", timeout)
return s.httpServer.Shutdown(ctx)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"status": "ok",
"time": time.Now().UTC().Format(time.RFC3339Nano),
})
}
func (s *Server) handleRentEnergy(w http.ResponseWriter, r *http.Request) {
var req rentEnergyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request payload", err)
return
}
defer r.Body.Close()
ctx := r.Context()
resp, err := s.energySvc.EnsureEnergy(ctx, service.EnsureEnergyRequest{
FromAddress: req.FromAddress,
ToAddress: req.ToAddress,
Amount: req.Amount,
CallbackURL: req.CallbackURL,
})
if err != nil {
s.logger.ErrorContext(ctx, "ensure energy failed", "error", err)
writeError(w, http.StatusBadGateway, "failed to ensure energy", err)
return
}
writeJSON(w, http.StatusOK, newRentEnergyResponse(resp))
}
func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
s.logger.Info("http request",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"duration", time.Since(start),
"request_id", middleware.GetReqID(r.Context()),
)
})
}
type rentEnergyRequest struct {
FromAddress string `json:"from_address"`
ToAddress string `json:"to_address"`
Amount string `json:"amount"`
CallbackURL string `json:"callback_url"`
}
type rentEnergyResponse struct {
FromAddress string `json:"from_address"`
ToAddress string `json:"to_address"`
Amount string `json:"amount"`
AddressAdded bool `json:"address_added"`
RecommendedEnergy int `json:"recommended_energy"`
EnergyNeeded int `json:"energy_needed"`
CyclesBefore int `json:"cycles_before"`
CyclesAfter int `json:"cycles_after"`
CyclesPurchased int `json:"cycles_purchased,omitempty"`
TotalCycles int `json:"total_cycles,omitempty"`
OrderID string `json:"order_id,omitempty"`
OrderStatus string `json:"order_status,omitempty"`
TotalCost float64 `json:"total_cost,omitempty"`
NextDelegation int64 `json:"next_delegation_time,omitempty"`
Analysis netts.TransferDetails `json:"analysis"`
Status *statusSummary `json:"status,omitempty"`
}
type statusSummary struct {
Mode string `json:"mode"`
Status string `json:"status"`
CyclesRemaining int `json:"cycles_remaining"`
OpenOrders int `json:"open_orders"`
ExpiryTime int64 `json:"expiry_time"`
}
func newRentEnergyResponse(resp *service.EnsureEnergyResponse) rentEnergyResponse {
var status *statusSummary
if resp.Status != nil {
status = &statusSummary{
Mode: resp.Status.Mode,
Status: resp.Status.Status,
CyclesRemaining: resp.Status.CyclesRemaining,
OpenOrders: resp.Status.OpenOrders,
ExpiryTime: resp.Status.ExpiryTime,
}
}
return rentEnergyResponse{
FromAddress: resp.FromAddress,
ToAddress: resp.ToAddress,
Amount: resp.Amount,
AddressAdded: resp.AddressAdded,
RecommendedEnergy: resp.RecommendedEnergy,
EnergyNeeded: resp.EnergyNeeded,
CyclesBefore: resp.CyclesBefore,
CyclesAfter: resp.CyclesAfter,
CyclesPurchased: resp.CyclesPurchased,
TotalCycles: resp.TotalCycles,
OrderID: resp.OrderID,
OrderStatus: resp.OrderStatus,
TotalCost: resp.TotalCost,
NextDelegation: resp.NextDelegation,
Analysis: resp.Analysis,
Status: status,
}
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func writeError(w http.ResponseWriter, status int, message string, err error) {
type errorBody struct {
Error string `json:"error"`
Details string `json:"details,omitempty"`
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
detail := ""
if err != nil {
detail = err.Error()
}
_ = json.NewEncoder(w).Encode(errorBody{
Error: message,
Details: detail,
})
}