Skip to content
Merged
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
186 changes: 179 additions & 7 deletions internal/domain/config/wrangler.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
package config

import (
"encoding/json"
"reflect"
)

type WranglerConfig struct {
Name string `json:"name"`
Main string `json:"main"`
Expand All @@ -12,20 +17,22 @@ type WranglerConfig struct {
R2Buckets []WranglerR2Bucket `json:"r2_buckets,omitempty"`
Routes []WranglerRoute `json:"routes,omitempty"`
Env map[string]WranglerEnvConfig `json:"env,omitempty"`
Extra map[string]json.RawMessage `json:"-"`
}

type WranglerObservability struct {
Enabled bool `json:"enabled"`
}

type WranglerEnvConfig struct {
Name string `json:"name,omitempty"`
AI *WranglerAIBinding `json:"ai,omitempty"`
Vars map[string]string `json:"vars,omitempty"`
KVNamespaces []WranglerKVNamespace `json:"kv_namespaces,omitempty"`
D1Databases []WranglerD1Database `json:"d1_databases,omitempty"`
R2Buckets []WranglerR2Bucket `json:"r2_buckets,omitempty"`
Routes []WranglerRoute `json:"routes,omitempty"`
Name string `json:"name,omitempty"`
AI *WranglerAIBinding `json:"ai,omitempty"`
Vars map[string]string `json:"vars,omitempty"`
KVNamespaces []WranglerKVNamespace `json:"kv_namespaces,omitempty"`
D1Databases []WranglerD1Database `json:"d1_databases,omitempty"`
R2Buckets []WranglerR2Bucket `json:"r2_buckets,omitempty"`
Routes []WranglerRoute `json:"routes,omitempty"`
Extra map[string]json.RawMessage `json:"-"`
}

type WranglerAIBinding struct {
Expand Down Expand Up @@ -57,3 +64,168 @@ type WranglerRoute struct {
ZoneID string `json:"zone_id,omitempty"`
CustomDomain bool `json:"custom_domain,omitempty"`
}

func (w *WranglerConfig) UnmarshalJSON(data []byte) error {
type alias WranglerConfig
var decoded alias
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}

var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
deleteKnownWranglerKeys(raw)

*w = WranglerConfig(decoded)
if len(raw) > 0 {
w.Extra = raw
} else {
w.Extra = nil
}
return nil
}

func (w WranglerConfig) MarshalJSON() ([]byte, error) {
raw := cloneRawMap(w.Extra)
deleteKnownWranglerKeys(raw)

putString(raw, "name", w.Name)
putString(raw, "main", w.Main)
putValue(raw, "compatibility_date", w.CompatibilityDate)
putValue(raw, "observability", w.Observability)
putValue(raw, "ai", w.AI)
putValue(raw, "vars", w.Vars)
putValue(raw, "kv_namespaces", w.KVNamespaces)
putValue(raw, "d1_databases", w.D1Databases)
putValue(raw, "r2_buckets", w.R2Buckets)
putValue(raw, "routes", w.Routes)
putValue(raw, "env", w.Env)

return json.Marshal(raw)
}

func (w *WranglerEnvConfig) UnmarshalJSON(data []byte) error {
type alias WranglerEnvConfig
var decoded alias
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}

var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
deleteKnownWranglerEnvKeys(raw)

*w = WranglerEnvConfig(decoded)
if len(raw) > 0 {
w.Extra = raw
} else {
w.Extra = nil
}
return nil
}

func (w WranglerEnvConfig) MarshalJSON() ([]byte, error) {
raw := cloneRawMap(w.Extra)
deleteKnownWranglerEnvKeys(raw)

putString(raw, "name", w.Name)
putValue(raw, "ai", w.AI)
putValue(raw, "vars", w.Vars)
putValue(raw, "kv_namespaces", w.KVNamespaces)
putValue(raw, "d1_databases", w.D1Databases)
putValue(raw, "r2_buckets", w.R2Buckets)
putValue(raw, "routes", w.Routes)

return json.Marshal(raw)
}

func cloneRawMap(source map[string]json.RawMessage) map[string]json.RawMessage {
if len(source) == 0 {
return map[string]json.RawMessage{}
}
cloned := make(map[string]json.RawMessage, len(source))
for key, value := range source {
cloned[key] = append(json.RawMessage(nil), value...)
}
return cloned
}

