Skip to content

Commit 6ecc184

Browse files
committed
refactor: share googlechat api fetch handling
1 parent e64cc90 commit 6ecc184

2 files changed

Lines changed: 111 additions & 114 deletions

File tree

extensions/googlechat/src/api.test.ts

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,21 @@ const account = {
1313
config: {},
1414
} as ResolvedGoogleChatAccount;
1515

16+
function stubSuccessfulSend(name: string) {
17+
const fetchMock = vi
18+
.fn()
19+
.mockResolvedValue(new Response(JSON.stringify({ name }), { status: 200 }));
20+
vi.stubGlobal("fetch", fetchMock);
21+
return fetchMock;
22+
}
23+
24+
async function expectDownloadToRejectForResponse(response: Response) {
25+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
26+
await expect(
27+
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
28+
).rejects.toThrow(/max bytes/i);
29+
}
30+
1631
describe("downloadGoogleChatMedia", () => {
1732
afterEach(() => {
1833
vi.unstubAllGlobals();
@@ -29,11 +44,7 @@ describe("downloadGoogleChatMedia", () => {
2944
status: 200,
3045
headers: { "content-length": "50", "content-type": "application/octet-stream" },
3146
});
32-
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
33-
34-
await expect(
35-
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
36-
).rejects.toThrow(/max bytes/i);
47+
await expectDownloadToRejectForResponse(response);
3748
});
3849

3950
it("rejects when streamed payload exceeds max bytes", async () => {
@@ -52,11 +63,7 @@ describe("downloadGoogleChatMedia", () => {
5263
status: 200,
5364
headers: { "content-type": "application/octet-stream" },
5465
});
55-
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
56-
57-
await expect(
58-
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
59-
).rejects.toThrow(/max bytes/i);
66+
await expectDownloadToRejectForResponse(response);
6067
});
6168
});
6269

@@ -66,12 +73,7 @@ describe("sendGoogleChatMessage", () => {
6673
});
6774

