Go: keep empty lists as [] through decode, re-marshal and requests

Merged
JE
7d ago
+513-20

What

  • Generated Go types: the Go overlay (go/oapi-overlay.yaml) tags every list property x-omitempty: false + x-omitzero: true, so list fields in pkg/generated are tagged omitzero instead of omitempty. Types regenerated with go generate; no hand edits.
  • Client.GetAll / GetAllWithLimit: start from an empty slice instead of nil.
  • Tests (go/pkg/fizzy/json_shape_test.go):
    • round-trip sweep over every generated type with a list field: [] decodes non-nil and re-marshals as [], {} stays absent. A coverage check against the generated source fails if a new list type isn't in the sweep.
    • UpdateBoard with UserIds: []string{} sends "user_ids": [].
    • GetAll returns [] for a server-sent [].
    • go/ast check: every exported field of every exported struct (generated and hand-written) has a snake_case json tag. Non-data types are allowlisted with a reason; Content_Type/Content_MD5 are kept because that's how Fizzy sends them.

Why

prefer-skip-optional-pointer turns optional lists into plain slices tagged omitempty, and omitempty drops an empty non-nil slice. So:

  • a Card the server sent with "tags": [] (also assignees, steps, Board.user_ids, …) lost the key when re-marshaled, which reads as "unknown" rather than "none";
  • a caller couldn't clear a list. UpdateBoardRequest.UserIds, Create/UpdateWebhookRequest.SubscribedActions and BulkReadNotificationsRequest.NotificationIds set to [] never reached the server.

GetAll built its result by appending to a nil slice, so an empty list came back nil and marshaled as null.

Evidence

Before (tests run against the old types and client): 15 round-trip assertion failures across the 12 list-bearing types, body user_ids = "", want [], and GetAll returned nil for a server-sent []. After: all pass, and Go conformance passes 134/134.

Compat

Field types don't change. The only output change: an empty list now marshals as [] where it used to be omitted. A nil list is still omitted. Required lists (BoardAccesses.users, Identity.accounts, Webhook.subscribed_actions) also pick up omitzero, because the overlay filter can't see the parent's required set. A nil one now marshals as absent instead of null. The server always sends these lists, so a decoded value is never nil.

This needs encoding/json omitzero, which arrived in Go 1.24. The module already requires a newer Go than that.

Other languages: surveyed, not changed

  • TypeScript and Ruby pass the server's JSON through as-is, so [] and the keys survive.
  • Rust wraps optional lists in Option<Vec<_>> with skip_serializing_if = "Option::is_none", so Some([]) serializes as [], and serde(rename) is emitted wherever the field name differs from the wire key.
  • Swift uses encodeIfPresent, which keeps []; BaseService.encoder writes snake_case keys.
  • Kotlin: request bodies send [], and responses decode [] as an empty list. Every response field the model writes has a snake_case @SerialName.

Conformance: four new request-shapes cases send an empty list on UpdateBoard (user_ids), CreateWebhook and UpdateWebhook (subscribed_actions) and BulkReadNotifications (notification_ids), and assert the key reaches the wire. The Go runner used to send every fixture body as a raw map. For these four operations it now decodes the body into the generated request type and sends that, so the cases go through the json tags. Against the old omitempty types, all four fail. The TypeScript runner maps these fixture bodies to the SDK's camelCase fields, and the Kotlin runner passes user_ids through.