Skip to content

Commit 1bb8e5a

Browse files
committed
[public-api] Implement experimental TeamsService.CreateTeam
1 parent 92dd887 commit 1bb8e5a

File tree

7 files changed

+272
-0
lines changed

7 files changed

+272
-0
lines changed

Diff for: components/public-api-server/go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ require (
1111
github.com/gitpod-io/gitpod/usage-api v0.0.0-00010101000000-000000000000
1212
github.com/golang/mock v1.6.0
1313
github.com/google/go-cmp v0.5.9
14+
github.com/google/uuid v1.1.2
1415
github.com/gorilla/handlers v1.5.1
1516
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0
1617
github.com/prometheus/client_golang v1.13.0

Diff for: components/public-api-server/go.sum

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: components/public-api-server/pkg/apiv1/team.go

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
2+
// Licensed under the GNU Affero General Public License (AGPL).
3+
// See License-AGPL.txt in the project root for license information.
4+
5+
package apiv1
6+
7+
import (
8+
"context"
9+
"fmt"
10+
protocol "github.com/gitpod-io/gitpod/gitpod-protocol"
11+
12+
connect "github.com/bufbuild/connect-go"
13+
"github.com/gitpod-io/gitpod/common-go/log"
14+
"github.com/gitpod-io/gitpod/public-api-server/pkg/auth"
15+
"github.com/gitpod-io/gitpod/public-api-server/pkg/proxy"
16+
v1 "github.com/gitpod-io/gitpod/public-api/experimental/v1"
17+
"github.com/gitpod-io/gitpod/public-api/experimental/v1/v1connect"
18+
)
19+
20+
func NewTeamsService(pool proxy.ServerConnectionPool) *TeamService {
21+
return &TeamService{
22+
connectionPool: pool,
23+
}
24+
}
25+
26+
var _ v1connect.TeamsServiceHandler = (*TeamService)(nil)
27+
28+
type TeamService struct {
29+
connectionPool proxy.ServerConnectionPool
30+
31+
v1connect.UnimplementedTeamsServiceHandler
32+
}
33+
34+
func (s *TeamService) CreateTeam(ctx context.Context, req *connect.Request[v1.CreateTeamRequest]) (*connect.Response[v1.CreateTeamResponse], error) {
35+
token := auth.TokenFromContext(ctx)
36+
37+
if req.Msg.GetName() == "" {
38+
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("Name is a required argument when creating a team."))
39+
}
40+
41+
server, err := s.connectionPool.Get(ctx, token)
42+
if err != nil {
43+
log.Log.WithError(err).Error("Failed to get connection to server.")
44+
return nil, connect.NewError(connect.CodeInternal, err)
45+
}
46+
47+
team, err := server.CreateTeam(ctx, req.Msg.GetName())
48+
if err != nil {
49+
return nil, proxy.ConvertError(err)
50+
}
51+
52+
members, err := server.GetTeamMembers(ctx, team.ID)
53+
if err != nil {
54+
return nil, proxy.ConvertError(err)
55+
}
56+
57+
invite, err := server.GetGenericInvite(ctx, team.ID)
58+
if err != nil {
59+
return nil, proxy.ConvertError(err)
60+
}
61+
62+
return connect.NewResponse(&v1.CreateTeamResponse{
63+
Team: &v1.Team{
64+
Id: team.ID,
65+
Name: team.Name,
66+
Slug: team.Slug,
67+
Members: teamMembersToAPIResponse(members),
68+
TeamInvitation: &v1.TeamInvitation{
69+
Id: invite.ID,
70+
},
71+
},
72+
}), nil
73+
}
74+
75+
func teamMembersToAPIResponse(members []*protocol.TeamMemberInfo) []*v1.TeamMember {
76+
var result []*v1.TeamMember
77+
78+
for _, m := range members {
79+
result = append(result, &v1.TeamMember{
80+
UserId: m.UserId,
81+
Role: teamRoleToAPIResponse(m.Role),
82+
})
83+
}
84+
85+
return result
86+
}
87+
88+
func teamRoleToAPIResponse(role protocol.TeamMemberRole) v1.TeamRole {
89+
switch role {
90+
case protocol.TeamMember_Owner:
91+
return v1.TeamRole_TEAM_ROLE_OWNER
92+
case protocol.TeamMember_Member:
93+
return v1.TeamRole_TEAM_ROLE_MEMBER
94+
default:
95+
return v1.TeamRole_TEAM_ROLE_UNSPECIFIED
96+
}
97+
}

