diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-14 11:52:56 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-14 11:53:08 +0900 |
| commit | 2889b562e64993482bd13fd806af8ed0865bab8b (patch) | |
| tree | 39400ac4d994fb33d2c544e7d4b9d98f8ecbd86a /backend/graphql/resolver | |
| parent | e216c3bc97994b4172d15d52b46d5f6b75f35ea4 (diff) | |
| download | feedaka-2889b562e64993482bd13fd806af8ed0865bab8b.tar.gz feedaka-2889b562e64993482bd13fd806af8ed0865bab8b.tar.zst feedaka-2889b562e64993482bd13fd806af8ed0865bab8b.zip | |
refactor: migrate API from GraphQL to REST (TypeSpec/OpenAPI)
Replace the entire GraphQL stack (gqlgen, urql, graphql-codegen) with a
TypeSpec → OpenAPI 3.x pipeline using oapi-codegen for Go server stubs
and openapi-fetch + openapi-typescript for the frontend client.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'backend/graphql/resolver')
| -rw-r--r-- | backend/graphql/resolver/auth_helpers.go | 29 | ||||
| -rw-r--r-- | backend/graphql/resolver/pagination.go | 177 | ||||
| -rw-r--r-- | backend/graphql/resolver/resolver.go | 18 | ||||
| -rw-r--r-- | backend/graphql/resolver/schema.resolvers.go | 461 |
4 files changed, 0 insertions, 685 deletions
diff --git a/backend/graphql/resolver/auth_helpers.go b/backend/graphql/resolver/auth_helpers.go deleted file mode 100644 index dcc09fb..0000000 --- a/backend/graphql/resolver/auth_helpers.go +++ /dev/null @@ -1,29 +0,0 @@ -package resolver - -import ( - "context" - "errors" - "fmt" - - "github.com/labstack/echo/v4" - appcontext "undef.ninja/x/feedaka/context" -) - -// getUserIDFromContext retrieves the authenticated user ID from context -// This is a wrapper around the GetUserID function from the context package -func getUserIDFromContext(ctx context.Context) (int64, error) { - userID, ok := appcontext.GetUserID(ctx) - if !ok { - return 0, fmt.Errorf("authentication required") - } - return userID, nil -} - -// Helper function to get Echo context from GraphQL context -func getEchoContext(ctx context.Context) (echo.Context, error) { - echoCtx, ok := ctx.Value("echo").(echo.Context) - if !ok { - return nil, errors.New("echo context not found") - } - return echoCtx, nil -} diff --git a/backend/graphql/resolver/pagination.go b/backend/graphql/resolver/pagination.go deleted file mode 100644 index 1a14650..0000000 --- a/backend/graphql/resolver/pagination.go +++ /dev/null @@ -1,177 +0,0 @@ -package resolver - -import ( - "context" - "fmt" - "strconv" - - "undef.ninja/x/feedaka/db" - "undef.ninja/x/feedaka/graphql/model" -) - -const defaultPageSize = 30 -const maxPageSize = 100 - -// articleRow is a common interface for all paginated article query rows. -type articleRow struct { - ID int64 - FeedID int64 - Guid string - Title string - Url string - IsRead int64 - FeedID2 int64 - FeedUrl string - FeedTitle string - FeedIsSubscribed int64 -} - -func toArticleRow(r any) articleRow { - switch v := r.(type) { - case db.GetArticlesPaginatedRow: - return articleRow{v.ID, v.FeedID, v.Guid, v.Title, v.Url, v.IsRead, v.FeedID2, v.FeedUrl, v.FeedTitle, v.FeedIsSubscribed} - case db.GetArticlesPaginatedAfterRow: - return articleRow{v.ID, v.FeedID, v.Guid, v.Title, v.Url, v.IsRead, v.FeedID2, v.FeedUrl, v.FeedTitle, v.FeedIsSubscribed} - case db.GetArticlesByFeedPaginatedRow: - return articleRow{v.ID, v.FeedID, v.Guid, v.Title, v.Url, v.IsRead, v.FeedID2, v.FeedUrl, v.FeedTitle, v.FeedIsSubscribed} - case db.GetArticlesByFeedPaginatedAfterRow: - return articleRow{v.ID, v.FeedID, v.Guid, v.Title, v.Url, v.IsRead, v.FeedID2, v.FeedUrl, v.FeedTitle, v.FeedIsSubscribed} - default: - panic("unexpected row type") - } -} - -func rowToArticle(row articleRow) *model.Article { - return &model.Article{ - ID: strconv.FormatInt(row.ID, 10), - FeedID: strconv.FormatInt(row.FeedID, 10), - GUID: row.Guid, - Title: row.Title, - URL: row.Url, - IsRead: row.IsRead == 1, - Feed: &model.Feed{ - ID: strconv.FormatInt(row.FeedID2, 10), - URL: row.FeedUrl, - Title: row.FeedTitle, - IsSubscribed: row.FeedIsSubscribed == 1, - }, - } -} - -func (r *queryResolver) paginatedArticles(ctx context.Context, isRead int64, feedID *string, after *string, first *int32) (*model.ArticleConnection, error) { - userID, err := getUserIDFromContext(ctx) - if err != nil { - return nil, err - } - - limit := int64(defaultPageSize) - if first != nil { - limit = int64(*first) - if limit <= 0 { - limit = int64(defaultPageSize) - } - if limit > maxPageSize { - limit = maxPageSize - } - } - - // Fetch limit+1 to determine hasNextPage - fetchLimit := limit + 1 - - var rawRows []any - - if feedID != nil { - parsedFeedID, err := strconv.ParseInt(*feedID, 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid feed ID: %w", err) - } - - if after != nil { - cursor, err := strconv.ParseInt(*after, 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid cursor: %w", err) - } - rows, err := r.Queries.GetArticlesByFeedPaginatedAfter(ctx, db.GetArticlesByFeedPaginatedAfterParams{ - IsRead: isRead, - UserID: userID, - FeedID: parsedFeedID, - ID: cursor, - Limit: fetchLimit, - }) - if err != nil { - return nil, fmt.Errorf("failed to query articles: %w", err) - } - for _, row := range rows { - rawRows = append(rawRows, row) - } - } else { - rows, err := r.Queries.GetArticlesByFeedPaginated(ctx, db.GetArticlesByFeedPaginatedParams{ - IsRead: isRead, - UserID: userID, - FeedID: parsedFeedID, - Limit: fetchLimit, - }) - if err != nil { - return nil, fmt.Errorf("failed to query articles: %w", err) - } - for _, row := range rows { - rawRows = append(rawRows, row) - } - } - } else { - if after != nil { - cursor, err := strconv.ParseInt(*after, 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid cursor: %w", err) - } - rows, err := r.Queries.GetArticlesPaginatedAfter(ctx, db.GetArticlesPaginatedAfterParams{ - IsRead: isRead, - UserID: userID, - ID: cursor, - Limit: fetchLimit, - }) - if err != nil { - return nil, fmt.Errorf("failed to query articles: %w", err) - } - for _, row := range rows { - rawRows = append(rawRows, row) - } - } else { - rows, err := r.Queries.GetArticlesPaginated(ctx, db.GetArticlesPaginatedParams{ - IsRead: isRead, - UserID: userID, - Limit: fetchLimit, - }) - if err != nil { - return nil, fmt.Errorf("failed to query articles: %w", err) - } - for _, row := range rows { - rawRows = append(rawRows, row) - } - } - } - - hasNextPage := int64(len(rawRows)) > limit - if hasNextPage { - rawRows = rawRows[:limit] - } - - articles := make([]*model.Article, 0, len(rawRows)) - for _, raw := range rawRows { - articles = append(articles, rowToArticle(toArticleRow(raw))) - } - - var endCursor *string - if len(articles) > 0 { - lastID := articles[len(articles)-1].ID - endCursor = &lastID - } - - return &model.ArticleConnection{ - Articles: articles, - PageInfo: &model.PageInfo{ - HasNextPage: hasNextPage, - EndCursor: endCursor, - }, - }, nil -} diff --git a/backend/graphql/resolver/resolver.go b/backend/graphql/resolver/resolver.go deleted file mode 100644 index dea85a0..0000000 --- a/backend/graphql/resolver/resolver.go +++ /dev/null @@ -1,18 +0,0 @@ -package resolver - -import ( - "database/sql" - - "undef.ninja/x/feedaka/auth" - "undef.ninja/x/feedaka/db" -) - -// This file will not be regenerated automatically. -// -// It serves as dependency injection for your app, add any dependencies you require here. - -type Resolver struct { - DB *sql.DB - Queries *db.Queries - SessionConfig *auth.SessionConfig -} diff --git a/backend/graphql/resolver/schema.resolvers.go b/backend/graphql/resolver/schema.resolvers.go deleted file mode 100644 index 0392945..0000000 --- a/backend/graphql/resolver/schema.resolvers.go +++ /dev/null @@ -1,461 +0,0 @@ -package resolver - -// This file will be automatically regenerated based on the schema, any resolver implementations -// will be copied through when generating and any unknown code will be moved to the end. -// Code generated by github.com/99designs/gqlgen version v0.17.76 - -import ( - "context" - "database/sql" - "fmt" - "strconv" - "time" - - "undef.ninja/x/feedaka/auth" - "undef.ninja/x/feedaka/db" - "undef.ninja/x/feedaka/feed" - gql "undef.ninja/x/feedaka/graphql" - "undef.ninja/x/feedaka/graphql/model" -) - -// AddFeed is the resolver for the addFeed field. -func (r *mutationResolver) AddFeed(ctx context.Context, url string) (*model.Feed, error) { - userID, err := getUserIDFromContext(ctx) - if err != nil { - return nil, err - } - - // Fetch the feed to get its title - f, err := feed.Fetch(ctx, url) - if err != nil { - return nil, fmt.Errorf("failed to parse feed: %w", err) - } - - // Insert the feed into the database - dbFeed, err := r.Queries.CreateFeed(ctx, db.CreateFeedParams{ - Url: url, - Title: f.Title, - FetchedAt: time.Now().UTC().Format(time.RFC3339), - UserID: userID, - }) - if err != nil { - return nil, fmt.Errorf("failed to insert feed: %w", err) - } - - // Sync articles from the feed - if err := feed.Sync(ctx, r.Queries, dbFeed.ID, f); err != nil { - return nil, fmt.Errorf("failed to sync articles: %w", err) - } - - return &model.Feed{ - ID: strconv.FormatInt(dbFeed.ID, 10), - URL: dbFeed.Url, - Title: dbFeed.Title, - FetchedAt: dbFeed.FetchedAt, - IsSubscribed: dbFeed.IsSubscribed == 1, - }, nil -} - -// UnsubscribeFeed is the resolver for the unsubscribeFeed field. -func (r *mutationResolver) UnsubscribeFeed(ctx context.Context, id string) (bool, error) { - userID, err := getUserIDFromContext(ctx) - if err != nil { - return false, err - } - - feedID, err := strconv.ParseInt(id, 10, 64) - if err != nil { - return false, fmt.Errorf("invalid feed ID: %w", err) - } - - // Fetch feed - feed, err := r.Queries.GetFeed(ctx, feedID) - if err != nil { - if err == sql.ErrNoRows { - return false, fmt.Errorf("feed not found") - } - return false, fmt.Errorf("failed to query feed: %w", err) - } - - // Check authorization - if feed.UserID != userID { - return false, fmt.Errorf("forbidden: you don't have access to this feed") - } - - err = r.Queries.UnsubscribeFeed(ctx, feed.ID) - if err != nil { - return false, fmt.Errorf("failed to unsubscribe from feed: %w", err) - } - - return true, nil -} - -// MarkArticleRead is the resolver for the markArticleRead field. -func (r *mutationResolver) MarkArticleRead(ctx context.Context, id string) (*model.Article, error) { - userID, err := getUserIDFromContext(ctx) - if err != nil { - return nil, err - } - - articleID, err := strconv.ParseInt(id, 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid article ID: %w", err) - } - - // Fetch article - article, err := r.Queries.GetArticle(ctx, articleID) - if err != nil { - if err == sql.ErrNoRows { - return nil, fmt.Errorf("article not found") - } - return nil, fmt.Errorf("failed to query article: %w", err) - } - - // Check authorization (article belongs to a feed owned by user) - feed, err := r.Queries.GetFeed(ctx, article.FeedID) - if err != nil { - return nil, fmt.Errorf("failed to query feed: %w", err) - } - if feed.UserID != userID { - return nil, fmt.Errorf("forbidden: you don't have access to this article") - } - - // Update the article's read status - err = r.Queries.UpdateArticleReadStatus(ctx, db.UpdateArticleReadStatusParams{ - IsRead: 1, - ID: article.ID, - }) - if err != nil { - return nil, fmt.Errorf("failed to mark article as read: %w", err) - } - - // Fetch the updated article - return r.Query().Article(ctx, id) -} - -// MarkArticleUnread is the resolver for the markArticleUnread field. -func (r *mutationResolver) MarkArticleUnread(ctx context.Context, id string) (*model.Article, error) { - userID, err := getUserIDFromContext(ctx) - if err != nil { - return nil, err - } - - articleID, err := strconv.ParseInt(id, 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid article ID: %w", err) - } - - // Fetch article - article, err := r.Queries.GetArticle(ctx, articleID) - if err != nil { - if err == sql.ErrNoRows { - return nil, fmt.Errorf("article not found") - } - return nil, fmt.Errorf("failed to query article: %w", err) - } - - // Check authorization (article belongs to a feed owned by user) - feed, err := r.Queries.GetFeed(ctx, article.FeedID) - if err != nil { - return nil, fmt.Errorf("failed to query feed: %w", err) - } - if feed.UserID != userID { - return nil, fmt.Errorf("forbidden: you don't have access to this article") - } - - // Update the article's read status - err = r.Queries.UpdateArticleReadStatus(ctx, db.UpdateArticleReadStatusParams{ - IsRead: 0, - ID: article.ID, - }) - if err != nil { - return nil, fmt.Errorf("failed to mark article as unread: %w", err) - } - - // Fetch the updated article - return r.Query().Article(ctx, id) -} - -// MarkFeedRead is the resolver for the markFeedRead field. -func (r *mutationResolver) MarkFeedRead(ctx context.Context, id string) (*model.Feed, error) { - userID, err := getUserIDFromContext(ctx) - if err != nil { - return nil, err - } - - feedID, err := strconv.ParseInt(id, 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid feed ID: %w", err) - } - - // Fetch feed - feed, err := r.Queries.GetFeed(ctx, feedID) - if err != nil { - if err == sql.ErrNoRows { - return nil, fmt.Errorf("feed not found") - } - return nil, fmt.Errorf("failed to query feed: %w", err) - } - - // Check authorization - if feed.UserID != userID { - return nil, fmt.Errorf("forbidden: you don't have access to this feed") - } - - // Update all articles in the feed to be read - err = r.Queries.MarkFeedArticlesRead(ctx, feed.ID) - if err != nil { - return nil, fmt.Errorf("failed to mark feed as read: %w", err) - } - - // Fetch the updated feed - return r.Query().Feed(ctx, id) -} - -// MarkFeedUnread is the resolver for the markFeedUnread field. -func (r *mutationResolver) MarkFeedUnread(ctx context.Context, id string) (*model.Feed, error) { - userID, err := getUserIDFromContext(ctx) - if err != nil { - return nil, err - } - - feedID, err := strconv.ParseInt(id, 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid feed ID: %w", err) - } - - // Fetch feed - feed, err := r.Queries.GetFeed(ctx, feedID) - if err != nil { - if err == sql.ErrNoRows { - return nil, fmt.Errorf("feed not found") - } - return nil, fmt.Errorf("failed to query feed: %w", err) - } - - // Check authorization - if feed.UserID != userID { - return nil, fmt.Errorf("forbidden: you don't have access to this feed") - } - - // Update all articles in the feed to be unread - err = r.Queries.MarkFeedArticlesUnread(ctx, feed.ID) - if err != nil { - return nil, fmt.Errorf("failed to mark feed as unread: %w", err) - } - - // Fetch the updated feed - return r.Query().Feed(ctx, id) -} - -// Login is the resolver for the login field. -func (r *mutationResolver) Login(ctx context.Context, username string, password string) (*model.AuthPayload, error) { - // Verify user credentials - user, err := r.Queries.GetUserByUsername(ctx, username) - if err != nil { - if err == sql.ErrNoRows { - return nil, fmt.Errorf("invalid credentials") - } - return nil, fmt.Errorf("failed to query user: %w", err) - } - - // Verify password - if !auth.VerifyPassword(user.PasswordHash, password) { - return nil, fmt.Errorf("invalid credentials") - } - - // Get Echo context to create session - echoCtx, err := getEchoContext(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get echo context: %w", err) - } - - // Create session and store user ID - if err := r.SessionConfig.SetUserID(echoCtx, user.ID); err != nil { - return nil, fmt.Errorf("failed to create session: %w", err) - } - - return &model.AuthPayload{ - User: &model.User{ - ID: strconv.FormatInt(user.ID, 10), - Username: user.Username, - }, - }, nil -} - -// Logout is the resolver for the logout field. -func (r *mutationResolver) Logout(ctx context.Context) (bool, error) { - // Get Echo context to destroy session - echoCtx, err := getEchoContext(ctx) - if err != nil { - return false, fmt.Errorf("failed to get echo context: %w", err) - } - - // Destroy session - if err := r.SessionConfig.DestroySession(echoCtx); err != nil { - return false, fmt.Errorf("failed to destroy session: %w", err) - } - - return true, nil -} - -// Feeds is the resolver for the feeds field. -func (r *queryResolver) Feeds(ctx context.Context) ([]*model.Feed, error) { - userID, err := getUserIDFromContext(ctx) - if err != nil { - return nil, err - } - - dbFeeds, err := r.Queries.GetFeeds(ctx, userID) - if err != nil { - return nil, fmt.Errorf("failed to query feeds: %w", err) - } - - // Fetch unread counts for all feeds - unreadCounts, err := r.Queries.GetFeedUnreadCounts(ctx, userID) - if err != nil { - return nil, fmt.Errorf("failed to query unread counts: %w", err) - } - countMap := make(map[int64]int64, len(unreadCounts)) - for _, uc := range unreadCounts { - countMap[uc.FeedID] = uc.UnreadCount - } - - var feeds []*model.Feed - for _, dbFeed := range dbFeeds { - feeds = append(feeds, &model.Feed{ - ID: strconv.FormatInt(dbFeed.ID, 10), - URL: dbFeed.Url, - Title: dbFeed.Title, - FetchedAt: dbFeed.FetchedAt, - IsSubscribed: dbFeed.IsSubscribed == 1, - UnreadCount: int32(countMap[dbFeed.ID]), - }) - } - - return feeds, nil -} - -// UnreadArticles is the resolver for the unreadArticles field. -func (r *queryResolver) UnreadArticles(ctx context.Context, feedID *string, after *string, first *int32) (*model.ArticleConnection, error) { - return r.paginatedArticles(ctx, 0, feedID, after, first) -} - -// ReadArticles is the resolver for the readArticles field. -func (r *queryResolver) ReadArticles(ctx context.Context, feedID *string, after *string, first *int32) (*model.ArticleConnection, error) { - return r.paginatedArticles(ctx, 1, feedID, after, first) -} - -// Feed is the resolver for the feed field. -func (r *queryResolver) Feed(ctx context.Context, id string) (*model.Feed, error) { - userID, err := getUserIDFromContext(ctx) - if err != nil { - return nil, err - } - - feedID, err := strconv.ParseInt(id, 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid feed ID: %w", err) - } - - // Fetch feed - dbFeed, err := r.Queries.GetFeed(ctx, feedID) - if err != nil { - if err == sql.ErrNoRows { - return nil, fmt.Errorf("feed not found") - } - return nil, fmt.Errorf("failed to query feed: %w", err) - } - - // Check authorization - if dbFeed.UserID != userID { - return nil, fmt.Errorf("forbidden: you don't have access to this feed") - } - - return &model.Feed{ - ID: strconv.FormatInt(dbFeed.ID, 10), - URL: dbFeed.Url, - Title: dbFeed.Title, - FetchedAt: dbFeed.FetchedAt, - IsSubscribed: dbFeed.IsSubscribed == 1, - }, nil -} - -// Article is the resolver for the article field. -func (r *queryResolver) Article(ctx context.Context, id string) (*model.Article, error) { - userID, err := getUserIDFromContext(ctx) - if err != nil { - return nil, err - } - - articleID, err := strconv.ParseInt(id, 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid article ID: %w", err) - } - - // Fetch article - row, err := r.Queries.GetArticle(ctx, articleID) - if err != nil { - if err == sql.ErrNoRows { - return nil, fmt.Errorf("article not found") - } - return nil, fmt.Errorf("failed to query article: %w", err) - } - - // Check authorization (article's feed belongs to user) - // Note: GetArticle already joins with feeds table and returns feed info, - // but we need to check the user_id. Since GetArticleRow doesn't include user_id, - // we need to fetch the feed separately. - feed, err := r.Queries.GetFeed(ctx, row.FeedID) - if err != nil { - return nil, fmt.Errorf("failed to query feed: %w", err) - } - if feed.UserID != userID { - return nil, fmt.Errorf("forbidden: you don't have access to this article") - } - - return &model.Article{ - ID: strconv.FormatInt(row.ID, 10), - FeedID: strconv.FormatInt(row.FeedID, 10), - GUID: row.Guid, - Title: row.Title, - URL: row.Url, - IsRead: row.IsRead == 1, - Feed: &model.Feed{ - ID: strconv.FormatInt(row.FeedID2, 10), - URL: row.FeedUrl, - Title: row.FeedTitle, - }, - }, nil -} - -// CurrentUser is the resolver for the currentUser field. -func (r *queryResolver) CurrentUser(ctx context.Context) (*model.User, error) { - userID, err := getUserIDFromContext(ctx) - if err != nil { - // Not authenticated - return nil (not an error) - return nil, nil - } - - user, err := r.Queries.GetUserByID(ctx, userID) - if err != nil { - if err == sql.ErrNoRows { - return nil, nil - } - return nil, fmt.Errorf("failed to query user: %w", err) - } - - return &model.User{ - ID: strconv.FormatInt(user.ID, 10), - Username: user.Username, - }, nil -} - -// Mutation returns gql.MutationResolver implementation. -func (r *Resolver) Mutation() gql.MutationResolver { return &mutationResolver{r} } - -// Query returns gql.QueryResolver implementation. -func (r *Resolver) Query() gql.QueryResolver { return &queryResolver{r} } - -type mutationResolver struct{ *Resolver } -type queryResolver struct{ *Resolver } |
