From 1b43fe097525d5e202e127d1090ade293318ec41 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 18 May 2026 22:35:07 +0300 Subject: [PATCH 1/3] refactor(app): restructure application initialization and error handling --- cmd/webhix/main.go | 10 +++- internal/app/app.go | 34 ++++------- internal/app/deps.go | 117 ++++++++++++++++++++++++++++++------- internal/app/errors.go | 12 ++++ internal/app/http.go | 109 ---------------------------------- internal/app/services.go | 41 ------------- internal/core/hook_core.go | 16 ++--- internal/repos/hook.go | 18 +++--- internal/server/handler.go | 82 +++++++++++++------------- 9 files changed, 185 insertions(+), 254 deletions(-) create mode 100644 internal/app/errors.go delete mode 100644 internal/app/http.go delete mode 100644 internal/app/services.go diff --git a/cmd/webhix/main.go b/cmd/webhix/main.go index 21433b5..3e7ea30 100644 --- a/cmd/webhix/main.go +++ b/cmd/webhix/main.go @@ -26,8 +26,14 @@ func main() { os.Exit(1) } - if err := app.Start(ctx, cfg, os.Args[1:]); err != nil { - slog.Error("app run", "err", err) + application, err := app.New(ctx, cfg) + if err != nil { + slog.Error("up app", "err", err) + os.Exit(1) + } + + if err := application.Start(ctx); err != nil { + slog.Error("start app", "err", err) os.Exit(1) } } diff --git a/internal/app/app.go b/internal/app/app.go index f725b3e..ec37c94 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -7,17 +7,15 @@ import ( "time" "github.com/GaIsBAX/Webhix/internal/config" - "github.com/GaIsBAX/Webhix/internal/hub" ) const shutdownTimeout = 10 * time.Second type App struct { - server *http.Server - config *config.Config - deps *dependencies - events *hub.Hub - services *services + server *http.Server + + config *config.Config + deps *dependencies } func New(ctx context.Context, cfg *config.Config) (*App, error) { @@ -26,25 +24,15 @@ func New(ctx context.Context, cfg *config.Config) (*App, error) { return nil, err } - services := newServices(deps.repositories) - events := hub.New() - - mux, err := newMux(cfg, services, events) - if err != nil { - return nil, err - } - - handler, err := newHTTPHandler(cfg, mux) - if err != nil { - return nil, err + server := &http.Server{ + Addr: cfg.Addr, + Handler: deps.mux, } return &App{ - server: newHTTPServer(cfg, handler), - config: cfg, - deps: deps, - events: events, - services: services, + server: server, + config: cfg, + deps: deps, }, nil } @@ -68,7 +56,7 @@ func (a *App) Start(ctx context.Context) error { func (a *App) Shutdown(ctx context.Context) error { slog.Info("shutting down") - a.events.Close() + a.deps.infra.hub.Close() shutdownCtx, cancel := context.WithTimeout(ctx, shutdownTimeout) defer cancel() diff --git a/internal/app/deps.go b/internal/app/deps.go index b9bf0d0..d4e47fa 100644 --- a/internal/app/deps.go +++ b/internal/app/deps.go @@ -4,56 +4,131 @@ import ( "context" "errors" "fmt" + "net/http" "github.com/GaIsBAX/Webhix/internal/config" + "github.com/GaIsBAX/Webhix/internal/core" + "github.com/GaIsBAX/Webhix/internal/hub" "github.com/GaIsBAX/Webhix/internal/repos" + "github.com/GaIsBAX/Webhix/internal/server" "github.com/GaIsBAX/Webhix/internal/store" ) type dependencies struct { - db *store.Database - repositories *repositories + mux *http.ServeMux + cfg *config.Config + + infra *infrastructure + repos *repositories + services *services } func newDependencies(ctx context.Context, cfg *config.Config) (*dependencies, error) { - db, err := store.New(ctx, cfg.DBPath) + var deps dependencies + + mux := http.NewServeMux() + + infra, err := newInfrastructure(ctx, cfg) if err != nil { - return nil, fmt.Errorf("open database: %w", err) + return nil, err } - if err := db.Migrate(); err != nil { - if closeErr := db.Close(); closeErr != nil { - return nil, errors.Join( - fmt.Errorf("migrate database: %w", err), - fmt.Errorf("close database after migration failure: %w", closeErr), - ) - } + repos := newRepositories(infra.db) + services := newServices(repos) - return nil, fmt.Errorf("migrate database: %w", err) - } + deps.mux = mux + deps.cfg = cfg - return &dependencies{ - db: db, - repositories: newRepositories(db), - }, nil + deps.infra = infra + deps.repos = repos + deps.services = services + + return &deps, nil +} + +type services struct { + hook *core.Hook + serve *core.Serve + version *core.Version +} + +func newServices(repos *repositories) *services { + hook := core.NewHook(repos.hook) + serve := core.NewServe(repos.serve) + version := core.NewVersion() + + return &services{ + hook: hook, + serve: serve, + version: version, + } } type repositories struct { - hook *repos.HookRepository + hook *repos.Hook serve *repos.Serve } func newRepositories(db *store.Database) *repositories { return &repositories{ - hook: repos.NewHookRepository(db.DB), + hook: repos.NewHook(db.DB), serve: repos.NewServe(db.DB), } } func (d *dependencies) close() error { - if d.db != nil { - return d.db.Close() + if d.infra.db != nil { + return d.infra.db.Close() } return nil } + +type infrastructure struct { + db *store.Database + hub *hub.Hub +} + +func newInfrastructure(ctx context.Context, cfg *config.Config) (*infrastructure, error) { + db, err := store.New(ctx, cfg.DBPath) + if err != nil { + return nil, err + } + + if err := db.Migrate(); err != nil { + if closeErr := db.Close(); closeErr != nil { + return nil, errors.Join( + fmt.Errorf("%w: %w", ErrMigrateDatabase, err), + fmt.Errorf("%w after migration failure: %w", ErrCloseDatabase, closeErr), + ) + } + + return nil, fmt.Errorf("%w: %w", ErrMigrateDatabase, err) + } + + hub := hub.New() + + return &infrastructure{ + db: db, + hub: hub, + }, nil +} + +type handlers struct { + hook server.Hook +} + +func newHandlers(deps *dependencies) *handlers { + return &handlers{ + hook: *server.NewHook(&server.HookDeps{ + Mux: deps.mux, + Service: deps.services.hook, + Hub: deps.infra.hub, + Opts: server.HookOptions{ + BaseURL: deps.cfg.BaseURL, + MaxBodySize: deps.cfg.MaxBodySize, + ReadOnly: deps.cfg.ReadOnly, + }, + }), + } +} diff --git a/internal/app/errors.go b/internal/app/errors.go new file mode 100644 index 0000000..9c6adc9 --- /dev/null +++ b/internal/app/errors.go @@ -0,0 +1,12 @@ +package app + +import "errors" + +var ( + ErrOpenDatabase = errors.New("open database") + ErrMigrateDatabase = errors.New("migrate database") + ErrCloseDatabase = errors.New("close database") + ErrAuthSetup = errors.New("auth setup") + ErrAuthRequired = errors.New("auth is required") + ErrInvalidTrustedProxies = errors.New("invalid trusted proxies") +) diff --git a/internal/app/http.go b/internal/app/http.go deleted file mode 100644 index cd7745c..0000000 --- a/internal/app/http.go +++ /dev/null @@ -1,109 +0,0 @@ -package app - -import ( - "fmt" - "io/fs" - "net/http" - "time" - - "github.com/GaIsBAX/Webhix/internal/config" - "github.com/GaIsBAX/Webhix/internal/hub" - "github.com/GaIsBAX/Webhix/internal/server" - "github.com/GaIsBAX/Webhix/internal/server/middleware" - "github.com/GaIsBAX/Webhix/internal/web" -) - -const readHeaderTimeout = 5 * time.Second - -func newMux(cfg *config.Config, services *services, events *hub.Hub) (*http.ServeMux, error) { - mux := http.NewServeMux() - - registerWebhookRoutes(mux, cfg, services.hook, events) - - if err := registerStaticRoutes(mux); err != nil { - return nil, err - } - - return mux, nil -} - -func registerWebhookRoutes( - mux *http.ServeMux, - cfg *config.Config, - hookService server.HookService, - events *hub.Hub, -) { - handler := server.NewHookHandler( - mux, - hookService, - events, - server.HookHandlerOptions{ - BaseURL: cfg.BaseURL, - MaxBodySize: cfg.MaxBodySize, - ReadOnly: cfg.ReadOnly, - }, - ) - - handler.RegisterRoutes() -} - -func registerStaticRoutes(mux *http.ServeMux) error { - staticSub, err := fs.Sub(web.Static, "static") - if err != nil { - return err - } - - staticHandler := http.FileServer(http.FS(staticSub)) - - mux.Handle("/ui/", http.StripPrefix("/ui/", staticHandler)) - mux.Handle("/", staticHandler) - - return nil -} - -func newHTTPHandler(cfg *config.Config, mux *http.ServeMux) (http.Handler, error) { - handler := http.Handler(mux) - - auth, err := newAuthMiddleware(cfg) - if err != nil { - return nil, err - } - - handler = auth.Protect(handler) - - if len(cfg.TrustedProxies) > 0 { - trustedProxies := middleware.NewTrustedProxies(cfg.TrustedProxies) - if trustedProxies == nil { - return nil, fmt.Errorf("invalid trusted proxies") - } - - handler = trustedProxies.BehindProxy(handler) - } - - return handler, nil -} - -func newAuthMiddleware(cfg *config.Config) (*middleware.Auth, error) { - password, secretKey, err := authCredentials(cfg) - if err != nil { - return nil, fmt.Errorf("auth setup: %w", err) - } - - return middleware.NewAuth(password, secretKey), nil -} - -func newHTTPServer(cfg *config.Config, handler http.Handler) *http.Server { - return &http.Server{ - Addr: cfg.Addr, - Handler: handler, - ReadHeaderTimeout: readHeaderTimeout, - } -} - -func authCredentials(cfg *config.Config) (password, secretKey string, err error) { - if cfg.Password == "" && cfg.SecretKey == "" { - return "", "", fmt.Errorf("auth is required: set WEBHIX_PASSWORD or WEBHIX_SECRET_KEY") - } - - return cfg.Password, cfg.SecretKey, nil -} diff --git a/internal/app/services.go b/internal/app/services.go deleted file mode 100644 index 055b595..0000000 --- a/internal/app/services.go +++ /dev/null @@ -1,41 +0,0 @@ -package app - -import ( - "context" - - "github.com/GaIsBAX/Webhix/internal/cli" - "github.com/GaIsBAX/Webhix/internal/cli/serve" - "github.com/GaIsBAX/Webhix/internal/config" - "github.com/GaIsBAX/Webhix/internal/core" - "github.com/GaIsBAX/Webhix/internal/domain" -) - -type services struct { - hook *core.HookService - serve *core.Serve -} - -func newServices(repositories *repositories) *services { - return &services{ - hook: core.NewHookService(repositories.hook), - serve: core.NewServe(repositories.serve), - } -} - -func NewVersionService() *core.Version { - return core.NewVersion() -} - -func Start(ctx context.Context, cfg *config.Config, args []string) error { - versionService := NewVersionService() - serveFactory := serve.ServiceFactoryFunc(func(ctx context.Context, cfg *config.Config) (serve.Service, domain.ServeStartFunc, error) { - application, err := New(ctx, cfg) - if err != nil { - return nil, nil, err - } - - return application.services.serve, application.Start, nil - }) - - return cli.Run(ctx, cfg, args, versionService, serveFactory) -} diff --git a/internal/core/hook_core.go b/internal/core/hook_core.go index e539ab9..5563ce1 100644 --- a/internal/core/hook_core.go +++ b/internal/core/hook_core.go @@ -16,17 +16,17 @@ type HookRepository interface { UpsertHookResponse(ctx context.Context, hookID int64, params domain.UpsertHookResponseParams) (domain.HookResponse, error) } -type HookService struct { +type Hook struct { repo HookRepository } -func NewHookService(repo HookRepository) *HookService { - return &HookService{ +func NewHook(repo HookRepository) *Hook { + return &Hook{ repo: repo, } } -func (s *HookService) CreateHook(ctx context.Context, token string) (domain.Hook, error) { +func (s *Hook) CreateHook(ctx context.Context, token string) (domain.Hook, error) { if token == "" { token = pkg.GeneratePrefixedString("ho") } @@ -34,7 +34,7 @@ func (s *HookService) CreateHook(ctx context.Context, token string) (domain.Hook return s.repo.CreateHook(ctx, token) } -func (s *HookService) ReceiveWebhook(ctx context.Context, token string, params domain.CreateWebhookRequestParams) (domain.WebhookRequest, domain.HookResponse, error) { +func (s *Hook) ReceiveWebhook(ctx context.Context, token string, params domain.CreateWebhookRequestParams) (domain.WebhookRequest, domain.HookResponse, error) { hook, err := s.repo.GetHookByToken(ctx, token) if err != nil { return domain.WebhookRequest{}, domain.HookResponse{}, err @@ -55,7 +55,7 @@ func (s *HookService) ReceiveWebhook(ctx context.Context, token string, params d return req, resp, nil } -func (s *HookService) ListWebhookRequests(ctx context.Context, token string) ([]domain.WebhookRequest, error) { +func (s *Hook) ListWebhookRequests(ctx context.Context, token string) ([]domain.WebhookRequest, error) { hook, err := s.repo.GetHookByToken(ctx, token) if err != nil { return nil, err @@ -64,7 +64,7 @@ func (s *HookService) ListWebhookRequests(ctx context.Context, token string) ([] return s.repo.ListWebhookRequests(ctx, hook.ID) } -func (s *HookService) GetHookResponse(ctx context.Context, token string) (domain.HookResponse, error) { +func (s *Hook) GetHookResponse(ctx context.Context, token string) (domain.HookResponse, error) { hook, err := s.repo.GetHookByToken(ctx, token) if err != nil { return domain.HookResponse{}, err @@ -73,7 +73,7 @@ func (s *HookService) GetHookResponse(ctx context.Context, token string) (domain return s.repo.GetHookResponse(ctx, hook.ID) } -func (s *HookService) SetHookResponse(ctx context.Context, token string, params domain.UpsertHookResponseParams) (domain.HookResponse, error) { +func (s *Hook) SetHookResponse(ctx context.Context, token string, params domain.UpsertHookResponseParams) (domain.HookResponse, error) { hook, err := s.repo.GetHookByToken(ctx, token) if err != nil { return domain.HookResponse{}, err diff --git a/internal/repos/hook.go b/internal/repos/hook.go index 6e9b1de..389991f 100644 --- a/internal/repos/hook.go +++ b/internal/repos/hook.go @@ -10,17 +10,17 @@ import ( "github.com/GaIsBAX/Webhix/internal/store/sqlc" ) -type HookRepository struct { +type Hook struct { q *sqlc.Queries } -func NewHookRepository(db sqlc.DBTX) *HookRepository { - return &HookRepository{ +func NewHook(db sqlc.DBTX) *Hook { + return &Hook{ q: sqlc.New(db), } } -func (r *HookRepository) CreateHook(ctx context.Context, token string) (domain.Hook, error) { +func (r *Hook) CreateHook(ctx context.Context, token string) (domain.Hook, error) { hook, err := r.q.CreateHook(ctx, sqlc.CreateHookParams{ Token: token, Name: sql.NullString{}, @@ -32,7 +32,7 @@ func (r *HookRepository) CreateHook(ctx context.Context, token string) (domain.H return toDomainHook(hook), nil } -func (r *HookRepository) GetHookByToken(ctx context.Context, token string) (domain.Hook, error) { +func (r *Hook) GetHookByToken(ctx context.Context, token string) (domain.Hook, error) { hook, err := r.q.GetHookByToken(ctx, token) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -44,7 +44,7 @@ func (r *HookRepository) GetHookByToken(ctx context.Context, token string) (doma return toDomainHook(hook), nil } -func (r *HookRepository) CreateWebhookRequest(ctx context.Context, params domain.CreateWebhookRequestParams) (domain.WebhookRequest, error) { +func (r *Hook) CreateWebhookRequest(ctx context.Context, params domain.CreateWebhookRequestParams) (domain.WebhookRequest, error) { req, err := r.q.CreateWebhookRequest(ctx, sqlc.CreateWebhookRequestParams{ HookID: params.HookID, @@ -64,7 +64,7 @@ func (r *HookRepository) CreateWebhookRequest(ctx context.Context, params domain return toDomainWebhookRequest(req), nil } -func (r *HookRepository) ListWebhookRequests(ctx context.Context, hookID int64) ([]domain.WebhookRequest, error) { +func (r *Hook) ListWebhookRequests(ctx context.Context, hookID int64) ([]domain.WebhookRequest, error) { rows, err := r.q.ListWebhookRequestsByHookID(ctx, hookID) if err != nil { return nil, err @@ -78,7 +78,7 @@ func (r *HookRepository) ListWebhookRequests(ctx context.Context, hookID int64) return result, nil } -func (r *HookRepository) GetHookResponse(ctx context.Context, hookID int64) (domain.HookResponse, error) { +func (r *Hook) GetHookResponse(ctx context.Context, hookID int64) (domain.HookResponse, error) { row, err := r.q.GetHookResponseByHookID(ctx, hookID) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -90,7 +90,7 @@ func (r *HookRepository) GetHookResponse(ctx context.Context, hookID int64) (dom return toDomainHookResponse(row), nil } -func (r *HookRepository) UpsertHookResponse(ctx context.Context, hookID int64, params domain.UpsertHookResponseParams) (domain.HookResponse, error) { +func (r *Hook) UpsertHookResponse(ctx context.Context, hookID int64, params domain.UpsertHookResponseParams) (domain.HookResponse, error) { headersJSON, err := json.Marshal(params.Headers) if err != nil { return domain.HookResponse{}, err diff --git a/internal/server/handler.go b/internal/server/handler.go index b23cd18..7b5766b 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -23,43 +23,41 @@ type HookService interface { SetHookResponse(ctx context.Context, token string, params domain.UpsertHookResponseParams) (domain.HookResponse, error) } -type HookHandlerOptions struct { +type HookOptions struct { BaseURL string MaxBodySize int64 ReadOnly bool } -type HookHandler struct { - mux *http.ServeMux - service HookService - hub *hub.Hub +type HookDeps struct { + Mux *http.ServeMux + Service HookService + Hub *hub.Hub + Opts HookOptions +} - opts HookHandlerOptions +type Hook struct { + deps *HookDeps } -func NewHookHandler(mux *http.ServeMux, srv HookService, hub *hub.Hub, opts HookHandlerOptions) *HookHandler { - if opts.MaxBodySize <= 0 { - opts.MaxBodySize = DefaultMaxBodySize +func NewHook(deps *HookDeps) *Hook { + if deps.Opts.MaxBodySize <= 0 { + deps.Opts.MaxBodySize = DefaultMaxBodySize } - return &HookHandler{ - mux: mux, - service: srv, - hub: hub, - opts: opts, - } + return &Hook{deps: deps} } -func (h *HookHandler) RegisterRoutes() { - h.mux.HandleFunc("POST /api/endpoints", h.CreateEndpoint) - h.mux.HandleFunc("GET /api/endpoints/{token}/requests", h.ListRequests) - h.mux.HandleFunc("GET /api/endpoints/{token}/events", h.StreamEvents) - h.mux.HandleFunc("GET /api/endpoints/{token}/response", h.GetResponse) - h.mux.HandleFunc("PUT /api/endpoints/{token}/response", h.SetResponse) - h.mux.HandleFunc("/r/{token}", h.ReceiveWebhook) +func (h *Hook) RegisterRoutes() { + h.deps.Mux.HandleFunc("POST /api/endpoints", h.CreateEndpoint) + h.deps.Mux.HandleFunc("GET /api/endpoints/{token}/requests", h.ListRequests) + h.deps.Mux.HandleFunc("GET /api/endpoints/{token}/events", h.StreamEvents) + h.deps.Mux.HandleFunc("GET /api/endpoints/{token}/response", h.GetResponse) + h.deps.Mux.HandleFunc("PUT /api/endpoints/{token}/response", h.SetResponse) + h.deps.Mux.HandleFunc("/r/{token}", h.ReceiveWebhook) } -func (h *HookHandler) CreateEndpoint(w http.ResponseWriter, r *http.Request) { +func (h *Hook) CreateEndpoint(w http.ResponseWriter, r *http.Request) { if h.readOnly(w) { return } @@ -71,7 +69,7 @@ func (h *HookHandler) CreateEndpoint(w http.ResponseWriter, r *http.Request) { return } - hook, err := h.service.CreateHook(r.Context(), contract.Name) + hook, err := h.deps.Service.CreateHook(r.Context(), contract.Name) if err != nil { slog.Error("create endpoint", "err", err) SendError(w, http.StatusInternalServerError, ErrInternal) @@ -82,7 +80,7 @@ func (h *HookHandler) CreateEndpoint(w http.ResponseWriter, r *http.Request) { ID: hook.ID, Token: hook.Token, Name: hook.Name, - URL: h.opts.BaseURL + "/r/" + hook.Token, + URL: h.deps.Opts.BaseURL + "/r/" + hook.Token, }) if err != nil { slog.Error("marshal endpoint", "err", err) @@ -93,7 +91,7 @@ func (h *HookHandler) CreateEndpoint(w http.ResponseWriter, r *http.Request) { SendSuccess(w, http.StatusCreated, data) } -func (h *HookHandler) ReceiveWebhook(w http.ResponseWriter, r *http.Request) { +func (h *Hook) ReceiveWebhook(w http.ResponseWriter, r *http.Request) { if h.readOnly(w) { return } @@ -107,14 +105,14 @@ func (h *HookHandler) ReceiveWebhook(w http.ResponseWriter, r *http.Request) { return } - r.Body = http.MaxBytesReader(w, r.Body, h.opts.MaxBodySize) + r.Body = http.MaxBytesReader(w, r.Body, h.deps.Opts.MaxBodySize) body, err := io.ReadAll(r.Body) if err != nil { var maxBytesErr *http.MaxBytesError if errors.As(err, &maxBytesErr) { SendError(w, http.StatusRequestEntityTooLarge, WithDetails(ErrPayloadTooLarge, ErrorDetailContract{ Field: "body", - Message: fmt.Sprintf("body exceeds %d bytes limit", h.opts.MaxBodySize), + Message: fmt.Sprintf("body exceeds %d bytes limit", h.deps.Opts.MaxBodySize), })) return } @@ -126,7 +124,7 @@ func (h *HookHandler) ReceiveWebhook(w http.ResponseWriter, r *http.Request) { return } - req, customResp, err := h.service.ReceiveWebhook(r.Context(), token, domain.CreateWebhookRequestParams{ + req, customResp, err := h.deps.Service.ReceiveWebhook(r.Context(), token, domain.CreateWebhookRequestParams{ Method: r.Method, Path: r.URL.Path, Query: r.URL.RawQuery, @@ -153,7 +151,7 @@ func (h *HookHandler) ReceiveWebhook(w http.ResponseWriter, r *http.Request) { return } - h.hub.Publish(token, data) + h.deps.Hub.Publish(token, data) if customResp.StatusCode > 0 { for k, v := range customResp.Headers { @@ -171,10 +169,10 @@ func (h *HookHandler) ReceiveWebhook(w http.ResponseWriter, r *http.Request) { SendSuccess(w, http.StatusOK, data) } -func (h *HookHandler) ListRequests(w http.ResponseWriter, r *http.Request) { +func (h *Hook) ListRequests(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") - reqs, err := h.service.ListWebhookRequests(r.Context(), token) + reqs, err := h.deps.Service.ListWebhookRequests(r.Context(), token) if err != nil { if errors.Is(err, domain.ErrNotFound) { SendError(w, http.StatusNotFound, ErrNotFound) @@ -200,7 +198,7 @@ func (h *HookHandler) ListRequests(w http.ResponseWriter, r *http.Request) { SendSuccess(w, http.StatusOK, data) } -func (h *HookHandler) StreamEvents(w http.ResponseWriter, r *http.Request) { +func (h *Hook) StreamEvents(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") w.Header().Set("Content-Type", "text/event-stream") @@ -213,7 +211,7 @@ func (h *HookHandler) StreamEvents(w http.ResponseWriter, r *http.Request) { f.Flush() } - ch, unsubscribe := h.hub.Subscribe(token) + ch, unsubscribe := h.deps.Hub.Subscribe(token) defer unsubscribe() flusher, canFlush := w.(http.Flusher) @@ -222,8 +220,10 @@ func (h *HookHandler) StreamEvents(w http.ResponseWriter, r *http.Request) { select { case <-r.Context().Done(): return - case <-h.hub.Done(): + + case <-h.deps.Hub.Done(): return + case data := <-ch: if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil { return @@ -235,10 +235,10 @@ func (h *HookHandler) StreamEvents(w http.ResponseWriter, r *http.Request) { } } -func (h *HookHandler) GetResponse(w http.ResponseWriter, r *http.Request) { +func (h *Hook) GetResponse(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") - resp, err := h.service.GetHookResponse(r.Context(), token) + resp, err := h.deps.Service.GetHookResponse(r.Context(), token) if err != nil { if errors.Is(err, domain.ErrNotFound) { SendError(w, http.StatusNotFound, ErrNotFound) @@ -258,7 +258,7 @@ func (h *HookHandler) GetResponse(w http.ResponseWriter, r *http.Request) { SendSuccess(w, http.StatusOK, data) } -func (h *HookHandler) SetResponse(w http.ResponseWriter, r *http.Request) { +func (h *Hook) SetResponse(w http.ResponseWriter, r *http.Request) { if h.readOnly(w) { return } @@ -284,7 +284,7 @@ func (h *HookHandler) SetResponse(w http.ResponseWriter, r *http.Request) { return } - resp, err := h.service.SetHookResponse(r.Context(), token, params) + resp, err := h.deps.Service.SetHookResponse(r.Context(), token, params) if err != nil { if errors.Is(err, domain.ErrNotFound) { SendError(w, http.StatusNotFound, ErrNotFound) @@ -304,8 +304,8 @@ func (h *HookHandler) SetResponse(w http.ResponseWriter, r *http.Request) { SendSuccess(w, http.StatusOK, data) } -func (h *HookHandler) readOnly(w http.ResponseWriter) bool { - if !h.opts.ReadOnly { +func (h *Hook) readOnly(w http.ResponseWriter) bool { + if !h.deps.Opts.ReadOnly { return false } From b85ffd762846ac61bf85fd141797246cbb1be234 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 18 May 2026 22:58:29 +0300 Subject: [PATCH 2/3] refactor(app): enhance application structure with new HTTP handler and middleware integration --- internal/app/app.go | 32 ++++++++++++++++++- internal/app/deps.go | 16 ++++++++-- internal/cli/serve/command.go | 10 +++--- internal/cli/serve/runner.go | 6 ++-- internal/cli/version/command.go | 20 ++++++++++-- internal/core/hook_core.go | 40 ++++++++++++++++++++---- internal/core/serve_core.go | 13 +++++--- internal/domain/hook.go | 54 ++++++++++++++------------------- internal/domain/version.go | 8 ++--- internal/repos/hook.go | 2 +- internal/server/handler.go | 13 +++++--- internal/server/helpers.go | 2 +- 12 files changed, 151 insertions(+), 65 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index ec37c94..d97668b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -2,11 +2,14 @@ package app import ( "context" + "errors" "log/slog" "net/http" "time" "github.com/GaIsBAX/Webhix/internal/config" + "github.com/GaIsBAX/Webhix/internal/server" + "github.com/GaIsBAX/Webhix/internal/server/middleware" ) const shutdownTimeout = 10 * time.Second @@ -24,9 +27,17 @@ func New(ctx context.Context, cfg *config.Config) (*App, error) { return nil, err } + handler, err := newHTTPHandler(deps.mux, cfg) + if err != nil { + if closeErr := deps.close(); closeErr != nil { + return nil, errors.Join(err, closeErr) + } + return nil, err + } + server := &http.Server{ Addr: cfg.Addr, - Handler: deps.mux, + Handler: handler, } return &App{ @@ -36,6 +47,25 @@ func New(ctx context.Context, cfg *config.Config) (*App, error) { }, nil } +func newHTTPHandler(mux *http.ServeMux, cfg *config.Config) (http.Handler, error) { + var middlewares []func(http.Handler) http.Handler + + if len(cfg.TrustedProxies) > 0 { + trustedProxies := middleware.NewTrustedProxies(cfg.TrustedProxies) + if trustedProxies == nil { + return nil, ErrInvalidTrustedProxies + } + middlewares = append(middlewares, trustedProxies.BehindProxy) + } + + if cfg.Password != "" || cfg.SecretKey != "" { + auth := middleware.NewAuth(cfg.Password, cfg.SecretKey) + middlewares = append(middlewares, auth.Protect) + } + + return server.Chain(mux, middlewares...), nil +} + func (a *App) Start(ctx context.Context) error { serverErr := make(chan error, 1) go func() { diff --git a/internal/app/deps.go b/internal/app/deps.go index d4e47fa..67e2b82 100644 --- a/internal/app/deps.go +++ b/internal/app/deps.go @@ -12,6 +12,7 @@ import ( "github.com/GaIsBAX/Webhix/internal/repos" "github.com/GaIsBAX/Webhix/internal/server" "github.com/GaIsBAX/Webhix/internal/store" + "github.com/GaIsBAX/Webhix/pkg" ) type dependencies struct { @@ -21,6 +22,7 @@ type dependencies struct { infra *infrastructure repos *repositories services *services + handlers *handlers } func newDependencies(ctx context.Context, cfg *config.Config) (*dependencies, error) { @@ -42,6 +44,8 @@ func newDependencies(ctx context.Context, cfg *config.Config) (*dependencies, er deps.infra = infra deps.repos = repos deps.services = services + deps.handlers = newHandlers(&deps) + deps.handlers.registerRoutes() return &deps, nil } @@ -53,7 +57,9 @@ type services struct { } func newServices(repos *repositories) *services { - hook := core.NewHook(repos.hook) + hook := core.NewHook(repos.hook, func() string { + return pkg.GeneratePrefixedString("ho") + }) serve := core.NewServe(repos.serve) version := core.NewVersion() @@ -115,12 +121,12 @@ func newInfrastructure(ctx context.Context, cfg *config.Config) (*infrastructure } type handlers struct { - hook server.Hook + hook *server.Hook } func newHandlers(deps *dependencies) *handlers { return &handlers{ - hook: *server.NewHook(&server.HookDeps{ + hook: server.NewHook(&server.HookDeps{ Mux: deps.mux, Service: deps.services.hook, Hub: deps.infra.hub, @@ -132,3 +138,7 @@ func newHandlers(deps *dependencies) *handlers { }), } } + +func (h *handlers) registerRoutes() { + h.hook.RegisterRoutes() +} diff --git a/internal/cli/serve/command.go b/internal/cli/serve/command.go index cfd16c0..d3b04ea 100644 --- a/internal/cli/serve/command.go +++ b/internal/cli/serve/command.go @@ -5,7 +5,7 @@ import ( "log/slog" "github.com/GaIsBAX/Webhix/internal/config" - "github.com/GaIsBAX/Webhix/internal/domain" + "github.com/GaIsBAX/Webhix/internal/core" "github.com/spf13/cobra" ) @@ -15,16 +15,16 @@ const ( ) type Service interface { - Run(ctx context.Context, opts domain.ServeRunOptions, start domain.ServeStartFunc, onRetentionError func(error)) error + Run(ctx context.Context, opts core.ServeRunOptions, start core.ServeStartFunc, onRetentionError func(error)) error } type ServiceFactory interface { - New(ctx context.Context, cfg *config.Config) (Service, domain.ServeStartFunc, error) + New(ctx context.Context, cfg *config.Config) (Service, core.ServeStartFunc, error) } -type ServiceFactoryFunc func(ctx context.Context, cfg *config.Config) (Service, domain.ServeStartFunc, error) +type ServiceFactoryFunc func(ctx context.Context, cfg *config.Config) (Service, core.ServeStartFunc, error) -func (f ServiceFactoryFunc) New(ctx context.Context, cfg *config.Config) (Service, domain.ServeStartFunc, error) { +func (f ServiceFactoryFunc) New(ctx context.Context, cfg *config.Config) (Service, core.ServeStartFunc, error) { return f(ctx, cfg) } diff --git a/internal/cli/serve/runner.go b/internal/cli/serve/runner.go index 32f77f2..da04e0d 100644 --- a/internal/cli/serve/runner.go +++ b/internal/cli/serve/runner.go @@ -5,11 +5,11 @@ import ( "log/slog" "github.com/GaIsBAX/Webhix/internal/config" - "github.com/GaIsBAX/Webhix/internal/domain" + "github.com/GaIsBAX/Webhix/internal/core" ) -func run(ctx context.Context, service Service, start domain.ServeStartFunc, cfg *config.Config, opts Options) error { - return service.Run(ctx, domain.ServeRunOptions{ +func run(ctx context.Context, service Service, start core.ServeStartFunc, cfg *config.Config, opts Options) error { + return service.Run(ctx, core.ServeRunOptions{ Retention: opts.Retention, ReadOnly: cfg.ReadOnly, }, start, func(err error) { diff --git a/internal/cli/version/command.go b/internal/cli/version/command.go index b711193..76fcc30 100644 --- a/internal/cli/version/command.go +++ b/internal/cli/version/command.go @@ -15,6 +15,13 @@ type Service interface { Info() domain.VersionInfo } +type versionInfoContract struct { + Version string `json:"version" yaml:"version"` + Commit string `json:"commit" yaml:"commit"` + Built string `json:"built" yaml:"built"` + Go string `json:"go" yaml:"go"` +} + func NewCommand(ctx context.Context, service Service) *cobra.Command { opts := NewOptions() @@ -39,11 +46,11 @@ func print(w io.Writer, info domain.VersionInfo, output string) error { switch output { case outputJSON: encoder := json.NewEncoder(w) - return encoder.Encode(info) + return encoder.Encode(toContract(info)) case outputYAML: encoder := yaml.NewEncoder(w) - if err := encoder.Encode(info); err != nil { + if err := encoder.Encode(toContract(info)); err != nil { return err } return encoder.Close() @@ -60,3 +67,12 @@ func print(w io.Writer, info domain.VersionInfo, output string) error { return err } } + +func toContract(info domain.VersionInfo) versionInfoContract { + return versionInfoContract{ + Version: info.Version, + Commit: info.Commit, + Built: info.Built, + Go: info.Go, + } +} diff --git a/internal/core/hook_core.go b/internal/core/hook_core.go index 5563ce1..5457319 100644 --- a/internal/core/hook_core.go +++ b/internal/core/hook_core.go @@ -2,11 +2,15 @@ package core import ( "context" + "errors" "github.com/GaIsBAX/Webhix/internal/domain" - "github.com/GaIsBAX/Webhix/pkg" ) +const defaultHookResponseStatusCode int64 = 200 + +type TokenGenerator func() string + type HookRepository interface { CreateHook(ctx context.Context, token string) (domain.Hook, error) GetHookByToken(ctx context.Context, token string) (domain.Hook, error) @@ -17,18 +21,24 @@ type HookRepository interface { } type Hook struct { - repo HookRepository + repo HookRepository + generateToken TokenGenerator } -func NewHook(repo HookRepository) *Hook { +func NewHook(repo HookRepository, generateToken TokenGenerator) *Hook { + if generateToken == nil { + generateToken = func() string { return "" } + } + return &Hook{ - repo: repo, + repo: repo, + generateToken: generateToken, } } func (s *Hook) CreateHook(ctx context.Context, token string) (domain.Hook, error) { if token == "" { - token = pkg.GeneratePrefixedString("ho") + token = s.generateToken() } return s.repo.CreateHook(ctx, token) @@ -49,6 +59,9 @@ func (s *Hook) ReceiveWebhook(ctx context.Context, token string, params domain.C resp, err := s.repo.GetHookResponse(ctx, hook.ID) if err != nil { + if errors.Is(err, domain.ErrNotFound) { + return req, defaultHookResponse(), nil + } return domain.WebhookRequest{}, domain.HookResponse{}, err } @@ -70,7 +83,15 @@ func (s *Hook) GetHookResponse(ctx context.Context, token string) (domain.HookRe return domain.HookResponse{}, err } - return s.repo.GetHookResponse(ctx, hook.ID) + resp, err := s.repo.GetHookResponse(ctx, hook.ID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + return defaultHookResponse(), nil + } + return domain.HookResponse{}, err + } + + return resp, nil } func (s *Hook) SetHookResponse(ctx context.Context, token string, params domain.UpsertHookResponseParams) (domain.HookResponse, error) { @@ -81,3 +102,10 @@ func (s *Hook) SetHookResponse(ctx context.Context, token string, params domain. return s.repo.UpsertHookResponse(ctx, hook.ID, params) } + +func defaultHookResponse() domain.HookResponse { + return domain.HookResponse{ + StatusCode: defaultHookResponseStatusCode, + Headers: map[string]string{}, + } +} diff --git a/internal/core/serve_core.go b/internal/core/serve_core.go index 7f2c7d1..a4f397b 100644 --- a/internal/core/serve_core.go +++ b/internal/core/serve_core.go @@ -4,10 +4,15 @@ import ( "context" "errors" "time" - - "github.com/GaIsBAX/Webhix/internal/domain" ) +type ServeRunOptions struct { + Retention time.Duration + ReadOnly bool +} + +type ServeStartFunc func(context.Context) error + type ServeRepository interface { DeleteWebhookRequestsOlderThan(ctx context.Context, retention time.Duration) (int64, error) GetCountRequests(ctx context.Context) (int64, error) @@ -49,12 +54,12 @@ func (s *Serve) RetentionCleaner(ctx context.Context, retention time.Duration) ( } } -func (s *Serve) Run(ctx context.Context, opts domain.ServeRunOptions, start domain.ServeStartFunc, onRetentionError func(error)) error { +func (s *Serve) Run(ctx context.Context, opts ServeRunOptions, start ServeStartFunc, onRetentionError func(error)) error { s.StartRetentionCleaner(ctx, opts, onRetentionError) return start(ctx) } -func (s *Serve) StartRetentionCleaner(ctx context.Context, opts domain.ServeRunOptions, onError func(error)) { +func (s *Serve) StartRetentionCleaner(ctx context.Context, opts ServeRunOptions, onError func(error)) { if opts.Retention <= 0 || opts.ReadOnly { return } diff --git a/internal/domain/hook.go b/internal/domain/hook.go index 599b797..98911e2 100644 --- a/internal/domain/hook.go +++ b/internal/domain/hook.go @@ -1,41 +1,40 @@ package domain import ( - "context" "fmt" "time" ) type Hook struct { - ID int64 `json:"id"` - Token string `json:"token"` - Name string `json:"name,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID int64 + Token string + Name string + CreatedAt time.Time + UpdatedAt time.Time } type WebhookRequest struct { - ID int64 `json:"id"` - HookID int64 `json:"hookId"` - Method string `json:"method"` - Path string `json:"path"` - Query string `json:"query,omitempty"` - Headers string `json:"headers"` - Body []byte `json:"body,omitempty"` - RemoteAddr string `json:"remoteAddr,omitempty"` - ContentType string `json:"contentType,omitempty"` - BodySize int64 `json:"bodySize"` - ReceivedAt time.Time `json:"receivedAt"` + ID int64 + HookID int64 + Method string + Path string + Query string + Headers string + Body []byte + RemoteAddr string + ContentType string + BodySize int64 + ReceivedAt time.Time } type HookResponse struct { - ID int64 `json:"id"` - HookID int64 `json:"hookId"` - StatusCode int64 `json:"statusCode"` - Headers map[string]string `json:"headers"` - Body []byte `json:"body,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID int64 + HookID int64 + StatusCode int64 + Headers map[string]string + Body []byte + CreatedAt time.Time + UpdatedAt time.Time } type CreateWebhookRequestParams struct { @@ -56,13 +55,6 @@ type UpsertHookResponseParams struct { Body []byte } -type ServeRunOptions struct { - Retention time.Duration - ReadOnly bool -} - -type ServeStartFunc func(context.Context) error - func (p UpsertHookResponseParams) Validate() error { if p.StatusCode < 100 || p.StatusCode > 599 { return fmt.Errorf("statusCode must be between 100 and 599") diff --git a/internal/domain/version.go b/internal/domain/version.go index 93944d6..d44961d 100644 --- a/internal/domain/version.go +++ b/internal/domain/version.go @@ -1,8 +1,8 @@ package domain type VersionInfo struct { - Version string `json:"version" yaml:"version"` - Commit string `json:"commit" yaml:"commit"` - Built string `json:"built" yaml:"built"` - Go string `json:"go" yaml:"go"` + Version string + Commit string + Built string + Go string } diff --git a/internal/repos/hook.go b/internal/repos/hook.go index 389991f..5cf2973 100644 --- a/internal/repos/hook.go +++ b/internal/repos/hook.go @@ -82,7 +82,7 @@ func (r *Hook) GetHookResponse(ctx context.Context, hookID int64) (domain.HookRe row, err := r.q.GetHookResponseByHookID(ctx, hookID) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return domain.HookResponse{StatusCode: 200, Headers: map[string]string{}}, nil + return domain.HookResponse{}, domain.ErrNotFound } return domain.HookResponse{}, err } diff --git a/internal/server/handler.go b/internal/server/handler.go index 7b5766b..9f91d69 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -10,7 +10,6 @@ import ( "net/http" "github.com/GaIsBAX/Webhix/internal/domain" - "github.com/GaIsBAX/Webhix/internal/hub" ) const DefaultMaxBodySize int64 = 5 << 20 // 5MB @@ -23,6 +22,12 @@ type HookService interface { SetHookResponse(ctx context.Context, token string, params domain.UpsertHookResponseParams) (domain.HookResponse, error) } +type EventBroker interface { + Done() <-chan struct{} + Subscribe(token string) (<-chan []byte, func()) + Publish(token string, data []byte) +} + type HookOptions struct { BaseURL string MaxBodySize int64 @@ -32,7 +37,7 @@ type HookOptions struct { type HookDeps struct { Mux *http.ServeMux Service HookService - Hub *hub.Hub + Hub EventBroker Opts HookOptions } @@ -62,7 +67,7 @@ func (h *Hook) CreateEndpoint(w http.ResponseWriter, r *http.Request) { return } - contract, err := DecodeContract[CreateEndpointRequestContract](r) + contract, err := DecodeRequest[CreateEndpointRequestContract](r) if err != nil { slog.Error("create endpoint", "err", err) SendError(w, http.StatusInternalServerError, ErrInternal) @@ -265,7 +270,7 @@ func (h *Hook) SetResponse(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") - contract, err := DecodeContract[SetHookResponseRequestContract](r) + contract, err := DecodeRequest[SetHookResponseRequestContract](r) if err != nil { SendError(w, http.StatusBadRequest, ErrBadRequest) return diff --git a/internal/server/helpers.go b/internal/server/helpers.go index 8e3d869..b7eb0a9 100644 --- a/internal/server/helpers.go +++ b/internal/server/helpers.go @@ -22,7 +22,7 @@ func SendError(w http.ResponseWriter, status int, msg ErrorContract) { Send(w, status, NewErrorResponseContract(msg)) } -func DecodeContract[T any](req *http.Request) (*T, error) { +func DecodeRequest[T any](req *http.Request) (*T, error) { var body T if err := json.NewDecoder(req.Body).Decode(&body); err != nil { From 2a7faae41ab4889c2af9dbb517fe1ddb006fce9c Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 18 May 2026 23:09:41 +0300 Subject: [PATCH 3/3] fix(app): configure HTTP read header timeout --- internal/app/app.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index d97668b..2672e38 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -12,7 +12,10 @@ import ( "github.com/GaIsBAX/Webhix/internal/server/middleware" ) -const shutdownTimeout = 10 * time.Second +const ( + readHeaderTimeout = 5 * time.Second + shutdownTimeout = 10 * time.Second +) type App struct { server *http.Server @@ -36,8 +39,9 @@ func New(ctx context.Context, cfg *config.Config) (*App, error) { } server := &http.Server{ - Addr: cfg.Addr, - Handler: handler, + Addr: cfg.Addr, + Handler: handler, + ReadHeaderTimeout: readHeaderTimeout, } return &App{