Skip to content

[public-api] Implement experimental TeamsService.CreateTeam #14152

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Nov 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions components/public-api-server/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/gitpod-io/gitpod/usage-api v0.0.0-00010101000000-000000000000
github.com/golang/mock v1.6.0
github.com/google/go-cmp v0.5.9
github.com/google/uuid v1.1.2
github.com/gorilla/handlers v1.5.1
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0
github.com/prometheus/client_golang v1.13.0
Expand Down
2 changes: 2 additions & 0 deletions components/public-api-server/go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

97 changes: 97 additions & 0 deletions components/public-api-server/pkg/apiv1/team.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
// Licensed under the GNU Affero General Public License (AGPL).
// See License-AGPL.txt in the project root for license information.

package apiv1

import (
"context"
"fmt"
protocol "github.com/gitpod-io/gitpod/gitpod-protocol"

connect "github.com/bufbuild/connect-go"
"github.com/gitpod-io/gitpod/common-go/log"
"github.com/gitpod-io/gitpod/public-api-server/pkg/auth"
"github.com/gitpod-io/gitpod/public-api-server/pkg/proxy"
v1 "github.com/gitpod-io/gitpod/public-api/experimental/v1"
"github.com/gitpod-io/gitpod/public-api/experimental/v1/v1connect"
)

func NewTeamsService(pool proxy.ServerConnectionPool) *TeamService {
return &TeamService{
connectionPool: pool,
}
}

var _ v1connect.TeamsServiceHandler = (*TeamService)(nil)

type TeamService struct {
connectionPool proxy.ServerConnectionPool

v1connect.UnimplementedTeamsServiceHandler
}

func (s *TeamService) CreateTeam(ctx context.Context, req *connect.Request[v1.CreateTeamRequest]) (*connect.Response[v1.CreateTeamResponse], error) {
token := auth.TokenFromContext(ctx)

if req.Msg.GetName() == "" {
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("Name is a required argument when creating a team."))
}

server, err := s.connectionPool.Get(ctx, token)
if err != nil {
log.Log.WithError(err).Error("Failed to get connection to server.")
return nil, connect.NewError(connect.CodeInternal, err)
}

team, err := server.CreateTeam(ctx, req.Msg.GetName())
if err != nil {
return nil, proxy.ConvertError(err)
}

members, err := server.GetTeamMembers(ctx, team.ID)
if err != nil {
return nil, proxy.ConvertError(err)
}

invite, err := server.GetGenericInvite(ctx, team.ID)
if err != nil {
return nil, proxy.ConvertError(err)
}

return connect.NewResponse(&v1.CreateTeamResponse{
Team: &v1.Team{
Id: team.ID,
Name: team.Name,
Slug: team.Slug,
Members: teamMembersToAPIResponse(members),
TeamInvitation: &v1.TeamInvitation{
Id: invite.ID,
},
},
}), nil
}

func teamMembersToAPIResponse(members []*protocol.TeamMemberInfo) []*v1.TeamMember {
var result []*v1.TeamMember

for _, m := range members {
result = append(result, &v1.TeamMember{
UserId: m.UserId,
Role: teamRoleToAPIResponse(m.Role),
})
}

return result
}

func teamRoleToAPIResponse(role protocol.TeamMemberRole) v1.TeamRole {
switch role {
case protocol.TeamMember_Owner:
return v1.TeamRole_TEAM_ROLE_OWNER
case protocol.TeamMember_Member:
return v1.TeamRole_TEAM_ROLE_MEMBER
default:
return v1.TeamRole_TEAM_ROLE_UNSPECIFIED
}
}
150 changes: 150 additions & 0 deletions components/public-api-server/pkg/apiv1/team_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
// Licensed under the GNU Affero General Public License (AGPL).
// See License-AGPL.txt in the project root for license information.

package apiv1

import (
"context"
"errors"
"github.com/bufbuild/connect-go"
protocol "github.com/gitpod-io/gitpod/gitpod-protocol"
"github.com/gitpod-io/gitpod/public-api-server/pkg/auth"
v1 "github.com/gitpod-io/gitpod/public-api/experimental/v1"
"github.com/gitpod-io/gitpod/public-api/experimental/v1/v1connect"
"github.com/golang/mock/gomock"
"github.com/google/go-cmp/cmp"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/testing/protocmp"
"net/http"
"net/http/httptest"
"testing"
"time"
)

