feat: add a new REST endpoint - GET /api/v1/user
continuous-integration/drone/pr Build is failing
Details
continuous-integration/drone/pr Build is failing
Details
This commit is contained in:
parent
87da75847d
commit
b057a13bb3
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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)}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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))
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue