feat: add a new REST endpoint - GET /api/v1/user
continuous-integration/drone/pr Build is failing Details

This commit is contained in:
Dawid Wysokiński 2022-11-21 06:44:12 +01:00
parent 87da75847d
commit b057a13bb3
Signed by: Kichiyaki
GPG Key ID: B5445E357FB8B892
9 changed files with 409 additions and 95 deletions

View File

@ -0,0 +1,120 @@
// Code generated by counterfeiter. DO NOT EDIT.
package mock
import (
"context"
"sync"
"gitea.dwysokinski.me/twhelp/sessions/internal/domain"
"gitea.dwysokinski.me/twhelp/sessions/internal/router/rest"
)
type FakeAPIKeyVerifier struct {
VerifyStub func(context.Context, string) (domain.User, error)
verifyMutex sync.RWMutex
verifyArgsForCall []struct {
arg1 context.Context
arg2 string
}
verifyReturns struct {
result1 domain.User
result2 error
}
verifyReturnsOnCall map[int]struct {
result1 domain.User
result2 error
}
invocations map[string][][]interface{}
invocationsMutex sync.RWMutex
}
func (fake *FakeAPIKeyVerifier) Verify(arg1 context.Context, arg2 string) (domain.User, error) {
fake.verifyMutex.Lock()
ret, specificReturn := fake.verifyReturnsOnCall[len(fake.verifyArgsForCall)]
fake.verifyArgsForCall = append(fake.verifyArgsForCall, struct {
arg1 context.Context
arg2 string
}{arg1, arg2})
stub := fake.VerifyStub
fakeReturns := fake.verifyReturns
fake.recordInvocation("Verify", []interface{}{arg1, arg2})
fake.verifyMutex.Unlock()
if stub != nil {
return stub(arg1, arg2)
}
if specificReturn {
return ret.result1, ret.result2
}
return fakeReturns.result1, fakeReturns.result2
}
func (fake *FakeAPIKeyVerifier) VerifyCallCount() int {
fake.verifyMutex.RLock()
defer fake.verifyMutex.RUnlock()
return len(fake.verifyArgsForCall)
}
func (fake *FakeAPIKeyVerifier) VerifyCalls(stub func(context.Context, string) (domain.User, error)) {
fake.verifyMutex.Lock()
defer fake.verifyMutex.Unlock()
fake.VerifyStub = stub
}
func (fake *FakeAPIKeyVerifier) VerifyArgsForCall(i int) (context.Context, string) {
fake.verifyMutex.RLock()
defer fake.verifyMutex.RUnlock()
argsForCall := fake.verifyArgsForCall[i]
return argsForCall.arg1, argsForCall.arg2
}
func (fake *FakeAPIKeyVerifier) VerifyReturns(result1 domain.User, result2 error) {
fake.verifyMutex.Lock()
defer fake.verifyMutex.Unlock()
fake.VerifyStub = nil
fake.verifyReturns = struct {
result1 domain.User
result2 error
}{result1, result2}
}
func (fake *FakeAPIKeyVerifier) VerifyReturnsOnCall(i int, result1 domain.User, result2 error) {
fake.verifyMutex.Lock()
defer fake.verifyMutex.Unlock()
fake.VerifyStub = nil
if fake.verifyReturnsOnCall == nil {
fake.verifyReturnsOnCall = make(map[int]struct {
result1 domain.User
result2 error
})
}
fake.verifyReturnsOnCall[i] = struct {
result1 domain.User
result2 error
}{result1, result2}
}
func (fake *FakeAPIKeyVerifier) Invocations() map[string][][]interface{} {
fake.invocationsMutex.RLock()
defer fake.invocationsMutex.RUnlock()
fake.verifyMutex.RLock()
defer fake.verifyMutex.RUnlock()
copiedInvocations := map[string][][]interface{}{}
for key, value := range fake.invocations {
copiedInvocations[key] = value
}
return copiedInvocations
}
func (fake *FakeAPIKeyVerifier) recordInvocation(key string, args []interface{}) {
fake.invocationsMutex.Lock()
defer fake.invocationsMutex.Unlock()
if fake.invocations == nil {
fake.invocations = map[string][][]interface{}{}
}
if fake.invocations[key] == nil {
fake.invocations[key] = [][]interface{}{}
}
fake.invocations[key] = append(fake.invocations[key], args)
}
var _ rest.APIKeyVerifier = new(FakeAPIKeyVerifier)

View File

@ -1,6 +1,7 @@
package model
type APIError struct {
//nolint:lll
Code string `json:"code" enums:"entity-not-found,route-not-found,method-not-allowed,validation-error,unauthorized,already-exists,internal-server-error"`
Message string `json:"message"`
} // @name ApiError

View File

