Compare commits

..

10 Commits

Author SHA1 Message Date
995f306a6a #14 - readme 2025-03-11 16:31:00 +02:00
80602bf04f #13 - browse articles 2025-03-11 16:08:34 +02:00
f561eb4c34 #12 - update feeds 2025-03-11 15:25:52 +02:00
a76915c7fc #11 - unfollow 2025-03-11 15:08:29 +02:00
5dfe911793 #10 - user logged in middleware 2025-03-11 15:00:59 +02:00
b7b7a7d55d #9 - feed follows 2025-03-11 14:53:10 +02:00
ed8b8860b7 #8 - list feeds, no content still 2025-03-11 14:10:01 +02:00
e89bfba392 #7 - saving feed, but without content 2025-03-11 13:57:16 +02:00
5a7fac748b #6 - fetch feed 2025-03-11 12:41:15 +02:00
e74d33ab55 #5 - list users 2025-03-11 12:23:58 +02:00
41 changed files with 1171 additions and 1 deletions

44
README.md Normal file
View File

@@ -0,0 +1,44 @@
## Requirements
To run this program you will need to install:
- PostresSQL
- Go CLI
For Dev:
- Goose (for migrations) `go install github.com/pressly/goose/v3/cmd/goose@latest`
- from `sql/schema` directory of the project run `goose postgres "postgres://postgres:postgres@localhost:5432/gator" up` to do the migrations
- SQLC (for generating models) `go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest`
- from root of the project run `sqlc generate` to build models from SQL files
## Setup
set up a config in your home directory `~/.gatorconfig.json`
```json
{
"db_url": "protocol://username:password@host:port/database?sslmode=disable",
"current_user_name": "username_goes_here"
}
```
## Installation
Because GO does not allow custom names when installing application (as far as I know) you can either:
- install from source with `go install` and use `boot-dev-blog-aggregator` to run the program, or
- build the code with custom name `go build -o gator` and use generated executable instead
## Usage
first: make an account
> ./gator register YOUR_USERNAME
then you can follow some topics:
> ./gator follow https://blog.boot.dev/index.xml
let them update:
> ./gator agg 1m
and see the content:
> ./gator browse X
(where X is number of articles to be shown, they are sorted from latest or oldest by publishing data)

View File

@@ -0,0 +1,57 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: createFeed.sql
package database
import (
"context"
"time"
"github.com/google/uuid"
)
const createFeed = `-- name: CreateFeed :one
INSERT INTO feeds (id, created_at, updated_at, user_id, url, name)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6
)
RETURNING id, created_at, updated_at, name, url, user_id, last_fetched_at
`
type CreateFeedParams struct {
ID uuid.UUID
CreatedAt time.Time
UpdatedAt time.Time
UserID uuid.UUID
Url string
Name string
}
func (q *Queries) CreateFeed(ctx context.Context, arg CreateFeedParams) (Feed, error) {
row := q.db.QueryRowContext(ctx, createFeed,
arg.ID,
arg.CreatedAt,
arg.UpdatedAt,
arg.UserID,
arg.Url,
arg.Name,
)
var i Feed
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Name,
&i.Url,
&i.UserID,
&i.LastFetchedAt,
)
return i, err
}

View File

@@ -0,0 +1,73 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: createFeedFollow.sql
package database
import (
"context"
"time"
"github.com/google/uuid"
)
const createFeedFollow = `-- name: CreateFeedFollow :one
WITH inserted_feed_follow AS (
INSERT INTO feed_follows (id, created_at, updated_at, user_id, feed_id)
VALUES (
$1,
$2,
$3,
$4,
$5
)
RETURNING id, created_at, updated_at, user_id, feed_id
) SELECT
inserted_feed_follow.id, inserted_feed_follow.created_at, inserted_feed_follow.updated_at, inserted_feed_follow.user_id, inserted_feed_follow.feed_id,
feeds.name AS feed_name,
users.name AS user_name
FROM inserted_feed_follow
INNER JOIN users ON users.id = inserted_feed_follow.user_id
INNER JOIN feeds ON feeds.id = inserted_feed_follow.feed_id
`
type CreateFeedFollowParams struct {
ID uuid.UUID
CreatedAt time.Time
UpdatedAt time.Time
UserID uuid.UUID
FeedID uuid.UUID
}
type CreateFeedFollowRow struct {
ID uuid.UUID
CreatedAt time.Time
UpdatedAt time.Time
UserID uuid.UUID
FeedID uuid.UUID
FeedName string
UserName string
}
func (q *Queries) CreateFeedFollow(ctx context.Context, arg CreateFeedFollowParams) (CreateFeedFollowRow, error) {
row := q.db.QueryRowContext(ctx, createFeedFollow,
arg.ID,
arg.CreatedAt,
arg.UpdatedAt,
arg.UserID,
arg.FeedID,
)
var i CreateFeedFollowRow
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.UserID,
&i.FeedID,
&i.FeedName,
&i.UserName,
)
return i, err
}

View File

@@ -0,0 +1,73 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: createPost.sql
package database
import (
"context"
"time"
"github.com/google/uuid"
)
const createPost = `-- name: CreatePost :one
INSERT INTO posts (id, created_at, updated_at, url, title, description, feed_id, published_at)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8
) ON CONFLICT (url) DO UPDATE
SET
id = $1,
created_at = $2,
updated_at = $3,
title = $5,
description = $6,
feed_id = $7,
published_at = $8
WHERE posts.url = $4
RETURNING id, created_at, updated_at, title, url, description, published_at, feed_id
`
type CreatePostParams struct {
ID uuid.UUID
CreatedAt time.Time
UpdatedAt time.Time
Url string
Title string
Description string
FeedID uuid.UUID
PublishedAt time.Time
}
func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) (Post, error) {
row := q.db.QueryRowContext(ctx, createPost,
arg.ID,
arg.CreatedAt,
arg.UpdatedAt,
arg.Url,
arg.Title,
arg.Description,
arg.FeedID,
arg.PublishedAt,
)
var i Post
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Title,
&i.Url,
&i.Description,
&i.PublishedAt,
&i.FeedID,
)
return i, err
}

View File

@@ -0,0 +1,26 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: feleteFeedFollow.sql
package database
import (
"context"
"github.com/google/uuid"
)
const deleteFeedFollow = `-- name: DeleteFeedFollow :exec
DELETE FROM feed_follows WHERE feed_follows.user_id = $1 AND feed_follows.feed_id = (SELECT id FROM feeds WHERE url = $2)
`
type DeleteFeedFollowParams struct {
UserID uuid.UUID
Url string
}
func (q *Queries) DeleteFeedFollow(ctx context.Context, arg DeleteFeedFollowParams) error {
_, err := q.db.ExecContext(ctx, deleteFeedFollow, arg.UserID, arg.Url)
return err
}

View File

@@ -0,0 +1,29 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: getFeedByUrl.sql
package database
import (
"context"
)
const getFeedByUrl = `-- name: GetFeedByUrl :one
SELECT id, created_at, updated_at, name, url, user_id, last_fetched_at FROM feeds WHERE url = $1
`
func (q *Queries) GetFeedByUrl(ctx context.Context, url string) (Feed, error) {
row := q.db.QueryRowContext(ctx, getFeedByUrl, url)
var i Feed
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Name,
&i.Url,
&i.UserID,
&i.LastFetchedAt,
)
return i, err
}

View File

