Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core/build_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package core

import (
"context"
"encoding/json"
"encoding/json/v2"
"fmt"
"io"
"net/http"
Expand Down
67 changes: 52 additions & 15 deletions core/error.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package core

import (
"encoding/json"
"encoding/json/jsontext"
"encoding/json/v2"
"fmt"
"strings"

Expand Down Expand Up @@ -70,29 +71,65 @@ func (s *CliError) MarshalHuman() (string, error) {
return strings.Join(sections, "\n\n"), nil
}

// jsonError returns the JSON representation of e, reproducing the encoding/json v1
// behavior: an error is marshaled through its exported fields or MarshalJSON, and an
// error with no exported fields (e.g. errors.errorString) marshals to {}.
func jsonError(e error) string {
b, err := json.Marshal(e, jsontext.EscapeForHTML(true))
if err != nil {
return "{}"
}

return string(b)
}

// jsonQuoted returns s as a JSON string literal.
func jsonQuoted(s string) (string, error) {
b, err := json.Marshal(s, jsontext.EscapeForHTML(true))

return string(b), err
}

func (s *CliError) MarshalJSON() ([]byte, error) {
if s.Empty {
type emptyRes struct{}

return json.Marshal(&emptyRes{})
return []byte("{}"), nil
}

message := s.Message
if message == "" && s.Err != nil {
message = s.Err.Error()
}

type tmpRes struct {
Message string `json:"message,omitempty"`
Error error `json:"error,omitempty"`
Details string `json:"details,omitempty"`
Hint string `json:"hint,omitempty"`
// encoding/json v2 refuses to marshal errors with no exported fields and its
// omitempty drops them, so the fields are assembled by hand to keep the v1 wire format.
parts := make([]string, 0, 4)
if message != "" {
q, err := jsonQuoted(message)
if err != nil {
return nil, err
}

parts = append(parts, `"message":`+q)
}
if s.Err != nil {
parts = append(parts, `"error":`+jsonError(s.Err))
}
if s.Details != "" {
q, err := jsonQuoted(s.Details)
if err != nil {
return nil, err
}

parts = append(parts, `"details":`+q)
}
if s.Hint != "" {
q, err := jsonQuoted(s.Hint)
if err != nil {
return nil, err
}

parts = append(parts, `"hint":`+q)
}

return json.Marshal(&tmpRes{
Message: message,
Error: s.Err,
Details: s.Details,
Hint: s.Hint,
})
return []byte("{" + strings.Join(parts, ",") + "}"), nil
}
2 changes: 1 addition & 1 deletion core/example.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package core

import (
"encoding/json"
"encoding/json/v2"
"fmt"
"strings"

Expand Down
2 changes: 1 addition & 1 deletion core/human/marshal_func.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package human

import (
"encoding/json"
"encoding/json/v2"
"fmt"
"net"
"reflect"
Expand Down
73 changes: 69 additions & 4 deletions core/printer.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package core

import (
"encoding/json"
"encoding"
jsonv1 "encoding/json"
"encoding/json/jsontext"
"encoding/json/v2"
"errors"
"fmt"
"io"
Expand All @@ -14,6 +17,12 @@ import (
"github.com/scaleway/scaleway-cli/v2/internal/gofields"
)

// OmitEmptyLegacy is an encoding/json/v2 option that preserves the legacy
// (v1) omitempty semantics: a non-nil pointer to an empty struct is not
// considered empty. This keeps SDK union markers such as
// "load_balancer": {} or "public_network": {} in the JSON output.
var OmitEmptyLegacy = jsonv1.OmitEmptyWithLegacySemantics(true)

// Type defines an formatter format.
type PrinterType string

Expand Down Expand Up @@ -284,9 +293,15 @@ func (p *Printer) printJSON(data any) error {
if isError {
writer = p.stderr
}
encoder := json.NewEncoder(writer)
opts := []json.Options{
json.Deterministic(true),
json.FormatNilSliceAsNull(true),
json.FormatNilMapAsNull(true),
jsontext.EscapeForHTML(true),
Comment on lines +296 to +300
OmitEmptyLegacy,
}
if p.jsonPretty {
encoder.SetIndent("", " ")
opts = append(opts, jsontext.WithIndent(" "))
}

// We handle special case to make sure that a nil slice is marshal as `[]`
Expand All @@ -296,7 +311,57 @@ func (p *Printer) printJSON(data any) error {
return err
}

return encoder.Encode(data)
// encoding/json v2 refuses to marshal structs with no exported fields
// (e.g. *bytes.Buffer), where v1 encoded them as {}.
if noExportedFields(data) {
_, err := writer.Write([]byte("{}\n"))
Comment on lines +316 to +317

return err
}

if err := json.MarshalWrite(writer, data, opts...); err != nil {
return err
}
_, err = io.WriteString(writer, "\n")

return err
}

var (
marshalJSONType = reflect.TypeOf((*json.Marshaler)(nil)).Elem()
marshalTextType = reflect.TypeOf((*encoding.TextMarshaler)(nil)).Elem()
)

// noExportedFields reports whether v (or the value it points to) is a struct
// with no exported fields and no JSON or text marshaling hook.
func noExportedFields(v any) bool {
rv := reflect.ValueOf(v)
for rv.Kind() == reflect.Pointer || rv.Kind() == reflect.Interface {
if rv.IsNil() {
return false
}
rv = rv.Elem()
}
if rv.Kind() != reflect.Struct {
return false
}
t := rv.Type()
for f := range t.Fields() {
if f.PkgPath == "" {
return false
}
}

// encoding/json v2 can still marshal such a struct when a JSON or text
// marshaling hook is available (e.g. time.Time, netip.Addr),
// so only fall back to {} when no hook exists.
if t.Implements(marshalJSONType) || t.Implements(marshalTextType) ||
reflect.PointerTo(t).Implements(marshalJSONType) ||
reflect.PointerTo(t).Implements(marshalTextType) {
return false
}

return true
}

func (p *Printer) printYAML(data any) error {
Expand Down
66 changes: 66 additions & 0 deletions core/printer_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package core_test

import (
"bytes"
"context"
"net/netip"
"reflect"
"testing"
"time"

"github.com/scaleway/scaleway-cli/v2/core"
)
Expand Down Expand Up @@ -240,3 +243,66 @@ func Test_TemplatePrinter(t *testing.T) {
Check: core.TestCheckGolden(),
}))
}

func Test_JSONPrinter(t *testing.T) {
type Empty struct{}
type Endpoint struct {
ID string `json:"id"`
LoadBalancer *Empty `json:"load_balancer,omitempty"`
}

commands := core.NewCommands(
&core.Command{
Namespace: "time",
ArgsType: reflect.TypeOf(struct{}{}),
Run: func(_ context.Context, _ any) (any, error) {
return time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC), nil
},
},
&core.Command{
Namespace: "addr",
ArgsType: reflect.TypeOf(struct{}{}),
Run: func(_ context.Context, _ any) (any, error) {
return netip.MustParseAddr("127.0.0.1"), nil
},
},
&core.Command{
Namespace: "buffer",
ArgsType: reflect.TypeOf(struct{}{}),
Run: func(_ context.Context, _ any) (any, error) {
return &bytes.Buffer{}, nil
},
},
&core.Command{
Namespace: "endpoint",
ArgsType: reflect.TypeOf(struct{}{}),
Run: func(_ context.Context, _ any) (any, error) {
return Endpoint{ID: "111111111", LoadBalancer: &Empty{}}, nil
},
},
)

t.Run("time", core.Test(&core.TestConfig{
Commands: commands,
Cmd: "scw time -o json",
Check: core.TestCheckGolden(),
}))

t.Run("addr", core.Test(&core.TestConfig{
Commands: commands,
Cmd: "scw addr -o json",
Check: core.TestCheckGolden(),
}))

t.Run("buffer", core.Test(&core.TestConfig{
Commands: commands,
Cmd: "scw buffer -o json",
Check: core.TestCheckGolden(),
}))

t.Run("union-marker", core.Test(&core.TestConfig{
Commands: commands,
Cmd: "scw endpoint -o json",
Check: core.TestCheckGolden(),
}))
}
2 changes: 1 addition & 1 deletion core/result.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package core

import (
"encoding/json"
"encoding/json/v2"
"fmt"
"strings"

Expand Down
3 changes: 3 additions & 0 deletions core/testdata/test-json-printer-addr.cassette.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions core/testdata/test-json-printer-addr.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲
🟩🟩🟩 STDOUT️ 🟩🟩🟩️
"127.0.0.1"
🟩🟩🟩 JSON STDOUT 🟩🟩🟩
"127.0.0.1"
3 changes: 3 additions & 0 deletions core/testdata/test-json-printer-buffer.cassette.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions core/testdata/test-json-printer-buffer.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲
🟩🟩🟩 STDOUT️ 🟩🟩🟩️
{}
🟩🟩🟩 JSON STDOUT 🟩🟩🟩
{}
3 changes: 3 additions & 0 deletions core/testdata/test-json-printer-time.cassette.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions core/testdata/test-json-printer-time.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲
🟩🟩🟩 STDOUT️ 🟩🟩🟩️
"2026-11-03T12:00:00Z"
🟩🟩🟩 JSON STDOUT 🟩🟩🟩
"2026-11-03T12:00:00Z"
3 changes: 3 additions & 0 deletions core/testdata/test-json-printer-union-marker.cassette.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions core/testdata/test-json-printer-union-marker.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲
🟩🟩🟩 STDOUT️ 🟩🟩🟩️
{"id":"111111111","load_balancer":{}}
🟩🟩🟩 JSON STDOUT 🟩🟩🟩
{
"id": "111111111",
"load_balancer": {}
}
11 changes: 9 additions & 2 deletions core/testing.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package core

import (
"bytes"
"encoding/json"
"encoding/json/v2"
"flag"
"fmt"
"io"
Expand Down Expand Up @@ -326,7 +326,6 @@ var foldersUsingVCRv4 = []string{
"internal/namespaces/flexibleip/v1alpha1",
"internal/namespaces/iam/v1alpha1",
"internal/namespaces/info",
"internal/namespaces/init",
"internal/namespaces/instance/v1",
"internal/namespaces/instance/v2alpha1",
"internal/namespaces/iot/v1",
Expand Down Expand Up @@ -436,6 +435,13 @@ func Test(config *TestConfig) func(t *testing.T) {
overrideEnv = map[string]string{}
}

// Scrub ambient KUBECONFIG so tests relying on the default $HOME/.kube/config
// path are not affected by the host environment. Tests that need it set it
// explicitly via TestConfig.OverrideEnv.
if _, ok := overrideEnv["KUBECONFIG"]; !ok {
overrideEnv["KUBECONFIG"] = ""
}

if config.TmpHomeDir {
dir := t.TempDir()
overrideEnv["HOME"] = dir
Expand Down Expand Up @@ -1009,6 +1015,7 @@ func marshalGolden(t *testing.T, ctx *CheckFuncCtx) string {
str := buffer.String()
// In order to avoid diff in goldens we set all timestamp to the same date
str = uniformTimestamps(str)
str = uniformTimestampsWithOffSet(str)
// Replace Windows return carriage.
str = strings.ReplaceAll(str, "\r", "")

Expand Down
Loading