From ca2087eb7b22b8d7c905e73234470674a81d3b18 Mon Sep 17 00:00:00 2001 From: Egor Aristov Date: Wed, 22 Jan 2025 10:37:02 +0300 Subject: [PATCH] move http handler to separate package --- cmd/webserver/webserver.go | 163 +---------------------------- internal/delivery/http/handler.go | 166 ++++++++++++++++++++++++++++++ internal/validators/validators.go | 19 ++++ 3 files changed, 188 insertions(+), 160 deletions(-) create mode 100644 internal/delivery/http/handler.go create mode 100644 internal/validators/validators.go diff --git a/cmd/webserver/webserver.go b/cmd/webserver/webserver.go index 4cb8b28..b0ae30c 100644 --- a/cmd/webserver/webserver.go +++ b/cmd/webserver/webserver.go @@ -1,44 +1,20 @@ package main import ( - "bytes" - "compress/flate" "context" - "encoding/base64" - "encoding/json" - "fmt" "github.com/egor3f/rssalchemy/internal/adapters/natsadapter" "github.com/egor3f/rssalchemy/internal/config" - "github.com/egor3f/rssalchemy/internal/models" - "github.com/ericchiang/css" - "github.com/go-playground/validator/v10" - "github.com/gorilla/feeds" + httpApi "github.com/egor3f/rssalchemy/internal/delivery/http" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/labstack/gommon/log" "github.com/nats-io/nats.go" - "html" - "io" "net/http" "os" "os/signal" - "reflect" "time" ) -type Specs struct { - URL string `json:"URL" validate:"url"` - SelectorPost string `json:"selector_post" validate:"selector"` - SelectorTitle string `json:"selector_title" validate:"selector"` - SelectorLink string `json:"selector_link" validate:"selector"` - SelectorDescription string `json:"selector_description" validate:"selector"` - SelectorAuthor string `json:"selector_author" validate:"selector"` - SelectorCreated string `json:"selector_created" validate:"selector"` - SelectorContent string `json:"selector_content" validate:"selector"` - SelectorEnclosure string `json:"selector_enclosure" validate:"selector"` - CacheLifetime string `json:"cache_lifetime"` -} - func main() { cfg, err := config.Read() if err != nil { @@ -53,11 +29,6 @@ func main() { baseCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() - validate := validator.New(validator.WithRequiredStructEnabled()) - if err := validate.RegisterValidation("selector", validateSelector); err != nil { - log.Panicf("register validation: %v", err) - } - natsc, err := nats.Connect(cfg.NatsUrl) if err != nil { log.Panicf("nats connect failed: %v", err) @@ -77,69 +48,9 @@ func main() { e.Use(middleware.Logger()) e.Use(middleware.Recover()) e.Static("/", "frontend/wizard") - e.GET( - "/api/v1/render/:specs", func(c echo.Context) error { - specsParam := c.Param("specs") - specs, err := decodeSpecs(specsParam, validate) - if err != nil { - return echo.NewHTTPError(400, fmt.Errorf("decode specs: %w", err)) - } - task := models.Task{ - URL: specs.URL, - SelectorPost: specs.SelectorPost, - SelectorTitle: specs.SelectorTitle, - SelectorLink: specs.SelectorLink, - SelectorDescription: specs.SelectorDescription, - SelectorAuthor: specs.SelectorAuthor, - SelectorCreated: specs.SelectorCreated, - SelectorContent: specs.SelectorContent, - SelectorEnclosure: specs.SelectorEnclosure, - } - - taskTimeout := 20 * time.Second - minLifetime := taskTimeout - maxLifetime := 24 * time.Hour - cacheLifetime, err := time.ParseDuration(specs.CacheLifetime) - if err != nil { - return echo.NewHTTPError(400, "invalid cache lifetime") - } - if cacheLifetime < minLifetime { - cacheLifetime = minLifetime - } - if cacheLifetime > maxLifetime { - cacheLifetime = maxLifetime - } - - timeoutCtx, cancel := context.WithTimeout(baseCtx, taskTimeout) - defer cancel() - - encodedTask, err := json.Marshal(task) - if err != nil { - return echo.NewHTTPError(500, fmt.Errorf("task marshal error: %v", err)) - } - - taskResultBytes, err := cq.ProcessWorkCached(timeoutCtx, cacheLifetime, task.CacheKey(), encodedTask) - if err != nil { - return echo.NewHTTPError(500, fmt.Errorf("queued cache failed: %v", err)) - } - - var result models.TaskResult - if err := json.Unmarshal(taskResultBytes, &result); err != nil { - log.Errorf("cached value unmarshal failed: %v", err) - return echo.NewHTTPError(500, fmt.Errorf("cached value unmarshal failed: %v", err)) - } - - atom, err := makeFeed(task, result) - if err != nil { - log.Errorf("make feed failed: %v", err) - return echo.NewHTTPError(500) - } - - c.Response().Header().Set("Content-Type", "text/xml") - return c.String(200, atom) - }, - ) + apiHandler := httpApi.New(cq) + apiHandler.SetupRoutes(e.Group("/api/v1")) go func() { if err := e.Start(cfg.WebserverAddress); err != nil && err != http.ErrServerClosed { @@ -154,71 +65,3 @@ func main() { e.Logger.Errorf("failed to shutdown server: %v", err) } } - -func makeFeed(task models.Task, result models.TaskResult) (string, error) { - feedTS := time.Now() - if len(result.Items) > 0 { - feedTS = result.Items[0].Created - } - feed := feeds.Feed{ - Title: html.EscapeString(result.Title), - Link: &feeds.Link{Href: task.URL}, - Updated: feedTS, - } - for _, item := range result.Items { - feed.Items = append(feed.Items, &feeds.Item{ - Title: html.EscapeString(item.Title), - Link: &feeds.Link{Href: item.Link}, - Author: &feeds.Author{Name: item.AuthorName}, - Description: item.Description, - Created: item.Created, - Updated: item.Updated, - Content: item.Content, - }) - } - atomFeed := (&feeds.Atom{Feed: &feed}).AtomFeed() - atomFeed.Icon = result.Icon - for i, entry := range atomFeed.Entries { - entry.Author.Uri = result.Items[i].AuthorLink - } - atom, err := feeds.ToXML(atomFeed) - if err != nil { - return "", fmt.Errorf("feed to xml: %w", err) - } - return atom, nil -} - -func decodeSpecs(specsParam string, validate *validator.Validate) (Specs, error) { - decodedSpecsParam, err := base64.StdEncoding.WithPadding(base64.NoPadding).DecodeString(specsParam) - if err != nil { - return Specs{}, fmt.Errorf("failed to decode specs: %w", err) - } - rc := flate.NewReader(bytes.NewReader(decodedSpecsParam)) - decodedSpecsParam, err = io.ReadAll(rc) - if err != nil { - return Specs{}, fmt.Errorf("failed to unzip specs: %w", err) - } - var specs Specs - if err := json.Unmarshal(decodedSpecsParam, &specs); err != nil { - return Specs{}, fmt.Errorf("failed to unmarshal specs: %w", err) - } - if err := validate.Struct(specs); err != nil { - return Specs{}, fmt.Errorf("specs are invalid: %w", err) - } - return specs, nil -} - -func validateSelector(fl validator.FieldLevel) bool { - if fl.Field().Kind() != reflect.String { - return false - } - _, err := css.Parse(fl.Field().String()) - if err != nil { - log.Debugf("selector %s invalid: %v", fl.Field().String(), err) - } - return err == nil -} - -func cdata(s string) string { - return "" -} diff --git a/internal/delivery/http/handler.go b/internal/delivery/http/handler.go new file mode 100644 index 0000000..d16b33c --- /dev/null +++ b/internal/delivery/http/handler.go @@ -0,0 +1,166 @@ +package http + +import ( + "bytes" + "compress/flate" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "github.com/egor3f/rssalchemy/internal/adapters" + "github.com/egor3f/rssalchemy/internal/models" + "github.com/egor3f/rssalchemy/internal/validators" + "github.com/go-playground/validator/v10" + "github.com/gorilla/feeds" + "github.com/labstack/echo/v4" + "github.com/labstack/gommon/log" + "html" + "io" + "time" +) + +type Handler struct { + validate *validator.Validate + CachedQueue adapters.CachedWorkQueue +} + +func New(cq adapters.CachedWorkQueue) *Handler { + h := Handler{CachedQueue: cq} + h.validate = validator.New(validator.WithRequiredStructEnabled()) + if err := h.validate.RegisterValidation("selector", validators.ValidateSelector); err != nil { + log.Panicf("register validation: %v", err) + } + return &h +} + +func (h *Handler) SetupRoutes(g *echo.Group) { + g.GET("/render/:specs", h.handleRender) +} + +type Specs struct { + URL string `json:"URL" validate:"url"` + SelectorPost string `json:"selector_post" validate:"selector"` + SelectorTitle string `json:"selector_title" validate:"selector"` + SelectorLink string `json:"selector_link" validate:"selector"` + SelectorDescription string `json:"selector_description" validate:"selector"` + SelectorAuthor string `json:"selector_author" validate:"selector"` + SelectorCreated string `json:"selector_created" validate:"selector"` + SelectorContent string `json:"selector_content" validate:"selector"` + SelectorEnclosure string `json:"selector_enclosure" validate:"selector"` + CacheLifetime string `json:"cache_lifetime"` +} + +func (h *Handler) handleRender(c echo.Context) error { + specsParam := c.Param("specs") + specs, err := h.decodeSpecs(specsParam) + if err != nil { + return echo.NewHTTPError(400, fmt.Errorf("decode specs: %w", err)) + } + + task := models.Task{ + URL: specs.URL, + SelectorPost: specs.SelectorPost, + SelectorTitle: specs.SelectorTitle, + SelectorLink: specs.SelectorLink, + SelectorDescription: specs.SelectorDescription, + SelectorAuthor: specs.SelectorAuthor, + SelectorCreated: specs.SelectorCreated, + SelectorContent: specs.SelectorContent, + SelectorEnclosure: specs.SelectorEnclosure, + } + + taskTimeout := 20 * time.Second + minLifetime := taskTimeout + maxLifetime := 24 * time.Hour + cacheLifetime, err := time.ParseDuration(specs.CacheLifetime) + if err != nil { + return echo.NewHTTPError(400, "invalid cache lifetime") + } + if cacheLifetime < minLifetime { + cacheLifetime = minLifetime + } + if cacheLifetime > maxLifetime { + cacheLifetime = maxLifetime + } + + timeoutCtx, cancel := context.WithTimeout(context.Background(), taskTimeout) + defer cancel() + + encodedTask, err := json.Marshal(task) + if err != nil { + return echo.NewHTTPError(500, fmt.Errorf("task marshal error: %v", err)) + } + + taskResultBytes, err := h.CachedQueue.ProcessWorkCached(timeoutCtx, cacheLifetime, task.CacheKey(), encodedTask) + if err != nil { + return echo.NewHTTPError(500, fmt.Errorf("queued cache failed: %v", err)) + } + + var result models.TaskResult + if err := json.Unmarshal(taskResultBytes, &result); err != nil { + log.Errorf("cached value unmarshal failed: %v", err) + return echo.NewHTTPError(500, fmt.Errorf("cached value unmarshal failed: %v", err)) + } + + atom, err := makeFeed(task, result) + if err != nil { + log.Errorf("make feed failed: %v", err) + return echo.NewHTTPError(500) + } + + c.Response().Header().Set("Content-Type", "text/xml") + return c.String(200, atom) +} + +func (h *Handler) decodeSpecs(specsParam string) (Specs, error) { + decodedSpecsParam, err := base64.StdEncoding.WithPadding(base64.NoPadding).DecodeString(specsParam) + if err != nil { + return Specs{}, fmt.Errorf("failed to decode specs: %w", err) + } + rc := flate.NewReader(bytes.NewReader(decodedSpecsParam)) + decodedSpecsParam, err = io.ReadAll(rc) + if err != nil { + return Specs{}, fmt.Errorf("failed to unzip specs: %w", err) + } + var specs Specs + if err := json.Unmarshal(decodedSpecsParam, &specs); err != nil { + return Specs{}, fmt.Errorf("failed to unmarshal specs: %w", err) + } + if err := h.validate.Struct(specs); err != nil { + return Specs{}, fmt.Errorf("specs are invalid: %w", err) + } + return specs, nil +} + +func makeFeed(task models.Task, result models.TaskResult) (string, error) { + feedTS := time.Now() + if len(result.Items) > 0 { + feedTS = result.Items[0].Created + } + feed := feeds.Feed{ + Title: html.EscapeString(result.Title), + Link: &feeds.Link{Href: task.URL}, + Updated: feedTS, + } + for _, item := range result.Items { + feed.Items = append(feed.Items, &feeds.Item{ + Title: html.EscapeString(item.Title), + Link: &feeds.Link{Href: item.Link}, + Author: &feeds.Author{Name: item.AuthorName}, + Description: item.Description, + Created: item.Created, + Updated: item.Updated, + Content: item.Content, + }) + } + atomFeed := (&feeds.Atom{Feed: &feed}).AtomFeed() + atomFeed.Icon = result.Icon + for i, entry := range atomFeed.Entries { + entry.Author.Uri = result.Items[i].AuthorLink + } + atom, err := feeds.ToXML(atomFeed) + if err != nil { + return "", fmt.Errorf("feed to xml: %w", err) + } + return atom, nil +} diff --git a/internal/validators/validators.go b/internal/validators/validators.go new file mode 100644 index 0000000..27c80be --- /dev/null +++ b/internal/validators/validators.go @@ -0,0 +1,19 @@ +package validators + +import ( + "github.com/ericchiang/css" + "github.com/go-playground/validator/v10" + "github.com/labstack/gommon/log" + "reflect" +) + +func ValidateSelector(fl validator.FieldLevel) bool { + if fl.Field().Kind() != reflect.String { + return false + } + _, err := css.Parse(fl.Field().String()) + if err != nil { + log.Debugf("selector %s invalid: %v", fl.Field().String(), err) + } + return err == nil +}