Repository: slack-go/slack
Pull request #1376 (2025-02-19 07:22:39)

Expose private File Upload V2 methods to support multiple file uploads in a single message
Hi, thank you for maintaining slack-go!

Currently, `UploadFileV2Context` works by sequentially calling three private methods in the following flow:

1. getUploadURLExternal (retrieves the upload URL)
2. uploadToURL (uploads the file to that URL)
3. completeUploadExternal (finalizes the upload)

Because these methods are private, developers can only upload one file per message via `UploadFileV2Context`. However, the Slack API itself supports uploading multiple files in a single message by running these steps for each file and then calling completeUploadURLExternal for all file IDs together.

To unlock this functionality while still using slack-go, I’ve made the following changes in this PR:

- Made `getUploadURLExternal` and `completeUploadExternal` public
  - This allows developers to orchestrate multiple file uploads (Steps 1–3 repeated for each file) and finalize them all in a single message.
- `uploadToURL` remains private for now. This method is simply a regular POST request handler, not part of Slack’s official API. Since making getUploadURLExternal and completeUploadExternal public already addresses the main use case of uploading multiple files, this might be sufficient.
  - However, if you feel uploadToURL should also be made public for consistency, please let me know and I can include that change in this PR.
  

Thank you for considering this proposal. Being able to handle multi-file uploads in a single Slack message through slack-go would be extremely helpful. If you have any suggestions or an alternative approach, I’d love to hear them!

# Related Issues

- https://github.com/slack-go/slack/issues/1346
- https://github.com/slack-go/slack/issues/1288

##### Pull Request Guidelines

These are recommendations for pull requests.
They are strictly guidelines to help manage expectations.

##### PR preparation
Run `make pr-prep` from the root of the repository to run formatting, linting and tests.

##### Should this be an issue instead
- [ ] is it a convenience method? (no new functionality, streamlines some use case)
- [ ] exposes a previously private type, const, method, etc.
- [ ] is it application specific (caching, retry logic, rate limiting, etc)
- [ ] is it performance related.

##### API changes

Since API changes have to be maintained they undergo a more detailed review and are more likely to require changes.

- no tests, if you're adding to the API include at least a single test of the happy case.
- If you can accomplish your goal without changing the API, then do so.
- dependency changes. updates are okay. adding/removing need justification.

###### Examples of API changes that do not meet guidelines:
- in library cache for users. caches are use case specific.
- Convenience methods for Sending Messages, update, post, ephemeral, etc. consider opening an issue instead.



Patch:
diff --git a/files.go b/files.go
index 0682fb6..0d6626b 100644
--- a/files.go
+++ b/files.go
@@ -173,14 +173,14 @@ type UploadFileV2Parameters struct {
 	SnippetText     string
 }
 
