Skip to content

Commit c76e863

Browse files
authored
add devconnect api client (#6887)
Co-authored-by: Mathusan Selvarajah <mathusan@google.com>
1 parent 95f3fad commit c76e863

File tree

3 files changed

+324
-0
lines changed

3 files changed

+324
-0
lines changed

src/api.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,16 @@ export const cloudbuildOrigin = utils.envOverride(
100100
"https://cloudbuild.googleapis.com",
101101
);
102102

103+
export const developerConnectOrigin = utils.envOverride(
104+
"FIREBASE_DEVELOPERCONNECT_URL",
105+
"https://developerconnect.googleapis.com",
106+
);
107+
108+
export const developerConnectP4SAOrigin = utils.envOverride(
109+
"FIREBASE_DEVELOPERCONNECT_P4SA_URL",
110+
"gcp-sa-developerconnect.iam.gserviceaccount.com",
111+
);
112+
103113
export const cloudschedulerOrigin = utils.envOverride(
104114
"FIREBASE_CLOUDSCHEDULER_URL",
105115
"https://cloudscheduler.googleapis.com",

src/gcp/devConnect.ts

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import { Client } from "../apiv2";
2+
import { developerConnectOrigin, developerConnectP4SAOrigin } from "../api";
3+
4+
const PAGE_SIZE_MAX = 100;
5+
6+
export const client = new Client({
7+
urlPrefix: developerConnectOrigin,
8+
auth: true,
9+
apiVersion: "v1",
10+
});
11+
12+
export interface OperationMetadata {
13+
createTime: string;
14+
endTime: string;
15+
target: string;
16+
verb: string;
17+
requestedCancellation: boolean;
18+
apiVersion: string;
19+
}
20+
21+
export interface Operation {
22+
name: string;
23+
metadata?: OperationMetadata;
24+
done: boolean;
25+
error?: { code: number; message: string; details: unknown };
26+
response?: any;
27+
}
28+
29+
export interface OAuthCredential {
30+
oauthTokenSecretVersion: string;
31+
username: string;
32+
}
33+
34+
type GitHubApp = "GIT_HUB_APP_UNSPECIFIED" | "DEVELOPER_CONNECT" | "FIREBASE";
35+
36+
export interface GitHubConfig {
37+
githubApp?: GitHubApp;
38+
authorizerCredential?: OAuthCredential;
39+
appInstallationId?: string;
40+
installationUri?: string;
41+
}
42+
43+
type InstallationStage =
44+
| "STAGE_UNSPECIFIED"
45+
| "PENDING_CREATE_APP"
46+
| "PENDING_USER_OAUTH"
47+
| "PENDING_INSTALL_APP"
48+
| "COMPLETE";
49+
50+
export interface InstallationState {
51+
stage: InstallationStage;
52+
message: string;
53+
actionUri: string;
54+
}
55+
56+
export interface Connection {
57+
name: string;
58+
createTime?: string;
59+
updateTime?: string;
60+
deleteTime?: string;
61+
labels?: {
62+
[key: string]: string;
63+
};
64+
githubConfig?: GitHubConfig;
65+
installationState: InstallationState;
66+
disabled?: boolean;
67+
reconciling?: boolean;
68+
annotations?: {
69+
[key: string]: string;
70+
};
71+
etag?: string;
72+
uid?: string;
73+
}
74+
75+
type ConnectionOutputOnlyFields =
76+
| "createTime"
77+
| "updateTime"
78+
| "deleteTime"
79+
| "installationState"
80+
| "reconciling"
81+
| "uid";
82+
83+
export interface GitRepositoryLink {
84+
name: string;
85+
cloneUri: string;
86+
createTime: string;
87+
updateTime: string;
88+
deleteTime: string;
89+
labels?: {
90+
[key: string]: string;
91+
};
92+
etag?: string;
93+
reconciling: boolean;
94+
annotations?: {
95+
[key: string]: string;
96+
};
97+
uid: string;
98+
}
99+
100+
type GitRepositoryLinkOutputOnlyFields =
101+
| "createTime"
102+
| "updateTime"
103+
| "deleteTime"
104+
| "reconciling"
105+
| "uid";
106+
107+
export interface LinkableGitRepositories {
108+
repositories: LinkableGitRepository[];
109+
nextPageToken: string;
110+
}
111+
112+
export interface LinkableGitRepository {
113+
cloneUri: string;
114+
}
115+
116+
/**
117+
* Creates a Developer Connect Connection.
118+
*/
119+
export async function createConnection(
120+
projectId: string,
121+
location: string,
122+
connectionId: string,
123+
githubConfig: GitHubConfig,
124+
): Promise<Operation> {
125+
const config: GitHubConfig = {
126+
...githubConfig,
127+
githubApp: "FIREBASE",
128+
};
129+
const res = await client.post<
130+
Omit<Omit<Connection, "name">, ConnectionOutputOnlyFields>,
131+
Operation
132+
>(
133+
`projects/${projectId}/locations/${location}/connections`,
134+
{
135+
githubConfig: config,
136+
},
137+
{ queryParams: { connectionId } },
138+
);
139+
return res.body;
140+
}
141+
142+
/**
143+
* Gets details of a single Developer Connect Connection.
144+
*/
145+
export async function getConnection(
146+
projectId: string,
147+
location: string,
148+
connectionId: string,
149+
): Promise<Connection> {
150+
const name = `projects/${projectId}/locations/${location}/connections/${connectionId}`;
151+
const res = await client.get<Connection>(name);
152+
return res.body;
153+
}
154+
155+
/**
156+
* List Developer Connect Connections
157+
*/
158+
export async function listConnections(projectId: string, location: string): Promise<Connection[]> {
159+
const conns: Connection[] = [];
160+
const getNextPage = async (pageToken = ""): Promise<void> => {
161+
const res = await client.get<{
162+
connections: Connection[];
163+
nextPageToken?: string;
164+
}>(`/projects/${projectId}/locations/${location}/connections`, {
165+
queryParams: {
166+
pageSize: PAGE_SIZE_MAX,
167+
pageToken,
168+
},
169+
});
170+
if (Array.isArray(res.body.connections)) {
171+
conns.push(...res.body.connections);
172+
}
173+
if (res.body.nextPageToken) {
174+
await getNextPage(res.body.nextPageToken);
175+
}
176+
};
177+
await getNextPage();
178+
return conns;
179+
}
180+
181+
/**
182+
* Gets a list of repositories that can be added to the provided Connection.
183+
*/
184+
export async function fetchLinkableGitRepositories(
185+
projectId: string,
186+
location: string,
187+
connectionId: string,
188+
pageToken = "",
189+
pageSize = 1000,
190+
): Promise<LinkableGitRepositories> {
191+
const name = `projects/${projectId}/locations/${location}/connections/${connectionId}:fetchLinkableRepositories`;
192+
const res = await client.get<LinkableGitRepositories>(name, {
193+
queryParams: {
194+
pageSize,
195+
pageToken,
196+
},
197+
});
198+
199+
return res.body;
200+
}
201+
202+
/**
203+
* Creates a GitRepositoryLink.Upon linking a Git Repository, Developer
204+
* Connect will configure the Git Repository to send webhook events to
205+
* Developer Connect.
206+
*/
207+
export async function createGitRepositoryLink(
208+
projectId: string,
209+
location: string,
210+
connectionId: string,
211+
gitRepositoryLinkId: string,
212+
cloneUri: string,
213+
): Promise<Operation> {
214+
const res = await client.post<
215+
Omit<GitRepositoryLink, GitRepositoryLinkOutputOnlyFields | "name">,
216+
Operation
217+
>(
218+
`projects/${projectId}/locations/${location}/connections/${connectionId}/gitRepositoryLinks`,
219+
{ cloneUri },
220+
{ queryParams: { gitRepositoryLinkId } },
221+
);
222+
return res.body;
223+
}
224+
225+
/**
226+
* Get details of a single GitRepositoryLink
227+
*/
228+
export async function getGitRepositoryLink(
229+
projectId: string,
230+
location: string,
231+
connectionId: string,
232+
gitRepositoryLinkId: string,
233+
): Promise<GitRepositoryLink> {
234+
const name = `projects/${projectId}/locations/${location}/connections/${connectionId}/gitRepositoryLinks/${gitRepositoryLinkId}`;
235+
const res = await client.get<GitRepositoryLink>(name);
236+
return res.body;
237+
}
238+
239+
/**
240+
* Returns email associated with the Developer Connect Service Agent
241+
*/
242+
export function serviceAgentEmail(projectNumber: string): string {
243+
return `service-${projectNumber}@${developerConnectP4SAOrigin}`;
244+
}

src/test/gcp/devconnect.spec.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { expect } from "chai";
2+
import * as sinon from "sinon";
3+
import * as devconnect from "../../gcp/devConnect";
4+
5+
describe("developer connect", () => {
6+
let post: sinon.SinonStub;
7+
let get: sinon.SinonStub;
8+
9+
const projectId = "project";
10+
const location = "us-central1";
11+
const connectionId = "apphosting-connection";
12+
const connectionsRequestPath = `projects/${projectId}/locations/${location}/connections`;
13+
14+
beforeEach(() => {
15+
post = sinon.stub(devconnect.client, "post");
16+
get = sinon.stub(devconnect.client, "get");
17+
});
18+
19+
afterEach(() => {
20+
post.restore();
21+
get.restore();
22+
});
23+
24+
describe("createConnection", () => {
25+
it("ensures githubConfig is FIREBASE", async () => {
26+
post.returns({ body: {} });
27+
await devconnect.createConnection(projectId, location, connectionId, {});
28+
29+
expect(post).to.be.calledWith(
30+
connectionsRequestPath,
31+
{ githubConfig: { githubApp: "FIREBASE" } },
32+
{ queryParams: { connectionId } },
33+
);
34+
});
35+
});
36+
37+
describe("listConnections", () => {
38+
it("interates through all pages and returns a single list", async () => {
39+
const firstConnection = { name: "conn1", installationState: { stage: "COMPLETE" } };
40+
const secondConnection = { name: "conn2", installationState: { stage: "COMPLETE" } };
41+
const thirdConnection = { name: "conn3", installationState: { stage: "COMPLETE" } };
42+
43+
get
44+
.onFirstCall()
45+
.returns({
46+
body: {
47+
connections: [firstConnection],
48+
nextPageToken: "someToken",
49+
},
50+
})
51+
.onSecondCall()
52+
.returns({
53+
body: {
54+
connections: [secondConnection],
55+
nextPageToken: "someToken2",
56+
},
57+
})
58+
.onThirdCall()
59+
.returns({
60+
body: {
61+
connections: [thirdConnection],
62+
},
63+
});
64+
65+
const conns = await devconnect.listConnections(projectId, location);
66+
expect(get).callCount(3);
67+
expect(conns).to.deep.equal([firstConnection, secondConnection, thirdConnection]);
68+
});
69+
});
70+
});

0 commit comments

Comments
 (0)