Diff for: components/public-api-server/pkg/apiv1/team_test.go

+150
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
2+
// Licensed under the GNU Affero General Public License (AGPL).
3+
// See License-AGPL.txt in the project root for license information.
4+
5+
package apiv1
6+
7+
import (
8+
"context"
9+
"errors"
10+
"github.com/bufbuild/connect-go"
11+
protocol "github.com/gitpod-io/gitpod/gitpod-protocol"
12+
"github.com/gitpod-io/gitpod/public-api-server/pkg/auth"
13+
v1 "github.com/gitpod-io/gitpod/public-api/experimental/v1"
14+
"github.com/gitpod-io/gitpod/public-api/experimental/v1/v1connect"
15+
"github.com/golang/mock/gomock"
16+
"github.com/google/go-cmp/cmp"
17+
"github.com/google/uuid"
18+
"github.com/stretchr/testify/require"
19+
"google.golang.org/protobuf/testing/protocmp"
20+
"net/http"
21+
"net/http/httptest"
22+
"testing"
23+
"time"
24+
)
25+
26+
func TestTeamsService_CreateTeam(t *testing.T) {
27+
28+
var (
29+
name = "Shiny New Team"
30+
slug = "shiny-new-team"
31+
id = uuid.New().String()
32+
)
33+
34+
t.Run("returns invalid argument when name is empty", func(t *testing.T) {
35+
ctx := context.Background()
36+
_, client := setupTeamService(t)
37+
38+
_, err := client.CreateTeam(ctx, connect.NewRequest(&v1.CreateTeamRequest{Name: ""}))
39+
require.Error(t, err)
40+
require.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
41+
})
42+
43+
t.Run("returns invalid request when server returns invalid request", func(t *testing.T) {
44+
ctx := context.Background()
45+
serverMock, client := setupTeamService(t)
46+
47+
serverMock.EXPECT().CreateTeam(gomock.Any(), name).Return(nil, errors.New("code 400"))
48+
49+
_, err := client.CreateTeam(ctx, connect.NewRequest(&v1.CreateTeamRequest{Name: name}))
50+
require.Error(t, err)
51+
require.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
52+
})
53+
54+
t.Run("returns team with members and invite", func(t *testing.T) {
55+
ts := time.Now().UTC()
56+
teamMembers := []*protocol.TeamMemberInfo{
57+
{
58+
UserId: uuid.New().String(),
59+
FullName: "Alice Alice",
60+
PrimaryEmail: "[email protected]",
61+
AvatarUrl: "",
62+
Role: protocol.TeamMember_Owner,
63+
MemberSince: "",
64+
}, {
65+
UserId: uuid.New().String(),
66+
FullName: "Bob Bob",
67+
PrimaryEmail: "[email protected]",
68+
AvatarUrl: "",
69+
Role: protocol.TeamMember_Member,
70+
MemberSince: "",
71+
},
72+
}
73+
inviteID := uuid.New().String()
74+
75+
serverMock, client := setupTeamService(t)
76+
77+
serverMock.EXPECT().CreateTeam(gomock.Any(), name).Return(&protocol.Team{
78+
ID: id,
79+
Name: name,
80+
Slug: slug,
81+
CreationTime: ts.String(),
82+
}, nil)
83+
serverMock.EXPECT().GetTeamMembers(gomock.Any(), id).Return(teamMembers, nil)
84+
serverMock.EXPECT().GetGenericInvite(gomock.Any(), id).Return(&protocol.TeamMembershipInvite{
85+
ID: inviteID,
86+
TeamID: id,
87+
Role: "",
88+
CreationTime: "",
89+
InvalidationTime: "",
90+
InvitedEmail: "",
91+
}, nil)
92+
93+
response, err := client.CreateTeam(context.Background(), connect.NewRequest(&v1.CreateTeamRequest{Name: name}))
94+
require.NoError(t, err)
95+
96+
requireEqualProto(t, &v1.CreateTeamResponse{
97+
Team: &v1.Team{
98+
Id: id,
99+
Name: name,
100+
Slug: slug,
101+
Members: []*v1.TeamMember{
102+
{
103+
UserId: teamMembers[0].UserId,
104+
Role: teamRoleToAPIResponse(teamMembers[0].Role),
105+
},
106+
{
107+
UserId: teamMembers[1].UserId,
108+
Role: teamRoleToAPIResponse(teamMembers[1].Role),
109+
},
110+
},
111+
TeamInvitation: &v1.TeamInvitation{
112+
Id: inviteID,
113+
},
114+
},
115+
}, response.Msg)
116+
})
117+
}
118+
119+
func setupTeamService(t *testing.T) (*protocol.MockAPIInterface, v1connect.TeamsServiceClient) {
120+
t.Helper()
121+
122+
ctrl := gomock.NewController(t)
123+
t.Cleanup(ctrl.Finish)
124+
125+
serverMock := protocol.NewMockAPIInterface(ctrl)
126+
127+
svc := NewTeamsService(&FakeServerConnPool{
128+
api: serverMock,
129+
})
130+
131+
_, handler := v1connect.NewTeamsServiceHandler(svc)
132+
133+
srv := httptest.NewServer(handler)
134+
t.Cleanup(srv.Close)
135+
136+
client := v1connect.NewTeamsServiceClient(http.DefaultClient, srv.URL, connect.WithInterceptors(
137+
auth.NewClientInterceptor("auth-token"),
138+
))
139+
140+
return serverMock, client
141+
}
142+
143+
func requireEqualProto(t *testing.T, expected interface{}, actual interface{}) {
144+
t.Helper()
145+
146+
diff := cmp.Diff(expected, actual, protocmp.Transform())
147+
if diff != "" {
148+
require.Fail(t, diff)
149+
}
150+
}

