Skip to content

[server] Implement grpc/connect getTeam RPC #17068

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 13 commits into from
Mar 30, 2023
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
45 changes: 45 additions & 0 deletions components/server/src/api/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2023 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.
*/

import * as http from "http";
import * as express from "express";
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
import { inject, injectable } from "inversify";
import { APITeamsService } from "./teams";
import { APIUserService } from "./user";
import { ConnectRouter } from "@bufbuild/connect";
import { expressConnectMiddleware } from "@bufbuild/connect-express";
import { UserService as UserServiceDefinition } from "@gitpod/public-api/lib/gitpod/experimental/v1/user_connectweb";
import { TeamsService as TeamsServiceDefinition } from "@gitpod/public-api/lib/gitpod/experimental/v1/teams_connectweb";
import { AddressInfo } from "net";

@injectable()
export class API {
@inject(APIUserService) protected readonly apiUserService: APIUserService;
@inject(APITeamsService) protected readonly apiTeamService: APITeamsService;

public listen(port: number): http.Server {
const app = express();
this.register(app);

const server = app.listen(9877, () => {
log.info(`Connect API server listening on port: ${(server.address() as AddressInfo).port}`);
});

return server;
}

private register(app: express.Application) {
app.use(
expressConnectMiddleware({
routes: (router: ConnectRouter) => {
router.service(UserServiceDefinition, this.apiUserService);
router.service(TeamsServiceDefinition, this.apiTeamService);
},
}),
);
}
}
141 changes: 141 additions & 0 deletions components/server/src/api/teams.spec.db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Copyright (c) 2023 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.
*/
import { suite, test, timeout } from "mocha-typescript";
import { APIUserService } from "./user";
import { Container } from "inversify";
import { TeamDB, TypeORM, UserDB, testContainer } from "@gitpod/gitpod-db/lib";
import { API } from "./server";
import * as http from "http";
import { createConnectTransport } from "@bufbuild/connect-node";
import { Code, ConnectError, PromiseClient, createPromiseClient } from "@bufbuild/connect";
import { AddressInfo } from "net";
import { TeamsService as TeamsServiceDefinition } from "@gitpod/public-api/lib/gitpod/experimental/v1/teams_connectweb";
import { WorkspaceStarter } from "../workspace/workspace-starter";
import { UserService } from "../user/user-service";
import { APITeamsService } from "./teams";
import { v4 as uuidv4 } from "uuid";
import * as chai from "chai";
import { GetTeamRequest, Team, TeamMember, TeamRole } from "@gitpod/public-api/lib/gitpod/experimental/v1/teams_pb";
import { DBTeam } from "@gitpod/gitpod-db/lib/typeorm/entity/db-team";
import { Connection } from "typeorm";
import { Timestamp } from "@bufbuild/protobuf";

const expect = chai.expect;

