From b735fec174024c4cbcb11c25bfd966becbea44b8 Mon Sep 17 00:00:00 2001 From: "kawasaki.taiga" Date: Fri, 15 Dec 2023 21:35:08 +0900 Subject: [PATCH] Add fileUploadV2 method to BaseSlackAPIClient and add new tests to api_test.ts. --- src/api-proxy.ts | 1 + src/api_test.ts | 195 ++++++++++++++++++++++++++++++++ src/base-client.ts | 78 +++++++++++++ src/dev_deps.ts | 1 + src/typed-method-types/files.ts | 32 ++++++ src/types.ts | 4 + 6 files changed, 311 insertions(+) create mode 100644 src/typed-method-types/files.ts diff --git a/src/api-proxy.ts b/src/api-proxy.ts index 59cc385..2cbc677 100644 --- a/src/api-proxy.ts +++ b/src/api-proxy.ts @@ -16,6 +16,7 @@ export const ProxifyAndTypeClient = (baseClient: BaseSlackAPIClient) => { setSlackApiUrl: baseClient.setSlackApiUrl.bind(baseClient), apiCall: baseClient.apiCall.bind(baseClient), response: baseClient.response.bind(baseClient), + fileUploadV2: baseClient.fileUploadV2.bind(baseClient), }; // Create our proxy, and type it w/ our api method types diff --git a/src/api_test.ts b/src/api_test.ts index ddf3e52..8444531 100644 --- a/src/api_test.ts +++ b/src/api_test.ts @@ -365,6 +365,201 @@ Deno.test("SlackAPI class", async (t) => { }, ); + await t.step( + "fileUploadV2 method", + async (t) => { + const client = SlackAPI("test-token"); + await t.step( + "should successfully upload a single file", + async () => { + const testFile = { + file: new Blob(["test"]), + filename: "test.txt", + length: "6", + fileId: "test_id", + }; + mf.mock("POST@/api/files.getUploadURLExternal", () => { + return new Response( + JSON.stringify({ + "ok": true, + "upload_url": "https://files.slack.com/test", + "file_id": "test_id", + }), + ); + }); + mf.mock("POST@/test", () => { + return new Response( + undefined, + { status: 200 }, + ); + }); + mf.mock("POST@/api/files.completeUploadExternal", () => { + return new Response( + `{"ok":true}`, + ); + }); + const response = await client.fileUploadV2({ + file_uploads: [ + testFile, + ], + }); + response.forEach((res) => assertEquals(res.ok, true)); + + mf.reset(); + }, + ); + + await t.step( + "should successfully upload multiple file", + async () => { + const testFile = { + file: new Blob(["test"]), + filename: "test.txt", + length: "6", + fileId: "test_id", + }; + const testTextFile = { + file: "test", + filename: "test.txt", + length: "6", + fileId: "test_id", + }; + mf.mock("POST@/api/files.getUploadURLExternal", () => { + return new Response( + JSON.stringify({ + "ok": true, + "upload_url": "https://files.slack.com/test", + "file_id": "test_id", + }), + ); + }); + mf.mock("POST@/test", () => { + return new Response( + undefined, + { status: 200 }, + ); + }); + mf.mock("POST@/api/files.completeUploadExternal", () => { + return new Response( + `{"ok":true}`, + ); + }); + const response = await client.fileUploadV2({ + file_uploads: [ + testFile, + testTextFile, + ], + }); + response.forEach((res) => assertEquals(res.ok, true)); + + mf.reset(); + }, + ); + await t.step( + "should rejects when get upload url fails", + async () => { + const testFile = { + file: new Blob(["test"]), + filename: "test.txt", + length: "6", + fileId: "test_id", + }; + mf.mock("POST@/api/files.getUploadURLExternal", () => { + return new Response( + JSON.stringify({ + "ok": false, + }), + ); + }); + await assertRejects(async () => + await client.fileUploadV2({ + file_uploads: [ + testFile, + ], + }) + ); + + mf.reset(); + }, + ); + await t.step( + "should rejects when upload fails", + async () => { + const testFile = { + file: new Blob(["test"]), + filename: "test.txt", + length: "6", + fileId: "test_id", + }; + mf.mock("POST@/api/files.getUploadURLExternal", () => { + return new Response( + JSON.stringify({ + "ok": true, + "upload_url": "https://files.slack.com/test", + "file_id": "test_id", + }), + ); + }); + mf.mock("POST@/test", () => { + return new Response( + undefined, + { status: 500 }, + ); + }); + await assertRejects(async () => + await client.fileUploadV2({ + file_uploads: [ + testFile, + ], + }) + ); + + mf.reset(); + }, + ); + await t.step( + "should rejects when upload complete fails", + async () => { + const testFile = { + file: new Blob(["test"]), + filename: "test.txt", + length: "6", + fileId: "test_id", + }; + mf.mock("POST@/api/files.getUploadURLExternal", () => { + return new Response( + JSON.stringify({ + "ok": true, + "upload_url": "https://files.slack.com/test", + "file_id": "test_id", + }), + ); + }); + mf.mock("POST@/test", () => { + return new Response( + undefined, + { status: 200 }, + ); + }); + mf.mock("POST@/api/files.completeUploadExternal", () => { + return new Response( + `{"ok":false}`, + ); + }); + await assertRejects(async () => + await client.fileUploadV2({ + file_uploads: [ + testFile, + ], + }) + ); + + mf.reset(); + }, + ); + }, + ); + mf.uninstall(); }); diff --git a/src/base-client.ts b/src/base-client.ts index c952d75..5ab1be9 100644 --- a/src/base-client.ts +++ b/src/base-client.ts @@ -6,6 +6,7 @@ import { } from "./types.ts"; import { createHttpError, HttpError } from "./deps.ts"; import { getUserAgent, serializeData } from "./base-client-helpers.ts"; +import { FileUploadV2, FileUploadV2Args } from "./typed-method-types/files.ts"; export class BaseSlackAPIClient implements BaseSlackClient { #token?: string; @@ -72,6 +73,83 @@ export class BaseSlackAPIClient implements BaseSlackClient { return await this.createBaseResponse(response); } + async fileUploadV2( + args: FileUploadV2Args, + ) { + const { file_uploads } = args; + const uploadUrls = await Promise.all( + file_uploads.map((file) => this.getFileUploadUrl(file)), + ); + + await Promise.all( + uploadUrls.map((uploadUrl, index) => + this.uploadFile(uploadUrl.upload_url, file_uploads[index].file) + ), + ); + + return await Promise.all( + uploadUrls.map((uploadUrl, index) => + this.completeFileUpload(uploadUrl.file_id, file_uploads[index]) + ), + ); + } + + private async getFileUploadUrl(file: FileUploadV2) { + const fileMetaData = { + filename: file.filename, + length: file.length, + alt_text: file.alt_text, + snippet_type: file.snippet_type, + }; + const response = await this.apiCall( + "files.getUploadURLExternal", + fileMetaData, + ); + + if (!response.ok) { + throw new Error(JSON.stringify(response.response_metadata)); + } + return response; + } + + private async completeFileUpload(fileID: string, file: FileUploadV2) { + const fileMetaData = { + files: JSON.stringify([{ id: fileID, title: file.title }]), + channel_id: file.channel_id, + initial_comment: file.initial_comment, + thread_ts: file.thread_ts, + }; + const response = await this.apiCall( + "files.completeUploadExternal", + fileMetaData, + ); + if (!response.ok) { + throw new Error(JSON.stringify(response.response_metadata)); + } + return response; + } + + private async uploadFile( + uploadUrl: string, + file: FileUploadV2["file"], + ) { + const response = await fetch(uploadUrl, { + headers: { + "Content-Type": typeof file === "string" + ? "text/plain" + : "application/octet-stream", + "User-Agent": getUserAgent(), + }, + method: "POST", + body: file, + }); + + if (!response.ok) { + throw await this.createHttpError(response); + } + return; + } + private async createHttpError(response: Response): Promise { const text = await response.text(); return createHttpError( diff --git a/src/dev_deps.ts b/src/dev_deps.ts index 71ecd76..68f530b 100644 --- a/src/dev_deps.ts +++ b/src/dev_deps.ts @@ -4,6 +4,7 @@ export { assertExists, assertInstanceOf, assertRejects, + fail, } from "https://deno.land/std@0.132.0/testing/asserts.ts"; export * as mf from "https://deno.land/x/mock_fetch@0.3.0/mod.ts"; export { isHttpError } from "https://deno.land/std@0.182.0/http/http_errors.ts"; diff --git a/src/typed-method-types/files.ts b/src/typed-method-types/files.ts new file mode 100644 index 0000000..f805307 --- /dev/null +++ b/src/typed-method-types/files.ts @@ -0,0 +1,32 @@ +import { BaseResponse } from "../types.ts"; + +export type FileUploadV2 = { + /** @description Description of image for screen-reader. */ + alt_text?: string; + /** @description Syntax type of the snippet being uploaded. */ + snippet_type?: string; + /** @description The message text introducing the file in specified channels. */ + channel_id?: string; + /** @description Provide another message's ts value to upload this file as a reply. Never use a reply's ts value; use its parent instead. */ + thread_ts?: string; + /** @description The message text introducing the file in specified channels. */ + initial_comment?: string; + /** @description Title of the file being uploaded */ + title?: string; + + /** @description Size in bytes of the file being uploaded. */ + length: string; + /** @description Name of the file being uploaded. */ + filename: string; + /** @description Filetype of the file being uploaded. */ + file: Blob | ReadableStream | string | ArrayBuffer; +}; + +export type FileUploadV2Args = { + file_uploads: FileUploadV2[]; +}; + +export type GetUploadURLExternalResponse = BaseResponse & { + file_id: string; + upload_url: string; +}; diff --git a/src/types.ts b/src/types.ts index 13159e3..4c977bb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ import { TypedSlackAPIMethodsType } from "./typed-method-types/mod.ts"; import { SlackAPIMethodsType } from "./generated/method-types/mod.ts"; +import { FileUploadV2Args } from "./typed-method-types/files.ts"; export type { DatastoreItem } from "./typed-method-types/apps.ts"; @@ -52,6 +53,9 @@ export type BaseSlackClient = { setSlackApiUrl: (slackApiUrl: string) => BaseSlackClient; apiCall: BaseClientCall; response: BaseClientResponse; + fileUploadV2: ( + args: FileUploadV2Args, + ) => Promise; }; // TODO: [brk-chg] return a `Promise` object