Skip to content

Commit 6248b45

Browse files
author
Wojciech Bednarzak
committed
Add database interface for shows and episode
This abstracts getting the data from the database. In the commit there are 2 implementations: consul and sql.
1 parent 2eba5ec commit 6248b45

File tree

10 files changed

+531
-5
lines changed

10 files changed

+531
-5
lines changed

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ go 1.16
55
require (
66
cloud.google.com/go v0.78.0 // indirect
77
github.com/go-sql-driver/mysql v1.4.0
8+
github.com/go-test/deep v1.0.7
89
github.com/gorilla/mux v1.6.2
910
github.com/gorilla/sessions v1.1.2
11+
github.com/hashicorp/consul/api v1.8.1
1012
github.com/mattn/go-sqlite3 v1.14.6
1113
golang.org/x/net v0.0.0-20210119194325-5f4716e94777
1214
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99

go.sum

Lines changed: 79 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package consul
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"path"
8+
"strings"
9+
10+
"github.com/hashicorp/consul/api"
11+
12+
"tracker/internal/database"
13+
)
14+
15+
// Database contains methods for getting the data about shows and episodes
16+
type Database struct {
17+
kv KV
18+
19+
prefix string
20+
}
21+
22+
func NewDatabase(prefix string, opts ...Option) (*Database, error) {
23+
client, err := api.NewClient(api.DefaultConfig())
24+
if err != nil {
25+
return nil, fmt.Errorf("unable to create client: %w", err)
26+
}
27+
28+
db := &Database{
29+
kv: client.KV(),
30+
prefix: prefix,
31+
}
32+
33+
for _, opt := range opts {
34+
opt(db)
35+
}
36+
37+
return db, nil
38+
}
39+
40+
// get value from the database
41+
func (db *Database) get(ctx context.Context, key string, value interface{}) error {
42+
opt := &api.QueryOptions{}
43+
opt = opt.WithContext(ctx)
44+
45+
p := path.Join(db.prefix, key)
46+
47+
kv, _, err := db.kv.Get(p, opt)
48+
if err != nil {
49+
return fmt.Errorf("unable to fetch %s: %w", p, err)
50+
}
51+
if kv == nil {
52+
return fmt.Errorf("%s not found: %w", p, database.ErrNotFound)
53+
}
54+
55+
if err := json.Unmarshal(kv.Value, value); err != nil {
56+
return fmt.Errorf("unable to unmarshal data: %w", err)
57+
}
58+
59+
return nil
60+
}
61+
62+
func (db *Database) list(ctx context.Context, prefix string) (map[string][]byte, error) {
63+
opt := &api.QueryOptions{}
64+
opt = opt.WithContext(ctx)
65+
66+
p := path.Join(db.prefix, prefix)
67+
68+
kvs, _, err := db.kv.List(p, opt)
69+
if err != nil {
70+
return nil, fmt.Errorf("unable to list %s: %w", p, err)
71+
}
72+
73+
m := make(map[string][]byte, len(kvs))
74+
for _, kv := range kvs {
75+
// Remove all the prefixes from the keys.
76+
m[strings.TrimPrefix(kv.Key, p+"/")] = kv.Value
77+
}
78+
79+
return m, nil
80+
}
81+
82+
// Option allows to set options for the Consul database.
83+
type Option func(*Database)
84+
85+
func KVClient(kv KV) Option {
86+
return func(db *Database) {
87+
db.kv = kv
88+
}
89+
}
90+
91+
// KV abstracts over consul KV interface, for testability
92+
type KV interface {
93+
Put(p *api.KVPair, q *api.WriteOptions) (*api.WriteMeta, error)
94+
Get(key string, q *api.QueryOptions) (*api.KVPair, *api.QueryMeta, error)
95+
List(prefix string, q *api.QueryOptions) (api.KVPairs, *api.QueryMeta, error)
96+
}