-type getUploadURLExternalParameters struct {
-	altText     string
-	fileSize    int
-	fileName    string
-	snippetText string
+type GetUploadURLExternalParameters struct {
+	AltText     string
+	FileSize    int
+	FileName    string
+	SnippetText string
 }
 
-type getUploadURLExternalResponse struct {
+type GetUploadURLExternalResponse struct {
 	UploadURL string `json:"upload_url"`
 	FileID    string `json:"file_id"`
 	SlackResponse
@@ -199,14 +199,14 @@ type FileSummary struct {
 	Title string `json:"title"`
 }
 
-type completeUploadExternalParameters struct {
-	title           string
-	channel         string
-	initialComment  string
-	threadTimestamp string
+type CompleteUploadExternalParameters struct {
+	Files           []FileSummary
+	Channel         string
+	InitialComment  string
+	ThreadTimestamp string
 }
 
-type completeUploadExternalResponse struct {
+type CompleteUploadExternalResponse struct {
 	SlackResponse
 	Files []FileSummary `json:"files"`
 }
@@ -506,20 +506,28 @@ func (api *Client) ShareFilePublicURLContext(ctx context.Context, fileID string)
 	return &response.File, response.Comments, &response.Paging, nil
 }
 
-// getUploadURLExternal gets a URL and fileID from slack which can later be used to upload a file.
-func (api *Client) getUploadURLExternal(ctx context.Context, params getUploadURLExternalParameters) (*getUploadURLExternalResponse, error) {
+// GetUploadURLExternalContext gets a URL and fileID from slack which can later be used to upload a file.
+// Slack API docs: https://api.slack.com/methods/files.getUploadURLExternal
+func (api *Client) GetUploadURLExternalContext(ctx context.Context, params GetUploadURLExternalParameters) (*GetUploadURLExternalResponse, error) {
+	if params.FileName == "" {
+		return nil, fmt.Errorf("FileName cannot be empty")
+	}
+	if params.FileSize == 0 {
+		return nil, fmt.Errorf("FileSize cannot be 0")
+	}
+
 	values := url.Values{
 		"token":    {api.token},
-		"filename": {params.fileName},
-		"length":   {strconv.Itoa(params.fileSize)},
+		"filename": {params.FileName},
+		"length":   {strconv.Itoa(params.FileSize)},
 	}
-	if params.altText != "" {
-		values.Add("initial_comment", params.altText)
+	if params.AltText != "" {
+		values.Add("initial_comment", params.AltText)
 	}
-	if params.snippetText != "" {
-		values.Add("thread_ts", params.snippetText)
+	if params.SnippetText != "" {
+		values.Add("thread_ts", params.SnippetText)
 	}
-	response := &getUploadURLExternalResponse{}
+	response := &GetUploadURLExternalResponse{}
 	err := api.postMethod(ctx, "files.getUploadURLExternal", values, response)
 	if err != nil {
 		return nil, err
@@ -542,28 +550,28 @@ func (api *Client) uploadToURL(ctx context.Context, params uploadToURLParameters
 	return err
 }
 
-// completeUploadExternal once files are uploaded, this completes the upload and shares it to the specified channel
-func (api *Client) completeUploadExternal(ctx context.Context, fileID string, params completeUploadExternalParameters) (file *completeUploadExternalResponse, err error) {
-	request := []FileSummary{{ID: fileID, Title: params.title}}
-	requestBytes, err := json.Marshal(request)
+// CompleteUploadExternalContext once files are uploaded, this completes the upload and shares it to the specified channel
+// Slack API docs: https://api.slack.com/methods/files.completeUploadExternal
+func (api *Client) CompleteUploadExternalContext(ctx context.Context, params CompleteUploadExternalParameters) (file *CompleteUploadExternalResponse, err error) {
+	filesBytes, err := json.Marshal(params.Files)
 	if err != nil {
 		return nil, err
 	}
 	values := url.Values{
 		"token": {api.token},
-		"files": {string(requestBytes)},
+		"files": {string(filesBytes)},
 	}
 
-	if params.channel != "" {
-		values.Add("channel_id", params.channel)
+	if params.Channel != "" {
+		values.Add("channel_id", params.Channel)
 	}
-	if params.initialComment != "" {
-		values.Add("initial_comment", params.initialComment)
+	if params.InitialComment != "" {
+		values.Add("initial_comment", params.InitialComment)
 	}
-	if params.threadTimestamp != "" {
-		values.Add("thread_ts", params.threadTimestamp)
+	if params.ThreadTimestamp != "" {
+		values.Add("thread_ts", params.ThreadTimestamp)
 	}
-	response := &completeUploadExternalResponse{}
+	response := &CompleteUploadExternalResponse{}
 	err = api.postMethod(ctx, "files.completeUploadExternal", values, response)
 	if err != nil {
 		return nil, err
@@ -594,11 +602,11 @@ func (api *Client) UploadFileV2Context(ctx context.Context, params UploadFileV2P
 		return nil, fmt.Errorf("file.upload.v2: file size cannot be 0")
 	}
 
-	u, err := api.getUploadURLExternal(ctx, getUploadURLExternalParameters{
-		altText:     params.AltTxt,
-		fileName:    params.Filename,
-		fileSize:    params.FileSize,
-		snippetText: params.SnippetText,
+	u, err := api.GetUploadURLExternalContext(ctx, GetUploadURLExternalParameters{
+		AltText:     params.AltTxt,
+		FileName:    params.Filename,
+		FileSize:    params.FileSize,
+		SnippetText: params.SnippetText,
 	})
 	if err != nil {
 		return nil, err
@@ -615,11 +623,14 @@ func (api *Client) UploadFileV2Context(ctx context.Context, params UploadFileV2P
 		return nil, err
 	}
 
-	c, err := api.completeUploadExternal(ctx, u.FileID, completeUploadExternalParameters{
-		title:           params.Title,
-		channel:         params.Channel,
-		initialComment:  params.InitialComment,
-		threadTimestamp: params.ThreadTimestamp,
+	c, err := api.CompleteUploadExternalContext(ctx, CompleteUploadExternalParameters{
+		Files: []FileSummary{{
+			ID:    u.FileID,
+			Title: params.Title,
+		}},
+		Channel:         params.Channel,
+		InitialComment:  params.InitialComment,
+		ThreadTimestamp: params.ThreadTimestamp,
 	})
 	if err != nil {
 		return nil, err


Test patch:
diff --git a/files_test.go b/files_test.go
index 1df46aa..0fb9b2b 100644
--- a/files_test.go
+++ b/files_test.go
@@ -2,7 +2,9 @@ package slack
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
+	"fmt"
 	"io"
 	"log"
 	"net/http"
@@ -214,7 +216,7 @@ func TestUploadFileWithoutFilename(t *testing.T) {
 
 func uploadURLHandler(rw http.ResponseWriter, r *http.Request) {
 	rw.Header().Set("Content-Type", "application/json")
-	response, _ := json.Marshal(getUploadURLExternalResponse{
+	response, _ := json.Marshal(GetUploadURLExternalResponse{
 		FileID:        "RandomID",
 		UploadURL:     "http://" + serverAddr + "/abc",
 		SlackResponse: SlackResponse{Ok: true}})
@@ -228,7 +230,7 @@ func urlFileUploadHandler(rw http.ResponseWriter, r *http.Request) {
 
 func completeURLUpload(rw http.ResponseWriter, r *http.Request) {
 	rw.Header().Set("Content-Type", "application/json")
-	response, _ := json.Marshal(completeUploadExternalResponse{
+	response, _ := json.Marshal(CompleteUploadExternalResponse{
 		Files: []FileSummary{
 			{
 				ID:    "RandomID",
@@ -282,3 +284,264 @@ func TestUploadFileV2(t *testing.T) {
 		t.Errorf("Unexpected error: %s", err)
 	}
 }
+
+type mockGetUploadURLExternalHttpClient struct {
+	ResponseStatus int
+	ResponseBody   []byte
+}
+
+func (m *mockGetUploadURLExternalHttpClient) Do(req *http.Request) (*http.Response, error) {
+	if req.URL.Path != "files.getUploadURLExternal" {
+		return nil, fmt.Errorf("invalid path: %s", req.URL.Path)
+	}
+
+	return &http.Response{
+		StatusCode: m.ResponseStatus,
+		Body:       io.NopCloser(bytes.NewBuffer(m.ResponseBody)),
+	}, nil
+}
+
+func TestGetUploadURLExternalContext(t *testing.T) {
+	type testCase struct {
+		title             string
+		params            GetUploadURLExternalParameters
+		wantSlackResponse []byte
+		wantResponse      GetUploadURLExternalResponse
+		wantErr           error
+	}
+	testCases := []testCase{
+		{
+			title: "Testing with required parameters",
+			params: GetUploadURLExternalParameters{
+				FileName: "test.txt",
+				FileSize: 10,
+			},
+			wantSlackResponse: []byte(`{"ok":true,"file_id":"RandomID","upload_url":"http://test-server/abc"}`),
+			wantResponse: GetUploadURLExternalResponse{
+				FileID:    "RandomID",
+				UploadURL: "http://test-server/abc",
+				SlackResponse: SlackResponse{
+					Ok: true,
+				},
+			},
+		},
+		{
+			title: "Testing with optional parameters",
+			params: GetUploadURLExternalParameters{
+				FileSize:    10,
+				FileName:    "test.txt",
+				AltText:     "test-alt-text",
+				SnippetText: "test-snippet-text",
+			},
+			wantSlackResponse: []byte(`{"ok":true,"file_id":"RandomID","upload_url":"http://test-server/abc"}`),
+			wantResponse: GetUploadURLExternalResponse{
+				FileID:    "RandomID",
+				UploadURL: "http://test-server/abc",
+				SlackResponse: SlackResponse{
+					Ok: true,
+				},
+			},
+		},
+		{
+			title: "Testing with request error",
+			params: GetUploadURLExternalParameters{
+				FileName: "test.txt",
+				FileSize: 10,
+			},
+			wantSlackResponse: []byte(`{"ok":false,"error":"errored"}`),
+			wantErr:           fmt.Errorf("errored"),
+		},
+		{
+			title: "Testing with invalid parameters: empty file name",
+			params: GetUploadURLExternalParameters{
+				FileName: "",
+				FileSize: 10,
+			},
+			wantErr: fmt.Errorf("FileName cannot be empty"),
+		},
+		{
+			title: "Testing with invalid parameters: file size 0",
+			params: GetUploadURLExternalParameters{
+				FileName: "test.txt",
+				FileSize: 0,
+			},
+			wantErr: fmt.Errorf("FileSize cannot be 0"),
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.title, func(t *testing.T) {
+			api := &Client{
+				token: validToken,
+				httpclient: &mockGetUploadURLExternalHttpClient{
+					ResponseStatus: 200,
+					ResponseBody:   tc.wantSlackResponse,
+				},
+			}
+
+			gotResponse, err := api.GetUploadURLExternalContext(context.Background(), tc.params)
+
+			if err != nil {
+				if tc.wantErr == nil {
+					t.Fatalf("GetUploadURLExternalContext() error = %v, want nil", err)
+				}
+				if err.Error() != tc.wantErr.Error() {
+					t.Errorf("GetUploadURLExternalContext() error = %v, want %v", err, tc.wantErr)
+				}
+			} else {
+				if tc.wantErr != nil {
+					t.Fatalf("GetUploadURLExternalContext() error = nil, want %v", tc.wantErr)
+				}
+				if !reflect.DeepEqual(gotResponse, &tc.wantResponse) {
+					t.Errorf("GetUploadURLExternalContext() = %v, want %v", gotResponse, tc.wantResponse)
+				}
+			}
+		})
+	}
+}
+
+type mockCompleteUploadExternalHttpClient struct {
+	ResponseStatus int
+	ResponseBody   []byte
+}
+
+func (m *mockCompleteUploadExternalHttpClient) Do(req *http.Request) (*http.Response, error) {
+	if req.URL.Path != "files.completeUploadExternal" {
+		return nil, fmt.Errorf("invalid path: %s", req.URL.Path)
+	}
+
+	return &http.Response{
+		StatusCode: m.ResponseStatus,
+		Body:       io.NopCloser(bytes.NewBuffer(m.ResponseBody)),
+	}, nil
+}
+
+func TestCompleteUploadExternalContext(t *testing.T) {
+	type testCase struct {
+		title        string
+		params       CompleteUploadExternalParameters
+		wantResponse CompleteUploadExternalResponse
+		wantErr      bool
+	}
+	testCases := []testCase{
+		{
+			title: "Testing with required parameters",
+			params: CompleteUploadExternalParameters{
+				Files: []FileSummary{
+					{
+						ID: "ID1",
+					},
+					{
+						ID: "ID2",
+					},
+				},
+			},
+			wantResponse: CompleteUploadExternalResponse{
+				Files: []FileSummary{
+					{
+						ID: "ID1",
+					},
+					{
+						ID: "ID2",
+					},
+				},
+				SlackResponse: SlackResponse{Ok: true},
+			},
+		},
+		{
+			title: "Testing with optional parameters",
+			params: CompleteUploadExternalParameters{
+				Files: []FileSummary{
+					{
+						ID: "ID1",
+					},
+					{
+						ID:    "ID2",
+						Title: "Title2",
+					},
+				},
+				Channel:         "test-channel",
+				InitialComment:  "test-comment",
+				ThreadTimestamp: "1234567890.123456",
+			},
+			wantResponse: CompleteUploadExternalResponse{
+				Files: []FileSummary{
+					{
+						ID: "ID1",
+					},
+					{
+						ID:    "ID2",
+						Title: "Title2",
+					},
+				},
+				SlackResponse: SlackResponse{Ok: true},
+			},
+		},
+		{
+			title: "Testing with error",
+			params: CompleteUploadExternalParameters{
+				Files: []FileSummary{
+					{
+						ID: "ID1",
+					},
+				},
+			},
+			wantErr: true,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.title, func(t *testing.T) {
+			var resBody map[string]interface{}
+			if !tc.wantErr {
+				resBody = map[string]interface{}{
+					"ok": true,
+				}
+				files := make([]map[string]string, 0)
+				for _, file := range tc.params.Files {
+					m := map[string]string{
+						"id": file.ID,
+					}
+					if file.Title != "" {
+						m["title"] = file.Title
+					}
+					files = append(files, m)
+				}
+				resBody["files"] = files
+			} else {
+				resBody = map[string]interface{}{
+					"ok":    false,
+					"error": "errored",
+				}
+			}
+
+			resBodyBytes, err := json.Marshal(resBody)
+			if err != nil {
+				t.Fatalf("failed to marshal response body: %v", err)
+			}
+
+			api := &Client{
+				token: validToken,
+				httpclient: &mockCompleteUploadExternalHttpClient{
+					ResponseStatus: 200,
+					ResponseBody:   resBodyBytes,
+				},
+			}
+
+			gotResponse, err := api.CompleteUploadExternalContext(context.Background(), tc.params)
+
+			if err != nil {
+				if !tc.wantErr {
+					t.Errorf("CompleteUploadExternalContext() error = %v, want nil", err)
+				}
+			} else {
+				if tc.wantErr {
+					t.Fatalf("CompleteUploadExternalContext() error = nil, want %v", tc.wantErr)
+				}
+				if !reflect.DeepEqual(gotResponse, &tc.wantResponse) {
+					t.Errorf("CompleteUploadExternalContext() = %v, want %v", gotResponse, tc.wantResponse)
+				}
+			}
+		})
+	}
+}