@suite(timeout(10000))
export class APITeamsServiceSpec {
private container: Container;
private server: http.Server;

private client: PromiseClient<typeof TeamsServiceDefinition>;
private dbConn: Connection;

async before() {
this.container = testContainer.createChild();
this.container.bind(API).toSelf().inSingletonScope();
this.container.bind(APIUserService).toSelf().inSingletonScope();
this.container.bind(APITeamsService).toSelf().inSingletonScope();

this.container.bind(WorkspaceStarter).toConstantValue({} as WorkspaceStarter);
this.container.bind(UserService).toConstantValue({} as UserService);

// Clean-up database
const typeorm = testContainer.get<TypeORM>(TypeORM);
this.dbConn = await typeorm.getConnection();
await this.dbConn.getRepository(DBTeam).delete({});

// Start an actual server for tests
this.server = this.container.get<API>(API).listen(0);

// Construct a client to point against our server
const address = this.server.address() as AddressInfo;
const transport = createConnectTransport({
baseUrl: `http://localhost:${address.port}`,
httpVersion: "1.1",
});

this.client = createPromiseClient(TeamsServiceDefinition, transport);
}

async after() {
await new Promise((resolve, reject) => {
if (!this.server) {
return resolve(null);
}

this.server.close((err) => {
if (err) {
return reject(err);
}
resolve(null);
});
});
}

@test async getTeam_invalidArgument() {
const payloads = [
new GetTeamRequest({}), // empty
new GetTeamRequest({ teamId: "foo-bar" }), // not a valid UUID
];

for (let payload of payloads) {
try {
await this.client.getTeam(payload);
expect.fail("get team did not throw an exception");
} catch (err) {
expect(err).to.be.an.instanceof(ConnectError);
expect(err.code).to.equal(Code.InvalidArgument);
}
}
}

@test async getTeam_notFoundWhenTeamDoesNotExist() {
try {
await this.client.getTeam(
new GetTeamRequest({
teamId: uuidv4(),
}),
);
expect.fail("get team did not throw an exception");
} catch (err) {
expect(err).to.be.an.instanceof(ConnectError);
expect(err.code).to.equal(Code.NotFound);
}
}

@test async getTeam_happy() {
const teamDB = this.container.get<TeamDB>(TeamDB);
const userDB = this.container.get<UserDB>(UserDB);
const user = await userDB.storeUser(await userDB.newUser());
const team = await teamDB.createTeam(user.id, "myteam");
const invite = await teamDB.resetGenericInvite(team.id);

const response = await this.client.getTeam(
new GetTeamRequest({
teamId: team.id,
}),
);
expect(response.team).to.deep.equal(
new Team({
id: team.id,
slug: team.slug,
name: team.name,
members: [
new TeamMember({
userId: user.id,
avatarUrl: user.avatarUrl,
fullName: user.fullName,
role: TeamRole.OWNER,
memberSince: Timestamp.fromDate(new Date(team.creationTime)),
}),
],
teamInvitation: {
id: invite.id,
},
}),
);
}
}
58 changes: 55 additions & 3 deletions components/server/src/api/teams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import { Code, ConnectError, ServiceImpl } from "@bufbuild/connect";
import { injectable } from "inversify";
import { inject, injectable } from "inversify";
import { TeamsService as TeamServiceInterface } from "@gitpod/public-api/lib/gitpod/experimental/v1/teams_connectweb";
import {
CreateTeamRequest,
Expand All @@ -22,17 +22,46 @@ import {
ListTeamsResponse,
ResetTeamInvitationRequest,
ResetTeamInvitationResponse,
Team,
TeamInvitation,
TeamMember,
TeamRole,
UpdateTeamMemberRequest,
UpdateTeamMemberResponse,
} from "@gitpod/public-api/lib/gitpod/experimental/v1/teams_pb";
import { TeamDB } from "@gitpod/gitpod-db/lib";
import { validate } from "uuid";
import { OrgMemberInfo, Organization, TeamMembershipInvite } from "@gitpod/gitpod-protocol";
import { Timestamp } from "@bufbuild/protobuf";

@injectable()
export class APITeamService implements ServiceImpl<typeof TeamServiceInterface> {
export class APITeamsService implements ServiceImpl<typeof TeamServiceInterface> {
@inject(TeamDB) protected readonly teamDB: TeamDB;

public async createTeam(req: CreateTeamRequest): Promise<CreateTeamResponse> {
throw new ConnectError("unimplemented", Code.Unimplemented);
}
public async getTeam(req: GetTeamRequest): Promise<GetTeamResponse> {
throw new ConnectError("unimplemented", Code.Unimplemented);
const { teamId } = req;

if (!teamId || !validate(teamId)) {
throw new ConnectError("Invalid argument: teamId", Code.InvalidArgument);
}

const team = await this.teamDB.findTeamById(teamId);
if (!team) {
throw new ConnectError(`Team (ID: ${teamId}) does not exist`, Code.NotFound);
}

const members = await this.teamDB.findMembersByTeam(teamId);
let invite = await this.teamDB.findGenericInviteByTeamId(teamId);
if (!invite) {
invite = await this.teamDB.resetGenericInvite(teamId);
}

return new GetTeamResponse({
team: toAPITeam(team, members, invite),
});
}
public async listTeams(req: ListTeamsRequest): Promise<ListTeamsResponse> {
throw new ConnectError("unimplemented", Code.Unimplemented);
Expand All @@ -53,3 +82,26 @@ export class APITeamService implements ServiceImpl<typeof TeamServiceInterface>
throw new ConnectError("unimplemented", Code.Unimplemented);
}
}

export function toAPITeam(team: Organization, members: OrgMemberInfo[], invite: TeamMembershipInvite): Team {
return new Team({
id: team.id,
name: team.name,
slug: team.slug,
teamInvitation: new TeamInvitation({
id: invite.id,
}),
members: members.map(memberToAPI),
});
}

export function memberToAPI(member: OrgMemberInfo): TeamMember {
return new TeamMember({
avatarUrl: member.avatarUrl,
fullName: member.fullName,
memberSince: Timestamp.fromDate(new Date(member.memberSince)),
primaryEmail: member.primaryEmail,
role: member.role === "owner" ? TeamRole.OWNER : TeamRole.MEMBER,
userId: member.userId,
});
}
2 changes: 0 additions & 2 deletions components/server/src/api/user.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,6 @@ export class APIUserServiceSpec {

for (let scenario of scenarios) {
try {
console.log("blockUser", scenario);
console.log("sut", sut);
await sut.blockUser(scenario);
expect.fail("blockUser did not throw an exception");
} catch (err) {
Expand Down
6 changes: 4 additions & 2 deletions components/server/src/container-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ import { spicedbClientFromEnv, SpiceDBClient } from "./authorization/spicedb";
import { Authorizer, PermissionChecker } from "./authorization/perms";
import { EnvVarService } from "./workspace/env-var-service";
import { APIUserService } from "./api/user";
import { APITeamService } from "./api/teams";
import { APITeamsService } from "./api/teams";
import { API } from "./api/server";

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

// grpc / Connect API
bind(APIUserService).toSelf().inSingletonScope();
bind(APITeamService).toSelf().inSingletonScope();
bind(APITeamsService).toSelf().inSingletonScope();
bind(API).toSelf().inSingletonScope();
});
25 changes: 3 additions & 22 deletions components/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,7 @@ import { WebhookEventGarbageCollector } from "./projects/webhook-event-garbage-c
import { LivenessController } from "./liveness/liveness-controller";
import { IamSessionApp } from "./iam/iam-session-app";
import { LongRunningMigrationService } from "@gitpod/gitpod-db/lib/long-running-migration/long-running-migration";
import { expressConnectMiddleware } from "@bufbuild/connect-express";
import { UserService as UserServiceDefinition } from "@gitpod/public-api/lib/gitpod/experimental/v1/user_connectweb";
import { TeamsService as TeamsServiceDefinition } from "@gitpod/public-api/lib/gitpod/experimental/v1/teams_connectweb";
import { APIUserService } from "./api/user";
import { ConnectRouter } from "@bufbuild/connect";
import { APITeamService } from "./api/teams";
import { API } from "./api/server";

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

@inject(APIUserService) protected readonly apiUserService: APIUserService;
@inject(APITeamService) protected readonly apiTeamService: APITeamService;
@inject(API) protected readonly api: API;
protected apiServer?: http.Server;

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

{
const apiApp = express();
apiApp.use(
expressConnectMiddleware({
routes: (router: ConnectRouter) => {
router.service(UserServiceDefinition, this.apiUserService);
router.service(TeamsServiceDefinition, this.apiTeamService);
},
}),
);
this.apiServer = apiApp.listen(9877, () => {
log.info(`Connect API server listening on: ${<AddressInfo>this.apiServer!.address()}`);
});
}
this.apiServer = this.api.listen(9877);

this.debugApp.start();
}
Expand Down