Skip to content

Commit 9fef280

Browse files
authored
[server] Implement grpc/connect getTeam RPC (#17068)
* [server] Implement grpc/connect getTeam RPC * fix * fix * fix * fix * fix * fix * fix * Fix * Fix * fix * cleanup logs * Fix
1 parent 4f187cc commit 9fef280

File tree

6 files changed

+248
-29
lines changed

6 files changed

+248
-29
lines changed

components/server/src/api/server.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import * as http from "http";
8+
import * as express from "express";
9+
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
10+
import { inject, injectable } from "inversify";
11+
import { APITeamsService } from "./teams";
12+
import { APIUserService } from "./user";
13+
import { ConnectRouter } from "@bufbuild/connect";
14+
import { expressConnectMiddleware } from "@bufbuild/connect-express";
15+
import { UserService as UserServiceDefinition } from "@gitpod/public-api/lib/gitpod/experimental/v1/user_connectweb";
16+
import { TeamsService as TeamsServiceDefinition } from "@gitpod/public-api/lib/gitpod/experimental/v1/teams_connectweb";
17+
import { AddressInfo } from "net";
18+
19+
@injectable()
20+
export class API {
21+
@inject(APIUserService) protected readonly apiUserService: APIUserService;
22+
@inject(APITeamsService) protected readonly apiTeamService: APITeamsService;
23+
24+
public listen(port: number): http.Server {
25+
const app = express();
26+
this.register(app);
27+
28+
const server = app.listen(9877, () => {
29+
log.info(`Connect API server listening on port: ${(server.address() as AddressInfo).port}`);
30+
});
31+
32+
return server;
33+
}
34+
35+
private register(app: express.Application) {
36+
app.use(
37+
expressConnectMiddleware({
38+
routes: (router: ConnectRouter) => {
39+
router.service(UserServiceDefinition, this.apiUserService);
40+
router.service(TeamsServiceDefinition, this.apiTeamService);
41+
},
42+
}),
43+
);
44+
}
45+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/**
2+
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
import { suite, test, timeout } from "mocha-typescript";
7+
import { APIUserService } from "./user";
8+
import { Container } from "inversify";
9+
import { TeamDB, TypeORM, UserDB, testContainer } from "@gitpod/gitpod-db/lib";
10+
import { API } from "./server";
11+
import * as http from "http";
12+
import { createConnectTransport } from "@bufbuild/connect-node";
13+
import { Code, ConnectError, PromiseClient, createPromiseClient } from "@bufbuild/connect";
14+
import { AddressInfo } from "net";
15+
import { TeamsService as TeamsServiceDefinition } from "@gitpod/public-api/lib/gitpod/experimental/v1/teams_connectweb";
16+
import { WorkspaceStarter } from "../workspace/workspace-starter";
17+
import { UserService } from "../user/user-service";
18+
import { APITeamsService } from "./teams";
19+
import { v4 as uuidv4 } from "uuid";
20+
import * as chai from "chai";
21+
import { GetTeamRequest, Team, TeamMember, TeamRole } from "@gitpod/public-api/lib/gitpod/experimental/v1/teams_pb";
22+
import { DBTeam } from "@gitpod/gitpod-db/lib/typeorm/entity/db-team";
23+
import { Connection } from "typeorm";
24+
import { Timestamp } from "@bufbuild/protobuf";
25+
26+
const expect = chai.expect;
27+
28+
@suite(timeout(10000))
29+
export class APITeamsServiceSpec {
30+
private container: Container;
31+
private server: http.Server;
32+
33+
private client: PromiseClient<typeof TeamsServiceDefinition>;
34+
private dbConn: Connection;
35+
36+
async before() {
37+
this.container = testContainer.createChild();
38+
this.container.bind(API).toSelf().inSingletonScope();
39+
this.container.bind(APIUserService).toSelf().inSingletonScope();
40+
this.container.bind(APITeamsService).toSelf().inSingletonScope();
41+
42+
this.container.bind(WorkspaceStarter).toConstantValue({} as WorkspaceStarter);
43+
this.container.bind(UserService).toConstantValue({} as UserService);
44+
45+
// Clean-up database
46+
const typeorm = testContainer.get<TypeORM>(TypeORM);
47+
this.dbConn = await typeorm.getConnection();
48+
await this.dbConn.getRepository(DBTeam).delete({});
49+
50+
// Start an actual server for tests
51+
this.server = this.container.get<API>(API).listen(0);
52+
53+
// Construct a client to point against our server
54+
const address = this.server.address() as AddressInfo;
55+
const transport = createConnectTransport({
56+
baseUrl: `http://localhost:${address.port}`,
57+
httpVersion: "1.1",
58+
});
59+
60+
this.client = createPromiseClient(TeamsServiceDefinition, transport);
61+
}
62+
63+
async after() {
64+
await new Promise((resolve, reject) => {
65+
if (!this.server) {
66+
return resolve(null);
67+
}
68+
69+
this.server.close((err) => {
70+
if (err) {
71+
return reject(err);
72+
}
73+
resolve(null);
74+
});
75+
});
76+
}
77+
78+
@test async getTeam_invalidArgument() {
79+
const payloads = [
80+
new GetTeamRequest({}), // empty
81+
new GetTeamRequest({ teamId: "foo-bar" }), // not a valid UUID
82+
];
83+
84+
for (let payload of payloads) {
85+
try {
86+
await this.client.getTeam(payload);
87+
expect.fail("get team did not throw an exception");
88+
} catch (err) {
89+
expect(err).to.be.an.instanceof(ConnectError);
90+
expect(err.code).to.equal(Code.InvalidArgument);
91+
}
92+
}
93+
}
94+
95+
@test async getTeam_notFoundWhenTeamDoesNotExist() {
96+
try {
97+
await this.client.getTeam(
98+
new GetTeamRequest({
99+
teamId: uuidv4(),
100+
}),
101+
);
102+
expect.fail("get team did not throw an exception");
103+
} catch (err) {
104+
expect(err).to.be.an.instanceof(ConnectError);
105+
expect(err.code).to.equal(Code.NotFound);
106+
}
107+
}
108+
109+
@test async getTeam_happy() {
110+
const teamDB = this.container.get<TeamDB>(TeamDB);
111+
const userDB = this.container.get<UserDB>(UserDB);
112+
const user = await userDB.storeUser(await userDB.newUser());
113+
const team = await teamDB.createTeam(user.id, "myteam");
114+
const invite = await teamDB.resetGenericInvite(team.id);
115+
116+
const response = await this.client.getTeam(
117+
new GetTeamRequest({
118+
teamId: team.id,
119+
}),
120+
);
121+
expect(response.team).to.deep.equal(
122+
new Team({
123+
id: team.id,
124+
slug: team.slug,
125+
name: team.name,
126+
members: [
127+
new TeamMember({
128+
userId: user.id,
129+
avatarUrl: user.avatarUrl,
130+
fullName: user.fullName,
131+
role: TeamRole.OWNER,
132+
memberSince: Timestamp.fromDate(new Date(team.creationTime)),
133+
}),
134+
],
135+
teamInvitation: {
136+
id: invite.id,
137+
},
138+
}),
139+
);
140+
}
141+
}

components/server/src/api/teams.ts

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import { Code, ConnectError, ServiceImpl } from "@bufbuild/connect";
8-
import { injectable } from "inversify";
8+
import { inject, injectable } from "inversify";
99
import { TeamsService as TeamServiceInterface } from "@gitpod/public-api/lib/gitpod/experimental/v1/teams_connectweb";
1010
import {
1111
CreateTeamRequest,
@@ -22,17 +22,46 @@ import {
2222
ListTeamsResponse,
2323
ResetTeamInvitationRequest,
2424
ResetTeamInvitationResponse,
25+
Team,
26+
TeamInvitation,
27+
TeamMember,
28+
TeamRole,
2529
UpdateTeamMemberRequest,
2630
UpdateTeamMemberResponse,
2731
} from "@gitpod/public-api/lib/gitpod/experimental/v1/teams_pb";
32+
import { TeamDB } from "@gitpod/gitpod-db/lib";
33+
import { validate } from "uuid";
34+
import { OrgMemberInfo, Organization, TeamMembershipInvite } from "@gitpod/gitpod-protocol";
35+
import { Timestamp } from "@bufbuild/protobuf";
2836

2937
@injectable()
30-
export class APITeamService implements ServiceImpl<typeof TeamServiceInterface> {
38+
export class APITeamsService implements ServiceImpl<typeof TeamServiceInterface> {
39+
@inject(TeamDB) protected readonly teamDB: TeamDB;
40+
3141
public async createTeam(req: CreateTeamRequest): Promise<CreateTeamResponse> {
3242
throw new ConnectError("unimplemented", Code.Unimplemented);
3343
}
3444
public async getTeam(req: GetTeamRequest): Promise<GetTeamResponse> {
35-
throw new ConnectError("unimplemented", Code.Unimplemented);
45+
const { teamId } = req;
46+
47+
if (!teamId || !validate(teamId)) {
48+
throw new ConnectError("Invalid argument: teamId", Code.InvalidArgument);
49+
}
50+
51+
const team = await this.teamDB.findTeamById(teamId);
52+
if (!team) {
53+
throw new ConnectError(`Team (ID: ${teamId}) does not exist`, Code.NotFound);
54+
}
55+
56+
const members = await this.teamDB.findMembersByTeam(teamId);
57+
let invite = await this.teamDB.findGenericInviteByTeamId(teamId);
58+
if (!invite) {
59+
invite = await this.teamDB.resetGenericInvite(teamId);
60+
}
61+
62+
return new GetTeamResponse({
63+
team: toAPITeam(team, members, invite),
64+
});
3665
}
3766
public async listTeams(req: ListTeamsRequest): Promise<ListTeamsResponse> {
3867
throw new ConnectError("unimplemented", Code.Unimplemented);
@@ -53,3 +82,26 @@ export class APITeamService implements ServiceImpl<typeof TeamServiceInterface>
5382
throw new ConnectError("unimplemented", Code.Unimplemented);
5483
}
5584
}
85+
86+
export function toAPITeam(team: Organization, members: OrgMemberInfo[], invite: TeamMembershipInvite): Team {
87+
return new Team({
88+
id: team.id,
89+
name: team.name,
90+
slug: team.slug,
91+
teamInvitation: new TeamInvitation({
92+
id: invite.id,
93+
}),
94+
members: members.map(memberToAPI),
95+
});
96+
}
97+
98+
export function memberToAPI(member: OrgMemberInfo): TeamMember {
99+
return new TeamMember({
100+
avatarUrl: member.avatarUrl,
101+
fullName: member.fullName,
102+
memberSince: Timestamp.fromDate(new Date(member.memberSince)),
103+
primaryEmail: member.primaryEmail,
104+
role: member.role === "owner" ? TeamRole.OWNER : TeamRole.MEMBER,
105+
userId: member.userId,
106+
});
107+
}

components/server/src/api/user.spec.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,6 @@ export class APIUserServiceSpec {
6161

6262
for (let scenario of scenarios) {
6363
try {
64-
console.log("blockUser", scenario);
65-
console.log("sut", sut);
6664
await sut.blockUser(scenario);
6765
expect.fail("blockUser did not throw an exception");
6866
} catch (err) {

components/server/src/container-module.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ import { spicedbClientFromEnv, SpiceDBClient } from "./authorization/spicedb";
114114
import { Authorizer, PermissionChecker } from "./authorization/perms";
115115
import { EnvVarService } from "./workspace/env-var-service";
116116
import { APIUserService } from "./api/user";
117-
import { APITeamService } from "./api/teams";
117+
import { APITeamsService } from "./api/teams";
118+
import { API } from "./api/server";
118119

119120
export const productionContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => {
120121
bind(Config).toConstantValue(ConfigFile.fromFile());
@@ -318,5 +319,6 @@ export const productionContainerModule = new ContainerModule((bind, unbind, isBo
318319

319320
// grpc / Connect API
320321
bind(APIUserService).toSelf().inSingletonScope();
321-
bind(APITeamService).toSelf().inSingletonScope();
322+
bind(APITeamsService).toSelf().inSingletonScope();
323+
bind(API).toSelf().inSingletonScope();
322324
});

components/server/src/server.ts

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,7 @@ import { WebhookEventGarbageCollector } from "./projects/webhook-event-garbage-c
5252
import { LivenessController } from "./liveness/liveness-controller";
5353
import { IamSessionApp } from "./iam/iam-session-app";
5454
import { LongRunningMigrationService } from "@gitpod/gitpod-db/lib/long-running-migration/long-running-migration";
55-
import { expressConnectMiddleware } from "@bufbuild/connect-express";
56-
import { UserService as UserServiceDefinition } from "@gitpod/public-api/lib/gitpod/experimental/v1/user_connectweb";
57-
import { TeamsService as TeamsServiceDefinition } from "@gitpod/public-api/lib/gitpod/experimental/v1/teams_connectweb";
58-
import { APIUserService } from "./api/user";
59-
import { ConnectRouter } from "@bufbuild/connect";
60-
import { APITeamService } from "./api/teams";
55+
import { API } from "./api/server";
6156

6257
@injectable()
6358
export class Server<C extends GitpodClient, S extends GitpodServer> {
@@ -99,8 +94,7 @@ export class Server<C extends GitpodClient, S extends GitpodServer> {
9994
protected iamSessionApp?: express.Application;
10095
protected iamSessionAppServer?: http.Server;
10196

102-
@inject(APIUserService) protected readonly apiUserService: APIUserService;
103-
@inject(APITeamService) protected readonly apiTeamService: APITeamService;
97+
@inject(API) protected readonly api: API;
10498
protected apiServer?: http.Server;
10599

106100
protected readonly eventEmitter = new EventEmitter();
@@ -399,20 +393,7 @@ export class Server<C extends GitpodClient, S extends GitpodServer> {
399393
});
400394
}
401395

402-
{
403-
const apiApp = express();
404-
apiApp.use(
405-
expressConnectMiddleware({
406-
routes: (router: ConnectRouter) => {
407-
router.service(UserServiceDefinition, this.apiUserService);
408-
router.service(TeamsServiceDefinition, this.apiTeamService);
409-
},
410-
}),
411-
);
412-
this.apiServer = apiApp.listen(9877, () => {
413-
log.info(`Connect API server listening on: ${<AddressInfo>this.apiServer!.address()}`);
414-
});
415-
}
396+
this.apiServer = this.api.listen(9877);
416397

417398
this.debugApp.start();
418399
}

0 commit comments

Comments
 (0)