diff --git a/binary.go b/binary.go
index 5fd3797..ac6a180 100644
--- a/binary.go
+++ b/binary.go
@@ -10,7 +10,10 @@ import (
// interface.
type Binary struct{}
-var _ FormatRenderer = (*Binary)(nil)
+var (
+ _ Handler = (*Binary)(nil)
+ _ FormatsHandler = (*Binary)(nil)
+)
// Render writes result of calling MarshalBinary() on v. If v does not implment
// encoding.BinaryMarshaler the ErrCannotRander error will be returned.
@@ -33,6 +36,7 @@ func (br *Binary) Render(w io.Writer, v any) error {
return nil
}
+// Formats returns a list of format strings that this Handler supports.
func (br *Binary) Formats() []string {
return []string{"binary", "bin"}
}
diff --git a/binary_test.go b/binary_test.go
index f3be967..83974b0 100644
--- a/binary_test.go
+++ b/binary_test.go
@@ -78,3 +78,9 @@ func TestBinary_Render(t *testing.T) {
})
}
}
+
+func TestBinary_Formats(t *testing.T) {
+ h := &Binary{}
+
+ assert.Equal(t, []string{"binary", "bin"}, h.Formats())
+}
diff --git a/interfaces.go b/interfaces.go
new file mode 100644
index 0000000..7e51669
--- /dev/null
+++ b/interfaces.go
@@ -0,0 +1,41 @@
+package render
+
+import "io"
+
+// Handler interface is for single format renderers, which can only render a
+// single format. It is the basis of the multi-format support offerred by the
+// render package.
+type Handler interface {
+ // Render writes v into w in the format that the Handler supports.
+ //
+ // If v does not implement a required interface, or otherwise cannot be
+ // rendered to the format in question, then a ErrCannotRender error must be
+ // returned. Any other errors should be returned as is.
+ Render(w io.Writer, v any) error
+}
+
+// PrettyHandler interface is a optional interface that can be implemented by
+// Handler implementations to render a value in a pretty way. This is
+// useful for formats that support pretty printing, like in the case of JSON and
+// XML.
+type PrettyHandler interface {
+ // RenderPretty writes v into w in the format that the Handler supports,
+ // using a pretty variant of the format. The exact definition of "pretty" is
+ // up to the handler. Typically this would be mean adding line breaks and
+ // indentation, like in the case of JSON and XML.
+ //
+ // If v does not implement a required interface, or otherwise cannot be
+ // rendered to the format in question, then a ErrCannotRender error must be
+ // returned. Any other errors should be returned as is.
+ RenderPretty(w io.Writer, v any) error
+}
+
+// FormatsHandler is an optional interface that can be implemented by Handler
+// implementations to return a list of formats that the handler supports. This
+// is used by the New function to allow format aliases like "yml" for "yaml".
+type FormatsHandler interface {
+ // Formats returns a list of strings which all target the same format. In
+ // most cases this would just be a single value, but multiple values are
+ // supported for the sake of aliases, like "yaml" and "yml".
+ Formats() []string
+}
diff --git a/json.go b/json.go
index ad3eccd..295f111 100644
--- a/json.go
+++ b/json.go
@@ -6,36 +6,49 @@ import (
"io"
)
-// JSON is a renderer that marshals values to JSON.
-type JSON struct {
- // Pretty specifies whether the output should be pretty-printed. If true,
- // the output will be indented and newlines will be added.
- Pretty bool
+// JSONDefualtIndent is the default indentation string used by JSON instances
+// when pretty rendering if no Indent value is set on the JSON instance itself.
+var JSONDefualtIndent = " "
- // Prefix is the prefix added to each level of indentation when Pretty is
- // true.
+// JSON is a Handler that marshals values to JSON.
+type JSON struct {
+ // Prefix is the prefix added to each level of indentation when pretty
+ // rendering.
Prefix string
- // Indent is the string added to each level of indentation when Pretty is
- // true. If empty, two spaces will be used instead.
+ // Indent is the string added to each level of indentation when pretty
+ // rendering. If empty, two spaces will be used instead.
Indent string
}
-var _ FormatRenderer = (*JSON)(nil)
+var (
+ _ Handler = (*JSON)(nil)
+ _ PrettyHandler = (*JSON)(nil)
+ _ FormatsHandler = (*JSON)(nil)
+)
// Render marshals the given value to JSON.
func (jr *JSON) Render(w io.Writer, v any) error {
- enc := json.NewEncoder(w)
- if jr.Pretty {
- prefix := jr.Prefix
- indent := jr.Indent
- if indent == "" {
- indent = " "
- }
-
- enc.SetIndent(prefix, indent)
+ err := json.NewEncoder(w).Encode(v)
+ if err != nil {
+ return fmt.Errorf("%w: %w", ErrFailed, err)
}
+ return nil
+}
+
+// RenderPretty marshals the given value to JSON with line breaks and
+// indentation.
+func (jr *JSON) RenderPretty(w io.Writer, v any) error {
+ prefix := jr.Prefix
+ indent := jr.Indent
+ if indent == "" {
+ indent = JSONDefualtIndent
+ }
+
+ enc := json.NewEncoder(w)
+ enc.SetIndent(prefix, indent)
+
err := enc.Encode(v)
if err != nil {
return fmt.Errorf("%w: %w", ErrFailed, err)
@@ -44,6 +57,7 @@ func (jr *JSON) Render(w io.Writer, v any) error {
return nil
}
+// Formats returns a list of format strings that this Handler supports.
func (jr *JSON) Formats() []string {
return []string{"json"}
}
diff --git a/json_test.go b/json_test.go
index 9bba39b..2ef29de 100644
--- a/json_test.go
+++ b/json_test.go
@@ -23,38 +23,22 @@ func (mjm *mockJSONMarshaler) MarshalJSON() ([]byte, error) {
func TestJSON_Render(t *testing.T) {
tests := []struct {
- name string
- pretty bool
- prefix string
- indent string
- value interface{}
- want string
- wantErr string
- wantErrIs []error
+ name string
+ prefix string
+ indent string
+ value any
+ want string
+ wantPretty string
+ wantErr string
+ wantErrIs []error
}{
{
- name: "simple object without pretty",
- pretty: false,
- value: map[string]int{"age": 30},
- want: "{\"age\":30}\n",
+ name: "simple object",
+ value: map[string]int{"age": 30},
+ want: "{\"age\":30}\n",
},
{
- name: "simple object with pretty",
- pretty: true,
- value: map[string]int{"age": 30},
- want: "{\n \"age\": 30\n}\n",
- },
- {
- name: "pretty with prefix and indent",
- pretty: true,
- prefix: "// ",
- indent: "\t",
- value: map[string]int{"age": 30},
- want: "{\n// \t\"age\": 30\n// }\n",
- },
- {
- name: "prefix and indent without pretty",
- pretty: false,
+ name: "ignores prefix and indent",
prefix: "// ",
indent: "\t",
value: map[string]int{"age": 30},
@@ -72,7 +56,6 @@ func TestJSON_Render(t *testing.T) {
},
{
name: "invalid value",
- pretty: false,
value: make(chan int),
wantErr: "render: failed: json: unsupported type: chan int",
wantErrIs: []error{Err, ErrFailed},
@@ -81,7 +64,6 @@ func TestJSON_Render(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
j := &JSON{
- Pretty: tt.pretty,
Prefix: tt.prefix,
Indent: tt.indent,
}
@@ -105,3 +87,76 @@ func TestJSON_Render(t *testing.T) {
})
}
}
+
+func TestJSON_RenderPretty(t *testing.T) {
+ tests := []struct {
+ name string
+ prefix string
+ indent string
+ value any
+ want string
+ wantPretty string
+ wantErr string
+ wantErrIs []error
+ }{
+ {
+ name: "simple object",
+ value: map[string]int{"age": 30},
+ want: "{\n \"age\": 30\n}\n",
+ },
+ {
+ name: "uses prefix and indent",
+ prefix: "// ",
+ indent: "\t",
+ value: map[string]int{"age": 30},
+ want: "{\n// \t\"age\": 30\n// }\n",
+ },
+ {
+ name: "implements json.Marshaler",
+ value: &mockJSONMarshaler{data: []byte(`{"age":30}`)},
+ want: "{\n \"age\": 30\n}\n",
+ },
+ {
+ name: "error from json.Marshaler",
+ value: &mockJSONMarshaler{err: errors.New("marshal error!!1")},
+ wantErrIs: []error{Err, ErrFailed},
+ },
+ {
+ name: "invalid value",
+ value: make(chan int),
+ wantErr: "render: failed: json: unsupported type: chan int",
+ wantErrIs: []error{Err, ErrFailed},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ j := &JSON{
+ Prefix: tt.prefix,
+ Indent: tt.indent,
+ }
+ var buf bytes.Buffer
+
+ err := j.RenderPretty(&buf, tt.value)
+ got := buf.String()
+
+ if tt.wantErr != "" {
+ require.Error(t, err)
+ assert.EqualError(t, err, tt.wantErr)
+ }
+ for _, e := range tt.wantErrIs {
+ assert.ErrorIs(t, err, e)
+ }
+
+ if tt.wantErr == "" && len(tt.wantErrIs) == 0 {
+ require.NoError(t, err)
+ assert.Equal(t, tt.want, got)
+ }
+ })
+ }
+}
+
+func TestJSON_Formats(t *testing.T) {
+ h := &JSON{}
+
+ assert.Equal(t, []string{"json"}, h.Formats())
+}
diff --git a/multi.go b/multi.go
index 35fb6e2..fa39e4d 100644
--- a/multi.go
+++ b/multi.go
@@ -6,18 +6,22 @@ import (
"io"
)
-// Multi is a renderer that tries multiple renderers until one succeeds.
+// Multi is a Handler that tries multiple handlers until one succeeds.
type Multi struct {
- Renderers []FormatRenderer
+ Handlers []Handler
}
-var _ FormatRenderer = (*Multi)(nil)
+var (
+ _ Handler = (*Multi)(nil)
+ _ PrettyHandler = (*Multi)(nil)
+ _ FormatsHandler = (*Multi)(nil)
+)
-// Render tries each renderer in order until one succeeds. If none succeed,
-// ErrCannotRender is returned. If a renderer returns an error that is not
+// Render tries each handler in order until one succeeds. If none succeed,
+// ErrCannotRender is returned. If a handler returns an error that is not
// ErrCannotRender, that error is returned.
func (mr *Multi) Render(w io.Writer, v any) error {
- for _, r := range mr.Renderers {
+ for _, r := range mr.Handlers {
err := r.Render(w, v)
if err == nil {
return nil
@@ -30,11 +34,37 @@ func (mr *Multi) Render(w io.Writer, v any) error {
return fmt.Errorf("%w: %T", ErrCannotRender, v)
}
+// RenderPretty tries each handler in order until one succeeds. If none
+// succeed, ErrCannotRender is returned. If a handler returns an error that is
+// not ErrCannotRender, that error is returned.
+//
+// If a handler implements PrettyHandler, then the RenderPretty method is used
+// instead of Render. Otherwise, the Render method is used.
+func (mr *Multi) RenderPretty(w io.Writer, v any) error {
+ for _, r := range mr.Handlers {
+ var err error
+ if x, ok := r.(PrettyHandler); ok {
+ err = x.RenderPretty(w, v)
+ } else {
+ err = r.Render(w, v)
+ }
+ if err == nil {
+ return nil
+ }
+ if !errors.Is(err, ErrCannotRender) {
+ return err
+ }
+ }
+
+ return fmt.Errorf("%w: %T", ErrCannotRender, v)
+}
+
+// Formats returns a list of format strings that this Handler supports.
func (mr *Multi) Formats() []string {
formats := make(map[string]struct{})
- for _, r := range mr.Renderers {
- if x, ok := r.(Formats); ok {
+ for _, r := range mr.Handlers {
+ if x, ok := r.(FormatsHandler); ok {
for _, f := range x.Formats() {
formats[f] = struct{}{}
}
diff --git a/multi_test.go b/multi_test.go
index 1c258d5..9ea2d17 100644
--- a/multi_test.go
+++ b/multi_test.go
@@ -8,84 +8,129 @@ import (
"github.com/stretchr/testify/assert"
)
-func TestMultiRenderer_Render(t *testing.T) {
- successRenderer := &mockRenderer{output: "success output"}
- cannotRenderer := &mockRenderer{err: ErrCannotRender}
- failRenderer := &mockRenderer{err: errors.New("mock error")}
+var multiHandlerTestCases = []struct {
+ name string
+ handlers []Handler
+ value any
+ want string
+ wantPretty string
+ wantErr string
+ wantErrIs []error
+}{
+ {
+ name: "no handler can render",
+ handlers: []Handler{
+ &mockHandler{err: ErrCannotRender},
+ &mockHandler{err: ErrCannotRender},
+ },
+ value: "test",
+ wantErr: "render: cannot render: string",
+ wantErrIs: []error{ErrCannotRender},
+ },
+ {
+ name: "one handler can render",
+ handlers: []Handler{
+ &mockHandler{err: ErrCannotRender},
+ &mockHandler{output: "success output"},
+ &mockHandler{err: ErrCannotRender},
+ },
+ value: struct{}{},
+ want: "success output",
+ wantPretty: "success output",
+ },
+ {
+ name: "one pretty handler can render",
+ handlers: []Handler{
+ &mockHandler{err: ErrCannotRender},
+ &mockPrettyHandler{
+ output: "success output",
+ prettyOutput: "pretty success output",
+ },
+ &mockHandler{err: ErrCannotRender},
+ },
+ value: struct{}{},
+ want: "success output",
+ wantPretty: "pretty success output",
+ },
+ {
+ name: "multiple handlers can render",
+ handlers: []Handler{
+ &mockHandler{err: ErrCannotRender},
+ &mockHandler{output: "first output"},
+ &mockHandler{output: "second output"},
+ },
+ value: struct{}{},
+ want: "first output",
+ wantPretty: "first output",
+ },
+ {
+ name: "multiple pretty handlers can render",
+ handlers: []Handler{
+ &mockHandler{err: ErrCannotRender},
+ &mockPrettyHandler{
+ output: "first output",
+ prettyOutput: "pretty first output",
+ },
+ &mockPrettyHandler{
+ output: "second output",
+ prettyOutput: "pretty second output",
+ },
+ },
+ value: struct{}{},
+ want: "first output",
+ wantPretty: "pretty first output",
+ },
+ {
+ name: "first handler fails",
+ handlers: []Handler{
+ &mockHandler{err: errors.New("mock error")},
+ &mockHandler{output: "success output"},
+ },
+ value: struct{}{},
+ wantErr: "mock error",
+ },
+ {
+ name: "fails after cannot render",
+ handlers: []Handler{
+ &mockHandler{err: ErrCannotRender},
+ &mockHandler{err: errors.New("mock error")},
+ &mockHandler{output: "success output"},
+ },
+ value: struct{}{},
+ wantErr: "mock error",
+ },
+ {
+ name: "fails after success render",
+ handlers: []Handler{
+ &mockHandler{output: "success output"},
+ &mockHandler{err: errors.New("mock error")},
+ &mockHandler{err: ErrCannotRender},
+ },
+ value: struct{}{},
+ want: "success output",
+ wantPretty: "success output",
+ },
+ {
+ name: "fails after success render with prettier handlers",
+ handlers: []Handler{
+ &mockPrettyHandler{
+ output: "success output",
+ prettyOutput: "pretty success output",
+ },
+ &mockHandler{err: errors.New("mock error")},
+ &mockHandler{err: ErrCannotRender},
+ },
+ value: struct{}{},
+ want: "success output",
+ wantPretty: "pretty success output",
+ },
+}
- tests := []struct {
- name string
- renderers []FormatRenderer
- value interface{}
- want string
- wantErr string
- wantErrIs []error
- }{
- {
- name: "no renderer can render",
- renderers: []FormatRenderer{
- cannotRenderer,
- cannotRenderer,
- },
- value: "test",
- wantErr: "render: cannot render: string",
- wantErrIs: []error{ErrCannotRender},
- },
- {
- name: "one renderer can render",
- renderers: []FormatRenderer{
- cannotRenderer,
- successRenderer,
- cannotRenderer,
- },
- value: struct{}{},
- want: "success output",
- },
- {
- name: "multiple renderers can render",
- renderers: []FormatRenderer{
- &mockRenderer{err: ErrCannotRender},
- &mockRenderer{output: "first output"},
- &mockRenderer{output: "second output"},
- },
- value: struct{}{},
- want: "first output",
- },
- {
- name: "first renderer fails",
- renderers: []FormatRenderer{
- failRenderer,
- successRenderer,
- },
- value: struct{}{},
- wantErr: "mock error",
- },
- {
- name: "fails after cannot render",
- renderers: []FormatRenderer{
- cannotRenderer,
- failRenderer,
- successRenderer,
- },
- value: struct{}{},
- wantErr: "mock error",
- },
- {
- name: "fails after success render",
- renderers: []FormatRenderer{
- successRenderer,
- failRenderer,
- cannotRenderer,
- },
- value: struct{}{},
- want: "success output",
- },
- }
-
- for _, tt := range tests {
+func TestMulti_Render(t *testing.T) {
+ for _, tt := range multiHandlerTestCases {
t.Run(tt.name, func(t *testing.T) {
mr := &Multi{
- Renderers: tt.renderers,
+ Handlers: tt.handlers,
}
var buf bytes.Buffer
@@ -106,3 +151,88 @@ func TestMultiRenderer_Render(t *testing.T) {
})
}
}
+
+func TestMulti_RenderPretty(t *testing.T) {
+ for _, tt := range multiHandlerTestCases {
+ t.Run(tt.name, func(t *testing.T) {
+ mr := &Multi{
+ Handlers: tt.handlers,
+ }
+ var buf bytes.Buffer
+
+ err := mr.RenderPretty(&buf, tt.value)
+ got := buf.String()
+
+ if tt.wantErr != "" {
+ assert.EqualError(t, err, tt.wantErr)
+ }
+ for _, e := range tt.wantErrIs {
+ assert.ErrorIs(t, err, e)
+ }
+
+ if tt.wantErr == "" && len(tt.wantErrIs) == 0 {
+ assert.NoError(t, err)
+ assert.Equal(t, tt.wantPretty, got)
+ }
+ })
+ }
+}
+
+func TestMulti_Formats(t *testing.T) {
+ tests := []struct {
+ name string
+ handlers []Handler
+ want []string
+ }{
+ {
+ name: "single handler without a Formats method",
+ handlers: []Handler{
+ &mockHandler{},
+ },
+ want: []string{},
+ },
+ {
+ name: "multiple handlers without a Formats method",
+ handlers: []Handler{
+ &mockHandler{},
+ },
+ want: []string{},
+ },
+ {
+ name: "single handler with a Formats method",
+ handlers: []Handler{
+ &mockFormatsHandler{formats: []string{"yaml", "yml"}},
+ },
+ want: []string{"yaml", "yml"},
+ },
+ {
+ name: "multiple handlers without a Formats method",
+ handlers: []Handler{
+ &mockFormatsHandler{formats: []string{"yaml", "yml"}},
+ &mockFormatsHandler{formats: []string{"text", "txt"}},
+ },
+ want: []string{"yaml", "yml", "text", "txt"},
+ },
+ {
+ name: "mixture of handlers with and without a Formats method",
+ handlers: []Handler{
+ &mockFormatsHandler{formats: []string{"yaml", "yml"}},
+ &mockHandler{},
+ &mockFormatsHandler{formats: []string{"binary", "bin"}},
+ &mockHandler{},
+ },
+ want: []string{"yaml", "yml", "binary", "bin"},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mr := &Multi{
+ Handlers: tt.handlers,
+ }
+
+ got := mr.Formats()
+
+ assert.ElementsMatch(t, tt.want, got)
+ })
+ }
+}
diff --git a/render.go b/render.go
index 8d25904..cc3020c 100644
--- a/render.go
+++ b/render.go
@@ -1,14 +1,20 @@
// Package render provides a simple and flexible way to render a value to a
// io.Writer using different formats based on a format string argument.
//
-// It allows rendering a custom type which can be marshaled to JSON, YAML, XML,
-// while also supporting plain text by implementing fmt.Stringer or io.WriterTo.
-// Binary output is also supported by implementing the encoding.BinaryMarshaler
-// interface.
+// It is designed around using a custom type/struct to render your output.
+// Thanks to Go's marshaling interfaces, you get JSON, YAML, and XML support
+// almost for free. While plain text output is supported by the type
+// implementing io.Reader, io.WriterTo, fmt.Stringer, or error interfaces, or by
+// simply being a type which can easily be type cast to a byte slice.
//
// Originally intended to easily implement CLI tools which can output their data
// as plain text, as well as JSON/YAML with a simple switch of a format string.
// But it can just as easily render to any io.Writer.
+//
+// The package is designed to be flexible and extensible with a sensible set of
+// defaults accessible via package level functions. You can create your own
+// Renderer for custom formats, or create new handlers that support custom
+// formats.
package render
import (
@@ -26,36 +32,11 @@ var (
// due to the value not supporting the format, or the value itself not being
// renderable. Only Renderer implementations should return this error.
ErrCannotRender = fmt.Errorf("%w: cannot render", Err)
-)
-// FormatRenderer interface is for single format renderers, which can only
-// render a single format.
-type FormatRenderer interface {
- // Render writes v into w in the format that the FormatRenderer supports.
- //
- // If v does not implement a required interface, or otherwise cannot be
- // rendered to the format in question, then a ErrCannotRender error must be
- // returned. Any other errors should be returned as is.
- Render(w io.Writer, v any) error
-}
-
-// Formats is an optional interface that can be implemented by FormatRenderer
-// implementations to return a list of formats that the renderer supports. This
-// is used by the NewRenderer function to allowing format aliases like "yml" for
-// "yaml".
-type Formats interface {
- Formats() []string
-}
-
-var (
- prettyRenderer = New(map[string]FormatRenderer{
- "binary": &Binary{},
- "json": &JSON{Pretty: true},
- "text": &Text{},
- "xml": &XML{Pretty: true},
- "yaml": &YAML{Indent: 2},
- })
- compactRenderer = New(map[string]FormatRenderer{
+ // Base is a renderer that supports all formats. It is used by the package
+ // level NewWith function to create new renderers with a sub-set of
+ // formats.
+ Base = New(map[string]Handler{
"binary": &Binary{},
"json": &JSON{},
"text": &Text{},
@@ -63,110 +44,39 @@ var (
"yaml": &YAML{},
})
- DefaultPretty = prettyRenderer.OnlyWith("json", "text", "xml", "yaml")
- DefaultCompact = compactRenderer.OnlyWith("json", "text", "xml", "yaml")
+ // Default is the default renderer that is used by package level Render,
+ // Compact, Pretty functions. It supports JSON, Text, and YAML formats.
+ Default = Base.NewWith("json", "text", "yaml")
)
-// Render renders the given value to the given writer using the given format.
-// If pretty is true, the value will be rendered in a pretty way, otherwise it
-// will be rendered in a compact way.
+// Render renders the given value to the given writer using the given format. If
+// pretty is true, the value will be rendered "pretty" if the target format
+// supports it, otherwise it will be rendered in a compact way.
//
-// By default it supports the following formats:
+// It uses the default renderer to render the value, which supports JSON, Text,
+// and YAML formats out of the box.
//
-// - "text": Renders values via a myriad of ways.
-// - "json": Renders values using the encoding/json package.
-// - "yaml": Renders values using the gopkg.in/yaml.v3 package.
-// - "xml": Renders values using the encoding/xml package.
-//
-// If the format is not supported, a ErrUnsupportedFormat error will be
-// returned.
+// If you need to support a custom set of formats, use the New function to
+// create a new Renderer with the formats you need. If you need new custom
+// renderers, manually create a new Renderer.
func Render(w io.Writer, format string, pretty bool, v any) error {
- if pretty {
- return DefaultPretty.Render(w, format, v)
- }
-
- return DefaultCompact.Render(w, format, v)
+ return Default.Render(w, format, pretty, v)
}
-// Pretty renders the given value to the given writer using the given format.
-// The format must be one of the formats supported by the default renderer.
-//
-// By default it supports the following formats:
-//
-// - "text": Renders values via a myriad of ways.
-// - "json": Renders values using the encoding/json package, with pretty
-// printing enabled.
-// - "yaml": Renders values using the gopkg.in/yaml.v3 package, with an
-// indentation of 2 spaces.
-// - "xml": Renders values using the encoding/xml package, with pretty
-// printing enabled.
-//
-// If the format is not supported, a ErrUnsupportedFormat error will be
-// returned.
-//
-// If you need to support a custom set of formats, use the New function to
-// create a new Renderer with the formats you need. If you need new custom
-// renderers, manually create a new Renderer.
-func Pretty(w io.Writer, format string, v any) error {
- return DefaultPretty.Render(w, format, v)
-}
-
-// Compact renders the given value to the given writer using the given format.
-// The format must be one of the formats supported by the default renderer.
-//
-// By default it supports the following formats:
-//
-// - "text": Renders values via a myriad of ways..
-// - "json": Renders values using the encoding/json package.
-// - "yaml": Renders values using the gopkg.in/yaml.v3 package.
-// - "xml": Renders values using the encoding/xml package.
-//
-// If the format is not supported, a ErrUnsupportedFormat error will be
-// returned.
-//
-// If you need to support a custom set of formats, use the New function to
-// create a new Renderer with the formats you need. If you need new custom
-// renderers, manually create a new Renderer.
+// Compact is a convenience function that calls the Default renderer's Compact
+// method. It is the same as calling Render with pretty set to false.
func Compact(w io.Writer, format string, v any) error {
- return DefaultCompact.Render(w, format, v)
+ return Default.Compact(w, format, v)
}
-// NewCompact returns a new renderer which only supports the specified formats
-// and renders structured formats compactly. If no formats are specified, a
-// error is returned.
-//
-// If any of the formats are not supported by, a ErrUnsupported error is
-// returned.
-func NewCompact(formats ...string) (*Renderer, error) {
- if len(formats) == 0 {
- return nil, fmt.Errorf("%w: no formats specified", Err)
- }
-
- for _, format := range formats {
- if _, ok := compactRenderer.Renderers[format]; !ok {
- return nil, fmt.Errorf("%w: %s", ErrUnsupportedFormat, format)
- }
- }
-
- return compactRenderer.OnlyWith(formats...), nil
+// Pretty is a convenience function that calls the Default renderer's Pretty
+// method. It is the same as calling Render with pretty set to true.
+func Pretty(w io.Writer, format string, v any) error {
+ return Default.Pretty(w, format, v)
}
-// NewPretty returns a new renderer which only supports the specified formats
-// and renders structured formats in a pretty way. If no formats are specified,
-// a error is returned.
-//
-// If any of the formats are not supported by, a ErrUnsupported error is
-// returned.
-func NewPretty(formats ...string) (*Renderer, error) {
- if len(formats) == 0 {
- return nil, fmt.Errorf("%w: no formats specified", Err)
- }
-
- for _, format := range formats {
- if _, ok := prettyRenderer.Renderers[format]; !ok {
- return nil, fmt.Errorf("%w: %s", ErrUnsupportedFormat, format)
- }
- }
-
- return prettyRenderer.OnlyWith(formats...), nil
+// NewWith creates a new Renderer with the given formats. Only formats on the
+// BaseRender will be supported.
+func NewWith(formats ...string) *Renderer {
+ return Base.NewWith(formats...)
}
diff --git a/render_example_test.go b/render_example_test.go
index 34441f4..a59e6e0 100644
--- a/render_example_test.go
+++ b/render_example_test.go
@@ -39,10 +39,7 @@ func ExampleRender_json() {
buf := &bytes.Buffer{}
err := render.Pretty(buf, "json", data)
if err != nil {
- fmt.Printf("err: %s\n", err)
-
- return
- // panic(err)
+ panic(err)
}
fmt.Println(buf.String())
@@ -142,13 +139,13 @@ func ExampleRender_xml() {
Tags: []string{"golang", "json", "yaml", "toml"},
}
- // Create a new renderer that supports XML in addition to default JSON, YAML
- // and Text.
- r, _ := render.NewPretty("json", "text", "xml", "yaml")
+ // Create a new renderer that supports XML in addition to the default JSON,
+ // Text, and YAML formats.
+ renderer := render.NewWith("json", "text", "xml", "yaml")
// Render the object to XML.
buf := &bytes.Buffer{}
- err := r.Render(buf, "xml", data)
+ err := renderer.Pretty(buf, "xml", data)
if err != nil {
panic(err)
}
diff --git a/render_test.go b/render_test.go
index 27eb43e..92ed8a0 100644
--- a/render_test.go
+++ b/render_test.go
@@ -30,23 +30,93 @@ func (mw *mockWriter) String() string {
return mw.buf.String()
}
-type mockRenderer struct {
- output string
- err error
+type mockHandler struct {
+ output string
+ formats []string
+ err error
}
-var _ FormatRenderer = (*mockRenderer)(nil)
+var (
+ _ Handler = (*mockHandler)(nil)
+ _ FormatsHandler = (*mockHandler)(nil)
+)
-func (m *mockRenderer) Render(w io.Writer, _ any) error {
- _, err := w.Write([]byte(m.output))
+func (mh *mockHandler) Render(w io.Writer, _ any) error {
+ _, err := w.Write([]byte(mh.output))
- if m.err != nil {
- return m.err
+ if mh.err != nil {
+ return mh.err
}
return err
}
+func (mh *mockHandler) Formats() []string {
+ return mh.formats
+}
+
+type mockPrettyHandler struct {
+ output string
+ prettyOutput string
+ formats []string
+ err error
+}
+
+var (
+ _ Handler = (*mockPrettyHandler)(nil)
+ _ PrettyHandler = (*mockPrettyHandler)(nil)
+ _ FormatsHandler = (*mockPrettyHandler)(nil)
+)
+
+func (mph *mockPrettyHandler) Render(w io.Writer, _ any) error {
+ _, err := w.Write([]byte(mph.output))
+
+ if mph.err != nil {
+ return mph.err
+ }
+
+ return err
+}
+
+func (mph *mockPrettyHandler) RenderPretty(w io.Writer, _ any) error {
+ _, err := w.Write([]byte(mph.prettyOutput))
+
+ if mph.err != nil {
+ return mph.err
+ }
+
+ return err
+}
+
+func (mph *mockPrettyHandler) Formats() []string {
+ return mph.formats
+}
+
+type mockFormatsHandler struct {
+ output string
+ formats []string
+ err error
+}
+
+var (
+ _ Handler = (*mockFormatsHandler)(nil)
+ _ FormatsHandler = (*mockFormatsHandler)(nil)
+)
+
+func (mph *mockFormatsHandler) Render(w io.Writer, _ any) error {
+ _, err := w.Write([]byte(mph.output))
+
+ if mph.err != nil {
+ return mph.err
+ }
+
+ return err
+}
+
+func (mph *mockFormatsHandler) Formats() []string {
+ return mph.formats
+}
+
type renderFormatTestCase struct {
name string
writeErr error
@@ -69,6 +139,12 @@ var binaryFormattestCases = []renderFormatTestCase{
value: &mockBinaryMarshaler{data: []byte("test string")},
want: "test string",
},
+ {
+ name: "capitalized format",
+ formats: []string{"BINARY", "BIN"},
+ value: &mockBinaryMarshaler{data: []byte("test string")},
+ want: "test string",
+ },
{
name: "without binary marshaler",
formats: []string{"binary", "bin"},
@@ -112,6 +188,13 @@ var jsonFormatTestCases = []renderFormatTestCase{
wantPretty: "{\n \"age\": 30\n}\n",
wantCompact: "{\"age\":30}\n",
},
+ {
+ name: "capitalized format",
+ formats: []string{"JSON"},
+ value: map[string]int{"age": 30},
+ wantPretty: "{\n \"age\": 30\n}\n",
+ wantCompact: "{\"age\":30}\n",
+ },
{
name: "with json marshaler",
formats: []string{"json"},
@@ -151,6 +234,12 @@ var textFormatTestCases = []renderFormatTestCase{
wantErr: "render: unsupported format: {{format}}",
wantErrIs: []error{Err, ErrUnsupportedFormat},
},
+ {
+ name: "capitalized format",
+ formats: []string{"TEXT", "TXT", "PLAIN"},
+ value: []byte("test byte slice"),
+ want: "test byte slice",
+ },
{
name: "byte slice",
formats: []string{"text", "txt", "plain"},
@@ -344,6 +433,12 @@ var xmlFormatTestCases = []renderFormatTestCase{
value: &mockXMLMarshaler{elm: "test string"},
want: "test string",
},
+ {
+ name: "capitalized format",
+ formats: []string{"XML"},
+ value: &mockXMLMarshaler{elm: "test string"},
+ want: "test string",
+ },
{
name: "xml format with error from xml.Marshaler",
formats: []string{"xml"},
@@ -379,6 +474,12 @@ var yamlFormatTestCases = []renderFormatTestCase{
value: map[string]int{"age": 30},
want: "age: 30\n",
},
+ {
+ name: "capitalized format",
+ formats: []string{"YAML", "YML"},
+ value: map[string]int{"age": 30},
+ want: "age: 30\n",
+ },
{
name: "yaml format with nested structure",
formats: []string{"yaml", "yml"},
@@ -419,6 +520,72 @@ var yamlFormatTestCases = []renderFormatTestCase{
},
}
+func TestRender(t *testing.T) {
+ tests := []renderFormatTestCase{}
+ tests = append(tests, jsonFormatTestCases...)
+ tests = append(tests, textFormatTestCases...)
+ tests = append(tests, yamlFormatTestCases...)
+
+ for _, tt := range tests {
+ for _, pretty := range []bool{false, true} {
+ for _, format := range tt.formats {
+ name := format + " format " + tt.name
+ if pretty {
+ name = "pretty " + name
+ }
+
+ t.Run(name, func(t *testing.T) {
+ w := &mockWriter{WriteErr: tt.writeErr}
+
+ value := tt.value
+ if tt.valueFunc != nil {
+ value = tt.valueFunc()
+ }
+
+ var err error
+ var panicRes any
+ func() {
+ defer func() {
+ if r := recover(); r != nil {
+ panicRes = r
+ }
+ }()
+ err = Render(w, format, pretty, value)
+ }()
+
+ got := w.String()
+ want := tt.want
+ if pretty && tt.wantPretty != "" {
+ want = tt.wantPretty
+ } else if tt.wantCompact != "" {
+ want = tt.wantCompact
+ }
+
+ if tt.wantPanic != "" {
+ assert.Equal(t, tt.wantPanic, panicRes)
+ }
+
+ if tt.wantErr != "" {
+ wantErr := strings.ReplaceAll(
+ tt.wantErr, "{{format}}", format,
+ )
+ assert.EqualError(t, err, wantErr)
+ }
+ for _, e := range tt.wantErrIs {
+ assert.ErrorIs(t, err, e)
+ }
+
+ if tt.wantPanic == "" &&
+ tt.wantErr == "" && len(tt.wantErrIs) == 0 {
+ assert.NoError(t, err)
+ assert.Equal(t, want, got)
+ }
+ })
+ }
+ }
+ }
+}
+
func TestPretty(t *testing.T) {
tests := []renderFormatTestCase{}
tests = append(tests, jsonFormatTestCases...)
@@ -536,3 +703,86 @@ func TestCompact(t *testing.T) {
}
}
}
+
+func TestNewWith(t *testing.T) {
+ tests := []struct {
+ name string
+ formats []string
+ want *Renderer
+ }{
+ {
+ name: "no formats",
+ formats: nil,
+ want: &Renderer{
+ Handlers: map[string]Handler{},
+ },
+ },
+ {
+ name: "single format",
+ formats: []string{"json"},
+ want: &Renderer{
+ Handlers: map[string]Handler{
+ "json": &JSON{},
+ },
+ },
+ },
+ {
+ name: "multiple formats",
+ formats: []string{"json", "xml"},
+ want: &Renderer{
+ Handlers: map[string]Handler{
+ "json": &JSON{},
+ "xml": &XML{},
+ },
+ },
+ },
+ {
+ name: "multiple formats with aliases",
+ formats: []string{"yaml", "text", "binary"},
+ want: &Renderer{
+ Handlers: map[string]Handler{
+ "bin": &Binary{},
+ "binary": &Binary{},
+ "plain": &Text{},
+ "text": &Text{},
+ "txt": &Text{},
+ "yaml": &YAML{},
+ "yml": &YAML{},
+ },
+ },
+ },
+ {
+ name: "duplicate formats",
+ formats: []string{"json", "json", "yaml", "yaml"},
+ want: &Renderer{
+ Handlers: map[string]Handler{
+ "json": &JSON{},
+ "yaml": &YAML{},
+ "yml": &YAML{},
+ },
+ },
+ },
+ {
+ name: "capitalized formats",
+ formats: []string{"YAML", "TEXT", "BINARY"},
+ want: &Renderer{
+ Handlers: map[string]Handler{
+ "bin": &Binary{},
+ "binary": &Binary{},
+ "plain": &Text{},
+ "text": &Text{},
+ "txt": &Text{},
+ "yaml": &YAML{},
+ "yml": &YAML{},
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := NewWith(tt.formats...)
+
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
diff --git a/renderer.go b/renderer.go
index 73af5e4..dd76ff1 100644
--- a/renderer.go
+++ b/renderer.go
@@ -4,61 +4,86 @@ import (
"errors"
"fmt"
"io"
+ "strings"
)
-// ErrUnsupportedFormat is returned when a format is not supported by a
-// renderer. Any method that accepts a format string may return this error.
+// ErrUnsupportedFormat is returned when a format is not supported by any
+// Handler.
var ErrUnsupportedFormat = fmt.Errorf("%w: unsupported format", Err)
-// Renderer is a renderer that delegates rendering to another renderer
-// based on a format value.
+// Renderer exposes methods for rendering values to different formats. The
+// Renderer delegates rendering to format specific handlers based on the format
+// string given.
type Renderer struct {
- // Renderers is a map of format names to renderers. When Render is called,
- // the format is used to look up the renderer to use.
- Renderers map[string]FormatRenderer
+ // Handlers is a map of format names to Handler. When Render is called,
+ // the format is used to look up the Handler to use.
+ Handlers map[string]Handler
}
// New returns a new Renderer that delegates rendering to the specified
-// renderers.
+// Handlers.
//
-// Any renderers which implement the Formats interface, will also be set as the
-// renderer for all format strings returned by Format() on the renderer.
-func New(renderers map[string]FormatRenderer) *Renderer {
- newRenderers := make(map[string]FormatRenderer, len(renderers))
+// Any Handlers which implement the FormatsHandler interface, will also be set
+// as the handler for all format strings returned by Formats() on the handler.
+func New(handlers map[string]Handler) *Renderer {
+ r := &Renderer{Handlers: make(map[string]Handler, len(handlers))}
- for format, r := range renderers {
- newRenderers[format] = r
+ for format, handler := range handlers {
+ r.Add(format, handler)
+ }
- if x, ok := r.(Formats); ok {
- for _, f := range x.Formats() {
- if f != format {
- newRenderers[f] = r
- }
+ return r
+}
+
+// Add adds a Handler to the Renderer. If the handler implements the
+// FormatsHandler interface, the handler will be added for all formats returned
+// by Formats().
+func (r *Renderer) Add(format string, handler Handler) {
+ if format != "" {
+ r.Handlers[strings.ToLower(format)] = handler
+ }
+
+ if x, ok := handler.(FormatsHandler); ok {
+ for _, f := range x.Formats() {
+ if f != "" && f != format {
+ r.Handlers[strings.ToLower(f)] = handler
}
}
}
-
- return &Renderer{Renderers: newRenderers}
}
-// Render renders a value to an io.Writer using the specified format. If the
-// format is not supported, ErrUnsupportedFormat is returned.
+// Render renders a value to the given io.Writer using the specified format.
//
-// If the format is supported, but the value cannot be rendered to the format,
-// the error returned by the renderer is returned. In most cases this will be
-// ErrCannotRender, but it could be a different error if the renderer returns
-// one.
-func (r *Renderer) Render(w io.Writer, format string, v any) error {
- renderer, ok := r.Renderers[format]
+// If pretty is true, it will attempt to render the value with pretty
+// formatting if the underlying Handler supports pretty formatting.
+//
+// If the format is not supported or the value cannot be rendered to the format,
+// a ErrUnsupportedFormat error is returned.
+func (r *Renderer) Render(
+ w io.Writer,
+ format string,
+ pretty bool,
+ v any,
+) error {
+ handler, ok := r.Handlers[strings.ToLower(format)]
if !ok {
return fmt.Errorf("%w: %s", ErrUnsupportedFormat, format)
}
- err := renderer.Render(w, v)
+ prettyHandler, ok := handler.(PrettyHandler)
+ var err error
+ if pretty && ok {
+ err = prettyHandler.RenderPretty(w, v)
+ } else {
+ err = handler.Render(w, v)
+ }
+
if err != nil {
if errors.Is(err, ErrCannotRender) {
return fmt.Errorf("%w: %s", ErrUnsupportedFormat, format)
}
+
+ // Ensure that the error is wrapped with ErrFailed if it is not already.
if !errors.Is(err, ErrFailed) {
return fmt.Errorf("%w: %w", ErrFailed, err)
}
@@ -69,14 +94,27 @@ func (r *Renderer) Render(w io.Writer, format string, v any) error {
return nil
}
-func (r *Renderer) OnlyWith(formats ...string) *Renderer {
- renderers := make(map[string]FormatRenderer, len(formats))
+// Compact is a convenience method that calls Render with pretty set to false.
+func (r *Renderer) Compact(w io.Writer, format string, v any) error {
+ return r.Render(w, format, false, v)
+}
+
+// Pretty is a convenience method that calls Render with pretty set to true.
+func (r *Renderer) Pretty(w io.Writer, format string, v any) error {
+ return r.Render(w, format, true, v)
+}
+
+// NewWith creates a new Renderer with the formats given, if they have handlers
+// in the currener Renderer. It essentially allows to restrict a Renderer to a
+// only a sub-set of supported formats.
+func (r *Renderer) NewWith(formats ...string) *Renderer {
+ handlers := make(map[string]Handler, len(formats))
for _, format := range formats {
- if r, ok := r.Renderers[format]; ok {
- renderers[format] = r
+ if r, ok := r.Handlers[strings.ToLower(format)]; ok {
+ handlers[format] = r
}
}
- return New(renderers)
+ return New(handlers)
}
diff --git a/renderer_test.go b/renderer_test.go
index 1ab848f..d9e9992 100644
--- a/renderer_test.go
+++ b/renderer_test.go
@@ -8,32 +8,230 @@ import (
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
)
+func TestNew(t *testing.T) {
+ tests := []struct {
+ name string
+ handlers map[string]Handler
+ want *Renderer
+ }{
+ {
+ name: "nil handlers map",
+ want: &Renderer{Handlers: map[string]Handler{}},
+ },
+ {
+ name: "empty handlers map",
+ handlers: map[string]Handler{},
+ want: &Renderer{Handlers: map[string]Handler{}},
+ },
+ {
+ name: "single handler",
+ handlers: map[string]Handler{
+ "mock": &mockHandler{},
+ },
+ want: &Renderer{Handlers: map[string]Handler{
+ "mock": &mockHandler{},
+ }},
+ },
+ {
+ name: "multiple handlers",
+ handlers: map[string]Handler{
+ "mock": &mockHandler{},
+ "other": &mockHandler{output: "other output"},
+ },
+ want: &Renderer{Handlers: map[string]Handler{
+ "mock": &mockHandler{},
+ "other": &mockHandler{output: "other output"},
+ }},
+ },
+ {
+ name: "multiple handlers with alias formats",
+ handlers: map[string]Handler{
+ "mock": &mockFormatsHandler{formats: []string{"mock", "m"}},
+ "other": &mockFormatsHandler{formats: []string{"other", "o"}},
+ },
+ want: &Renderer{Handlers: map[string]Handler{
+ "mock": &mockFormatsHandler{formats: []string{"mock", "m"}},
+ "m": &mockFormatsHandler{formats: []string{"mock", "m"}},
+ "other": &mockFormatsHandler{formats: []string{"other", "o"}},
+ "o": &mockFormatsHandler{formats: []string{"other", "o"}},
+ }},
+ },
+ {
+ name: "multiple handlers with custom formats",
+ handlers: map[string]Handler{
+ "foo": &mockFormatsHandler{formats: []string{"mock", "m"}},
+ "bar": &mockFormatsHandler{formats: []string{"other", "o"}},
+ },
+ want: &Renderer{Handlers: map[string]Handler{
+ "foo": &mockFormatsHandler{formats: []string{"mock", "m"}},
+ "mock": &mockFormatsHandler{formats: []string{"mock", "m"}},
+ "m": &mockFormatsHandler{formats: []string{"mock", "m"}},
+ "bar": &mockFormatsHandler{formats: []string{"other", "o"}},
+ "other": &mockFormatsHandler{formats: []string{"other", "o"}},
+ "o": &mockFormatsHandler{formats: []string{"other", "o"}},
+ }},
+ },
+ {
+ name: "multiple handlers with capitalized formats",
+ handlers: map[string]Handler{
+ "Foo": &mockFormatsHandler{formats: []string{"MOCK", "m"}},
+ "Bar": &mockFormatsHandler{formats: []string{"OTHER", "o"}},
+ },
+ want: &Renderer{Handlers: map[string]Handler{
+ "foo": &mockFormatsHandler{formats: []string{"MOCK", "m"}},
+ "mock": &mockFormatsHandler{formats: []string{"MOCK", "m"}},
+ "m": &mockFormatsHandler{formats: []string{"MOCK", "m"}},
+ "bar": &mockFormatsHandler{formats: []string{"OTHER", "o"}},
+ "other": &mockFormatsHandler{formats: []string{"OTHER", "o"}},
+ "o": &mockFormatsHandler{formats: []string{"OTHER", "o"}},
+ }},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := New(tt.handlers)
+
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestRenderer_Add(t *testing.T) {
+ tests := []struct {
+ name string
+ format string
+ handler Handler
+ want []string
+ }{
+ {
+ name: "handler without Formats",
+ format: "tackle",
+ handler: &mockHandler{},
+ want: []string{"tackle"},
+ },
+ {
+ name: "hander with Formats",
+ format: "hackle",
+ handler: &mockFormatsHandler{formats: []string{"hackle"}},
+ want: []string{"hackle"},
+ },
+ {
+ name: "hander with alias formats",
+ format: "hackle",
+ handler: &mockFormatsHandler{formats: []string{"hackle", "hack"}},
+ want: []string{"hackle", "hack"},
+ },
+ {
+ name: "given format differs from Formats",
+ format: "foobar",
+ handler: &mockFormatsHandler{formats: []string{"hackle", "hack"}},
+ want: []string{"foobar", "hackle", "hack"},
+ },
+ {
+ name: "lowercases capitalized formats",
+ format: "FooBar",
+ handler: &mockFormatsHandler{formats: []string{"HACKLE", "Hack"}},
+ want: []string{"foobar", "hackle", "hack"},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := &Renderer{Handlers: map[string]Handler{}}
+
+ r.Add(tt.format, tt.handler)
+
+ for _, f := range tt.want {
+ got, ok := r.Handlers[f]
+ assert.Truef(t, ok, "not added as %q format", f)
+ assert.Equal(t, tt.handler, got)
+ }
+
+ gotFormats := []string{}
+ for f := range r.Handlers {
+ gotFormats = append(gotFormats, f)
+ }
+ assert.ElementsMatch(t, tt.want, gotFormats)
+ })
+ }
+}
+
func TestRenderer_Render(t *testing.T) {
tests := []struct {
name string
- renderers map[string]FormatRenderer
+ handlers map[string]Handler
format string
- value interface{}
+ pretty bool
+ value any
want string
wantErr string
wantErrIs []error
}{
{
- name: "existing renderer",
- renderers: map[string]FormatRenderer{
- "mock": &mockRenderer{output: "mock output"},
+ name: "no pretty with handler that supports pretty",
+ handlers: map[string]Handler{
+ "mock": &mockPrettyHandler{
+ output: "plain output",
+ prettyOutput: "pretty output",
+ },
},
format: "mock",
+ pretty: false,
value: struct{}{},
- want: "mock output",
+ want: "plain output",
},
{
- name: "existing renderer returns error",
- renderers: map[string]FormatRenderer{
- "other": &mockRenderer{
+ name: "no pretty with handler that does not support pretty",
+ handlers: map[string]Handler{
+ "mock": &mockHandler{output: "plain output"},
+ },
+ format: "mock",
+ pretty: false,
+ value: struct{}{},
+ want: "plain output",
+ },
+ {
+ name: "pretty with handler that supports pretty",
+ handlers: map[string]Handler{
+ "mock": &mockPrettyHandler{
+ output: "plain output",
+ prettyOutput: "pretty output",
+ },
+ },
+ format: "mock",
+ pretty: true,
+ value: struct{}{},
+ want: "pretty output",
+ },
+ {
+ name: "pretty with handler that does not support pretty",
+ handlers: map[string]Handler{
+ "mock": &mockHandler{
+ output: "plain output",
+ },
+ },
+ format: "mock",
+ pretty: true,
+ value: struct{}{},
+ want: "plain output",
+ },
+ {
+ name: "normalizes given format to lowercase",
+ handlers: map[string]Handler{
+ "mock": &mockHandler{
+ output: "plain output",
+ },
+ },
+ format: "MOCK",
+ pretty: true,
+ value: struct{}{},
+ want: "plain output",
+ },
+ {
+ name: "handler returns error",
+ handlers: map[string]Handler{
+ "other": &mockHandler{
output: "mock output",
err: errors.New("mock error"),
},
@@ -43,9 +241,9 @@ func TestRenderer_Render(t *testing.T) {
wantErr: "render: failed: mock error",
},
{
- name: "existing renderer returns ErrCannotRender",
- renderers: map[string]FormatRenderer{
- "other": &mockRenderer{
+ name: "handler returns ErrCannotRender",
+ handlers: map[string]Handler{
+ "other": &mockHandler{
output: "mock output",
err: fmt.Errorf("%w: mock", ErrCannotRender),
},
@@ -56,8 +254,8 @@ func TestRenderer_Render(t *testing.T) {
wantErrIs: []error{Err, ErrUnsupportedFormat},
},
{
- name: "non-existing renderer",
- renderers: map[string]FormatRenderer{},
+ name: "non-existing handler",
+ handlers: map[string]Handler{},
format: "unknown",
value: struct{}{},
wantErr: "render: unsupported format: unknown",
@@ -66,12 +264,194 @@ func TestRenderer_Render(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- fr := &Renderer{
- Renderers: tt.renderers,
+ r := &Renderer{
+ Handlers: tt.handlers,
}
var buf bytes.Buffer
- err := fr.Render(&buf, tt.format, tt.value)
+ err := r.Render(&buf, tt.format, tt.pretty, tt.value)
+ got := buf.String()
+
+ if tt.wantErr != "" {
+ assert.EqualError(t, err, tt.wantErr)
+ }
+ for _, e := range tt.wantErrIs {
+ assert.ErrorIs(t, err, e)
+ }
+
+ if tt.wantErr == "" && len(tt.wantErrIs) == 0 {
+ assert.NoError(t, err)
+ assert.Equal(t, tt.want, got)
+ }
+ })
+ }
+}
+
+func TestRenderer_Compact(t *testing.T) {
+ tests := []struct {
+ name string
+ handlers map[string]Handler
+ format string
+ value any
+ want string
+ wantErr string
+ wantErrIs []error
+ }{
+ {
+ name: "handler supports pretty",
+ handlers: map[string]Handler{
+ "mock": &mockPrettyHandler{
+ output: "plain output",
+ prettyOutput: "pretty output",
+ },
+ },
+ format: "mock",
+ value: struct{}{},
+ want: "plain output",
+ },
+ {
+ name: "handler does not support pretty",
+ handlers: map[string]Handler{
+ "mock": &mockHandler{output: "plain output"},
+ },
+ format: "mock",
+ value: struct{}{},
+ want: "plain output",
+ },
+ {
+ name: "handler returns error",
+ handlers: map[string]Handler{
+ "other": &mockHandler{
+ output: "mock output",
+ err: errors.New("mock error"),
+ },
+ },
+ format: "other",
+ value: struct{}{},
+ wantErr: "render: failed: mock error",
+ },
+ {
+ name: "handler returns ErrCannotRender",
+ handlers: map[string]Handler{
+ "other": &mockHandler{
+ output: "mock output",
+ err: fmt.Errorf("%w: mock", ErrCannotRender),
+ },
+ },
+ format: "other",
+ value: struct{}{},
+ wantErr: "render: unsupported format: other",
+ wantErrIs: []error{Err, ErrUnsupportedFormat},
+ },
+ {
+ name: "non-existing handler",
+ handlers: map[string]Handler{},
+ format: "unknown",
+ value: struct{}{},
+ wantErr: "render: unsupported format: unknown",
+ wantErrIs: []error{Err, ErrUnsupportedFormat},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := &Renderer{
+ Handlers: tt.handlers,
+ }
+ var buf bytes.Buffer
+
+ err := r.Compact(&buf, tt.format, tt.value)
+ got := buf.String()
+
+ if tt.wantErr != "" {
+ assert.EqualError(t, err, tt.wantErr)
+ }
+ for _, e := range tt.wantErrIs {
+ assert.ErrorIs(t, err, e)
+ }
+
+ if tt.wantErr == "" && len(tt.wantErrIs) == 0 {
+ assert.NoError(t, err)
+ assert.Equal(t, tt.want, got)
+ }
+ })
+ }
+}
+
+func TestRenderer_Pretty(t *testing.T) {
+ tests := []struct {
+ name string
+ handlers map[string]Handler
+ format string
+ value any
+ want string
+ wantErr string
+ wantErrIs []error
+ }{
+ {
+ name: "handler supports pretty",
+ handlers: map[string]Handler{
+ "mock": &mockPrettyHandler{
+ output: "plain output",
+ prettyOutput: "pretty output",
+ },
+ },
+ format: "mock",
+ value: struct{}{},
+ want: "pretty output",
+ },
+ {
+ name: "handler does not support pretty",
+ handlers: map[string]Handler{
+ "mock": &mockHandler{
+ output: "plain output",
+ },
+ },
+ format: "mock",
+ value: struct{}{},
+ want: "plain output",
+ },
+ {
+ name: "handler returns error",
+ handlers: map[string]Handler{
+ "other": &mockHandler{
+ output: "mock output",
+ err: errors.New("mock error"),
+ },
+ },
+ format: "other",
+ value: struct{}{},
+ wantErr: "render: failed: mock error",
+ },
+ {
+ name: "handler returns ErrCannotRender",
+ handlers: map[string]Handler{
+ "other": &mockHandler{
+ output: "mock output",
+ err: fmt.Errorf("%w: mock", ErrCannotRender),
+ },
+ },
+ format: "other",
+ value: struct{}{},
+ wantErr: "render: unsupported format: other",
+ wantErrIs: []error{Err, ErrUnsupportedFormat},
+ },
+ {
+ name: "non-existing handler",
+ handlers: map[string]Handler{},
+ format: "unknown",
+ value: struct{}{},
+ wantErr: "render: unsupported format: unknown",
+ wantErrIs: []error{Err, ErrUnsupportedFormat},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := &Renderer{
+ Handlers: tt.handlers,
+ }
+ var buf bytes.Buffer
+
+ err := r.Pretty(&buf, tt.format, tt.value)
got := buf.String()
if tt.wantErr != "" {
@@ -97,6 +477,75 @@ func TestRenderer_RenderAllFormats(t *testing.T) {
tests = append(tests, xmlFormatTestCases...)
tests = append(tests, yamlFormatTestCases...)
+ for _, tt := range tests {
+ for _, pretty := range []bool{false, true} {
+ for _, format := range tt.formats {
+ name := format + " format " + tt.name
+ if pretty {
+ name = "pretty " + name
+ }
+
+ t.Run(name, func(t *testing.T) {
+ w := &mockWriter{WriteErr: tt.writeErr}
+
+ value := tt.value
+ if tt.valueFunc != nil {
+ value = tt.valueFunc()
+ }
+
+ var err error
+ var panicRes any
+
+ func() {
+ defer func() {
+ if r := recover(); r != nil {
+ panicRes = r
+ }
+ }()
+ err = Base.Render(w, format, pretty, value)
+ }()
+
+ got := w.String()
+ want := tt.want
+ if pretty && tt.wantPretty != "" {
+ want = tt.wantPretty
+ } else if tt.wantCompact != "" {
+ want = tt.wantCompact
+ }
+
+ if tt.wantPanic != "" {
+ assert.Equal(t, tt.wantPanic, panicRes)
+ }
+
+ if tt.wantErr != "" {
+ wantErr := strings.ReplaceAll(
+ tt.wantErr, "{{format}}", format,
+ )
+ assert.EqualError(t, err, wantErr)
+ }
+ for _, e := range tt.wantErrIs {
+ assert.ErrorIs(t, err, e)
+ }
+
+ if tt.wantPanic == "" &&
+ tt.wantErr == "" && len(tt.wantErrIs) == 0 {
+ assert.NoError(t, err)
+ assert.Equal(t, want, got)
+ }
+ })
+ }
+ }
+ }
+}
+
+func TestRenderer_CompactAllFormats(t *testing.T) {
+ tests := []renderFormatTestCase{}
+ tests = append(tests, binaryFormattestCases...)
+ tests = append(tests, jsonFormatTestCases...)
+ tests = append(tests, textFormatTestCases...)
+ tests = append(tests, xmlFormatTestCases...)
+ tests = append(tests, yamlFormatTestCases...)
+
for _, tt := range tests {
for _, format := range tt.formats {
t.Run(format+" format "+tt.name, func(t *testing.T) {
@@ -109,8 +558,6 @@ func TestRenderer_RenderAllFormats(t *testing.T) {
var err error
var panicRes any
- renderer := compactRenderer
- require.NoError(t, err)
func() {
defer func() {
@@ -118,7 +565,7 @@ func TestRenderer_RenderAllFormats(t *testing.T) {
panicRes = r
}
}()
- err = renderer.Render(w, format, value)
+ err = Base.Compact(w, format, value)
}()
got := w.String()
@@ -152,3 +599,65 @@ func TestRenderer_RenderAllFormats(t *testing.T) {
}
}
}
+
+func TestRenderer_PrettyAllFormats(t *testing.T) {
+ tests := []renderFormatTestCase{}
+ tests = append(tests, binaryFormattestCases...)
+ tests = append(tests, jsonFormatTestCases...)
+ tests = append(tests, textFormatTestCases...)
+ tests = append(tests, xmlFormatTestCases...)
+ tests = append(tests, yamlFormatTestCases...)
+
+ for _, tt := range tests {
+ for _, format := range tt.formats {
+ t.Run(format+" format "+tt.name, func(t *testing.T) {
+ w := &mockWriter{WriteErr: tt.writeErr}
+
+ value := tt.value
+ if tt.valueFunc != nil {
+ value = tt.valueFunc()
+ }
+
+ var err error
+ var panicRes any
+
+ func() {
+ defer func() {
+ if r := recover(); r != nil {
+ panicRes = r
+ }
+ }()
+ err = Base.Pretty(w, format, value)
+ }()
+
+ got := w.String()
+ var want string
+ if tt.wantPretty == "" && tt.wantCompact == "" {
+ want = tt.want
+ } else {
+ want = tt.wantPretty
+ }
+
+ if tt.wantPanic != "" {
+ assert.Equal(t, tt.wantPanic, panicRes)
+ }
+
+ if tt.wantErr != "" {
+ wantErr := strings.ReplaceAll(
+ tt.wantErr, "{{format}}", format,
+ )
+ assert.EqualError(t, err, wantErr)
+ }
+ for _, e := range tt.wantErrIs {
+ assert.ErrorIs(t, err, e)
+ }
+
+ if tt.wantPanic == "" &&
+ tt.wantErr == "" && len(tt.wantErrIs) == 0 {
+ assert.NoError(t, err)
+ assert.Equal(t, want, got)
+ }
+ })
+ }
+ }
+}
diff --git a/text.go b/text.go
index 47f7934..d86e219 100644
--- a/text.go
+++ b/text.go
@@ -5,8 +5,10 @@ import (
"io"
)
-// Text is a renderer that writes the given value to the writer as is. It
-// supports rendering the following types as plain text:
+// Text is a Handler that writes the given value to the writer as text,
+// supporting multiple types and interfaces.
+//
+// Supports rendering the following types as text:
//
// - []byte
// - []rune
@@ -23,7 +25,10 @@ import (
// If the value is of any other type, a ErrCannotRender error will be returned.
type Text struct{}
-var _ FormatRenderer = (*Text)(nil)
+var (
+ _ Handler = (*Text)(nil)
+ _ FormatsHandler = (*Text)(nil)
+)
// Render writes the given value to the writer as text.
func (t *Text) Render(w io.Writer, v any) error {
@@ -58,6 +63,7 @@ func (t *Text) Render(w io.Writer, v any) error {
return nil
}
+// Formats returns a list of format strings that this Handler supports.
func (t *Text) Formats() []string {
return []string{"text", "txt", "plain"}
}
diff --git a/text_test.go b/text_test.go
index 7ec9891..0dfba6a 100644
--- a/text_test.go
+++ b/text_test.go
@@ -216,3 +216,9 @@ func TestText_Render(t *testing.T) {
})
}
}
+
+func TestText_Formats(t *testing.T) {
+ h := &Text{}
+
+ assert.Equal(t, []string{"text", "txt", "plain"}, h.Formats())
+}
diff --git a/xml.go b/xml.go
index ff10da3..9d14bfb 100644
--- a/xml.go
+++ b/xml.go
@@ -6,36 +6,49 @@ import (
"io"
)
+// XMLDefualtIndent is the default indentation string used by XML instances when
+// pretty rendering if no Indent value is set.
+var XMLDefualtIndent = " "
+
// XML is a Renderer that marshals a value to XML.
type XML struct {
- // Pretty specifies whether the output should be pretty-printed. If true,
- // the output will be indented and newlines will be added.
- Pretty bool
-
- // Prefix is the prefix added to each level of indentation when Pretty is
- // true.
+ // Prefix is the prefix added to each level of indentation when pretty
+ // rendering.
Prefix string
- // Indent is the string added to each level of indentation when Pretty is
- // true. If empty, two spaces will be used instead.
+ // Indent is the string added to each level of indentation when pretty
+ // rendering. If empty, XMLDefualtIndent be used.
Indent string
}
-var _ FormatRenderer = (*XML)(nil)
+var (
+ _ Handler = (*XML)(nil)
+ _ PrettyHandler = (*XML)(nil)
+ _ FormatsHandler = (*XML)(nil)
+)
// Render marshals the given value to XML.
func (x *XML) Render(w io.Writer, v any) error {
- enc := xml.NewEncoder(w)
- if x.Pretty {
- prefix := x.Prefix
- indent := x.Indent
- if indent == "" {
- indent = " "
- }
-
- enc.Indent(prefix, indent)
+ err := xml.NewEncoder(w).Encode(v)
+ if err != nil {
+ return fmt.Errorf("%w: %w", ErrFailed, err)
}
+ return nil
+}
+
+// RenderPretty marshals the given value to XML with line breaks and
+// indentation.
+func (x *XML) RenderPretty(w io.Writer, v any) error {
+ prefix := x.Prefix
+ indent := x.Indent
+ if indent == "" {
+ indent = XMLDefualtIndent
+ }
+
+ enc := xml.NewEncoder(w)
+ enc.Indent(prefix, indent)
+
err := enc.Encode(v)
if err != nil {
return fmt.Errorf("%w: %w", ErrFailed, err)
@@ -44,6 +57,7 @@ func (x *XML) Render(w io.Writer, v any) error {
return nil
}
+// Formats returns a list of format strings that this Handler supports.
func (x *XML) Formats() []string {
return []string{"xml"}
}
diff --git a/xml_test.go b/xml_test.go
index 2f449a8..ecce0bc 100644
--- a/xml_test.go
+++ b/xml_test.go
@@ -32,7 +32,6 @@ func (mxm *mockXMLMarshaler) MarshalXML(
func TestXML_Render(t *testing.T) {
tests := []struct {
name string
- pretty bool
prefix string
indent string
value any
@@ -41,8 +40,7 @@ func TestXML_Render(t *testing.T) {
wantErrIs []error
}{
{
- name: "simple object without pretty",
- pretty: false,
+ name: "simple object",
value: struct {
XMLName xml.Name `xml:"user"`
Age int `xml:"age"`
@@ -50,28 +48,7 @@ func TestXML_Render(t *testing.T) {
want: `30`,
},
{
- name: "simple object with pretty",
- pretty: true,
- value: struct {
- XMLName xml.Name `xml:"user"`
- Age int `xml:"age"`
- }{Age: 30},
- want: "\n 30\n",
- },
- {
- name: "pretty with prefix and indent",
- pretty: true,
- prefix: "//",
- indent: "\t",
- value: struct {
- XMLName xml.Name `xml:"user"`
- Age int `xml:"age"`
- }{Age: 30},
- want: "//\n//\t30\n//",
- },
- {
- name: "prefix and indent without pretty",
- pretty: false,
+ name: "ignores indent without pretty",
prefix: "//",
indent: "\t",
value: struct {
@@ -93,7 +70,6 @@ func TestXML_Render(t *testing.T) {
},
{
name: "invalid value",
- pretty: false,
value: make(chan int),
wantErr: "render: failed: xml: unsupported type: chan int",
wantErrIs: []error{Err, ErrFailed},
@@ -103,7 +79,6 @@ func TestXML_Render(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
x := &XML{
- Pretty: tt.pretty,
Prefix: tt.prefix,
Indent: tt.indent,
}
@@ -126,3 +101,82 @@ func TestXML_Render(t *testing.T) {
})
}
}
+
+func TestXML_RenderPretty(t *testing.T) {
+ tests := []struct {
+ name string
+ prefix string
+ indent string
+ value any
+ want string
+ wantErr string
+ wantErrIs []error
+ }{
+ {
+ name: "simple object",
+ value: struct {
+ XMLName xml.Name `xml:"user"`
+ Age int `xml:"age"`
+ }{Age: 30},
+ want: "\n 30\n",
+ },
+ {
+ name: "uses prefix and indent",
+ prefix: "//",
+ indent: "\t",
+ value: struct {
+ XMLName xml.Name `xml:"user"`
+ Age int `xml:"age"`
+ }{Age: 30},
+ want: "//\n//\t30\n//",
+ },
+ {
+ name: "implements xml.Marshaler",
+ value: &mockXMLMarshaler{elm: "test string"},
+ want: "test string",
+ },
+ {
+ name: "error from xml.Marshaler",
+ value: &mockXMLMarshaler{err: errors.New("mock error")},
+ wantErr: "render: failed: mock error",
+ wantErrIs: []error{Err, ErrFailed},
+ },
+ {
+ name: "invalid value",
+ value: make(chan int),
+ wantErr: "render: failed: xml: unsupported type: chan int",
+ wantErrIs: []error{Err, ErrFailed},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ x := &XML{
+ Prefix: tt.prefix,
+ Indent: tt.indent,
+ }
+ var buf bytes.Buffer
+
+ err := x.RenderPretty(&buf, tt.value)
+ got := buf.String()
+
+ if tt.wantErr != "" {
+ assert.EqualError(t, err, tt.wantErr)
+ }
+ for _, e := range tt.wantErrIs {
+ assert.ErrorIs(t, err, e)
+ }
+
+ if tt.wantErr == "" && len(tt.wantErrIs) == 0 {
+ assert.NoError(t, err)
+ assert.Equal(t, tt.want, got)
+ }
+ })
+ }
+}
+
+func TestXML_Formats(t *testing.T) {
+ h := &XML{}
+
+ assert.Equal(t, []string{"xml"}, h.Formats())
+}
diff --git a/yaml.go b/yaml.go
index 5f43ac9..e2bd7bb 100644
--- a/yaml.go
+++ b/yaml.go
@@ -7,24 +7,28 @@ import (
"gopkg.in/yaml.v3"
)
-// YAML is a renderer that marshals the given value to YAML.
+var YAMLDefaultIndent = 2
+
+// YAML is a Handler that marshals the given value to YAML.
type YAML struct {
// Indent controls how many spaces will be used for indenting nested blocks
- // in the output YAML. When Indent is zero, 2 will be used by default.
+ // in the output YAML. When Indent is zero, YAMLDefaultIndent will be used.
Indent int
}
-var _ FormatRenderer = (*YAML)(nil)
+var (
+ _ Handler = (*YAML)(nil)
+ _ FormatsHandler = (*YAML)(nil)
+)
// Render marshals the given value to YAML.
func (y *YAML) Render(w io.Writer, v any) error {
- enc := yaml.NewEncoder(w)
-
indent := y.Indent
if indent == 0 {
- indent = 2
+ indent = YAMLDefaultIndent
}
+ enc := yaml.NewEncoder(w)
enc.SetIndent(indent)
err := enc.Encode(v)
@@ -35,6 +39,7 @@ func (y *YAML) Render(w io.Writer, v any) error {
return nil
}
+// Formats returns a list of format strings that this Handler supports.
func (y *YAML) Formats() []string {
return []string{"yaml", "yml"}
}
diff --git a/yaml_test.go b/yaml_test.go
index 477af86..3565659 100644
--- a/yaml_test.go
+++ b/yaml_test.go
@@ -24,7 +24,7 @@ func TestYAML_Render(t *testing.T) {
tests := []struct {
name string
indent int
- value interface{}
+ value any
want string
wantErr string
wantErrIs []error
@@ -113,3 +113,9 @@ func TestYAML_Render(t *testing.T) {
})
}
}
+
+func TestYAML_Formats(t *testing.T) {
+ h := &YAML{}
+
+ assert.Equal(t, []string{"yaml", "yml"}, h.Formats())
+}