Skip to content

fix(json): emit an empty list rather than null when there is nothing to list (#1389) - #1892

Open
sujeito-operator wants to merge 1 commit into
fastly:mainfrom
sujeito-operator:fix/1389-json-empty-list
Open

fix(json): emit an empty list rather than null when there is nothing to list (#1389)#1892
sujeito-operator wants to merge 1 commit into
fastly:mainfrom
sujeito-operator:fix/1389-json-empty-list

Conversation

@sujeito-operator

Copy link
Copy Markdown

Closes #1389.

The defect

On main @406aae3, an account with no secret stores:

fastly secret-store list --quiet --json
  -> null

fastly config-store list --quiet --json
  -> []

Same question, two answers, so a caller cannot treat --json output as a list without
special-casing the empty account.

Why the fix is not in secretstore

secret-store list accumulates into var data []fastly.SecretStore and hands that to
(*JSONOutput).WriteJSON. On an empty account nothing is ever appended, data is still
nil, and encoding/json encodes a nil slice as null.

Nothing there is specific to secret stores. WriteJSON is the single encoder every
--json command in this CLI goes through -- 366 call sites under pkg/ -- so
the same output is one var data []T away in any of them. Changing the declaration in
secretstore/list.go would close this ticket and leave the class open, so the change is at
the choke point: a nil slice encodes as [], a nil map as {{}}.

What it deliberately does not change

Only the value's own nil-ness is considered. Everything below is absent rather than
empty, and null is the honest encoding of absent:

value before after
[]string(nil) null []
map[string]int(nil) null {{}}
(*T)(nil) null null
nil interface null null
[]byte(nil) null null
struct{{ Items []string }}{{}} {{"items": null}} {{"items": null}}

[]byte is the one that needed a decision: it encodes as a base64 string rather than a
list, so emptying it would trade null for "", and neither of those is an empty list.
It is excluded explicitly and there is a test pinning that.

Also unchanged, because I read it as a different question from the one in this ticket:
kv-store list --json and object-store list --json still print the API's
{{"Data": [...], "Meta": {{...}}}} envelope rather than a bare list. That is the second
inconsistency named in the thread, and it is a change to what those two commands pass to
WriteJSON rather than to WriteJSON itself. Happy to send it as a separate PR if you
want it.

Tests

pkg/argparser/flags_test.go gains a table of 11 cases over WriteJSON. 3 of them fail
on unmodified flags.go
-- proved by git stash push pkg/argparser/flags.go and
re-running, not assumed:

--- FAIL: TestJSONOutputWriteJSON/nil_slice_is_an_empty_list,_not_null
    flags_test.go:531: wanted "[]\n", got "null\n"
--- FAIL: TestJSONOutputWriteJSON/nil_slice_of_structs_is_an_empty_list,_not_null
--- FAIL: TestJSONOutputWriteJSON/nil_map_is_an_empty_object,_not_null

The other 8 pass on both sides on purpose -- they are the boundary rows in the table above,
and a guard that passes either way is what shows the change is scoped.

pkg/commands/secretstore/secretstore_test.go gains the end-to-end case the issue reports,
by both routes that reach it: the API returns an empty Data, and the API returns no
response body at all. Both fail on pristine with wanted "[]\n", got "null\n".

What was run, on go1.26.6 / linux amd64

pristine main @406aae3 with this patch
go test ./... 142 packages ok, 0 failed 142 packages ok, 0 failed

go build ./... rc=0. gofmt -l pkg cmd empty. go vet ./pkg/argparser/... ./pkg/commands/secretstore/... clean. golangci-lint run at the pinned v2.4.0: 0 issues.
go mod tidy leaves go.mod and go.sum byte-identical. CHANGELOG.md has a
Commitizen-style entry under Bug Fixes.

Two things make all does that I did not run, so they are not being claimed:
make semgrep (semgrep is not installed on the machine this was written on) and
make install. -race was not run either -- the race detector needs cgo and this
machine has no C compiler. That is not a gap against your CI: make test here is
go test -v -timeout 15m ./... with no -race, so the run above is the same run.

AI-assisted

This patch was written by an autonomous agent. Every number above is a run on the machine
that wrote it rather than an inference: the baseline was run on pristine main first, the
3-of-11 and 2-of-2 failing counts were produced by stashing pkg/argparser/flags.go and
re-running, and the boundary table is the test table.

…to list

`secret-store list --json` printed `null` on an account with no secret
stores while `config-store list --json` printed `[]` for the same
situation, so `--json` output could not be treated as a list without
special-casing the empty account.

The cause is not in secretstore: commands accumulate into `var data []T`
and hand that to (*JSONOutput).WriteJSON, and encoding/json writes a nil
slice as null. WriteJSON is the one encoder every --json command goes
through, so the same output is one `var data []T` away in any of them.
Fixing the declaration in secretstore/list.go would close the ticket and
leave the class open; this normalises at the choke point instead.

Only the value's own nil-ness is considered. A nil pointer, a nil
interface and nil fields inside a struct still encode as null, because
they are absent rather than empty. A nil []byte is excluded too: it
encodes as a base64 string, so emptying it would trade null for "",
and neither is an empty list.

11 table cases over WriteJSON, 3 of which fail on the unmodified file,
plus the end-to-end secret-store list --json case by both routes that
reach it. Closes fastly#1389.
@sujeito-operator
sujeito-operator requested a review from a team as a code owner August 20, 2026 15:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE REQUEST] Secret Store list command to produce empty array instead of null for json output

1 participant