feat: add a new REST endpoint - GET /api/v1/user/sessions/:server (#14)
continuous-integration/drone/push Build is passing Details

Reviewed-on: #14
This commit is contained in:
Dawid Wysokiński 2022-11-25 05:55:31 +00:00
parent 5872ead643
commit 786cc60e38
15 changed files with 417 additions and 64 deletions

View File

@ -38,7 +38,7 @@ func TestAPIKey_Create(t *testing.T) {
t.Run("ERR: user doesn't exist", func(t *testing.T) {
t.Parallel()
params, err := domain.NewCreateAPIKeyParams(uuid.NewString(), fixture.user(t, "user-1").ID+1)
params, err := domain.NewCreateAPIKeyParams(uuid.NewString(), fixture.user(t, "user-1").ID+11111)
require.NoError(t, err)
apiKey, err := repo.Create(context.Background(), params)

View File

@ -162,6 +162,16 @@ func (f *bunfixture) apiKey(tb testing.TB, id string) domain.APIKey {
return ak.ToDomain()
}
func (f *bunfixture) session(tb testing.TB, id string) domain.Session {
tb.Helper()
row, err := f.Row("Session." + id)
require.NoError(tb, err)
sess, ok := row.(*model.Session)
require.True(tb, ok)
return sess.ToDomain()
}
func generateSchema() string {
return strings.TrimFunc(strings.ReplaceAll(uuid.NewString(), "-", "_"), unicode.IsNumber)
}

View File

@ -2,6 +2,7 @@ package bundb
import (
"context"
"database/sql"
"errors"
"fmt"
@ -20,10 +21,10 @@ func NewSession(db *bun.DB) *Session {
return &Session{db: db}
}
func (u *Session) CreateOrUpdate(ctx context.Context, params domain.CreateSessionParams) (domain.Session, error) {
func (s *Session) CreateOrUpdate(ctx context.Context, params domain.CreateSessionParams) (domain.Session, error) {
sess := model.NewSession(params)
if _, err := u.db.NewInsert().
if _, err := s.db.NewInsert().
Model(&sess).
On("CONFLICT ON CONSTRAINT sessions_user_id_server_key_key DO UPDATE").
Set("sid = EXCLUDED.sid").
@ -39,6 +40,28 @@ func (u *Session) CreateOrUpdate(ctx context.Context, params domain.CreateSessio
return sess.ToDomain(), nil
}
func (s *Session) Get(ctx context.Context, userID int64, serverKey string) (domain.Session, error) {
var sess model.Session
if err := s.db.NewSelect().
Model(&sess).
Where("user_id = ?", userID).
Where("server_key = ?", serverKey).
Scan(ctx); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.Session{}, domain.SessionNotFoundError{ServerKey: serverKey}
}
return domain.Session{}, fmt.Errorf(
"something went wrong while selecting sess (userID=%d,serverKey=%s) from the db: %w",
userID,
serverKey,
err,
)
}
return sess.ToDomain(), nil
}
func mapCreateSessionError(err error, params domain.CreateSessionParams) error {
var pgError pgdriver.Error
if !errors.As(err, &pgError) {

View File

@ -3,6 +3,7 @@ package bundb_test
import (
"context"
"encoding/base64"
"fmt"
"testing"
"time"
@ -73,3 +74,52 @@ func TestSession_CreateOrUpdate(t *testing.T) {
assert.Zero(t, sess)
})
}
func TestSession_Get(t *testing.T) {
t.Parallel()
db := newDB(t)
fixture := loadFixtures(t, db)
repo := bundb.NewSession(db)
sessFixture := fixture.session(t, "user-1-session-1")
t.Run("OK", func(t *testing.T) {
t.Parallel()
sess, err := repo.Get(context.Background(), sessFixture.UserID, sessFixture.ServerKey)
assert.NoError(t, err)
assert.Equal(t, sessFixture, sess)
})
t.Run("ERR: session not found", func(t *testing.T) {
t.Parallel()
tests := []struct {
userID int64
serverKey string
}{
{
userID: sessFixture.UserID + 11111,
serverKey: sessFixture.ServerKey,
},
{
userID: sessFixture.UserID,
serverKey: sessFixture.ServerKey + "1111",
},
}
for _, tt := range tests {
tt := tt
t.Run(fmt.Sprintf("UserID=%d,ServerKey=%s", tt.userID, tt.serverKey), func(t *testing.T) {
t.Parallel()
sess, err := repo.Get(context.Background(), tt.userID, tt.serverKey)
assert.ErrorIs(t, err, domain.SessionNotFoundError{
ServerKey: tt.serverKey,
})
assert.Zero(t, sess)
})
}
})
}

View File

@ -5,10 +5,38 @@
name: User-1
name_lower: user-1
created_at: 2022-03-15T15:00:10.000Z
- _id: user-2
id: 11112
name: User-2
name_lower: user-2
created_at: 2022-04-15T15:00:10.000Z
- model: APIKey
rows:
- _id: user-1-api-key-1
id: 11111
key: 4c0d2d63-4ef7-4c23-bb4d-f3646cc9658f
user_id: 11111
created_at: 2022-03-15T15:15:10.000Z
created_at: 2022-03-15T15:15:10.000Z
- model: Session
rows:
- _id: user-1-session-1
id: 111111
user_id: 11111
server_key: pl181
sid: NzM3NjgzZmMtYWNlNC00MTI4LThiNWYtMWRlNTYwYjdjNmUx
created_at: 2022-03-15T15:15:10.000Z
updated_at: 2022-03-15T15:15:10.000Z
- _id: user-1-session-2
id: 111112
user_id: 11111
server_key: pl183
sid: NzM3NjgzZmMtYWNlNC00MTI4LThiNWYtMWRlNTYwYjdjNmUx
created_at: 2022-03-25T15:15:10.000Z
updated_at: 2022-03-25T15:15:10.000Z
- _id: user-2-session-1
id: 111121
user_id: 11112
server_key: pl181
sid: NzM3NjgzZmMtYWNlNC00MTI4LThiNWYtMWRlNTYwYjdjNmUx
created_at: 2022-04-25T15:15:10.000Z
updated_at: 2022-04-25T15:15:10.000Z

View File

@ -80,9 +80,9 @@ func TestUser_Get(t *testing.T) {
t.Run("ERR: user not found", func(t *testing.T) {
t.Parallel()
user, err := repo.Get(context.Background(), userFromFixture.ID+1)
user, err := repo.Get(context.Background(), userFromFixture.ID+1111111)
assert.ErrorIs(t, err, domain.UserNotFoundError{
ID: userFromFixture.ID + 1,
ID: userFromFixture.ID + 1111111,
})
assert.Zero(t, user)
})

View File

@ -2,6 +2,7 @@ package domain
import (
"encoding/base64"
"fmt"
"time"
)
@ -83,3 +84,19 @@ func isBase64(s string) bool {
_, err := base64.StdEncoding.DecodeString(s)
return err == nil
}
type SessionNotFoundError struct {
ServerKey string
}
func (e SessionNotFoundError) Error() string {
return fmt.Sprintf("session (ServerKey=%s) not found", e.ServerKey)
}
func (e SessionNotFoundError) UserError() string {
return e.Error()
}
func (e SessionNotFoundError) Code() ErrorCode {
return ErrorCodeEntityNotFound
}

View File

@ -2,6 +2,7 @@ package domain_test
import (
"encoding/base64"
"fmt"
"testing"
"gitea.dwysokinski.me/twhelp/sessions/internal/domain"
@ -101,3 +102,15 @@ func TestNewCreateSessionParams(t *testing.T) {
})
}
}
func TestSessionNotFoundError(t *testing.T) {
t.Parallel()
err := domain.SessionNotFoundError{
ServerKey: "pl151",
}
var _ domain.Error = err
assert.Equal(t, fmt.Sprintf("session (ServerKey=%s) not found", err.ServerKey), err.Error())
assert.Equal(t, err.Error(), err.UserError())
assert.Equal(t, domain.ErrorCodeEntityNotFound, err.Code())
}

View File

@ -0,0 +1,23 @@
package model
import "gitea.dwysokinski.me/twhelp/sessions/internal/domain"
type Session struct {
ServerKey string `json:"serverKey"`
SID string `json:"sid"`
} // @name Session
func NewSession(s domain.Session) Session {
return Session{
ServerKey: s.ServerKey,
SID: s.SID,
}
}
type GetSessionResp struct {
Data Session `json:"data"`
} // @name GetSessionResp
func NewGetSessionResp(s domain.Session) GetSessionResp {
return GetSessionResp{Data: NewSession(s)}
}

View File

@ -0,0 +1,47 @@
package model_test
import (
"encoding/base64"
"testing"
"time"
"gitea.dwysokinski.me/twhelp/sessions/internal/domain"
"gitea.dwysokinski.me/twhelp/sessions/internal/router/rest/internal/model"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func TestNewSession(t *testing.T) {
t.Parallel()
sess := domain.Session{
ID: 1111,
UserID: 111,
ServerKey: "pl151",
SID: base64.StdEncoding.EncodeToString([]byte(uuid.NewString())),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
assertSession(t, sess, model.NewSession(sess))
}
func TestNewGetSessionResp(t *testing.T) {
t.Parallel()
sess := domain.Session{
ID: 1111,
UserID: 111,
ServerKey: "pl151",
SID: base64.StdEncoding.EncodeToString([]byte(uuid.NewString())),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
assertSession(t, sess, model.NewGetSessionResp(sess).Data)
}
func assertSession(tb testing.TB, du domain.Session, ru model.Session) {
tb.Helper()
assert.Equal(tb, du.ServerKey, ru.ServerKey)
assert.Equal(tb, du.SID, ru.SID)
}

View File

@ -71,8 +71,9 @@ func NewRouter(cfg RouterConfig) *chi.Mux {
authMw := authMiddleware(cfg.APIKeyVerifier)
r.With(authMw).Get("/user", uh.getAuthenticated)
r.With(authMw).Get("/user", uh.getCurrent)
r.With(authMw).Put("/user/sessions/{serverKey}", sh.createOrUpdate)
r.With(authMw).Get("/user/sessions/{serverKey}", sh.getCurrentUser)
})
return router

View File

@ -6,6 +6,7 @@ import (
"net/http"
"gitea.dwysokinski.me/twhelp/sessions/internal/domain"
"gitea.dwysokinski.me/twhelp/sessions/internal/router/rest/internal/model"
"github.com/go-chi/chi/v5"
)
@ -16,6 +17,7 @@ const (
//counterfeiter:generate -o internal/mock/session_service.gen.go . SessionService
type SessionService interface {
CreateOrUpdate(ctx context.Context, params domain.CreateSessionParams) (domain.Session, error)
Get(ctx context.Context, userID int64, serverKey string) (domain.Session, error)
}
type sessionHandler struct {
@ -28,8 +30,8 @@ type sessionHandler struct {
// @Tags users,sessions
// @Accept plain
// @Success 204
// @Success 400 {object} model.ErrorResp
// @Success 401 {object} model.ErrorResp
// @Failure 400 {object} model.ErrorResp
// @Failure 401 {object} model.ErrorResp
// @Failure 500 {object} model.ErrorResp
// @Security ApiKeyAuth
// @Param serverKey path string true "Server key"
@ -60,3 +62,29 @@ func (h *sessionHandler) createOrUpdate(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusNoContent)
}
// @ID getCurrentUserSession
// @Summary Get a session
// @Description Get a session
// @Tags users,sessions
// @Success 200 {object} model.GetSessionResp
// @Failure 400 {object} model.ErrorResp
// @Failure 401 {object} model.ErrorResp
// @Failure 404 {object} model.ErrorResp
// @Failure 500 {object} model.ErrorResp
// @Security ApiKeyAuth
// @Param serverKey path string true "Server key"
// @Router /user/sessions/{serverKey} [get]
func (h *sessionHandler) getCurrentUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chiCtx := chi.RouteContext(ctx)
user, _ := userFromContext(ctx)
sess, err := h.svc.Get(ctx, user.ID, chiCtx.URLParam("serverKey"))
if err != nil {
renderErr(w, err)
return
}
renderJSON(w, http.StatusOK, model.NewGetSessionResp(sess))
}

View File

@ -34,19 +34,11 @@ func TestSession_createOrUpdate(t *testing.T) {
{
name: "OK",
setup: func(apiKeySvc *mock.FakeAPIKeyVerifier, sessionSvc *mock.FakeSessionService) {
apiKeySvc.VerifyCalls(func(ctx context.Context, key string) (domain.User, error) {
if key != apiKey {
return domain.User{}, domain.APIKeyNotFoundError{
Key: key,
}
}
return domain.User{
ID: 111,
Name: "name",
CreatedAt: now,
}, nil
})
apiKeySvc.VerifyReturns(domain.User{
ID: 111,
Name: "name",
CreatedAt: now,
}, nil)
sessionSvc.CreateOrUpdateReturns(domain.Session{}, nil)
},
apiKey: apiKey,
@ -57,19 +49,11 @@ func TestSession_createOrUpdate(t *testing.T) {
{
name: "ERR: len(serverKey) > 10",
setup: func(apiKeySvc *mock.FakeAPIKeyVerifier, sessionSvc *mock.FakeSessionService) {
apiKeySvc.VerifyCalls(func(ctx context.Context, key string) (domain.User, error) {
if key != apiKey {
return domain.User{}, domain.APIKeyNotFoundError{
Key: key,
}
}
return domain.User{
ID: 111,
Name: "name",
CreatedAt: now,
}, nil
})
apiKeySvc.VerifyReturns(domain.User{
ID: 111,
Name: "name",
CreatedAt: now,
}, nil)
},
apiKey: apiKey,
serverKey: "012345678890",
@ -86,19 +70,11 @@ func TestSession_createOrUpdate(t *testing.T) {
{
name: "ERR: SID is required",
setup: func(apiKeySvc *mock.FakeAPIKeyVerifier, sessionSvc *mock.FakeSessionService) {
apiKeySvc.VerifyCalls(func(ctx context.Context, key string) (domain.User, error) {
if key != apiKey {
return domain.User{}, domain.APIKeyNotFoundError{
Key: key,
}
}
return domain.User{
ID: 111,
Name: "name",
CreatedAt: now,
}, nil
})
apiKeySvc.VerifyReturns(domain.User{
ID: 111,
Name: "name",
CreatedAt: now,
}, nil)
},
apiKey: apiKey,
serverKey: "pl151",
@ -115,19 +91,11 @@ func TestSession_createOrUpdate(t *testing.T) {
{
name: "ERR: SID is not a valid base64",
setup: func(apiKeySvc *mock.FakeAPIKeyVerifier, sessionSvc *mock.FakeSessionService) {
apiKeySvc.VerifyCalls(func(ctx context.Context, key string) (domain.User, error) {
if key != apiKey {
return domain.User{}, domain.APIKeyNotFoundError{
Key: key,
}
}
return domain.User{
ID: 111,
Name: "name",
CreatedAt: now,
}, nil
})
apiKeySvc.VerifyReturns(domain.User{
ID: 111,
Name: "name",
CreatedAt: now,
}, nil)
},
apiKey: apiKey,
serverKey: "pl151",
@ -216,3 +184,139 @@ func TestSession_createOrUpdate(t *testing.T) {
})
}
}
func TestSession_getCurrentUser(t *testing.T) {
t.Parallel()
now := time.Now()
apiKey := uuid.NewString()
sid := base64.StdEncoding.EncodeToString([]byte(uuid.NewString()))
tests := []struct {
name string
setup func(*mock.FakeAPIKeyVerifier, *mock.FakeSessionService)
apiKey string
serverKey string
expectedStatus int
target any
expectedResponse any
}{
{
name: "OK",
setup: func(apiKeySvc *mock.FakeAPIKeyVerifier, sessionSvc *mock.FakeSessionService) {
apiKeySvc.VerifyReturns(domain.User{
ID: 111,
Name: "name",
CreatedAt: now,
}, nil)
sessionSvc.GetCalls(func(ctx context.Context, userID int64, serverKey string) (domain.Session, error) {
return domain.Session{
ID: 111,
UserID: userID,
ServerKey: serverKey,
SID: sid,
CreatedAt: now,
UpdatedAt: now,
}, nil
})
},
apiKey: apiKey,
serverKey: "pl151",
expectedStatus: http.StatusOK,
target: &model.GetSessionResp{},
expectedResponse: &model.GetSessionResp{
Data: model.Session{
ServerKey: "pl151",
SID: sid,
},
},
},
{
name: "ERR: session not found",
setup: func(apiKeySvc *mock.FakeAPIKeyVerifier, sessionSvc *mock.FakeSessionService) {
apiKeySvc.VerifyReturns(domain.User{
ID: 111,
Name: "name",
CreatedAt: now,
}, nil)
sessionSvc.GetCalls(func(ctx context.Context, userID int64, serverKey string) (domain.Session, error) {
return domain.Session{}, domain.SessionNotFoundError{
ServerKey: serverKey,
}
})
},
apiKey: apiKey,
expectedStatus: http.StatusNotFound,
serverKey: "pl151",
target: &model.ErrorResp{},
expectedResponse: &model.ErrorResp{
Error: model.APIError{
Code: domain.ErrorCodeEntityNotFound.String(),
Message: "session (ServerKey=pl151) not found",
},
},
},
{
name: "ERR: apiKey == \"\"",
setup: func(apiKeySvc *mock.FakeAPIKeyVerifier, sessionSvc *mock.FakeSessionService) {},
apiKey: "",
expectedStatus: http.StatusUnauthorized,
serverKey: "pl151",
target: &model.ErrorResp{},
expectedResponse: &model.ErrorResp{
Error: model.APIError{
Code: "unauthorized",
Message: "invalid API key",
},
},
},
{
name: "ERR: unexpected API key",
setup: func(apiKeySvc *mock.FakeAPIKeyVerifier, sessionSvc *mock.FakeSessionService) {
apiKeySvc.VerifyCalls(func(ctx context.Context, key string) (domain.User, error) {
if key != apiKey {
return domain.User{}, domain.APIKeyNotFoundError{
Key: key,
}
}
return domain.User{
ID: 111,
Name: "name",
CreatedAt: now,
}, nil
})
},
apiKey: uuid.NewString(),
serverKey: "pl151",
expectedStatus: http.StatusUnauthorized,
target: &model.ErrorResp{},
expectedResponse: &model.ErrorResp{
Error: model.APIError{
Code: "unauthorized",
Message: "invalid API key",
},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
apiKeySvc := &mock.FakeAPIKeyVerifier{}
sessionSvc := &mock.FakeSessionService{}
tt.setup(apiKeySvc, sessionSvc)
router := rest.NewRouter(rest.RouterConfig{
APIKeyVerifier: apiKeySvc,
SessionService: sessionSvc,
})
resp := doRequest(router, http.MethodGet, "/v1/user/sessions/"+tt.serverKey, tt.apiKey, nil)
defer resp.Body.Close()
assertJSONResponse(t, resp, tt.expectedStatus, tt.expectedResponse, tt.target)
})
}
}

View File

@ -9,17 +9,17 @@ import (
type userHandler struct {
}
// @ID getAuthenticatedUser
// @ID getCurrentUser
// @Summary Get the authenticated user
// @Description Get the authenticated user
// @Tags users
// @Produce json
// @Success 200 {object} model.GetUserResp
// @Success 401 {object} model.ErrorResp
// @Failure 401 {object} model.ErrorResp
// @Failure 500 {object} model.ErrorResp
// @Security ApiKeyAuth
// @Router /user [get]
func (h *userHandler) getAuthenticated(w http.ResponseWriter, r *http.Request) {
func (h *userHandler) getCurrent(w http.ResponseWriter, r *http.Request) {
u, _ := userFromContext(r.Context())
renderJSON(w, http.StatusOK, model.NewGetUserResp(u))
}

View File

@ -11,6 +11,7 @@ import (
//counterfeiter:generate -o internal/mock/session_repository.gen.go . SessionRepository
type SessionRepository interface {
CreateOrUpdate(ctx context.Context, params domain.CreateSessionParams) (domain.Session, error)
Get(ctx context.Context, userID int64, serverKey string) (domain.Session, error)
}
type Session struct {
@ -37,3 +38,11 @@ func (s *Session) CreateOrUpdate(ctx context.Context, params domain.CreateSessio
return sess, nil
}
func (s *Session) Get(ctx context.Context, userID int64, serverKey string) (domain.Session, error) {
sess, err := s.repo.Get(ctx, userID, serverKey)
if err != nil {
return domain.Session{}, fmt.Errorf("SessionRepository.Get: %w", err)
}
return sess, nil
}