@@ -0,0 +1,65 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: getFeedFollowsByUser.sql
package database
import (
"context"
"time"
"github.com/google/uuid"
)
const getFeedFollowsByUser = `-- name: GetFeedFollowsByUser :many
SELECT
feed_follows.id, feed_follows.created_at, feed_follows.updated_at, feed_follows.user_id, feed_follows.feed_id,
feeds.name AS feed_name,
users.name AS user_name
FROM feed_follows
INNER JOIN users ON users.id = feed_follows.user_id
INNER JOIN feeds ON feeds.id = feed_follows.feed_id
WHERE feed_follows.user_id = $1
`
type GetFeedFollowsByUserRow struct {
ID uuid.UUID
CreatedAt time.Time
UpdatedAt time.Time
UserID uuid.UUID
FeedID uuid.UUID
FeedName string
UserName string
}
func (q *Queries) GetFeedFollowsByUser(ctx context.Context, userID uuid.UUID) ([]GetFeedFollowsByUserRow, error) {
rows, err := q.db.QueryContext(ctx, getFeedFollowsByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetFeedFollowsByUserRow
for rows.Next() {
var i GetFeedFollowsByUserRow
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.UserID,
&i.FeedID,
&i.FeedName,
&i.UserName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@@ -0,0 +1,29 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: getNextFeedToFetch.sql
package database
import (
"context"
)
const getNextFeedToFetch = `-- name: GetNextFeedToFetch :one
SELECT id, created_at, updated_at, name, url, user_id, last_fetched_at FROM feeds ORDER BY last_fetched_at NULLS FIRST LIMIT 1
`
func (q *Queries) GetNextFeedToFetch(ctx context.Context) (Feed, error) {
row := q.db.QueryRowContext(ctx, getNextFeedToFetch)
var i Feed
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Name,
&i.Url,
&i.UserID,
&i.LastFetchedAt,
)
return i, err
}

View File

@@ -0,0 +1,54 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: getPostsForUser.sql
package database
import (
"context"
"github.com/google/uuid"
)
const getPostsForUser = `-- name: GetPostsForUser :many
SELECT id, created_at, updated_at, title, url, description, published_at, feed_id FROM posts WHERE feed_id IN (SELECT feed_follows.feed_id FROM feed_follows WHERE feed_follows.user_id = $1)
ORDER BY published_at DESC LIMIT $2
`
type GetPostsForUserParams struct {
UserID uuid.UUID
Limit int32
}
func (q *Queries) GetPostsForUser(ctx context.Context, arg GetPostsForUserParams) ([]Post, error) {
rows, err := q.db.QueryContext(ctx, getPostsForUser, arg.UserID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Post
for rows.Next() {
var i Post
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Title,
&i.Url,
&i.Description,
&i.PublishedAt,
&i.FeedID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@@ -0,0 +1,42 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: getUsers.sql
package database
import (
"context"
)
const getUsers = `-- name: GetUsers :many
SELECT id, created_at, updated_at, name FROM users WHERE true
`
func (q *Queries) GetUsers(ctx context.Context) ([]User, error) {
rows, err := q.db.QueryContext(ctx, getUsers)
if err != nil {
return nil, err
}
defer rows.Close()
var items []User
for rows.Next() {
var i User
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Name,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@@ -0,0 +1,43 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: listFeeds.sql
package database
import (
"context"
)
const listFeeds = `-- name: ListFeeds :many
SELECT name, url, (SELECT name FROM users WHERE users.id = feeds.user_id) as user_name FROM feeds
`
type ListFeedsRow struct {
Name string
Url string
UserName string
}
func (q *Queries) ListFeeds(ctx context.Context) ([]ListFeedsRow, error) {
rows, err := q.db.QueryContext(ctx, listFeeds)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListFeedsRow
for rows.Next() {
var i ListFeedsRow
if err := rows.Scan(&i.Name, &i.Url, &i.UserName); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: markFeeedFetched.sql
package database
import (
"context"
"github.com/google/uuid"
)
const markFeedFetched = `-- name: MarkFeedFetched :one
UPDATE feeds SET last_fetched_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = $1
RETURNING id, created_at, updated_at, name, url, user_id, last_fetched_at
`
func (q *Queries) MarkFeedFetched(ctx context.Context, id uuid.UUID) (Feed, error) {
row := q.db.QueryRowContext(ctx, markFeedFetched, id)
var i Feed
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Name,
&i.Url,
&i.UserID,
&i.LastFetchedAt,
)
return i, err
}

View File

@@ -5,11 +5,41 @@
package database package database
import ( import (
"database/sql"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
) )
type Feed struct {
ID uuid.UUID
CreatedAt time.Time
UpdatedAt time.Time
Name string
Url string
UserID uuid.UUID
LastFetchedAt sql.NullTime
}
type FeedFollow struct {
ID uuid.UUID
CreatedAt time.Time
UpdatedAt time.Time
UserID uuid.UUID
FeedID uuid.UUID
}
type Post struct {
ID uuid.UUID
CreatedAt time.Time
UpdatedAt time.Time
Title string
Url string
Description string
PublishedAt time.Time
FeedID uuid.UUID
}
type User struct { type User struct {
ID uuid.UUID ID uuid.UUID
CreatedAt time.Time CreatedAt time.Time

View File

@@ -0,0 +1,58 @@
package handlers
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/rdarius/boot-dev-blog-aggregator/internal/config"
"github.com/rdarius/boot-dev-blog-aggregator/internal/database"
"github.com/rdarius/boot-dev-blog-aggregator/internal/rss"
"log"
"os"
"time"
)
func AddFeedHandler(s *config.State, cmd config.Command, user database.User) error {
if len(cmd.Args) < 2 {
log.Fatal("usage: boot-dev-blog-aggregator addfeed NAME URL")
}
ctx := context.Background()
name := cmd.Args[0]
url := cmd.Args[1]
_, err := rss.FetchFeed(ctx, url)
if err != nil {
fmt.Printf("Failed to fetch feed: %v\n", err)
os.Exit(1)
}
f, err := s.DB.CreateFeed(ctx, database.CreateFeedParams{
ID: uuid.New(),
CreatedAt: time.Time{},
UpdatedAt: time.Time{},
UserID: user.ID,
Url: url,
Name: name,
})
if err != nil {
return err
}
_, err = s.DB.CreateFeedFollow(ctx, database.CreateFeedFollowParams{
ID: uuid.New(),
CreatedAt: time.Time{},
UpdatedAt: time.Time{},
UserID: user.ID,
FeedID: f.ID,
})
if err != nil {
return err
}
fmt.Printf("%v\n", f)
return nil
}

View File

@@ -0,0 +1,36 @@
package handlers
import (
"context"
"fmt"
"github.com/rdarius/boot-dev-blog-aggregator/internal/config"
"github.com/rdarius/boot-dev-blog-aggregator/internal/database"
"strconv"
)
func BrowseHandler(s *config.State, cmd config.Command, user database.User) error {
var limit int32
if len(cmd.Args) < 1 {
limit = 2
} else {
l, err := strconv.Atoi(cmd.Args[0])
if err != nil {
return err
}
limit = int32(l)
}
posts, err := s.DB.GetPostsForUser(context.Background(), database.GetPostsForUserParams{
UserID: user.ID,
Limit: limit,
})
if err != nil {
return err
}
for _, post := range posts {
fmt.Printf("Title: %s\nURL: %s\nArticle: %sPublished: %s\n\n", post.Title, post.Url, post.Description, post.PublishedAt)
}
return nil
}

View File

@@ -0,0 +1,24 @@
package handlers
import (
"context"
"fmt"
"github.com/rdarius/boot-dev-blog-aggregator/internal/config"
"github.com/rdarius/boot-dev-blog-aggregator/internal/database"
)
func GetFeedFollowsByUserHandler(s *config.State, cmd config.Command, user database.User) error {
ctx := context.Background()
ff, err := s.DB.GetFeedFollowsByUser(ctx, user.ID)
if err != nil {
return err
}
for _, feed := range ff {
fmt.Printf("* %s\n", feed.FeedName)
}
return nil
}

View File

@@ -0,0 +1,28 @@
package handlers
import (
"github.com/rdarius/boot-dev-blog-aggregator/internal/config"
"log"
"time"
)
func FetchFeedHandler(s *config.State, cmd config.Command) error {
if len(cmd.Args) < 1 {
log.Fatal("usage: boot-dev-blog-aggregator agg time_between_reqs")
}
t, err := time.ParseDuration(cmd.Args[0])
if err != nil {
return err
}
ticker := time.NewTicker(t)
for ; ; <-ticker.C {
err := ScrapeFeedsHandler(s, cmd)
if err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,62 @@
package handlers
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/rdarius/boot-dev-blog-aggregator/internal/config"
"github.com/rdarius/boot-dev-blog-aggregator/internal/database"
"github.com/rdarius/boot-dev-blog-aggregator/internal/rss"
"log"
"os"
"time"
)
func FollowFeedHandler(s *config.State, cmd config.Command, user database.User) error {
if len(cmd.Args) < 1 {
log.Fatal("usage: boot-dev-blog-aggregator follow URL")
}
ctx := context.Background()
url := cmd.Args[0]
ff, err := s.DB.GetFeedByUrl(ctx, url)
if err != nil {
// no feed, get it and save it
fd, err := rss.FetchFeed(ctx, url)
if err != nil {
fmt.Printf("Failed to fetch feed: %v\n", err)
os.Exit(1)
}
res, err := s.DB.CreateFeed(ctx, database.CreateFeedParams{
ID: uuid.New(),
CreatedAt: time.Time{},
UpdatedAt: time.Time{},
UserID: user.ID,
Url: url,
Name: fd.Channel.Title,
})
if err != nil {
fmt.Printf("Failed to create feed: %v\n", err)
os.Exit(1)
}
ff = res
}
feed, err := s.DB.CreateFeedFollow(ctx, database.CreateFeedFollowParams{
ID: uuid.New(),
CreatedAt: time.Time{},
UpdatedAt: time.Time{},
UserID: user.ID,
FeedID: ff.ID,
})
if err != nil {
return err
}
fmt.Printf("%v\n", feed)
return nil
}

View File

@@ -0,0 +1,30 @@
package handlers
import (
"context"
"fmt"
"github.com/rdarius/boot-dev-blog-aggregator/internal/config"
"os"
)
func GetUsersHandler(s *config.State, cmd config.Command) error {
ctx := context.Background()
u, err := s.DB.GetUsers(ctx)
if err != nil {
fmt.Println("Failed to get Users")
os.Exit(1)
}
for _, u := range u {
fmt.Printf("* %s", u.Name)
if u.Name == s.Config.CurrentUserName {
fmt.Print(" (current)")
}
fmt.Print("\n")
}
return nil
}

View File

@@ -0,0 +1,21 @@
package handlers
import (
"context"
"fmt"
"github.com/rdarius/boot-dev-blog-aggregator/internal/config"
)
func ListFeedsHandler(s *config.State, cmd config.Command) error {
ctx := context.Background()
feeds, err := s.DB.ListFeeds(ctx)
if err != nil {
return err
}
for _, feed := range feeds {
fmt.Printf("Article: %s | Url: %s | User: %s\n", feed.Name, feed.Url, feed.UserName)
}
return nil
}

View File

@@ -26,7 +26,7 @@ func LoginHandler(s *config.State, cmd config.Command) error {
return fmt.Errorf("failed to set current user: %w", err) return fmt.Errorf("failed to set current user: %w", err)
} }
fmt.Printf("User set to %s\n", s.Config.CurrentUserName) fmt.Printf("User set to %s\n", u.Name)
return nil return nil
} }

View File

@@ -0,0 +1,57 @@
package handlers
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/rdarius/boot-dev-blog-aggregator/internal/config"
"github.com/rdarius/boot-dev-blog-aggregator/internal/database"
"github.com/rdarius/boot-dev-blog-aggregator/internal/parser"
"github.com/rdarius/boot-dev-blog-aggregator/internal/rss"
"time"
)
func ScrapeFeedsHandler(s *config.State, cmd config.Command) error {
ctx := context.Background()
nextFeed, err := s.DB.GetNextFeedToFetch(ctx)
if err != nil {
return err
}
_, err = s.DB.MarkFeedFetched(ctx, nextFeed.ID)
if err != nil {
return err
}
feedData, err := rss.FetchFeed(ctx, nextFeed.Url)
if err != nil {
return err
}
fmt.Println("Feed Title: " + feedData.Channel.Title)
for _, i := range feedData.Channel.Item {
pubTime, err := parser.ParseTimestamp(i.PubDate)
if err != nil {
return err
}
_, err = s.DB.CreatePost(ctx, database.CreatePostParams{
ID: uuid.New(),
CreatedAt: time.Time{},
UpdatedAt: time.Time{},
FeedID: nextFeed.ID,
Url: i.Link,
Title: i.Title,
Description: i.Description,
PublishedAt: pubTime,
})
if err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,31 @@
package handlers
import (
"context"
"fmt"
"github.com/rdarius/boot-dev-blog-aggregator/internal/config"
"github.com/rdarius/boot-dev-blog-aggregator/internal/database"
"log"
)
func UnfollowFeedHandler(s *config.State, cmd config.Command, user database.User) error {
if len(cmd.Args) < 1 {
log.Fatal("usage: boot-dev-blog-aggregator unfollow URL")
}
ctx := context.Background()
url := cmd.Args[0]
err := s.DB.DeleteFeedFollow(ctx, database.DeleteFeedFollowParams{
UserID: user.ID,
Url: url,
})
if err != nil {
return err
}
fmt.Println("Feed unfollow successfully")
return nil
}

View File

@@ -0,0 +1,29 @@
package parser
import (
"fmt"
"time"
)
func ParseTimestamp(str string) (time.Time, error) {
layouts := []string{
time.RFC1123, // "Mon, 02 Jan 2006 15:04:05 MST"
time.RFC1123Z, // "Mon, 02 Jan 2006 15:04:05 -0700"
time.RFC3339, // "2006-01-02T15:04:05Z07:00"
time.RFC3339Nano, // "2006-01-02T15:04:05.999999999Z07:00"
"2006-01-02 15:04:05", // Common SQL format
"02 Jan 2006 15:04:05", // "02 Jan 2025 15:04:05"
"02-Jan-2006 15:04:05", // "02-Jan-2025 15:04:05"
"02/01/2006 15:04:05", // "02/01/2025 15:04:05"
}
var t time.Time
var err error
for _, layout := range layouts {
t, err = time.Parse(layout, str)
if err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("unknown timestamp format: %s", str)
}

57
internal/rss/rss.go Normal file
View File

@@ -0,0 +1,57 @@
package rss
import (
"context"
"encoding/xml"
"html"
"io"
"net/http"
)
type Feed struct {
Channel struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
Item []Item `xml:"item"`
} `xml:"channel"`
}
type Item struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
PubDate string `xml:"pubDate"`
}
func FetchFeed(ctx context.Context, feedURL string) (*Feed, error) {
req, err := http.NewRequestWithContext(ctx, "GET", feedURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "gator")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
res, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var feed Feed
err = xml.Unmarshal(res, &feed)
if err != nil {
return nil, err
}
feed.Channel.Title = html.UnescapeString(feed.Channel.Title)
feed.Channel.Link = html.UnescapeString(feed.Channel.Link)
feed.Channel.Description = html.UnescapeString(feed.Channel.Description)
for i, _ := range feed.Channel.Item {
feed.Channel.Item[i].Link = html.UnescapeString(feed.Channel.Item[i].Link)
feed.Channel.Item[i].Description = html.UnescapeString(feed.Channel.Item[i].Description)
feed.Channel.Item[i].PubDate = html.UnescapeString(feed.Channel.Item[i].PubDate)
feed.Channel.Item[i].Title = html.UnescapeString(feed.Channel.Item[i].Title)
}
return &feed, nil
}

19
main.go
View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"database/sql" "database/sql"
_ "github.com/lib/pq" _ "github.com/lib/pq"
"github.com/rdarius/boot-dev-blog-aggregator/internal/config" "github.com/rdarius/boot-dev-blog-aggregator/internal/config"
@@ -34,6 +35,14 @@ func main() {
commands.Register("login", handlers.LoginHandler) commands.Register("login", handlers.LoginHandler)
commands.Register("register", handlers.RegisterHandler) commands.Register("register", handlers.RegisterHandler)
commands.Register("reset", handlers.ResetUsersHandler) commands.Register("reset", handlers.ResetUsersHandler)
commands.Register("users", handlers.GetUsersHandler)
commands.Register("agg", handlers.FetchFeedHandler)
commands.Register("addfeed", middlewareLoggedIn(handlers.AddFeedHandler))
commands.Register("feeds", handlers.ListFeedsHandler)
commands.Register("follow", middlewareLoggedIn(handlers.FollowFeedHandler))
commands.Register("unfollow", middlewareLoggedIn(handlers.UnfollowFeedHandler))
commands.Register("following", middlewareLoggedIn(handlers.GetFeedFollowsByUserHandler))
commands.Register("browse", middlewareLoggedIn(handlers.BrowseHandler))
if len(os.Args) < 2 { if len(os.Args) < 2 {
log.Fatal("usage: boot-dev-blog-aggregator <command> [args...]") log.Fatal("usage: boot-dev-blog-aggregator <command> [args...]")
@@ -48,3 +57,13 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
} }
func middlewareLoggedIn(handler func(s *config.State, cmd config.Command, user database.User) error) func(*config.State, config.Command) error {
return func(state *config.State, command config.Command) error {
user, err := state.DB.GetUser(context.Background(), state.Config.CurrentUserName)
if err != nil {
log.Fatal("user not logged in")
}
return handler(state, command, user)
}
}

View File

@@ -0,0 +1,11 @@
-- name: CreateFeed :one
INSERT INTO feeds (id, created_at, updated_at, user_id, url, name)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6
)
RETURNING *;

View File

@@ -0,0 +1,20 @@
-- name: CreateFeedFollow :one
WITH inserted_feed_follow AS (
INSERT INTO feed_follows (id, created_at, updated_at, user_id, feed_id)
VALUES (
$1,
$2,
$3,
$4,
$5
)
RETURNING *
) SELECT
inserted_feed_follow.*,
feeds.name AS feed_name,
users.name AS user_name
FROM inserted_feed_follow
INNER JOIN users ON users.id = inserted_feed_follow.user_id
INNER JOIN feeds ON feeds.id = inserted_feed_follow.feed_id;

View File

@@ -0,0 +1,22 @@
-- name: CreatePost :one
INSERT INTO posts (id, created_at, updated_at, url, title, description, feed_id, published_at)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8
) ON CONFLICT (url) DO UPDATE
SET
id = $1,
created_at = $2,
updated_at = $3,
title = $5,
description = $6,
feed_id = $7,
published_at = $8
WHERE posts.url = $4
RETURNING *;

View File

@@ -0,0 +1,2 @@
-- name: DeleteFeedFollow :exec
DELETE FROM feed_follows WHERE feed_follows.user_id = $1 AND feed_follows.feed_id = (SELECT id FROM feeds WHERE url = $2);

View File

@@ -0,0 +1,2 @@
-- name: GetFeedByUrl :one
SELECT * FROM feeds WHERE url = $1;

View File

@@ -0,0 +1,9 @@
-- name: GetFeedFollowsByUser :many
SELECT
feed_follows.*,
feeds.name AS feed_name,
users.name AS user_name
FROM feed_follows
INNER JOIN users ON users.id = feed_follows.user_id
INNER JOIN feeds ON feeds.id = feed_follows.feed_id
WHERE feed_follows.user_id = $1;

View File

@@ -0,0 +1,2 @@
-- name: GetNextFeedToFetch :one
SELECT * FROM feeds ORDER BY last_fetched_at NULLS FIRST LIMIT 1;

View File

@@ -0,0 +1,3 @@
-- name: GetPostsForUser :many
SELECT * FROM posts WHERE feed_id IN (SELECT feed_follows.feed_id FROM feed_follows WHERE feed_follows.user_id = $1)
ORDER BY published_at DESC LIMIT $2;

2
sql/queries/getUsers.sql Normal file
View File

@@ -0,0 +1,2 @@
-- name: GetUsers :many
SELECT * FROM users WHERE true;

View File

@@ -0,0 +1,2 @@
-- name: ListFeeds :many
SELECT name, url, (SELECT name FROM users WHERE users.id = feeds.user_id) as user_name FROM feeds;

View File

@@ -0,0 +1,3 @@
-- name: MarkFeedFetched :one
UPDATE feeds SET last_fetched_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = $1
RETURNING *;

12
sql/schema/0002_feed.sql Normal file
View File

@@ -0,0 +1,12 @@
-- +goose Up
CREATE TABLE feeds(
id UUID PRIMARY KEY,
created_at TIMESTAMP not null DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP not null,
name VARCHAR(128) not null,
url VARCHAR(512) not null UNIQUE,
user_id UUID not null REFERENCES users(id) ON DELETE CASCADE
);
-- +goose Down
DROP TABLE feeds;

View File

@@ -0,0 +1,12 @@
-- +goose Up
CREATE TABLE feed_follows(
id UUID PRIMARY KEY,
created_at TIMESTAMP not null DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP not null,
user_id UUID not null REFERENCES users(id) ON DELETE CASCADE,
feed_id UUID not null REFERENCES feeds(id) ON DELETE CASCADE,
CONSTRAINT UC_User_Feed UNIQUE(user_id, feed_id)
);
-- +goose Down
DROP TABLE feeds;

View File

@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE feeds ADD COLUMN last_fetched_at timestamp DEFAULT null;
-- +goose Down
ALTER TABLE feeds DROP COLUMN last_fetched_at;

14
sql/schema/0005_posts.sql Normal file
View File

@@ -0,0 +1,14 @@
-- +goose Up
CREATE TABLE posts(
id UUID PRIMARY KEY,
created_at TIMESTAMP not null DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP not null,
title VARCHAR(128) not null,
url VARCHAR(512) not null UNIQUE,
description TEXT not null,
published_at TIMESTAMP not null ,
feed_id UUID not null REFERENCES feeds(id) ON DELETE CASCADE
);
-- +goose Down
DROP TABLE posts;