diff --git a/pkg/api/api.go b/pkg/api/api.go index 9b207a37293..0969191d551 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -248,10 +248,11 @@ func (hs *HttpServer) registerRoutes() { // Folders apiRoute.Group("/folders", func(folderRoute RouteRegister) { - folderRoute.Get("/:id", wrap(GetFolderById)) + folderRoute.Get("/:uid", wrap(GetFolder)) + folderRoute.Get("/id/:id", wrap(GetFolder)) folderRoute.Post("/", bind(m.CreateFolderCommand{}), wrap(CreateFolder)) - folderRoute.Put("/:id", bind(m.UpdateFolderCommand{}), wrap(UpdateFolder)) - folderRoute.Delete("/:id", wrap(DeleteFolder)) + folderRoute.Put("/:uid", bind(m.UpdateFolderCommand{}), wrap(UpdateFolder)) + folderRoute.Delete("/:uid", wrap(DeleteFolder)) }) // Dashboard diff --git a/pkg/api/dtos/folder.go b/pkg/api/dtos/folder.go index 7ce7825fb6a..159d41fee1c 100644 --- a/pkg/api/dtos/folder.go +++ b/pkg/api/dtos/folder.go @@ -4,8 +4,9 @@ import "time" type Folder struct { Id int64 `json:"id"` + Uid string `json:"uid"` Title string `json:"title"` - Slug string `json:"slug"` + Url string `json:"url"` HasAcl bool `json:"hasAcl"` CanSave bool `json:"canSave"` CanEdit bool `json:"canEdit"` diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 9823f93982e..5358caa4b61 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -12,8 +12,8 @@ import ( "github.com/grafana/grafana/pkg/util" ) -func getFolderHelper(orgId int64, slug string, id int64) (*m.Dashboard, Response) { - query := m.GetDashboardQuery{Slug: slug, Id: id, OrgId: orgId} +func getFolderHelper(orgId int64, id int64, uid string) (*m.Dashboard, Response) { + query := m.GetDashboardQuery{OrgId: orgId, Id: id, Uid: uid} if err := bus.Dispatch(&query); err != nil { if err == m.ErrDashboardNotFound { err = m.ErrFolderNotFound @@ -53,8 +53,8 @@ func GetFoldersForSignedInUser(c *middleware.Context) Response { return Json(200, query.Result) } -func GetFolderById(c *middleware.Context) Response { - folder, rsp := getFolderHelper(c.OrgId, "", c.ParamsInt64(":id")) +func GetFolder(c *middleware.Context) Response { + folder, rsp := getFolderHelper(c.OrgId, c.ParamsInt64(":id"), c.Params(":uid")) if rsp != nil { return rsp } @@ -72,14 +72,13 @@ func CreateFolder(c *middleware.Context, cmd m.CreateFolderCommand) Response { cmd.OrgId = c.OrgId cmd.UserId = c.UserId - dashFolder := m.NewDashboardFolder(cmd.Title) + dashFolder := cmd.GetDashboardModel() guardian := guardian.NewDashboardGuardian(0, c.OrgId, c.SignedInUser) if canSave, err := guardian.CanSave(); err != nil || !canSave { return folderGuardianResponse(err) } - // Check if Title is empty if dashFolder.Title == "" { return ApiError(400, m.ErrFolderTitleEmpty.Error(), nil) } @@ -92,9 +91,6 @@ func CreateFolder(c *middleware.Context, cmd m.CreateFolderCommand) Response { return ApiError(403, "Quota reached", nil) } - dashFolder.CreatedBy = c.UserId - dashFolder.UpdatedBy = c.UserId - dashItem := &dashboards.SaveDashboardItem{ Dashboard: dashFolder, OrgId: c.OrgId, @@ -113,8 +109,9 @@ func CreateFolder(c *middleware.Context, cmd m.CreateFolderCommand) Response { func UpdateFolder(c *middleware.Context, cmd m.UpdateFolderCommand) Response { cmd.OrgId = c.OrgId cmd.UserId = c.UserId + uid := c.Params(":uid") - dashFolder, rsp := getFolderHelper(c.OrgId, "", c.ParamsInt64(":id")) + dashFolder, rsp := getFolderHelper(c.OrgId, 0, uid) if rsp != nil { return rsp } @@ -124,13 +121,8 @@ func UpdateFolder(c *middleware.Context, cmd m.UpdateFolderCommand) Response { return folderGuardianResponse(err) } - dashFolder.Data.Set("title", cmd.Title) - dashFolder.Title = cmd.Title - dashFolder.Data.Set("version", cmd.Version) - dashFolder.Version = cmd.Version - dashFolder.UpdatedBy = c.UserId + cmd.UpdateDashboardModel(dashFolder) - // Check if Title is empty if dashFolder.Title == "" { return ApiError(400, m.ErrFolderTitleEmpty.Error(), nil) } @@ -139,6 +131,7 @@ func UpdateFolder(c *middleware.Context, cmd m.UpdateFolderCommand) Response { Dashboard: dashFolder, OrgId: c.OrgId, UserId: c.UserId, + Overwrite: cmd.Overwrite, } folder, err := dashboards.GetRepository().SaveDashboard(dashItem) @@ -151,7 +144,7 @@ func UpdateFolder(c *middleware.Context, cmd m.UpdateFolderCommand) Response { } func DeleteFolder(c *middleware.Context) Response { - dashFolder, rsp := getFolderHelper(c.OrgId, "", c.ParamsInt64(":id")) + dashFolder, rsp := getFolderHelper(c.OrgId, 0, c.Params(":uid")) if rsp != nil { return rsp } @@ -177,17 +170,18 @@ func toDto(guardian *guardian.DashboardGuardian, folder *m.Dashboard) dtos.Folde // Finding creator and last updater of the folder updater, creator := "Anonymous", "Anonymous" - if folder.UpdatedBy > 0 { - updater = getUserLogin(folder.UpdatedBy) - } if folder.CreatedBy > 0 { creator = getUserLogin(folder.CreatedBy) } + if folder.UpdatedBy > 0 { + updater = getUserLogin(folder.UpdatedBy) + } return dtos.Folder{ Id: folder.Id, + Uid: folder.Uid, Title: folder.Title, - Slug: folder.Slug, + Url: folder.GetUrl(), HasAcl: folder.HasAcl, CanSave: canSave, CanEdit: canEdit, @@ -205,6 +199,10 @@ func toFolderError(err error) Response { return ApiError(400, m.ErrFolderTitleEmpty.Error(), nil) } + if err == m.ErrDashboardWithSameNameInFolderExists { + return Json(412, util.DynMap{"status": "name-exists", "message": m.ErrFolderSameNameExists.Error()}) + } + if err == m.ErrDashboardWithSameUIDExists { return Json(412, util.DynMap{"status": "uid-exists", "message": m.ErrFolderWithSameUIDExists.Error()}) } diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index cbe7eb7a280..6ef18e9a749 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -23,8 +23,11 @@ func TestFoldersApiEndpoint(t *testing.T) { fakeDash.FolderId = 1 fakeDash.HasAcl = false + var getDashboardQueries []*m.GetDashboardQuery + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { query.Result = fakeDash + getDashboardQueries = append(getDashboardQueries, query) return nil }) @@ -33,19 +36,40 @@ func TestFoldersApiEndpoint(t *testing.T) { Convey("When user is an Org Editor", func() { role := m.ROLE_EDITOR + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/uid", "/api/folders/:uid", role, func(sc *scenarioContext) { + callGetFolder(sc) + So(sc.resp.Code, ShouldEqual, 404) + + Convey("Should lookup folder by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "uid") + }) + }) + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { callGetFolder(sc) So(sc.resp.Code, ShouldEqual, 404) + + Convey("Should lookup folder by id", func() { + So(getDashboardQueries[0].Id, ShouldEqual, 1) + }) }) - updateFolderScenario("When calling PUT on", "/api/folders/1", "/api/folders/:id", role, updateFolderCmd, func(sc *scenarioContext) { + updateFolderScenario("When calling PUT on", "/api/folders/uid", "/api/folders/:uid", role, updateFolderCmd, func(sc *scenarioContext) { callUpdateFolder(sc) So(sc.resp.Code, ShouldEqual, 404) + + Convey("Should lookup folder by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "uid") + }) }) - loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/folders/uid", "/api/folders/:uid", role, func(sc *scenarioContext) { callDeleteFolder(sc) So(sc.resp.Code, ShouldEqual, 404) + + Convey("Should lookup folder by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "uid") + }) }) }) }) @@ -55,8 +79,11 @@ func TestFoldersApiEndpoint(t *testing.T) { fakeFolder.Id = 1 fakeFolder.HasAcl = false + var getDashboardQueries []*m.GetDashboardQuery + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { query.Result = fakeFolder + getDashboardQueries = append(getDashboardQueries, query) return nil }) @@ -82,12 +109,20 @@ func TestFoldersApiEndpoint(t *testing.T) { Title: fakeFolder.Title, } + updateFolderCmd := m.UpdateFolderCommand{ + Title: fakeFolder.Title, + } + Convey("When user is an Org Viewer", func() { role := m.ROLE_VIEWER - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/uid", "/api/folders/:uid", role, func(sc *scenarioContext) { folder := getFolderShouldReturn200(sc) + Convey("Should lookup folder by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "uid") + }) + Convey("Should not be able to edit or save folder", func() { So(folder.CanEdit, ShouldBeFalse) So(folder.CanSave, ShouldBeFalse) @@ -95,23 +130,54 @@ func TestFoldersApiEndpoint(t *testing.T) { }) }) - loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { + folder := getFolderShouldReturn200(sc) + + Convey("Should lookup folder by id", func() { + So(getDashboardQueries[0].Id, ShouldEqual, 1) + }) + + Convey("Should not be able to edit or save folder", func() { + So(folder.CanEdit, ShouldBeFalse) + So(folder.CanSave, ShouldBeFalse) + So(folder.CanAdmin, ShouldBeFalse) + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/folders/uid", "/api/folders/:uid", role, func(sc *scenarioContext) { callDeleteFolder(sc) So(sc.resp.Code, ShouldEqual, 403) + + Convey("Should lookup folder by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "uid") + }) }) createFolderScenario("When calling POST on", "/api/folders", "/api/folders", role, cmd, func(sc *scenarioContext) { callCreateFolder(sc) So(sc.resp.Code, ShouldEqual, 403) }) + + updateFolderScenario("When calling PUT on", "/api/folders/uid", "/api/folders/:uid", role, updateFolderCmd, func(sc *scenarioContext) { + callUpdateFolder(sc) + So(sc.resp.Code, ShouldEqual, 403) + + Convey("Should lookup folder by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "uid") + }) + }) }) Convey("When user is an Org Editor", func() { role := m.ROLE_EDITOR - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/uid", "/api/folders/:uid", role, func(sc *scenarioContext) { folder := getFolderShouldReturn200(sc) + Convey("Should lookup folder by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "uid") + }) + Convey("Should be able to edit or save folder", func() { So(folder.CanEdit, ShouldBeTrue) So(folder.CanSave, ShouldBeTrue) @@ -119,15 +185,42 @@ func TestFoldersApiEndpoint(t *testing.T) { }) }) - loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { + folder := getFolderShouldReturn200(sc) + + Convey("Should lookup folder by id", func() { + So(getDashboardQueries[0].Id, ShouldEqual, 1) + }) + + Convey("Should be able to edit or save folder", func() { + So(folder.CanEdit, ShouldBeTrue) + So(folder.CanSave, ShouldBeTrue) + So(folder.CanAdmin, ShouldBeFalse) + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/folders/uid", "/api/folders/:uid", role, func(sc *scenarioContext) { callDeleteFolder(sc) So(sc.resp.Code, ShouldEqual, 200) + + Convey("Should lookup folder by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "uid") + }) }) createFolderScenario("When calling POST on", "/api/folders", "/api/folders", role, cmd, func(sc *scenarioContext) { callCreateFolder(sc) So(sc.resp.Code, ShouldEqual, 200) }) + + updateFolderScenario("When calling PUT on", "/api/folders/uid", "/api/folders/:uid", role, updateFolderCmd, func(sc *scenarioContext) { + callUpdateFolder(sc) + So(sc.resp.Code, ShouldEqual, 200) + + Convey("Should lookup folder by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "uid") + }) + }) }) }) @@ -136,8 +229,11 @@ func TestFoldersApiEndpoint(t *testing.T) { fakeFolder.Id = 1 fakeFolder.HasAcl = true + var getDashboardQueries []*m.GetDashboardQuery + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { query.Result = fakeFolder + getDashboardQueries = append(getDashboardQueries, query) return nil }) @@ -163,50 +259,110 @@ func TestFoldersApiEndpoint(t *testing.T) { Title: fakeFolder.Title, } + updateFolderCmd := m.UpdateFolderCommand{ + Title: fakeFolder.Title, + } + Convey("When user is an Org Viewer and has no permissions for this folder", func() { role := m.ROLE_VIEWER - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { - sc.handlerFunc = GetFolderById - sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/uid", "/api/folders/:uid", role, func(sc *scenarioContext) { + callGetFolder(sc) + + Convey("Should lookup folder by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "uid") + }) Convey("Should be denied access", func() { So(sc.resp.Code, ShouldEqual, 403) }) }) - loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { + callGetFolder(sc) + + Convey("Should lookup folder by id", func() { + So(getDashboardQueries[0].Id, ShouldEqual, 1) + }) + + Convey("Should be denied access", func() { + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/folders/uid", "/api/folders/:uid", role, func(sc *scenarioContext) { callDeleteFolder(sc) So(sc.resp.Code, ShouldEqual, 403) + + Convey("Should lookup folder by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "uid") + }) }) createFolderScenario("When calling POST on", "/api/folders", "/api/folders", role, cmd, func(sc *scenarioContext) { callCreateFolder(sc) So(sc.resp.Code, ShouldEqual, 403) }) + + updateFolderScenario("When calling PUT on", "/api/folders/uid", "/api/folders/:uid", role, updateFolderCmd, func(sc *scenarioContext) { + callUpdateFolder(sc) + So(sc.resp.Code, ShouldEqual, 403) + + Convey("Should lookup folder by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "uid") + }) + }) }) Convey("When user is an Org Editor and has no permissions for this folder", func() { role := m.ROLE_EDITOR - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { - sc.handlerFunc = GetFolderById - sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/uid", "/api/folders/:uid", role, func(sc *scenarioContext) { + callGetFolder(sc) + + Convey("Should lookup folder by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "uid") + }) Convey("Should be denied access", func() { So(sc.resp.Code, ShouldEqual, 403) }) }) - loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/folders/1", "/api/folders/:id", role, func(sc *scenarioContext) { + callGetFolder(sc) + + Convey("Should lookup folder by id", func() { + So(getDashboardQueries[0].Id, ShouldEqual, 1) + }) + + Convey("Should be denied access", func() { + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/folders/uid", "/api/folders/:uid", role, func(sc *scenarioContext) { callDeleteFolder(sc) So(sc.resp.Code, ShouldEqual, 403) + + Convey("Should lookup folder by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "uid") + }) }) createFolderScenario("When calling POST on", "/api/folders", "/api/folders", role, cmd, func(sc *scenarioContext) { callCreateFolder(sc) So(sc.resp.Code, ShouldEqual, 403) }) + + updateFolderScenario("When calling PUT on", "/api/folders/uid", "/api/folders/:uid", role, updateFolderCmd, func(sc *scenarioContext) { + callUpdateFolder(sc) + So(sc.resp.Code, ShouldEqual, 403) + + Convey("Should lookup folder by uid", func() { + So(getDashboardQueries[0].Uid, ShouldEqual, "uid") + }) + }) }) }) } @@ -224,7 +380,7 @@ func getFolderShouldReturn200(sc *scenarioContext) dtos.Folder { } func callGetFolder(sc *scenarioContext) { - sc.handlerFunc = GetFolderById + sc.handlerFunc = GetFolder sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() } diff --git a/pkg/models/folders.go b/pkg/models/folders.go index f07b5daa04e..6ea0ac16759 100644 --- a/pkg/models/folders.go +++ b/pkg/models/folders.go @@ -2,6 +2,7 @@ package models import ( "errors" + "strings" "time" ) @@ -10,14 +11,16 @@ var ( ErrFolderNotFound = errors.New("Folder not found") ErrFolderVersionMismatch = errors.New("The folder has been changed by someone else") ErrFolderTitleEmpty = errors.New("Folder title cannot be empty") - ErrFolderWithSameUIDExists = errors.New("A folder with the same uid already exists") + ErrFolderWithSameUIDExists = errors.New("A folder/dashboard with the same uid already exists") + ErrFolderSameNameExists = errors.New("A folder or dashboard in the general folder with the same name already exists") ErrFolderFailedGenerateUniqueUid = errors.New("Failed to generate unique folder id") ) type Folder struct { Id int64 + Uid string Title string - Slug string + Url string OrgId int64 Version int @@ -29,6 +32,45 @@ type Folder struct { HasAcl bool } +// GetDashboardModel turns the command into the savable model +func (cmd *CreateFolderCommand) GetDashboardModel() *Dashboard { + dashFolder := NewDashboardFolder(strings.TrimSpace(cmd.Title)) + dashFolder.OrgId = cmd.OrgId + dashFolder.Uid = strings.TrimSpace(cmd.Uid) + dashFolder.Data.Set("uid", cmd.Uid) + + userId := cmd.UserId + + if userId == 0 { + userId = -1 + } + + dashFolder.CreatedBy = userId + dashFolder.UpdatedBy = userId + dashFolder.UpdateSlug() + + return dashFolder +} + +// UpdateDashboardModel updates an existing model from command into model for update +func (cmd *UpdateFolderCommand) UpdateDashboardModel(dashFolder *Dashboard) { + dashFolder.Title = strings.TrimSpace(cmd.Title) + dashFolder.Data.Set("title", cmd.Title) + dashFolder.Uid = dashFolder.Data.MustString("uid") + dashFolder.Data.Set("version", cmd.Version) + dashFolder.Version = cmd.Version + dashFolder.IsFolder = true + + userId := cmd.UserId + + if userId == 0 { + userId = -1 + } + + dashFolder.UpdatedBy = userId + dashFolder.UpdateSlug() +} + // // COMMANDS // @@ -36,16 +78,18 @@ type Folder struct { type CreateFolderCommand struct { OrgId int64 `json:"-"` UserId int64 `json:"userId"` + Uid string `json:"uid"` Title string `json:"title"` Result *Folder } type UpdateFolderCommand struct { - OrgId int64 `json:"-"` - UserId int64 `json:"userId"` - Title string `json:"title"` - Version int `json:"version"` + OrgId int64 `json:"-"` + UserId int64 `json:"userId"` + Title string `json:"title"` + Version int `json:"version"` + Overwrite bool `json:"overwrite"` Result *Folder }