dnscontrol/pkg/cloudflare-go/workers.go
Tom Limoncelli 7fd6a74e0c
CLOUDFLAREAPI: CF_REDIRECT/CF_TEMP_REDIRECT should dtrt using Single Redirects (#3002)
Co-authored-by: Josh Zhang <jzhang1@stackoverflow.com>
2024-06-18 17:38:50 -04:00

631 lines
19 KiB
Go

package cloudflare
import (
"bytes"
"context"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"net/textproto"
"strings"
"time"
"github.com/goccy/go-json"
)
// WorkerRequestParams provides parameters for worker requests for both enterprise and standard requests.
type WorkerRequestParams struct {
ZoneID string
ScriptName string
}
type CreateWorkerParams struct {
ScriptName string
Script string
// DispatchNamespaceName uploads the worker to a WFP dispatch namespace if provided
DispatchNamespaceName *string
// Module changes the Content-Type header to specify the script is an
// ES Module syntax script.
Module bool
// Logpush opts the worker into Workers Logpush logging. A nil value leaves
// the current setting unchanged.
//
// Documentation: https://developers.cloudflare.com/workers/platform/logpush/
Logpush *bool
// TailConsumers specifies a list of Workers that will consume the logs of
// the attached Worker.
// Documentation: https://developers.cloudflare.com/workers/platform/tail-workers/
TailConsumers *[]WorkersTailConsumer
// Bindings should be a map where the keys are the binding name, and the
// values are the binding content
Bindings map[string]WorkerBinding
// CompatibilityDate is a date in the form yyyy-mm-dd,
// which will be used to determine which version of the Workers runtime is used.
// https://developers.cloudflare.com/workers/platform/compatibility-dates/
CompatibilityDate string
// CompatibilityFlags are the names of features of the Workers runtime to be enabled or disabled,
// usually used together with CompatibilityDate.
// https://developers.cloudflare.com/workers/platform/compatibility-dates/#compatibility-flags
CompatibilityFlags []string
Placement *Placement
// Tags are used to better manage CRUD operations at scale.
// https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/platform/tags/
Tags []string
}
func (p CreateWorkerParams) RequiresMultipart() bool {
switch {
case p.Module:
return true
case p.Logpush != nil:
return true
case p.Placement != nil:
return true
case len(p.Bindings) > 0:
return true
case p.CompatibilityDate != "":
return true
case len(p.CompatibilityFlags) > 0:
return true
case p.TailConsumers != nil:
return true
case len(p.Tags) > 0:
return true
}
return false
}
type UpdateWorkersScriptContentParams struct {
ScriptName string
Script string
// DispatchNamespaceName uploads the worker to a WFP dispatch namespace if provided
DispatchNamespaceName *string
// Module changes the Content-Type header to specify the script is an
// ES Module syntax script.
Module bool
}
type UpdateWorkersScriptSettingsParams struct {
ScriptName string
// Logpush opts the worker into Workers Logpush logging. A nil value leaves
// the current setting unchanged.
//
// Documentation: https://developers.cloudflare.com/workers/platform/logpush/
Logpush *bool
// TailConsumers specifies a list of Workers that will consume the logs of
// the attached Worker.
// Documentation: https://developers.cloudflare.com/workers/platform/tail-workers/
TailConsumers *[]WorkersTailConsumer
// Bindings should be a map where the keys are the binding name, and the
// values are the binding content
Bindings map[string]WorkerBinding
// CompatibilityDate is a date in the form yyyy-mm-dd,
// which will be used to determine which version of the Workers runtime is used.
// https://developers.cloudflare.com/workers/platform/compatibility-dates/
CompatibilityDate string
// CompatibilityFlags are the names of features of the Workers runtime to be enabled or disabled,
// usually used together with CompatibilityDate.
// https://developers.cloudflare.com/workers/platform/compatibility-dates/#compatibility-flags
CompatibilityFlags []string
Placement *Placement
}
// WorkerScriptParams provides a worker script and the associated bindings.
type WorkerScriptParams struct {
ScriptName 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
}
// WorkerRoute is used to map traffic matching a URL pattern to a workers
//
// API reference: https://api.cloudflare.com/#worker-routes-properties
type WorkerRoute struct {
ID string `json:"id,omitempty"`
Pattern string `json:"pattern"`
ScriptName string `json:"script,omitempty"`
}
// WorkerRoutesResponse embeds Response struct and slice of WorkerRoutes.
type WorkerRoutesResponse struct {
Response
Routes []WorkerRoute `json:"result"`
}
// WorkerRouteResponse embeds Response struct and a single WorkerRoute.
type WorkerRouteResponse struct {
Response
WorkerRoute `json:"result"`
}
// WorkerScript Cloudflare Worker struct with metadata.
type WorkerScript struct {
WorkerMetaData
Script string `json:"script"`
UsageModel string `json:"usage_model,omitempty"`
}
type WorkersTailConsumer struct {
Service string `json:"service"`
Environment *string `json:"environment,omitempty"`
Namespace *string `json:"namespace,omitempty"`
}
// WorkerMetaData contains worker script information such as size, creation & modification dates.
type WorkerMetaData struct {
ID string `json:"id,omitempty"`
ETAG string `json:"etag,omitempty"`
Size int `json:"size,omitempty"`
CreatedOn time.Time `json:"created_on,omitempty"`
ModifiedOn time.Time `json:"modified_on,omitempty"`
Logpush *bool `json:"logpush,omitempty"`
TailConsumers *[]WorkersTailConsumer `json:"tail_consumers,omitempty"`
LastDeployedFrom *string `json:"last_deployed_from,omitempty"`
DeploymentId *string `json:"deployment_id,omitempty"`
PlacementMode *PlacementMode `json:"placement_mode,omitempty"`
PipelineHash *string `json:"pipeline_hash,omitempty"`
}
// WorkerListResponse wrapper struct for API response to worker script list API call.
type WorkerListResponse struct {
Response
ResultInfo
WorkerList []WorkerMetaData `json:"result"`
}
// WorkerScriptResponse wrapper struct for API response to worker script calls.
type WorkerScriptResponse struct {
Response
Module bool
WorkerScript `json:"result"`
}
// WorkerScriptSettingsResponse wrapper struct for API response to worker script settings calls.
type WorkerScriptSettingsResponse struct {
Response
WorkerMetaData
}
type ListWorkersParams struct{}
type DeleteWorkerParams struct {
ScriptName string
// DispatchNamespaceName is the dispatch namespace the Worker is uploaded to.
DispatchNamespace *string
}
type PlacementMode string
const (
PlacementModeOff PlacementMode = ""
PlacementModeSmart PlacementMode = "smart"
)
type Placement struct {
Mode PlacementMode `json:"mode"`
}
// DeleteWorker deletes a single Worker.
//
// API reference: https://developers.cloudflare.com/api/operations/worker-script-delete-worker
func (api *API) DeleteWorker(ctx context.Context, rc *ResourceContainer, params DeleteWorkerParams) error {
if rc.Level != AccountRouteLevel {
return ErrRequiredAccountLevelResourceContainer
}
if rc.Identifier == "" {
return ErrMissingAccountID
}
uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s", rc.Identifier, params.ScriptName)
if params.DispatchNamespace != nil && *params.DispatchNamespace != "" {
uri = fmt.Sprintf("/accounts/%s/workers/dispatch/namespaces/%s/scripts/%s", rc.Identifier, *params.DispatchNamespace, params.ScriptName)
}
res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil)
var r WorkerScriptResponse
if err != nil {
return err
}
err = json.Unmarshal(res, &r)
if err != nil {
return fmt.Errorf("%s: %w", errUnmarshalError, err)
}
return nil
}
// GetWorker fetch raw script content for your worker returns string containing
// worker code js.
//
// API reference: https://developers.cloudflare.com/api/operations/worker-script-download-worker
func (api *API) GetWorker(ctx context.Context, rc *ResourceContainer, scriptName string) (WorkerScriptResponse, error) {
return api.GetWorkerWithDispatchNamespace(ctx, rc, scriptName, "")
}
// GetWorker fetch raw script content for your worker returns string containing
// worker code js.
//
// API reference: https://developers.cloudflare.com/api/operations/worker-script-download-worker
func (api *API) GetWorkerWithDispatchNamespace(ctx context.Context, rc *ResourceContainer, scriptName string, dispatchNamespace string) (WorkerScriptResponse, error) {
if rc.Level != AccountRouteLevel {
return WorkerScriptResponse{}, ErrRequiredAccountLevelResourceContainer
}
if rc.Identifier == "" {
return WorkerScriptResponse{}, ErrMissingAccountID
}
uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s", rc.Identifier, scriptName)
if dispatchNamespace != "" {
uri = fmt.Sprintf("/accounts/%s/workers/dispatch/namespaces/%s/scripts/%s/content", rc.Identifier, dispatchNamespace, scriptName)
}
res, err := api.makeRequestContextWithHeadersComplete(ctx, http.MethodGet, uri, nil, nil)
var r WorkerScriptResponse
if err != nil {
return r, err
}
// 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 := io.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
}
// ListWorkers returns list of Workers for given account.
//
// API reference: https://developers.cloudflare.com/api/operations/worker-script-list-workers
func (api *API) ListWorkers(ctx context.Context, rc *ResourceContainer, params ListWorkersParams) (WorkerListResponse, *ResultInfo, error) {
if rc.Level != AccountRouteLevel {
return WorkerListResponse{}, &ResultInfo{}, ErrRequiredAccountLevelResourceContainer
}
if rc.Identifier == "" {
return WorkerListResponse{}, &ResultInfo{}, ErrMissingAccountID
}
uri := fmt.Sprintf("/accounts/%s/workers/scripts", rc.Identifier)
res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
if err != nil {
return WorkerListResponse{}, &ResultInfo{}, err
}
var r WorkerListResponse
err = json.Unmarshal(res, &r)
if err != nil {
return WorkerListResponse{}, &ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err)
}
return r, &r.ResultInfo, nil
}
// UploadWorker pushes raw script content for your Worker.
//
// API reference: https://developers.cloudflare.com/api/operations/worker-script-upload-worker-module
func (api *API) UploadWorker(ctx context.Context, rc *ResourceContainer, params CreateWorkerParams) (WorkerScriptResponse, error) {
if rc.Level != AccountRouteLevel {
return WorkerScriptResponse{}, ErrRequiredAccountLevelResourceContainer
}
if rc.Identifier == "" {
return WorkerScriptResponse{}, ErrMissingAccountID
}
body := []byte(params.Script)
var (
contentType = "application/javascript"
err error
)
if params.RequiresMultipart() {
contentType, body, err = formatMultipartBody(params)
if err != nil {
return WorkerScriptResponse{}, err
}
}
uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s", rc.Identifier, params.ScriptName)
if params.DispatchNamespaceName != nil && *params.DispatchNamespaceName != "" {
uri = fmt.Sprintf("/accounts/%s/workers/dispatch/namespaces/%s/scripts/%s", rc.Identifier, *params.DispatchNamespaceName, params.ScriptName)
}
headers := make(http.Header)
headers.Set("Content-Type", contentType)
res, err := api.makeRequestContextWithHeaders(ctx, http.MethodPut, uri, body, headers)
var r WorkerScriptResponse
if err != nil {
return r, err
}
err = json.Unmarshal(res, &r)
if err != nil {
return r, fmt.Errorf("%s: %w", errUnmarshalError, err)
}
return r, nil
}
// GetWorkersScriptContent returns the pure script content of a worker.
//
// API reference: https://developers.cloudflare.com/api/operations/worker-script-get-content
func (api *API) GetWorkersScriptContent(ctx context.Context, rc *ResourceContainer, scriptName string) (string, error) {
if rc.Level != AccountRouteLevel {
return "", ErrRequiredAccountLevelResourceContainer
}
if rc.Identifier == "" {
return "", ErrMissingAccountID
}
uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s/content/v2", rc.Identifier, scriptName)
res, err := api.makeRequestContextWithHeadersComplete(ctx, http.MethodGet, uri, nil, nil)
if err != nil {
return "", err
}
return string(res.Body), nil
}
// UpdateWorkersScriptContent pushes only script content, no metadata.
//
// API reference: https://developers.cloudflare.com/api/operations/worker-script-put-content
func (api *API) UpdateWorkersScriptContent(ctx context.Context, rc *ResourceContainer, params UpdateWorkersScriptContentParams) (WorkerScriptResponse, error) {
if rc.Level != AccountRouteLevel {
return WorkerScriptResponse{}, ErrRequiredAccountLevelResourceContainer
}
if rc.Identifier == "" {
return WorkerScriptResponse{}, ErrMissingAccountID
}
body := []byte(params.Script)
var (
contentType = "application/javascript"
err error
)
if params.Module {
var formattedParams CreateWorkerParams
formattedParams.Script = params.Script
formattedParams.ScriptName = params.ScriptName
formattedParams.Module = params.Module
formattedParams.DispatchNamespaceName = params.DispatchNamespaceName
contentType, body, err = formatMultipartBody(formattedParams)
if err != nil {
return WorkerScriptResponse{}, err
}
}
uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s/content", rc.Identifier, params.ScriptName)
if params.DispatchNamespaceName != nil {
uri = fmt.Sprintf("/accounts/%s/workers/dispatch_namespaces/%s/scripts/%s/content", rc.Identifier, *params.DispatchNamespaceName, params.ScriptName)
}
headers := make(http.Header)
headers.Set("Content-Type", contentType)
res, err := api.makeRequestContextWithHeaders(ctx, http.MethodPut, uri, body, headers)
var r WorkerScriptResponse
if err != nil {
return r, err
}
err = json.Unmarshal(res, &r)
if err != nil {
return r, fmt.Errorf("%s: %w", errUnmarshalError, err)
}
return r, nil
}
// GetWorkersScriptSettings returns the metadata of a worker.
//
// API reference: https://developers.cloudflare.com/api/operations/worker-script-get-settings
func (api *API) GetWorkersScriptSettings(ctx context.Context, rc *ResourceContainer, scriptName string) (WorkerScriptSettingsResponse, error) {
if rc.Level != AccountRouteLevel {
return WorkerScriptSettingsResponse{}, ErrRequiredAccountLevelResourceContainer
}
if rc.Identifier == "" {
return WorkerScriptSettingsResponse{}, ErrMissingAccountID
}
uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s/settings", rc.Identifier, scriptName)
res, err := api.makeRequestContextWithHeaders(ctx, http.MethodGet, uri, nil, nil)
var r WorkerScriptSettingsResponse
if err != nil {
return r, err
}
err = json.Unmarshal(res, &r)
if err != nil {
return r, fmt.Errorf("%s: %w", errUnmarshalError, err)
}
r.Success = true
return r, nil
}
// UpdateWorkersScriptSettings pushes only script metadata.
//
// API reference: https://developers.cloudflare.com/api/operations/worker-script-patch-settings
func (api *API) UpdateWorkersScriptSettings(ctx context.Context, rc *ResourceContainer, params UpdateWorkersScriptSettingsParams) (WorkerScriptSettingsResponse, error) {
if rc.Level != AccountRouteLevel {
return WorkerScriptSettingsResponse{}, ErrRequiredAccountLevelResourceContainer
}
if rc.Identifier == "" {
return WorkerScriptSettingsResponse{}, ErrMissingAccountID
}
body, err := json.Marshal(params)
if err != nil {
return WorkerScriptSettingsResponse{}, err
}
headers := make(http.Header)
headers.Set("Content-Type", "application/json")
uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s/settings", rc.Identifier, params.ScriptName)
res, err := api.makeRequestContextWithHeaders(ctx, http.MethodPatch, uri, body, headers)
var r WorkerScriptSettingsResponse
if err != nil {
return r, err
}
err = json.Unmarshal(res, &r)
if err != nil {
return r, fmt.Errorf("%s: %w", errUnmarshalError, err)
}
r.Success = true
return r, nil
}
// Returns content-type, body, error.
func formatMultipartBody(params CreateWorkerParams) (string, []byte, error) {
var buf = &bytes.Buffer{}
var mpw = multipart.NewWriter(buf)
defer mpw.Close()
// Write metadata part
var scriptPartName string
meta := struct {
BodyPart string `json:"body_part,omitempty"`
MainModule string `json:"main_module,omitempty"`
Bindings []workerBindingMeta `json:"bindings"`
Logpush *bool `json:"logpush,omitempty"`
TailConsumers *[]WorkersTailConsumer `json:"tail_consumers,omitempty"`
CompatibilityDate string `json:"compatibility_date,omitempty"`
CompatibilityFlags []string `json:"compatibility_flags,omitempty"`
Placement *Placement `json:"placement,omitempty"`
Tags []string `json:"tags"`
}{
Bindings: make([]workerBindingMeta, 0, len(params.Bindings)),
Logpush: params.Logpush,
TailConsumers: params.TailConsumers,
CompatibilityDate: params.CompatibilityDate,
CompatibilityFlags: params.CompatibilityFlags,
Placement: params.Placement,
Tags: params.Tags,
}
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)
if err != nil {
return "", nil, err
}
meta.Bindings = append(meta.Bindings, bindingMeta)
bodyWriters = append(bodyWriters, bodyWriter)
}
var hdr = textproto.MIMEHeader{}
hdr.Set("content-disposition", fmt.Sprintf(`form-data; name="%s"`, "metadata"))
hdr.Set("content-type", "application/json")
pw, err := mpw.CreatePart(hdr)
if err != nil {
return "", nil, err
}
metaJSON, err := json.Marshal(meta)
if err != nil {
return "", nil, err
}
_, err = pw.Write(metaJSON)
if err != nil {
return "", nil, err
}
// Write script part
hdr = textproto.MIMEHeader{}
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
}
_, err = pw.Write([]byte(params.Script))
if err != nil {
return "", nil, err
}
// Write other bindings with parts
for _, w := range bodyWriters {
if w != nil {
err = w(mpw)
if err != nil {
return "", nil, err
}
}
}
mpw.Close()
return mpw.FormDataContentType(), buf.Bytes(), nil
}