Skip to content
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

Add files.uploadV2 #111

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
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
2 changes: 2 additions & 0 deletions scripts/src/public-api-methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ export const getPublicAPIMethods = () => {
"files.list",
"files.revokePublicURL",
"files.sharedPublicURL",
"files.getUploadURLExternal",
"files.completeUploadExternal",
"files.upload",
"files.remote.add",
"files.remote.info",
Expand Down
1 change: 1 addition & 0 deletions src/api-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,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
Expand Down
195 changes: 195 additions & 0 deletions src/api_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,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();
});

Expand Down
81 changes: 81 additions & 0 deletions src/base-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
} from "./types.ts";
import { createHttpError, type HttpError } from "@std/http/http-errors";
import { getUserAgent, serializeData } from "./base-client-helpers.ts";
import type {
FileUploadV2,
FileUploadV2Args,
} from "./typed-method-types/files.ts";

export class BaseSlackAPIClient implements BaseSlackClient {
#token?: string;
Expand All @@ -28,7 +32,7 @@
return this;
}

// TODO: [brk-chg] return the `Promise<Response>` object

Check warning on line 35 in src/base-client.ts

View workflow job for this annotation

GitHub Actions / Health Score

src/base-client.ts#L35

Problematic comment ("TODO") identified
async apiCall(
method: string,
data: SlackAPIMethodArgs = {},
Expand All @@ -53,7 +57,7 @@
return await this.createBaseResponse(response);
}

// TODO: [brk-chg] return a `Promise<Response>` object

Check warning on line 60 in src/base-client.ts

View workflow job for this annotation

GitHub Actions / Health Score

src/base-client.ts#L60

Problematic comment ("TODO") identified
async response(
url: string,
data: Record<string, unknown>,
Expand All @@ -72,6 +76,83 @@
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<HttpError> {
const text = await response.text();
return createHttpError(
Expand Down
2 changes: 2 additions & 0 deletions src/generated/method-types/api_method_types_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,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");
Expand Down
2 changes: 2 additions & 0 deletions src/generated/method-types/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ export type FilesAPIType = {
comments: {
delete: SlackAPIMethod;
};
completeUploadExternal: SlackAPIMethod;
delete: SlackAPIMethod;
getUploadURLExternal: SlackAPIMethod;
info: SlackAPIMethod;
list: SlackAPICursorPaginatedMethod;
remote: {
Expand Down
44 changes: 44 additions & 0 deletions src/typed-method-types/files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { BaseResponse } from "../types.ts";

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 Name of the file being uploaded. */
filename?: string;
/** @description Filetype of the file being uploaded. */
file: Exclude<BodyInit, FormData | URLSearchParams>;
}

// Channels and filetype is no longer a supported field and filename is required for file.uploadV2.
export type FileUploadV2 =
& Omit<FileUpload, "channels" | "filetype" | "content">
& {
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[];
};

export type GetUploadURLExternalResponse = BaseResponse & {
file_id: string;
upload_url: string;
};
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { TypedSlackAPIMethodsType } from "./typed-method-types/mod.ts";
import type { SlackAPIMethodsType } from "./generated/method-types/mod.ts";
import type { FileUploadV2Args } from "./typed-method-types/files.ts";

export type { DatastoreItem } from "./typed-method-types/apps.ts";

Expand All @@ -7,7 +8,7 @@
ValidTriggerTypes as Trigger,
} from "./typed-method-types/workflows/triggers/mod.ts";

// TODO: [brk-chg] remove this in favor of `Response`

Check warning on line 11 in src/types.ts

View workflow job for this annotation

GitHub Actions / Health Score

src/types.ts#L11

Problematic comment ("TODO") identified
export type BaseResponse = {
/**
* @description `true` if the response from the server was successful, `false` otherwise.
Expand Down Expand Up @@ -52,15 +53,18 @@
setSlackApiUrl: (slackApiUrl: string) => BaseSlackClient;
apiCall: BaseClientCall;
response: BaseClientResponse;
fileUploadV2: (
args: FileUploadV2Args,
) => Promise<BaseResponse[]>;
};

// TODO: [brk-chg] return a `Promise<Response>` object

Check warning on line 61 in src/types.ts

View workflow job for this annotation

GitHub Actions / Health Score

src/types.ts#L61

Problematic comment ("TODO") identified
type BaseClientCall = (
method: string,
data?: SlackAPIMethodArgs,
) => Promise<BaseResponse>;

// TODO: [brk-chg] return a `Promise<Response>` object

Check warning on line 67 in src/types.ts

View workflow job for this annotation

GitHub Actions / Health Score

src/types.ts#L67

Problematic comment ("TODO") identified
type BaseClientResponse = (
url: string,
data: Record<string, unknown>,
Expand Down
Loading