diff --git a/.gitignore b/.gitignore index e200850..5a56409 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /__build/* !__build/.gitkeep +/.environment diff --git a/.golangci.yaml b/.golangci.yaml index 059f5bc..7afe920 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -13,6 +13,15 @@ output: sort-results: true linters-settings: + depguard: + rules: + main: + files: + - $all + allow: + - $gostd + - codeflow.dananglin.me.uk/apollo/gator + - github.com/google/uuid lll: line-length: 140 diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..ae71b86 --- /dev/null +++ b/commands.go @@ -0,0 +1,454 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strconv" + "time" + + "codeflow.dananglin.me.uk/apollo/gator/internal/database" + "codeflow.dananglin.me.uk/apollo/gator/internal/rss" + "github.com/google/uuid" + "github.com/lib/pq" +) + +type commands struct { + commandMap map[string]commandFunc +} + +type commandFunc func(*state, command) error + +type command struct { + name string + args []string +} + +func (c *commands) register(name string, f commandFunc) { + c.commandMap[name] = f +} + +func (c *commands) run(s *state, cmd command) error { + runFunc, ok := c.commandMap[cmd.name] + if !ok { + return fmt.Errorf("unrecognised command: %s", cmd.name) + } + + return runFunc(s, cmd) +} + +func handlerLogin(s *state, cmd command) error { + if len(cmd.args) != 1 { + return fmt.Errorf("unexpected number of arguments: want 1, got %d", len(cmd.args)) + } + + username := cmd.args[0] + + user, err := s.db.GetUserByName(context.Background(), username) + if err != nil { + return fmt.Errorf("unable to get the user from the database: %w", err) + } + + if err := s.config.SetUser(user.Name); err != nil { + return fmt.Errorf("login error: %w", err) + } + + fmt.Printf("The current user is set to %q.\n", username) + + return nil +} + +func handlerRegister(s *state, cmd command) error { + if len(cmd.args) != 1 { + return fmt.Errorf("unexpected number of arguments: want 1, got %d", len(cmd.args)) + } + + name := cmd.args[0] + + timestamp := time.Now() + + args := database.CreateUserParams{ + ID: uuid.New(), + CreatedAt: timestamp, + UpdatedAt: timestamp, + Name: name, + } + + user, err := s.db.CreateUser(context.Background(), args) + if err != nil { + if uniqueViolation(err) { + return errors.New("this user is already registered") + } + + return fmt.Errorf("unable to register the user: %w", err) + } + + if err := s.config.SetUser(name); err != nil { + return fmt.Errorf("unable to update the configuration: %w", err) + } + + fmt.Printf("Successfully registered %s.\n", user.Name) + fmt.Println("DEBUG:", user) + + return nil +} + +func handlerReset(s *state, _ command) error { + if err := s.db.DeleteAllUsers(context.Background()); err != nil { + fmt.Errorf("unable to delete the users from the database: %w", err) + } + + fmt.Println("Successfully removed all users from the database.") + + return nil +} + +func handlerUsers(s *state, _ command) error { + users, err := s.db.GetAllUsers(context.Background()) + if err != nil { + fmt.Errorf("unable to get the users from the database: %w", err) + } + + if len(users) == 0 { + fmt.Println("There are no registered users.") + + return nil + } + + fmt.Printf("Registered users:\n\n") + + for _, user := range users { + if user.Name == s.config.CurrentUsername { + fmt.Printf("- %s (current)\n", user.Name) + } else { + fmt.Printf("- %s\n", user.Name) + } + } + + return nil +} + +func handlerAgg(s *state, cmd command) error { + if len(cmd.args) != 1 { + return fmt.Errorf("unexpected number of arguments: want 1, got %d", len(cmd.args)) + } + + intervalArg := cmd.args[0] + + interval, err := time.ParseDuration(intervalArg) + if err != nil { + return fmt.Errorf("unable to parse the interval: %w", err) + } + + fmt.Printf("Fetching feeds every %s\n", interval.String()) + + tick := time.Tick(interval) + + for range tick { + if err := scrapeFeeds(s); err != nil { + fmt.Println("ERROR: %v", err) + } + } + + return nil +} + +func handlerAddFeed(s *state, cmd command, user database.User) error { + if len(cmd.args) != 2 { + return fmt.Errorf("unexpected number of arguments: want 2, got %d", len(cmd.args)) + } + + name, url := cmd.args[0], cmd.args[1] + + timestamp := time.Now() + + createdFeedArgs := database.CreateFeedParams{ + ID: uuid.New(), + CreatedAt: timestamp, + UpdatedAt: timestamp, + Name: name, + Url: url, + UserID: user.ID, + } + + feed, err := s.db.CreateFeed(context.Background(), createdFeedArgs) + if err != nil { + return fmt.Errorf("unable to add the feed: %w", err) + } + + fmt.Println("Successfully added the feed.") + + fmt.Println("DEBUG:", feed) + + createFeedFollowArgs := database.CreateFeedFollowParams{ + ID: uuid.New(), + CreatedAt: timestamp, + UpdatedAt: timestamp, + UserID: user.ID, + FeedID: feed.ID, + } + + followRecord, err := s.db.CreateFeedFollow(context.Background(), createFeedFollowArgs) + if err != nil { + return fmt.Errorf("unable to create the feed follow record in the database: %w", err) + } + + fmt.Printf("You are now following the feed %q.\n", followRecord.FeedName) + fmt.Println("DEBUG:", followRecord) + + return nil +} + +func handlerFeeds(s *state, _ command) error { + feeds, err := s.db.GetAllFeeds(context.Background()) + if err != nil { + return fmt.Errorf("unable to get the feeds from the database: %w", err) + } + + fmt.Printf("Feeds:\n\n") + + for _, feed := range feeds { + user, err := s.db.GetUserByID(context.Background(), feed.UserID) + if err != nil { + return fmt.Errorf( + "unable to get the creator of %s: %w", + feed.Name, + err, + ) + } + + fmt.Printf( + "- Name: %s\n URL: %s\n Created by: %s\n", + feed.Name, + feed.Url, + user.Name, + ) + } + + return nil +} + +func handlerFollow(s *state, cmd command, user database.User) error { + if len(cmd.args) != 1 { + return fmt.Errorf("unexpected number of arguments: want 2, got %d", len(cmd.args)) + } + + url := cmd.args[0] + + feed, err := s.db.GetFeedByUrl(context.Background(), url) + if err != nil { + return fmt.Errorf("unable to get the feed data from the database: %w", err) + } + + timestamp := time.Now() + + args := database.CreateFeedFollowParams{ + ID: uuid.New(), + CreatedAt: timestamp, + UpdatedAt: timestamp, + UserID: user.ID, + FeedID: feed.ID, + } + + followRecord, err := s.db.CreateFeedFollow(context.Background(), args) + if err != nil { + if uniqueViolation(err) { + return errors.New("you are already following this feed") + } + + return fmt.Errorf("unable to create the feed follow record in the database: %w", err) + } + + fmt.Printf("You are now following the feed %q.\n", followRecord.FeedName) + fmt.Println("DEBUG:", followRecord) + + return nil +} + +func handlerFollowing(s *state, _ command, user database.User) error { + following, err := s.db.GetFeedFollowsForUser(context.Background(), user.ID) + if err != nil { + return fmt.Errorf("unable to get the list of feeds from the database: %w", err) + } + + if len(following) == 0 { + fmt.Println("You are not following any feeds.") + + return nil + } + + fmt.Printf("\nYou are following:\n\n") + + for _, feed := range following { + fmt.Printf("- %s\n", feed) + } + + return nil +} + +func handlerUnfollow(s *state, cmd command, user database.User) error { + if len(cmd.args) != 1 { + return fmt.Errorf("unexpected number of arguments: want 2, got %d", len(cmd.args)) + } + + url := cmd.args[0] + + feed, err := s.db.GetFeedByUrl(context.Background(), url) + if err != nil { + return fmt.Errorf("unable to get the feed data from the database: %w", err) + } + + args := database.DeleteFeedFollowParams{ + UserID: user.ID, + FeedID: feed.ID, + } + + if err := s.db.DeleteFeedFollow(context.Background(), args); err != nil { + return fmt.Errorf("unable to delete the feed follow record from the database: %w", err) + } + + fmt.Printf("You have successfully unfollowed %q.\n", feed.Name) + + return nil +} + +func handlerBrowse(s *state, cmd command, user database.User) error { + if len(cmd.args) > 1 { + return fmt.Errorf("unexpected number of arguments: want 0 or 1, got %d", len(cmd.args)) + } + + var err error + + limit := 2 + + if len(cmd.args) == 1 { + limit, err = strconv.Atoi(cmd.args[0]) + if err != nil { + return fmt.Errorf("unable to convert %s to a number: %w", cmd.args[0], err) + } + } + + args := database.GetPostsForUserParams{ + UserID: user.ID, + Limit: int32(limit), + } + + posts, err := s.db.GetPostsForUser(context.Background(), args) + if err != nil { + return fmt.Errorf("unable to get the posts: %w", err) + } + + fmt.Printf("\nPosts:\n\n") + + for _, post := range posts { + fmt.Printf( + "- Title: %s\n URL: %s\n Published at: %s\n", + post.Title, + post.Url, + post.PublishedAt, + ) + } + + return nil +} + +func scrapeFeeds(s *state) error { + feed, err := s.db.GetNextFeedToFetch(context.Background()) + if err != nil { + return fmt.Errorf("unable to get the next feed from the database: %w", err) + } + + fmt.Printf("\nFetching feed from %s\n", feed.Url) + + feedDetails, err := rss.FetchFeed(context.Background(), feed.Url) + if err != nil { + return fmt.Errorf("unable to fetch the feed: %w", err) + } + + timestamp := time.Now() + + lastFetched := sql.NullTime{ + Time: timestamp, + Valid: true, + } + + markFeedFetchedArgs := database.MarkFeedFetchedParams{ + ID: feed.ID, + LastFetchedAt: lastFetched, + UpdatedAt: timestamp, + } + + if err := s.db.MarkFeedFetched(context.Background(), markFeedFetchedArgs); err != nil { + return fmt.Errorf("unable to mark the feed as fetched in the database: %w", err) + } + + timeParsingFormats := []string{ + time.RFC1123Z, + time.RFC1123, + } + + for _, item := range feedDetails.Channel.Items { + var ( + pubDate time.Time + err error + ) + + pubDateFormatted := false + + for _, format := range timeParsingFormats { + pubDate, err = time.Parse(format, item.PubDate) + if err == nil { + pubDateFormatted = true + + break + } + } + + if !pubDateFormatted { + fmt.Printf( + "Error: unable to format the publication date (%s) of %q.\n", + item.PubDate, + item.Title, + ) + + continue + } + + timestamp := time.Now() + + args := database.CreatePostParams{ + ID: uuid.New(), + CreatedAt: timestamp, + UpdatedAt: timestamp, + Title: item.Title, + Url: item.Link, + Description: item.Description, + FeedID: feed.ID, + PublishedAt: pubDate, + } + + _, err = s.db.CreatePost(context.Background(), args) + if err != nil && !uniqueViolation(err) { + fmt.Printf( + "Error: unable to add the post %q to the database: %v.\n", + item.Title, + err, + ) + } + } + + return nil +} + +func uniqueViolation(err error) bool { + var pqError *pq.Error + + if errors.As(err, &pqError) { + if pqError.Code.Name() == "unique_violation" { + return true + } + } + + return false +} diff --git a/go.mod b/go.mod index 65ae29e..588f3b0 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,8 @@ module codeflow.dananglin.me.uk/apollo/gator go 1.23.1 + +require ( + github.com/google/uuid v1.6.0 + github.com/lib/pq v1.10.9 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ae20c4c --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8def918 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,76 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type Config struct { + CurrentUsername string `json:"currentUsername"` + DBConfig DBConfig `json:"database"` +} + +type DBConfig struct { + URL string `json:"url"` +} + +func NewConfig() (Config, error) { + path, err := configFilePath() + if err != nil { + return Config{}, fmt.Errorf("unable to get the path to the configuration file: %w", err) + } + + data, err := os.ReadFile(path) + if err != nil { + return Config{}, fmt.Errorf("unable to read %s: %w", path, err) + } + + var cfg Config + + if err := json.Unmarshal(data, &cfg); err != nil { + return Config{}, fmt.Errorf("unable to decode the JSON data: %w", err) + } + + return cfg, nil +} + +func (c *Config) SetUser(user string) error { + c.CurrentUsername = user + + return write(*c) +} + +func configFilePath() (string, error) { + userConfigDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("unable to get the user's home config directory: %w", err) + } + + path := filepath.Join(userConfigDir, "gator", "config.json") + + return path, nil +} + +func write(cfg Config) error { + path, err := configFilePath() + if err != nil { + return fmt.Errorf("unable to get the path to the configuration file: %w", err) + } + + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("unable to create %s: %w", path, err) + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + + if err := encoder.Encode(cfg); err != nil { + return fmt.Errorf("unable to save the config to file: %w", err) + } + + return nil +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..dacb52e --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package database + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/database/feed_follows.sql.go b/internal/database/feed_follows.sql.go new file mode 100644 index 0000000..2e35729 --- /dev/null +++ b/internal/database/feed_follows.sql.go @@ -0,0 +1,121 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: feed_follows.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 +} + +const deleteFeedFollow = `-- name: DeleteFeedFollow :exec +DELETE FROM feed_follows +WHERE user_id = $1 AND feed_id = $2 +` + +type DeleteFeedFollowParams struct { + UserID uuid.UUID + FeedID uuid.UUID +} + +func (q *Queries) DeleteFeedFollow(ctx context.Context, arg DeleteFeedFollowParams) error { + _, err := q.db.ExecContext(ctx, deleteFeedFollow, arg.UserID, arg.FeedID) + return err +} + +const getFeedFollowsForUser = `-- name: GetFeedFollowsForUser :many +SELECT feeds.name as feeds_name +FROM feed_follows +INNER JOIN feeds ON feed_follows.feed_id = feeds.id +WHERE feed_follows.user_id = $1 +` + +func (q *Queries) GetFeedFollowsForUser(ctx context.Context, userID uuid.UUID) ([]string, error) { + rows, err := q.db.QueryContext(ctx, getFeedFollowsForUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var feeds_name string + if err := rows.Scan(&feeds_name); err != nil { + return nil, err + } + items = append(items, feeds_name) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/database/feeds.sql.go b/internal/database/feeds.sql.go new file mode 100644 index 0000000..070ef55 --- /dev/null +++ b/internal/database/feeds.sql.go @@ -0,0 +1,160 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: feeds.sql + +package database + +import ( + "context" + "database/sql" + "time" + + "github.com/google/uuid" +) + +const createFeed = `-- name: CreateFeed :one +INSERT INTO feeds( + id, + created_at, + updated_at, + name, + url, + user_id +) +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 + Name string + Url string + UserID uuid.UUID +} + +func (q *Queries) CreateFeed(ctx context.Context, arg CreateFeedParams) (Feed, error) { + row := q.db.QueryRowContext(ctx, createFeed, + arg.ID, + arg.CreatedAt, + arg.UpdatedAt, + arg.Name, + arg.Url, + arg.UserID, + ) + var i Feed + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Name, + &i.Url, + &i.UserID, + &i.LastFetchedAt, + ) + return i, err +} + +const getAllFeeds = `-- name: GetAllFeeds :many +SELECT id, created_at, updated_at, name, url, user_id, last_fetched_at + FROM feeds +` + +func (q *Queries) GetAllFeeds(ctx context.Context) ([]Feed, error) { + rows, err := q.db.QueryContext(ctx, getAllFeeds) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Feed + for rows.Next() { + var i Feed + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Name, + &i.Url, + &i.UserID, + &i.LastFetchedAt, + ); 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 +} + +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 +} + +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 ASC 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 +} + +const markFeedFetched = `-- name: MarkFeedFetched :exec +UPDATE feeds + SET last_fetched_at = $2, updated_at = $3 + WHERE id = $1 +` + +type MarkFeedFetchedParams struct { + ID uuid.UUID + LastFetchedAt sql.NullTime + UpdatedAt time.Time +} + +func (q *Queries) MarkFeedFetched(ctx context.Context, arg MarkFeedFetchedParams) error { + _, err := q.db.ExecContext(ctx, markFeedFetched, arg.ID, arg.LastFetchedAt, arg.UpdatedAt) + return err +} diff --git a/internal/database/models.go b/internal/database/models.go new file mode 100644 index 0000000..2f48911 --- /dev/null +++ b/internal/database/models.go @@ -0,0 +1,48 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package database + +import ( + "database/sql" + "time" + + "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 { + ID uuid.UUID + CreatedAt time.Time + UpdatedAt time.Time + Name string +} diff --git a/internal/database/posts.sql.go b/internal/database/posts.sql.go new file mode 100644 index 0000000..82d0914 --- /dev/null +++ b/internal/database/posts.sql.go @@ -0,0 +1,119 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: posts.sql + +package database + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +const createPost = `-- name: CreatePost :one +INSERT INTO posts ( + id, + created_at, + updated_at, + title, + url, + description, + published_at, + feed_id +) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8 +) +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 + Title string + Url string + Description string + PublishedAt time.Time + FeedID uuid.UUID +} + +func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) (Post, error) { + row := q.db.QueryRowContext(ctx, createPost, + arg.ID, + arg.CreatedAt, + arg.UpdatedAt, + arg.Title, + arg.Url, + arg.Description, + arg.PublishedAt, + arg.FeedID, + ) + 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 +} + +const getPostsForUser = `-- name: GetPostsForUser :many +SELECT title, url, published_at + FROM posts + WHERE feed_id IN ( + SELECT feed_id + FROM feed_follows + WHERE user_id = $1 + ) + ORDER BY published_at DESC + LIMIT $2 +` + +type GetPostsForUserParams struct { + UserID uuid.UUID + Limit int32 +} + +type GetPostsForUserRow struct { + Title string + Url string + PublishedAt time.Time +} + +func (q *Queries) GetPostsForUser(ctx context.Context, arg GetPostsForUserParams) ([]GetPostsForUserRow, error) { + rows, err := q.db.QueryContext(ctx, getPostsForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetPostsForUserRow + for rows.Next() { + var i GetPostsForUserRow + if err := rows.Scan(&i.Title, &i.Url, &i.PublishedAt); 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 +} diff --git a/internal/database/users.sql.go b/internal/database/users.sql.go new file mode 100644 index 0000000..c3b6d6a --- /dev/null +++ b/internal/database/users.sql.go @@ -0,0 +1,126 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: users.sql + +package database + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +const createUser = `-- name: CreateUser :one +INSERT INTO users (id, created_at, updated_at, name) +VALUES ( + $1, + $2, + $3, + $4 +) +RETURNING id, created_at, updated_at, name +` + +type CreateUserParams struct { + ID uuid.UUID + CreatedAt time.Time + UpdatedAt time.Time + Name string +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRowContext(ctx, createUser, + arg.ID, + arg.CreatedAt, + arg.UpdatedAt, + arg.Name, + ) + var i User + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Name, + ) + return i, err +} + +const deleteAllUsers = `-- name: DeleteAllUsers :exec +DELETE FROM users +` + +func (q *Queries) DeleteAllUsers(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllUsers) + return err +} + +const getAllUsers = `-- name: GetAllUsers :many +SELECT id, created_at, updated_at, name + FROM users +` + +func (q *Queries) GetAllUsers(ctx context.Context) ([]User, error) { + rows, err := q.db.QueryContext(ctx, getAllUsers) + 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 +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, created_at, updated_at, name + FROM users + WHERE id = $1 +` + +func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByID, id) + var i User + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Name, + ) + return i, err +} + +const getUserByName = `-- name: GetUserByName :one +SELECT id, created_at, updated_at, name + FROM users + WHERE name = $1 +` + +func (q *Queries) GetUserByName(ctx context.Context, name string) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByName, name) + var i User + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Name, + ) + return i, err +} diff --git a/internal/rss/rss.go b/internal/rss/rss.go new file mode 100644 index 0000000..3ed4d71 --- /dev/null +++ b/internal/rss/rss.go @@ -0,0 +1,81 @@ +package rss + +import ( + "context" + "encoding/xml" + "fmt" + "html" + "io" + "net/http" +) + +type Feed struct { + Channel Channel `xml:"channel"` +} + +type Channel struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + Items []Item `xml:"item"` +} + +type Item struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + PubDate string `xml:"pubDate"` +} + +func FetchFeed(ctx context.Context, url string) (*Feed, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("received an error creating the HTTP request: %w", err) + } + + request.Header.Set("User-Agent", "Gator/0.0.0") + + client := http.Client{} + + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("error getting the response from the server: %w", err) + } + defer response.Body.Close() + + if response.StatusCode >= 400 { + return nil, fmt.Errorf( + "received a bad status from %s: (%d) %s", + url, + response.StatusCode, + response.Status, + ) + } + + data, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf( + "unable to read the response from the server: %w", + err, + ) + } + + var feed Feed + + if err := xml.Unmarshal(data, &feed); err != nil { + return nil, fmt.Errorf( + "unable to decode the XML data: %w", + err, + ) + } + + feed.Channel.Title = html.UnescapeString(feed.Channel.Title) + feed.Channel.Description = html.UnescapeString(feed.Channel.Description) + + for _, item := range feed.Channel.Items { + item.Title = html.UnescapeString(item.Title) + item.Description = html.UnescapeString(item.Description) + } + + return &feed, nil +} diff --git a/magefiles/mage.go b/magefiles/mage.go index c875bd6..7ec5c9c 100644 --- a/magefiles/mage.go +++ b/magefiles/mage.go @@ -56,7 +56,6 @@ func Lint() error { // To rebuild packages that are already up-to-date set GATOR_BUILD_REBUILD_ALL=1 // To enable verbose mode set GATOR_BUILD_VERBOSE=1 func Build() error { - main := "main.go" flags := ldflags() build := sh.RunCmd("go", "build") args := []string{"-ldflags=" + flags, "-o", binary} @@ -69,7 +68,7 @@ func Build() error { args = append(args, "-v") } - args = append(args, main) + args = append(args, ".") return build(args...) } diff --git a/main.go b/main.go index a3255e8..a86c3cc 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,21 @@ package main import ( + "database/sql" + "errors" "fmt" "os" + + "codeflow.dananglin.me.uk/apollo/gator/internal/config" + "codeflow.dananglin.me.uk/apollo/gator/internal/database" + _ "github.com/lib/pq" ) +type state struct { + db *database.Queries + config *config.Config +} + var ( binaryVersion string buildTime string @@ -20,5 +31,59 @@ func main() { } func run() error { - return nil + cfg, err := config.NewConfig() + if err != nil { + return fmt.Errorf("unable to load the configuration: %w", err) + } + + db, err := sql.Open("postgres", cfg.DBConfig.URL) + if err != nil { + return fmt.Errorf("unable to open a connection to the database: %w", err) + } + + s := state{ + db: database.New(db), + config: &cfg, + } + + cmds := commands{ + commandMap: make(map[string]commandFunc), + } + + cmds.register("login", handlerLogin) + cmds.register("register", handlerRegister) + cmds.register("reset", handlerReset) + cmds.register("users", handlerUsers) + cmds.register("agg", handlerAgg) + cmds.register("addfeed", middlewareLoggedIn(handlerAddFeed)) + cmds.register("feeds", handlerFeeds) + cmds.register("follow", middlewareLoggedIn(handlerFollow)) + cmds.register("unfollow", middlewareLoggedIn(handlerUnfollow)) + cmds.register("following", middlewareLoggedIn(handlerFollowing)) + cmds.register("browse", middlewareLoggedIn(handlerBrowse)) + + cmd, err := parseArgs(os.Args[1:]) + if err != nil { + return fmt.Errorf("unable to parse the command: %w", err) + } + + return cmds.run(&s, cmd) +} + +func parseArgs(args []string) (command, error) { + if len(args) == 0 { + return command{}, errors.New("no arguments given") + } + + if len(args) == 1 { + return command{ + name: args[0], + args: make([]string, 0), + }, nil + } + + return command{ + name: args[0], + args: args[1:], + }, nil } diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..407cf12 --- /dev/null +++ b/middleware.go @@ -0,0 +1,19 @@ +package main + +import ( + "context" + "fmt" + + "codeflow.dananglin.me.uk/apollo/gator/internal/database" +) + +func middlewareLoggedIn(handler func(s *state, cmd command, user database.User) error) commandFunc { + return func(s *state, cmd command) error { + user, err := s.db.GetUserByName(context.Background(), s.config.CurrentUsername) + if err != nil { + return fmt.Errorf("unable to get the user from the database: %w", err) + } + + return handler(s, cmd, user) + } +} diff --git a/sql/queries/feed_follows.sql b/sql/queries/feed_follows.sql new file mode 100644 index 0000000..070b53a --- /dev/null +++ b/sql/queries/feed_follows.sql @@ -0,0 +1,32 @@ +-- 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; + +-- name: GetFeedFollowsForUser :many +SELECT feeds.name as feeds_name +FROM feed_follows +INNER JOIN feeds ON feed_follows.feed_id = feeds.id +WHERE feed_follows.user_id = $1; + +-- name: DeleteFeedFollow :exec +DELETE FROM feed_follows +WHERE user_id = $1 AND feed_id = $2; diff --git a/sql/queries/feeds.sql b/sql/queries/feeds.sql new file mode 100644 index 0000000..97038c5 --- /dev/null +++ b/sql/queries/feeds.sql @@ -0,0 +1,37 @@ +-- name: CreateFeed :one +INSERT INTO feeds( + id, + created_at, + updated_at, + name, + url, + user_id +) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6 +) +RETURNING *; + +-- name: GetAllFeeds :many +SELECT * + FROM feeds; + +-- name: GetFeedByUrl :one +SELECT * + FROM feeds + WHERE url = $1; + +-- name: MarkFeedFetched :exec +UPDATE feeds + SET last_fetched_at = $2, updated_at = $3 + WHERE id = $1; + +-- name: GetNextFeedToFetch :one +SELECT * + FROM feeds + ORDER BY last_fetched_at ASC NULLS FIRST LIMIT 1; diff --git a/sql/queries/posts.sql b/sql/queries/posts.sql new file mode 100644 index 0000000..0959d4d --- /dev/null +++ b/sql/queries/posts.sql @@ -0,0 +1,33 @@ +-- name: CreatePost :one +INSERT INTO posts ( + id, + created_at, + updated_at, + title, + url, + description, + published_at, + feed_id +) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8 +) +RETURNING *; + +-- name: GetPostsForUser :many +SELECT title, url, published_at + FROM posts + WHERE feed_id IN ( + SELECT feed_id + FROM feed_follows + WHERE user_id = $1 + ) + ORDER BY published_at DESC + LIMIT $2; diff --git a/sql/queries/users.sql b/sql/queries/users.sql new file mode 100644 index 0000000..5ba2f3a --- /dev/null +++ b/sql/queries/users.sql @@ -0,0 +1,26 @@ +-- name: CreateUser :one +INSERT INTO users (id, created_at, updated_at, name) +VALUES ( + $1, + $2, + $3, + $4 +) +RETURNING *; + +-- name: GetUserByName :one +SELECT * + FROM users + WHERE name = $1; + +-- name: GetUserByID :one +SELECT * + FROM users + WHERE id = $1; + +-- name: GetAllUsers :many +SELECT * + FROM users; + +-- name: DeleteAllUsers :exec +DELETE FROM users; diff --git a/sql/schema/001_users.sql b/sql/schema/001_users.sql new file mode 100644 index 0000000..1ad5d17 --- /dev/null +++ b/sql/schema/001_users.sql @@ -0,0 +1,10 @@ +-- +goose Up +CREATE TABLE users ( + id UUID PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + name varchar(255) NOT NULL UNIQUE +); + +-- +goose Down +DROP TABLE users; diff --git a/sql/schema/002_feeds.sql b/sql/schema/002_feeds.sql new file mode 100644 index 0000000..6fe2f46 --- /dev/null +++ b/sql/schema/002_feeds.sql @@ -0,0 +1,13 @@ +-- +goose Up +CREATE TABLE feeds ( + id UUID PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + name varchar(255) NOT NULL, + url varchar(255) NOT NULL UNIQUE, + user_id UUID NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- +goose Down +DROP TABLE feeds; diff --git a/sql/schema/003_feed_follows.sql b/sql/schema/003_feed_follows.sql new file mode 100644 index 0000000..df36c73 --- /dev/null +++ b/sql/schema/003_feed_follows.sql @@ -0,0 +1,14 @@ +-- +goose Up +CREATE TABLE feed_follows ( + id UUID PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + user_id UUID NOT NULL, + feed_id UUID NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (feed_id) REFERENCES feeds(id) ON DELETE CASCADE, + UNIQUE(user_id, feed_id) +); + +-- +goose Down +DROP TABLE feed_follows; diff --git a/sql/schema/004_add_last_fetched_at_to_feeds.sql b/sql/schema/004_add_last_fetched_at_to_feeds.sql new file mode 100644 index 0000000..6a38099 --- /dev/null +++ b/sql/schema/004_add_last_fetched_at_to_feeds.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE feeds ADD COLUMN last_fetched_at TIMESTAMP; + +-- +goose Down +ALTER TABLE feeds DROP COLUMN last_fetched_at; diff --git a/sql/schema/005_add_posts_table.sql b/sql/schema/005_add_posts_table.sql new file mode 100644 index 0000000..fd286b0 --- /dev/null +++ b/sql/schema/005_add_posts_table.sql @@ -0,0 +1,15 @@ +-- +goose Up +CREATE TABLE posts ( + id UUID PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + title TEXT NOT NULL DEFAULT 'Undefined', + url varchar(255) NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT 'Undefined', + published_at TIMESTAMP NOT NULL, + feed_id UUID NOT NULL, + FOREIGN KEY (feed_id) REFERENCES feeds(id) ON DELETE CASCADE +); + +-- +goose Down +DROP TABLE posts; diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..7fd9709 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,9 @@ +--- +version: "2" +sql: +- schema: "sql/schema" + queries: "sql/queries" + engine: "postgresql" + gen: + go: + out: "internal/database"