internal/database/consul/shows.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package consul
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"path"
8+
9+
"tracker/internal/types/show"
10+
)
11+
12+
type ShowsDatabase struct {
13+
db *Database
14+
prefix string
15+
}
16+
17+
func (db *Database) Shows() *ShowsDatabase {
18+
return &ShowsDatabase{
19+
db: db,
20+
prefix: "shows",
21+
}
22+
}
23+
24+
func (db *ShowsDatabase) List(ctx context.Context) ([]*show.Show, error) {
25+
showIDs, err := db.list(ctx, "list")
26+
if err != nil {
27+
return nil, fmt.Errorf("unable to get show IDs")
28+
}
29+
30+
// TODO: Speed up by running these in parallel
31+
var shows []*show.Show
32+
for showID := range showIDs {
33+
show, err := db.showDetails(ctx, showID)
34+
if err != nil {
35+
return nil, fmt.Errorf("unable to get show %s: %w", showID, err)
36+
}
37+
shows = append(shows, show)
38+
}
39+
40+
return shows, nil
41+
}
42+
43+
func (db *ShowsDatabase) Details(ctx context.Context, id string) (*show.Show, error) {
44+
// TODO: Run getting show details and show episodes in parallel
45+
s, err := db.showDetails(ctx, id)
46+
if err != nil {
47+
return nil, fmt.Errorf("unable to get details about %s: %w", id, err)
48+
}
49+
50+
episodes, err := db.list(ctx, path.Join("episodes", id))
51+
if err != nil {
52+
return nil, fmt.Errorf("unable to get episodes for %s: %w", id, err)
53+
}
54+
55+
for _, ep := range episodes {
56+
var episode show.Episode
57+
if err := json.Unmarshal(ep, &episode); err != nil {
58+
return nil, fmt.Errorf("unable to parse episode: %w", err)
59+
}
60+
s.Episodes = append(s.Episodes, &episode)
61+
}
62+
63+
return s, nil
64+
}
65+
66+
func (db *ShowsDatabase) showDetails(ctx context.Context, id string) (*show.Show, error) {
67+
var show show.Show
68+
if err := db.get(ctx, path.Join("list", id), &show); err != nil {
69+
return nil, err
70+
}
71+
72+
return &show, nil
73+
}
74+
75+
func (db *ShowsDatabase) get(ctx context.Context, key string, value interface{}) error {
76+
return db.db.get(ctx, path.Join(db.prefix, key), value)
77+
}
78+
79+
func (db *ShowsDatabase) list(ctx context.Context, prefix string) (map[string][]byte, error) {
80+
return db.db.list(ctx, path.Join(db.prefix, prefix))
81+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package consul
2+
3+
import (
4+
"context"
5+
"errors"
6+
"strings"
7+
"testing"
8+
"tracker/internal/types/show"
9+
10+
"github.com/go-test/deep"
11+
"github.com/hashicorp/consul/api"
12+
)
13+
14+
var errNotFound = errors.New("kv: not found")
15+
16+
var testData = map[string][]byte{
17+
"tracker/shows/list/westworld": []byte(`{"id": 1, "name": "Westworld"}`),
18+
"tracker/shows/episodes/westworld_s01e01": []byte(`{"title":"The Original", "season": 1, "episode": 1}`),
19+
20+
"tracker/shows/list/expanse": []byte(`{"id": 2, "name": "The Expanse"}`),
21+
}
22+
23+
func TestList(t *testing.T) {
24+
kv := &testKV{m: testData}
25+
db, err := NewDatabase("tracker", KVClient(kv))
26+
if err != nil {
27+
t.Fatalf("unable to setup database: %v", err)
28+
}
29+
30+
showsDB := db.Shows()
31+
32+
want := []*show.Show{
33+
{ID: 2, Name: "The Expanse"},
34+
{ID: 1, Name: "Westworld"},
35+
}
36+
37+
got, err := showsDB.List(context.Background())
38+
if err != nil {
39+
t.Fatalf("unexpected error listing shows: %v", err)
40+
}
41+
42+
if diff := deep.Equal(got, want); diff != nil {
43+
t.Fatalf("List() = %v, want %v, diff = %v", got, want, diff)
44+
}
45+
}
46+
47+
func TestDetails(t *testing.T) {
48+
kv := &testKV{m: testData}
49+
db, err := NewDatabase("tracker", KVClient(kv))
50+
if err != nil {
51+
t.Fatalf("unable to setup database: %v", err)
52+
}
53+
54+
showsDB := db.Shows()
55+
56+
want := &show.Show{
57+
ID: 1,
58+
Name: "Westworld",
59+
Episodes: []*show.Episode{
60+
{Title: "The Original", Season: 1, Episode: 1},
61+
},
62+
}
63+
64+
got, err := showsDB.Details(context.Background(), "westworld")
65+
if err != nil {
66+
t.Fatalf("unexpected error getting details: %v", err)
67+
}
68+
69+
if diff := deep.Equal(got, want); diff != nil {
70+
t.Fatalf("Details() = %v, want %v, diff = %v", got, want, diff)
71+
}
72+
73+
}
74+
75+
type testKV struct {
76+
m map[string][]byte
77+
}
78+
79+
func (kv *testKV) Get(key string, _ *api.QueryOptions) (*api.KVPair, *api.QueryMeta, error) {
80+
if v, exists := kv.m[key]; exists {
81+
return &api.KVPair{Key: key, Value: v}, nil, nil
82+
} else {
83+
return nil, nil, errNotFound
84+
}
85+
}
86+
87+
func (kv *testKV) List(prefix string, _ *api.QueryOptions) (api.KVPairs, *api.QueryMeta, error) {
88+
var pairs []*api.KVPair
89+
for key, value := range kv.m {
90+
if strings.HasPrefix(key, prefix) {
91+
pairs = append(pairs, &api.KVPair{Key: key, Value: value})
92+
}
93+
}
94+
95+
return api.KVPairs(pairs), nil, nil
96+
}
97+
98+
func (kv *testKV) Put(_ *api.KVPair, _ *api.WriteOptions) (*api.WriteMeta, error) {
99+
return nil, nil
100+
}

internal/database/database.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package database
2+
3+
import (
4+
"context"
5+
"tracker/internal/types/show"
6+
)
7+
8+
type Error string
9+
10+
func (e Error) Error() string {
11+
return string(e)
12+
}
13+
14+
const (
15+
ErrNotFound = Error("not found")
16+
)
17+
18+
// Database
19+
type Database interface {
20+
Shows() ShowsDatabase
21+
}
22+
23+
type ShowsDatabase interface {
24+
// List returns the list of shows. The episode list is empty in each show.
25+
List(context.Context) ([]*show.Show, error)
26+
// Details gives details about a show by a show ID, including the episodes.
27+
Details(context.Context, int) (*show.Show, error)
28+
}

internal/database/sql/database.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package sql
2+
3+
import "database/sql"
4+
5+
type Database struct {
6+
db *sql.DB
7+
}
8+
9+
func NewDatabase(db *sql.DB) *Database {
10+
return &Database{db: db}
11+
}

0 commit comments

Comments
 (0)