diff --git a/go.mod b/go.mod index 6dd3d99..9c3288d 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,8 @@ module github.com/StevanFreeborn/gator go 1.26.3 + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/lib/pq v1.12.3 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9e3584b --- /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.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= diff --git a/internal/command/command.go b/internal/command/command.go index 318b57f..b1ad5b2 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -1,9 +1,18 @@ package command import ( + "cmp" + "context" "fmt" + "net/url" + "slices" + "strings" + "time" + "github.com/StevanFreeborn/gator/internal/database" + "github.com/StevanFreeborn/gator/internal/rss" "github.com/StevanFreeborn/gator/internal/state" + "github.com/google/uuid" ) type Command struct { @@ -36,6 +45,12 @@ func NewRegistry() *CommandRegistry { commands := []*Command{ loginCommand(), + registerCommand(), + resetCommand(), + usersCommand(), + aggCommand(), + addFeedCommand(), + feedsCommand(), } for _, cmd := range commands { @@ -63,7 +78,13 @@ func loginCommand() *Command { username := s.Arguments[0] - err := s.Config.SetUser(username) + _, err := s.Database.GetUserByName(context.Background(), username) + + if err != nil { + return fmt.Errorf("Failed to login") + } + + err = s.Config.SetUser(username) if err != nil { return err @@ -74,3 +95,174 @@ func loginCommand() *Command { return nil }) } + +func registerCommand() *Command { + return newCommand("register", func(s *state.State) error { + if len(s.Arguments) == 0 { + return fmt.Errorf("Did not receive expected username argument") + } + + createUserParams := database.CreateUserParams{ + ID: uuid.New(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Name: s.Arguments[0], + } + + createdUser, err := s.Database.CreateUser(context.Background(), createUserParams) + + if err != nil { + return err + } + + s.Config.SetUser(createdUser.Name) + + fmt.Printf("Successfully registered user '%s':\n", createdUser.Name) + fmt.Printf(" Id => %s\n", createdUser.ID) + fmt.Printf(" CreatedAt => %s\n", createdUser.CreatedAt) + fmt.Printf(" UpdatedAt => %s\n", createdUser.UpdatedAt) + fmt.Printf("Current user set to user '%s':\n", createdUser.Name) + + return nil + }) +} + +func resetCommand() *Command { + return newCommand("reset", func(s *state.State) error { + err := s.Database.DeleteAllUsers(context.Background()) + + if err != nil { + return err + } + + fmt.Println("Successfully delete all users") + + return nil + }) +} + +func usersCommand() *Command { + return newCommand("users", func(s *state.State) error { + users, err := s.Database.GetAllUsers(context.Background()) + + if err != nil { + return err + } + + slices.SortFunc(users, func(a, b database.User) int { + return cmp.Compare(a.Name, b.Name) + }) + + for _, user := range users { + msg := "* %s" + + if user.Name == s.Config.CurrentUserName { + msg += " (current)" + } + + fmt.Printf(msg+"\n", user.Name) + } + + return nil + }) +} + +func aggCommand() *Command { + return newCommand("agg", func(s *state.State) error { + // if len(s.Arguments) == 0 { + // return fmt.Errorf("Did not receive expected feed argument") + // } + + // feed := s.Arguments[0] + // + // validUrl, err := url.Parse(feed) + // + // if err != nil { + // return fmt.Errorf("Feed '%s' is not a valid url", feed) + // } + + rssFeed, err := rss.FetchFeed(context.Background(), "https://www.wagslane.dev/index.xml") + + if err != nil { + return err + } + + fmt.Printf("%v\n", rssFeed) + + return nil + }) +} + +func addFeedCommand() *Command { + return newCommand("addfeed", func(s *state.State) error { + if len(s.Arguments) < 2 { + return fmt.Errorf("Did not receive expected feed name and url") + } + + feedName := s.Arguments[0] + feedUrl := s.Arguments[1] + + validatedFeedUrl, err := url.Parse(feedUrl) + + if strings.TrimSpace(feedName) == "" { + return fmt.Errorf("Feed name cannot be empty") + } + + if err != nil { + return fmt.Errorf("Feed url '%s' is not a valid url", feedUrl) + } + + currentUser, err := s.GetCurrentUser(context.Background()) + + if err != nil { + return fmt.Errorf("Currently logged in user does not exist. Cannot add feed for non-existent user.") + } + + createFeedParams := database.CreateFeedParams{ + ID: uuid.New(), + UserID: currentUser.ID, + Name: feedName, + Url: validatedFeedUrl.String(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + createdFeed, err := s.Database.CreateFeed(context.Background(), createFeedParams) + + if err != nil { + return err + } + + fmt.Printf("Successfully added feed '%s' with url '%s'\n", createdFeed.Name, createdFeed.Url) + fmt.Printf(" Id => %s\n", createdFeed.ID) + fmt.Printf(" UserId => %s\n", createdFeed.UserID) + fmt.Printf(" CreatedAt => %s\n", createdFeed.CreatedAt) + fmt.Printf(" UpdatedAt => %s\n", createdFeed.UpdatedAt) + + return nil + }) +} + +func feedsCommand() *Command { + return newCommand("feeds", func(s *state.State) error { + feeds, err := s.Database.GetAllFeeds(context.Background()) + + if err != nil { + return err + } + + for _, feed := range feeds { + feedsUserName := "Unknown" + + user, err := s.Database.GetUserById(context.Background(), feed.UserID) + + if err == nil { + feedsUserName = user.Name + } + + fmt.Printf("* %s [%s] (%s)\n", feed.Name, feed.Url, feedsUserName) + } + + return nil + }) +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..9f7a5a6 --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +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/feeds.sql.go b/internal/database/feeds.sql.go new file mode 100644 index 0000000..6ea9520 --- /dev/null +++ b/internal/database/feeds.sql.go @@ -0,0 +1,90 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: feeds.sql + +package database + +import ( + "context" + "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 +` + +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, + ) + return i, err +} + +const getAllFeeds = `-- name: GetAllFeeds :many +SELECT id, created_at, updated_at, name, url, user_id 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, + ); 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/models.go b/internal/database/models.go new file mode 100644 index 0000000..257fe55 --- /dev/null +++ b/internal/database/models.go @@ -0,0 +1,27 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package database + +import ( + "time" + + "github.com/google/uuid" +) + +type Feed struct { + ID uuid.UUID + CreatedAt time.Time + UpdatedAt time.Time + Name string + Url string + UserID uuid.UUID +} + +type User struct { + ID uuid.UUID + CreatedAt time.Time + UpdatedAt time.Time + Name string +} diff --git a/internal/database/users.sql.go b/internal/database/users.sql.go new file mode 100644 index 0000000..35bee9c --- /dev/null +++ b/internal/database/users.sql.go @@ -0,0 +1,123 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// 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..73f9d31 --- /dev/null +++ b/internal/rss/rss.go @@ -0,0 +1,61 @@ +package rss + +import ( + "context" + "encoding/xml" + "html" + "net/http" +) + +type RSSItem struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + PubDate string `xml:"pubDate"` +} + +type RSSChannel struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + Item []RSSItem `xml:"item"` +} + +type RSSFeed struct { + Channel RSSChannel `xml:"channel"` +} + +func FetchFeed(ctx context.Context, feedURL string) (*RSSFeed, error) { + var rssFeed RSSFeed + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil) + req.Header.Set("User-Agent", "gator") + + if err != nil { + return nil, err + } + + res, err := http.DefaultClient.Do(req) + + if err != nil { + return nil, err + } + + defer res.Body.Close() + + err = xml.NewDecoder(res.Body).Decode(&rssFeed) + + if err != nil { + return nil, err + } + + rssFeed.Channel.Title = html.UnescapeString(rssFeed.Channel.Title) + rssFeed.Channel.Description = html.UnescapeString(rssFeed.Channel.Description) + + for _, item := range rssFeed.Channel.Item { + item.Title = html.UnescapeString(item.Title) + item.Description = html.UnescapeString(item.Description) + } + + return &rssFeed, nil +} diff --git a/internal/state/state.go b/internal/state/state.go index d04d9d2..d325e8c 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -1,12 +1,22 @@ package state -import "github.com/StevanFreeborn/gator/internal/config" +import ( + "context" + + "github.com/StevanFreeborn/gator/internal/config" + "github.com/StevanFreeborn/gator/internal/database" +) type State struct { + Database *database.Queries Config *config.Config Arguments []string } -func NewState(c *config.Config, args []string) *State { - return &State{Config: c, Arguments: args} +func NewState(d *database.Queries, c *config.Config, args []string) *State { + return &State{Database: d, Config: c, Arguments: args} +} + +func (s *State) GetCurrentUser(ctx context.Context) (database.User, error) { + return s.Database.GetUserByName(ctx, s.Config.CurrentUserName) } diff --git a/main.go b/main.go index 19eb4a9..1de04b6 100644 --- a/main.go +++ b/main.go @@ -1,12 +1,15 @@ package main import ( + "database/sql" "fmt" "os" "github.com/StevanFreeborn/gator/internal/command" "github.com/StevanFreeborn/gator/internal/config" + "github.com/StevanFreeborn/gator/internal/database" "github.com/StevanFreeborn/gator/internal/state" + _ "github.com/lib/pq" ) func main() { @@ -19,19 +22,28 @@ func main() { if err != nil { fmt.Printf("Error reading config file: %s", err) + os.Exit(1) + } + + db, err := sql.Open("postgres", c.DbUrl) + + if err != nil { + fmt.Printf("Error connecting to database: %s", err) + os.Exit(1) } cmd := os.Args[1] args := os.Args[2:] - s := state.NewState(c, args) + dbQueries := database.New(db) + s := state.NewState(dbQueries, c, args) registry := command.NewRegistry() err = registry.RunCommand(cmd, s) if err != nil { fmt.Printf("Error running '%s' command: %s\n", cmd, err) - os.Exit(2) + os.Exit(1) } os.Exit(0) diff --git a/sql/queries/feeds.sql b/sql/queries/feeds.sql new file mode 100644 index 0000000..3872061 --- /dev/null +++ b/sql/queries/feeds.sql @@ -0,0 +1,14 @@ +-- 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 id, created_at, updated_at, name, url, user_id FROM feeds; diff --git a/sql/queries/users.sql b/sql/queries/users.sql new file mode 100644 index 0000000..4e0aa9e --- /dev/null +++ b/sql/queries/users.sql @@ -0,0 +1,23 @@ +-- name: CreateUser :one +INSERT INTO users (id, created_at, updated_at, name) +VALUES ( + $1, + $2, + $3, + $4 +) +RETURNING *; + +-- name: GetUserByName :one +SELECT id, created_at, updated_at, name FROM users +WHERE name = $1; + +-- name: GetUserById :one +SELECT id, created_at, updated_at, name FROM users +WHERE id = $1; + +-- name: GetAllUsers :many +SELECT id, created_at, updated_at, name FROM users; + +-- name: DeleteAllUsers :exec +DELETE FROM users; diff --git a/sql/schema/002_feeds.sql b/sql/schema/002_feeds.sql new file mode 100644 index 0000000..14352e8 --- /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 TEXT NOT NULL, + url TEXT UNIQUE NOT NULL, + user_id UUID NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- +goose Down +DROP TABLE feeds; diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..08ef51e --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,8 @@ +version: "2" +sql: + - schema: "sql/schema" + queries: "sql/queries" + engine: "postgresql" + gen: + go: + out: "internal/database"