Diff for: components/public-api-server/pkg/proxy/errors.go

+11
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ func ConvertError(err error) error {
1717

1818
s := err.Error()
1919

20+
// components/gitpod-protocol/src/messaging/error.ts
21+
if strings.Contains(s, "code 400") {
22+
return connect.NewError(connect.CodeInvalidArgument, err)
23+
}
24+
25+
// components/gitpod-protocol/src/messaging/error.ts
2026
if strings.Contains(s, "code 401") {
2127
return connect.NewError(connect.CodePermissionDenied, err)
2228
}
@@ -31,6 +37,11 @@ func ConvertError(err error) error {
3137
return connect.NewError(connect.CodeNotFound, err)
3238
}
3339

40+
// components/gitpod-protocol/src/messaging/error.ts
41+
if strings.Contains(s, "code 409") {
42+
return connect.NewError(connect.CodeAlreadyExists, err)
43+
}
44+
3445
// components/gitpod-messagebus/src/jsonrpc-server.ts#47
3546
if strings.Contains(s, "code -32603") {
3647
return connect.NewError(connect.CodeInternal, err)

Diff for: components/public-api-server/pkg/proxy/errors_test.go

+8
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ func TestConvertError(t *testing.T) {
2626
WebsocketError: errors.New("jsonrpc2: code -32603 message: Request getWorkspace failed with message: No workspace with id 'some-id' found."),
2727
ExpectedStatus: connect.CodeInternal,
2828
},
29+
{
30+
WebsocketError: errors.New("code 400"),
31+
ExpectedStatus: connect.CodeInvalidArgument,
32+
},
33+
{
34+
WebsocketError: errors.New("code 409"),
35+
ExpectedStatus: connect.CodeAlreadyExists,
36+
},
2937
}
3038

3139
for _, s := range scenarios {

Diff for: components/public-api-server/pkg/server/server.go

+3
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ func register(srv *baseserver.Server, connPool proxy.ServerConnectionPool) error
9999
workspacesRoute, workspacesServiceHandler := v1connect.NewWorkspacesServiceHandler(apiv1.NewWorkspaceService(connPool), handlerOptions...)
100100
srv.HTTPMux().Handle(workspacesRoute, workspacesServiceHandler)
101101

102+
teamsRoute, teamsServiceHandler := v1connect.NewTeamsServiceHandler(apiv1.NewTeamsService(connPool), handlerOptions...)
103+
srv.HTTPMux().Handle(teamsRoute, teamsServiceHandler)
104+
102105
return nil
103106
}
104107

0 commit comments

Comments
 (0)