From bca440a0d6494be21b7dacab0a340a048b43e32c Mon Sep 17 00:00:00 2001 From: Thanatat Tamtan Date: Sun, 30 Aug 2026 13:18:39 +0700 Subject: [PATCH] chore: switch JSON to encoding/json/v2 Migrate marshal/unmarshal call sites to encoding/json/v2. Retag bool/number/pointer/time fields from omitempty to omitzero so v2's redefined omitempty does not drop location feature flags, send 0 for "use server default" ints, or emit year-1 timestamps. Site manifests pass json.Deterministic so release-sha map key order stays stable. --- Requested via Grok Work Prompter: ACS Prompter: ACS --- action.go | 2 +- auditactortype.go | 2 +- auditoutcome.go | 2 +- client/client.go | 6 +- client/dropbox_create_upload_url.go | 18 ++--- client/dropbox_upload.go | 2 +- client/jsonv2_test.go | 55 +++++++++++++++ client/notification_stream.go | 4 +- client/site.go | 13 ++-- deployer.go | 84 +++++++++++------------ deployment.go | 2 +- deploymentaction.go | 2 +- domain.go | 6 +- domaincertstatus.go | 2 +- domainstatus.go | 2 +- email.go | 2 +- errors.go | 2 +- github.go | 2 +- githubtrigger.go | 2 +- jsonv2_test.go | 100 ++++++++++++++++++++++++++++ location.go | 10 +-- notification.go | 10 +-- notificationchanneltype.go | 2 +- registry.go | 2 +- status.go | 2 +- transformphase.go | 2 +- validate.go | 2 +- wafaction.go | 2 +- 28 files changed, 249 insertions(+), 93 deletions(-) create mode 100644 client/jsonv2_test.go create mode 100644 jsonv2_test.go diff --git a/action.go b/action.go index 62ba348..71abb40 100644 --- a/action.go +++ b/action.go @@ -1,7 +1,7 @@ package api import ( - "encoding/json" + "encoding/json/v2" ) //go:generate stringer -type=Action -linecomment diff --git a/auditactortype.go b/auditactortype.go index ab84d68..b895828 100644 --- a/auditactortype.go +++ b/auditactortype.go @@ -1,6 +1,6 @@ package api -import "encoding/json" +import "encoding/json/v2" //go:generate stringer -type=AuditActorType -linecomment type AuditActorType int diff --git a/auditoutcome.go b/auditoutcome.go index b161c38..e556610 100644 --- a/auditoutcome.go +++ b/auditoutcome.go @@ -1,6 +1,6 @@ package api -import "encoding/json" +import "encoding/json/v2" //go:generate stringer -type=AuditOutcome -linecomment type AuditOutcome int diff --git a/client/client.go b/client/client.go index f3d699b..bde8d63 100644 --- a/client/client.go +++ b/client/client.go @@ -3,7 +3,7 @@ package client import ( "bytes" "context" - "encoding/json" + "encoding/json/v2" "fmt" "io" "net/http" @@ -185,7 +185,7 @@ func (c *Client) invoke(ctx context.Context, api string, r any, res any) error { } var reqBody bytes.Buffer - err := json.NewEncoder(&reqBody).Encode(r) + err := json.MarshalWrite(&reqBody, r) if err != nil { return err } @@ -222,7 +222,7 @@ func (c *Client) invoke(ctx context.Context, api string, r any, res any) error { respBody.Result = res respBody.Error = &errMsg - err = json.NewDecoder(resp.Body).Decode(&respBody) + err = json.UnmarshalRead(resp.Body, &respBody) if err != nil { return err } diff --git a/client/dropbox_create_upload_url.go b/client/dropbox_create_upload_url.go index 0cf5539..244f892 100644 --- a/client/dropbox_create_upload_url.go +++ b/client/dropbox_create_upload_url.go @@ -3,7 +3,7 @@ package client import ( "bytes" "context" - "encoding/json" + "encoding/json/v2" "fmt" "io" "net/http" @@ -32,10 +32,10 @@ type DropboxCreateUploadURLOptions struct { Project string `json:"project" yaml:"project"` // project sid the upload is authorized and billed against Filename string `json:"filename,omitempty" yaml:"filename"` // optional filename recorded in Content-Disposition for downloads ContentType string `json:"contentType,omitempty" yaml:"contentType"` // optional; when set, the PUT must send this exact Content-Type - MinSize int64 `json:"minSize,omitempty" yaml:"minSize"` // optional min bytes; the server floors it at 1 so empty uploads are refused - MaxSize int64 `json:"maxSize,omitempty" yaml:"maxSize"` // optional max bytes; the server clamps to its cap (default 5 GiB) - TTLDays int `json:"ttl,omitempty" yaml:"ttl"` // download lifetime in days, 1-7; 0 -> server default 1 - Expires int `json:"expires,omitempty" yaml:"expires"` // upload-URL validity in seconds, 1-3600; 0 -> server default 900 + MinSize int64 `json:"minSize,omitzero" yaml:"minSize"` // optional min bytes; the server floors it at 1 so empty uploads are refused + MaxSize int64 `json:"maxSize,omitzero" yaml:"maxSize"` // optional max bytes; the server clamps to its cap (default 5 GiB) + TTLDays int `json:"ttl,omitzero" yaml:"ttl"` // download lifetime in days, 1-7; 0 -> server default 1 + Expires int `json:"expires,omitzero" yaml:"expires"` // upload-URL validity in seconds, 1-3600; 0 -> server default 900 Endpoint string `json:"-" yaml:"-"` // optional dropbox base URL override; empty -> DefaultDropboxEndpoint } @@ -86,10 +86,10 @@ func (c *Client) DropboxCreateUploadURL(ctx context.Context, opts *DropboxCreate Project string `json:"project"` Filename string `json:"filename,omitempty"` ContentType string `json:"contentType,omitempty"` - MinSize int64 `json:"minSize,omitempty"` - MaxSize int64 `json:"maxSize,omitempty"` - TTL int `json:"ttl,omitempty"` - Expires int `json:"expires,omitempty"` + MinSize int64 `json:"minSize,omitzero"` + MaxSize int64 `json:"maxSize,omitzero"` + TTL int `json:"ttl,omitzero"` + Expires int `json:"expires,omitzero"` }{ Project: opts.Project, Filename: opts.Filename, diff --git a/client/dropbox_upload.go b/client/dropbox_upload.go index eb5b067..b80359b 100644 --- a/client/dropbox_upload.go +++ b/client/dropbox_upload.go @@ -3,7 +3,7 @@ package client import ( "bytes" "context" - "encoding/json" + "encoding/json/v2" "fmt" "io" "net/http" diff --git a/client/jsonv2_test.go b/client/jsonv2_test.go new file mode 100644 index 0000000..219b33b --- /dev/null +++ b/client/jsonv2_test.go @@ -0,0 +1,55 @@ +package client + +import ( + "strings" + "testing" + + json "encoding/json/v2" +) + +func TestDropboxOptionalIntsOmitted(t *testing.T) { + got, err := json.Marshal(DropboxCreateUploadURLOptions{Project: "p"}) + if err != nil { + t.Fatal(err) + } + s := string(got) + for _, k := range []string{"minSize", "maxSize", "ttl", "expires"} { + if strings.Contains(s, k) { + t.Fatalf("zero %s should be omitted, got %s", k, s) + } + } + if s != `{"project":"p"}` { + t.Fatalf("got %s", s) + } +} + +func TestSiteManifestDeterministic(t *testing.T) { + m := siteManifest{ + Environment: "production", + Files: map[string]siteManifestEntry{ + "z.txt": {Blob: "b", CT: "text/plain", Cache: "html"}, + "a.html": {Blob: "a", CT: "text/html", Cache: "html"}, + }, + } + var prev string + for range 20 { + b, err := json.Marshal(m, json.Deterministic(true)) + if err != nil { + t.Fatal(err) + } + if prev == "" { + prev = string(b) + continue + } + if string(b) != prev { + t.Fatalf("non-deterministic marshal:\n %s\n %s", prev, b) + } + } + if !strings.Contains(prev, `"a.html"`) || !strings.Contains(prev, `"z.txt"`) { + t.Fatalf("missing files: %s", prev) + } + // Sorted map keys: a.html before z.txt. + if i, j := strings.Index(prev, `"a.html"`), strings.Index(prev, `"z.txt"`); i < 0 || j < 0 || i > j { + t.Fatalf("files keys not sorted: %s", prev) + } +} diff --git a/client/notification_stream.go b/client/notification_stream.go index 2d4a6ac..0f0635d 100644 --- a/client/notification_stream.go +++ b/client/notification_stream.go @@ -4,7 +4,7 @@ import ( "bufio" "bytes" "context" - "encoding/json" + "encoding/json/v2" "errors" "fmt" "io" @@ -79,7 +79,7 @@ func (c *Client) NotificationPullStream(ctx context.Context, m *api.Notification } var body bytes.Buffer - if err := json.NewEncoder(&body).Encode(m); err != nil { + if err := json.MarshalWrite(&body, m); err != nil { return err } diff --git a/client/site.go b/client/site.go index f8f9fb3..c1724ae 100644 --- a/client/site.go +++ b/client/site.go @@ -5,7 +5,8 @@ import ( "context" "crypto/sha256" "encoding/hex" - "encoding/json" + "encoding/json/jsontext" + "encoding/json/v2" "fmt" "io" "io/fs" @@ -88,8 +89,8 @@ func (r *SitePublishResult) Table() [][]string { // siteManifestEntry / siteManifest mirror the apiserver's release manifest JSON // shape. The release-sha the server content-addresses against is sha256 of the -// exact manifest bytes we PUT; encoding/json sorts map keys, so the bytes are -// deterministic for a given input. +// exact manifest bytes we PUT; Marshal uses json.Deterministic so map keys are +// sorted and the bytes are stable for a given input. type siteManifestEntry struct { Blob string `json:"blob"` CT string `json:"ct"` @@ -259,7 +260,7 @@ func (c *Client) PublishSite(ctx context.Context, opts *SitePublishOptions) (*Si SPA: opts.SPA, NotFound: opts.NotFound, Files: files, - }) + }, json.Deterministic(true)) if err != nil { return nil, fmt.Errorf("site: encode manifest: %w", err) } @@ -321,8 +322,8 @@ func (c *Client) siteDo(ctx context.Context, method, p string, q url.Values, bod // failures; other guard failures (bad session, sha mismatch) return a // plain-text body via http.Error. var env struct { - OK bool `json:"ok"` - Result json.RawMessage `json:"result"` + OK bool `json:"ok"` + Result jsontext.Value `json:"result"` Error struct { Message string `json:"message"` } `json:"error"` diff --git a/deployer.go b/deployer.go index 95aa1d9..7895cb2 100644 --- a/deployer.go +++ b/deployer.go @@ -22,26 +22,26 @@ type DeployerIsDomainActive struct { type GetCommandsResult []*DeployerCommandItem type DeployerCommandItem struct { - PullSecretCreate *DeployerCommandPullSecretCreate `json:"pullSecretCreate,omitempty"` - PullSecretDelete *DeployerCommandMetadata `json:"pullSecretDelete,omitempty"` - WorkloadIdentityCreate *DeployerCommandWorkloadIdentityCreate `json:"workloadIdentityCreate,omitempty"` - WorkloadIdentityDelete *DeployerCommandMetadata `json:"workloadIdentityDelete,omitempty"` - DiskCreate *DeployerCommandDiskCreate `json:"diskCreate,omitempty"` - DiskDelete *DeployerCommandMetadata `json:"diskDelete,omitempty"` - DeploymentDeploy *DeployerCommandDeploymentDeploy `json:"deploymentDeploy,omitempty"` - DeploymentDelete *DeployerCommandDeploymentMetadata `json:"deploymentDelete,omitempty"` - DeploymentPause *DeployerCommandDeploymentMetadata `json:"deploymentPause,omitempty"` - DeploymentCleanup *DeployerCommandDeploymentMetadata `json:"deploymentCleanup,omitempty"` - RouteCreate *DeployerCommandRouteCreate `json:"routeCreate,omitempty"` - RouteDelete *DeployerCommandRouteDelete `json:"routeDelete,omitempty"` - DomainCertCreate *DeployerCommandDomainCertCreate `json:"domainCertCreate,omitempty"` - DomainCertDelete *DeployerCommandDomainCertDelete `json:"domainCertDelete,omitempty"` - WAFSet *DeployerCommandWAFSet `json:"wafSet,omitempty"` - WAFDelete *DeployerCommandWAFDelete `json:"wafDelete,omitempty"` - CacheSet *DeployerCommandCacheSet `json:"cacheSet,omitempty"` - CacheDelete *DeployerCommandCacheDelete `json:"cacheDelete,omitempty"` - TransformSet *DeployerCommandTransformSet `json:"transformSet,omitempty"` - TransformDelete *DeployerCommandTransformDelete `json:"transformDelete,omitempty"` + PullSecretCreate *DeployerCommandPullSecretCreate `json:"pullSecretCreate,omitzero"` + PullSecretDelete *DeployerCommandMetadata `json:"pullSecretDelete,omitzero"` + WorkloadIdentityCreate *DeployerCommandWorkloadIdentityCreate `json:"workloadIdentityCreate,omitzero"` + WorkloadIdentityDelete *DeployerCommandMetadata `json:"workloadIdentityDelete,omitzero"` + DiskCreate *DeployerCommandDiskCreate `json:"diskCreate,omitzero"` + DiskDelete *DeployerCommandMetadata `json:"diskDelete,omitzero"` + DeploymentDeploy *DeployerCommandDeploymentDeploy `json:"deploymentDeploy,omitzero"` + DeploymentDelete *DeployerCommandDeploymentMetadata `json:"deploymentDelete,omitzero"` + DeploymentPause *DeployerCommandDeploymentMetadata `json:"deploymentPause,omitzero"` + DeploymentCleanup *DeployerCommandDeploymentMetadata `json:"deploymentCleanup,omitzero"` + RouteCreate *DeployerCommandRouteCreate `json:"routeCreate,omitzero"` + RouteDelete *DeployerCommandRouteDelete `json:"routeDelete,omitzero"` + DomainCertCreate *DeployerCommandDomainCertCreate `json:"domainCertCreate,omitzero"` + DomainCertDelete *DeployerCommandDomainCertDelete `json:"domainCertDelete,omitzero"` + WAFSet *DeployerCommandWAFSet `json:"wafSet,omitzero"` + WAFDelete *DeployerCommandWAFDelete `json:"wafDelete,omitzero"` + CacheSet *DeployerCommandCacheSet `json:"cacheSet,omitzero"` + CacheDelete *DeployerCommandCacheDelete `json:"cacheDelete,omitzero"` + TransformSet *DeployerCommandTransformSet `json:"transformSet,omitzero"` + TransformDelete *DeployerCommandTransformDelete `json:"transformDelete,omitzero"` } type DeployerCommandMetadata struct { @@ -302,26 +302,26 @@ type DeployerCommandTransformDelete struct { type DeployerSetResult []*DeployerSetResultItem type DeployerSetResultItem struct { - PullSecretCreate *DeployerSetResultItemGeneral `json:"pullSecretCreate,omitempty"` - PullSecretDelete *DeployerSetResultItemGeneral `json:"pullSecretDelete,omitempty"` - WorkloadIdentityCreate *DeployerSetResultItemGeneral `json:"workloadIdentityCreate,omitempty"` - WorkloadIdentityDelete *DeployerSetResultItemGeneral `json:"workloadIdentityDelete,omitempty"` - DiskCreate *DeployerSetResultItemGeneral `json:"diskCreate,omitempty"` - DiskDelete *DeployerSetResultItemGeneral `json:"diskDelete,omitempty"` - DeploymentDeploy *DeployerSetResultItemDeploy `json:"deploymentDeploy,omitempty"` - DeploymentDelete *DeployerSetResultItemGeneral `json:"deploymentDelete,omitempty"` - DeploymentPause *DeployerSetResultItemDeployment `json:"deploymentPause,omitempty"` - DeploymentCleanup *DeployerSetResultItemDeployment `json:"deploymentCleanup,omitempty"` - RouteCreate *DeployerSetResultItemGeneral `json:"routeCreate,omitempty"` - RouteDelete *DeployerSetResultItemGeneral `json:"routeDelete,omitempty"` - DomainCertCreate *DeployerSetResultItemDomainCert `json:"domainCertCreate,omitempty"` - DomainCertDelete *DeployerSetResultItemGeneral `json:"domainCertDelete,omitempty"` - WAFSet *DeployerSetResultItemGeneral `json:"wafSet,omitempty"` - WAFDelete *DeployerSetResultItemGeneral `json:"wafDelete,omitempty"` - CacheSet *DeployerSetResultItemGeneral `json:"cacheSet,omitempty"` - CacheDelete *DeployerSetResultItemGeneral `json:"cacheDelete,omitempty"` - TransformSet *DeployerSetResultItemGeneral `json:"transformSet,omitempty"` - TransformDelete *DeployerSetResultItemGeneral `json:"transformDelete,omitempty"` + PullSecretCreate *DeployerSetResultItemGeneral `json:"pullSecretCreate,omitzero"` + PullSecretDelete *DeployerSetResultItemGeneral `json:"pullSecretDelete,omitzero"` + WorkloadIdentityCreate *DeployerSetResultItemGeneral `json:"workloadIdentityCreate,omitzero"` + WorkloadIdentityDelete *DeployerSetResultItemGeneral `json:"workloadIdentityDelete,omitzero"` + DiskCreate *DeployerSetResultItemGeneral `json:"diskCreate,omitzero"` + DiskDelete *DeployerSetResultItemGeneral `json:"diskDelete,omitzero"` + DeploymentDeploy *DeployerSetResultItemDeploy `json:"deploymentDeploy,omitzero"` + DeploymentDelete *DeployerSetResultItemGeneral `json:"deploymentDelete,omitzero"` + DeploymentPause *DeployerSetResultItemDeployment `json:"deploymentPause,omitzero"` + DeploymentCleanup *DeployerSetResultItemDeployment `json:"deploymentCleanup,omitzero"` + RouteCreate *DeployerSetResultItemGeneral `json:"routeCreate,omitzero"` + RouteDelete *DeployerSetResultItemGeneral `json:"routeDelete,omitzero"` + DomainCertCreate *DeployerSetResultItemDomainCert `json:"domainCertCreate,omitzero"` + DomainCertDelete *DeployerSetResultItemGeneral `json:"domainCertDelete,omitzero"` + WAFSet *DeployerSetResultItemGeneral `json:"wafSet,omitzero"` + WAFDelete *DeployerSetResultItemGeneral `json:"wafDelete,omitzero"` + CacheSet *DeployerSetResultItemGeneral `json:"cacheSet,omitzero"` + CacheDelete *DeployerSetResultItemGeneral `json:"cacheDelete,omitzero"` + TransformSet *DeployerSetResultItemGeneral `json:"transformSet,omitzero"` + TransformDelete *DeployerSetResultItemGeneral `json:"transformDelete,omitzero"` } type DeployerSetResultItemGeneral struct { @@ -338,14 +338,14 @@ type DeployerSetResultItemGeneral struct { // historical behavior), so the gate is correct regardless of deploy order. type DeployerSetResultItemDomainCert struct { ID int64 `json:"id"` - Ready *bool `json:"ready,omitempty"` + Ready *bool `json:"ready,omitzero"` } type DeployerSetResultItemDeploy struct { ID int64 `json:"id"` Revision int64 `json:"revision"` Success bool `json:"success"` - NodePort *int `json:"nodePort,omitempty"` + NodePort *int `json:"nodePort,omitzero"` } type DeployerSetResultItemDeployment struct { diff --git a/deployment.go b/deployment.go index 74431e8..3505318 100644 --- a/deployment.go +++ b/deployment.go @@ -2,7 +2,7 @@ package api import ( "context" - "encoding/json" + "encoding/json/v2" "fmt" "path/filepath" "strconv" diff --git a/deploymentaction.go b/deploymentaction.go index ba29c2b..0b9d607 100644 --- a/deploymentaction.go +++ b/deploymentaction.go @@ -1,6 +1,6 @@ package api -import "encoding/json" +import "encoding/json/v2" //go:generate stringer -type=DeploymentAction -linecomment type DeploymentAction int diff --git a/domain.go b/domain.go index 1b2365d..44ea4d4 100644 --- a/domain.go +++ b/domain.go @@ -98,7 +98,7 @@ type DomainItem struct { // show how long a cert has been issuing and warn as it nears the reclaim // window. Cleared once the cert issues (created) or is torn down. CertStatus DomainCertStatus `json:"certStatus" yaml:"certStatus"` - CertPendingSince time.Time `json:"certPendingSince,omitempty" yaml:"certPendingSince,omitempty"` + CertPendingSince time.Time `json:"certPendingSince,omitzero" yaml:"certPendingSince,omitempty"` CreatedAt time.Time `json:"createdAt" yaml:"createdAt"` CreatedBy string `json:"createdBy" yaml:"createdBy"` } @@ -114,8 +114,8 @@ type DomainVerification struct { // location's load balancer (directly or via a proxy with a matching ownership // TXT). LastCheckedAt is the most recent attempt. type DomainVerificationDNS struct { - VerifiedAt time.Time `json:"verifiedAt,omitempty"` - LastCheckedAt time.Time `json:"lastCheckedAt,omitempty"` + VerifiedAt time.Time `json:"verifiedAt,omitzero"` + LastCheckedAt time.Time `json:"lastCheckedAt,omitzero"` Errors []string `json:"errors,omitempty"` } diff --git a/domaincertstatus.go b/domaincertstatus.go index c360333..148e7d0 100644 --- a/domaincertstatus.go +++ b/domaincertstatus.go @@ -1,6 +1,6 @@ package api -import "encoding/json" +import "encoding/json/v2" //go:generate stringer -type=DomainCertStatus -linecomment type DomainCertStatus int diff --git a/domainstatus.go b/domainstatus.go index cbd9146..d42325b 100644 --- a/domainstatus.go +++ b/domainstatus.go @@ -1,6 +1,6 @@ package api -import "encoding/json" +import "encoding/json/v2" //go:generate stringer -type=DomainStatus -linecomment type DomainStatus int diff --git a/email.go b/email.go index c58ae1f..5d5b48f 100644 --- a/email.go +++ b/email.go @@ -2,7 +2,7 @@ package api import ( "context" - "encoding/json" + "encoding/json/v2" "fmt" "strings" "time" diff --git a/errors.go b/errors.go index 9a2f4f4..a2954ba 100644 --- a/errors.go +++ b/errors.go @@ -1,7 +1,7 @@ package api import ( - "encoding/json" + "encoding/json/v2" "fmt" "slices" "strings" diff --git a/github.go b/github.go index 8ef3a5d..8a2fa64 100644 --- a/github.go +++ b/github.go @@ -293,7 +293,7 @@ type GitHubLinkItem struct { CreatedBy string `json:"createdBy" yaml:"createdBy"` // WorkflowConfig is the console's saved workflow-generator inputs for this // link, so the generator can pre-fill them. nil when never saved. - WorkflowConfig *GitHubWorkflowConfig `json:"workflowConfig,omitempty" yaml:"workflowConfig,omitempty"` + WorkflowConfig *GitHubWorkflowConfig `json:"workflowConfig,omitzero" yaml:"workflowConfig,omitempty"` } type GitHubListResult struct { diff --git a/githubtrigger.go b/githubtrigger.go index 187f1fe..9b274f2 100644 --- a/githubtrigger.go +++ b/githubtrigger.go @@ -1,7 +1,7 @@ package api import ( - "encoding/json" + "encoding/json/v2" "strconv" ) diff --git a/jsonv2_test.go b/jsonv2_test.go new file mode 100644 index 0000000..11760d9 --- /dev/null +++ b/jsonv2_test.go @@ -0,0 +1,100 @@ +package api + +import ( + json "encoding/json/v2" + "strings" + "testing" + "time" +) + +func TestLocationFeaturesJSON(t *testing.T) { + enabled := &struct{}{} + got, err := json.Marshal(LocationFeatures{ + Disk: enabled, + WAF: enabled, + Cache: enabled, + Transform: enabled, + }) + if err != nil { + t.Fatal(err) + } + // Presence of {} is the feature flag. omitempty under v2 would drop these + // because {} is an empty JSON value; omitzero keeps them. + want := `{"disk":{},"waf":{},"cache":{},"transform":{}}` + if string(got) != want { + t.Fatalf("enabled features = %s, want %s", got, want) + } + + got, err = json.Marshal(LocationFeatures{}) + if err != nil { + t.Fatal(err) + } + if string(got) != "{}" { + t.Fatalf("zero features = %s, want {}", got) + } +} + +func TestZeroTimeOmitted(t *testing.T) { + got, err := json.Marshal(DomainVerificationDNS{}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(got), "0001-01-01") { + t.Fatalf("zero times should be omitted, got %s", got) + } + + ts := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + got, err = json.Marshal(DomainVerificationDNS{VerifiedAt: ts}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), `"verifiedAt":"2026-08-01T00:00:00Z"`) { + t.Fatalf("non-zero verifiedAt missing: %s", got) + } + if strings.Contains(string(got), "lastCheckedAt") { + t.Fatalf("zero lastCheckedAt should be omitted, got %s", got) + } +} + +func TestStatusJSONRoundTrip(t *testing.T) { + b, err := json.Marshal(Success) + if err != nil { + t.Fatal(err) + } + if string(b) != `"success"` { + t.Fatalf("Marshal(Success) = %s", b) + } + var s Status + if err := json.Unmarshal([]byte(`"pending"`), &s); err != nil { + t.Fatal(err) + } + if s != Pending { + t.Fatalf("Unmarshal pending = %v", s) + } +} + +func TestNilSliceMarshalsEmptyArray(t *testing.T) { + got, err := json.Marshal(LocationListResult{}) + if err != nil { + t.Fatal(err) + } + if string(got) != `{"items":[]}` { + t.Fatalf("nil items = %s, want {\"items\":[]}", got) + } +} + +func TestEmailAddrUnmarshal(t *testing.T) { + var a EmailAddr + if err := json.Unmarshal([]byte(`"user@example.com"`), &a); err != nil { + t.Fatal(err) + } + if a.Email != "user@example.com" || a.Name != "" { + t.Fatalf("string form = %+v", a) + } + if err := json.Unmarshal([]byte(`{"email":"a@b.c","name":"Ada"}`), &a); err != nil { + t.Fatal(err) + } + if a.Email != "a@b.c" || a.Name != "Ada" { + t.Fatalf("object form = %+v", a) + } +} diff --git a/location.go b/location.go index 831e1e3..8492836 100644 --- a/location.go +++ b/location.go @@ -63,14 +63,14 @@ func (m *LocationItem) Table() [][]string { } type LocationFeatures struct { - WorkloadIdentity bool `json:"workloadIdentity,omitempty" yaml:"workloadIdentity"` - Disk *struct{} `json:"disk,omitempty" yaml:"disk"` - WAF *struct{} `json:"waf,omitempty" yaml:"waf"` + WorkloadIdentity bool `json:"workloadIdentity,omitzero" yaml:"workloadIdentity"` + Disk *struct{} `json:"disk,omitzero" yaml:"disk"` + WAF *struct{} `json:"waf,omitzero" yaml:"waf"` // Cache gates the edge cache-override feature (cache.* RPCs). It is EDGE-only // and independent of WAF: enable it only for locations whose edge control // plane runs CP_CACHE_ENABLED (the apiserver cannot verify edge readiness, so // the flag is the human contract that the edge is watching cache ConfigMaps). - Cache *struct{} `json:"cache,omitempty" yaml:"cache"` + Cache *struct{} `json:"cache,omitzero" yaml:"cache"` // Transform gates the declarative request/response transform feature // (transform.* RPCs). v1 runs IN-CLUSTER (the in-cluster parapet-ingress- // controller's TransformZone plugin), independent of WAF/Cache: enable it only @@ -78,7 +78,7 @@ type LocationFeatures struct { // apiserver cannot verify plugin readiness, so the flag is the human contract // that the controller is watching transform ConfigMaps). Enabling it before // the plugin is live makes a transform.set a silent bound-but-unconsumed no-op. - Transform *struct{} `json:"transform,omitempty" yaml:"transform"` + Transform *struct{} `json:"transform,omitzero" yaml:"transform"` } type LocationGet struct { diff --git a/notification.go b/notification.go index 36d8de5..af97ff2 100644 --- a/notification.go +++ b/notification.go @@ -72,11 +72,11 @@ type Notification interface { // PullTTLSeconds sets how long the channel survives without a Pull before it is // auto-deleted (0 = server default). PullTTLSeconds is ignored for push channels. type NotificationConfig struct { - Type string `json:"type" yaml:"type"` // webhook|discord|pull - URL string `json:"url" yaml:"url"` // delivery target (empty for pull; on Update empty keeps stored; Discord token redacted in responses) - Secret string `json:"secret,omitempty" yaml:"secret,omitempty"` // write-only signing key - InsecureSkipVerify bool `json:"insecureSkipVerify" yaml:"insecureSkipVerify"` // skip TLS verify - PullTTLSeconds int `json:"pullTtlSeconds,omitempty" yaml:"pullTtlSeconds,omitempty"` // pull only; 0 = server default + Type string `json:"type" yaml:"type"` // webhook|discord|pull + URL string `json:"url" yaml:"url"` // delivery target (empty for pull; on Update empty keeps stored; Discord token redacted in responses) + Secret string `json:"secret,omitempty" yaml:"secret,omitempty"` // write-only signing key + InsecureSkipVerify bool `json:"insecureSkipVerify" yaml:"insecureSkipVerify"` // skip TLS verify + PullTTLSeconds int `json:"pullTtlSeconds,omitzero" yaml:"pullTtlSeconds,omitempty"` // pull only; 0 = server default } // NotificationSubscription filters which changes a channel receives. A change is diff --git a/notificationchanneltype.go b/notificationchanneltype.go index 9b27053..3b417ec 100644 --- a/notificationchanneltype.go +++ b/notificationchanneltype.go @@ -1,6 +1,6 @@ package api -import "encoding/json" +import "encoding/json/v2" //go:generate stringer -type=NotificationChannelType -linecomment type NotificationChannelType int diff --git a/registry.go b/registry.go index b9ac8e5..4839981 100644 --- a/registry.go +++ b/registry.go @@ -141,7 +141,7 @@ func (m *RegistryGetProjectStorage) Valid() error { type RegistryProjectStorage struct { Size int64 `json:"size" yaml:"size"` - UpdatedAt *time.Time `json:"updatedAt,omitempty" yaml:"updatedAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitzero" yaml:"updatedAt,omitempty"` } type RegistryDelete struct { diff --git a/status.go b/status.go index f8986fe..890e7ff 100644 --- a/status.go +++ b/status.go @@ -1,7 +1,7 @@ package api import ( - "encoding/json" + "encoding/json/v2" ) type Status int diff --git a/transformphase.go b/transformphase.go index d384b4b..457e349 100644 --- a/transformphase.go +++ b/transformphase.go @@ -1,7 +1,7 @@ package api import ( - "encoding/json" + "encoding/json/v2" "fmt" ) diff --git a/validate.go b/validate.go index 5851428..2983197 100644 --- a/validate.go +++ b/validate.go @@ -1,7 +1,7 @@ package api import ( - "encoding/json" + "encoding/json/v2" "errors" "net" "regexp" diff --git a/wafaction.go b/wafaction.go index b51f817..9d81838 100644 --- a/wafaction.go +++ b/wafaction.go @@ -1,6 +1,6 @@ package api -import "encoding/json" +import "encoding/json/v2" //go:generate stringer -type=WAFAction -linecomment type WAFAction int