Repository: cloudflare/cloudflare-go
Pull request #1040 (2022-08-13 23:30:39)

Add support for multipart downloads for module-format Worker scripts
## Description

Since #1010 allows uploading a Worker script in module format, calling DownloadWorker on the same script will return a multipart/form-data response.

### Asks

1. _Update: still need this; for the moment I'm assuming that any multipart worker is in module format._ Cloudflare API for download a worker should add a Content-Type header to each multipart/form-data section to specify whether the script in that section is application/javascript or application/javascript+module.
2. _Update: took this approach, please review!_ Guidance on a deep change: `makeRequest...` et al should return a content-type header not just the body.
3. _Update: didn't like this approach:_ ~Guidance on a workaround: `makeRequest...` et al continue to just return the response body, so consumers assume the format, or detect the format with a heuristic. For example, `DownloadWorker` can check the first two bytes and if they are `--` then assume the rest of that line is the multipart boundary string and parse the body accordingly. This is so gross.~

### Details

This approach digs into the `makeRequest...` family (`makeRequestWithAuthTypeAndHeaders` is the full implementation) and adds support to return the first body of a multipart response. `makeRequest`, et al, does not return the mime-type of the response. Consuming functions mostly assume it is application/json, except DownloadWorker which implicitly assumes it's application/javascript.

While I've solved the initial problem with #1010 that you can upload a module-format worker but if you try to download it, the DownloadWorker method return raw multipart/form-data, I'm stuck on the next problem: the Module true/false status is _implied_ by whether the download script API returns a javascript content-type or a multipart content-type.

There's no further clue because the Cloudflare API for downloading a worker only gives a Content-Disposition header on its response for a module worker:
```
Content-Type: multipart/form-data; boundary=mimeboundary

--mimeboundary
Content-Disposition: form-data; name="worker.js"

export default { etc etc etc }
--mimeboundary
```
vs. non-module format:
```
Content-Type: application/json

addEventListener( etc etc )
```

This isn't consistent with **uploading** a module worker, where the Content-Type of the mime-part must be set to `application/javascript+module` like this:
```
--mimeboundary
Content-Disposition: form-data; name="worker.js"
Content-Type: application/javascript+module

export default { etc etc etc }
--mimeboundary
```

Sidenote: why was the method `UploadWorkerWithBindings` added? The bindings are in a param structure, isn't the point to add new params without creating a new method? And in fact if you call UploadWorker with Module-format worker, it shunts to UploadWorkerWithBindings! The salient difference is whether the upload is in multipart format or a direct script. The code path I can see that isn't in multipart format is a non-module worker without bindings (the original product, so probably also the most common case). _Per manual testing, the Cloudflare API will accept a multipart/form-data with a plain script as its body -- that is, **UploadWorker** is sufficient for all cases and **UploadWorkerWithBindings** is duplicative._

## Has your change been tested?

_**Works**_ but creates an awkward roundtrip problem in the current iteration:

1. `UploadWorker` with Module: true because it's a module script.
```
        scriptFile, err := os.ReadFile("cool-script.js")
        scriptText := string(scriptFile)

        UploadWorker(ctx,
                &cloudflare.WorkerRequestParams{
                        ScriptName: "modtest",
                },
                &cloudflare.WorkerScriptParams{
                        Script: scriptText,
                        Module: true,
                },
        )
```
4. `DownloadWorker` with the script name and returns the script body. But what format is it?
```
        scriptText = DownloadWorker(ctx,
                &cloudflare.WorkerRequestParams{
                        ScriptName: "modtest",
                },
        )
```
5. `UploadWorker` ... with Module true or false? There's no indication from DownloadWorker which to use.
```
        strings.Replace(scriptText, "foo", "bar", -1)

        UploadWorker(ctx,
                &cloudflare.WorkerRequestParams{
                        ScriptName: "modtest",
                },
                &cloudflare.WorkerScriptParams{
                        Script: scriptText,
                        Module: true,   <---- ?????????????????                },
        )
```


## Screenshots (if appropriate):

## Types of changes

What sort of change does your code introduce/modify?

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)

## Checklist:

- [x] My code follows the code style of this project.
- [ ] My change requires a change to the documentation.
- [ ] I have updated the documentation accordingly.
- [x] I have added tests to cover my changes.
- [x] All new and existing tests passed.
- [x] This change is using publicly documented (api.cloudflare.com or developers.cloudflare.com) and stable APIs.