@ -0,0 +1,29 @@
package model
import (
"time"
"gitea.dwysokinski.me/twhelp/sessions/internal/domain"
)
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"createdAt" format:"date-time"`
} // @name User
func NewUser(u domain.User) User {
return User{
ID: u.ID,
Name: u.Name,
CreatedAt: u.CreatedAt,
}
}
type GetUserResp struct {
Data User `json:"data"`
} // @name GetUserResp
func NewGetUserResp(u domain.User) GetUserResp {
return GetUserResp{Data: NewUser(u)}
}

View File

@ -0,0 +1,50 @@
package rest
import (
"net/http"
"gitea.dwysokinski.me/twhelp/sessions/internal/domain"
"gitea.dwysokinski.me/twhelp/sessions/internal/router/rest/internal/model"
"golang.org/x/net/context"
)
type authCtxKey struct{}
func authMiddleware(verifier APIKeyVerifier) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("X-API-Key")
if key == "" {
renderJSON(w, http.StatusUnauthorized, model.ErrorResp{
Error: model.APIError{
Code: "unauthorized",
Message: "invalid API key",
},
})
return
}
u, err := verifier.Verify(r.Context(), key)
if err != nil {
renderJSON(w, http.StatusUnauthorized, model.ErrorResp{
Error: model.APIError{
Code: "unauthorized",
Message: "invalid API key",
},
})
return
}
next.ServeHTTP(w, r.WithContext(userToContext(r.Context(), u)))
})
}
}
func userToContext(ctx context.Context, u domain.User) context.Context {
return context.WithValue(ctx, authCtxKey{}, u)
}
func userFromContext(ctx context.Context) (domain.User, bool) {
u, ok := ctx.Value(authCtxKey{}).(domain.User)
return u, ok
}

View File

@ -2,8 +2,8 @@ package rest
//go:generate swag init -g openapi.go -o "./internal/docs"
// @title Sessions API
// @version 1.0
// @title Sessions API
// @version 1.0
// @contact.name Dawid Wysokiński
// @contact.url https://dwysokinski.me
@ -14,5 +14,5 @@ package rest
// @query.collection.format multi
// @securityDefinitions.apikey ApiKeyAuth
// @in header
// @name X-Api-Key
// @in header
// @name X-Api-Key

View File

@ -3,8 +3,6 @@ package rest
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"gitea.dwysokinski.me/twhelp/sessions/internal/domain"
@ -31,6 +29,7 @@ type SwaggerConfig struct {
Schemes []string
}
//counterfeiter:generate -o internal/mock/api_key_verifier.gen.go . APIKeyVerifier
type APIKeyVerifier interface {
Verify(ctx context.Context, key string) (domain.User, error)
}
@ -42,6 +41,9 @@ type RouterConfig struct {
}
func NewRouter(cfg RouterConfig) *chi.Mux {
// handlers
uh := userHandler{}
router := chi.NewRouter()
if cfg.CORS.Enabled {
@ -64,13 +66,9 @@ func NewRouter(cfg RouterConfig) *chi.Mux {
r.Get("/swagger/*", httpSwagger.Handler())
}
r.Route("/", func(r chi.Router) {
r.Use(authMiddleware(cfg.APIKeyVerifier))
authMw := authMiddleware(cfg.APIKeyVerifier)
r.Get("/user", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
})
r.With(authMw).Get("/user", uh.getAuthenticated)
})
return router
@ -94,84 +92,53 @@ func methodNotAllowedHandler(w http.ResponseWriter, _ *http.Request) {
})
}
func authMiddleware(verifier APIKeyVerifier) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("X-API-Key")
if key == "" {
renderJSON(w, http.StatusUnauthorized, model.ErrorResp{
Error: model.APIError{
Code: "unauthorized",
Message: "invalid API key",
},
})
return
}
user, err := verifier.Verify(r.Context(), key)
if err != nil {
renderJSON(w, http.StatusUnauthorized, model.ErrorResp{
Error: model.APIError{
Code: "unauthorized",
Message: "invalid API key",
},
})
return
}
fmt.Println(user)
next.ServeHTTP(w, r)
})
}
}
type internalServerError struct {
err error
}
func (e internalServerError) Error() string {
return e.err.Error()
}
func (e internalServerError) UserError() string {
return "internal server error"
}
func (e internalServerError) Code() domain.ErrorCode {
return domain.ErrorCodeUnknown
}
func (e internalServerError) Unwrap() error {
return e.err
}
func renderErr(w http.ResponseWriter, err error) {
var userError domain.Error
if !errors.As(err, &userError) {
userError = internalServerError{err: err}
}
renderJSON(w, errorCodeToHTTPStatus(userError.Code()), model.ErrorResp{
Error: model.APIError{
Code: userError.Code().String(),
Message: userError.UserError(),
},
})
}
func errorCodeToHTTPStatus(code domain.ErrorCode) int {
switch code {
case domain.ErrorCodeValidationError:
return http.StatusBadRequest
case domain.ErrorCodeEntityNotFound:
return http.StatusNotFound
case domain.ErrorCodeAlreadyExists:
return http.StatusConflict
case domain.ErrorCodeUnknown:
fallthrough
default:
return http.StatusInternalServerError
}
}
// type internalServerError struct {
// err error
// }
//
// func (e internalServerError) Error() string {
// return e.err.Error()
// }
//
// func (e internalServerError) UserError() string {
// return "internal server error"
// }
//
// func (e internalServerError) Code() domain.ErrorCode {
// return domain.ErrorCodeUnknown
// }
//
// func (e internalServerError) Unwrap() error {
// return e.err
// }
//
// func renderErr(w http.ResponseWriter, err error) {
// var userError domain.Error
// if !errors.As(err, &userError) {
// userError = internalServerError{err: err}
// }
// renderJSON(w, errorCodeToHTTPStatus(userError.Code()), model.ErrorResp{
// Error: model.APIError{
// Code: userError.Code().String(),
// Message: userError.UserError(),
// },
// })
// }
//
// func errorCodeToHTTPStatus(code domain.ErrorCode) int {
// switch code {
// case domain.ErrorCodeValidationError:
// return http.StatusBadRequest
// case domain.ErrorCodeEntityNotFound:
// return http.StatusNotFound
// case domain.ErrorCodeAlreadyExists:
// return http.StatusConflict
// case domain.ErrorCodeUnknown:
// fallthrough
// default:
// return http.StatusInternalServerError
// }
// }
func renderJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")

View File

@ -27,7 +27,7 @@ func TestRouteNotFound(t *testing.T) {
},
}
resp := doRequest(rest.NewRouter(rest.RouterConfig{}), http.MethodGet, "/v1/"+uuid.NewString(), nil)
resp := doRequest(rest.NewRouter(rest.RouterConfig{}), http.MethodGet, "/v1/"+uuid.NewString(), "", nil)
defer resp.Body.Close()
assertJSONResponse(t, resp, http.StatusNotFound, expectedResp, &model.ErrorResp{})
}
@ -46,7 +46,7 @@ func TestMethodNotAllowed(t *testing.T) {
Swagger: rest.SwaggerConfig{
Enabled: true,
},
}), http.MethodPost, "/v1/swagger/index.html", nil)
}), http.MethodPost, "/v1/swagger/index.html", "", nil)
defer resp.Body.Close()
assertJSONResponse(t, resp, http.StatusMethodNotAllowed, expectedResp, &model.ErrorResp{})
}
@ -82,7 +82,7 @@ func TestSwagger(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
resp := doRequest(router, http.MethodGet, tt.target, nil)
resp := doRequest(router, http.MethodGet, tt.target, "", nil)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, tt.expectedContentType, resp.Header.Get("Content-Type"))
@ -90,9 +90,13 @@ func TestSwagger(t *testing.T) {
}
}
func doRequest(mux chi.Router, method, target string, body io.Reader) *http.Response {
func doRequest(mux chi.Router, method, target, apiKey string, body io.Reader) *http.Response {
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, httptest.NewRequest(method, target, body))
req := httptest.NewRequest(method, target, body)
if apiKey != "" {
req.Header.Set("X-Api-Key", apiKey)
}
mux.ServeHTTP(rr, req)
return rr.Result()
}

View File

@ -0,0 +1,25 @@
package rest
import (
"net/http"
"gitea.dwysokinski.me/twhelp/sessions/internal/router/rest/internal/model"
)
type userHandler struct {
}
// @ID getAuthenticatedUser
// @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 500 {object} model.ErrorResp
// @Security ApiKeyAuth
// @Router /user [get]
func (h *userHandler) getAuthenticated(w http.ResponseWriter, r *http.Request) {
u, _ := userFromContext(r.Context())
renderJSON(w, http.StatusOK, model.NewGetUserResp(u))
}

View File

@ -0,0 +1,118 @@
package rest_test
import (
"context"
"net/http"
"testing"
"time"
"gitea.dwysokinski.me/twhelp/sessions/internal/domain"
"gitea.dwysokinski.me/twhelp/sessions/internal/router/rest"
"gitea.dwysokinski.me/twhelp/sessions/internal/router/rest/internal/mock"
"gitea.dwysokinski.me/twhelp/sessions/internal/router/rest/internal/model"
"github.com/google/uuid"
)
func TestUser_getAuthenticated(t *testing.T) {
t.Parallel()
now := time.Now()
apiKey := uuid.NewString()
tests := []struct {
name string
setup func(apiKeySvc *mock.FakeAPIKeyVerifier)
apiKey string
expectedStatus int
target any
expectedResponse any
}{
{
name: "OK",
setup: func(apiKeySvc *mock.FakeAPIKeyVerifier) {
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: apiKey,
expectedStatus: http.StatusOK,
target: &model.GetUserResp{},
expectedResponse: &model.GetUserResp{
Data: model.User{
ID: 111,
Name: "name",
CreatedAt: now,
},
},
},
{
name: "ERR: apiKey == \"\"",
setup: func(apiKeySvc *mock.FakeAPIKeyVerifier) {},
apiKey: "",
expectedStatus: http.StatusUnauthorized,
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) {
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(),
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{}
tt.setup(apiKeySvc)
router := rest.NewRouter(rest.RouterConfig{
APIKeyVerifier: apiKeySvc,
})
resp := doRequest(router, http.MethodGet, "/v1/user", tt.apiKey, nil)
defer resp.Body.Close()
assertJSONResponse(t, resp, tt.expectedStatus, tt.expectedResponse, tt.target)
})
}
}