diff --git a/components/public-api-server/go.mod b/components/public-api-server/go.mod index da799e286927e2..2df36fdc87bead 100644 --- a/components/public-api-server/go.mod +++ b/components/public-api-server/go.mod @@ -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 diff --git a/components/public-api-server/go.sum b/components/public-api-server/go.sum index 176ca9686e9e9b..b77dbfcac97de0 100644 --- a/components/public-api-server/go.sum +++ b/components/public-api-server/go.sum @@ -139,6 +139,8 @@ github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= diff --git a/components/public-api-server/pkg/apiv1/team.go b/components/public-api-server/pkg/apiv1/team.go new file mode 100644 index 00000000000000..5f046648eecf5b --- /dev/null +++ b/components/public-api-server/pkg/apiv1/team.go @@ -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 + } +} diff --git a/components/public-api-server/pkg/apiv1/team_test.go b/components/public-api-server/pkg/apiv1/team_test.go new file mode 100644 index 00000000000000..209bada72fff93 --- /dev/null +++ b/components/public-api-server/pkg/apiv1/team_test.go @@ -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: "alice@alice.com", + AvatarUrl: "", + Role: protocol.TeamMember_Owner, + MemberSince: "", + }, { + UserId: uuid.New().String(), + FullName: "Bob Bob", + PrimaryEmail: "bob@bob.com", + 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) + } +} diff --git a/components/public-api-server/pkg/proxy/errors.go b/components/public-api-server/pkg/proxy/errors.go index 7d3db776aaf934..0497ad8953db15 100644 --- a/components/public-api-server/pkg/proxy/errors.go +++ b/components/public-api-server/pkg/proxy/errors.go @@ -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) } @@ -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) diff --git a/components/public-api-server/pkg/proxy/errors_test.go b/components/public-api-server/pkg/proxy/errors_test.go index 8874450d88de0a..59557c4715aca8 100644 --- a/components/public-api-server/pkg/proxy/errors_test.go +++ b/components/public-api-server/pkg/proxy/errors_test.go @@ -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 { diff --git a/components/public-api-server/pkg/server/server.go b/components/public-api-server/pkg/server/server.go index 8d843ec8967cc4..fdc13fbe038a34 100644 --- a/components/public-api-server/pkg/server/server.go +++ b/components/public-api-server/pkg/server/server.go @@ -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 }