Compare commits

...
3 Commits
Author SHA1 Message Date
Stevan Freeborn c012f6008c feat: implemented many commands
- added register command
- added reset command
- added users command
- added agg command
- added feeds command
2026-08-01 14:26:19 -05:00
Stevan Freeborn fb53b052b0 chore: update gitignore 2026-08-01 11:38:23 -05:00
Stevan Freeborn c1325925ed feat: add users table migration 2026-08-01 11:35:49 -05:00
16 changed files with 624 additions and 9 deletions
+1
View File
@@ -0,0 +1 @@
.sqls.yml
+5
View File
@@ -1,3 +1,8 @@
module github.com/StevanFreeborn/gator module github.com/StevanFreeborn/gator
go 1.26.3 go 1.26.3
require (
github.com/google/uuid v1.6.0 // indirect
github.com/lib/pq v1.12.3 // indirect
)
+4
View File
@@ -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=
+193 -1
View File
@@ -1,9 +1,18 @@
package command package command
import ( import (
"cmp"
"context"
"fmt" "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/StevanFreeborn/gator/internal/state"
"github.com/google/uuid"
) )
type Command struct { type Command struct {
@@ -36,6 +45,12 @@ func NewRegistry() *CommandRegistry {
commands := []*Command{ commands := []*Command{
loginCommand(), loginCommand(),
registerCommand(),
resetCommand(),
usersCommand(),
aggCommand(),
addFeedCommand(),
feedsCommand(),
} }
for _, cmd := range commands { for _, cmd := range commands {
@@ -63,7 +78,13 @@ func loginCommand() *Command {
username := s.Arguments[0] 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 { if err != nil {
return err return err
@@ -74,3 +95,174 @@ func loginCommand() *Command {
return nil 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
})
}
+31
View File
@@ -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,
}
}
+90
View File
@@ -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
}
+27
View File
@@ -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
}
+123
View File
@@ -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
}
+61
View File
@@ -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
}
+13 -3
View File
@@ -1,12 +1,22 @@
package state 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 { type State struct {
Database *database.Queries
Config *config.Config Config *config.Config
Arguments []string Arguments []string
} }
func NewState(c *config.Config, args []string) *State { func NewState(d *database.Queries, c *config.Config, args []string) *State {
return &State{Config: c, Arguments: args} 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)
} }
+14 -2
View File
@@ -1,12 +1,15 @@
package main package main
import ( import (
"database/sql"
"fmt" "fmt"
"os" "os"
"github.com/StevanFreeborn/gator/internal/command" "github.com/StevanFreeborn/gator/internal/command"
"github.com/StevanFreeborn/gator/internal/config" "github.com/StevanFreeborn/gator/internal/config"
"github.com/StevanFreeborn/gator/internal/database"
"github.com/StevanFreeborn/gator/internal/state" "github.com/StevanFreeborn/gator/internal/state"
_ "github.com/lib/pq"
) )
func main() { func main() {
@@ -19,19 +22,28 @@ func main() {
if err != nil { if err != nil {
fmt.Printf("Error reading config file: %s", err) 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] cmd := os.Args[1]
args := os.Args[2:] args := os.Args[2:]
s := state.NewState(c, args) dbQueries := database.New(db)
s := state.NewState(dbQueries, c, args)
registry := command.NewRegistry() registry := command.NewRegistry()
err = registry.RunCommand(cmd, s) err = registry.RunCommand(cmd, s)
if err != nil { if err != nil {
fmt.Printf("Error running '%s' command: %s\n", cmd, err) fmt.Printf("Error running '%s' command: %s\n", cmd, err)
os.Exit(2) os.Exit(1)
} }
os.Exit(0) os.Exit(0)
+14
View File
@@ -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;
+23
View File
@@ -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;
+4 -3
View File
@@ -1,8 +1,9 @@
-- TODO: Finish writing SQL migration
-- for users table
-- +goose Up -- +goose Up
CREATE TABLE users ( CREATE TABLE users (
id UUID id UUID PRIMARY KEY,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
name TEXT UNIQUE NOT NULL
); );
-- +goose Down -- +goose Down
+13
View File
@@ -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;
+8
View File
@@ -0,0 +1,8 @@
version: "2"
sql:
- schema: "sql/schema"
queries: "sql/queries"
engine: "postgresql"
gen:
go:
out: "internal/database"