From 5ab41812792963e2d2b1232da07e919dca5e81ca Mon Sep 17 00:00:00 2001 From: "kawasaki.taiga" Date: Fri, 15 Dec 2023 17:20:45 +0900 Subject: [PATCH 1/3] Generate files.uploadV2 methods. Generate files.getUploadURLExternal and files.completeUploadExternal. --- scripts/src/public-api-methods.ts | 2 ++ src/generated/method-types/api_method_types_test.ts | 2 ++ src/generated/method-types/files.ts | 2 ++ 3 files changed, 6 insertions(+) diff --git a/scripts/src/public-api-methods.ts b/scripts/src/public-api-methods.ts index eec7185..399778c 100644 --- a/scripts/src/public-api-methods.ts +++ b/scripts/src/public-api-methods.ts @@ -154,6 +154,8 @@ export const getPublicAPIMethods = () => { "files.list", "files.revokePublicURL", "files.sharedPublicURL", + "files.getUploadURLExternal", + "files.completeUploadExternal", "files.upload", "files.remote.add", "files.remote.info", diff --git a/src/generated/method-types/api_method_types_test.ts b/src/generated/method-types/api_method_types_test.ts index 083df9a..eba789d 100644 --- a/src/generated/method-types/api_method_types_test.ts +++ b/src/generated/method-types/api_method_types_test.ts @@ -184,7 +184,9 @@ Deno.test("SlackAPIMethodsType generated types", () => { assertEquals(typeof client.enterprise.auth.idpconfig.remove, "function"); assertEquals(typeof client.enterprise.auth.idpconfig.set, "function"); assertEquals(typeof client.files.comments.delete, "function"); + assertEquals(typeof client.files.completeUploadExternal, "function"); assertEquals(typeof client.files.delete, "function"); + assertEquals(typeof client.files.getUploadURLExternal, "function"); assertEquals(typeof client.files.info, "function"); assertEquals(typeof client.files.list, "function"); assertEquals(typeof client.files.remote.add, "function"); diff --git a/src/generated/method-types/files.ts b/src/generated/method-types/files.ts index 2ecee62..2ddc4fa 100644 --- a/src/generated/method-types/files.ts +++ b/src/generated/method-types/files.ts @@ -7,7 +7,9 @@ export type FilesAPIType = { comments: { delete: SlackAPIMethod; }; + completeUploadExternal: SlackAPIMethod; delete: SlackAPIMethod; + getUploadURLExternal: SlackAPIMethod; info: SlackAPIMethod; list: SlackAPICursorPaginatedMethod; remote: { From b735fec174024c4cbcb11c25bfd966becbea44b8 Mon Sep 17 00:00:00 2001 From: "kawasaki.taiga" Date: Fri, 15 Dec 2023 21:35:08 +0900 Subject: [PATCH 2/3] 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 From be9ee119d11e708b4bff28bc501c47bdf1bad655 Mon Sep 17 00:00:00 2001 From: "kawasaki.taiga" Date: Mon, 18 Dec 2023 23:23:35 +0900 Subject: [PATCH 3/3] Refactor file upload types in files.ts. Modify the definition of the FileUploadV2 type to extend the FileUpload type. And modify the FileUpload type so that it does not directly reference ReadableStream, ensuring npm build succeeds. --- src/typed-method-types/files.ts | 38 ++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/typed-method-types/files.ts b/src/typed-method-types/files.ts index f805307..4d24248 100644 --- a/src/typed-method-types/files.ts +++ b/src/typed-method-types/files.ts @@ -1,26 +1,38 @@ 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; +interface FileUpload { + /** @description Comma-separated list of channel names or IDs where the file will be shared. */ + channels?: string; + /** @description If omitting this parameter, you must provide a file. */ + content?: string; + /** @description A file type identifier. */ + filetype?: 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; + filename?: string; /** @description Filetype of the file being uploaded. */ - file: Blob | ReadableStream | string | ArrayBuffer; -}; + file: Exclude; +} + +// Channels and filetype is no longer a supported field and filename is required for file.uploadV2. +export type FileUploadV2 = + & Omit + & { + channel_id?: string; + /** @description Description of image for screen-reader. */ + alt_text?: string; + /** @description Syntax type of the snippet being uploaded. */ + snippet_type?: string; + /** @description Size in bytes of the file being uploaded. */ + length: string; + /** @description Name of the file being uploaded. */ + filename: string; + }; export type FileUploadV2Args = { file_uploads: FileUploadV2[];