feat: initial commit
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
This commit is contained in:
5
internal/store/error.go
Normal file
5
internal/store/error.go
Normal file
@ -0,0 +1,5 @@
|
||||
package store
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
19
internal/store/id.go
Normal file
19
internal/store/id.go
Normal file
@ -0,0 +1,19 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func NewID() string {
|
||||
return uuid.NewString()
|
||||
}
|
||||
|
||||
func ParseID[T ~string](raw string) (T, error) {
|
||||
uuid, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
return *new(T), errors.WithStack(err)
|
||||
}
|
||||
|
||||
return T(uuid.String()), nil
|
||||
}
|
27
internal/store/proxy.go
Normal file
27
internal/store/proxy.go
Normal file
@ -0,0 +1,27 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ProxyID string
|
||||
|
||||
func NewProxyID() ProxyID {
|
||||
return ProxyID(NewID())
|
||||
}
|
||||
|
||||
var ParseProxyID = ParseID[ProxyID]
|
||||
|
||||
type ProxyHeader struct {
|
||||
ID ProxyID
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Proxy struct {
|
||||
ProxyHeader
|
||||
To *url.URL
|
||||
From []string
|
||||
Weight int
|
||||
}
|
73
internal/store/proxy_repository.go
Normal file
73
internal/store/proxy_repository.go
Normal file
@ -0,0 +1,73 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type ProxyRepository interface {
|
||||
CreateProxy(ctx context.Context, to *url.URL, from ...string) (*Proxy, error)
|
||||
UpdateProxy(ctx context.Context, id ProxyID, funcs ...UpdateProxyOptionFunc) (*Proxy, error)
|
||||
QueryProxy(ctx context.Context, funcs ...QueryProxyOptionFunc) ([]*ProxyHeader, error)
|
||||
GetProxy(ctx context.Context, id ProxyID) (*Proxy, error)
|
||||
DeleteProxy(ctx context.Context, id ProxyID) error
|
||||
}
|
||||
|
||||
type UpdateProxyOptionFunc func(*UpdateProxyOptions)
|
||||
|
||||
type UpdateProxyOptions struct {
|
||||
To *url.URL
|
||||
From []string
|
||||
}
|
||||
|
||||
func WithProxyUpdateTo(to *url.URL) UpdateProxyOptionFunc {
|
||||
return func(o *UpdateProxyOptions) {
|
||||
o.To = to
|
||||
}
|
||||
}
|
||||
|
||||
func WithProxyUpdateFrom(from ...string) UpdateProxyOptionFunc {
|
||||
return func(o *UpdateProxyOptions) {
|
||||
o.From = from
|
||||
}
|
||||
}
|
||||
|
||||
type QueryProxyOptionFunc func(*QueryProxyOptions)
|
||||
|
||||
type QueryProxyOptions struct {
|
||||
To *url.URL
|
||||
IDs []ProxyID
|
||||
From []string
|
||||
Offset *int
|
||||
Limit *int
|
||||
}
|
||||
|
||||
func WithProxyQueryOffset(offset int) QueryProxyOptionFunc {
|
||||
return func(o *QueryProxyOptions) {
|
||||
o.Offset = &offset
|
||||
}
|
||||
}
|
||||
|
||||
func WithProxyQueryLimit(limit int) QueryProxyOptionFunc {
|
||||
return func(o *QueryProxyOptions) {
|
||||
o.Limit = &limit
|
||||
}
|
||||
}
|
||||
|
||||
func WithProxyQueryTo(to *url.URL) QueryProxyOptionFunc {
|
||||
return func(o *QueryProxyOptions) {
|
||||
o.To = to
|
||||
}
|
||||
}
|
||||
|
||||
func WithProxyQueryFrom(from ...string) QueryProxyOptionFunc {
|
||||
return func(o *QueryProxyOptions) {
|
||||
o.From = from
|
||||
}
|
||||
}
|
||||
|
||||
func WithProxyQueryIDs(ids ...ProxyID) QueryProxyOptionFunc {
|
||||
return func(o *QueryProxyOptions) {
|
||||
o.IDs = ids
|
||||
}
|
||||
}
|
253
internal/store/redis/proxy_repository.go
Normal file
253
internal/store/redis/proxy_repository.go
Normal file
@ -0,0 +1,253 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/store"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
keyID = "id"
|
||||
keyFrom = "from"
|
||||
keyTo = "to"
|
||||
keyUpdatedAt = "updated_at"
|
||||
keyCreatedAt = "created_at"
|
||||
keyWeight = "weight"
|
||||
keyPrefixProxy = "proxy:"
|
||||
)
|
||||
|
||||
type ProxyRepository struct {
|
||||
client redis.UniversalClient
|
||||
}
|
||||
|
||||
// GetProxy implements store.ProxyRepository
|
||||
func (r *ProxyRepository) GetProxy(ctx context.Context, id store.ProxyID) (*store.Proxy, error) {
|
||||
var proxy store.Proxy
|
||||
|
||||
key := proxyKey(id)
|
||||
|
||||
cmd := r.client.HMGet(ctx, key, keyFrom, keyTo, keyWeight, keyCreatedAt, keyUpdatedAt)
|
||||
|
||||
values, err := cmd.Result()
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if allNilValues(values) {
|
||||
return nil, errors.WithStack(store.ErrNotFound)
|
||||
}
|
||||
|
||||
proxy.ID = id
|
||||
|
||||
from, err := unwrap[[]string](values[0])
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
proxy.From = from
|
||||
|
||||
rawTo, ok := values[1].(string)
|
||||
if !ok {
|
||||
return nil, errors.Errorf("unexpected 'to' value of type '%T'", values[1])
|
||||
}
|
||||
|
||||
to, err := url.Parse(rawTo)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
proxy.To = to
|
||||
|
||||
weight, err := unwrap[int](values[2])
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
proxy.Weight = weight
|
||||
|
||||
createdAt, err := unwrap[time.Time](values[3])
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
proxy.CreatedAt = createdAt
|
||||
|
||||
updatedAt, err := unwrap[time.Time](values[4])
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
proxy.UpdatedAt = updatedAt
|
||||
|
||||
return &proxy, nil
|
||||
}
|
||||
|
||||
// CreateProxy implements store.ProxyRepository
|
||||
func (r *ProxyRepository) CreateProxy(ctx context.Context, to *url.URL, from ...string) (*store.Proxy, error) {
|
||||
id := store.NewProxyID()
|
||||
now := time.Now().UTC()
|
||||
|
||||
_, err := r.client.Pipelined(ctx, func(p redis.Pipeliner) error {
|
||||
key := proxyKey(id)
|
||||
|
||||
p.HMSet(ctx, key, keyID, string(id))
|
||||
p.HMSet(ctx, key, keyFrom, wrap(from))
|
||||
p.HMSet(ctx, key, keyTo, to.String())
|
||||
p.HMSet(ctx, key, keyWeight, wrap(0))
|
||||
p.HMSet(ctx, key, keyCreatedAt, wrap(now))
|
||||
p.HMSet(ctx, key, keyUpdatedAt, wrap(now))
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return &store.Proxy{
|
||||
ProxyHeader: store.ProxyHeader{
|
||||
ID: id,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
To: to,
|
||||
From: from,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteProxy implements store.ProxyRepository
|
||||
func (r *ProxyRepository) DeleteProxy(ctx context.Context, id store.ProxyID) error {
|
||||
key := proxyKey(id)
|
||||
|
||||
if cmd := r.client.Del(ctx, key); cmd.Err() != nil {
|
||||
return errors.WithStack(cmd.Err())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// QueryProxy implements store.ProxyRepository
|
||||
func (r *ProxyRepository) QueryProxy(ctx context.Context, funcs ...store.QueryProxyOptionFunc) ([]*store.ProxyHeader, error) {
|
||||
iter := r.client.Scan(ctx, 0, keyPrefixProxy+"*", 0).Iterator()
|
||||
|
||||
headers := make([]*store.ProxyHeader, 0)
|
||||
|
||||
for iter.Next(ctx) {
|
||||
key := iter.Val()
|
||||
|
||||
cmd := r.client.HMGet(ctx, key, keyID, keyCreatedAt, keyUpdatedAt)
|
||||
|
||||
values, err := cmd.Result()
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if allNilValues(values) {
|
||||
continue
|
||||
}
|
||||
|
||||
id, ok := values[0].(string)
|
||||
if !ok {
|
||||
return nil, errors.Errorf("unexpected 'id' field value for key '%s': '%s'", key, values[0])
|
||||
}
|
||||
|
||||
createdAt, err := unwrap[time.Time](values[1])
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
updatedAt, err := unwrap[time.Time](values[2])
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
h := &store.ProxyHeader{
|
||||
ID: store.ProxyID(id),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
|
||||
headers = append(headers, h)
|
||||
}
|
||||
|
||||
if err := iter.Err(); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return headers, nil
|
||||
}
|
||||
|
||||
// UpdateProxy implements store.ProxyRepository
|
||||
func (*ProxyRepository) UpdateProxy(ctx context.Context, id store.ProxyID, funcs ...store.UpdateProxyOptionFunc) (*store.Proxy, error) {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
func NewProxyRepository(client redis.UniversalClient) *ProxyRepository {
|
||||
return &ProxyRepository{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
var _ store.ProxyRepository = &ProxyRepository{}
|
||||
|
||||
func proxyKey(id store.ProxyID) string {
|
||||
return keyPrefixProxy + string(id)
|
||||
}
|
||||
|
||||
type jsonWrapper[T any] struct {
|
||||
value T
|
||||
}
|
||||
|
||||
func (w *jsonWrapper[T]) MarshalBinary() ([]byte, error) {
|
||||
data, err := json.Marshal(w.value)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (w *jsonWrapper[T]) UnmarshalBinary(data []byte) error {
|
||||
if err := json.Unmarshal(data, &w.value); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *jsonWrapper[T]) Value() T {
|
||||
return w.value
|
||||
}
|
||||
|
||||
func wrap[T any](v T) *jsonWrapper[T] {
|
||||
return &jsonWrapper[T]{v}
|
||||
}
|
||||
|
||||
func unwrap[T any](v any) (T, error) {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return *new(T), errors.Errorf("could not unwrap value of type '%T'", v)
|
||||
}
|
||||
|
||||
u := new(T)
|
||||
|
||||
if err := json.Unmarshal([]byte(str), u); err != nil {
|
||||
return *new(T), errors.WithStack(err)
|
||||
}
|
||||
|
||||
return *u, nil
|
||||
}
|
||||
|
||||
func allNilValues(values []any) bool {
|
||||
for _, v := range values {
|
||||
if v != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
64
internal/store/redis/proxy_repository_test.go
Normal file
64
internal/store/redis/proxy_repository_test.go
Normal file
@ -0,0 +1,64 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/store/testsuite"
|
||||
"github.com/ory/dockertest/v3"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var client redis.UniversalClient
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// uses a sensible default on windows (tcp/http) and linux/osx (socket)
|
||||
pool, err := dockertest.NewPool("")
|
||||
if err != nil {
|
||||
log.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
// uses pool to try to connect to Docker
|
||||
err = pool.Client.Ping()
|
||||
if err != nil {
|
||||
log.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
// pulls an image, creates a container based on it and runs it
|
||||
resource, err := pool.Run("redis", "alpine3.17", []string{})
|
||||
if err != nil {
|
||||
log.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
if err := pool.Retry(func() error {
|
||||
client = redis.NewUniversalClient(&redis.UniversalOptions{
|
||||
Addrs: []string{resource.GetHostPort("6379/tcp")},
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
if cmd := client.Ping(ctx); cmd.Err() != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
log.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
code := m.Run()
|
||||
|
||||
if err := pool.Purge(resource); err != nil {
|
||||
log.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestProxyRepository(t *testing.T) {
|
||||
repository := NewProxyRepository(client)
|
||||
testsuite.TestProxyRepository(t, repository)
|
||||
}
|
191
internal/store/testsuite/proxy_repository.go
Normal file
191
internal/store/testsuite/proxy_repository.go
Normal file
@ -0,0 +1,191 @@
|
||||
package testsuite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"forge.cadoles.com/cadoles/bouncer/internal/store"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type proxyRepositoryTestCase struct {
|
||||
Name string
|
||||
Do func(repo store.ProxyRepository) error
|
||||
}
|
||||
|
||||
var proxyRepositoryTestCases = []proxyRepositoryTestCase{
|
||||
{
|
||||
Name: "Create proxy",
|
||||
Do: func(repo store.ProxyRepository) error {
|
||||
ctx := context.Background()
|
||||
|
||||
url, err := url.Parse("http://example.com")
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
proxy, err := repo.CreateProxy(ctx, url, "*:*")
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if proxy == nil {
|
||||
return errors.Errorf("proxy should not be nil")
|
||||
}
|
||||
|
||||
if proxy.ID == "" {
|
||||
return errors.Errorf("proxy.ID should not be empty")
|
||||
}
|
||||
|
||||
if proxy.To == nil {
|
||||
return errors.Errorf("proxy.To should not be nil")
|
||||
}
|
||||
|
||||
if e, g := url.String(), proxy.To.String(); e != g {
|
||||
return errors.Errorf("proxy.URL.String(): expected '%v', got '%v'", url.String(), proxy.To.String())
|
||||
}
|
||||
|
||||
if proxy.CreatedAt.IsZero() {
|
||||
return errors.Errorf("proxy.CreatedAt should not be zero value")
|
||||
}
|
||||
|
||||
if proxy.UpdatedAt.IsZero() {
|
||||
return errors.Errorf("proxy.UpdatedAt should not be zero value")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Create then get proxy",
|
||||
Do: func(repo store.ProxyRepository) error {
|
||||
ctx := context.Background()
|
||||
|
||||
url, err := url.Parse("http://example.com")
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
createdProxy, err := repo.CreateProxy(ctx, url, "127.0.0.1:*", "localhost:*")
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
foundProxy, err := repo.GetProxy(ctx, createdProxy.ID)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if e, g := createdProxy.ID, foundProxy.ID; e != g {
|
||||
return errors.Errorf("foundProxy.ID: expected '%v', got '%v'", createdProxy.ID, foundProxy.ID)
|
||||
}
|
||||
|
||||
if e, g := createdProxy.From, foundProxy.From; !reflect.DeepEqual(e, g) {
|
||||
return errors.Errorf("foundProxy.From: expected '%v', got '%v'", createdProxy.From, foundProxy.From)
|
||||
}
|
||||
|
||||
if e, g := createdProxy.To.String(), foundProxy.To.String(); e != g {
|
||||
return errors.Errorf("foundProxy.To: expected '%v', got '%v'", createdProxy.To, foundProxy.To)
|
||||
}
|
||||
|
||||
if e, g := createdProxy.CreatedAt, foundProxy.CreatedAt; e != g {
|
||||
return errors.Errorf("foundProxy.CreatedAt: expected '%v', got '%v'", createdProxy.CreatedAt, foundProxy.CreatedAt)
|
||||
}
|
||||
|
||||
if e, g := createdProxy.UpdatedAt, foundProxy.UpdatedAt; e != g {
|
||||
return errors.Errorf("foundProxy.UpdatedAt: expected '%v', got '%v'", createdProxy.UpdatedAt, foundProxy.UpdatedAt)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Create then delete proxy",
|
||||
Do: func(repo store.ProxyRepository) error {
|
||||
ctx := context.Background()
|
||||
|
||||
url, err := url.Parse("http://example.com")
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
createdProxy, err := repo.CreateProxy(ctx, url, "127.0.0.1:*", "localhost:*")
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := repo.DeleteProxy(ctx, createdProxy.ID); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
foundProxy, err := repo.GetProxy(ctx, createdProxy.ID)
|
||||
if err == nil {
|
||||
return errors.New("err should not be nil")
|
||||
}
|
||||
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
return errors.Errorf("err should be store.ErrNotFound, got '%+v'", err)
|
||||
}
|
||||
|
||||
if foundProxy != nil {
|
||||
return errors.Errorf("foundProxy should be nil, got '%v'", foundProxy)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Create then query",
|
||||
Do: func(repo store.ProxyRepository) error {
|
||||
ctx := context.Background()
|
||||
|
||||
url, err := url.Parse("http://example.com")
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
createdProxy, err := repo.CreateProxy(ctx, url, "127.0.0.1:*", "localhost:*")
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
headers, err := repo.QueryProxy(ctx)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if len(headers) < 1 {
|
||||
return errors.Errorf("len(headers): expected value > 1, got '%v'", len(headers))
|
||||
}
|
||||
|
||||
found := false
|
||||
|
||||
for _, h := range headers {
|
||||
if h.ID == createdProxy.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return errors.New("could not find created proxy in query results")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestProxyRepository(t *testing.T, repo store.ProxyRepository) {
|
||||
for _, tc := range proxyRepositoryTestCases {
|
||||
func(tc proxyRepositoryTestCase) {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
if err := tc.Do(repo); err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
})
|
||||
}(tc)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user