func deleteKnownWranglerKeys(raw map[string]json.RawMessage) {
delete(raw, "name")
delete(raw, "main")
delete(raw, "compatibility_date")
delete(raw, "observability")
delete(raw, "ai")
delete(raw, "vars")
delete(raw, "kv_namespaces")
delete(raw, "d1_databases")
delete(raw, "r2_buckets")
delete(raw, "routes")
delete(raw, "env")
}

func deleteKnownWranglerEnvKeys(raw map[string]json.RawMessage) {
delete(raw, "name")
delete(raw, "ai")
delete(raw, "vars")
delete(raw, "kv_namespaces")
delete(raw, "d1_databases")
delete(raw, "r2_buckets")
delete(raw, "routes")
}

func putString(raw map[string]json.RawMessage, key, value string) {
if value == "" {
delete(raw, key)
return
}
putValue(raw, key, value)
}

func putValue(raw map[string]json.RawMessage, key string, value any) {
if isEmptyJSONValue(value) {
delete(raw, key)
return
}
data, err := json.Marshal(value)
if err != nil {
return
}
raw[key] = data
}

func isEmptyJSONValue(value any) bool {
if value == nil {
return true
}
rv := reflect.ValueOf(value)
switch rv.Kind() {
case reflect.Pointer, reflect.Interface, reflect.Map, reflect.Slice:
if rv.IsNil() {
return true
}
}

switch typed := value.(type) {
case string:
return typed == ""
case map[string]string:
return len(typed) == 0
case map[string]WranglerEnvConfig:
return len(typed) == 0
case []WranglerKVNamespace:
return len(typed) == 0
case []WranglerD1Database:
return len(typed) == 0
case []WranglerR2Bucket:
return len(typed) == 0
case []WranglerRoute:
return len(typed) == 0
default:
return false
}
}
76 changes: 76 additions & 0 deletions internal/domain/config/wrangler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package config

import (
"encoding/json"
"strings"
"testing"
)

func TestWranglerConfigRoundTripPreservesUnknownFields(t *testing.T) {
input := []byte(`{
"name": "demo-worker",
"main": "dist/worker.mjs",
"compatibility_date": "2026-03-19",
"ai": { "binding": "AI", "remote": true },
"unsafe": { "bindings": [{ "name": "EXTRA" }] },
"env": {
"production": {
"vars": { "FOO": "bar" },
"durable_objects": {
"bindings": [{ "name": "COUNTER", "class_name": "Counter" }]
}
}
}
}`)

var cfg WranglerConfig
if err := json.Unmarshal(input, &cfg); err != nil {
t.Fatalf("unmarshal wrangler config: %v", err)
}

cfg.Vars = map[string]string{"BAR": "baz"}

output, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal wrangler config: %v", err)
}

text := string(output)
if !strings.Contains(text, `"unsafe":{"bindings":[{"name":"EXTRA"}]}`) {
t.Fatalf("unknown top-level field was not preserved: %s", text)
}
if !strings.Contains(text, `"durable_objects":{"bindings":[{"name":"COUNTER","class_name":"Counter"}]}`) {
t.Fatalf("unknown env field was not preserved: %s", text)
}
if !strings.Contains(text, `"vars":{"BAR":"baz"}`) {
t.Fatalf("known var update was not written: %s", text)
}
}

func TestWranglerConfigMarshalDoesNotResurrectClearedKnownFields(t *testing.T) {
input := []byte(`{
"name": "demo-worker",
"ai": { "binding": "AI", "remote": true },
"unsafe": { "bindings": [{ "name": "EXTRA" }] }
}`)

var cfg WranglerConfig
if err := json.Unmarshal(input, &cfg); err != nil {
t.Fatalf("unmarshal wrangler config: %v", err)
}

cfg.AI = nil

output, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal wrangler config: %v", err)
}

text := string(output)
if strings.Contains(text, `"ai":`) {
t.Fatalf("cleared known field should not be preserved from extras: %s", text)
}
if !strings.Contains(text, `"unsafe":{"bindings":[{"name":"EXTRA"}]}`) {
t.Fatalf("unknown field should still be preserved: %s", text)
}
}
Loading