6875
it("adds messageReplyOption when sending to an existing thread", async () => {
69-
const fetchMock = vi
70-
.fn()
71-
.mockResolvedValue(
72-
new Response(JSON.stringify({ name: "spaces/AAA/messages/123" }), { status: 200 }),
73-
);
74-
vi.stubGlobal("fetch", fetchMock);
76+
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/123");
7577

7678
await sendGoogleChatMessage({
7779
account,
@@ -89,12 +91,7 @@ describe("sendGoogleChatMessage", () => {
8991
});
9092

9193
it("does not set messageReplyOption for non-thread sends", async () => {
92-
const fetchMock = vi
93-
.fn()
94-
.mockResolvedValue(
95-
new Response(JSON.stringify({ name: "spaces/AAA/messages/124" }), { status: 200 }),
96-
);
97-
vi.stubGlobal("fetch", fetchMock);
94+
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/124");
9895

9996
await sendGoogleChatMessage({
10097
account,

extensions/googlechat/src/api.ts

Lines changed: 92 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -14,60 +14,77 @@ const headersToObject = (headers?: HeadersInit): Record<string, string> =>
1414
? Object.fromEntries(headers)
1515
: headers || {};
1616

17-
async function fetchJson<T>(
18-
account: ResolvedGoogleChatAccount,
19-
url: string,
20-
init: RequestInit,
21-
): Promise<T> {
17+
async function withGoogleChatResponse<T>(params: {
18+
account: ResolvedGoogleChatAccount;
19+
url: string;
20+
init?: RequestInit;
21+
auditContext: string;
22+
errorPrefix?: string;
23+
handleResponse: (response: Response) => Promise<T>;
24+
}): Promise<T> {
25+
const {
26+
account,
27+
url,
28+
init,
29+
auditContext,
30+
errorPrefix = "Google Chat API",
31+
handleResponse,
32+
} = params;
2233
const token = await getGoogleChatAccessToken(account);
23-
const { response: res, release } = await fetchWithSsrFGuard({
34+
const { response, release } = await fetchWithSsrFGuard({
2435
url,
2536
init: {
2637
...init,
2738
headers: {
28-
...headersToObject(init.headers),
39+
...headersToObject(init?.headers),
2940
Authorization: `Bearer ${token}`,
30-
"Content-Type": "application/json",
3141
},
3242
},
33-
auditContext: "googlechat.api.json",
43+
auditContext,
3444
});
3545
try {
36-
if (!res.ok) {
37-
const text = await res.text().catch(() => "");
38-
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
46+
if (!response.ok) {
47+
const text = await response.text().catch(() => "");
48+
throw new Error(`${errorPrefix} ${response.status}: ${text || response.statusText}`);
3949
}
40-
return (await res.json()) as T;
50+
return await handleResponse(response);
4151
} finally {
4252
await release();
4353
}
4454
}
4555

46-
async function fetchOk(
56+
async function fetchJson<T>(
4757
account: ResolvedGoogleChatAccount,
4858
url: string,
4959
init: RequestInit,
50-
): Promise<void> {
51-
const token = await getGoogleChatAccessToken(account);
52-
const { response: res, release } = await fetchWithSsrFGuard({
60+
): Promise<T> {
61+
return await withGoogleChatResponse({
62+
account,
5363
url,
5464
init: {
5565
...init,
5666
headers: {
5767
...headersToObject(init.headers),
58-
Authorization: `Bearer ${token}`,
68+
"Content-Type": "application/json",
5969
},
6070
},
71+
auditContext: "googlechat.api.json",
72+
handleResponse: async (response) => (await response.json()) as T,
73+
});
74+
}
75+
76+
async function fetchOk(
77+
account: ResolvedGoogleChatAccount,
78+
url: string,
79+
init: RequestInit,
80+
): Promise<void> {
81+
await withGoogleChatResponse({
82+
account,
83+
url,
84+
init,
6185
auditContext: "googlechat.api.ok",
86+
handleResponse: async () => undefined,
6287
});
63-
try {
64-
if (!res.ok) {
65-
const text = await res.text().catch(() => "");
66-
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
67-
}
68-
} finally {
69-
await release();
70-
}
7188
}
7289

7390
async function fetchBuffer(
@@ -76,60 +93,48 @@ async function fetchBuffer(
7693
init?: RequestInit,
7794
options?: { maxBytes?: number },
7895
): Promise<{ buffer: Buffer; contentType?: string }> {
79-
const token = await getGoogleChatAccessToken(account);
80-
const { response: res, release } = await fetchWithSsrFGuard({
96+
return await withGoogleChatResponse({
97+
account,
8198
url,
82-
init: {
83-
...init,
84-
headers: {
85-
...headersToObject(init?.headers),
86-
Authorization: `Bearer ${token}`,
87-
},
88-
},
99+
init,
89100
auditContext: "googlechat.api.buffer",
90-
});
91-
try {
92-
if (!res.ok) {
93-
const text = await res.text().catch(() => "");
94-
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
95-
}
96-
const maxBytes = options?.maxBytes;
97-
const lengthHeader = res.headers.get("content-length");
98-
if (maxBytes && lengthHeader) {
99-
const length = Number(lengthHeader);
100-
if (Number.isFinite(length) && length > maxBytes) {
101-
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
102-
}
103-
}
104-
if (!maxBytes || !res.body) {
105-
const buffer = Buffer.from(await res.arrayBuffer());
106-
const contentType = res.headers.get("content-type") ?? undefined;
107-
return { buffer, contentType };
108-
}
109-
const reader = res.body.getReader();
110-
const chunks: Buffer[] = [];
111-
let total = 0;
112-
while (true) {
113-
const { done, value } = await reader.read();
114-
if (done) {
115-
break;
101+
handleResponse: async (res) => {
102+
const maxBytes = options?.maxBytes;
103+
const lengthHeader = res.headers.get("content-length");
104+
if (maxBytes && lengthHeader) {
105+
const length = Number(lengthHeader);
106+
if (Number.isFinite(length) && length > maxBytes) {
107+
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
108+
}
116109
}
117-
if (!value) {
118-
continue;
110+
if (!maxBytes || !res.body) {
111+
const buffer = Buffer.from(await res.arrayBuffer());
112+
const contentType = res.headers.get("content-type") ?? undefined;
113+
return { buffer, contentType };
119114
}
120-
total += value.length;
121-
if (total > maxBytes) {
122-
await reader.cancel();
123-
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
115+
const reader = res.body.getReader();
116+
const chunks: Buffer[] = [];
117+
let total = 0;
118+
while (true) {
119+
const { done, value } = await reader.read();
120+
if (done) {
121+
break;
122+
}
123+
if (!value) {
124+
continue;
125+
}
126+
total += value.length;
127+
if (total > maxBytes) {
128+
await reader.cancel();
129+
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
130+
}
131+
chunks.push(Buffer.from(value));
124132
}
125-
chunks.push(Buffer.from(value));
126-
}
127-
const buffer = Buffer.concat(chunks, total);
128-
const contentType = res.headers.get("content-type") ?? undefined;
129-
return { buffer, contentType };
130-
} finally {
131-
await release();
132-
}
133+
const buffer = Buffer.concat(chunks, total);
134+
const contentType = res.headers.get("content-type") ?? undefined;
135+
return { buffer, contentType };
136+
},
137+
});
133138
}
134139

135140
export async function sendGoogleChatMessage(params: {
@@ -208,34 +213,29 @@ export async function uploadGoogleChatAttachment(params: {
208213
Buffer.from(footer, "utf8"),
209214
]);
210215

211-
const token = await getGoogleChatAccessToken(account);
212216
const url = `${CHAT_UPLOAD_BASE}/${space}/attachments:upload?uploadType=multipart`;
213-
const { response: res, release } = await fetchWithSsrFGuard({
217+
const payload = await withGoogleChatResponse<{
218+
attachmentDataRef?: { attachmentUploadToken?: string };
219+
}>({
220+
account,
214221
url,
215222
init: {
216223
method: "POST",
217224
headers: {
218-
Authorization: `Bearer ${token}`,
219225
"Content-Type": `multipart/related; boundary=${boundary}`,
220226
},
221227
body,
222228
},
223229
auditContext: "googlechat.upload",
230+
errorPrefix: "Google Chat upload",
231+
handleResponse: async (response) =>
232+
(await response.json()) as {
233+
attachmentDataRef?: { attachmentUploadToken?: string };
234+
},
224235
});
225-
try {
226-
if (!res.ok) {
227-
const text = await res.text().catch(() => "");
228-
throw new Error(`Google Chat upload ${res.status}: ${text || res.statusText}`);
229-
}
230-
const payload = (await res.json()) as {
231-
attachmentDataRef?: { attachmentUploadToken?: string };
232-
};
233-
return {
234-
attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken,
235-
};
236-
} finally {
237-
await release();
238-
}
236+
return {
237+
attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken,
238+
};
239239
}
240240

241241
export async function downloadGoogleChatMedia(params: {

0 commit comments

Comments
 (0)