diff --git a/internal/domain/config/wrangler.go b/internal/domain/config/wrangler.go index 783853f..d7fe5ea 100644 --- a/internal/domain/config/wrangler.go +++ b/internal/domain/config/wrangler.go @@ -1,5 +1,10 @@ package config +import ( + "encoding/json" + "reflect" +) + type WranglerConfig struct { Name string `json:"name"` Main string `json:"main"` @@ -12,6 +17,7 @@ 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 { @@ -19,13 +25,14 @@ type WranglerObservability struct { } 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 { @@ -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 + } +} diff --git a/internal/domain/config/wrangler_test.go b/internal/domain/config/wrangler_test.go new file mode 100644 index 0000000..8d37fa7 --- /dev/null +++ b/internal/domain/config/wrangler_test.go @@ -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) + } +}