func TestTeamsService_CreateTeam(t *testing.T) {

var (
name = "Shiny New Team"
slug = "shiny-new-team"
id = uuid.New().String()
)

t.Run("returns invalid argument when name is empty", func(t *testing.T) {
ctx := context.Background()
_, client := setupTeamService(t)

_, err := client.CreateTeam(ctx, connect.NewRequest(&v1.CreateTeamRequest{Name: ""}))
require.Error(t, err)
require.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
})

t.Run("returns invalid request when server returns invalid request", func(t *testing.T) {
ctx := context.Background()
serverMock, client := setupTeamService(t)

serverMock.EXPECT().CreateTeam(gomock.Any(), name).Return(nil, errors.New("code 400"))

_, err := client.CreateTeam(ctx, connect.NewRequest(&v1.CreateTeamRequest{Name: name}))
require.Error(t, err)
require.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
})

t.Run("returns team with members and invite", func(t *testing.T) {
ts := time.Now().UTC()
teamMembers := []*protocol.TeamMemberInfo{
{
UserId: uuid.New().String(),
FullName: "Alice Alice",
PrimaryEmail: "[email protected]",
AvatarUrl: "",
Role: protocol.TeamMember_Owner,
MemberSince: "",
}, {
UserId: uuid.New().String(),
FullName: "Bob Bob",
PrimaryEmail: "[email protected]",
AvatarUrl: "",
Role: protocol.TeamMember_Member,
MemberSince: "",
},
}
inviteID := uuid.New().String()

serverMock, client := setupTeamService(t)

serverMock.EXPECT().CreateTeam(gomock.Any(), name).Return(&protocol.Team{
ID: id,
Name: name,
Slug: slug,
CreationTime: ts.String(),
}, nil)
serverMock.EXPECT().GetTeamMembers(gomock.Any(), id).Return(teamMembers, nil)
serverMock.EXPECT().GetGenericInvite(gomock.Any(), id).Return(&protocol.TeamMembershipInvite{
ID: inviteID,
TeamID: id,
Role: "",
CreationTime: "",
InvalidationTime: "",
InvitedEmail: "",
}, nil)

response, err := client.CreateTeam(context.Background(), connect.NewRequest(&v1.CreateTeamRequest{Name: name}))
require.NoError(t, err)

requireEqualProto(t, &v1.CreateTeamResponse{
Team: &v1.Team{
Id: id,
Name: name,
Slug: slug,
Members: []*v1.TeamMember{
{
UserId: teamMembers[0].UserId,
Role: teamRoleToAPIResponse(teamMembers[0].Role),
},
{
UserId: teamMembers[1].UserId,
Role: teamRoleToAPIResponse(teamMembers[1].Role),
},
},
TeamInvitation: &v1.TeamInvitation{
Id: inviteID,
},
},
}, response.Msg)
})
}

func setupTeamService(t *testing.T) (*protocol.MockAPIInterface, v1connect.TeamsServiceClient) {
t.Helper()

ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)

serverMock := protocol.NewMockAPIInterface(ctrl)

svc := NewTeamsService(&FakeServerConnPool{
api: serverMock,
})

_, handler := v1connect.NewTeamsServiceHandler(svc)

srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)

client := v1connect.NewTeamsServiceClient(http.DefaultClient, srv.URL, connect.WithInterceptors(
auth.NewClientInterceptor("auth-token"),
))

return serverMock, client
}

func requireEqualProto(t *testing.T, expected interface{}, actual interface{}) {
t.Helper()

diff := cmp.Diff(expected, actual, protocmp.Transform())
if diff != "" {
require.Fail(t, diff)
}
}
11 changes: 11 additions & 0 deletions components/public-api-server/pkg/proxy/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ func ConvertError(err error) error {

s := err.Error()

// components/gitpod-protocol/src/messaging/error.ts
if strings.Contains(s, "code 400") {
return connect.NewError(connect.CodeInvalidArgument, err)
}

// components/gitpod-protocol/src/messaging/error.ts
if strings.Contains(s, "code 401") {
return connect.NewError(connect.CodePermissionDenied, err)
}
Expand All @@ -31,6 +37,11 @@ func ConvertError(err error) error {
return connect.NewError(connect.CodeNotFound, err)
}

// components/gitpod-protocol/src/messaging/error.ts
if strings.Contains(s, "code 409") {
return connect.NewError(connect.CodeAlreadyExists, err)
}

// components/gitpod-messagebus/src/jsonrpc-server.ts#47
if strings.Contains(s, "code -32603") {
return connect.NewError(connect.CodeInternal, err)
Expand Down
8 changes: 8 additions & 0 deletions components/public-api-server/pkg/proxy/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ func TestConvertError(t *testing.T) {
WebsocketError: errors.New("jsonrpc2: code -32603 message: Request getWorkspace failed with message: No workspace with id 'some-id' found."),
ExpectedStatus: connect.CodeInternal,
},
{
WebsocketError: errors.New("code 400"),
ExpectedStatus: connect.CodeInvalidArgument,
},
{
WebsocketError: errors.New("code 409"),
ExpectedStatus: connect.CodeAlreadyExists,
},
}

for _, s := range scenarios {
Expand Down
3 changes: 3 additions & 0 deletions components/public-api-server/pkg/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ func register(srv *baseserver.Server, connPool proxy.ServerConnectionPool) error
workspacesRoute, workspacesServiceHandler := v1connect.NewWorkspacesServiceHandler(apiv1.NewWorkspaceService(connPool), handlerOptions...)
srv.HTTPMux().Handle(workspacesRoute, workspacesServiceHandler)

teamsRoute, teamsServiceHandler := v1connect.NewTeamsServiceHandler(apiv1.NewTeamsService(connPool), handlerOptions...)
srv.HTTPMux().Handle(teamsRoute, teamsServiceHandler)

return nil
}

Expand Down