diff --git a/api/handlers/errors.go b/api/handlers/errors.go new file mode 100644 index 00000000..c4b3d3ce --- /dev/null +++ b/api/handlers/errors.go @@ -0,0 +1,7 @@ +package handlers + +import "errors" + +var ( + errMissingUserID = errors.New("missing user id") +) diff --git a/api/handlers/user_handler.go b/api/handlers/user_handler.go new file mode 100644 index 00000000..adcbe075 --- /dev/null +++ b/api/handlers/user_handler.go @@ -0,0 +1,167 @@ +package handlers + +import ( + "errors" + "net/http" + "net/url" + "strconv" + + "github.com/odpf/columbus/star" + "github.com/odpf/columbus/user" + "github.com/odpf/salt/log" +) + +// UserHandler exposes a REST interface to user +type UserHandler struct { + starRepository star.Repository + logger log.Logger +} + +func (h *UserHandler) GetStarred(w http.ResponseWriter, r *http.Request) { + userID := user.FromContext(r.Context()) + if userID == "" { + h.logger.Warn(errMissingUserID.Error()) + WriteJSONError(w, http.StatusBadRequest, errMissingUserID.Error()) + return + } + + starCfg := h.buildStarConfig(r.URL.Query()) + + starredAssets, err := h.starRepository.GetAllByUserID(r.Context(), starCfg, userID) + if err != nil { + if errors.As(err, new(star.InvalidError)) { + WriteJSONError(w, http.StatusBadRequest, err.Error()) + return + } + if errors.As(err, new(star.NotFoundError)) { + WriteJSONError(w, http.StatusNotFound, err.Error()) + return + } + internalServerError(w, h.logger, err.Error()) + return + } + + writeJSON(w, http.StatusOK, starredAssets) +} + +func (h *UserHandler) StarAsset(w http.ResponseWriter, r *http.Request) { + userID := user.FromContext(r.Context()) + if userID == "" { + h.logger.Warn(errMissingUserID.Error()) + WriteJSONError(w, http.StatusBadRequest, errMissingUserID.Error()) + return + } + + starring := h.buildStar(r.URL.Query()) + + starID, err := h.starRepository.Create(r.Context(), userID, starring) + if err != nil { + if errors.As(err, new(star.InvalidError)) { + WriteJSONError(w, http.StatusBadRequest, err.Error()) + return + } + if errors.As(err, new(star.UserNotFoundError)) { + WriteJSONError(w, http.StatusNotFound, err.Error()) + return + } + if errors.As(err, new(star.DuplicateRecordError)) { + // idempotent + writeJSON(w, http.StatusNoContent, starID) + return + } + internalServerError(w, h.logger, err.Error()) + return + } + + writeJSON(w, http.StatusNoContent, starID) +} + +func (h *UserHandler) GetStarredAsset(w http.ResponseWriter, r *http.Request) { + userID := user.FromContext(r.Context()) + if userID == "" { + h.logger.Warn(errMissingUserID.Error()) + WriteJSONError(w, http.StatusBadRequest, errMissingUserID.Error()) + return + } + + starring := h.buildStar(r.URL.Query()) + + starID, err := h.starRepository.GetUserStarredAsset(r.Context(), userID, starring) + if err != nil { + if errors.As(err, new(star.InvalidError)) { + WriteJSONError(w, http.StatusBadRequest, err.Error()) + return + } + if errors.As(err, new(star.NotFoundError)) { + WriteJSONError(w, http.StatusNotFound, err.Error()) + return + } + internalServerError(w, h.logger, err.Error()) + return + } + + writeJSON(w, http.StatusOK, starID) +} + +func (h *UserHandler) UnstarAsset(w http.ResponseWriter, r *http.Request) { + userID := user.FromContext(r.Context()) + if userID == "" { + h.logger.Warn(errMissingUserID.Error()) + WriteJSONError(w, http.StatusBadRequest, errMissingUserID.Error()) + return + } + + starring := h.buildStar(r.URL.Query()) + + err := h.starRepository.Delete(r.Context(), userID, starring) + if err != nil { + if errors.As(err, new(star.InvalidError)) { + WriteJSONError(w, http.StatusBadRequest, err.Error()) + return + } + if errors.As(err, new(star.NotFoundError)) { + // idempotent + writeJSON(w, http.StatusNoContent, "success") + return + } + internalServerError(w, h.logger, err.Error()) + return + } + + writeJSON(w, http.StatusNoContent, "success") +} + +func (h *UserHandler) buildStarConfig(query url.Values) star.Config { + var page, size int + var err error + sizeString := query.Get("size") + if sizeString != "" { + size, err = strconv.Atoi(sizeString) + if err != nil { + h.logger.Warn("can't parse \"size\" query params") + } + } + pageString := query.Get("page") + if pageString != "" { + page, err = strconv.Atoi(pageString) + if err != nil { + h.logger.Warn("can't parse \"page\" query params") + } + } + return star.Config{Page: page, Size: size} +} + +func (h *UserHandler) buildStar(query url.Values) *star.Star { + return &star.Star{ + AssetType: query.Get("asset_type"), + AssetURN: query.Get("asset_urn"), + } +} + +func NewUserHandler(logger log.Logger, repo star.Repository) *UserHandler { + h := &UserHandler{ + starRepository: repo, + logger: logger, + } + return h +} diff --git a/api/handlers/user_handler_test.go b/api/handlers/user_handler_test.go new file mode 100644 index 00000000..3e39e070 --- /dev/null +++ b/api/handlers/user_handler_test.go @@ -0,0 +1,474 @@ +package handlers_test + +import ( + "encoding/json" + "errors" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + "github.com/odpf/columbus/api/handlers" + mocks "github.com/odpf/columbus/lib/mock" + "github.com/odpf/columbus/star" + "github.com/odpf/columbus/user" + "github.com/odpf/salt/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestGetStarred(t *testing.T) { + type testCase struct { + Description string + ExpectStatus int + Setup func(tc *testCase, er *mocks.StarRepository) + MutateRequest func(req *http.Request) *http.Request + PostCheck func(t *testing.T, tc *testCase, resp *http.Response) error + } + + userID := "dummy-user-id" + page := 2 + size := 20 + + var testCases = []testCase{ + { + Description: "should return 400 status code if user id not found in context", + ExpectStatus: http.StatusBadRequest, + Setup: func(tc *testCase, sr *mocks.StarRepository) {}, + }, + { + Description: "should return 500 status code if failed to fetch starred", + ExpectStatus: http.StatusInternalServerError, + MutateRequest: func(req *http.Request) *http.Request { + params := url.Values{} + params.Add("page", strconv.Itoa(page)) + params.Add("size", strconv.Itoa(size)) + req.URL.RawQuery = params.Encode() + ctx := user.NewContext(req.Context(), userID) + return req.WithContext(ctx) + }, + Setup: func(tc *testCase, er *mocks.StarRepository) { + er.On("GetAllByUserID", mock.AnythingOfType("*context.valueCtx"), star.Config{Page: page, Size: size}, userID).Return(nil, errors.New("failed to fetch starred")) + }, + }, + { + Description: "should return 404 status code if starred not found", + ExpectStatus: http.StatusNotFound, + MutateRequest: func(req *http.Request) *http.Request { + params := url.Values{} + params.Add("page", strconv.Itoa(page)) + params.Add("size", strconv.Itoa(size)) + req.URL.RawQuery = params.Encode() + ctx := user.NewContext(req.Context(), userID) + return req.WithContext(ctx) + }, + Setup: func(tc *testCase, er *mocks.StarRepository) { + er.On("GetAllByUserID", mock.AnythingOfType("*context.valueCtx"), star.Config{Page: page, Size: size}, userID).Return(nil, star.NotFoundError{}) + }, + }, + { + Description: "should return 200 starred assets of a user if no error", + ExpectStatus: http.StatusOK, + MutateRequest: func(req *http.Request) *http.Request { + params := url.Values{} + params.Add("page", strconv.Itoa(page)) + params.Add("size", strconv.Itoa(size)) + req.URL.RawQuery = params.Encode() + ctx := user.NewContext(req.Context(), userID) + return req.WithContext(ctx) + }, + Setup: func(tc *testCase, er *mocks.StarRepository) { + er.On("GetAllByUserID", mock.AnythingOfType("*context.valueCtx"), star.Config{Page: page, Size: size}, userID).Return([]star.Star{ + {ID: "1", AssetURN: "asset-urn-1", AssetType: "asset-type"}, + {ID: "2", AssetURN: "asset-urn-2", AssetType: "asset-type"}, + {ID: "3", AssetURN: "asset-urn-3", AssetType: "asset-type"}, + }, nil) + + }, + PostCheck: func(t *testing.T, tc *testCase, resp *http.Response) error { + actual, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + + expected, err := json.Marshal([]star.Star{ + {ID: "1", AssetURN: "asset-urn-1", AssetType: "asset-type"}, + {ID: "2", AssetURN: "asset-urn-2", AssetType: "asset-type"}, + {ID: "3", AssetURN: "asset-urn-3", AssetType: "asset-type"}, + }) + require.NoError(t, err) + + assert.JSONEq(t, string(expected), string(actual)) + + return nil + }, + }, + } + for _, tc := range testCases { + t.Run(tc.Description, func(t *testing.T) { + er := new(mocks.StarRepository) + logger := log.NewNoop() + defer er.AssertExpectations(t) + tc.Setup(&tc, er) + + handler := handlers.NewUserHandler(logger, er) + rr := httptest.NewRequest("GET", "/", nil) + rw := httptest.NewRecorder() + + if tc.MutateRequest != nil { + rr = tc.MutateRequest(rr) + } + + handler.GetStarred(rw, rr) + if rw.Code != tc.ExpectStatus { + t.Errorf("expected handler to return %d status, was %d instead", tc.ExpectStatus, rw.Code) + return + } + + if tc.PostCheck != nil { + if err := tc.PostCheck(t, &tc, rw.Result()); err != nil { + t.Error(err) + } + } + }) + } +} + +func TestStarAsset(t *testing.T) { + type testCase struct { + Description string + ExpectStatus int + Setup func(tc *testCase, er *mocks.StarRepository) + MutateRequest func(req *http.Request) *http.Request + } + + assetType := "an-asset-type" + assetURN := "dummy-asset-urn" + userID := "dummy-user-id" + + var testCases = []testCase{ + { + Description: "should return 400 status code if user id not found in context", + ExpectStatus: http.StatusBadRequest, + Setup: func(tc *testCase, sr *mocks.StarRepository) {}, + }, + { + Description: "should return 400 status code if star in param is invalid", + ExpectStatus: http.StatusBadRequest, + MutateRequest: func(req *http.Request) *http.Request { + params := url.Values{} + params.Add("asset_type", assetType) + params.Add("asset_urn", assetURN) + req.URL.RawQuery = params.Encode() + ctx := user.NewContext(req.Context(), userID) + return req.WithContext(ctx) + }, + Setup: func(tc *testCase, er *mocks.StarRepository) { + er.On("Create", mock.AnythingOfType("*context.valueCtx"), userID, &star.Star{AssetURN: assetURN, AssetType: assetType}).Return("", star.InvalidError{}) + }, + }, + { + Description: "should return 404 status code if user not exist in the table", + ExpectStatus: http.StatusNotFound, + MutateRequest: func(req *http.Request) *http.Request { + params := url.Values{} + params.Add("asset_type", assetType) + params.Add("asset_urn", assetURN) + req.URL.RawQuery = params.Encode() + ctx := user.NewContext(req.Context(), userID) + return req.WithContext(ctx) + }, + Setup: func(tc *testCase, er *mocks.StarRepository) { + er.On("Create", mock.AnythingOfType("*context.valueCtx"), userID, &star.Star{AssetURN: assetURN, AssetType: assetType}).Return("", star.UserNotFoundError{UserID: userID}) + }, + }, + { + Description: "should return 500 status code if failed to star an asset", + ExpectStatus: http.StatusInternalServerError, + MutateRequest: func(req *http.Request) *http.Request { + params := url.Values{} + params.Add("asset_type", assetType) + params.Add("asset_urn", assetURN) + req.URL.RawQuery = params.Encode() + ctx := user.NewContext(req.Context(), userID) + return req.WithContext(ctx) + }, + Setup: func(tc *testCase, er *mocks.StarRepository) { + er.On("Create", mock.AnythingOfType("*context.valueCtx"), userID, &star.Star{AssetURN: assetURN, AssetType: assetType}).Return("", errors.New("failed to star an asset")) + }, + }, + { + Description: "should return 204 if starring success", + ExpectStatus: http.StatusNoContent, + MutateRequest: func(req *http.Request) *http.Request { + params := url.Values{} + params.Add("asset_type", assetType) + params.Add("asset_urn", assetURN) + req.URL.RawQuery = params.Encode() + ctx := user.NewContext(req.Context(), userID) + return req.WithContext(ctx) + }, + Setup: func(tc *testCase, er *mocks.StarRepository) { + er.On("Create", mock.AnythingOfType("*context.valueCtx"), userID, mock.AnythingOfType("*star.Star")).Return("1234", nil) + }, + }, + { + Description: "should return 204 if asset is already starred", + ExpectStatus: http.StatusNoContent, + MutateRequest: func(req *http.Request) *http.Request { + params := url.Values{} + params.Add("asset_type", assetType) + params.Add("asset_urn", assetURN) + req.URL.RawQuery = params.Encode() + ctx := user.NewContext(req.Context(), userID) + return req.WithContext(ctx) + }, + Setup: func(tc *testCase, er *mocks.StarRepository) { + er.On("Create", mock.AnythingOfType("*context.valueCtx"), userID, mock.AnythingOfType("*star.Star")).Return("", star.DuplicateRecordError{}) + }, + }, + } + for _, tc := range testCases { + t.Run(tc.Description, func(t *testing.T) { + er := new(mocks.StarRepository) + logger := log.NewNoop() + defer er.AssertExpectations(t) + tc.Setup(&tc, er) + + handler := handlers.NewUserHandler(logger, er) + rr := httptest.NewRequest("PUT", "/", nil) + rw := httptest.NewRecorder() + + if tc.MutateRequest != nil { + rr = tc.MutateRequest(rr) + } + + handler.StarAsset(rw, rr) + if rw.Code != tc.ExpectStatus { + t.Errorf("expected handler to return %d status, was %d instead", tc.ExpectStatus, rw.Code) + return + } + }) + } +} + +func TestGetStarredAsset(t *testing.T) { + type testCase struct { + Description string + ExpectStatus int + Setup func(tc *testCase, er *mocks.StarRepository) + MutateRequest func(req *http.Request) *http.Request + PostCheck func(t *testing.T, tc *testCase, resp *http.Response) error + } + + assetType := "an-asset-type" + assetURN := "dummy-asset-urn" + userID := "dummy-user-id" + + var testCases = []testCase{ + { + Description: "should return 400 status code if user id not found in context", + ExpectStatus: http.StatusBadRequest, + Setup: func(tc *testCase, sr *mocks.StarRepository) {}, + }, + { + Description: "should return 500 status code if failed to fetch a starred asset", + ExpectStatus: http.StatusInternalServerError, + MutateRequest: func(req *http.Request) *http.Request { + params := url.Values{} + params.Add("asset_type", assetType) + params.Add("asset_urn", assetURN) + req.URL.RawQuery = params.Encode() + ctx := user.NewContext(req.Context(), userID) + return req.WithContext(ctx) + }, + Setup: func(tc *testCase, er *mocks.StarRepository) { + er.On("GetUserStarredAsset", mock.AnythingOfType("*context.valueCtx"), userID, &star.Star{AssetType: assetType, AssetURN: assetURN}).Return(nil, errors.New("failed to fetch starred")) + }, + }, + { + Description: "should return 400 status code if star in param is invalid", + ExpectStatus: http.StatusBadRequest, + MutateRequest: func(req *http.Request) *http.Request { + ctx := user.NewContext(req.Context(), userID) + return req.WithContext(ctx) + }, + Setup: func(tc *testCase, er *mocks.StarRepository) { + er.On("GetUserStarredAsset", mock.AnythingOfType("*context.valueCtx"), userID, &star.Star{}).Return(nil, star.InvalidError{}) + }, + }, + { + Description: "should return 404 status code if a starred asset not found", + ExpectStatus: http.StatusNotFound, + MutateRequest: func(req *http.Request) *http.Request { + params := url.Values{} + params.Add("asset_type", assetType) + params.Add("asset_urn", assetURN) + req.URL.RawQuery = params.Encode() + ctx := user.NewContext(req.Context(), userID) + return req.WithContext(ctx) + }, + Setup: func(tc *testCase, er *mocks.StarRepository) { + er.On("GetUserStarredAsset", mock.AnythingOfType("*context.valueCtx"), userID, &star.Star{AssetType: assetType, AssetURN: assetURN}).Return(nil, star.NotFoundError{}) + }, + }, + { + Description: "should return 200 starred assets of a user if no error", + ExpectStatus: http.StatusOK, + MutateRequest: func(req *http.Request) *http.Request { + params := url.Values{} + params.Add("asset_type", assetType) + params.Add("asset_urn", assetURN) + req.URL.RawQuery = params.Encode() + ctx := user.NewContext(req.Context(), userID) + return req.WithContext(ctx) + }, + Setup: func(tc *testCase, er *mocks.StarRepository) { + er.On("GetUserStarredAsset", mock.AnythingOfType("*context.valueCtx"), userID, &star.Star{AssetType: assetType, AssetURN: assetURN}).Return(&star.Star{AssetType: assetType, AssetURN: assetURN}, nil) + }, + PostCheck: func(t *testing.T, tc *testCase, resp *http.Response) error { + actual, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + + expected, err := json.Marshal(star.Star{AssetURN: assetURN, AssetType: assetType}) + require.NoError(t, err) + + assert.JSONEq(t, string(expected), string(actual)) + + return nil + }, + }, + } + for _, tc := range testCases { + t.Run(tc.Description, func(t *testing.T) { + er := new(mocks.StarRepository) + logger := log.NewNoop() + defer er.AssertExpectations(t) + tc.Setup(&tc, er) + + handler := handlers.NewUserHandler(logger, er) + rr := httptest.NewRequest("GET", "/", nil) + rw := httptest.NewRecorder() + + if tc.MutateRequest != nil { + rr = tc.MutateRequest(rr) + } + + handler.GetStarredAsset(rw, rr) + if rw.Code != tc.ExpectStatus { + t.Errorf("expected handler to return %d status, was %d instead", tc.ExpectStatus, rw.Code) + return + } + + if tc.PostCheck != nil { + if err := tc.PostCheck(t, &tc, rw.Result()); err != nil { + t.Error(err) + } + } + }) + } +} + +func TestUnstarAsset(t *testing.T) { + type testCase struct { + Description string + ExpectStatus int + Setup func(tc *testCase, er *mocks.StarRepository) + MutateRequest func(req *http.Request) *http.Request + } + + assetType := "an-asset-type" + assetURN := "dummy-asset-urn" + userID := "dummy-user-id" + + var testCases = []testCase{ + { + Description: "should return 400 status code if user id not found in context", + ExpectStatus: http.StatusBadRequest, + Setup: func(tc *testCase, sr *mocks.StarRepository) {}, + }, + { + Description: "should return 400 status code if star in param is invalid", + ExpectStatus: http.StatusBadRequest, + MutateRequest: func(req *http.Request) *http.Request { + params := url.Values{} + params.Add("asset_type", assetType) + params.Add("asset_urn", assetURN) + req.URL.RawQuery = params.Encode() + ctx := user.NewContext(req.Context(), userID) + return req.WithContext(ctx) + }, + Setup: func(tc *testCase, er *mocks.StarRepository) { + er.On("Delete", mock.AnythingOfType("*context.valueCtx"), userID, &star.Star{AssetURN: assetURN, AssetType: assetType}).Return(star.InvalidError{}) + }, + }, + { + Description: "should return 500 status code if failed to unstar an asset", + ExpectStatus: http.StatusInternalServerError, + MutateRequest: func(req *http.Request) *http.Request { + params := url.Values{} + params.Add("asset_type", assetType) + params.Add("asset_urn", assetURN) + req.URL.RawQuery = params.Encode() + ctx := user.NewContext(req.Context(), userID) + return req.WithContext(ctx) + }, + Setup: func(tc *testCase, er *mocks.StarRepository) { + er.On("Delete", mock.AnythingOfType("*context.valueCtx"), userID, &star.Star{AssetURN: assetURN, AssetType: assetType}).Return(errors.New("failed to star an asset")) + }, + }, + { + Description: "should return 204 if unstarring success", + ExpectStatus: http.StatusNoContent, + MutateRequest: func(req *http.Request) *http.Request { + params := url.Values{} + params.Add("asset_type", assetType) + params.Add("asset_urn", assetURN) + req.URL.RawQuery = params.Encode() + ctx := user.NewContext(req.Context(), userID) + return req.WithContext(ctx) + }, + Setup: func(tc *testCase, er *mocks.StarRepository) { + er.On("Delete", mock.AnythingOfType("*context.valueCtx"), userID, mock.AnythingOfType("*star.Star")).Return(nil) + }, + }, + { + Description: "should return 204 if asset is already unstarred", + ExpectStatus: http.StatusNoContent, + MutateRequest: func(req *http.Request) *http.Request { + params := url.Values{} + params.Add("asset_type", assetType) + params.Add("asset_urn", assetURN) + req.URL.RawQuery = params.Encode() + ctx := user.NewContext(req.Context(), userID) + return req.WithContext(ctx) + }, + Setup: func(tc *testCase, er *mocks.StarRepository) { + er.On("Delete", mock.AnythingOfType("*context.valueCtx"), userID, mock.AnythingOfType("*star.Star")).Return(star.NotFoundError{}) + }, + }, + } + for _, tc := range testCases { + t.Run(tc.Description, func(t *testing.T) { + er := new(mocks.StarRepository) + logger := log.NewNoop() + defer er.AssertExpectations(t) + tc.Setup(&tc, er) + + handler := handlers.NewUserHandler(logger, er) + rr := httptest.NewRequest("PUT", "/", nil) + rw := httptest.NewRecorder() + + if tc.MutateRequest != nil { + rr = tc.MutateRequest(rr) + } + + handler.UnstarAsset(rw, rr) + if rw.Code != tc.ExpectStatus { + t.Errorf("expected handler to return %d status, was %d instead", tc.ExpectStatus, rw.Code) + return + } + }) + } +} diff --git a/api/routes.go b/api/routes.go index 5d6ee915..fd020549 100644 --- a/api/routes.go +++ b/api/routes.go @@ -10,6 +10,7 @@ import ( "github.com/odpf/columbus/api/middleware" "github.com/odpf/columbus/discovery" "github.com/odpf/columbus/record" + "github.com/odpf/columbus/star" "github.com/odpf/columbus/tag" "github.com/odpf/columbus/user" ) @@ -24,6 +25,7 @@ type Config struct { LineageProvider handlers.LineageProvider UserService *user.Service MiddlewareConfig middleware.Config + StarRepository star.Repository } type Handlers struct { @@ -33,6 +35,7 @@ type Handlers struct { Lineage *handlers.LineageHandler Tag *handlers.TagHandler TagTemplate *handlers.TagTemplateHandler + User *handlers.UserHandler } func initHandlers(config Config) *Handlers { @@ -63,6 +66,10 @@ func initHandlers(config Config) *Handlers { config.Logger, config.TagTemplateService, ) + userHandler := handlers.NewUserHandler( + config.Logger, + config.StarRepository, + ) return &Handlers{ Type: typeHandler, @@ -71,6 +78,7 @@ func initHandlers(config Config) *Handlers { Lineage: lineageHandler, Tag: tagHandler, TagTemplate: tagTemplateHandler, + User: userHandler, } } diff --git a/api/v1beta1.go b/api/v1beta1.go index ddddb493..515f1ee2 100644 --- a/api/v1beta1.go +++ b/api/v1beta1.go @@ -27,6 +27,9 @@ func setupV1Beta1Router(router *mux.Router, handlers *Handlers) *mux.Router { Methods(http.MethodGet). HandlerFunc(handlers.Lineage.ListLineage) + userRouter := router.PathPrefix("/user").Subrouter() + setupUserRoutes(userRouter, handlers.User) + return router } @@ -53,7 +56,6 @@ func setupV1Beta1TypeRoutes(router *mux.Router, th *handlers.TypeHandler, rh *ha router.Path(recordURL+"/{id}"). Methods(http.MethodDelete, http.MethodHead). HandlerFunc(rh.Delete) - } func setupV1Beta1TagRoutes(router *mux.Router, baseURL string, th *handlers.TagHandler, tth *handlers.TagTemplateHandler) { @@ -72,5 +74,16 @@ func setupV1Beta1TagRoutes(router *mux.Router, baseURL string, th *handlers.TagH router.Methods(http.MethodGet).Path(templateURL + "/{template_urn}").HandlerFunc(tth.Find) router.Methods(http.MethodPut).Path(templateURL + "/{template_urn}").HandlerFunc(tth.Update) router.Methods(http.MethodDelete).Path(templateURL + "/{template_urn}").HandlerFunc(tth.Delete) +} + +func setupUserRoutes(router *mux.Router, uh *handlers.UserHandler) { + + router.Path("/starred"). + Methods(http.MethodGet, http.MethodHead). + HandlerFunc(uh.GetStarred) + userAssetsURL := "/starred/{asset_type}/{asset_urn}" + router.Methods(http.MethodPut).Path(userAssetsURL).HandlerFunc(uh.StarAsset) + router.Methods(http.MethodGet).Path(userAssetsURL).HandlerFunc(uh.GetStarredAsset) + router.Methods(http.MethodDelete).Path(userAssetsURL).HandlerFunc(uh.UnstarAsset) } diff --git a/cmd/serve.go b/cmd/serve.go index 5f06ee40..6d50830d 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -92,6 +92,7 @@ func initRouter( } tagTemplateService := tag.NewTemplateService(tagTemplateRepository) tagService := tag.NewService(tagRepository, tagTemplateService) + // init user userRepository, err := postgres.NewUserRepository(pgClient) if err != nil { @@ -101,6 +102,12 @@ func initRouter( IdentityProviderDefaultName: config.IdentityProviderDefaultName, }, userRepository) + // init star + starRepository, err := postgres.NewStarRepository(pgClient) + if err != nil { + logger.Fatal("failed to create new star repository", "error", err) + } + router := mux.NewRouter() if nrMonitor != nil { nrMonitor.MonitorRouter(router) @@ -127,6 +134,7 @@ func initRouter( TagTemplateService: tagTemplateService, UserService: userService, MiddlewareConfig: middlewareCfg, + StarRepository: starRepository, }) return router diff --git a/lib/mock/star_repository.go b/lib/mock/star_repository.go index f228731f..2723b0af 100644 --- a/lib/mock/star_repository.go +++ b/lib/mock/star_repository.go @@ -96,14 +96,16 @@ func (_m *StarRepository) GetUserIDs(ctx context.Context, cfg star.Config, starr } // GetUserStarredAsset provides a mock function with given fields: ctx, userID, starring -func (_m *StarRepository) GetUserStarredAsset(ctx context.Context, userID string, starring *star.Star) (star.Star, error) { +func (_m *StarRepository) GetUserStarredAsset(ctx context.Context, userID string, starring *star.Star) (*star.Star, error) { ret := _m.Called(ctx, userID, starring) - var r0 star.Star - if rf, ok := ret.Get(0).(func(context.Context, string, *star.Star) star.Star); ok { + var r0 *star.Star + if rf, ok := ret.Get(0).(func(context.Context, string, *star.Star) *star.Star); ok { r0 = rf(ctx, userID, starring) } else { - r0 = ret.Get(0).(star.Star) + if ret.Get(0) != nil { + r0 = ret.Get(0).(*star.Star) + } } var r1 error diff --git a/star/errors.go b/star/errors.go index afc41f9d..d06901f2 100644 --- a/star/errors.go +++ b/star/errors.go @@ -5,7 +5,6 @@ import ( ) type NotFoundError struct { - UserID string AssetURN string AssetType string } @@ -14,6 +13,14 @@ func (e NotFoundError) Error() string { return fmt.Sprintf("could not find starred asset urn \"%s\" with type \"%s\"", e.AssetURN, e.AssetType) } +type UserNotFoundError struct { + UserID string +} + +func (e UserNotFoundError) Error() string { + return fmt.Sprintf("could not find user with id \"%s\"", e.UserID) +} + type DuplicateRecordError struct { UserID string AssetURN string diff --git a/star/star.go b/star/star.go index 81655a2c..d250cbdd 100644 --- a/star/star.go +++ b/star/star.go @@ -31,6 +31,6 @@ type Repository interface { Create(ctx context.Context, userID string, starring *Star) (string, error) GetUserIDs(ctx context.Context, cfg Config, starring *Star) ([]string, error) GetAllByUserID(ctx context.Context, cfg Config, userID string) ([]Star, error) - GetUserStarredAsset(ctx context.Context, userID string, starring *Star) (Star, error) + GetUserStarredAsset(ctx context.Context, userID string, starring *Star) (*Star, error) Delete(ctx context.Context, userID string, starring *Star) error } diff --git a/store/postgres/errors.go b/store/postgres/errors.go index 1d02398f..6a62ac7f 100644 --- a/store/postgres/errors.go +++ b/store/postgres/errors.go @@ -3,8 +3,9 @@ package postgres import "errors" var ( - errNilDBClient = errors.New("db client is nil") - errNilPostgresClient = errors.New("postgres client is nil") - errDuplicateKey = errors.New("duplicate key") - errCheckViolation = errors.New("check constraint violation") + errNilDBClient = errors.New("db client is nil") + errNilPostgresClient = errors.New("postgres client is nil") + errDuplicateKey = errors.New("duplicate key") + errCheckViolation = errors.New("check constraint violation") + errForeignKeyViolation = errors.New("foreign key violation") ) diff --git a/store/postgres/migrations/000003_create_stars_table.up.sql b/store/postgres/migrations/000003_create_stars_table.up.sql index 58b3e94f..713faa7e 100644 --- a/store/postgres/migrations/000003_create_stars_table.up.sql +++ b/store/postgres/migrations/000003_create_stars_table.up.sql @@ -7,4 +7,4 @@ CREATE TABLE stars ( updated_at timestamp DEFAULT NOW() ); -CREATE UNIQUE INDEX stars_idx_user_id_asset_type_asset_urn ON stars(user_id,asset_type,asset_urn); \ No newline at end of file +CREATE UNIQUE INDEX stars_idx_user_id_asset_type_asset_urn ON stars(user_id,asset_type,asset_urn); diff --git a/store/postgres/postgres.go b/store/postgres/postgres.go index a2d180f9..6f68fe47 100644 --- a/store/postgres/postgres.go +++ b/store/postgres/postgres.go @@ -118,6 +118,8 @@ func checkPostgresError(err error) error { return fmt.Errorf("%w [%s]", errDuplicateKey, pgErr.Detail) case pgerrcode.CheckViolation: return fmt.Errorf("%w [%s]", errCheckViolation, pgErr.Detail) + case pgerrcode.ForeignKeyViolation: + return fmt.Errorf("%w [%s]", errForeignKeyViolation, pgErr.Detail) } } return err diff --git a/store/postgres/star_model_test.go b/store/postgres/star_model_test.go index fd228fc9..003453e6 100644 --- a/store/postgres/star_model_test.go +++ b/store/postgres/star_model_test.go @@ -8,9 +8,10 @@ import ( ) func TestStarModel(t *testing.T) { - t.Run("successfully build build star from star model", func(t *testing.T) { + t.Run("successfully build star from star model", func(t *testing.T) { sm := &StarModel{ ID: "id", + UserID: "userid", AssetURN: "asseturn", AssetType: "asserttype", CreatedAt: time.Now(), @@ -20,8 +21,8 @@ func TestStarModel(t *testing.T) { s := sm.toStar() assert.Equal(t, s.ID, sm.ID) - assert.Equal(t, s.AssetType, sm.AssetType) assert.Equal(t, s.AssetURN, sm.AssetURN) + assert.Equal(t, s.AssetType, sm.AssetType) assert.True(t, s.CreatedAt.Equal(sm.CreatedAt)) assert.True(t, s.UpdatedAt.Equal(sm.UpdatedAt)) }) diff --git a/store/postgres/star_repository.go b/store/postgres/star_repository.go index 28577ffd..ce7feb16 100644 --- a/store/postgres/star_repository.go +++ b/store/postgres/star_repository.go @@ -42,6 +42,9 @@ func (r *StarRepository) Create(ctx context.Context, userID string, starring *st if errors.Is(err, errDuplicateKey) { return "", star.DuplicateRecordError{UserID: userID, AssetType: starring.AssetType, AssetURN: starring.AssetURN} } + if errors.Is(err, errForeignKeyViolation) { + return "", star.UserNotFoundError{UserID: userID} + } return "", err } if starID == "" { @@ -109,7 +112,7 @@ func (r *StarRepository) GetAllByUserID(ctx context.Context, cfg star.Config, us } if len(starModels) == 0 { - return nil, star.NotFoundError{UserID: userID} + return nil, star.NotFoundError{} } return starModels.toStars(), nil @@ -136,7 +139,7 @@ func (r *StarRepository) GetUserStarredAsset(ctx context.Context, userID string, LIMIT 1 `, userID, starring.AssetType, starring.AssetURN) if errors.Is(err, sql.ErrNoRows) { - return nil, star.NotFoundError{UserID: userID, AssetType: starModel.AssetType, AssetURN: starModel.AssetURN} + return nil, star.NotFoundError{AssetType: starModel.AssetType, AssetURN: starModel.AssetURN} } if err != nil { return nil, fmt.Errorf("failed fetching star by user: %w", err) @@ -171,7 +174,7 @@ func (r *StarRepository) Delete(ctx context.Context, userID string, starring *st } if rowsAffected == 0 { - return star.NotFoundError{UserID: userID, AssetType: starring.AssetType, AssetURN: starring.AssetURN} + return star.NotFoundError{AssetType: starring.AssetType, AssetURN: starring.AssetURN} } return nil } diff --git a/store/postgres/star_repository_test.go b/store/postgres/star_repository_test.go index 77271262..b02f8572 100644 --- a/store/postgres/star_repository_test.go +++ b/store/postgres/star_repository_test.go @@ -99,6 +99,16 @@ func (r *StarRepositoryTestSuite) TestCreate() { r.Empty(id) }) + r.Run("return error user not found if user does not exist", func() { + err := setup(r.ctx, r.client) + r.NoError(err) + uid := uuid.New().String() + starring := getStar("asset-urn-1", "table") + id, err := r.repository.Create(r.ctx, uid, starring) + r.ErrorIs(err, star.UserNotFoundError{UserID: uid}) + r.Empty(id) + }) + r.Run("return invalid error if field in star is empty", func() { err := setup(r.ctx, r.client) r.NoError(err) @@ -190,7 +200,7 @@ func (r *StarRepositoryTestSuite) TestGetAllByUserID() { r.Run("return not found error if starred asset not found in db", func() { randomUserID := uuid.New().String() users, err := r.repository.GetAllByUserID(r.ctx, defaultCfg, randomUserID) - r.ErrorIs(err, star.NotFoundError{UserID: randomUserID}) + r.ErrorIs(err, star.NotFoundError{}) r.Empty(users) }) @@ -270,7 +280,7 @@ func (r *StarRepositoryTestSuite) TestGetUserStarredAsset() { randomUserID := uuid.New().String() starring := &star.Star{AssetType: "asset-type", AssetURN: "asset-urn"} users, err := r.repository.GetUserStarredAsset(r.ctx, randomUserID, starring) - r.ErrorIs(err, star.NotFoundError{UserID: randomUserID}) + r.ErrorIs(err, star.NotFoundError{}) r.Empty(users) }) diff --git a/swagger.yaml b/swagger.yaml index dd030967..57320d26 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -687,6 +687,165 @@ paths: description: internal server error schema: $ref: "#/definitions/Error" + "/v1beta1/user/starred": + get: + tags: + - User + - Star + summary: Get user starred assets + description: Get all assets starred by a user + produces: + - "application/json" + parameters: + - in: header + name: Columbus-User-Email + schema: + type: string + format: email + example: user@odpf.io + required: true + responses: + 200: + description: OK + schema: + type: array + items: + $ref: "#/definitions/Star" + 400: + description: validation error + schema: + $ref: "#/definitions/ValidationError" + 404: + description: not found + schema: + $ref: "#/definitions/Error" + 500: + description: internal server error + schema: + $ref: "#/definitions/Error" + "/v1beta1/user/{asset_type}/{asset_urn}": + put: + tags: + - User + - Star + summary: Star an asset + description: Mark an asset by a user with a star + produces: + - "application/json" + parameters: + - in: header + name: Columbus-User-Email + schema: + type: string + format: email + example: user@odpf.io + required: true + - in: path + name: asset_type + type: string + required: true + - in: path + name: asset_urn + type: string + required: true + responses: + 204: + description: Content + schema: + type: string + example: 1234-1234-1234 + 400: + description: validation error + schema: + $ref: "#/definitions/ValidationError" + 404: + description: not found + schema: + $ref: "#/definitions/Error" + 500: + description: internal server error + schema: + $ref: "#/definitions/Error" + delete: + tags: + - User + - Star + summary: Untar an asset + description: Unmark a starred asset of a user + produces: + - "application/json" + parameters: + - in: header + name: Columbus-User-Email + schema: + type: string + format: email + example: user@odpf.io + required: true + - in: path + name: asset_type + type: string + required: true + - in: path + name: asset_urn + type: string + required: true + responses: + 204: + description: No Content + schema: + type: string + example: success + 400: + description: validation error + schema: + $ref: "#/definitions/ValidationError" + 500: + description: internal server error + schema: + $ref: "#/definitions/Error" + get: + tags: + - User + - Star + summary: Get a starred asset of a user + description: Get a specific starred asset of a user + produces: + - "application/json" + parameters: + - in: header + name: Columbus-User-Email + schema: + type: string + format: email + example: user@odpf.io + required: true + - in: path + name: asset_type + type: string + required: true + - in: path + name: asset_urn + type: string + required: true + responses: + 200: + description: OK + schema: + type: object + $ref: "#/definitions/Star" + 400: + description: validation error + schema: + $ref: "#/definitions/ValidationError" + 404: + description: not found + schema: + $ref: "#/definitions/Error" + 500: + description: internal server error + schema: + $ref: "#/definitions/Error" definitions: Record: type: object @@ -883,3 +1042,15 @@ definitions: details: type: object description: error details. the keys are integer indices for the records that failed validation, and the value is a string describing the reason why that record fails validation + Star: + type: object + properties: + asset_type: + type: string + description: name of the type (for e.g. dagger, firehose) + asset_urn: + type: string + created_at: + type: string + updated_at: + type: string