Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 46 additions & 19 deletions internal/controller/feed_viewer.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
package controller

import (
"FeedCraft/internal/config"
"FeedCraft/internal/constant"
"FeedCraft/internal/craft"
"FeedCraft/internal/feedruntime"
"FeedCraft/internal/model"
"FeedCraft/internal/source"
"FeedCraft/internal/observability"
"FeedCraft/internal/util"
"errors"
"fmt"
Expand All @@ -16,6 +15,7 @@

"github.com/gin-gonic/gin"
"github.com/mmcdole/gofeed"
"gorm.io/gorm"
)

type FeedViewerPreviewReq struct {
Expand Down Expand Up @@ -76,6 +76,7 @@

const feedViewerInvalidURLMessage = "Please enter a valid http(s) feed URL"
const feedViewerInvalidURLError = "please enter a valid http(s) feed URL"
const feedViewerInvalidInputError = "please enter a valid feed input URI"

func PreviewFeedViewer(c *gin.Context) {
if c.Request.Method != http.MethodGet {
Expand All @@ -89,10 +90,14 @@
return
}

if err := validateFeedViewerURL(req.InputURL); err != nil {
if err := validateFeedViewerInputURI(req.InputURL); err != nil {
c.JSON(http.StatusBadRequest, util.APIResponse[any]{StatusCode: -1, Msg: formatFeedViewerValidationError(err)})
return
}
if req.CraftName != "" && req.CraftName != "proxy" && isFeedViewerInternalURI(req.InputURL) {
c.JSON(http.StatusBadRequest, util.APIResponse[any]{StatusCode: -1, Msg: "craft_name is only supported for external URLs"})
return
}

feed, err := loadFeedViewerPreview(c, req)
if err != nil {
Expand Down Expand Up @@ -159,24 +164,15 @@
}

func loadFeedViewerPreview(c *gin.Context, req FeedViewerPreviewReq) (*model.CraftFeed, error) {
cfg := &config.SourceConfig{
Type: constant.SourceRSS,
HttpFetcher: &config.HttpFetcherConfig{
URL: req.InputURL,
},
}

factory, err := source.Get(constant.SourceRSS)
provider, err := feedruntime.BuildProviderFromInputWithRecipeTrigger(c.Request.Context(), feedruntime.InputSpec{
Kind: feedruntime.InputKindURI,
URI: req.InputURL,
}, nil, observability.TriggerUserRequest)
if err != nil {
return nil, fmt.Errorf("factory not found: %w", err)
}

src, err := factory(cfg)
if err != nil {
return nil, fmt.Errorf("failed to create source: %w", err)
return nil, err
}

feed, err := src.Fetch(c.Request.Context())
feed, err := provider.Fetch(c.Request.Context())
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -376,7 +372,38 @@
return nil
}

func validateFeedViewerInputURI(rawURI string) error {

Check warning on line 375 in internal/controller/feed_viewer.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

internal/controller/feed_viewer.go#L375

Method validateFeedViewerInputURI has a cyclomatic complexity of 10 (limit is 8)
parsedURI, err := url.Parse(rawURI)
if err != nil || parsedURI == nil {
return errors.New(feedViewerInvalidInputError)
}
switch parsedURI.Scheme {
case "http", "https":
return validateFeedViewerURL(rawURI)
case "feedcraft":
resourceType := strings.TrimSpace(parsedURI.Host)
resourceID := strings.Trim(strings.TrimSpace(parsedURI.Path), "/")
if resourceType != "recipe" && resourceType != "topic" && resourceType != "inbox" {
return errors.New(feedViewerInvalidInputError)
}
if resourceID == "" || strings.Contains(resourceID, "/") {
return errors.New(feedViewerInvalidInputError)
}
return nil
default:
return errors.New(feedViewerInvalidInputError)
}
}

func isFeedViewerInternalURI(rawURI string) bool {
parsedURI, err := url.Parse(rawURI)
return err == nil && parsedURI != nil && parsedURI.Scheme == "feedcraft"
}

func classifyFeedViewerError(err error) (int, string) {
if errors.Is(err, gorm.ErrRecordNotFound) {
return http.StatusNotFound, "The selected preview target was not found."
}
msg := err.Error()
msg = strings.TrimPrefix(msg, "all items failed to process. last error: ")
lowerMsg := strings.ToLower(msg)
Expand Down
123 changes: 122 additions & 1 deletion internal/controller/feed_viewer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ import (
"net/url"
"strings"
"testing"
"time"

"FeedCraft/internal/craft"
"FeedCraft/internal/dao"

"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)

func TestPreviewFeedViewerAllowsInternalFeedURL(t *testing.T) {
Expand Down Expand Up @@ -132,6 +135,84 @@ func TestValidateFeedViewerURLAllowsPrivateIPLiteral(t *testing.T) {
}
}

func TestPreviewFeedViewerSupportsRecipeURI(t *testing.T) {
gin.SetMode(gin.TestMode)
db := topicFeedTestDatabase(t)
require.NoError(t, db.AutoMigrate(&dao.CustomRecipeV2{}, &dao.TopicFeed{}))
recipeID := uniqueTestID("preview-recipe")
createTopicTestRecipe(t, db, recipeID)

recorder := performFeedViewerPreviewRequest(t, http.MethodGet, "feedcraft://recipe/"+recipeID)

assertFeedViewerPreviewSuccess(t, recorder, "Test Feed", "Hello Topic")
}

func TestPreviewFeedViewerSupportsTopicURI(t *testing.T) {
gin.SetMode(gin.TestMode)
db := topicFeedTestDatabase(t)
require.NoError(t, db.AutoMigrate(&dao.CustomRecipeV2{}, &dao.TopicFeed{}))
recipeID := uniqueTestID("preview-topic-recipe")
topicID := uniqueTestID("preview-topic")
createTopicTestRecipe(t, db, recipeID)
createTopicTestTopic(t, db, &dao.TopicFeed{
ID: topicID,
Title: "Preview Topic",
Description: "Topic preview",
InputURIs: []string{"feedcraft://recipe/" + recipeID},
})

recorder := performFeedViewerPreviewRequest(t, http.MethodGet, "feedcraft://topic/"+topicID)

assertFeedViewerPreviewSuccess(t, recorder, "Preview Topic", "Hello Topic")
}

func TestPreviewFeedViewerSupportsInboxURI(t *testing.T) {
gin.SetMode(gin.TestMode)
db := topicFeedTestDatabase(t)
require.NoError(t, db.AutoMigrate(&dao.Inbox{}, &dao.InboxItem{}))
require.NoError(t, dao.CreateInbox(db, &dao.Inbox{
ID: "alerts",
Title: "Alerts Inbox",
Description: "Incoming alerts",
MaxItems: 100,
}))
require.NoError(t, db.Create(&dao.InboxItem{
InboxID: "alerts",
ItemID: "alert-1",
Title: "CPU usage high",
URL: "https://example.com/alerts/1",
Content: "<p>CPU usage exceeded threshold.</p>",
Summary: "CPU alert",
Author: "monitor",
PublishedAt: time.Unix(1700000000, 0),
CreatedAt: time.Unix(1700000000, 0),
}).Error)

recorder := performFeedViewerPreviewRequest(t, http.MethodGet, "feedcraft://inbox/alerts")

assertFeedViewerPreviewSuccess(t, recorder, "Alerts Inbox", "CPU usage high")
}

func TestPreviewFeedViewerRejectsCraftNameForInternalURI(t *testing.T) {
gin.SetMode(gin.TestMode)

recorder := performFeedViewerPreviewRequestWithCraft(t, http.MethodGet, "feedcraft://recipe/example", "summary")

if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d: %s", http.StatusBadRequest, recorder.Code, recorder.Body.String())
}
var response struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !strings.Contains(response.Msg, "craft_name is only supported for external URLs") {
t.Fatalf("expected craft_name validation message, got %q", response.Msg)
}
}

func TestNormalizeEmbeddingFilterPreviewRequest(t *testing.T) {
threshold := 0.72
maxContentLength := 1500
Expand Down Expand Up @@ -321,12 +402,52 @@ func TestClassifyFeedViewerErrorHandlesBrowserProviderFailures(t *testing.T) {

func performFeedViewerPreviewRequest(t *testing.T, method, inputURL string) *httptest.ResponseRecorder {
t.Helper()
return performFeedViewerPreviewRequestWithCraft(t, method, inputURL, "")
}

func performFeedViewerPreviewRequestWithCraft(t *testing.T, method, inputURL, craftName string) *httptest.ResponseRecorder {
t.Helper()

router := gin.New()
router.Handle(method, "/preview", PreviewFeedViewer)

request := httptest.NewRequest(method, "/preview?input_url="+url.QueryEscape(inputURL), nil)
query := url.Values{}
query.Set("input_url", inputURL)
if craftName != "" {
query.Set("craft_name", craftName)
}
request := httptest.NewRequest(method, "/preview?"+query.Encode(), nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
return recorder
}

func assertFeedViewerPreviewSuccess(t *testing.T, recorder *httptest.ResponseRecorder, expectedTitle, expectedItemTitle string) {
t.Helper()
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, recorder.Code, recorder.Body.String())
}

var response struct {
Code int `json:"code"`
Data struct {
Title string `json:"title"`
Items []struct {
Title string `json:"title"`
} `json:"items"`
} `json:"data"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if response.Code != 0 {
t.Fatalf("expected success code, got %d with msg %q", response.Code, response.Msg)
}
if response.Data.Title != expectedTitle {
t.Fatalf("expected title %q, got %q", expectedTitle, response.Data.Title)
}
if len(response.Data.Items) != 1 || response.Data.Items[0].Title != expectedItemTitle {
t.Fatalf("expected one item titled %q, got %+v", expectedItemTitle, response.Data.Items)
}
}
70 changes: 67 additions & 3 deletions internal/feedruntime/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
internalScheme = "feedcraft"
internalResourceTypeRecipe = "recipe"
internalResourceTypeTopic = "topic"
internalResourceTypeInbox = "inbox"
)

// InputSpec is the unified runtime input model for RecipeFeed and TopicFeed.
Expand All @@ -61,6 +62,10 @@
return NewBuilder(nil).BuildProviderFromInput(ctx, spec, stack)
}

func BuildProviderFromInputWithRecipeTrigger(ctx context.Context, spec InputSpec, stack []string, recipeTrigger string) (engine.FeedProvider, error) {
return NewBuilder(nil).BuildProviderFromInputWithRecipeTrigger(ctx, spec, stack, recipeTrigger)
}

func BuildTopicProvider(ctx context.Context, topicID string) (engine.FeedProvider, error) {
return NewBuilder(nil).BuildTopicProvider(ctx, topicID)
}
Expand All @@ -82,9 +87,16 @@
}

func (b *Builder) BuildProviderFromInput(ctx context.Context, spec InputSpec, stack []string) (engine.FeedProvider, error) {
return b.BuildProviderFromInputWithRecipeTrigger(ctx, spec, stack, observability.TriggerTopicAggregation)
}

func (b *Builder) BuildProviderFromInputWithRecipeTrigger(ctx context.Context, spec InputSpec, stack []string, recipeTrigger string) (engine.FeedProvider, error) {
if strings.TrimSpace(recipeTrigger) == "" {
recipeTrigger = observability.TriggerTopicAggregation
}
switch spec.Kind {
case InputKindURI:
return b.buildProviderFromURI(ctx, spec.URI, stack)
return b.buildProviderFromURI(ctx, spec.URI, stack, recipeTrigger)
case InputKindSource:
if spec.SourceConfig == nil {
return nil, errors.New("source input requires source_config")
Expand Down Expand Up @@ -212,7 +224,7 @@
}, nil
}

func (b *Builder) buildProviderFromURI(ctx context.Context, rawURI string, stack []string) (engine.FeedProvider, error) {
func (b *Builder) buildProviderFromURI(ctx context.Context, rawURI string, stack []string, recipeTrigger string) (engine.FeedProvider, error) {

Check warning on line 227 in internal/feedruntime/builder.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

internal/feedruntime/builder.go#L227

Method buildProviderFromURI has a cyclomatic complexity of 9 (limit is 8)
if rawURI == "" {
return nil, errors.New("uri input requires a non-empty uri")
}
Expand All @@ -232,9 +244,11 @@
}
switch resourceType {
case internalResourceTypeRecipe:
return b.buildRecipeProvider(ctx, resourceID, observability.TriggerTopicAggregation)
return b.buildRecipeProvider(ctx, resourceID, recipeTrigger)
case internalResourceTypeTopic:
return b.buildTopicProvider(ctx, resourceID, stack)
case internalResourceTypeInbox:
return &InboxProvider{DB: b.db(), InboxID: resourceID}, nil
default:
return nil, fmt.Errorf("unsupported internal resource type %q", resourceType)
}
Expand Down Expand Up @@ -438,6 +452,56 @@
return resourceType, resourceID, nil
}

// InboxProvider adapts inbox items into a feed without requiring an intermediate recipe.
type InboxProvider struct {
DB *gorm.DB
InboxID string
}

func (p *InboxProvider) Fetch(ctx context.Context) (*model.CraftFeed, error) {
_ = ctx
db := p.DB
if db == nil {
db = util.GetDatabase()
}

inbox, err := dao.GetInboxByID(db, p.InboxID)
if err != nil {
return nil, fmt.Errorf("failed to get inbox %s: %w", p.InboxID, err)
}

items, err := dao.ListInboxItems(db, p.InboxID)
if err != nil {
return nil, fmt.Errorf("failed to list inbox items: %w", err)
}
Comment on lines +462 to +476

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The ctx parameter is currently ignored in InboxProvider.Fetch. It is best practice to use db.WithContext(ctx) to ensure that database operations respect the request context, allowing for proper timeout and cancellation handling.

Suggested change
_ = ctx
db := p.DB
if db == nil {
db = util.GetDatabase()
}
inbox, err := dao.GetInboxByID(db, p.InboxID)
if err != nil {
return nil, fmt.Errorf("failed to get inbox %s: %w", p.InboxID, err)
}
items, err := dao.ListInboxItems(db, p.InboxID)
if err != nil {
return nil, fmt.Errorf("failed to list inbox items: %w", err)
}
db := p.DB
if db == nil {
db = util.GetDatabase()
}
db = db.WithContext(ctx)
inbox, err := dao.GetInboxByID(db, p.InboxID)
if err != nil {
return nil, fmt.Errorf("failed to get inbox %s: %w", p.InboxID, err)
}
items, err := dao.ListInboxItems(db, p.InboxID)
if err != nil {
return nil, fmt.Errorf("failed to list inbox items: %w", err)
}


feed := &model.CraftFeed{
Title: inbox.Title,
Description: inbox.Description,
Id: fmt.Sprintf("inbox:%s", p.InboxID),
Link: p.BaseURL(),
Articles: make([]*model.CraftArticle, 0, len(items)),
}
for _, item := range items {
feed.Articles = append(feed.Articles, &model.CraftArticle{
Title: item.Title,
Link: item.URL,
Content: item.Content,
Description: item.Summary,
Id: item.ItemID,
AuthorName: item.Author,
Created: item.PublishedAt,
Updated: item.PublishedAt,
})
}

return feed, nil
}

func (p *InboxProvider) BaseURL() string {
return fmt.Sprintf("feedcraft://inbox/%s", p.InboxID)
Comment on lines +501 to +502

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: Reuse the internal inbox resource type constant when constructing the feedcraft URI

BaseURL formats the URI as feedcraft://inbox/%s with a literal "inbox", while internalResourceTypeInbox is already defined and used for parsing. Please use that constant here so URI construction and parsing stay in sync if the identifier ever changes.

}

// RecipeProvider adapts a runtime RecipeFeed and adds execution metadata.
type RecipeProvider struct {
Recipe *engine.RecipeFeed
Expand Down
Loading
Loading