refactor: focus around Render/Compact/Pretty/NewWith functions

This is yet another drastic refactor of public API and concepts.
Hopefully the last one, as I'm now fairly happy with things.
This commit is contained in:
2024-03-25 01:40:31 +00:00
parent de3a9e55a8
commit e2e2754970
18 changed files with 1462 additions and 387 deletions

View File

@@ -10,7 +10,10 @@ import (
// interface. // interface.
type Binary struct{} 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 // Render writes result of calling MarshalBinary() on v. If v does not implment
// encoding.BinaryMarshaler the ErrCannotRander error will be returned. // encoding.BinaryMarshaler the ErrCannotRander error will be returned.
@@ -33,6 +36,7 @@ func (br *Binary) Render(w io.Writer, v any) error {
return nil return nil
} }
// Formats returns a list of format strings that this Handler supports.
func (br *Binary) Formats() []string { func (br *Binary) Formats() []string {
return []string{"binary", "bin"} return []string{"binary", "bin"}
} }

View File

@@ -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())
}

41
interfaces.go Normal file
View File

@@ -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
}

42
json.go
View File

@@ -6,35 +6,48 @@ import (
"io" "io"
) )
// JSON is a renderer that marshals values to JSON. // JSONDefualtIndent is the default indentation string used by JSON instances
type JSON struct { // when pretty rendering if no Indent value is set on the JSON instance itself.
// Pretty specifies whether the output should be pretty-printed. If true, var JSONDefualtIndent = " "
// 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 // JSON is a Handler that marshals values to JSON.
// true. type JSON struct {
// Prefix is the prefix added to each level of indentation when pretty
// rendering.
Prefix string Prefix string
// Indent is the string added to each level of indentation when Pretty is // Indent is the string added to each level of indentation when pretty
// true. If empty, two spaces will be used instead. // rendering. If empty, two spaces will be used instead.
Indent string Indent string
} }
var _ FormatRenderer = (*JSON)(nil) var (
_ Handler = (*JSON)(nil)
_ PrettyHandler = (*JSON)(nil)
_ FormatsHandler = (*JSON)(nil)
)
// Render marshals the given value to JSON. // Render marshals the given value to JSON.
func (jr *JSON) Render(w io.Writer, v any) error { func (jr *JSON) Render(w io.Writer, v any) error {
enc := json.NewEncoder(w) err := json.NewEncoder(w).Encode(v)
if jr.Pretty { 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 prefix := jr.Prefix
indent := jr.Indent indent := jr.Indent
if indent == "" { if indent == "" {
indent = " " indent = JSONDefualtIndent
} }
enc := json.NewEncoder(w)
enc.SetIndent(prefix, indent) enc.SetIndent(prefix, indent)
}
err := enc.Encode(v) err := enc.Encode(v)
if err != nil { if err != nil {
@@ -44,6 +57,7 @@ func (jr *JSON) Render(w io.Writer, v any) error {
return nil return nil
} }
// Formats returns a list of format strings that this Handler supports.
func (jr *JSON) Formats() []string { func (jr *JSON) Formats() []string {
return []string{"json"} return []string{"json"}
} }

View File

@@ -24,37 +24,21 @@ func (mjm *mockJSONMarshaler) MarshalJSON() ([]byte, error) {
func TestJSON_Render(t *testing.T) { func TestJSON_Render(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
pretty bool
prefix string prefix string
indent string indent string
value interface{} value any
want string want string
wantPretty string
wantErr string wantErr string
wantErrIs []error wantErrIs []error
}{ }{
{ {
name: "simple object without pretty", name: "simple object",
pretty: false,
value: map[string]int{"age": 30}, value: map[string]int{"age": 30},
want: "{\"age\":30}\n", want: "{\"age\":30}\n",
}, },
{ {
name: "simple object with pretty", name: "ignores prefix and indent",
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,
prefix: "// ", prefix: "// ",
indent: "\t", indent: "\t",
value: map[string]int{"age": 30}, value: map[string]int{"age": 30},
@@ -72,7 +56,6 @@ func TestJSON_Render(t *testing.T) {
}, },
{ {
name: "invalid value", name: "invalid value",
pretty: false,
value: make(chan int), value: make(chan int),
wantErr: "render: failed: json: unsupported type: chan int", wantErr: "render: failed: json: unsupported type: chan int",
wantErrIs: []error{Err, ErrFailed}, wantErrIs: []error{Err, ErrFailed},
@@ -81,7 +64,6 @@ func TestJSON_Render(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
j := &JSON{ j := &JSON{
Pretty: tt.pretty,
Prefix: tt.prefix, Prefix: tt.prefix,
Indent: tt.indent, 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())
}

View File

@@ -6,18 +6,22 @@ import (
"io" "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 { 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, // Render tries each handler in order until one succeeds. If none succeed,
// ErrCannotRender is returned. If a renderer returns an error that is not // ErrCannotRender is returned. If a handler returns an error that is not
// ErrCannotRender, that error is returned. // ErrCannotRender, that error is returned.
func (mr *Multi) Render(w io.Writer, v any) error { 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) err := r.Render(w, v)
if err == nil { if err == nil {
return nil return nil
@@ -30,11 +34,37 @@ func (mr *Multi) Render(w io.Writer, v any) error {
return fmt.Errorf("%w: %T", ErrCannotRender, v) 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 { func (mr *Multi) Formats() []string {
formats := make(map[string]struct{}) formats := make(map[string]struct{})
for _, r := range mr.Renderers { for _, r := range mr.Handlers {
if x, ok := r.(Formats); ok { if x, ok := r.(FormatsHandler); ok {
for _, f := range x.Formats() { for _, f := range x.Formats() {
formats[f] = struct{}{} formats[f] = struct{}{}
} }

View File

@@ -8,84 +8,129 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestMultiRenderer_Render(t *testing.T) { var multiHandlerTestCases = []struct {
successRenderer := &mockRenderer{output: "success output"}
cannotRenderer := &mockRenderer{err: ErrCannotRender}
failRenderer := &mockRenderer{err: errors.New("mock error")}
tests := []struct {
name string name string
renderers []FormatRenderer handlers []Handler
value interface{} value any
want string want string
wantPretty string
wantErr string wantErr string
wantErrIs []error wantErrIs []error
}{ }{
{ {
name: "no renderer can render", name: "no handler can render",
renderers: []FormatRenderer{ handlers: []Handler{
cannotRenderer, &mockHandler{err: ErrCannotRender},
cannotRenderer, &mockHandler{err: ErrCannotRender},
}, },
value: "test", value: "test",
wantErr: "render: cannot render: string", wantErr: "render: cannot render: string",
wantErrIs: []error{ErrCannotRender}, wantErrIs: []error{ErrCannotRender},
}, },
{ {
name: "one renderer can render", name: "one handler can render",
renderers: []FormatRenderer{ handlers: []Handler{
cannotRenderer, &mockHandler{err: ErrCannotRender},
successRenderer, &mockHandler{output: "success output"},
cannotRenderer, &mockHandler{err: ErrCannotRender},
}, },
value: struct{}{}, value: struct{}{},
want: "success output", want: "success output",
wantPretty: "success output",
}, },
{ {
name: "multiple renderers can render", name: "one pretty handler can render",
renderers: []FormatRenderer{ handlers: []Handler{
&mockRenderer{err: ErrCannotRender}, &mockHandler{err: ErrCannotRender},
&mockRenderer{output: "first output"}, &mockPrettyHandler{
&mockRenderer{output: "second output"}, 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{}{}, value: struct{}{},
want: "first output", want: "first output",
wantPretty: "first output",
}, },
{ {
name: "first renderer fails", name: "multiple pretty handlers can render",
renderers: []FormatRenderer{ handlers: []Handler{
failRenderer, &mockHandler{err: ErrCannotRender},
successRenderer, &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{}{}, value: struct{}{},
wantErr: "mock error", wantErr: "mock error",
}, },
{ {
name: "fails after cannot render", name: "fails after cannot render",
renderers: []FormatRenderer{ handlers: []Handler{
cannotRenderer, &mockHandler{err: ErrCannotRender},
failRenderer, &mockHandler{err: errors.New("mock error")},
successRenderer, &mockHandler{output: "success output"},
}, },
value: struct{}{}, value: struct{}{},
wantErr: "mock error", wantErr: "mock error",
}, },
{ {
name: "fails after success render", name: "fails after success render",
renderers: []FormatRenderer{ handlers: []Handler{
successRenderer, &mockHandler{output: "success output"},
failRenderer, &mockHandler{err: errors.New("mock error")},
cannotRenderer, &mockHandler{err: ErrCannotRender},
}, },
value: struct{}{}, value: struct{}{},
want: "success output", 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",
}, },
} }
for _, tt := range tests { func TestMulti_Render(t *testing.T) {
for _, tt := range multiHandlerTestCases {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
mr := &Multi{ mr := &Multi{
Renderers: tt.renderers, Handlers: tt.handlers,
} }
var buf bytes.Buffer 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)
})
}
}

164
render.go
View File

@@ -1,14 +1,20 @@
// Package render provides a simple and flexible way to render a value to a // 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. // 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, // It is designed around using a custom type/struct to render your output.
// while also supporting plain text by implementing fmt.Stringer or io.WriterTo. // Thanks to Go's marshaling interfaces, you get JSON, YAML, and XML support
// Binary output is also supported by implementing the encoding.BinaryMarshaler // almost for free. While plain text output is supported by the type
// interface. // 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 // 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. // 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. // 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 package render
import ( import (
@@ -26,36 +32,11 @@ var (
// due to the value not supporting the format, or the value itself not being // due to the value not supporting the format, or the value itself not being
// renderable. Only Renderer implementations should return this error. // renderable. Only Renderer implementations should return this error.
ErrCannotRender = fmt.Errorf("%w: cannot render", Err) ErrCannotRender = fmt.Errorf("%w: cannot render", Err)
)
// FormatRenderer interface is for single format renderers, which can only // Base is a renderer that supports all formats. It is used by the package
// render a single format. // level NewWith function to create new renderers with a sub-set of
type FormatRenderer interface { // formats.
// Render writes v into w in the format that the FormatRenderer supports. Base = New(map[string]Handler{
//
// 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{
"binary": &Binary{}, "binary": &Binary{},
"json": &JSON{}, "json": &JSON{},
"text": &Text{}, "text": &Text{},
@@ -63,110 +44,39 @@ var (
"yaml": &YAML{}, "yaml": &YAML{},
}) })
DefaultPretty = prettyRenderer.OnlyWith("json", "text", "xml", "yaml") // Default is the default renderer that is used by package level Render,
DefaultCompact = compactRenderer.OnlyWith("json", "text", "xml", "yaml") // 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. // Render renders the given value to the given writer using the given format. If
// If pretty is true, the value will be rendered in a pretty way, otherwise it // pretty is true, the value will be rendered "pretty" if the target format
// will be rendered in a compact way. // 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. // If you need to support a custom set of formats, use the New function to
// - "json": Renders values using the encoding/json package. // create a new Renderer with the formats you need. If you need new custom
// - "yaml": Renders values using the gopkg.in/yaml.v3 package. // renderers, manually create a new Renderer.
// - "xml": Renders values using the encoding/xml package.
//
// If the format is not supported, a ErrUnsupportedFormat error will be
// returned.
func Render(w io.Writer, format string, pretty bool, v any) error { func Render(w io.Writer, format string, pretty bool, v any) error {
if pretty { return Default.Render(w, format, pretty, v)
return DefaultPretty.Render(w, format, v)
} }
return DefaultCompact.Render(w, format, v) // 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.
// 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.
func Compact(w io.Writer, format string, v any) error { 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 // Pretty is a convenience function that calls the Default renderer's Pretty
// and renders structured formats compactly. If no formats are specified, a // method. It is the same as calling Render with pretty set to true.
// error is returned. func Pretty(w io.Writer, format string, v any) error {
// return Default.Pretty(w, format, v)
// 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 { // NewWith creates a new Renderer with the given formats. Only formats on the
if _, ok := compactRenderer.Renderers[format]; !ok { // BaseRender will be supported.
return nil, fmt.Errorf("%w: %s", ErrUnsupportedFormat, format) func NewWith(formats ...string) *Renderer {
} return Base.NewWith(formats...)
}
return compactRenderer.OnlyWith(formats...), nil
}
// 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
} }

View File

@@ -39,10 +39,7 @@ func ExampleRender_json() {
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
err := render.Pretty(buf, "json", data) err := render.Pretty(buf, "json", data)
if err != nil { if err != nil {
fmt.Printf("err: %s\n", err) panic(err)
return
// panic(err)
} }
fmt.Println(buf.String()) fmt.Println(buf.String())
@@ -142,13 +139,13 @@ func ExampleRender_xml() {
Tags: []string{"golang", "json", "yaml", "toml"}, Tags: []string{"golang", "json", "yaml", "toml"},
} }
// Create a new renderer that supports XML in addition to default JSON, YAML // Create a new renderer that supports XML in addition to the default JSON,
// and Text. // Text, and YAML formats.
r, _ := render.NewPretty("json", "text", "xml", "yaml") renderer := render.NewWith("json", "text", "xml", "yaml")
// Render the object to XML. // Render the object to XML.
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
err := r.Render(buf, "xml", data) err := renderer.Pretty(buf, "xml", data)
if err != nil { if err != nil {
panic(err) panic(err)
} }

View File

@@ -30,23 +30,93 @@ func (mw *mockWriter) String() string {
return mw.buf.String() return mw.buf.String()
} }
type mockRenderer struct { type mockHandler struct {
output string output string
formats []string
err error err error
} }
var _ FormatRenderer = (*mockRenderer)(nil) var (
_ Handler = (*mockHandler)(nil)
_ FormatsHandler = (*mockHandler)(nil)
)
func (m *mockRenderer) Render(w io.Writer, _ any) error { func (mh *mockHandler) Render(w io.Writer, _ any) error {
_, err := w.Write([]byte(m.output)) _, err := w.Write([]byte(mh.output))
if m.err != nil { if mh.err != nil {
return m.err return mh.err
} }
return 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 { type renderFormatTestCase struct {
name string name string
writeErr error writeErr error
@@ -69,6 +139,12 @@ var binaryFormattestCases = []renderFormatTestCase{
value: &mockBinaryMarshaler{data: []byte("test string")}, value: &mockBinaryMarshaler{data: []byte("test string")},
want: "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", name: "without binary marshaler",
formats: []string{"binary", "bin"}, formats: []string{"binary", "bin"},
@@ -112,6 +188,13 @@ var jsonFormatTestCases = []renderFormatTestCase{
wantPretty: "{\n \"age\": 30\n}\n", wantPretty: "{\n \"age\": 30\n}\n",
wantCompact: "{\"age\":30}\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", name: "with json marshaler",
formats: []string{"json"}, formats: []string{"json"},
@@ -151,6 +234,12 @@ var textFormatTestCases = []renderFormatTestCase{
wantErr: "render: unsupported format: {{format}}", wantErr: "render: unsupported format: {{format}}",
wantErrIs: []error{Err, ErrUnsupportedFormat}, wantErrIs: []error{Err, ErrUnsupportedFormat},
}, },
{
name: "capitalized format",
formats: []string{"TEXT", "TXT", "PLAIN"},
value: []byte("test byte slice"),
want: "test byte slice",
},
{ {
name: "byte slice", name: "byte slice",
formats: []string{"text", "txt", "plain"}, formats: []string{"text", "txt", "plain"},
@@ -344,6 +433,12 @@ var xmlFormatTestCases = []renderFormatTestCase{
value: &mockXMLMarshaler{elm: "test string"}, value: &mockXMLMarshaler{elm: "test string"},
want: "<mockXMLMarshaler>test string</mockXMLMarshaler>", want: "<mockXMLMarshaler>test string</mockXMLMarshaler>",
}, },
{
name: "capitalized format",
formats: []string{"XML"},
value: &mockXMLMarshaler{elm: "test string"},
want: "<mockXMLMarshaler>test string</mockXMLMarshaler>",
},
{ {
name: "xml format with error from xml.Marshaler", name: "xml format with error from xml.Marshaler",
formats: []string{"xml"}, formats: []string{"xml"},
@@ -379,6 +474,12 @@ var yamlFormatTestCases = []renderFormatTestCase{
value: map[string]int{"age": 30}, value: map[string]int{"age": 30},
want: "age: 30\n", 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", name: "yaml format with nested structure",
formats: []string{"yaml", "yml"}, 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) { func TestPretty(t *testing.T) {
tests := []renderFormatTestCase{} tests := []renderFormatTestCase{}
tests = append(tests, jsonFormatTestCases...) 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)
})
}
}

View File

@@ -4,61 +4,86 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"strings"
) )
// ErrUnsupportedFormat is returned when a format is not supported by a // ErrUnsupportedFormat is returned when a format is not supported by any
// renderer. Any method that accepts a format string may return this error. // Handler.
var ErrUnsupportedFormat = fmt.Errorf("%w: unsupported format", Err) var ErrUnsupportedFormat = fmt.Errorf("%w: unsupported format", Err)
// Renderer is a renderer that delegates rendering to another renderer // Renderer exposes methods for rendering values to different formats. The
// based on a format value. // Renderer delegates rendering to format specific handlers based on the format
// string given.
type Renderer struct { type Renderer struct {
// Renderers is a map of format names to renderers. When Render is called, // Handlers is a map of format names to Handler. When Render is called,
// the format is used to look up the renderer to use. // the format is used to look up the Handler to use.
Renderers map[string]FormatRenderer Handlers map[string]Handler
} }
// New returns a new Renderer that delegates rendering to the specified // 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 // Any Handlers which implement the FormatsHandler interface, will also be set
// renderer for all format strings returned by Format() on the renderer. // as the handler for all format strings returned by Formats() on the handler.
func New(renderers map[string]FormatRenderer) *Renderer { func New(handlers map[string]Handler) *Renderer {
newRenderers := make(map[string]FormatRenderer, len(renderers)) r := &Renderer{Handlers: make(map[string]Handler, len(handlers))}
for format, r := range renderers { for format, handler := range handlers {
newRenderers[format] = r r.Add(format, handler)
}
if x, ok := r.(Formats); ok { 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() { for _, f := range x.Formats() {
if f != format { if f != "" && f != format {
newRenderers[f] = r r.Handlers[strings.ToLower(f)] = handler
} }
} }
} }
} }
return &Renderer{Renderers: newRenderers} // Render renders a value to the given io.Writer using the specified format.
}
// Render renders a value to an io.Writer using the specified format. If the
// format is not supported, ErrUnsupportedFormat is returned.
// //
// If the format is supported, but the value cannot be rendered to the format, // If pretty is true, it will attempt to render the value with pretty
// the error returned by the renderer is returned. In most cases this will be // formatting if the underlying Handler supports pretty formatting.
// ErrCannotRender, but it could be a different error if the renderer returns //
// one. // If the format is not supported or the value cannot be rendered to the format,
func (r *Renderer) Render(w io.Writer, format string, v any) error { // a ErrUnsupportedFormat error is returned.
renderer, ok := r.Renderers[format] func (r *Renderer) Render(
w io.Writer,
format string,
pretty bool,
v any,
) error {
handler, ok := r.Handlers[strings.ToLower(format)]
if !ok { if !ok {
return fmt.Errorf("%w: %s", ErrUnsupportedFormat, format) 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 err != nil {
if errors.Is(err, ErrCannotRender) { if errors.Is(err, ErrCannotRender) {
return fmt.Errorf("%w: %s", ErrUnsupportedFormat, format) 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) { if !errors.Is(err, ErrFailed) {
return fmt.Errorf("%w: %w", ErrFailed, err) 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 return nil
} }
func (r *Renderer) OnlyWith(formats ...string) *Renderer { // Compact is a convenience method that calls Render with pretty set to false.
renderers := make(map[string]FormatRenderer, len(formats)) 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 { for _, format := range formats {
if r, ok := r.Renderers[format]; ok { if r, ok := r.Handlers[strings.ToLower(format)]; ok {
renderers[format] = r handlers[format] = r
} }
} }
return New(renderers) return New(handlers)
} }

View File

@@ -8,32 +8,230 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "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) { func TestRenderer_Render(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
renderers map[string]FormatRenderer handlers map[string]Handler
format string format string
value interface{} pretty bool
value any
want string want string
wantErr string wantErr string
wantErrIs []error wantErrIs []error
}{ }{
{ {
name: "existing renderer", name: "no pretty with handler that supports pretty",
renderers: map[string]FormatRenderer{ handlers: map[string]Handler{
"mock": &mockRenderer{output: "mock output"}, "mock": &mockPrettyHandler{
output: "plain output",
prettyOutput: "pretty output",
},
}, },
format: "mock", format: "mock",
pretty: false,
value: struct{}{}, value: struct{}{},
want: "mock output", want: "plain output",
}, },
{ {
name: "existing renderer returns error", name: "no pretty with handler that does not support pretty",
renderers: map[string]FormatRenderer{ handlers: map[string]Handler{
"other": &mockRenderer{ "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", output: "mock output",
err: errors.New("mock error"), err: errors.New("mock error"),
}, },
@@ -43,9 +241,9 @@ func TestRenderer_Render(t *testing.T) {
wantErr: "render: failed: mock error", wantErr: "render: failed: mock error",
}, },
{ {
name: "existing renderer returns ErrCannotRender", name: "handler returns ErrCannotRender",
renderers: map[string]FormatRenderer{ handlers: map[string]Handler{
"other": &mockRenderer{ "other": &mockHandler{
output: "mock output", output: "mock output",
err: fmt.Errorf("%w: mock", ErrCannotRender), err: fmt.Errorf("%w: mock", ErrCannotRender),
}, },
@@ -56,8 +254,8 @@ func TestRenderer_Render(t *testing.T) {
wantErrIs: []error{Err, ErrUnsupportedFormat}, wantErrIs: []error{Err, ErrUnsupportedFormat},
}, },
{ {
name: "non-existing renderer", name: "non-existing handler",
renderers: map[string]FormatRenderer{}, handlers: map[string]Handler{},
format: "unknown", format: "unknown",
value: struct{}{}, value: struct{}{},
wantErr: "render: unsupported format: unknown", wantErr: "render: unsupported format: unknown",
@@ -66,12 +264,194 @@ func TestRenderer_Render(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
fr := &Renderer{ r := &Renderer{
Renderers: tt.renderers, Handlers: tt.handlers,
} }
var buf bytes.Buffer 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() got := buf.String()
if tt.wantErr != "" { if tt.wantErr != "" {
@@ -97,6 +477,75 @@ func TestRenderer_RenderAllFormats(t *testing.T) {
tests = append(tests, xmlFormatTestCases...) tests = append(tests, xmlFormatTestCases...)
tests = append(tests, yamlFormatTestCases...) 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 _, tt := range tests {
for _, format := range tt.formats { for _, format := range tt.formats {
t.Run(format+" format "+tt.name, func(t *testing.T) { t.Run(format+" format "+tt.name, func(t *testing.T) {
@@ -109,8 +558,6 @@ func TestRenderer_RenderAllFormats(t *testing.T) {
var err error var err error
var panicRes any var panicRes any
renderer := compactRenderer
require.NoError(t, err)
func() { func() {
defer func() { defer func() {
@@ -118,7 +565,7 @@ func TestRenderer_RenderAllFormats(t *testing.T) {
panicRes = r panicRes = r
} }
}() }()
err = renderer.Render(w, format, value) err = Base.Compact(w, format, value)
}() }()
got := w.String() 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)
}
})
}
}
}

12
text.go
View File

@@ -5,8 +5,10 @@ import (
"io" "io"
) )
// Text is a renderer that writes the given value to the writer as is. It // Text is a Handler that writes the given value to the writer as text,
// supports rendering the following types as plain text: // supporting multiple types and interfaces.
//
// Supports rendering the following types as text:
// //
// - []byte // - []byte
// - []rune // - []rune
@@ -23,7 +25,10 @@ import (
// If the value is of any other type, a ErrCannotRender error will be returned. // If the value is of any other type, a ErrCannotRender error will be returned.
type Text struct{} type Text struct{}
var _ FormatRenderer = (*Text)(nil) var (
_ Handler = (*Text)(nil)
_ FormatsHandler = (*Text)(nil)
)
// Render writes the given value to the writer as text. // Render writes the given value to the writer as text.
func (t *Text) Render(w io.Writer, v any) error { 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 return nil
} }
// Formats returns a list of format strings that this Handler supports.
func (t *Text) Formats() []string { func (t *Text) Formats() []string {
return []string{"text", "txt", "plain"} return []string{"text", "txt", "plain"}
} }

View File

@@ -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())
}

40
xml.go
View File

@@ -6,35 +6,48 @@ import (
"io" "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. // XML is a Renderer that marshals a value to XML.
type XML struct { type XML struct {
// Pretty specifies whether the output should be pretty-printed. If true, // Prefix is the prefix added to each level of indentation when pretty
// the output will be indented and newlines will be added. // rendering.
Pretty bool
// Prefix is the prefix added to each level of indentation when Pretty is
// true.
Prefix string Prefix string
// Indent is the string added to each level of indentation when Pretty is // Indent is the string added to each level of indentation when pretty
// true. If empty, two spaces will be used instead. // rendering. If empty, XMLDefualtIndent be used.
Indent string Indent string
} }
var _ FormatRenderer = (*XML)(nil) var (
_ Handler = (*XML)(nil)
_ PrettyHandler = (*XML)(nil)
_ FormatsHandler = (*XML)(nil)
)
// Render marshals the given value to XML. // Render marshals the given value to XML.
func (x *XML) Render(w io.Writer, v any) error { func (x *XML) Render(w io.Writer, v any) error {
enc := xml.NewEncoder(w) err := xml.NewEncoder(w).Encode(v)
if x.Pretty { 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 prefix := x.Prefix
indent := x.Indent indent := x.Indent
if indent == "" { if indent == "" {
indent = " " indent = XMLDefualtIndent
} }
enc := xml.NewEncoder(w)
enc.Indent(prefix, indent) enc.Indent(prefix, indent)
}
err := enc.Encode(v) err := enc.Encode(v)
if err != nil { if err != nil {
@@ -44,6 +57,7 @@ func (x *XML) Render(w io.Writer, v any) error {
return nil return nil
} }
// Formats returns a list of format strings that this Handler supports.
func (x *XML) Formats() []string { func (x *XML) Formats() []string {
return []string{"xml"} return []string{"xml"}
} }

View File

@@ -32,7 +32,6 @@ func (mxm *mockXMLMarshaler) MarshalXML(
func TestXML_Render(t *testing.T) { func TestXML_Render(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
pretty bool
prefix string prefix string
indent string indent string
value any value any
@@ -41,8 +40,7 @@ func TestXML_Render(t *testing.T) {
wantErrIs []error wantErrIs []error
}{ }{
{ {
name: "simple object without pretty", name: "simple object",
pretty: false,
value: struct { value: struct {
XMLName xml.Name `xml:"user"` XMLName xml.Name `xml:"user"`
Age int `xml:"age"` Age int `xml:"age"`
@@ -50,28 +48,7 @@ func TestXML_Render(t *testing.T) {
want: `<user><age>30</age></user>`, want: `<user><age>30</age></user>`,
}, },
{ {
name: "simple object with pretty", name: "ignores indent without pretty",
pretty: true,
value: struct {
XMLName xml.Name `xml:"user"`
Age int `xml:"age"`
}{Age: 30},
want: "<user>\n <age>30</age>\n</user>",
},
{
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: "//<user>\n//\t<age>30</age>\n//</user>",
},
{
name: "prefix and indent without pretty",
pretty: false,
prefix: "//", prefix: "//",
indent: "\t", indent: "\t",
value: struct { value: struct {
@@ -93,7 +70,6 @@ func TestXML_Render(t *testing.T) {
}, },
{ {
name: "invalid value", name: "invalid value",
pretty: false,
value: make(chan int), value: make(chan int),
wantErr: "render: failed: xml: unsupported type: chan int", wantErr: "render: failed: xml: unsupported type: chan int",
wantErrIs: []error{Err, ErrFailed}, wantErrIs: []error{Err, ErrFailed},
@@ -103,7 +79,6 @@ func TestXML_Render(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
x := &XML{ x := &XML{
Pretty: tt.pretty,
Prefix: tt.prefix, Prefix: tt.prefix,
Indent: tt.indent, 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: "<user>\n <age>30</age>\n</user>",
},
{
name: "uses prefix and indent",
prefix: "//",
indent: "\t",
value: struct {
XMLName xml.Name `xml:"user"`
Age int `xml:"age"`
}{Age: 30},
want: "//<user>\n//\t<age>30</age>\n//</user>",
},
{
name: "implements xml.Marshaler",
value: &mockXMLMarshaler{elm: "test string"},
want: "<mockXMLMarshaler>test string</mockXMLMarshaler>",
},
{
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())
}

17
yaml.go
View File

@@ -7,24 +7,28 @@ import (
"gopkg.in/yaml.v3" "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 { type YAML struct {
// Indent controls how many spaces will be used for indenting nested blocks // 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 Indent int
} }
var _ FormatRenderer = (*YAML)(nil) var (
_ Handler = (*YAML)(nil)
_ FormatsHandler = (*YAML)(nil)
)
// Render marshals the given value to YAML. // Render marshals the given value to YAML.
func (y *YAML) Render(w io.Writer, v any) error { func (y *YAML) Render(w io.Writer, v any) error {
enc := yaml.NewEncoder(w)
indent := y.Indent indent := y.Indent
if indent == 0 { if indent == 0 {
indent = 2 indent = YAMLDefaultIndent
} }
enc := yaml.NewEncoder(w)
enc.SetIndent(indent) enc.SetIndent(indent)
err := enc.Encode(v) err := enc.Encode(v)
@@ -35,6 +39,7 @@ func (y *YAML) Render(w io.Writer, v any) error {
return nil return nil
} }
// Formats returns a list of format strings that this Handler supports.
func (y *YAML) Formats() []string { func (y *YAML) Formats() []string {
return []string{"yaml", "yml"} return []string{"yaml", "yml"}
} }

View File

@@ -24,7 +24,7 @@ func TestYAML_Render(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
indent int indent int
value interface{} value any
want string want string
wantErr string wantErr string
wantErrIs []error 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())
}