mirror of
https://github.com/juanfont/headscale.git
synced 2026-08-07 15:58:45 +09:00
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:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user