Added a basic homepage
This commit is contained in:
parent
d40640a648
commit
12fd3c04ca
113 changed files with 414 additions and 506 deletions
218
internal/services/auth.go
Normal file
218
internal/services/auth.go
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/camzawacki/personal-site/config"
|
||||
"github.com/camzawacki/personal-site/ent"
|
||||
"github.com/camzawacki/personal-site/ent/passwordtoken"
|
||||
"github.com/camzawacki/personal-site/ent/user"
|
||||
"github.com/camzawacki/personal-site/internal/context"
|
||||
"github.com/camzawacki/personal-site/internal/session"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
// authSessionName stores the name of the session which contains authentication data
|
||||
authSessionName = "ua"
|
||||
|
||||
// authSessionKeyUserID stores the key used to store the user ID in the session
|
||||
authSessionKeyUserID = "user_id"
|
||||
|
||||
// authSessionKeyAuthenticated stores the key used to store the authentication status in the session
|
||||
authSessionKeyAuthenticated = "authenticated"
|
||||
)
|
||||
|
||||
// NotAuthenticatedError is an error returned when a user is not authenticated
|
||||
type NotAuthenticatedError struct{}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e NotAuthenticatedError) Error() string {
|
||||
return "user not authenticated"
|
||||
}
|
||||
|
||||
// InvalidPasswordTokenError is an error returned when an invalid token is provided
|
||||
type InvalidPasswordTokenError struct{}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e InvalidPasswordTokenError) Error() string {
|
||||
return "invalid password token"
|
||||
}
|
||||
|
||||
// AuthClient is the client that handles authentication requests
|
||||
type AuthClient struct {
|
||||
config *config.Config
|
||||
orm *ent.Client
|
||||
}
|
||||
|
||||
// NewAuthClient creates a new authentication client
|
||||
func NewAuthClient(cfg *config.Config, orm *ent.Client) *AuthClient {
|
||||
return &AuthClient{
|
||||
config: cfg,
|
||||
orm: orm,
|
||||
}
|
||||
}
|
||||
|
||||
// Login logs in a user of a given ID
|
||||
func (c *AuthClient) Login(ctx echo.Context, userID int) error {
|
||||
sess, err := session.Get(ctx, authSessionName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess.Values[authSessionKeyUserID] = userID
|
||||
sess.Values[authSessionKeyAuthenticated] = true
|
||||
return sess.Save(ctx.Request(), ctx.Response())
|
||||
}
|
||||
|
||||
// Logout logs the requesting user out
|
||||
func (c *AuthClient) Logout(ctx echo.Context) error {
|
||||
sess, err := session.Get(ctx, authSessionName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess.Values[authSessionKeyAuthenticated] = false
|
||||
return sess.Save(ctx.Request(), ctx.Response())
|
||||
}
|
||||
|
||||
// GetAuthenticatedUserID returns the authenticated user's ID, if the user is logged in
|
||||
func (c *AuthClient) GetAuthenticatedUserID(ctx echo.Context) (int, error) {
|
||||
sess, err := session.Get(ctx, authSessionName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if sess.Values[authSessionKeyAuthenticated] == true {
|
||||
return sess.Values[authSessionKeyUserID].(int), nil
|
||||
}
|
||||
|
||||
return 0, NotAuthenticatedError{}
|
||||
}
|
||||
|
||||
// GetAuthenticatedUser returns the authenticated user if the user is logged in
|
||||
func (c *AuthClient) GetAuthenticatedUser(ctx echo.Context) (*ent.User, error) {
|
||||
if userID, err := c.GetAuthenticatedUserID(ctx); err == nil {
|
||||
return c.orm.User.Query().
|
||||
Where(user.ID(userID)).
|
||||
Only(ctx.Request().Context())
|
||||
}
|
||||
|
||||
return nil, NotAuthenticatedError{}
|
||||
}
|
||||
|
||||
// CheckPassword check if a given password matches a given hash
|
||||
func (c *AuthClient) CheckPassword(password, hash string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
}
|
||||
|
||||
// GeneratePasswordResetToken generates a password reset token for a given user.
|
||||
// For security purposes, the token itself is not stored in the database but rather
|
||||
// a hash of the token, exactly how passwords are handled. This method returns both
|
||||
// the generated token and the token entity which only contains the hash.
|
||||
func (c *AuthClient) GeneratePasswordResetToken(ctx echo.Context, userID int) (string, *ent.PasswordToken, error) {
|
||||
// Generate the token, which is what will go in the URL, but not the database
|
||||
token, err := c.RandomToken(c.config.App.PasswordToken.Length)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
// Create and save the password reset token
|
||||
pt, err := c.orm.PasswordToken.
|
||||
Create().
|
||||
SetToken(token).
|
||||
SetUserID(userID).
|
||||
Save(ctx.Request().Context())
|
||||
|
||||
return token, pt, err
|
||||
}
|
||||
|
||||
// GetValidPasswordToken returns a valid, non-expired password token entity for a given user, token ID and token.
|
||||
// Since the actual token is not stored in the database for security purposes, if a matching password token entity is
|
||||
// found a hash of the provided token is compared with the hash stored in the database in order to validate.
|
||||
func (c *AuthClient) GetValidPasswordToken(ctx echo.Context, userID, tokenID int, token string) (*ent.PasswordToken, error) {
|
||||
// Ensure expired tokens are never returned
|
||||
expiration := time.Now().Add(-c.config.App.PasswordToken.Expiration)
|
||||
|
||||
// Query to find a password token entity that matches the given user and token ID
|
||||
pt, err := c.orm.PasswordToken.
|
||||
Query().
|
||||
Where(passwordtoken.ID(tokenID)).
|
||||
Where(passwordtoken.HasUserWith(user.ID(userID))).
|
||||
Where(passwordtoken.CreatedAtGTE(expiration)).
|
||||
Only(ctx.Request().Context())
|
||||
|
||||
switch err.(type) {
|
||||
case *ent.NotFoundError:
|
||||
case nil:
|
||||
// Check the token for a hash match
|
||||
if err := c.CheckPassword(token, pt.Token); err == nil {
|
||||
return pt, nil
|
||||
}
|
||||
default:
|
||||
if !context.IsCanceledError(err) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return nil, InvalidPasswordTokenError{}
|
||||
}
|
||||
|
||||
// DeletePasswordTokens deletes all password tokens in the database for a belonging to a given user.
|
||||
// This should be called after a successful password reset.
|
||||
func (c *AuthClient) DeletePasswordTokens(ctx echo.Context, userID int) error {
|
||||
_, err := c.orm.PasswordToken.
|
||||
Delete().
|
||||
Where(passwordtoken.HasUserWith(user.ID(userID))).
|
||||
Exec(ctx.Request().Context())
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// RandomToken generates a random token string of a given length
|
||||
func (c *AuthClient) RandomToken(length int) (string, error) {
|
||||
b := make([]byte, (length/2)+1)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := hex.EncodeToString(b)
|
||||
return token[:length], nil
|
||||
}
|
||||
|
||||
// GenerateEmailVerificationToken generates an email verification token for a given email address using JWT which
|
||||
// is set to expire based on the duration stored in configuration
|
||||
func (c *AuthClient) GenerateEmailVerificationToken(email string) (string, error) {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"email": email,
|
||||
"exp": time.Now().Add(c.config.App.EmailVerificationTokenExpiration).Unix(),
|
||||
})
|
||||
|
||||
return token.SignedString([]byte(c.config.App.EncryptionKey))
|
||||
}
|
||||
|
||||
// ValidateEmailVerificationToken validates an email verification token and returns the associated email address if
|
||||
// the token is valid and has not expired
|
||||
func (c *AuthClient) ValidateEmailVerificationToken(token string) (string, error) {
|
||||
t, err := jwt.Parse(token, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
|
||||
return []byte(c.config.App.EncryptionKey), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if claims, ok := t.Claims.(jwt.MapClaims); ok && t.Valid {
|
||||
return claims["email"].(string), nil
|
||||
}
|
||||
|
||||
return "", errors.New("invalid or expired token")
|
||||
}
|
||||
147
internal/services/auth_test.go
Normal file
147
internal/services/auth_test.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/camzawacki/personal-site/ent/passwordtoken"
|
||||
"github.com/camzawacki/personal-site/ent/user"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAuthClient_Auth(t *testing.T) {
|
||||
assertNoAuth := func() {
|
||||
_, err := c.Auth.GetAuthenticatedUserID(ctx)
|
||||
assert.True(t, errors.Is(err, NotAuthenticatedError{}))
|
||||
_, err = c.Auth.GetAuthenticatedUser(ctx)
|
||||
assert.True(t, errors.Is(err, NotAuthenticatedError{}))
|
||||
}
|
||||
|
||||
assertNoAuth()
|
||||
|
||||
err := c.Auth.Login(ctx, usr.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
uid, err := c.Auth.GetAuthenticatedUserID(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, usr.ID, uid)
|
||||
|
||||
u, err := c.Auth.GetAuthenticatedUser(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, u.ID, usr.ID)
|
||||
|
||||
err = c.Auth.Logout(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertNoAuth()
|
||||
}
|
||||
|
||||
func TestAuthClient_CheckPassword(t *testing.T) {
|
||||
pw := "testcheckpassword"
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, hash, pw)
|
||||
err = c.Auth.CheckPassword(pw, string(hash))
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestAuthClient_GeneratePasswordResetToken(t *testing.T) {
|
||||
token, pt, err := c.Auth.GeneratePasswordResetToken(ctx, usr.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, token, c.Config.App.PasswordToken.Length)
|
||||
assert.NoError(t, c.Auth.CheckPassword(token, pt.Token))
|
||||
}
|
||||
|
||||
func TestAuthClient_GetValidPasswordToken(t *testing.T) {
|
||||
// Check that a fake token is not valid
|
||||
_, err := c.Auth.GetValidPasswordToken(ctx, usr.ID, 1, "faketoken")
|
||||
assert.Error(t, err)
|
||||
|
||||
// Generate a valid token and check that it is returned
|
||||
token, pt, err := c.Auth.GeneratePasswordResetToken(ctx, usr.ID)
|
||||
require.NoError(t, err)
|
||||
pt2, err := c.Auth.GetValidPasswordToken(ctx, usr.ID, pt.ID, token)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, pt.ID, pt2.ID)
|
||||
|
||||
// Expire the token by pushing the date far enough back
|
||||
count, err := c.ORM.PasswordToken.
|
||||
Update().
|
||||
SetCreatedAt(time.Now().Add(-(c.Config.App.PasswordToken.Expiration + time.Hour))).
|
||||
Where(passwordtoken.ID(pt.ID)).
|
||||
Save(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, count)
|
||||
|
||||
// Expired tokens should not be valid
|
||||
_, err = c.Auth.GetValidPasswordToken(ctx, usr.ID, pt.ID, token)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestAuthClient_DeletePasswordTokens(t *testing.T) {
|
||||
// Create three tokens for the user
|
||||
for i := 0; i < 3; i++ {
|
||||
_, _, err := c.Auth.GeneratePasswordResetToken(ctx, usr.ID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Delete all tokens for the user
|
||||
err := c.Auth.DeletePasswordTokens(ctx, usr.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that no tokens remain
|
||||
count, err := c.ORM.PasswordToken.
|
||||
Query().
|
||||
Where(passwordtoken.HasUserWith(user.ID(usr.ID))).
|
||||
Count(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, count)
|
||||
}
|
||||
|
||||
func TestAuthClient_RandomToken(t *testing.T) {
|
||||
length := c.Config.App.PasswordToken.Length
|
||||
a, err := c.Auth.RandomToken(length)
|
||||
require.NoError(t, err)
|
||||
b, err := c.Auth.RandomToken(length)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, a, length)
|
||||
assert.Len(t, b, length)
|
||||
assert.NotEqual(t, a, b)
|
||||
}
|
||||
|
||||
func TestAuthClient_EmailVerificationToken(t *testing.T) {
|
||||
t.Run("valid token", func(t *testing.T) {
|
||||
email := "test@localhost.com"
|
||||
token, err := c.Auth.GenerateEmailVerificationToken(email)
|
||||
require.NoError(t, err)
|
||||
|
||||
tokenEmail, err := c.Auth.ValidateEmailVerificationToken(token)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, email, tokenEmail)
|
||||
})
|
||||
|
||||
t.Run("invalid token", func(t *testing.T) {
|
||||
badToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAbG9jYWxob3N0LmNvbSIsImV4cCI6MTkxNzg2NDAwMH0.ScJCpfEEzlilKfRs_aVouzwPNKI28M3AIm-hyImQHUQ"
|
||||
_, err := c.Auth.ValidateEmailVerificationToken(badToken)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("expired token", func(t *testing.T) {
|
||||
c.Config.App.EmailVerificationTokenExpiration = -time.Hour
|
||||
email := "test@localhost.com"
|
||||
token, err := c.Auth.GenerateEmailVerificationToken(email)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = c.Auth.ValidateEmailVerificationToken(token)
|
||||
assert.Error(t, err)
|
||||
|
||||
c.Config.App.EmailVerificationTokenExpiration = time.Hour * 12
|
||||
})
|
||||
}
|
||||
351
internal/services/cache.go
Normal file
351
internal/services/cache.go
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/maypok86/otter"
|
||||
)
|
||||
|
||||
// ErrCacheMiss indicates that the requested key does not exist in the cache
|
||||
var ErrCacheMiss = errors.New("cache miss")
|
||||
|
||||
type (
|
||||
// CacheStore provides an interface for cache storage
|
||||
CacheStore interface {
|
||||
// get attempts to get a cached value
|
||||
get(context.Context, *CacheGetOp) (any, error)
|
||||
|
||||
// set attempts to set an entry in the cache
|
||||
set(context.Context, *CacheSetOp) error
|
||||
|
||||
// flush removes a given key and/or tags from the cache
|
||||
flush(context.Context, *CacheFlushOp) error
|
||||
|
||||
// close shuts down the cache storage
|
||||
close()
|
||||
}
|
||||
|
||||
// CacheClient is the client that allows you to interact with the cache
|
||||
CacheClient struct {
|
||||
// store holds the Cache storage
|
||||
store CacheStore
|
||||
}
|
||||
|
||||
// CacheSetOp handles chaining a set operation
|
||||
CacheSetOp struct {
|
||||
client *CacheClient
|
||||
key string
|
||||
group string
|
||||
data any
|
||||
expiration time.Duration
|
||||
tags []string
|
||||
}
|
||||
|
||||
// CacheGetOp handles chaining a get operation
|
||||
CacheGetOp struct {
|
||||
client *CacheClient
|
||||
key string
|
||||
group string
|
||||
}
|
||||
|
||||
// CacheFlushOp handles chaining a flush operation
|
||||
CacheFlushOp struct {
|
||||
client *CacheClient
|
||||
key string
|
||||
group string
|
||||
tags []string
|
||||
}
|
||||
|
||||
// inMemoryCacheStore is a cache store implementation in memory
|
||||
inMemoryCacheStore struct {
|
||||
store *otter.CacheWithVariableTTL[string, any]
|
||||
tagIndex *tagIndex
|
||||
}
|
||||
|
||||
// tagIndex maintains an index to support cache tags for in-memory cache stores.
|
||||
// There is a performance and memory impact to using cache tags since set and get operations using tags will require
|
||||
// locking, and we need to keep track of this index in order to keep everything in sync.
|
||||
// If using something like Redis for caching, you can leverage sets to store the index.
|
||||
// Cache tags can be useful and convenient, so you should decide if your app benefits enough from this.
|
||||
// As it stands here, there is no limiting how much memory this will consume and it will track all keys
|
||||
// and tags added and removed from the cache. You could store these in the cache itself but allowing these to
|
||||
// be evicted poses challenges.
|
||||
tagIndex struct {
|
||||
sync.Mutex
|
||||
tags map[string]map[string]struct{} // tag->keys
|
||||
keys map[string]map[string]struct{} // key->tags
|
||||
}
|
||||
)
|
||||
|
||||
// NewCacheClient creates a new cache client
|
||||
func NewCacheClient(store CacheStore) *CacheClient {
|
||||
return &CacheClient{store: store}
|
||||
}
|
||||
|
||||
// Close closes the connection to the cache
|
||||
func (c *CacheClient) Close() {
|
||||
c.store.close()
|
||||
}
|
||||
|
||||
// Set creates a cache set operation
|
||||
func (c *CacheClient) Set() *CacheSetOp {
|
||||
return &CacheSetOp{
|
||||
client: c,
|
||||
}
|
||||
}
|
||||
|
||||
// Get creates a cache get operation
|
||||
func (c *CacheClient) Get() *CacheGetOp {
|
||||
return &CacheGetOp{
|
||||
client: c,
|
||||
}
|
||||
}
|
||||
|
||||
// Flush creates a cache flush operation
|
||||
func (c *CacheClient) Flush() *CacheFlushOp {
|
||||
return &CacheFlushOp{
|
||||
client: c,
|
||||
}
|
||||
}
|
||||
|
||||
// cacheKey formats a cache key with an optional group
|
||||
func (c *CacheClient) cacheKey(group, key string) string {
|
||||
if group != "" {
|
||||
return fmt.Sprintf("%s::%s", group, key)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// Key sets the cache key
|
||||
func (c *CacheSetOp) Key(key string) *CacheSetOp {
|
||||
c.key = key
|
||||
return c
|
||||
}
|
||||
|
||||
// Group sets the cache group
|
||||
func (c *CacheSetOp) Group(group string) *CacheSetOp {
|
||||
c.group = group
|
||||
return c
|
||||
}
|
||||
|
||||
// Data sets the data to cache
|
||||
func (c *CacheSetOp) Data(data any) *CacheSetOp {
|
||||
c.data = data
|
||||
return c
|
||||
}
|
||||
|
||||
// Expiration sets the expiration duration of the cached data
|
||||
func (c *CacheSetOp) Expiration(expiration time.Duration) *CacheSetOp {
|
||||
c.expiration = expiration
|
||||
return c
|
||||
}
|
||||
|
||||
// Tags sets the cache tags
|
||||
func (c *CacheSetOp) Tags(tags ...string) *CacheSetOp {
|
||||
c.tags = tags
|
||||
return c
|
||||
}
|
||||
|
||||
// Save saves the data in the cache
|
||||
func (c *CacheSetOp) Save(ctx context.Context) error {
|
||||
switch {
|
||||
case c.key == "":
|
||||
return errors.New("no cache key specified")
|
||||
case c.data == nil:
|
||||
return errors.New("no cache data specified")
|
||||
case c.expiration == 0:
|
||||
return errors.New("no cache expiration specified")
|
||||
}
|
||||
|
||||
return c.client.store.set(ctx, c)
|
||||
}
|
||||
|
||||
// Key sets the cache key
|
||||
func (c *CacheGetOp) Key(key string) *CacheGetOp {
|
||||
c.key = key
|
||||
return c
|
||||
}
|
||||
|
||||
// Group sets the cache group
|
||||
func (c *CacheGetOp) Group(group string) *CacheGetOp {
|
||||
c.group = group
|
||||
return c
|
||||
}
|
||||
|
||||
// Fetch fetches the data from the cache
|
||||
func (c *CacheGetOp) Fetch(ctx context.Context) (any, error) {
|
||||
if c.key == "" {
|
||||
return nil, errors.New("no cache key specified")
|
||||
}
|
||||
|
||||
return c.client.store.get(ctx, c)
|
||||
}
|
||||
|
||||
// Key sets the cache key
|
||||
func (c *CacheFlushOp) Key(key string) *CacheFlushOp {
|
||||
c.key = key
|
||||
return c
|
||||
}
|
||||
|
||||
// Group sets the cache group
|
||||
func (c *CacheFlushOp) Group(group string) *CacheFlushOp {
|
||||
c.group = group
|
||||
return c
|
||||
}
|
||||
|
||||
// Tags sets the cache tags
|
||||
func (c *CacheFlushOp) Tags(tags ...string) *CacheFlushOp {
|
||||
c.tags = tags
|
||||
return c
|
||||
}
|
||||
|
||||
// Execute flushes the data from the cache
|
||||
func (c *CacheFlushOp) Execute(ctx context.Context) error {
|
||||
return c.client.store.flush(ctx, c)
|
||||
}
|
||||
|
||||
// newInMemoryCache creates a new in-memory CacheStore
|
||||
func newInMemoryCache(capacity int) (CacheStore, error) {
|
||||
s := &inMemoryCacheStore{
|
||||
tagIndex: newTagIndex(),
|
||||
}
|
||||
|
||||
store, err := otter.MustBuilder[string, any](capacity).
|
||||
WithVariableTTL().
|
||||
DeletionListener(func(key string, value any, cause otter.DeletionCause) {
|
||||
s.tagIndex.purgeKeys(key)
|
||||
}).
|
||||
Build()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.store = &store
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *inMemoryCacheStore) get(_ context.Context, op *CacheGetOp) (any, error) {
|
||||
v, exists := s.store.Get(op.client.cacheKey(op.group, op.key))
|
||||
|
||||
if !exists {
|
||||
return nil, ErrCacheMiss
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (s *inMemoryCacheStore) set(_ context.Context, op *CacheSetOp) error {
|
||||
key := op.client.cacheKey(op.group, op.key)
|
||||
|
||||
added := s.store.Set(
|
||||
key,
|
||||
op.data,
|
||||
op.expiration,
|
||||
)
|
||||
|
||||
if len(op.tags) > 0 {
|
||||
s.tagIndex.setTags(key, op.tags...)
|
||||
}
|
||||
|
||||
if !added {
|
||||
return errors.New("cache set failed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *inMemoryCacheStore) flush(_ context.Context, op *CacheFlushOp) error {
|
||||
keys := make([]string, 0)
|
||||
|
||||
if key := op.client.cacheKey(op.group, op.key); key != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
|
||||
if len(op.tags) > 0 {
|
||||
keys = append(keys, s.tagIndex.purgeTags(op.tags...)...)
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
s.store.Delete(key)
|
||||
}
|
||||
|
||||
s.tagIndex.purgeKeys(keys...)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *inMemoryCacheStore) close() {
|
||||
s.store.Close()
|
||||
}
|
||||
|
||||
func newTagIndex() *tagIndex {
|
||||
return &tagIndex{
|
||||
tags: make(map[string]map[string]struct{}),
|
||||
keys: make(map[string]map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (i *tagIndex) setTags(key string, tags ...string) {
|
||||
i.Lock()
|
||||
defer i.Unlock()
|
||||
|
||||
if _, exists := i.keys[key]; !exists {
|
||||
i.keys[key] = make(map[string]struct{})
|
||||
}
|
||||
|
||||
for _, tag := range tags {
|
||||
if _, exists := i.tags[tag]; !exists {
|
||||
i.tags[tag] = make(map[string]struct{})
|
||||
}
|
||||
i.tags[tag][key] = struct{}{}
|
||||
i.keys[key][tag] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func (i *tagIndex) purgeTags(tags ...string) []string {
|
||||
i.Lock()
|
||||
defer i.Unlock()
|
||||
|
||||
keys := make([]string, 0)
|
||||
|
||||
for _, tag := range tags {
|
||||
if tagKeys, exists := i.tags[tag]; exists {
|
||||
delete(i.tags, tag)
|
||||
|
||||
for key := range tagKeys {
|
||||
delete(i.keys[key], tag)
|
||||
if len(i.keys[key]) == 0 {
|
||||
delete(i.keys, key)
|
||||
}
|
||||
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func (i *tagIndex) purgeKeys(keys ...string) {
|
||||
i.Lock()
|
||||
defer i.Unlock()
|
||||
|
||||
for _, key := range keys {
|
||||
if keyTags, exists := i.keys[key]; exists {
|
||||
delete(i.keys, key)
|
||||
|
||||
for tag := range keyTags {
|
||||
delete(i.tags[tag], key)
|
||||
if len(i.tags[tag]) == 0 {
|
||||
delete(i.tags, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
105
internal/services/cache_test.go
Normal file
105
internal/services/cache_test.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCacheClient(t *testing.T) {
|
||||
type cacheTest struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
// Cache some data
|
||||
data := cacheTest{Value: "abcdef"}
|
||||
group := "testgroup"
|
||||
key := "testkey"
|
||||
err := c.Cache.
|
||||
Set().
|
||||
Group(group).
|
||||
Key(key).
|
||||
Data(data).
|
||||
Expiration(500 * time.Millisecond).
|
||||
Save(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get the data
|
||||
fromCache, err := c.Cache.
|
||||
Get().
|
||||
Group(group).
|
||||
Key(key).
|
||||
Fetch(context.Background())
|
||||
require.NoError(t, err)
|
||||
cast, ok := fromCache.(cacheTest)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, data, cast)
|
||||
|
||||
// The same key with the wrong group should fail
|
||||
_, err = c.Cache.
|
||||
Get().
|
||||
Key(key).
|
||||
Fetch(context.Background())
|
||||
assert.Equal(t, ErrCacheMiss, err)
|
||||
|
||||
// Flush the data
|
||||
err = c.Cache.
|
||||
Flush().
|
||||
Group(group).
|
||||
Key(key).
|
||||
Execute(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// The data should be gone
|
||||
assertFlushed := func(key string) {
|
||||
// The data should be gone
|
||||
_, err = c.Cache.
|
||||
Get().
|
||||
Group(group).
|
||||
Key(key).
|
||||
Fetch(context.Background())
|
||||
assert.Equal(t, ErrCacheMiss, err)
|
||||
}
|
||||
assertFlushed(key)
|
||||
|
||||
// Set with tags
|
||||
key = "testkey2"
|
||||
err = c.Cache.
|
||||
Set().
|
||||
Group(group).
|
||||
Key(key).
|
||||
Data(data).
|
||||
Tags("tag1", "tag2").
|
||||
Expiration(time.Hour).
|
||||
Save(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check the tag index
|
||||
index := c.Cache.store.(*inMemoryCacheStore).tagIndex
|
||||
gk := c.Cache.cacheKey(group, key)
|
||||
_, exists := index.tags["tag1"][gk]
|
||||
assert.True(t, exists)
|
||||
_, exists = index.tags["tag2"][gk]
|
||||
assert.True(t, exists)
|
||||
_, exists = index.keys[gk]["tag1"]
|
||||
assert.True(t, exists)
|
||||
_, exists = index.keys[gk]["tag2"]
|
||||
assert.True(t, exists)
|
||||
|
||||
// Flush one of tags
|
||||
err = c.Cache.
|
||||
Flush().
|
||||
Tags("tag1").
|
||||
Execute(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// The data should be gone
|
||||
assertFlushed(key)
|
||||
|
||||
// The index should be empty
|
||||
assert.Empty(t, index.tags)
|
||||
assert.Empty(t, index.keys)
|
||||
}
|
||||
246
internal/services/container.go
Normal file
246
internal/services/container.go
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
entsql "entgo.io/ent/dialect/sql"
|
||||
"github.com/labstack/echo/v4"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/mikestefanello/backlite"
|
||||
"github.com/camzawacki/personal-site/config"
|
||||
"github.com/camzawacki/personal-site/ent"
|
||||
"github.com/camzawacki/personal-site/internal/log"
|
||||
"github.com/spf13/afero"
|
||||
|
||||
// Required by ent.
|
||||
_ "github.com/camzawacki/personal-site/ent/runtime"
|
||||
)
|
||||
|
||||
// Container contains all services used by the application and provides an easy way to handle dependency
|
||||
// injection including within tests.
|
||||
type Container struct {
|
||||
// Validator stores a validator
|
||||
Validator *Validator
|
||||
|
||||
// Web stores the web framework.
|
||||
Web *echo.Echo
|
||||
|
||||
// Config stores the application configuration.
|
||||
Config *config.Config
|
||||
|
||||
// Cache contains the cache client.
|
||||
Cache *CacheClient
|
||||
|
||||
// Database stores the connection to the database.
|
||||
Database *sql.DB
|
||||
|
||||
// Files stores the file system.
|
||||
Files afero.Fs
|
||||
|
||||
// ORM stores a client to the ORM.
|
||||
ORM *ent.Client
|
||||
|
||||
// Mail stores an email sending client.
|
||||
Mail *MailClient
|
||||
|
||||
// Auth stores an authentication client.
|
||||
Auth *AuthClient
|
||||
|
||||
// Tasks stores the task client.
|
||||
Tasks *backlite.Client
|
||||
}
|
||||
|
||||
// NewContainer creates and initializes a new Container.
|
||||
func NewContainer() *Container {
|
||||
c := new(Container)
|
||||
c.initConfig()
|
||||
c.initValidator()
|
||||
c.initWeb()
|
||||
c.initCache()
|
||||
c.initDatabase()
|
||||
c.initFiles()
|
||||
c.initORM()
|
||||
c.initAuth()
|
||||
c.initMail()
|
||||
c.initTasks()
|
||||
return c
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts the Container down and disconnects all connections.
|
||||
func (c *Container) Shutdown() error {
|
||||
// Shutdown the web server.
|
||||
webCtx, webCancel := context.WithTimeout(context.Background(), c.Config.HTTP.ShutdownTimeout)
|
||||
defer webCancel()
|
||||
if err := c.Web.Shutdown(webCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Shutdown the task runner.
|
||||
taskCtx, taskCancel := context.WithTimeout(context.Background(), c.Config.Tasks.ShutdownTimeout)
|
||||
defer taskCancel()
|
||||
c.Tasks.Stop(taskCtx)
|
||||
|
||||
// Shutdown the ORM.
|
||||
if err := c.ORM.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Shutdown the database.
|
||||
if err := c.Database.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Shutdown the cache.
|
||||
c.Cache.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// initConfig initializes configuration.
|
||||
func (c *Container) initConfig() {
|
||||
cfg, err := config.GetConfig()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to load config: %v", err))
|
||||
}
|
||||
c.Config = &cfg
|
||||
|
||||
// Configure logging.
|
||||
switch cfg.App.Environment {
|
||||
case config.EnvProduction:
|
||||
slog.SetLogLoggerLevel(slog.LevelInfo)
|
||||
default:
|
||||
slog.SetLogLoggerLevel(slog.LevelDebug)
|
||||
}
|
||||
}
|
||||
|
||||
// initValidator initializes the validator.
|
||||
func (c *Container) initValidator() {
|
||||
c.Validator = NewValidator()
|
||||
}
|
||||
|
||||
// initWeb initializes the web framework.
|
||||
func (c *Container) initWeb() {
|
||||
c.Web = echo.New()
|
||||
c.Web.HideBanner = true
|
||||
c.Web.Validator = c.Validator
|
||||
}
|
||||
|
||||
// initCache initializes the cache.
|
||||
func (c *Container) initCache() {
|
||||
store, err := newInMemoryCache(c.Config.Cache.Capacity)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
c.Cache = NewCacheClient(store)
|
||||
}
|
||||
|
||||
// initDatabase initializes the database.
|
||||
func (c *Container) initDatabase() {
|
||||
var err error
|
||||
var connection string
|
||||
|
||||
switch c.Config.App.Environment {
|
||||
case config.EnvTest:
|
||||
// TODO: Drop/recreate the DB, if this isn't in memory?
|
||||
connection = c.Config.Database.TestConnection
|
||||
default:
|
||||
connection = c.Config.Database.Connection
|
||||
}
|
||||
|
||||
c.Database, err = openDB(c.Config.Database.Driver, connection)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// initFiles initializes the file system.
|
||||
func (c *Container) initFiles() {
|
||||
// Use in-memory storage for tests.
|
||||
if c.Config.App.Environment == config.EnvTest {
|
||||
c.Files = afero.NewMemMapFs()
|
||||
return
|
||||
}
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
if err := fs.MkdirAll(c.Config.Files.Directory, 0755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
c.Files = afero.NewBasePathFs(fs, c.Config.Files.Directory)
|
||||
}
|
||||
|
||||
// initORM initializes the ORM.
|
||||
func (c *Container) initORM() {
|
||||
drv := entsql.OpenDB(c.Config.Database.Driver, c.Database)
|
||||
c.ORM = ent.NewClient(ent.Driver(drv))
|
||||
|
||||
// Run the auto migration tool.
|
||||
if err := c.ORM.Schema.Create(context.Background()); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// initAuth initializes the authentication client.
|
||||
func (c *Container) initAuth() {
|
||||
c.Auth = NewAuthClient(c.Config, c.ORM)
|
||||
}
|
||||
|
||||
// initMail initialize the mail client.
|
||||
func (c *Container) initMail() {
|
||||
var err error
|
||||
c.Mail, err = NewMailClient(c.Config)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to create mail client: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// initTasks initializes the task client.
|
||||
func (c *Container) initTasks() {
|
||||
var err error
|
||||
// You could use a separate database for tasks, if you'd like, but using one
|
||||
// makes transaction support easier.
|
||||
c.Tasks, err = backlite.NewClient(backlite.ClientConfig{
|
||||
DB: c.Database,
|
||||
Logger: log.Default(),
|
||||
NumWorkers: c.Config.Tasks.Goroutines,
|
||||
ReleaseAfter: c.Config.Tasks.ReleaseAfter,
|
||||
CleanupInterval: c.Config.Tasks.CleanupInterval,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to create task client: %v", err))
|
||||
}
|
||||
|
||||
if err = c.Tasks.Install(); err != nil {
|
||||
panic(fmt.Sprintf("failed to install task schema: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// openDB opens a database connection.
|
||||
func openDB(driver, connection string) (*sql.DB, error) {
|
||||
if driver == "sqlite3" {
|
||||
// Helper to automatically create the directories that the specified sqlite file
|
||||
// should reside in, if one.
|
||||
d := strings.Split(connection, "/")
|
||||
if len(d) > 1 {
|
||||
dirpath := strings.Join(d[:len(d)-1], "/")
|
||||
|
||||
if err := os.MkdirAll(dirpath, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a random value is required, which is often used for in-memory test databases.
|
||||
if strings.Contains(connection, "$RAND") {
|
||||
connection = strings.Replace(connection, "$RAND", fmt.Sprint(rand.Int()), 1)
|
||||
}
|
||||
}
|
||||
|
||||
return sql.Open(driver, connection)
|
||||
}
|
||||
20
internal/services/container_test.go
Normal file
20
internal/services/container_test.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewContainer(t *testing.T) {
|
||||
assert.NotNil(t, c.Web)
|
||||
assert.NotNil(t, c.Config)
|
||||
assert.NotNil(t, c.Validator)
|
||||
assert.NotNil(t, c.Cache)
|
||||
assert.NotNil(t, c.Database)
|
||||
assert.NotNil(t, c.Files)
|
||||
assert.NotNil(t, c.ORM)
|
||||
assert.NotNil(t, c.Mail)
|
||||
assert.NotNil(t, c.Auth)
|
||||
assert.NotNil(t, c.Tasks)
|
||||
}
|
||||
127
internal/services/mail.go
Normal file
127
internal/services/mail.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
|
||||
"github.com/camzawacki/personal-site/config"
|
||||
"github.com/camzawacki/personal-site/internal/log"
|
||||
"maragu.dev/gomponents"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type (
|
||||
// MailClient provides a client for sending email
|
||||
// This is purposely not completed because there are many different methods and services
|
||||
// for sending email, many of which are very different. Choose what works best for you
|
||||
// and populate the methods below. For now, emails will just be logged.
|
||||
MailClient struct {
|
||||
// config stores application configuration.
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// mail represents an email to be sent.
|
||||
mail struct {
|
||||
client *MailClient
|
||||
from string
|
||||
to string
|
||||
subject string
|
||||
body string
|
||||
component gomponents.Node
|
||||
}
|
||||
)
|
||||
|
||||
// NewMailClient creates a new MailClient.
|
||||
func NewMailClient(cfg *config.Config) (*MailClient, error) {
|
||||
return &MailClient{
|
||||
config: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Compose creates a new email.
|
||||
func (m *MailClient) Compose() *mail {
|
||||
return &mail{
|
||||
client: m,
|
||||
from: m.config.Mail.FromAddress,
|
||||
}
|
||||
}
|
||||
|
||||
// skipSend determines if mail sending should be skipped.
|
||||
func (m *MailClient) skipSend() bool {
|
||||
return m.config.App.Environment != config.EnvProduction
|
||||
}
|
||||
|
||||
// send attempts to send the email.
|
||||
func (m *MailClient) send(email *mail, ctx echo.Context) error {
|
||||
switch {
|
||||
case email.to == "":
|
||||
return errors.New("email cannot be sent without a to address")
|
||||
case email.body == "" && email.component == nil:
|
||||
return errors.New("email cannot be sent without a body or component to render")
|
||||
}
|
||||
|
||||
// Check if a component was supplied.
|
||||
if email.component != nil {
|
||||
// Render the component and use as the body.
|
||||
// TODO pool the buffers?
|
||||
buf := bytes.NewBuffer(nil)
|
||||
if err := email.component.Render(buf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
email.body = buf.String()
|
||||
}
|
||||
|
||||
// Check if mail sending should be skipped.
|
||||
if m.skipSend() {
|
||||
log.Ctx(ctx).Debug("skipping email delivery",
|
||||
"to", email.to,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: Finish based on your mail sender of choice or stop logging below!
|
||||
log.Ctx(ctx).Info("sending email",
|
||||
"to", email.to,
|
||||
"subject", email.subject,
|
||||
"body", email.body,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// From sets the email from address.
|
||||
func (m *mail) From(from string) *mail {
|
||||
m.from = from
|
||||
return m
|
||||
}
|
||||
|
||||
// To sets the email address this email will be sent to.
|
||||
func (m *mail) To(to string) *mail {
|
||||
m.to = to
|
||||
return m
|
||||
}
|
||||
|
||||
// Subject sets the subject line of the email.
|
||||
func (m *mail) Subject(subject string) *mail {
|
||||
m.subject = subject
|
||||
return m
|
||||
}
|
||||
|
||||
// Body sets the body of the email.
|
||||
// This is not required and will be ignored if a component is set via Component().
|
||||
func (m *mail) Body(body string) *mail {
|
||||
m.body = body
|
||||
return m
|
||||
}
|
||||
|
||||
// Component sets a renderable component to use as the body of the email.
|
||||
func (m *mail) Component(component gomponents.Node) *mail {
|
||||
m.component = component
|
||||
return m
|
||||
}
|
||||
|
||||
// Send attempts to send the email.
|
||||
func (m *mail) Send(ctx echo.Context) error {
|
||||
return m.client.send(m, ctx)
|
||||
}
|
||||
3
internal/services/mail_test.go
Normal file
3
internal/services/mail_test.go
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
package services
|
||||
|
||||
// Fill this in once you implement your mail client
|
||||
46
internal/services/services_test.go
Normal file
46
internal/services/services_test.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/camzawacki/personal-site/config"
|
||||
"github.com/camzawacki/personal-site/ent"
|
||||
"github.com/camzawacki/personal-site/internal/tests"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
var (
|
||||
c *Container
|
||||
ctx echo.Context
|
||||
usr *ent.User
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Set the environment to test
|
||||
config.SwitchEnvironment(config.EnvTest)
|
||||
|
||||
// Create a new container
|
||||
c = NewContainer()
|
||||
|
||||
// Create a web context
|
||||
ctx, _ = tests.NewContext(c.Web, "/")
|
||||
tests.InitSession(ctx)
|
||||
|
||||
// Create a test user
|
||||
var err error
|
||||
if usr, err = tests.CreateUser(c.ORM); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Run tests
|
||||
exitVal := m.Run()
|
||||
|
||||
// Shutdown the container
|
||||
if err = c.Shutdown(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
os.Exit(exitVal)
|
||||
}
|
||||
26
internal/services/validator.go
Normal file
26
internal/services/validator.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// Validator provides validation mainly validating structs within the web context
|
||||
type Validator struct {
|
||||
// validator stores the underlying validator
|
||||
validator *validator.Validate
|
||||
}
|
||||
|
||||
// NewValidator creats a new Validator
|
||||
func NewValidator() *Validator {
|
||||
return &Validator{
|
||||
validator: validator.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// Validate validates a struct
|
||||
func (v *Validator) Validate(i any) error {
|
||||
if err := v.validator.Struct(i); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
19
internal/services/validator_test.go
Normal file
19
internal/services/validator_test.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestValidator(t *testing.T) {
|
||||
type example struct {
|
||||
Value string `validate:"required"`
|
||||
}
|
||||
e := example{}
|
||||
err := c.Validator.Validate(e)
|
||||
assert.Error(t, err)
|
||||
e.Value = "a"
|
||||
err = c.Validator.Validate(e)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue