satellite: add get user paged projects http endpoint

This change adds an endpoint to get a user's projects, similar to
the OwnedProjects GraphQL query.
The console.ProjectInfo struct has been renamed to UpsertProjectInfo
to more accurately reflect its use.

Issue: https://github.com/storj/storj/issues/6135

Change-Id: I802fe4694a5cc75a9df2b565476f6e6f473431d4
This commit is contained in:
Wilfred Asomani 2023-08-07 13:16:04 +00:00
parent b21041d6f9
commit 34e1caa55a
14 changed files with 169 additions and 62 deletions

View File

@ -261,7 +261,7 @@ func (system *Satellite) AddProject(ctx context.Context, ownerID uuid.UUID, name
if err != nil {
return nil, errs.Wrap(err)
}
project, err := system.API.Console.Service.CreateProject(ctx, console.ProjectInfo{
project, err := system.API.Console.Service.CreateProject(ctx, console.UpsertProjectInfo{
Name: name,
})
if err != nil {

View File

@ -28,8 +28,8 @@ var ErrApikeysAPI = errs.Class("consoleapi apikeys api")
var ErrUsersAPI = errs.Class("consoleapi users api")
type ProjectManagementService interface {
GenCreateProject(ctx context.Context, request console.ProjectInfo) (*console.Project, api.HTTPError)
GenUpdateProject(ctx context.Context, id uuid.UUID, request console.ProjectInfo) (*console.Project, api.HTTPError)
GenCreateProject(ctx context.Context, request console.UpsertProjectInfo) (*console.Project, api.HTTPError)
GenUpdateProject(ctx context.Context, id uuid.UUID, request console.UpsertProjectInfo) (*console.Project, api.HTTPError)
GenDeleteProject(ctx context.Context, id uuid.UUID) api.HTTPError
GenGetUsersProjects(ctx context.Context) ([]console.Project, api.HTTPError)
GenGetSingleBucketUsageRollup(ctx context.Context, projectID uuid.UUID, bucket string, since, before time.Time) (*accounting.BucketUsageRollup, api.HTTPError)
@ -126,7 +126,7 @@ func (h *ProjectManagementHandler) handleGenCreateProject(w http.ResponseWriter,
w.Header().Set("Content-Type", "application/json")
payload := console.ProjectInfo{}
payload := console.UpsertProjectInfo{}
if err = json.NewDecoder(r.Body).Decode(&payload); err != nil {
api.ServeError(h.log, w, http.StatusBadRequest, err)
return
@ -170,7 +170,7 @@ func (h *ProjectManagementHandler) handleGenUpdateProject(w http.ResponseWriter,
return
}
payload := console.ProjectInfo{}
payload := console.UpsertProjectInfo{}
if err = json.NewDecoder(r.Body).Decode(&payload); err != nil {
api.ServeError(h.log, w, http.StatusBadRequest, err)
return

View File

@ -35,7 +35,7 @@ func main() {
MethodName: "GenCreateProject",
RequestName: "createProject",
Response: &console.Project{},
Request: console.ProjectInfo{},
Request: console.UpsertProjectInfo{},
})
g.Patch("/update/{id}", &apigen.Endpoint{
@ -44,7 +44,7 @@ func main() {
MethodName: "GenUpdateProject",
RequestName: "updateProject",
Response: console.Project{},
Request: console.ProjectInfo{},
Request: console.UpsertProjectInfo{},
PathParams: []apigen.Param{
apigen.NewParam("id", uuid.UUID{}),
},

View File

@ -8,6 +8,7 @@ import (
"encoding/base64"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
@ -44,29 +45,18 @@ func (p *Projects) GetUserProjects(w http.ResponseWriter, r *http.Request) {
projects, err := p.service.GetUsersProjects(ctx)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
return
}
type jsonProject struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
OwnerID uuid.UUID `json:"ownerId"`
Description string `json:"description"`
MemberCount int `json:"memberCount"`
CreatedAt time.Time `json:"createdAt"`
}
response := make([]jsonProject, 0)
response := make([]console.ProjectInfo, 0)
for _, project := range projects {
response = append(response, jsonProject{
ID: project.PublicID,
Name: project.Name,
OwnerID: project.OwnerID,
Description: project.Description,
MemberCount: project.MemberCount,
CreatedAt: project.CreatedAt,
})
response = append(response, project.GetMinimal())
}
err = json.NewEncoder(w).Encode(response)
@ -75,6 +65,79 @@ func (p *Projects) GetUserProjects(w http.ResponseWriter, r *http.Request) {
}
}
// GetPagedProjects returns paged projects for a user.
func (p *Projects) GetPagedProjects(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
w.Header().Set("Content-Type", "application/json")
query := r.URL.Query()
limitParam := query.Get("limit")
if limitParam == "" {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("parameter 'limit' is required"))
return
}
limit, err := strconv.ParseUint(limitParam, 10, 32)
if err != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, err)
return
}
pageParam := query.Get("page")
if pageParam == "" {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("parameter 'page' is required"))
return
}
page, err := strconv.ParseUint(pageParam, 10, 32)
if err != nil {
p.serveJSONError(ctx, w, http.StatusBadRequest, err)
return
}
if page == 0 {
p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("parameter 'page' can not be 0"))
return
}
cursor := console.ProjectsCursor{
Limit: int(limit),
Page: int(page),
}
projectsPage, err := p.service.GetUsersOwnedProjectsPage(ctx, cursor)
if err != nil {
if console.ErrUnauthorized.Has(err) {
p.serveJSONError(ctx, w, http.StatusUnauthorized, err)
return
}
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
return
}
pageToSend := console.ProjectInfoPage{
Limit: projectsPage.Limit,
Offset: projectsPage.Offset,
PageCount: projectsPage.PageCount,
CurrentPage: projectsPage.CurrentPage,
TotalCount: projectsPage.TotalCount,
}
for _, project := range projectsPage.Projects {
pageToSend.Projects = append(pageToSend.Projects, project.GetMinimal())
}
err = json.NewEncoder(w).Encode(pageToSend)
if err != nil {
p.serveJSONError(ctx, w, http.StatusInternalServerError, err)
}
}
// GetSalt returns the project's salt.
func (p *Projects) GetSalt(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

View File

@ -206,7 +206,7 @@ func TestGraphqlMutation(t *testing.T) {
var projectIDField string
var projectPublicIDField string
t.Run("Create project mutation", func(t *testing.T) {
projectInfo := console.ProjectInfo{
projectInfo := console.UpsertProjectInfo{
Name: "Project name",
Description: "desc",
}

View File

@ -450,16 +450,16 @@ func graphqlProjectUsage() *graphql.Object {
})
}
// fromMapProjectInfo creates console.ProjectInfo from input args.
func fromMapProjectInfo(args map[string]interface{}) (project console.ProjectInfo) {
// fromMapProjectInfo creates console.UpsertProjectInfo from input args.
func fromMapProjectInfo(args map[string]interface{}) (project console.UpsertProjectInfo) {
project.Name, _ = args[FieldName].(string)
project.Description, _ = args[FieldDescription].(string)
return
}
// fromMapProjectInfoProjectLimits creates console.ProjectInfo from input args.
func fromMapProjectInfoProjectLimits(projectInfo, projectLimits map[string]interface{}) (project console.ProjectInfo, err error) {
// fromMapProjectInfoProjectLimits creates console.UpsertProjectInfo from input args.
func fromMapProjectInfoProjectLimits(projectInfo, projectLimits map[string]interface{}) (project console.UpsertProjectInfo, err error) {
project.Name, _ = projectInfo[FieldName].(string)
project.Description, _ = projectInfo[FieldDescription].(string)
storageLimit, err := strconv.Atoi(projectLimits[FieldStorageLimit].(string))

View File

@ -188,7 +188,7 @@ func TestGraphqlQuery(t *testing.T) {
return result.Data
}
createdProject, err := service.CreateProject(userCtx, console.ProjectInfo{
createdProject, err := service.CreateProject(userCtx, console.UpsertProjectInfo{
Name: "TestProject",
})
require.NoError(t, err)
@ -396,7 +396,7 @@ func TestGraphqlQuery(t *testing.T) {
assert.True(t, foundKey2)
})
project2, err := service.CreateProject(userCtx, console.ProjectInfo{
project2, err := service.CreateProject(userCtx, console.UpsertProjectInfo{
Name: "Project2",
Description: "Test desc",
})

View File

@ -590,6 +590,14 @@ func TestProjects(t *testing.T) {
require.Contains(t, body, "storageLimit")
}
{ // Get_OwnedProjects - HTTP
var projects console.ProjectInfoPage
resp, body := test.request(http.MethodGet, "/projects/paged?limit=6&page=1", nil)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NoError(t, json.Unmarshal([]byte(body), &projects))
require.NotEmpty(t, projects.Projects)
}
{ // Get_OwnedProjects
resp, body := test.request(http.MethodPost, "/graphql",
test.toJSON(map[string]interface{}{

View File

@ -276,6 +276,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc
projectsRouter.Use(server.withCORS)
projectsRouter.Use(server.withAuth)
projectsRouter.Handle("", http.HandlerFunc(projectsController.GetUserProjects)).Methods(http.MethodGet, http.MethodOptions)
projectsRouter.Handle("/paged", http.HandlerFunc(projectsController.GetPagedProjects)).Methods(http.MethodGet, http.MethodOptions)
projectsRouter.Handle("/{id}/salt", http.HandlerFunc(projectsController.GetSalt)).Methods(http.MethodGet, http.MethodOptions)
projectsRouter.Handle("/{id}/invite", http.HandlerFunc(projectsController.InviteUsers)).Methods(http.MethodPost, http.MethodOptions)
projectsRouter.Handle("/{id}/invite-link", http.HandlerFunc(projectsController.GetInviteLink)).Methods(http.MethodGet, http.MethodOptions)

View File

@ -114,8 +114,8 @@ type Project struct {
DefaultPlacement storj.PlacementConstraint `json:"defaultPlacement"`
}
// ProjectInfo holds data needed to create/update Project.
type ProjectInfo struct {
// UpsertProjectInfo holds data needed to create/update Project.
type UpsertProjectInfo struct {
Name string `json:"name"`
Description string `json:"description"`
StorageLimit memory.Size `json:"storageLimit"`
@ -123,6 +123,16 @@ type ProjectInfo struct {
CreatedAt time.Time `json:"createdAt"`
}
// ProjectInfo holds data sent via user facing http endpoints.
type ProjectInfo struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
OwnerID uuid.UUID `json:"ownerId"`
Description string `json:"description"`
MemberCount int `json:"memberCount"`
CreatedAt time.Time `json:"createdAt"`
}
// ProjectsCursor holds info for project
// cursor pagination.
type ProjectsCursor struct {
@ -146,6 +156,19 @@ type ProjectsPage struct {
TotalCount int64
}
// ProjectInfoPage is similar to ProjectsPage
// except the Projects field is ProjectInfo and is sent over HTTP API.
type ProjectInfoPage struct {
Projects []ProjectInfo `json:"projects"`
Limit int `json:"limit"`
Offset int64 `json:"offset"`
PageCount int `json:"pageCount"`
CurrentPage int `json:"currentPage"`
TotalCount int64 `json:"totalCount"`
}
// ValidateNameAndDescription validates project name and description strings.
// Project name must have more than 0 and less than 21 symbols.
// Project description can't have more than hundred symbols.
@ -164,3 +187,15 @@ func ValidateNameAndDescription(name string, description string) error {
return nil
}
// GetMinimal returns a ProjectInfo copy of a project.
func (p Project) GetMinimal() ProjectInfo {
return ProjectInfo{
ID: p.PublicID,
Name: p.Name,
OwnerID: p.OwnerID,
Description: p.Description,
MemberCount: p.MemberCount,
CreatedAt: p.CreatedAt,
}
}

View File

@ -1601,7 +1601,7 @@ func (s *Service) GetUsersOwnedProjectsPage(ctx context.Context, cursor Projects
}
// CreateProject is a method for creating new project.
func (s *Service) CreateProject(ctx context.Context, projectInfo ProjectInfo) (p *Project, err error) {
func (s *Service) CreateProject(ctx context.Context, projectInfo UpsertProjectInfo) (p *Project, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "create project")
if err != nil {
@ -1664,7 +1664,7 @@ func (s *Service) CreateProject(ctx context.Context, projectInfo ProjectInfo) (p
}
// GenCreateProject is a method for creating new project for generated api.
func (s *Service) GenCreateProject(ctx context.Context, projectInfo ProjectInfo) (p *Project, httpError api.HTTPError) {
func (s *Service) GenCreateProject(ctx context.Context, projectInfo UpsertProjectInfo) (p *Project, httpError api.HTTPError) {
var err error
defer mon.Task()(&ctx)(&err)
@ -1809,7 +1809,7 @@ func (s *Service) GenDeleteProject(ctx context.Context, projectID uuid.UUID) (ht
// UpdateProject is a method for updating project name and description by id.
// projectID here may be project.PublicID or project.ID.
func (s *Service) UpdateProject(ctx context.Context, projectID uuid.UUID, updatedProject ProjectInfo) (p *Project, err error) {
func (s *Service) UpdateProject(ctx context.Context, projectID uuid.UUID, updatedProject UpsertProjectInfo) (p *Project, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "update project name and description", zap.String("projectID", projectID.String()))
@ -1899,7 +1899,7 @@ func (s *Service) UpdateProject(ctx context.Context, projectID uuid.UUID, update
}
// GenUpdateProject is a method for updating project name and description by id for generated api.
func (s *Service) GenUpdateProject(ctx context.Context, projectID uuid.UUID, projectInfo ProjectInfo) (p *Project, httpError api.HTTPError) {
func (s *Service) GenUpdateProject(ctx context.Context, projectID uuid.UUID, projectInfo UpsertProjectInfo) (p *Project, httpError api.HTTPError) {
var err error
defer mon.Task()(&ctx)(&err)
@ -2953,7 +2953,7 @@ func (s *Service) checkProjectLimit(ctx context.Context, userID uuid.UUID) (curr
}
// checkProjectName is used to check if user has used project name before.
func (s *Service) checkProjectName(ctx context.Context, projectInfo ProjectInfo, userID uuid.UUID) (passesNameCheck bool, err error) {
func (s *Service) checkProjectName(ctx context.Context, projectInfo UpsertProjectInfo, userID uuid.UUID) (passesNameCheck bool, err error) {
defer mon.Task()(&ctx)(&err)
passesCheck := true

View File

@ -149,7 +149,7 @@ func TestService(t *testing.T) {
t.Run("CreateProject", func(t *testing.T) {
// Creating a project with a previously used name should fail
createdProject, err := service.CreateProject(userCtx1, console.ProjectInfo{
createdProject, err := service.CreateProject(userCtx1, console.UpsertProjectInfo{
Name: up1Proj.Name,
})
require.Error(t, err)
@ -169,7 +169,7 @@ func TestService(t *testing.T) {
userCtx, err := sat.UserContext(ctx, user.ID)
require.NoError(t, err)
p, err := service.CreateProject(userCtx, console.ProjectInfo{
p, err := service.CreateProject(userCtx, console.UpsertProjectInfo{
Name: "eu-project",
Description: "project with eu1 default placement",
CreatedAt: time.Now(),
@ -188,7 +188,7 @@ func TestService(t *testing.T) {
_, userCtx1 := getOwnerAndCtx(ctx, up1Proj)
// Updating own project should work
updatedProject, err := service.UpdateProject(userCtx1, up1Proj.ID, console.ProjectInfo{
updatedProject, err := service.UpdateProject(userCtx1, up1Proj.ID, console.UpsertProjectInfo{
Name: updatedName,
Description: updatedDescription,
StorageLimit: updatedStorageLimit,
@ -207,7 +207,7 @@ func TestService(t *testing.T) {
require.Equal(t, updatedBandwidthLimit, *updatedProject.UserSpecifiedBandwidthLimit)
// Updating someone else project details should not work
updatedProject, err = service.UpdateProject(userCtx1, up2Proj.ID, console.ProjectInfo{
updatedProject, err = service.UpdateProject(userCtx1, up2Proj.ID, console.UpsertProjectInfo{
Name: "newName",
Description: "TestUpdate",
StorageLimit: memory.Size(100),
@ -226,7 +226,7 @@ func TestService(t *testing.T) {
err = sat.DB.Console().Projects().Update(ctx, up1Proj)
require.NoError(t, err)
updateInfo := console.ProjectInfo{
updateInfo := console.UpsertProjectInfo{
Name: "a b c",
Description: "1 2 3",
StorageLimit: memory.Size(123),
@ -266,7 +266,7 @@ func TestService(t *testing.T) {
require.Equal(t, updateInfo.BandwidthLimit, *project.BandwidthLimit)
// attempting to update a project with a previously used name should fail
updatedProject, err = service.UpdateProject(userCtx1, up2Proj.ID, console.ProjectInfo{
updatedProject, err = service.UpdateProject(userCtx1, up2Proj.ID, console.UpsertProjectInfo{
Name: up1Proj.Name,
})
require.Error(t, err)
@ -276,7 +276,7 @@ func TestService(t *testing.T) {
_, err = service.AddProjectMembers(userCtx1, up1Proj.ID, []string{user2.Email})
require.NoError(t, err)
// Members should not be able to update project.
_, err = service.UpdateProject(userCtx2, up1Proj.ID, console.ProjectInfo{
_, err = service.UpdateProject(userCtx2, up1Proj.ID, console.UpsertProjectInfo{
Name: updatedName,
})
require.Error(t, err)
@ -765,7 +765,7 @@ func TestPaidTier(t *testing.T) {
require.Equal(t, usageConfig.Segment.Paid, *proj1.SegmentLimit)
// expect new project to be created with paid tier usage limits
proj2, err := service.CreateProject(userCtx, console.ProjectInfo{Name: "Project 2"})
proj2, err := service.CreateProject(userCtx, console.UpsertProjectInfo{Name: "Project 2"})
require.NoError(t, err)
require.Equal(t, usageConfig.Storage.Paid, *proj2.StorageLimit)
})
@ -821,7 +821,7 @@ func TestUpdateProjectExceedsLimits(t *testing.T) {
require.Equal(t, usageConfig.Segment.Free, *proj.SegmentLimit)
// update project name should succeed
_, err = service.UpdateProject(userCtx1, projectID, console.ProjectInfo{
_, err = service.UpdateProject(userCtx1, projectID, console.UpsertProjectInfo{
Name: updatedName,
Description: updatedDescription,
})
@ -838,7 +838,7 @@ func TestUpdateProjectExceedsLimits(t *testing.T) {
require.NoError(t, err)
// try to update project name should succeed
_, err = service.UpdateProject(userCtx1, projectID, console.ProjectInfo{
_, err = service.UpdateProject(userCtx1, projectID, console.UpsertProjectInfo{
Name: "updatedName",
Description: "updatedDescription",
})
@ -2020,7 +2020,7 @@ func TestServiceGenMethods(t *testing.T) {
updatedStorageLimit := memory.Size(100)
updatedBandwidthLimit := memory.Size(100)
info := console.ProjectInfo{
info := console.UpsertProjectInfo{
Name: updatedName,
Description: updatedDescription,
StorageLimit: updatedStorageLimit,
@ -2076,7 +2076,7 @@ func TestServiceGenMethods(t *testing.T) {
})
// create empty project for easy deletion
p, err := s.CreateProject(tt.ctx, console.ProjectInfo{
p, err := s.CreateProject(tt.ctx, console.UpsertProjectInfo{
Name: "foo",
Description: "bar",
})

View File

@ -131,7 +131,7 @@ func TestOIDC(t *testing.T) {
authed := console.WithUser(ctx, user)
project, err := sat.API.Console.Service.CreateProject(authed, console.ProjectInfo{
project, err := sat.API.Console.Service.CreateProject(authed, console.UpsertProjectInfo{
Name: "test",
})
require.NoError(t, err)

View File

@ -69,14 +69,6 @@ export class Project {
defaultPlacement: number;
}
export class ProjectInfo {
name: string;
description: string;
storageLimit: MemorySize;
bandwidthLimit: MemorySize;
createdAt: Time;
}
export class ResponseUser {
id: UUID;
fullName: string;
@ -94,11 +86,19 @@ export class ResponseUser {
mfaRecoveryCodeCount: number;
}
export class UpsertProjectInfo {
name: string;
description: string;
storageLimit: MemorySize;
bandwidthLimit: MemorySize;
createdAt: Time;
}
export class projectsHttpApiV0 {
private readonly http: HttpClient = new HttpClient();
private readonly ROOT_PATH: string = '/api/v0/projects';
public async createProject(request: ProjectInfo): Promise<Project> {
public async createProject(request: UpsertProjectInfo): Promise<Project> {
const path = `${this.ROOT_PATH}/create`;
const response = await this.http.post(path, JSON.stringify(request));
if (response.ok) {
@ -108,7 +108,7 @@ export class projectsHttpApiV0 {
throw new Error(err.error);
}
public async updateProject(request: ProjectInfo, id: UUID): Promise<Project> {
public async updateProject(request: UpsertProjectInfo, id: UUID): Promise<Project> {
const path = `${this.ROOT_PATH}/update/${id}`;
const response = await this.http.patch(path, JSON.stringify(request));
if (response.ok) {