[1]: https://help.github.com/articles/closing-issues-using-keywords/


github-actions[bot]: Oops! It looks like no changelog entry is attached to this PR. Please include a release note as described in https://github.com/cloudflare/cloudflare-go/blob/master/docs/changelog-process.md.

Example: 

~~~
```release-note:TYPE
Release note
```
~~~

If you do not require a release note to be included, please add the `workflow/skip-changelog-entry` label.
codecov-commenter: # [Codecov](https://codecov.io/gh/cloudflare/cloudflare-go/pull/1040?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare) Report
> Merging [#1040](https://codecov.io/gh/cloudflare/cloudflare-go/pull/1040?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare) (10e129b) into [master](https://codecov.io/gh/cloudflare/cloudflare-go/commit/6c5ea4aae34d51ef65c6898d105fa4f3694e21d4?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare) (6c5ea4a) will **increase** coverage by `0.80%`.
> The diff coverage is `66.37%`.

```diff
@@            Coverage Diff             @@
##           master    #1040      +/-   ##
==========================================
+ Coverage   49.06%   49.86%   +0.80%     
==========================================
  Files         108      115       +7     
  Lines       10428    10870     +442     
==========================================
+ Hits         5116     5420     +304     
- Misses       4200     4288      +88     
- Partials     1112     1162      +50     
```


| [Impacted Files](https://codecov.io/gh/cloudflare/cloudflare-go/pull/1040?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare) | Coverage Δ | |
|---|---|---|
| [access\_application.go](https://codecov.io/gh/cloudflare/cloudflare-go/pull/1040/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare#diff-YWNjZXNzX2FwcGxpY2F0aW9uLmdv) | `76.52% <ø> (ø)` | |
| [access\_bookmark.go](https://codecov.io/gh/cloudflare/cloudflare-go/pull/1040/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare#diff-YWNjZXNzX2Jvb2ttYXJrLmdv) | `72.44% <ø> (ø)` | |
| [workers\_account\_settings.go](https://codecov.io/gh/cloudflare/cloudflare-go/pull/1040/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare#diff-d29ya2Vyc19hY2NvdW50X3NldHRpbmdzLmdv) | `35.71% <35.71%> (ø)` | |
| [workers\_tail.go](https://codecov.io/gh/cloudflare/cloudflare-go/pull/1040/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare#diff-d29ya2Vyc190YWlsLmdv) | `52.00% <52.00%> (ø)` | |
| [workers\_subdomain.go](https://codecov.io/gh/cloudflare/cloudflare-go/pull/1040/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare#diff-d29ya2Vyc19zdWJkb21haW4uZ28=) | `57.14% <57.14%> (ø)` | |
| [email\_routing\_settings.go](https://codecov.io/gh/cloudflare/cloudflare-go/pull/1040/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare#diff-ZW1haWxfcm91dGluZ19zZXR0aW5ncy5nbw==) | `60.00% <60.00%> (ø)` | |
| [email\_routing\_rules.go](https://codecov.io/gh/cloudflare/cloudflare-go/pull/1040/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare#diff-ZW1haWxfcm91dGluZ19ydWxlcy5nbw==) | `65.64% <65.64%> (ø)` | |
| [lockdown.go](https://codecov.io/gh/cloudflare/cloudflare-go/pull/1040/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare#diff-bG9ja2Rvd24uZ28=) | `57.69% <66.00%> (+6.94%)` | :arrow_up: |
| [email\_routing\_destination.go](https://codecov.io/gh/cloudflare/cloudflare-go/pull/1040/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare#diff-ZW1haWxfcm91dGluZ19kZXN0aW5hdGlvbi5nbw==) | `66.25% <66.25%> (ø)` | |
| [filter.go](https://codecov.io/gh/cloudflare/cloudflare-go/pull/1040/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare#diff-ZmlsdGVyLmdv) | `42.96% <72.72%> (+0.51%)` | :arrow_up: |
| ... and [11 more](https://codecov.io/gh/cloudflare/cloudflare-go/pull/1040/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare) | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare)

sodabrew: @jacobbednarz thanks for quickly merging the two test fixits that I pulled out of this PR. I wrote a massive update to the OP with examples and problems of implied content-type. Looking forward to your comments!
jacobbednarz: this PR has become quite significantly larger than the intended scope and i'm afraid, i won't be merging it as is with such a large refactor of the core methods. leading into your next question, the experimental client (see https://github.com/cloudflare/cloudflare-go/blob/master/docs/experimental.md) will focus on these larger, sweeping improvements for the next major release. we'll be intentionally shaking away alot of the older library functionality that we have now which would be a better place for type of changes you're proposing here. i wouldn't recommend opening PRs for it at this stage, but, just be aware that it exists.

for now, if you want this functionality, we need to do it within the confines of the library API as it stands todya.
sodabrew: Ok, thanks for that feedback. I've adjusted the scope of changes to be a lot narrower, please take another look and let me know.
jacobbednarz: i've gone ahead and updated `APIResponse` to be more `http.Response` like as that will be the direction for the experimental client when we start porting functionality to it. this looks fine in the tests but do you want to confirm locally that this meets what you're after here before we merge?
sodabrew: Looks great! I've rebased and squashed down to three commits: the core change I arrived at, your edits thereupon, and worker test script whitespace changes.

Patch:
diff --git a/.changelog/1040.txt b/.changelog/1040.txt
new file mode 100644
index 000000000..5f3402d35
--- /dev/null
+++ b/.changelog/1040.txt
@@ -0,0 +1,3 @@
+```release-note:enhancement
+workers: Support for multipart encoding for DownloadWorker on a module-format Worker script
+```
diff --git a/cloudflare.go b/cloudflare.go
index f94aaf203..c14990dcc 100644
--- a/cloudflare.go
+++ b/cloudflare.go
@@ -176,21 +176,41 @@ func (api *API) makeRequestContextWithHeaders(ctx context.Context, method, uri s
 	return api.makeRequestWithAuthTypeAndHeaders(ctx, method, uri, params, api.authType, headers)
 }
 
-// Deprecated: Use `makeRequestContextWithHeaders` instead.
-//nolint:unused
-func (api *API) makeRequestWithHeaders(method, uri string, params interface{}, headers http.Header) ([]byte, error) {
-	return api.makeRequestWithAuthTypeAndHeaders(context.Background(), method, uri, params, api.authType, headers)
-}
-
 func (api *API) makeRequestWithAuthType(ctx context.Context, method, uri string, params interface{}, authType int) ([]byte, error) {
 	return api.makeRequestWithAuthTypeAndHeaders(ctx, method, uri, params, authType, nil)
 }
 
+// APIResponse holds the structure for a response from the API. It looks alot
+// like `http.Response` however, uses a `[]byte` for the `Body` instead of a
+// `io.ReadCloser`.
+//
+// This may go away in the experimental client in favour of `http.Response`.
+type APIResponse struct {
+	Body       []byte
+	Status     string
+	StatusCode int
+	Headers    http.Header
+}
+
 func (api *API) makeRequestWithAuthTypeAndHeaders(ctx context.Context, method, uri string, params interface{}, authType int, headers http.Header) ([]byte, error) {
+	res, err := api.makeRequestWithAuthTypeAndHeadersComplete(ctx, method, uri, params, api.authType, headers)
+	if err != nil {
+		return nil, err
+	}
+	return res.Body, err
+}
+
+// Use this method if an API response can have different Content-Type headers and different body formats.
+func (api *API) makeRequestContextWithHeadersComplete(ctx context.Context, method, uri string, params interface{}, headers http.Header) (*APIResponse, error) {
+	return api.makeRequestWithAuthTypeAndHeadersComplete(ctx, method, uri, params, api.authType, headers)
+}
+
+func (api *API) makeRequestWithAuthTypeAndHeadersComplete(ctx context.Context, method, uri string, params interface{}, authType int, headers http.Header) (*APIResponse, error) {
 	var err error
 	var resp *http.Response
 	var respErr error
 	var respBody []byte
+
 	for i := 0; i <= api.retryPolicy.MaxRetries; i++ {
 		var reqBody io.Reader
 		if params != nil {
@@ -278,12 +298,14 @@ func (api *API) makeRequestWithAuthTypeAndHeaders(ctx context.Context, method, u
 			break
 		}
 	}
+
+	// still had an error after all retries
 	if respErr != nil {
 		return nil, respErr
 	}
 
 	if api.Debug {
-		fmt.Printf("cloudflare-go [DEBUG] RESPONSE StatusCode:%d Body:%#v RayID:%s\n", resp.StatusCode, string(respBody), resp.Header.Get("cf-ray"))
+		fmt.Printf("cloudflare-go [DEBUG] RESPONSE StatusCode:%d RayID:%s ContentType:%s Body:%#v\n", resp.StatusCode, resp.Header.Get("cf-ray"), resp.Header.Get("content-type"), string(respBody))
 	}
 
 	if resp.StatusCode >= http.StatusBadRequest {
@@ -341,7 +363,12 @@ func (api *API) makeRequestWithAuthTypeAndHeaders(ctx context.Context, method, u
 		}
 	}
 
-	return respBody, nil
+	return &APIResponse{
+		Body:       respBody,
+		StatusCode: resp.StatusCode,
+		Status:     resp.Status,
+		Headers:    resp.Header,
+	}, nil
 }
 
 // request makes a HTTP request to the given API endpoint, returning the raw
diff --git a/images.go b/images.go
index d9f749ab1..58d3e17e1 100644
--- a/images.go
+++ b/images.go
@@ -138,12 +138,11 @@ func (api *API) UploadImage(ctx context.Context, accountID string, upload ImageU
 	}
 	_ = w.Close()
 
-	res, err := api.makeRequestWithAuthTypeAndHeaders(
+	res, err := api.makeRequestContextWithHeaders(
 		ctx,
 		http.MethodPost,
 		uri,
 		body,
-		api.authType,
 		http.Header{
 			"Accept":       []string{"application/json"},
 			"Content-Type": []string{w.FormDataContentType()},
diff --git a/workers.go b/workers.go
index 344c66965..dc1b13372 100644
--- a/workers.go
+++ b/workers.go
@@ -8,9 +8,12 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
+	"io/ioutil"
+	"mime"
 	"mime/multipart"
 	"net/http"
 	"net/textproto"
+	"strings"
 	"time"
 
 	"errors"
@@ -82,6 +85,7 @@ type WorkerListResponse struct {
 // WorkerScriptResponse wrapper struct for API response to worker script calls.
 type WorkerScriptResponse struct {
 	Response
+	Module       bool
 	WorkerScript `json:"result"`
 }
 
@@ -417,6 +421,7 @@ func (api *API) DownloadWorker(ctx context.Context, requestParams *WorkerRequest
 		return r, err
 	}
 	r.Script = string(res)
+	r.Module = false
 	r.Success = true
 	return r, nil
 }
@@ -429,12 +434,32 @@ func (api *API) downloadWorkerWithName(ctx context.Context, scriptName string) (
 		return WorkerScriptResponse{}, errors.New("account ID required")
 	}
 	uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s", api.AccountID, scriptName)
-	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+	res, err := api.makeRequestContextWithHeadersComplete(ctx, http.MethodGet, uri, nil, nil)
 	var r WorkerScriptResponse
 	if err != nil {
 		return r, err
 	}
-	r.Script = string(res)
+
+	// Check if the response type is multipart, in which case this was a module worker
+	mediaType, mediaParams, _ := mime.ParseMediaType(res.Headers.Get("content-type"))
+	if strings.HasPrefix(mediaType, "multipart/") {
+		bytesReader := bytes.NewReader(res.Body)
+		mimeReader := multipart.NewReader(bytesReader, mediaParams["boundary"])
+		mimePart, err := mimeReader.NextPart()
+		if err != nil {
+			return r, fmt.Errorf("could not get multipart response body: %w", err)
+		}
+		mimePartBody, err := ioutil.ReadAll(mimePart)
+		if err != nil {
+			return r, fmt.Errorf("could not read multipart response body: %w", err)
+		}
+		r.Script = string(mimePartBody)
+		r.Module = true
+	} else {
+		r.Script = string(res.Body)
+		r.Module = false
+	}
+
 	r.Success = true
 	return r, nil
 }
@@ -497,6 +522,7 @@ func (api *API) ListWorkerBindings(ctx context.Context, requestParams *WorkerReq
 		case WorkerWebAssemblyBindingType:
 			bindingListItem.Binding = WorkerWebAssemblyBinding{
 				Module: &bindingContentReader{
+					ctx:           ctx,
 					api:           api,
 					requestParams: requestParams,
 					bindingName:   name,
@@ -537,6 +563,7 @@ func (api *API) ListWorkerBindings(ctx context.Context, requestParams *WorkerReq
 type bindingContentReader struct {
 	api           *API
 	requestParams *WorkerRequestParams
+	ctx           context.Context
 	bindingName   string
 	content       []byte
 	position      int
@@ -546,7 +573,7 @@ func (b *bindingContentReader) Read(p []byte) (n int, err error) {
 	// Lazily load the content when Read() is first called
 	if b.content == nil {
 		uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s/bindings/%s/content", b.api.AccountID, b.requestParams.ScriptName, b.bindingName)
-		res, err := b.api.makeRequest(http.MethodGet, uri, nil)
+		res, err := b.api.makeRequestContext(b.ctx, http.MethodGet, uri, nil)
 		if err != nil {
 			return 0, err
 		}


Test patch:
diff --git a/workers_test.go b/workers_test.go
index 4df58a734..fcf7d4f45 100644
--- a/workers_test.go
+++ b/workers_test.go
@@ -23,7 +23,7 @@ const (
 }`
 	uploadWorkerResponseData = `{
     "result": {
-        "script": "addEventListener('fetch', event => {\n    event.passThroughOnException()\nevent.respondWith(handleRequest(event.request))\n})\n\nasync function handleRequest(request) {\n    return fetch(request)\n}",
+        "script": "addEventListener('fetch', event => {\n  event.passThroughOnException()\n  event.respondWith(handleRequest(event.request))\n})\n\nasync function handleRequest(request) {\n  return fetch(request)\n}",
         "etag": "279cf40d86d70b82f6cd3ba90a646b3ad995912da446836d7371c21c6a43977a",
         "size": 191,
         "modified_on": "2018-06-09T15:17:01.989141Z"
@@ -35,7 +35,7 @@ const (
 
 	uploadWorkerModuleResponseData = `{
     "result": {
-        "script": "export default {\n    async fetch(request, env, event) {\n     event.passThroughOnException()\n    return fetch(request)\n    }\n}",
+        "script": "export default {\n  async fetch(request, env, event) {\n    event.passThroughOnException()\n    return fetch(request)\n  }\n}",
         "etag": "279cf40d86d70b82f6cd3ba90a646b3ad995912da446836d7371c21c6a43977a",
         "size": 191,
         "modified_on": "2018-06-09T15:17:01.989141Z"
@@ -179,15 +179,38 @@ const (
   "errors": [],
   "messages": []
 }`
+	workerScript = `addEventListener('fetch', event => {
+  event.passThroughOnException()
+  event.respondWith(handleRequest(event.request))
+})
+
+async function handleRequest(request) {
+  return fetch(request)
+}`
+	workerModuleScript = `export default {
+  async fetch(request, env, event) {
+    event.passThroughOnException()
+    return fetch(request)
+  }
+}`
+	workerModuleScriptDownloadResponse = `
+--workermodulescriptdownload
+Content-Disposition: form-data; name="worker.js"
+
+export default {
+  async fetch(request, env, event) {
+    event.passThroughOnException()
+    return fetch(request)
+  }
+}
+--workermodulescriptdownload--
+`
 )
 
 var (
 	successResponse               = Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}}
-	workerScript                  = "addEventListener('fetch', event => {\n    event.passThroughOnException()\nevent.respondWith(handleRequest(event.request))\n})\n\nasync function handleRequest(request) {\n    return fetch(request)\n}"
-	workerModuleScript            = "export default {\n    async fetch(request, env, event) {\n     event.passThroughOnException()\n    return fetch(request)\n    }\n}"
 	deleteWorkerRouteResponseData = createWorkerRouteResponse
-
-	attachWorkerToDomainResponse = fmt.Sprintf(`{
+	attachWorkerToDomainResponse  = fmt.Sprintf(`{
     "result": {
         "id": "e7a57d8746e74ae49c25994dadb421b1",
 	"zone_id": "%s",
@@ -303,8 +326,9 @@ func TestWorkers_DeleteWorker(t *testing.T) {
 	})
 	res, err := client.DeleteWorker(context.Background(), &WorkerRequestParams{ZoneID: "foo"})
 	want := WorkerScriptResponse{
-		successResponse,
-		WorkerScript{}}
+		Response: successResponse,
+	}
+
 	if assert.NoError(t, err) {
 		assert.Equal(t, want.Response, res.Response)
 	}
@@ -321,8 +345,8 @@ func TestWorkers_DeleteWorkerWithName(t *testing.T) {
 	})
 	res, err := client.DeleteWorker(context.Background(), &WorkerRequestParams{ScriptName: "bar"})
 	want := WorkerScriptResponse{
-		successResponse,
-		WorkerScript{}}
+		Response: successResponse,
+	}
 	if assert.NoError(t, err) {
 		assert.Equal(t, want.Response, res.Response)
 	}
@@ -348,6 +372,7 @@ func TestWorkers_DownloadWorker(t *testing.T) {
 	res, err := client.DownloadWorker(context.Background(), &WorkerRequestParams{ZoneID: "foo"})
 	want := WorkerScriptResponse{
 		successResponse,
+		false,
 		WorkerScript{
 			Script: workerScript,
 		}}
@@ -368,6 +393,7 @@ func TestWorkers_DownloadWorkerWithName(t *testing.T) {
 	res, err := client.DownloadWorker(context.Background(), &WorkerRequestParams{ScriptName: "bar"})
 	want := WorkerScriptResponse{
 		successResponse,
+		false,
 		WorkerScript{
 			Script: workerScript,
 		}}
@@ -384,6 +410,27 @@ func TestWorkers_DownloadWorkerWithNameErrorsWithoutAccountId(t *testing.T) {
 	assert.Error(t, err)
 }
 
+func TestWorkers_DownloadWorkerModule(t *testing.T) {
+	setup(UsingAccount("foo"))
+	defer teardown()
+
+	mux.HandleFunc("/accounts/foo/workers/scripts/bar", func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+		w.Header().Set("content-type", "multipart/form-data; boundary=workermodulescriptdownload")
+		fmt.Fprintf(w, workerModuleScriptDownloadResponse) //nolint
+	})
+	res, err := client.DownloadWorker(context.Background(), &WorkerRequestParams{ScriptName: "bar"})
+	want := WorkerScriptResponse{
+		successResponse,
+		true,
+		WorkerScript{
+			Script: workerModuleScript,
+		}}
+	if assert.NoError(t, err) {
+		assert.Equal(t, want.Script, res.Script)
+	}
+}
+
 func TestWorkers_ListWorkerScripts(t *testing.T) {
 	setup(UsingAccount("foo"))
 	defer teardown()
@@ -430,6 +477,7 @@ func TestWorkers_UploadWorker(t *testing.T) {
 	formattedTime, _ := time.Parse(time.RFC3339Nano, "2018-06-09T15:17:01.989141Z")
 	want := WorkerScriptResponse{
 		successResponse,
+		false,
 		WorkerScript{
 			Script: workerScript,
 			WorkerMetaData: WorkerMetaData{
@@ -468,6 +516,7 @@ func TestWorkers_UploadWorkerAsModule(t *testing.T) {
 	formattedTime, _ := time.Parse(time.RFC3339Nano, "2018-06-09T15:17:01.989141Z")
 	want := WorkerScriptResponse{
 		successResponse,
+		false,
 		WorkerScript{
 			Script: workerModuleScript,
 			WorkerMetaData: WorkerMetaData{
@@ -496,6 +545,7 @@ func TestWorkers_UploadWorkerWithName(t *testing.T) {
 	formattedTime, _ := time.Parse(time.RFC3339Nano, "2018-06-09T15:17:01.989141Z")
 	want := WorkerScriptResponse{
 		successResponse,
+		false,
 		WorkerScript{
 			Script: workerScript,
 			WorkerMetaData: WorkerMetaData{
@@ -524,6 +574,7 @@ func TestWorkers_UploadWorkerSingleScriptWithAccount(t *testing.T) {
 	formattedTime, _ := time.Parse(time.RFC3339Nano, "2018-06-09T15:17:01.989141Z")
 	want := WorkerScriptResponse{
 		successResponse,
+		false,
 		WorkerScript{
 			Script: workerScript,
 			WorkerMetaData: WorkerMetaData{
@@ -629,6 +680,7 @@ func TestWorkers_UploadWorkerWithInheritBinding(t *testing.T) {
 	formattedTime, _ := time.Parse(time.RFC3339Nano, "2018-06-09T15:17:01.989141Z")
 	want := WorkerScriptResponse{
 		successResponse,
+		false,
 		WorkerScript{
 			Script: workerScript,
 			WorkerMetaData: WorkerMetaData{