cmd/headscale: fix JSON output of value-typed API objects

Generated API types have a pointer-receiver MarshalJSON that omits unset
optional fields. The CLI marshalled them by value, so stdlib reflection
called Opt*.MarshalJSON directly and failed with "unexpected end of JSON
input", breaking every -o json/-o yaml command. Make values addressable
first so the generated marshaler runs.
This commit is contained in:
Kristoffer Dalby
2026-06-17 20:31:45 +00:00
parent 5176dd23a9
commit 7526401656
2 changed files with 77 additions and 0 deletions
+29
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"reflect"
"slices"
"github.com/juanfont/headscale/hscontrol"
@@ -58,9 +59,37 @@ func newHeadscaleServerWithConfig() (*hscontrol.Headscale, error) {
return app, nil
}
// addressableForJSON makes value-typed generated API structs (and slice
// elements) addressable so their pointer-receiver MarshalJSON is used. That
// method omits unset optional fields; stdlib reflection over a value instead
// calls Opt*.MarshalJSON directly, which returns empty for an unset field and
// fails with "unexpected end of JSON input".
func addressableForJSON(v any) any {
rv := reflect.ValueOf(v)
switch rv.Kind() { //nolint:exhaustive // default handles all other kinds
case reflect.Slice, reflect.Array:
out := make([]any, rv.Len())
for i := range out {
out[i] = addressableForJSON(rv.Index(i).Interface())
}
return out
case reflect.Struct:
p := reflect.New(rv.Type())
p.Elem().Set(rv)
return p.Interface()
default:
return v
}
}
// formatOutput serialises result into the requested format. For the
// default (empty) format the human-readable override string is returned.
func formatOutput(result any, override string, outputFormat string) (string, error) {
result = addressableForJSON(result)
switch outputFormat {
case outputFormatJSON:
b, err := json.MarshalIndent(result, "", "\t")
+48
View File
@@ -0,0 +1,48 @@
package cli
import (
"encoding/json"
"strings"
"testing"
apiv1 "github.com/juanfont/headscale/gen/api/v1"
)
// TestFormatOutputValueWithUnsetOptional guards against a regression where the
// generated API types, passed by value with unset optional fields, fail to
// marshal because stdlib reflection calls Opt*.MarshalJSON directly.
func TestFormatOutputValueWithUnsetOptional(t *testing.T) {
// DisplayName, Email, CreatedAt and PictureURL are intentionally unset.
user := apiv1.User{
ID: apiv1.NewOptUint64(1),
Name: apiv1.NewOptString("alice"),
}
for _, format := range []string{outputFormatJSON, outputFormatJSONLine, outputFormatYAML} {
out, err := formatOutput(user, "", format)
if err != nil {
t.Fatalf("formatOutput(%s) on value type: %v", format, err)
}
if !strings.Contains(out, "alice") {
t.Errorf("formatOutput(%s) missing name: %q", format, out)
}
}
// A slice of value types must work too (e.g. "users list").
listOut, err := formatOutput([]apiv1.User{user}, "", outputFormatJSON)
if err != nil {
t.Fatalf("formatOutput(json) on slice of value type: %v", err)
}
var decoded []map[string]any
err = json.Unmarshal([]byte(listOut), &decoded)
if err != nil {
t.Fatalf("list output is not valid JSON: %v\n%s", err, listOut)
}
if len(decoded) != 1 || decoded[0]["name"] != "alice" {
t.Errorf("unexpected list output: %s", listOut)
}
}