Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions internal/database/consul/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ import (
"encoding/json"
"fmt"
"path"
"strings"

"github.com/hashicorp/consul/api"

"tracker/internal/database"
)

// UserDatabase implements database.UserDatabase and can be used
// to insert user data.
// Database contains methods for getting the data about shows and episodes
type Database struct {
kv KV

Expand All @@ -39,7 +39,7 @@ func NewDatabase(prefix string, opts ...Option) (*Database, error) {
}

// get value from the database
func (db *Database) get(ctx context.Context, key string, value interface{}) error {
func (db *Database) get(ctx context.Context, key string, value any) error {
opt := &api.QueryOptions{}
opt = opt.WithContext(ctx)

Expand All @@ -60,7 +60,7 @@ func (db *Database) get(ctx context.Context, key string, value interface{}) erro
return nil
}

func (db *Database) put(ctx context.Context, key string, value interface{}) error {
func (db *Database) put(ctx context.Context, key string, value any) error {
opt := &api.WriteOptions{}
opt = opt.WithContext(ctx)

Expand All @@ -79,6 +79,27 @@ func (db *Database) put(ctx context.Context, key string, value interface{}) erro
}

// Option allows to set options for the Consul database.type Option func(*Database)
func (db *Database) list(ctx context.Context, prefix string) (map[string][]byte, error) {
opt := &api.QueryOptions{}
opt = opt.WithContext(ctx)

p := path.Join(db.prefix, prefix)

kvs, _, err := db.kv.List(p, opt)
if err != nil {
return nil, fmt.Errorf("unable to list %s: %w", p, err)
}

m := make(map[string][]byte, len(kvs))
for _, kv := range kvs {
// Remove all the prefixes from the keys.
m[strings.TrimPrefix(kv.Key, p+"/")] = kv.Value
}

return m, nil
}

// Option allows to set options for the Consul database.
type Option func(*Database)

func KVClient(kv KV) Option {
Expand All @@ -91,4 +112,5 @@ func KVClient(kv KV) Option {
type KV interface {
Put(p *api.KVPair, q *api.WriteOptions) (*api.WriteMeta, error)
Get(key string, q *api.QueryOptions) (*api.KVPair, *api.QueryMeta, error)
List(prefix string, q *api.QueryOptions) (api.KVPairs, *api.QueryMeta, error)
}
38 changes: 38 additions & 0 deletions internal/database/consul/database_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package consul

import (
"errors"
"strings"

"github.com/hashicorp/consul/api"
)

var errNotFound = errors.New("kv: not found")

type testKV struct {
m map[string][]byte
}

func (kv *testKV) Get(key string, _ *api.QueryOptions) (*api.KVPair, *api.QueryMeta, error) {
if v, exists := kv.m[key]; exists {
return &api.KVPair{Key: key, Value: v}, nil, nil
} else {
return nil, nil, errNotFound
}
}

func (kv *testKV) Put(pair *api.KVPair, _ *api.WriteOptions) (*api.WriteMeta, error) {
kv.m[pair.Key] = pair.Value
return nil, nil
}

func (kv *testKV) List(prefix string, _ *api.QueryOptions) (api.KVPairs, *api.QueryMeta, error) {
var pairs []*api.KVPair
for key, value := range kv.m {
if strings.HasPrefix(key, prefix) {
pairs = append(pairs, &api.KVPair{Key: key, Value: value})
}
}

return api.KVPairs(pairs), nil, nil
}
81 changes: 81 additions & 0 deletions internal/database/consul/shows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package consul

import (
"context"
"encoding/json"
"fmt"
"path"

"tracker/internal/types/show"
)

type ShowsDatabase struct {
db *Database
prefix string
}

func (db *Database) Shows() *ShowsDatabase {
return &ShowsDatabase{
db: db,
prefix: "shows",
}
}

func (db *ShowsDatabase) List(ctx context.Context) ([]*show.Show, error) {
showIDs, err := db.list(ctx, "list")
if err != nil {
return nil, fmt.Errorf("unable to get show IDs")
}

// TODO: Speed up by running these in parallel
var shows []*show.Show
for showID := range showIDs {
show, err := db.showDetails(ctx, showID)
if err != nil {
return nil, fmt.Errorf("unable to get show %s: %w", showID, err)
}
shows = append(shows, show)
}

return shows, nil
}

func (db *ShowsDatabase) Details(ctx context.Context, id string) (*show.Show, error) {
// TODO: Run getting show details and show episodes in parallel
s, err := db.showDetails(ctx, id)
if err != nil {
return nil, fmt.Errorf("unable to get details about %s: %w", id, err)
}

episodes, err := db.list(ctx, path.Join("episodes", id))
if err != nil {
return nil, fmt.Errorf("unable to get episodes for %s: %w", id, err)
}

for _, ep := range episodes {
var episode show.Episode
if err := json.Unmarshal(ep, &episode); err != nil {
return nil, fmt.Errorf("unable to parse episode: %w", err)
}
s.Episodes = append(s.Episodes, &episode)
}

return s, nil
}

func (db *ShowsDatabase) showDetails(ctx context.Context, id string) (*show.Show, error) {
var show show.Show
if err := db.get(ctx, path.Join("list", id), &show); err != nil {
return nil, err
}

return &show, nil
}

func (db *ShowsDatabase) get(ctx context.Context, key string, value any) error {
return db.db.get(ctx, path.Join(db.prefix, key), value)
}

func (db *ShowsDatabase) list(ctx context.Context, prefix string) (map[string][]byte, error) {
return db.db.list(ctx, path.Join(db.prefix, prefix))
}
71 changes: 71 additions & 0 deletions internal/database/consul/shows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package consul

import (
"context"
"sort"
"testing"
"tracker/internal/types/show"

"github.com/go-test/deep"
)

var testData = map[string][]byte{
"tracker/shows/list/westworld": []byte(`{"id": 1, "name": "Westworld"}`),
"tracker/shows/episodes/westworld_s01e01": []byte(`{"title":"The Original", "season": 1, "episode": 1}`),

"tracker/shows/list/expanse": []byte(`{"id": 2, "name": "The Expanse"}`),
}

func TestList(t *testing.T) {
kv := &testKV{m: testData}
db, err := NewDatabase("tracker", KVClient(kv))
if err != nil {
t.Fatalf("unable to setup database: %v", err)
}

showsDB := db.Shows()

want := []*show.Show{
{ID: 2, Name: "The Expanse"},
{ID: 1, Name: "Westworld"},
}

got, err := showsDB.List(context.Background())
if err != nil {
t.Fatalf("unexpected error listing shows: %v", err)
}

sort.Sort(show.ByName(got))

if diff := deep.Equal(got, want); diff != nil {
t.Fatalf("List() = %v, want %v, diff = %v", got, want, diff)
}
}

func TestDetails(t *testing.T) {
kv := &testKV{m: testData}
db, err := NewDatabase("tracker", KVClient(kv))
if err != nil {
t.Fatalf("unable to setup database: %v", err)
}

showsDB := db.Shows()

want := &show.Show{
ID: 1,
Name: "Westworld",
Episodes: []*show.Episode{
{Title: "The Original", Season: 1, Episode: 1},
},
}

got, err := showsDB.Details(context.Background(), "westworld")
if err != nil {
t.Fatalf("unexpected error getting details: %v", err)
}

if diff := deep.Equal(got, want); diff != nil {
t.Fatalf("Details() = %v, want %v, diff = %v", got, want, diff)
}

}
4 changes: 2 additions & 2 deletions internal/database/consul/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ func (db *UsersDatabase) Details(ctx context.Context, email string) (*user.User,
return &u, nil
}

func (db *UsersDatabase) get(ctx context.Context, key string, value interface{}) error {
func (db *UsersDatabase) get(ctx context.Context, key string, value any) error {
return db.db.get(ctx, path.Join(db.prefix, key), value)
}

func (db *UsersDatabase) put(ctx context.Context, key string, value interface{}) error {
func (db *UsersDatabase) put(ctx context.Context, key string, value any) error {
return db.db.put(ctx, path.Join(db.prefix, key), value)
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,10 @@ import (
"tracker/internal/types/user"

"github.com/go-test/deep"
"github.com/hashicorp/consul/api"
)

var errNotFound = errors.New("kv: not found")

func TestImplements(t *testing.T) {
var i interface{} = &UsersDatabase{}
var i any = &UsersDatabase{}

if _, ok := i.(database.UsersDatabase); !ok {
t.Errorf("UserDatabase does not implement database.UserDatabase")
Expand Down Expand Up @@ -56,20 +53,3 @@ func TestE2E(t *testing.T) {
t.Fatalf("Get() = %v, got %v, diff = %v", got, want, diff)
}
}

type testKV struct {
m map[string][]byte
}

func (kv *testKV) Put(pair *api.KVPair, _ *api.WriteOptions) (*api.WriteMeta, error) {
kv.m[pair.Key] = pair.Value
return nil, nil
}

func (kv *testKV) Get(key string, _ *api.QueryOptions) (*api.KVPair, *api.QueryMeta, error) {
if v, exists := kv.m[key]; exists {
return &api.KVPair{Key: key, Value: v}, nil, nil
} else {
return nil, nil, errNotFound
}
}
11 changes: 10 additions & 1 deletion internal/database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package database
import (
"context"

"tracker/internal/types/show"
"tracker/internal/types/user"
)

Expand All @@ -16,9 +17,10 @@ const (
ErrNotFound = Error("not found")
)

// Database
// Database is the shared abstraction over all databases
type Database interface {
Users() UsersDatabase
Shows() ShowsDatabase
}

// UserDatabase abstracts the user interaction with the database.
Expand All @@ -28,3 +30,10 @@ type UsersDatabase interface {
// Details the user based on the email address.
Details(ctx context.Context, email string) (*user.User, error)
}

type ShowsDatabase interface {
// List returns the list of shows. The episode list is empty in each show.
List(context.Context) ([]*show.Show, error)
// Details gives details about a show by a show ID, including the episodes.
Details(context.Context, int) (*show.Show, error)
}
Loading