Repository: cloudflare/cloudflare-go
Pull request #1010 (2022-08-02 19:39:36)

add module upload support
PR to upstream the changes from https://github.com/Shopify/cloudflare-go/pull/16 that adds support for uploading scripts that use the module syntax.

It builds upon the changes started [here](https://github.com/cloudflare/cloudflare-go/pull/795)

Closes https://github.com/cloudflare/cloudflare-go/issues/794

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.

Patch:
diff --git a/.changelog/1010.txt b/.changelog/1010.txt
new file mode 100644
index 000000000..df85b31f4
--- /dev/null
+++ b/.changelog/1010.txt
@@ -0,0 +1,3 @@
+```release-note:enhancement
+workers: Add support to upload module workers
+```
diff --git a/workers.go b/workers.go
index a1f812b25..59db346e6 100644
--- a/workers.go
+++ b/workers.go
@@ -26,6 +26,10 @@ type WorkerRequestParams struct {
 type WorkerScriptParams struct {
 	Script string
 
+	// Module changes the Content-Type header to specify the script is an
+	// ES Module syntax script.
+	Module bool
+
 	// Bindings should be a map where the keys are the binding name, and the
 	// values are the binding content
 	Bindings map[string]WorkerBinding
@@ -592,11 +596,16 @@ func (api *API) ListWorkerScripts(ctx context.Context) (WorkerListResponse, erro
 // UploadWorker push raw script content for your worker.
 //
 // API reference: https://api.cloudflare.com/#worker-script-upload-worker
-func (api *API) UploadWorker(ctx context.Context, requestParams *WorkerRequestParams, data string) (WorkerScriptResponse, error) {
+func (api *API) UploadWorker(ctx context.Context, requestParams *WorkerRequestParams, params *WorkerScriptParams) (WorkerScriptResponse, error) {
+	if params.Module {
+		return api.UploadWorkerWithBindings(ctx, requestParams, params)
+	}
+
+	contentType := "application/javascript"
 	if requestParams.ScriptName != "" {
-		return api.uploadWorkerWithName(ctx, requestParams.ScriptName, "application/javascript", []byte(data))
+		return api.uploadWorkerWithName(ctx, requestParams.ScriptName, contentType, []byte(params.Script))
 	}
-	return api.uploadWorkerForZone(ctx, requestParams.ZoneID, "application/javascript", []byte(data))
+	return api.uploadWorkerForZone(ctx, requestParams.ZoneID, contentType, []byte(params.Script))
 }
 
 // UploadWorkerWithBindings push raw script content and bindings for your worker
@@ -655,15 +664,23 @@ func formatMultipartBody(params *WorkerScriptParams) (string, []byte, error) {
 	defer mpw.Close()
 
 	// Write metadata part
-	scriptPartName := "script"
+	var scriptPartName string
 	meta := struct {
-		BodyPart string              `json:"body_part"`
-		Bindings []workerBindingMeta `json:"bindings"`
+		BodyPart   string              `json:"body_part,omitempty"`
+		MainModule string              `json:"main_module,omitempty"`
+		Bindings   []workerBindingMeta `json:"bindings"`
 	}{
-		BodyPart: scriptPartName,
 		Bindings: make([]workerBindingMeta, 0, len(params.Bindings)),
 	}
 
+	if params.Module {
+		scriptPartName = "worker.mjs"
+		meta.MainModule = scriptPartName
+	} else {
+		scriptPartName = "script"
+		meta.BodyPart = scriptPartName
+	}
+
 	bodyWriters := make([]workerBindingBodyWriter, 0, len(params.Bindings))
 	for name, b := range params.Bindings {
 		bindingMeta, bodyWriter, err := b.serialize(name)
@@ -693,8 +710,16 @@ func formatMultipartBody(params *WorkerScriptParams) (string, []byte, error) {
 
 	// Write script part
 	hdr = textproto.MIMEHeader{}
-	hdr.Set("content-disposition", fmt.Sprintf(`form-data; name="%s"`, scriptPartName))
-	hdr.Set("content-type", "application/javascript")
+
+	contentType := "application/javascript"
+	if params.Module {
+		contentType = "application/javascript+module"
+		hdr.Set("content-disposition", fmt.Sprintf(`form-data; name="%s"; filename="%[1]s"`, scriptPartName))
+	} else {
+		hdr.Set("content-disposition", fmt.Sprintf(`form-data; name="%s"`, scriptPartName))
+	}
+	hdr.Set("content-type", contentType)
+
 	pw, err = mpw.CreatePart(hdr)
 	if err != nil {
 		return "", nil, err


Test patch:
diff --git a/workers_example_test.go b/workers_example_test.go
index d3350fa81..2206a3504 100644
--- a/workers_example_test.go
+++ b/workers_example_test.go
@@ -23,7 +23,7 @@ func ExampleAPI_UploadWorker() {
 		log.Fatal(err)
 	}
 
-	res, err := api.UploadWorker(context.Background(), &cloudflare.WorkerRequestParams{ZoneID: zoneID}, workerScript)
+	res, err := api.UploadWorker(context.Background(), &cloudflare.WorkerRequestParams{ZoneID: zoneID}, &cloudflare.WorkerScriptParams{Script: workerScript})
 	if err != nil {
 		log.Fatal(err)
 	}
@@ -38,7 +38,7 @@ func UploadWorkerWithName() {
 		log.Fatal(err)
 	}
 
-	res, err := api.UploadWorker(context.Background(), &cloudflare.WorkerRequestParams{ScriptName: "baz"}, workerScript)
+	res, err := api.UploadWorker(context.Background(), &cloudflare.WorkerRequestParams{ScriptName: "baz"}, &cloudflare.WorkerScriptParams{Script: workerScript})
 	if err != nil {
 		log.Fatal(err)
 	}
diff --git a/workers_test.go b/workers_test.go
index becb32a63..d21819c0c 100644
--- a/workers_test.go
+++ b/workers_test.go
@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"io/ioutil"
+	"mime/multipart"
 	"net/http"
 	"strings"
 	"testing"
@@ -31,6 +32,18 @@ const (
     "errors": [],
     "messages": []
 }`
+
+	uploadWorkerModuleResponseData = `{
+    "result": {
+        "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"
+    },
+    "success": true,
+    "errors": [],
+    "messages": []
+}`
 	updateWorkerRouteResponse = `{
     "result": {
         "id": "e7a57d8746e74ae49c25994dadb421b1",
@@ -171,6 +184,7 @@ const (
 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
 )
 
@@ -203,6 +217,21 @@ func getFormValue(r *http.Request, key string) ([]byte, error) {
 	return nil, fmt.Errorf("no value found for key %v", key)
 }
 
+func getFileDetails(r *http.Request, key string) (*multipart.FileHeader, error) {
+	err := r.ParseMultipartForm(1024 * 1024)
+	if err != nil {
+		return nil, err
+	}
+
+	fileHeaders := r.MultipartForm.File[key]
+
+	if len(fileHeaders) > 0 {
+		return fileHeaders[0], nil
+	}
+
+	return nil, fmt.Errorf("no value found for key %v", key)
+}
+
 type multipartUpload struct {
 	Script      string
 	BindingMeta map[string]workerBindingMeta
@@ -216,8 +245,9 @@ func parseMultipartUpload(r *http.Request) (multipartUpload, error) {
 	}
 
 	var metadata struct {
-		BodyPart string              `json:"body_part"`
-		Bindings []workerBindingMeta `json:"bindings"`
+		BodyPart   string              `json:"body_part,omitempty"`
+		MainModule string              `json:"main_module,omitempty"`
+		Bindings   []workerBindingMeta `json:"bindings"`
 	}
 	err = json.Unmarshal(mdBytes, &metadata)
 	if err != nil {
@@ -227,7 +257,11 @@ func parseMultipartUpload(r *http.Request) (multipartUpload, error) {
 	// Get the script
 	script, err := getFormValue(r, metadata.BodyPart)
 	if err != nil {
-		return multipartUpload{}, err
+		script, err = getFormValue(r, metadata.MainModule)
+
+		if err != nil {
+			return multipartUpload{}, err
+		}
 	}
 
 	// Since bindings are specified in the Go API as a map but are uploaded as a
@@ -379,7 +413,7 @@ func TestWorkers_UploadWorker(t *testing.T) {
 		w.Header().Set("content-type", "application/json")
 		fmt.Fprintf(w, uploadWorkerResponseData) //nolint
 	})
-	res, err := client.UploadWorker(context.Background(), &WorkerRequestParams{ZoneID: "foo"}, workerScript)
+	res, err := client.UploadWorker(context.Background(), &WorkerRequestParams{ZoneID: "foo"}, &WorkerScriptParams{Script: workerScript})
 	formattedTime, _ := time.Parse(time.RFC3339Nano, "2018-06-09T15:17:01.989141Z")
 	want := WorkerScriptResponse{
 		successResponse,
@@ -396,6 +430,44 @@ func TestWorkers_UploadWorker(t *testing.T) {
 	}
 }
 
+func TestWorkers_UploadWorkerAsModule(t *testing.T) {
+	setup()
+	defer teardown()
+
+	mux.HandleFunc("/zones/foo/workers/script", func(w http.ResponseWriter, r *http.Request) {
+		mpUpload, err := parseMultipartUpload(r)
+		assert.NoError(t, err)
+
+		assert.Equal(t, workerModuleScript, mpUpload.Script)
+
+		workerFileDetails, err := getFileDetails(r, "worker.mjs")
+		if !assert.NoError(t, err) {
+			assert.FailNow(t, "worker file not found in multipart form body")
+		}
+		contentTypeHeader := workerFileDetails.Header.Get("content-type")
+		expectedContentType := "application/javascript+module"
+		assert.Equal(t, expectedContentType, contentTypeHeader, "Expected content-type request header to be %s, got %s", expectedContentType, contentTypeHeader)
+
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, uploadWorkerModuleResponseData) //nolint
+	})
+	res, err := client.UploadWorker(context.Background(), &WorkerRequestParams{ZoneID: "foo"}, &WorkerScriptParams{Script: workerModuleScript, Module: true})
+	formattedTime, _ := time.Parse(time.RFC3339Nano, "2018-06-09T15:17:01.989141Z")
+	want := WorkerScriptResponse{
+		successResponse,
+		WorkerScript{
+			Script: workerModuleScript,
+			WorkerMetaData: WorkerMetaData{
+				ETAG:       "279cf40d86d70b82f6cd3ba90a646b3ad995912da446836d7371c21c6a43977a",
+				Size:       191,
+				ModifiedOn: formattedTime,
+			},
+		}}
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, res)
+	}
+}
+
 func TestWorkers_UploadWorkerWithName(t *testing.T) {
 	setup(UsingAccount("foo"))
 	defer teardown()
@@ -407,7 +479,7 @@ func TestWorkers_UploadWorkerWithName(t *testing.T) {
 		w.Header().Set("content-type", "application/json")
 		fmt.Fprintf(w, uploadWorkerResponseData) //nolint
 	})
-	res, err := client.UploadWorker(context.Background(), &WorkerRequestParams{ScriptName: "bar"}, workerScript)
+	res, err := client.UploadWorker(context.Background(), &WorkerRequestParams{ScriptName: "bar"}, &WorkerScriptParams{Script: workerScript})
 	formattedTime, _ := time.Parse(time.RFC3339Nano, "2018-06-09T15:17:01.989141Z")
 	want := WorkerScriptResponse{
 		successResponse,
@@ -435,7 +507,7 @@ func TestWorkers_UploadWorkerSingleScriptWithAccount(t *testing.T) {
 		w.Header().Set("content-type", "application/json")
 		fmt.Fprintf(w, uploadWorkerResponseData) //nolint
 	})
-	res, err := client.UploadWorker(context.Background(), &WorkerRequestParams{ZoneID: "foo"}, workerScript)
+	res, err := client.UploadWorker(context.Background(), &WorkerRequestParams{ZoneID: "foo"}, &WorkerScriptParams{Script: workerScript})
 	formattedTime, _ := time.Parse(time.RFC3339Nano, "2018-06-09T15:17:01.989141Z")
 	want := WorkerScriptResponse{
 		successResponse,
@@ -456,7 +528,7 @@ func TestWorkers_UploadWorkerWithNameErrorsWithoutAccountId(t *testing.T) {
 	setup()
 	defer teardown()
 
-	_, err := client.UploadWorker(context.Background(), &WorkerRequestParams{ScriptName: "bar"}, workerScript)
+	_, err := client.UploadWorker(context.Background(), &WorkerRequestParams{ScriptName: "bar"}, &WorkerScriptParams{Script: workerScript})
 	assert.Error(t, err)
 }
 
@@ -682,6 +754,51 @@ func TestWorkers_UploadWorkerWithPlainTextBinding(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestWorkers_UploadWorkerAsModuleWithPlainTextBinding(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.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method)
+
+		mpUpload, err := parseMultipartUpload(r)
+		assert.NoError(t, err)
+
+		expectedBindings := map[string]workerBindingMeta{
+			"b1": {
+				"name": "b1",
+				"type": "plain_text",
+				"text": "plain text value",
+			},
+		}
+		assert.Equal(t, workerModuleScript, mpUpload.Script)
+		assert.Equal(t, expectedBindings, mpUpload.BindingMeta)
+
+		workerFileDetails, err := getFileDetails(r, "worker.mjs")
+		if !assert.NoError(t, err) {
+			assert.FailNow(t, "worker file not found in multipart form body")
+		}
+		contentDispositonHeader := workerFileDetails.Header.Get("content-disposition")
+		expectedContentDisposition := fmt.Sprintf(`form-data; name="%s"; filename="%[1]s"`, "worker.mjs")
+		assert.Equal(t, expectedContentDisposition, contentDispositonHeader, "Expected content-disposition request header to be %s, got %s", expectedContentDisposition, contentDispositonHeader)
+
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, uploadWorkerModuleResponseData) //nolint
+	})
+
+	scriptParams := WorkerScriptParams{
+		Script: workerModuleScript,
+		Module: true,
+		Bindings: map[string]WorkerBinding{
+			"b1": WorkerPlainTextBinding{
+				Text: "plain text value",
+			},
+		},
+	}
+	_, err := client.UploadWorkerWithBindings(context.Background(), &WorkerRequestParams{ScriptName: "bar"}, &scriptParams)
+	assert.NoError(t, err)
+}
+
 func TestWorkers_UploadWorkerWithSecretTextBinding(t *testing.T) {
 	setup(UsingAccount("foo"))
 	defer teardown()