Parse feeds and fetch new videos

This commit is contained in:
Pavle Portic 2022-10-29 05:09:52 +02:00
parent cb6c40728b
commit fa2a3cccec
Signed by: TheEdgeOfRage
GPG Key ID: 66AD4BA646FBC0D2
24 changed files with 953 additions and 117 deletions

View File

@ -33,9 +33,9 @@ func main() {
return
}
db, err := db.NewPSQLDB(log, config.DB)
db, err := db.NewPostgresDB(log, config.DB)
if err != nil {
log.Log("level", "FATAL", "call", "db.NewPSQLDB", "error", err)
log.Log("level", "FATAL", "call", "db.NewPostgresDB", "error", err)
return
}

View File

@ -1,6 +1,9 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
@ -15,6 +18,7 @@ import (
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/auth"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
)
var testConfig config.Config
@ -23,12 +27,12 @@ func init() {
testConfig = config.TestConfig()
}
func setupTestServer(t *testing.T, authEnabled bool) *http.Server {
func setupTestServer(t *testing.T, authEnabled bool) (*http.Server, db.DB) {
l := log.NewNopLogger()
db, err := db.NewPSQLDB(l, testConfig.DB)
db, err := db.NewPostgresDB(l, testConfig.DB)
if !assert.NoError(t, err) {
return nil
return nil, nil
}
handler := handler.New(l, db)
@ -39,17 +43,17 @@ func setupTestServer(t *testing.T, authEnabled bool) *http.Server {
auth.AuthMiddleware(db),
)
if !assert.NoError(t, err) {
return nil
return nil, nil
}
return &http.Server{
Addr: fmt.Sprintf(":%v", testConfig.Gin.Port),
Handler: router,
}
}, db
}
func TestHealthz(t *testing.T) {
server := setupTestServer(t, false)
server, _ := setupTestServer(t, false)
if !assert.NotNil(t, server) {
return
}
@ -61,3 +65,24 @@ func TestHealthz(t *testing.T) {
assert.Equal(t, 200, w.Code)
assert.Equal(t, "healthy", w.Body.String())
}
func TestCreateUser(t *testing.T) {
server, db := setupTestServer(t, false)
if !assert.NotNil(t, server) {
return
}
jsonData, err := json.Marshal(models.User{Username: "test", Password: "test"})
if !assert.Nil(t, err) {
return
}
data := bytes.NewBuffer(jsonData)
req, _ := http.NewRequest("POST", "/register", data)
w := httptest.NewRecorder()
server.Handler.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.Equal(t, `{"msg":"user created"}`, w.Body.String())
db.DeleteUser(context.TODO(), "test")
}

75
db/channels.go Normal file
View File

@ -0,0 +1,75 @@
package db
import (
"context"
"github.com/lib/pq"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
)
var createChannelQuery = `INSERT INTO channels (id, name) VALUES ($1, $2)`
func (d *postgresDB) CreateChannel(ctx context.Context, channel models.Channel) error {
_, err := d.db.ExecContext(ctx, createChannelQuery, channel.ID, channel.Name)
if err != nil {
if pgerr, ok := err.(*pq.Error); ok {
if pgerr.Code == "23505" {
return ErrChannelExists
}
}
d.l.Log("level", "ERROR", "function", "db.CreateChannel", "error", err)
return err
}
return nil
}
var listChannelsQuery = `SELECT id, name FROM channels`
func (d *postgresDB) ListChannels(ctx context.Context) ([]models.Channel, error) {
rows, err := d.db.QueryContext(ctx, listChannelsQuery)
if err != nil {
d.l.Log("level", "ERROR", "function", "db.ListChannels", "call", "sql.QueryContext", "error", err)
return nil, err
}
defer rows.Close()
channels := make([]models.Channel, 0)
for rows.Next() {
var channel models.Channel
err = rows.Scan(&channel.ID, &channel.Name)
if err != nil {
d.l.Log("level", "ERROR", "function", "db.ListChannels", "call", "sql.Scan", "error", err)
return nil, err
}
channels = append(channels, channel)
}
return channels, nil
}
var getChannelSubscribersQuery = `SELECT username FROM user_subscriptions WHERE channel_id = $1`
func (d *postgresDB) GetChannelSubscribers(ctx context.Context, channelID string) ([]string, error) {
rows, err := d.db.QueryContext(ctx, getChannelSubscribersQuery, channelID)
if err != nil {
d.l.Log("level", "ERROR", "function", "db.GetChannelSubscribers", "call", "sql.QueryContext", "error", err)
return nil, err
}
defer rows.Close()
subs := make([]string, 0)
for rows.Next() {
var sub string
err = rows.Scan(&sub)
if err != nil {
d.l.Log("level", "ERROR", "function", "db.GetChannelSubscribers", "call", "sql.Scan", "error", err)
return nil, err
}
subs = append(subs, sub)
}
return subs, nil
}

View File

@ -2,14 +2,41 @@ package db
import (
"context"
"errors"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
)
var (
ErrChannelExists = errors.New("channel already exists")
ErrAlreadySubscribed = errors.New("already subscribed to channel")
ErrVideoExists = errors.New("video already exists")
)
// DB represents a database layer for getting video and channel data
type DB interface {
// GetNewVideos returns unwatched videos from all channels
GetNewVideos(ctx context.Context, username string) ([]*models.Video, error)
AuthenticateUser(ctx context.Context, username string, password string) (bool, error)
GetNewVideos(ctx context.Context, username string) ([]models.Video, error)
// CreateVideo adds a newly published video to the database
CreateVideo(ctx context.Context, video models.Video, channelID string) error
// CreateChannel starts tracking a new channel and fetch new videos for it
CreateChannel(ctx context.Context, channel models.Channel) error
// ListChannels lists all channels from the database
ListChannels(ctx context.Context) ([]models.Channel, error)
// GetChannelSubscribers lists all channels from the database
GetChannelSubscribers(ctx context.Context, channelID string) ([]string, error)
// AuthenticateUser verifies a user's password against a hashed value
AuthenticateUser(ctx context.Context, user models.User) (bool, error)
// CreateUser registers a new user in the database
CreateUser(ctx context.Context, user models.User) error
// DeleteUser registers a new user in the database
DeleteUser(ctx context.Context, username string) error
// SubscribeUserToChannel will start showing new videos for that channel to the user
SubscribeUserToChannel(ctx context.Context, username string, channelID string) error
// AddVideoToUser will list the video in the users feed
AddVideoToUser(ctx context.Context, username string, videoID string) error
// WatchVideo marks a video as watched so it no longer shows in the feed
WatchVideo(ctx context.Context, username string, videoID string) error
}

View File

@ -9,18 +9,18 @@ import (
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log"
)
type psqlDB struct {
type postgresDB struct {
l log.Logger
db *sql.DB
}
func NewPSQLDB(log log.Logger, dbCfg ytrssilConfig.DB) (*psqlDB, error) {
func NewPostgresDB(log log.Logger, dbCfg ytrssilConfig.DB) (*postgresDB, error) {
db, err := sql.Open("postgres", dbCfg.DBURI)
if err != nil {
return nil, err
}
return &psqlDB{
return &postgresDB{
l: log,
db: db,
}, nil

View File

@ -2,27 +2,27 @@ package db
import (
"context"
"fmt"
"github.com/alexedwards/argon2id"
"github.com/georgysavva/scany/v2/sqlscan"
"github.com/lib/pq"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
)
var authenticateUserQuery = `SELECT username, password FROM users WHERE username = $1`
var authenticateUserQuery = `SELECT password FROM users WHERE username = $1`
func (d *psqlDB) AuthenticateUser(ctx context.Context, username string, password string) (bool, error) {
var user []*models.User
err := sqlscan.Select(ctx, d.db, &user, authenticateUserQuery, username)
func (d *postgresDB) AuthenticateUser(ctx context.Context, user models.User) (bool, error) {
row := d.db.QueryRowContext(ctx, authenticateUserQuery, user.Username)
var hashedPassword string
err := row.Scan(&hashedPassword)
if err != nil {
d.l.Log("level", "ERROR", "function", "db.AuthenticateUser", "msg", "failed to query user", "error", err)
d.l.Log("level", "ERROR", "function", "db.AuthenticateUser", "error", err)
return false, err
}
match, err := argon2id.ComparePasswordAndHash(password, user[0].Password)
match, err := argon2id.ComparePasswordAndHash(user.Password, hashedPassword)
if err != nil {
d.l.Log("level", "ERROR", "function", "db.AuthenticateUser", "msg", "failed to check hashed passsword", "error", err)
d.l.Log("level", "ERROR", "function", "db.AuthenticateUser", "error", err)
return false, err
}
@ -31,22 +31,65 @@ func (d *psqlDB) AuthenticateUser(ctx context.Context, username string, password
var createUserQuery = `INSERT INTO users (username, password) VALUES ($1, $2)`
func (d *psqlDB) CreateUser(ctx context.Context, user models.User) error {
res, err := d.db.Exec(createUserQuery, user.Username, user.Password)
func (d *postgresDB) CreateUser(ctx context.Context, user models.User) error {
_, err := d.db.ExecContext(ctx, createUserQuery, user.Username, user.Password)
if err != nil {
d.l.Log("level", "ERROR", "function", "db.CreateUser", "msg", "failed to create user", "error", err)
d.l.Log("level", "ERROR", "function", "db.CreateUser", "error", err)
return err
}
return nil
}
var deleteUserQuery = `DELETE FROM users WHERE username = $1`
func (d *postgresDB) DeleteUser(ctx context.Context, username string) error {
_, err := d.db.ExecContext(ctx, deleteUserQuery, username)
if err != nil {
d.l.Log("level", "ERROR", "function", "db.DeleteUser", "error", err)
return err
}
return nil
}
var subscribeUserToChannelQuery = `INSERT INTO user_subscriptions (username, channel_id) VALUES ($1, $2)`
func (d *postgresDB) SubscribeUserToChannel(ctx context.Context, username string, channelID string) error {
_, err := d.db.ExecContext(ctx, subscribeUserToChannelQuery, username, channelID)
if err != nil {
if pgerr, ok := err.(*pq.Error); ok {
if pgerr.Code == "23505" {
return ErrAlreadySubscribed
}
}
d.l.Log("level", "ERROR", "function", "db.SubscribeUserToChannel", "error", err)
return err
}
return nil
}
var addVideoToUserQuery = `INSERT INTO user_videos (username, video_id) VALUES ($1, $2)`
func (d *postgresDB) AddVideoToUser(ctx context.Context, username string, videoID string) error {
_, err := d.db.ExecContext(ctx, addVideoToUserQuery, username, videoID)
if err != nil {
d.l.Log("level", "ERROR", "function", "db.AddVideoToUser", "error", err)
return err
}
return nil
}
var watchVideoQuery = `UPDATE user_videos SET watch_timestamp = NOW() WHERE username = $2 AND video_id = $3`
func (d *postgresDB) WatchVideo(ctx context.Context, username string, videoID string) error {
_, err := d.db.ExecContext(ctx, watchVideoQuery, username, videoID)
if err != nil {
d.l.Log("level", "ERROR", "function", "db.WatchVideo", "error", err)
return err
}
affected, err := res.RowsAffected()
if err != nil {
d.l.Log("level", "ERROR", "function", "db.CreateUser", "msg", "failed to get affected row count", "error", err)
return err
}
if affected != 1 {
d.l.Log("level", "ERROR", "function", "db.CreateUser", "msg", "failed to get affected row count", "error", err)
return fmt.Errorf("expected to insert one row, but %d were inserted", affected)
}
return nil
}

View File

@ -3,9 +3,8 @@ package db
import (
"context"
"github.com/georgysavva/scany/v2/sqlscan"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
"github.com/lib/pq"
)
var getNewVideosQuery = `
@ -25,13 +24,47 @@ var getNewVideosQuery = `
ORDER BY published_timestamp
`
func (d *psqlDB) GetNewVideos(ctx context.Context, username string) ([]*models.Video, error) {
var videos []*models.Video
err := sqlscan.Select(ctx, d.db, &videos, getNewVideosQuery, username)
func (d *postgresDB) GetNewVideos(ctx context.Context, username string) ([]models.Video, error) {
rows, err := d.db.QueryContext(ctx, getNewVideosQuery, username)
if err != nil {
d.l.Log("level", "ERROR", "function", "db.GetNewVideos", "msg", "failed to query new videos", "error", err)
d.l.Log("level", "ERROR", "function", "db.GetNewVideos", "call", "sql.QueryContext", "error", err)
return nil, err
}
defer rows.Close()
videos := make([]models.Video, 0)
for rows.Next() {
var video models.Video
err = rows.Scan(
&video.ID,
&video.Title,
&video.PublishedTime,
&video.WatchTime,
&video.ChannelName,
)
if err != nil {
d.l.Log("level", "ERROR", "function", "db.GetNewVideos", "call", "sql.Scan", "error", err)
return nil, err
}
videos = append(videos, video)
}
return videos, nil
}
var createVideoQuery = `INSERT INTO videos (id, title, published_timestamp, channel_id) VALUES ($1, $2, $3, $4)`
func (d *postgresDB) CreateVideo(ctx context.Context, video models.Video, channelID string) error {
_, err := d.db.ExecContext(ctx, createVideoQuery, video.ID, video.Title, video.PublishedTime, channelID)
if err != nil {
if pgerr, ok := err.(*pq.Error); ok {
if pgerr.Code == "23505" {
return ErrVideoExists
}
}
d.l.Log("level", "ERROR", "function", "db.CreateVideo", "call", "sql.Exec", "error", err)
return err
}
return nil
}

13
feedparser/date.go Normal file
View File

@ -0,0 +1,13 @@
package feedparser
import (
"time"
)
// Date type
type Date string
// Parse (Date function) and returns Time, error
func (d Date) Parse() (time.Time, error) {
return time.Parse(time.RFC3339, string(d)) // ISO8601
}

74
feedparser/feedparser.go Normal file
View File

@ -0,0 +1,74 @@
package feedparser
import (
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log"
"github.com/paulrosania/go-charset/charset"
)
var (
ErrInvalidChannelID = errors.New("invalid channel ID")
ErrParseFailed = errors.New("failed to parse feed")
)
var urlFormat = "https://www.youtube.com/feeds/videos.xml?channel_id=%s"
// Video struct for each video in the feed
type Video struct {
ID string `xml:"id"`
Title string `xml:"title"`
Published Date `xml:"published"`
}
// Channel struct for RSS
type Channel struct {
Name string `xml:"title"`
Videos []Video `xml:"entry"`
}
func read(l log.Logger, url string) (io.ReadCloser, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
l.Log("level", "ERROR", "function", "feedparser.read", "call", "http.NewRequest", "error", err)
return nil, err
}
response, err := http.DefaultClient.Do(req)
if err != nil {
l.Log("level", "ERROR", "function", "feedparser.read", "call", "http.Do", "error", err)
return nil, err
}
if response.StatusCode == http.StatusNotFound {
return nil, ErrInvalidChannelID
} else if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get feed with status %d", response.StatusCode)
}
return response.Body, nil
}
// Parse parses a YouTube channel XML feed from a channel ID
func Parse(l log.Logger, channelID string) (*Channel, error) {
url := fmt.Sprintf(urlFormat, channelID)
reader, err := read(l, url)
if err != nil {
return nil, err
}
defer reader.Close()
xmlDecoder := xml.NewDecoder(reader)
xmlDecoder.CharsetReader = charset.NewReader
var channel Channel
if err := xmlDecoder.Decode(&channel); err != nil {
l.Log("level", "ERROR", "function", "feedparser.read", "call", "xml.Decode", "error", err)
return nil, fmt.Errorf("%w: %s", ErrParseFailed, err.Error())
}
return &channel, nil
}

4
go.mod
View File

@ -4,11 +4,11 @@ go 1.19
require (
github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
github.com/georgysavva/scany/v2 v2.0.0-alpha.3
github.com/gin-gonic/gin v1.8.1
github.com/go-kit/log v0.2.1
github.com/jessevdk/go-flags v1.5.0
github.com/lib/pq v1.10.7
github.com/paulrosania/go-charset v0.0.0-20190326053356-55c9d7a5834c
github.com/stretchr/testify v1.8.0
)
@ -21,7 +21,6 @@ require (
github.com/go-playground/validator/v10 v10.11.0 // indirect
github.com/goccy/go-json v0.9.11 // indirect
github.com/google/go-cmp v0.5.8 // indirect
github.com/jackc/pgx/v5 v5.0.3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
@ -29,6 +28,7 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.5 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.9.0 // indirect
github.com/ugorji/go/codec v1.2.7 // indirect
golang.org/x/crypto v0.1.0 // indirect
golang.org/x/net v0.1.0 // indirect

14
go.sum
View File

@ -1,12 +1,9 @@
github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 h1:loy0fjI90vF44BPW4ZYOkE3tDkGTy7yHURusOJimt+I=
github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387/go.mod h1:GuR5j/NW7AU7tDAQUDGCtpiPxWIOy/c3kiRDnlwiCHc=
github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB05Ql//KWfeTFs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/georgysavva/scany/v2 v2.0.0-alpha.3 h1:+n7kJr/xyVQJcsE2ICn0jd/a4QaxaWnx+a+GJQTGRrQ=
github.com/georgysavva/scany/v2 v2.0.0-alpha.3/go.mod h1:sigOdh+0qb/+aOs3TVhehVT10p8qJL7K/Zhyz8vWo38=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.8.1 h1:4+fr/el88TOO3ewCmQr8cx/CtZ/umlIRIs5M4NTNjf8=
@ -25,17 +22,11 @@ github.com/go-playground/validator/v10 v10.11.0 h1:0W+xRM511GY47Yy3bZUbJVitCNg2B
github.com/go-playground/validator/v10 v10.11.0/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU=
github.com/goccy/go-json v0.9.11 h1:/pAaQDLHEoCq/5FFmSKBswWmK6H0e8g4159Kc/X/nqk=
github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
github.com/jackc/pgx/v5 v5.0.3 h1:4flM5ecR/555F0EcnjdaZa6MhBU+nr0QbZIo5vaKjuM=
github.com/jackc/pgx/v5 v5.0.3/go.mod h1:JBbvW3Hdw77jKl9uJrEDATUZIFM2VFPzRq4RWIhkF4o=
github.com/jackc/puddle/v2 v2.0.0 h1:Kwk/AlLigcnZsDssc3Zun1dk1tAtQNPaBBxBHWn0Mjc=
github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@ -59,17 +50,18 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/paulrosania/go-charset v0.0.0-20190326053356-55c9d7a5834c h1:P6XGcuPTigoHf4TSu+3D/7QOQ1MbL6alNwrGhcW7sKw=
github.com/paulrosania/go-charset v0.0.0-20190326053356-55c9d7a5834c/go.mod h1:YnNlZP7l4MhyGQ4CBRwv6ohZTPrUJJZtEv4ZgADkbs4=
github.com/pelletier/go-toml/v2 v2.0.5 h1:ipoSadvV8oGUjnUbMub59IDPPwfxF694nG/jwbMiyQg=
github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=

View File

@ -2,17 +2,22 @@ package handler
import (
"context"
"errors"
"strings"
"github.com/alexedwards/argon2id"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
)
type Handler interface {
CreateUser(ctx context.Context, user models.User) error
GetNewVideos(ctx context.Context, username string) ([]*models.Video, error)
SubscribeToChannel(ctx context.Context, username string, channelID string) error
GetNewVideos(ctx context.Context, username string) ([]models.Video, error)
FetchVideos(ctx context.Context) error
}
type handler struct {
@ -34,6 +39,89 @@ func (h *handler) CreateUser(ctx context.Context, user models.User) error {
return h.db.CreateUser(ctx, user)
}
func (h *handler) GetNewVideos(ctx context.Context, username string) ([]*models.Video, error) {
func (h *handler) SubscribeToChannel(ctx context.Context, username string, channelID string) error {
parsedChannel, err := feedparser.Parse(h.log, channelID)
if err != nil {
return err
}
channel := models.Channel{
ID: channelID,
Name: parsedChannel.Name,
}
err = h.db.CreateChannel(ctx, channel)
if !errors.Is(err, db.ErrChannelExists) {
return err
}
return h.db.SubscribeUserToChannel(ctx, username, channelID)
}
func (h *handler) GetNewVideos(ctx context.Context, username string) ([]models.Video, error) {
return h.db.GetNewVideos(ctx, username)
}
func (h *handler) addVideoToAllSubscribers(ctx context.Context, channelID string, videoID string) error {
subs, err := h.db.GetChannelSubscribers(ctx, channelID)
if err != nil {
h.log.Log("level", "ERROR", "call", "db.GetChannelSubscribers", "err", err)
return err
}
for _, sub := range subs {
err = h.db.AddVideoToUser(ctx, sub, videoID)
if err != nil {
h.log.Log("level", "ERROR", "call", "db.AddVideoToUser", "err", err)
continue
}
}
return nil
}
func (h *handler) fetchVideosForChannel(ctx context.Context, channelID string, parsedChannel *feedparser.Channel) {
for _, parsedVideo := range parsedChannel.Videos {
date, err := parsedVideo.Published.Parse()
if err != nil {
h.log.Log("level", "WARNING", "call", "feedparser.Parse", "err", err)
continue
}
id := strings.Split(parsedVideo.ID, ":")[2]
video := models.Video{
ID: id,
Title: parsedVideo.Title,
PublishedTime: date,
}
err = h.db.CreateVideo(ctx, video, channelID)
if err != nil {
if !errors.Is(err, db.ErrVideoExists) {
h.log.Log("level", "WARNING", "call", "db.CreateVideo", "err", err)
}
continue
}
err = h.addVideoToAllSubscribers(ctx, channelID, id)
if err != nil {
continue
}
}
}
func (h *handler) FetchVideos(ctx context.Context) error {
channels, err := h.db.ListChannels(ctx)
if err != nil {
return err
}
for _, channel := range channels {
parsedChannel, err := feedparser.Parse(h.log, channel.ID)
if err != nil {
continue
}
h.fetchVideosForChannel(ctx, channel.ID, parsedChannel)
}
return nil
}

View File

@ -24,10 +24,10 @@ func TestGetNewVideos(t *testing.T) {
l := log.NewNopLogger()
handler := New(l, &db_mock.DBMock{
GetNewVideosFunc: func(ctx context.Context, username string) ([]*models.Video, error) {
return []*models.Video{
GetNewVideosFunc: func(ctx context.Context, username string) ([]models.Video, error) {
return []models.Video{
{
VideoID: "test",
ID: "test",
ChannelName: "test",
Title: "test",
PublishedTime: time.Now(),
@ -43,7 +43,7 @@ func TestGetNewVideos(t *testing.T) {
// Assert
if assert.NoError(t, err) {
if assert.NotNil(t, resp) {
assert.Equal(t, resp[0].VideoID, "test")
assert.Equal(t, resp[0].ID, "test")
assert.Equal(t, resp[0].Title, "test")
}
}

View File

@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
)
// AuthMiddleware will authenticate against a static API key
@ -16,7 +17,11 @@ func AuthMiddleware(db db.DB) gin.HandlerFunc {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid basic auth header"})
return
}
authenticated, err := db.AuthenticateUser(c.Request.Context(), username, password)
user := models.User{
Username: username,
Password: password,
}
authenticated, err := db.AuthenticateUser(c.Request.Context(), user)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return

View File

@ -6,15 +6,17 @@ import (
"net/http/httptest"
"testing"
db_mock "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/mocks/db"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
db_mock "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/mocks/db"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
)
func setupTestServer() *http.Server {
db := &db_mock.DBMock{
AuthenticateUserFunc: func(ctx context.Context, username, password string) (bool, error) {
return username == "username" && password == "password", nil
AuthenticateUserFunc: func(ctx context.Context, user models.User) (bool, error) {
return user.Username == "username" && user.Password == "password", nil
},
}
@ -37,7 +39,7 @@ func TestSuccessfulAuthentication(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Add("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ=") // username:password
req.SetBasicAuth("username", "password") // Valid credentials
server.Handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
@ -52,7 +54,7 @@ func TestMissingAuthorizationHeader(t *testing.T) {
server.Handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Equal(t, `{"error":"invalid authorization header"}`, w.Body.String())
assert.Equal(t, `{"error":"invalid basic auth header"}`, w.Body.String())
}
func TestWrongCredentials(t *testing.T) {
@ -60,9 +62,9 @@ func TestWrongCredentials(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Add("Authorization", "Basic d3Jvbmc=")
req.SetBasicAuth("test", "test") // Invalid credentials
server.Handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Equal(t, `{"error":"invalid API Key"}`, w.Body.String())
assert.Equal(t, `{"error":"invalid username or password"}`, w.Body.String())
}

View File

@ -37,12 +37,14 @@ func SetupGinRouter(l log.Logger, handler handler.Handler, authMiddleware func(c
}
engine.GET("/healthz", srv.Healthz)
engine.POST("/register", srv.CreateUser)
engine.POST("/fetch", srv.FetchVideos)
// all APIs go in this routing group and require authentication
api := engine.Group("/api")
api.Use(authMiddleware)
{
api.GET("videos/new", srv.GetNewVideos)
api.POST("channels/:channel_id/subscribe", srv.SubscribeToChannel)
}
return engine, nil

View File

@ -1,10 +1,14 @@
package ytrssil
import (
"errors"
"net/http"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
"github.com/gin-gonic/gin"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
)
func (s *server) CreateUser(c *gin.Context) {
@ -23,3 +27,30 @@ func (s *server) CreateUser(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"msg": "user created"})
}
func (s *server) SubscribeToChannel(c *gin.Context) {
var channel models.Channel
err := c.BindUri(&channel)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
username := c.GetString("username")
err = s.handler.SubscribeToChannel(c.Request.Context(), username, channel.ID)
if err != nil {
if errors.Is(err, db.ErrAlreadySubscribed) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
if errors.Is(err, feedparser.ErrInvalidChannelID) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"msg": "subscribed to channel successfully"})
}

View File

@ -19,3 +19,13 @@ func (s *server) GetNewVideos(c *gin.Context) {
Videos: videos,
})
}
func (s *server) FetchVideos(c *gin.Context) {
err := s.handler.FetchVideos(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"msg": "videos fetched successfully"})
}

View File

@ -1,3 +1,4 @@
DROP TABLE IF EXISTS user_subscriptions;
DROP TABLE IF EXISTS user_videos;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS videos;

View File

@ -1,24 +1,29 @@
CREATE TABLE IF NOT EXISTS channels (
id text NOT NULL PRIMARY KEY,
name text NOT NULL,
feed_url text NOT NULL
id text NOT NULL PRIMARY KEY
, name text NOT NULL
);
CREATE TABLE IF NOT EXISTS videos (
id text NOT NULL PRIMARY KEY,
title text NOT NULL,
published_timestamp timestamp with time zone NOT NULL,
channel_id text NOT NULL REFERENCES channels(id)
id text NOT NULL PRIMARY KEY
, title text NOT NULL
, published_timestamp timestamp with time zone NOT NULL
, channel_id text NOT NULL REFERENCES channels(id)
);
CREATE TABLE IF NOT EXISTS users (
username text NOT NULL PRIMARY KEY,
password text NOT NULL
username text NOT NULL PRIMARY KEY
, password text NOT NULL
);
CREATE TABLE IF NOT EXISTS user_videos (
id SERIAL PRIMARY KEY,
username text NOT NULL REFERENCES users(username),
video_id text NOT NULL REFERENCES videos(id),
watch_timestamp timestamp with time zone
username text NOT NULL REFERENCES users(username)
, video_id text NOT NULL REFERENCES videos(id)
, watch_timestamp timestamp with time zone
, CONSTRAINT user_videos_pkey PRIMARY KEY (username, video_id)
);
CREATE TABLE IF NOT EXISTS user_subscriptions (
username text NOT NULL REFERENCES users(username)
, channel_id text NOT NULL REFERENCES channels(id)
, CONSTRAINT user_subscriptions_pkey PRIMARY KEY (channel_id, username)
);

View File

@ -20,15 +20,39 @@ var _ db.DB = &DBMock{}
//
// // make and configure a mocked db.DB
// mockedDB := &DBMock{
// AuthenticateUserFunc: func(ctx context.Context, username string, password string) (bool, error) {
// AddVideoToUserFunc: func(ctx context.Context, username string, videoID string) error {
// panic("mock out the AddVideoToUser method")
// },
// AuthenticateUserFunc: func(ctx context.Context, user models.User) (bool, error) {
// panic("mock out the AuthenticateUser method")
// },
// CreateChannelFunc: func(ctx context.Context, channel models.Channel) error {
// panic("mock out the CreateChannel method")
// },
// CreateUserFunc: func(ctx context.Context, user models.User) error {
// panic("mock out the CreateUser method")
// },
// GetNewVideosFunc: func(ctx context.Context, username string) ([]*models.Video, error) {
// CreateVideoFunc: func(ctx context.Context, video models.Video, channelID string) error {
// panic("mock out the CreateVideo method")
// },
// DeleteUserFunc: func(ctx context.Context, username string) error {
// panic("mock out the DeleteUser method")
// },
// GetChannelSubscribersFunc: func(ctx context.Context, channelID string) ([]string, error) {
// panic("mock out the GetChannelSubscribers method")
// },
// GetNewVideosFunc: func(ctx context.Context, username string) ([]models.Video, error) {
// panic("mock out the GetNewVideos method")
// },
// ListChannelsFunc: func(ctx context.Context) ([]models.Channel, error) {
// panic("mock out the ListChannels method")
// },
// SubscribeUserToChannelFunc: func(ctx context.Context, username string, channelID string) error {
// panic("mock out the SubscribeUserToChannel method")
// },
// WatchVideoFunc: func(ctx context.Context, username string, videoID string) error {
// panic("mock out the WatchVideo method")
// },
// }
//
// // use mockedDB in code that requires db.DB
@ -36,25 +60,63 @@ var _ db.DB = &DBMock{}
//
// }
type DBMock struct {
// AddVideoToUserFunc mocks the AddVideoToUser method.
AddVideoToUserFunc func(ctx context.Context, username string, videoID string) error
// AuthenticateUserFunc mocks the AuthenticateUser method.
AuthenticateUserFunc func(ctx context.Context, username string, password string) (bool, error)
AuthenticateUserFunc func(ctx context.Context, user models.User) (bool, error)
// CreateChannelFunc mocks the CreateChannel method.
CreateChannelFunc func(ctx context.Context, channel models.Channel) error
// CreateUserFunc mocks the CreateUser method.
CreateUserFunc func(ctx context.Context, user models.User) error
// CreateVideoFunc mocks the CreateVideo method.
CreateVideoFunc func(ctx context.Context, video models.Video, channelID string) error
// DeleteUserFunc mocks the DeleteUser method.
DeleteUserFunc func(ctx context.Context, username string) error
// GetChannelSubscribersFunc mocks the GetChannelSubscribers method.
GetChannelSubscribersFunc func(ctx context.Context, channelID string) ([]string, error)
// GetNewVideosFunc mocks the GetNewVideos method.
GetNewVideosFunc func(ctx context.Context, username string) ([]*models.Video, error)
GetNewVideosFunc func(ctx context.Context, username string) ([]models.Video, error)
// ListChannelsFunc mocks the ListChannels method.
ListChannelsFunc func(ctx context.Context) ([]models.Channel, error)
// SubscribeUserToChannelFunc mocks the SubscribeUserToChannel method.
SubscribeUserToChannelFunc func(ctx context.Context, username string, channelID string) error
// WatchVideoFunc mocks the WatchVideo method.
WatchVideoFunc func(ctx context.Context, username string, videoID string) error
// calls tracks calls to the methods.
calls struct {
// AuthenticateUser holds details about calls to the AuthenticateUser method.
AuthenticateUser []struct {
// AddVideoToUser holds details about calls to the AddVideoToUser method.
AddVideoToUser []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// Username is the username argument value.
Username string
// Password is the password argument value.
Password string
// VideoID is the videoID argument value.
VideoID string
}
// AuthenticateUser holds details about calls to the AuthenticateUser method.
AuthenticateUser []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// User is the user argument value.
User models.User
}
// CreateChannel holds details about calls to the CreateChannel method.
CreateChannel []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// Channel is the channel argument value.
Channel models.Channel
}
// CreateUser holds details about calls to the CreateUser method.
CreateUser []struct {
@ -63,6 +125,29 @@ type DBMock struct {
// User is the user argument value.
User models.User
}
// CreateVideo holds details about calls to the CreateVideo method.
CreateVideo []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// Video is the video argument value.
Video models.Video
// ChannelID is the channelID argument value.
ChannelID string
}
// DeleteUser holds details about calls to the DeleteUser method.
DeleteUser []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// Username is the username argument value.
Username string
}
// GetChannelSubscribers holds details about calls to the GetChannelSubscribers method.
GetChannelSubscribers []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// ChannelID is the channelID argument value.
ChannelID string
}
// GetNewVideos holds details about calls to the GetNewVideos method.
GetNewVideos []struct {
// Ctx is the ctx argument value.
@ -70,30 +155,99 @@ type DBMock struct {
// Username is the username argument value.
Username string
}
// ListChannels holds details about calls to the ListChannels method.
ListChannels []struct {
// Ctx is the ctx argument value.
Ctx context.Context
}
// SubscribeUserToChannel holds details about calls to the SubscribeUserToChannel method.
SubscribeUserToChannel []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// Username is the username argument value.
Username string
// ChannelID is the channelID argument value.
ChannelID string
}
// WatchVideo holds details about calls to the WatchVideo method.
WatchVideo []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// Username is the username argument value.
Username string
// VideoID is the videoID argument value.
VideoID string
}
}
lockAuthenticateUser sync.RWMutex
lockCreateUser sync.RWMutex
lockGetNewVideos sync.RWMutex
lockAddVideoToUser sync.RWMutex
lockAuthenticateUser sync.RWMutex
lockCreateChannel sync.RWMutex
lockCreateUser sync.RWMutex
lockCreateVideo sync.RWMutex
lockDeleteUser sync.RWMutex
lockGetChannelSubscribers sync.RWMutex
lockGetNewVideos sync.RWMutex
lockListChannels sync.RWMutex
lockSubscribeUserToChannel sync.RWMutex
lockWatchVideo sync.RWMutex
}
// AuthenticateUser calls AuthenticateUserFunc.
func (mock *DBMock) AuthenticateUser(ctx context.Context, username string, password string) (bool, error) {
if mock.AuthenticateUserFunc == nil {
panic("DBMock.AuthenticateUserFunc: method is nil but DB.AuthenticateUser was just called")
// AddVideoToUser calls AddVideoToUserFunc.
func (mock *DBMock) AddVideoToUser(ctx context.Context, username string, videoID string) error {
if mock.AddVideoToUserFunc == nil {
panic("DBMock.AddVideoToUserFunc: method is nil but DB.AddVideoToUser was just called")
}
callInfo := struct {
Ctx context.Context
Username string
Password string
VideoID string
}{
Ctx: ctx,
Username: username,
Password: password,
VideoID: videoID,
}
mock.lockAddVideoToUser.Lock()
mock.calls.AddVideoToUser = append(mock.calls.AddVideoToUser, callInfo)
mock.lockAddVideoToUser.Unlock()
return mock.AddVideoToUserFunc(ctx, username, videoID)
}
// AddVideoToUserCalls gets all the calls that were made to AddVideoToUser.
// Check the length with:
//
// len(mockedDB.AddVideoToUserCalls())
func (mock *DBMock) AddVideoToUserCalls() []struct {
Ctx context.Context
Username string
VideoID string
} {
var calls []struct {
Ctx context.Context
Username string
VideoID string
}
mock.lockAddVideoToUser.RLock()
calls = mock.calls.AddVideoToUser
mock.lockAddVideoToUser.RUnlock()
return calls
}
// AuthenticateUser calls AuthenticateUserFunc.
func (mock *DBMock) AuthenticateUser(ctx context.Context, user models.User) (bool, error) {
if mock.AuthenticateUserFunc == nil {
panic("DBMock.AuthenticateUserFunc: method is nil but DB.AuthenticateUser was just called")
}
callInfo := struct {
Ctx context.Context
User models.User
}{
Ctx: ctx,
User: user,
}
mock.lockAuthenticateUser.Lock()
mock.calls.AuthenticateUser = append(mock.calls.AuthenticateUser, callInfo)
mock.lockAuthenticateUser.Unlock()
return mock.AuthenticateUserFunc(ctx, username, password)
return mock.AuthenticateUserFunc(ctx, user)
}
// AuthenticateUserCalls gets all the calls that were made to AuthenticateUser.
@ -101,14 +255,12 @@ func (mock *DBMock) AuthenticateUser(ctx context.Context, username string, passw
//
// len(mockedDB.AuthenticateUserCalls())
func (mock *DBMock) AuthenticateUserCalls() []struct {
Ctx context.Context
Username string
Password string
Ctx context.Context
User models.User
} {
var calls []struct {
Ctx context.Context
Username string
Password string
Ctx context.Context
User models.User
}
mock.lockAuthenticateUser.RLock()
calls = mock.calls.AuthenticateUser
@ -116,6 +268,42 @@ func (mock *DBMock) AuthenticateUserCalls() []struct {
return calls
}
// CreateChannel calls CreateChannelFunc.
func (mock *DBMock) CreateChannel(ctx context.Context, channel models.Channel) error {
if mock.CreateChannelFunc == nil {
panic("DBMock.CreateChannelFunc: method is nil but DB.CreateChannel was just called")
}
callInfo := struct {
Ctx context.Context
Channel models.Channel
}{
Ctx: ctx,
Channel: channel,
}
mock.lockCreateChannel.Lock()
mock.calls.CreateChannel = append(mock.calls.CreateChannel, callInfo)
mock.lockCreateChannel.Unlock()
return mock.CreateChannelFunc(ctx, channel)
}
// CreateChannelCalls gets all the calls that were made to CreateChannel.
// Check the length with:
//
// len(mockedDB.CreateChannelCalls())
func (mock *DBMock) CreateChannelCalls() []struct {
Ctx context.Context
Channel models.Channel
} {
var calls []struct {
Ctx context.Context
Channel models.Channel
}
mock.lockCreateChannel.RLock()
calls = mock.calls.CreateChannel
mock.lockCreateChannel.RUnlock()
return calls
}
// CreateUser calls CreateUserFunc.
func (mock *DBMock) CreateUser(ctx context.Context, user models.User) error {
if mock.CreateUserFunc == nil {
@ -152,8 +340,120 @@ func (mock *DBMock) CreateUserCalls() []struct {
return calls
}
// CreateVideo calls CreateVideoFunc.
func (mock *DBMock) CreateVideo(ctx context.Context, video models.Video, channelID string) error {
if mock.CreateVideoFunc == nil {
panic("DBMock.CreateVideoFunc: method is nil but DB.CreateVideo was just called")
}
callInfo := struct {
Ctx context.Context
Video models.Video
ChannelID string
}{
Ctx: ctx,
Video: video,
ChannelID: channelID,
}
mock.lockCreateVideo.Lock()
mock.calls.CreateVideo = append(mock.calls.CreateVideo, callInfo)
mock.lockCreateVideo.Unlock()
return mock.CreateVideoFunc(ctx, video, channelID)
}
// CreateVideoCalls gets all the calls that were made to CreateVideo.
// Check the length with:
//
// len(mockedDB.CreateVideoCalls())
func (mock *DBMock) CreateVideoCalls() []struct {
Ctx context.Context
Video models.Video
ChannelID string
} {
var calls []struct {
Ctx context.Context
Video models.Video
ChannelID string
}
mock.lockCreateVideo.RLock()
calls = mock.calls.CreateVideo
mock.lockCreateVideo.RUnlock()
return calls
}
// DeleteUser calls DeleteUserFunc.
func (mock *DBMock) DeleteUser(ctx context.Context, username string) error {
if mock.DeleteUserFunc == nil {
panic("DBMock.DeleteUserFunc: method is nil but DB.DeleteUser was just called")
}
callInfo := struct {
Ctx context.Context
Username string
}{
Ctx: ctx,
Username: username,
}
mock.lockDeleteUser.Lock()
mock.calls.DeleteUser = append(mock.calls.DeleteUser, callInfo)
mock.lockDeleteUser.Unlock()
return mock.DeleteUserFunc(ctx, username)
}
// DeleteUserCalls gets all the calls that were made to DeleteUser.
// Check the length with:
//
// len(mockedDB.DeleteUserCalls())
func (mock *DBMock) DeleteUserCalls() []struct {
Ctx context.Context
Username string
} {
var calls []struct {
Ctx context.Context
Username string
}
mock.lockDeleteUser.RLock()
calls = mock.calls.DeleteUser
mock.lockDeleteUser.RUnlock()
return calls
}
// GetChannelSubscribers calls GetChannelSubscribersFunc.
func (mock *DBMock) GetChannelSubscribers(ctx context.Context, channelID string) ([]string, error) {
if mock.GetChannelSubscribersFunc == nil {
panic("DBMock.GetChannelSubscribersFunc: method is nil but DB.GetChannelSubscribers was just called")
}
callInfo := struct {
Ctx context.Context
ChannelID string
}{
Ctx: ctx,
ChannelID: channelID,
}
mock.lockGetChannelSubscribers.Lock()
mock.calls.GetChannelSubscribers = append(mock.calls.GetChannelSubscribers, callInfo)
mock.lockGetChannelSubscribers.Unlock()
return mock.GetChannelSubscribersFunc(ctx, channelID)
}
// GetChannelSubscribersCalls gets all the calls that were made to GetChannelSubscribers.
// Check the length with:
//
// len(mockedDB.GetChannelSubscribersCalls())
func (mock *DBMock) GetChannelSubscribersCalls() []struct {
Ctx context.Context
ChannelID string
} {
var calls []struct {
Ctx context.Context
ChannelID string
}
mock.lockGetChannelSubscribers.RLock()
calls = mock.calls.GetChannelSubscribers
mock.lockGetChannelSubscribers.RUnlock()
return calls
}
// GetNewVideos calls GetNewVideosFunc.
func (mock *DBMock) GetNewVideos(ctx context.Context, username string) ([]*models.Video, error) {
func (mock *DBMock) GetNewVideos(ctx context.Context, username string) ([]models.Video, error) {
if mock.GetNewVideosFunc == nil {
panic("DBMock.GetNewVideosFunc: method is nil but DB.GetNewVideos was just called")
}
@ -187,3 +487,115 @@ func (mock *DBMock) GetNewVideosCalls() []struct {
mock.lockGetNewVideos.RUnlock()
return calls
}
// ListChannels calls ListChannelsFunc.
func (mock *DBMock) ListChannels(ctx context.Context) ([]models.Channel, error) {
if mock.ListChannelsFunc == nil {
panic("DBMock.ListChannelsFunc: method is nil but DB.ListChannels was just called")
}
callInfo := struct {
Ctx context.Context
}{
Ctx: ctx,
}
mock.lockListChannels.Lock()
mock.calls.ListChannels = append(mock.calls.ListChannels, callInfo)
mock.lockListChannels.Unlock()
return mock.ListChannelsFunc(ctx)
}
// ListChannelsCalls gets all the calls that were made to ListChannels.
// Check the length with:
//
// len(mockedDB.ListChannelsCalls())
func (mock *DBMock) ListChannelsCalls() []struct {
Ctx context.Context
} {
var calls []struct {
Ctx context.Context
}
mock.lockListChannels.RLock()
calls = mock.calls.ListChannels
mock.lockListChannels.RUnlock()
return calls
}
// SubscribeUserToChannel calls SubscribeUserToChannelFunc.
func (mock *DBMock) SubscribeUserToChannel(ctx context.Context, username string, channelID string) error {
if mock.SubscribeUserToChannelFunc == nil {
panic("DBMock.SubscribeUserToChannelFunc: method is nil but DB.SubscribeUserToChannel was just called")
}
callInfo := struct {
Ctx context.Context
Username string
ChannelID string
}{
Ctx: ctx,
Username: username,
ChannelID: channelID,
}
mock.lockSubscribeUserToChannel.Lock()
mock.calls.SubscribeUserToChannel = append(mock.calls.SubscribeUserToChannel, callInfo)
mock.lockSubscribeUserToChannel.Unlock()
return mock.SubscribeUserToChannelFunc(ctx, username, channelID)
}
// SubscribeUserToChannelCalls gets all the calls that were made to SubscribeUserToChannel.
// Check the length with:
//
// len(mockedDB.SubscribeUserToChannelCalls())
func (mock *DBMock) SubscribeUserToChannelCalls() []struct {
Ctx context.Context
Username string
ChannelID string
} {
var calls []struct {
Ctx context.Context
Username string
ChannelID string
}
mock.lockSubscribeUserToChannel.RLock()
calls = mock.calls.SubscribeUserToChannel
mock.lockSubscribeUserToChannel.RUnlock()
return calls
}
// WatchVideo calls WatchVideoFunc.
func (mock *DBMock) WatchVideo(ctx context.Context, username string, videoID string) error {
if mock.WatchVideoFunc == nil {
panic("DBMock.WatchVideoFunc: method is nil but DB.WatchVideo was just called")
}
callInfo := struct {
Ctx context.Context
Username string
VideoID string
}{
Ctx: ctx,
Username: username,
VideoID: videoID,
}
mock.lockWatchVideo.Lock()
mock.calls.WatchVideo = append(mock.calls.WatchVideo, callInfo)
mock.lockWatchVideo.Unlock()
return mock.WatchVideoFunc(ctx, username, videoID)
}
// WatchVideoCalls gets all the calls that were made to WatchVideo.
// Check the length with:
//
// len(mockedDB.WatchVideoCalls())
func (mock *DBMock) WatchVideoCalls() []struct {
Ctx context.Context
Username string
VideoID string
} {
var calls []struct {
Ctx context.Context
Username string
VideoID string
}
mock.lockWatchVideo.RLock()
calls = mock.calls.WatchVideo
mock.lockWatchVideo.RUnlock()
return calls
}

View File

@ -2,9 +2,7 @@ package models
type Channel struct {
// YouTube ID of the channel
ChannelID string `json:"channel_id" dynamodbav:"channel_id"`
ID string `json:"channel_id" uri:"channel_id" binding:"required"`
// Name of the channel
Name string `json:"name" dynamodbav:"name"`
// Feed is the URL for the RSS feed
FeedURL string `json:"feed_url" dynamodbav:"feed_url"`
Name string `json:"name"`
}

View File

@ -1,5 +1,5 @@
package models
type GetNewVideosResponse struct {
Videos []*Video `json:"videos"`
Videos []Video `json:"videos"`
}

View File

@ -6,13 +6,13 @@ import (
type Video struct {
// YouTube ID of the video
VideoID string `json:"video_id" db:"video_id"`
ID string `json:"video_id"`
// Name of the channel the video belongs to
ChannelName string `json:"channel_name" db:"channel_name"`
ChannelName string `json:"channel_name"`
// Title of the video
Title string `json:"title" db:"title"`
Title string `json:"title"`
// Video publish timestamp
PublishedTime time.Time `json:"published_timestamp" db:"published_timestamp"`
PublishedTime time.Time `json:"published_timestamp"`
// Video watch timestamp
WatchTime *time.Time `json:"watch_timestamp" db:"watch_timestamp"`
WatchTime *time.Time `json:"watch_timestamp"`
}