mirror of
https://github.com/jimeh/go-render.git
synced 2026-02-19 03:16:39 +00:00
feat(render): experimental package to render arbitrary values to different formats
This commit is contained in:
34
binary.go
Normal file
34
binary.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Binary can render values which implment the encoding.BinaryMarshaler
|
||||||
|
// interface.
|
||||||
|
type Binary struct{}
|
||||||
|
|
||||||
|
var _ Renderer = (*Binary)(nil)
|
||||||
|
|
||||||
|
// Render writes result of calling MarshalBinary() on v. If v does not implment
|
||||||
|
// encoding.BinaryMarshaler the ErrCannotRander error will be returned.
|
||||||
|
func (bm *Binary) Render(w io.Writer, v any) error {
|
||||||
|
x, ok := v.(encoding.BinaryMarshaler)
|
||||||
|
if !ok {
|
||||||
|
return ErrCannotRender
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := x.MarshalBinary()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %w", Err, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = w.Write(b)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %w", Err, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
82
binary_test.go
Normal file
82
binary_test.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package render_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jimeh/go-render"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockBinaryMarshaler struct {
|
||||||
|
data []byte
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mbm *mockBinaryMarshaler) MarshalBinary() ([]byte, error) {
|
||||||
|
return mbm.data, mbm.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBinary_Render(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
writeErr error
|
||||||
|
value any
|
||||||
|
want string
|
||||||
|
wantErr string
|
||||||
|
wantErrIs []error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "implements encoding.BinaryMarshaler",
|
||||||
|
value: &mockBinaryMarshaler{data: []byte("test string")},
|
||||||
|
want: "test string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "does not implement encoding.BinaryMarshaler",
|
||||||
|
value: struct{}{},
|
||||||
|
wantErrIs: []error{render.Err, render.ErrCannotRender},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error marshaling",
|
||||||
|
value: &mockBinaryMarshaler{
|
||||||
|
data: []byte("test string"),
|
||||||
|
err: errors.New("marshal error!!1"),
|
||||||
|
},
|
||||||
|
wantErr: "render: marshal error!!1",
|
||||||
|
wantErrIs: []error{render.Err},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error writing to writer",
|
||||||
|
writeErr: errors.New("write error!!1"),
|
||||||
|
value: &mockBinaryMarshaler{data: []byte("test string")},
|
||||||
|
wantErr: "render: write error!!1",
|
||||||
|
wantErrIs: []error{render.Err},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
b := &render.Binary{}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
var got string
|
||||||
|
w := &mockWriter{WriteErr: tt.writeErr}
|
||||||
|
|
||||||
|
err = b.Render(w, tt.value)
|
||||||
|
got = w.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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
27
format.go
Normal file
27
format.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatRenderer is a renderer that delegates rendering to another renderer
|
||||||
|
// based on a format value.
|
||||||
|
type FormatRenderer struct {
|
||||||
|
Renderers map[string]Renderer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render renders a value to an io.Writer using the specified format. If the
|
||||||
|
// format is not supported, ErrCannotRender is returned.
|
||||||
|
//
|
||||||
|
// 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 *FormatRenderer) Render(w io.Writer, format string, v any) error {
|
||||||
|
renderer, ok := r.Renderers[format]
|
||||||
|
if ok {
|
||||||
|
return renderer.Render(w, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ErrCannotRender
|
||||||
|
}
|
||||||
76
format_test.go
Normal file
76
format_test.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package render_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jimeh/go-render"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatRenderer_Render(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
renderers map[string]render.Renderer
|
||||||
|
format string
|
||||||
|
value interface{}
|
||||||
|
want string
|
||||||
|
wantErr string
|
||||||
|
wantErrIs []error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "existing renderer",
|
||||||
|
renderers: map[string]render.Renderer{
|
||||||
|
"mock": &mockRenderer{output: "mock output"},
|
||||||
|
},
|
||||||
|
format: "mock",
|
||||||
|
value: struct{}{},
|
||||||
|
want: "mock output",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "existing renderer returns error",
|
||||||
|
renderers: map[string]render.Renderer{
|
||||||
|
"other": &mockRenderer{
|
||||||
|
output: "mock output",
|
||||||
|
err: errors.New("mock error"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
format: "other",
|
||||||
|
value: struct{}{},
|
||||||
|
wantErr: "mock error",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-existing renderer",
|
||||||
|
renderers: map[string]render.Renderer{},
|
||||||
|
format: "unknown",
|
||||||
|
value: struct{}{},
|
||||||
|
wantErrIs: []error{render.Err, render.ErrCannotRender},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
fr := &render.FormatRenderer{
|
||||||
|
Renderers: tt.renderers,
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := fr.Render(&buf, tt.format, 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
13
go.mod
Normal file
13
go.mod
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
module github.com/jimeh/go-render
|
||||||
|
|
||||||
|
go 1.20
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/stretchr/testify v1.9.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
)
|
||||||
10
go.sum
Normal file
10
go.sum
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
45
json.go
Normal file
45
json.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"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
|
||||||
|
|
||||||
|
// Prefix is the prefix added to each level of indentation when Pretty is
|
||||||
|
// true.
|
||||||
|
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 string
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Renderer = (*JSON)(nil)
|
||||||
|
|
||||||
|
// Render marshals the given value to JSON.
|
||||||
|
func (j *JSON) Render(w io.Writer, v any) error {
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
if j.Pretty {
|
||||||
|
prefix := j.Prefix
|
||||||
|
indent := j.Indent
|
||||||
|
if indent == "" {
|
||||||
|
indent = " "
|
||||||
|
}
|
||||||
|
|
||||||
|
enc.SetIndent(prefix, indent)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := enc.Encode(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %w", Err, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
79
json_test.go
Normal file
79
json_test.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package render_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jimeh/go-render"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
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: "simple object without pretty",
|
||||||
|
pretty: false,
|
||||||
|
value: map[string]int{"age": 30},
|
||||||
|
want: "{\"age\":30}\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple object with pretty",
|
||||||
|
pretty: true,
|
||||||
|
indent: " ",
|
||||||
|
value: map[string]int{"age": 30},
|
||||||
|
want: "{\n \"age\": 30\n}\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with prefix and indent",
|
||||||
|
pretty: true,
|
||||||
|
prefix: "// ",
|
||||||
|
indent: "\t",
|
||||||
|
value: map[string]int{"age": 30},
|
||||||
|
want: "{\n// \t\"age\": 30\n// }\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid value",
|
||||||
|
pretty: false,
|
||||||
|
value: make(chan int),
|
||||||
|
wantErr: "render: json: unsupported type: chan int",
|
||||||
|
wantErrIs: []error{render.Err},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
j := &render.JSON{
|
||||||
|
Pretty: tt.pretty,
|
||||||
|
Prefix: tt.prefix,
|
||||||
|
Indent: tt.indent,
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := j.Render(&buf, tt.value)
|
||||||
|
|
||||||
|
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)
|
||||||
|
got := buf.String()
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
30
multi.go
Normal file
30
multi.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MultiRenderer is a renderer that tries multiple renderers until one succeeds.
|
||||||
|
type MultiRenderer struct {
|
||||||
|
Renderers []Renderer
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Renderer = (*MultiRenderer)(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
|
||||||
|
// ErrCannotRender, that error is returned.
|
||||||
|
func (mr *MultiRenderer) Render(w io.Writer, v any) error {
|
||||||
|
for _, r := range mr.Renderers {
|
||||||
|
err := r.Render(w, v)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrCannotRender) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ErrCannotRender
|
||||||
|
}
|
||||||
110
multi_test.go
Normal file
110
multi_test.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package render_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jimeh/go-render"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMultiRenderer_Render(t *testing.T) {
|
||||||
|
successRenderer := &mockRenderer{output: "success output"}
|
||||||
|
cannotRenderer := &mockRenderer{err: render.ErrCannotRender}
|
||||||
|
failRenderer := &mockRenderer{err: errors.New("mock error")}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
renderers []render.Renderer
|
||||||
|
value interface{}
|
||||||
|
want string
|
||||||
|
wantErr string
|
||||||
|
wantErrIs []error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no renderer can render",
|
||||||
|
renderers: []render.Renderer{
|
||||||
|
cannotRenderer,
|
||||||
|
cannotRenderer,
|
||||||
|
},
|
||||||
|
value: struct{}{},
|
||||||
|
wantErrIs: []error{render.ErrCannotRender},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one renderer can render",
|
||||||
|
renderers: []render.Renderer{
|
||||||
|
cannotRenderer,
|
||||||
|
successRenderer,
|
||||||
|
cannotRenderer,
|
||||||
|
},
|
||||||
|
value: struct{}{},
|
||||||
|
want: "success output",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple renderers can render",
|
||||||
|
renderers: []render.Renderer{
|
||||||
|
&mockRenderer{err: render.ErrCannotRender},
|
||||||
|
&mockRenderer{output: "first output"},
|
||||||
|
&mockRenderer{output: "second output"},
|
||||||
|
},
|
||||||
|
value: struct{}{},
|
||||||
|
want: "first output",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "first renderer fails",
|
||||||
|
renderers: []render.Renderer{
|
||||||
|
failRenderer,
|
||||||
|
successRenderer,
|
||||||
|
},
|
||||||
|
value: struct{}{},
|
||||||
|
wantErr: "mock error",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fails after cannot render",
|
||||||
|
renderers: []render.Renderer{
|
||||||
|
cannotRenderer,
|
||||||
|
failRenderer,
|
||||||
|
successRenderer,
|
||||||
|
},
|
||||||
|
value: struct{}{},
|
||||||
|
wantErr: "mock error",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fails after success render",
|
||||||
|
renderers: []render.Renderer{
|
||||||
|
successRenderer,
|
||||||
|
failRenderer,
|
||||||
|
cannotRenderer,
|
||||||
|
},
|
||||||
|
value: struct{}{},
|
||||||
|
want: "success output",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
mr := &render.MultiRenderer{
|
||||||
|
Renderers: tt.renderers,
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := mr.Render(&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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
90
render.go
Normal file
90
render.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
Err = fmt.Errorf("render")
|
||||||
|
|
||||||
|
// ErrCannotRender is returned when a value cannot be rendered. This may be
|
||||||
|
// due to the value not supporting the format, or the value itself not being
|
||||||
|
// renderable.
|
||||||
|
ErrCannotRender = fmt.Errorf("%w: cannot render", Err)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Renderer is the interface that that individual renderers must implement.
|
||||||
|
type Renderer interface {
|
||||||
|
Render(w io.Writer, v any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// DefaultJSON is the default JSON renderer. It renders values using the
|
||||||
|
// encoding/json package, with pretty printing enabled.
|
||||||
|
DefaultJSON = &JSON{Pretty: true}
|
||||||
|
|
||||||
|
// DefaultXML is the default XML renderer. It renders values using the
|
||||||
|
// encoding/xml package, with pretty printing enabled.
|
||||||
|
DefaultXML = &XML{Pretty: true}
|
||||||
|
|
||||||
|
// DefaultYAML is the default YAML renderer. It renders values using the
|
||||||
|
// gopkg.in/yaml.v3 package, with an indentation of 2 spaces.
|
||||||
|
DefaultYAML = &YAML{Indent: 2}
|
||||||
|
|
||||||
|
// DefaultWriterTo is the default writer to renderer. It renders values
|
||||||
|
// using the io.WriterTo interface.
|
||||||
|
DefaultWriterTo = &WriterTo{}
|
||||||
|
|
||||||
|
// DefaultStringer is the default stringer renderer. It renders values
|
||||||
|
// using the fmt.Stringer interface.
|
||||||
|
DefaultStringer = &Stringer{}
|
||||||
|
|
||||||
|
// DefaultText is the default text renderer, used by the package level
|
||||||
|
// Render function. It renders values using the DefaultStringer and
|
||||||
|
// DefaultWriterTo renderers. This means a value must implement either the
|
||||||
|
// fmt.Stringer or io.WriterTo interfaces to be rendered.
|
||||||
|
DefaultText = &MultiRenderer{
|
||||||
|
Renderers: []Renderer{DefaultStringer, DefaultWriterTo},
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultBinaryMarshaler is the default binary marshaler renderer. It
|
||||||
|
// renders values using the encoding.BinaryMarshaler interface.
|
||||||
|
DefaultBinaryMarshaler = &Binary{}
|
||||||
|
|
||||||
|
// DefaultRenderer is the default renderer, used by the package level Render
|
||||||
|
// function. It supports the "json", "xml", "yaml", "text", "binary"
|
||||||
|
// formats.
|
||||||
|
DefaultRenderer = &FormatRenderer{map[string]Renderer{
|
||||||
|
"json": DefaultJSON,
|
||||||
|
"xml": DefaultXML,
|
||||||
|
"yaml": DefaultYAML,
|
||||||
|
"yml": DefaultYAML,
|
||||||
|
"text": DefaultText,
|
||||||
|
"binary": DefaultBinaryMarshaler,
|
||||||
|
"bin": DefaultBinaryMarshaler,
|
||||||
|
}}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Render 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:
|
||||||
|
//
|
||||||
|
// - "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.
|
||||||
|
// - "yml": Alias for "yaml".
|
||||||
|
// - "xml": Renders values using the encoding/xml package, with pretty
|
||||||
|
// printing enabled.
|
||||||
|
// - "text": Renders values using the fmt.Stringer and io.WriterTo interfaces.
|
||||||
|
// This means a value must implement either the fmt.Stringer or io.WriterTo
|
||||||
|
// interfaces to be rendered.
|
||||||
|
// - "binary": Renders values using the encoding.BinaryMarshaler interface.
|
||||||
|
// - "bin": Alias for "binary".
|
||||||
|
//
|
||||||
|
// If the format is not supported, a ErrCannotRender error will be returned.
|
||||||
|
func Render(w io.Writer, format string, v any) error {
|
||||||
|
return DefaultRenderer.Render(w, format, v)
|
||||||
|
}
|
||||||
38
render_test.go
Normal file
38
render_test.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package render_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockWriter struct {
|
||||||
|
WriteErr error
|
||||||
|
buf bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mw *mockWriter) Write(p []byte) (n int, err error) {
|
||||||
|
if mw.WriteErr != nil {
|
||||||
|
return 0, mw.WriteErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return mw.buf.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mw *mockWriter) String() string {
|
||||||
|
return mw.buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockRenderer struct {
|
||||||
|
output string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockRenderer) Render(w io.Writer, _ any) error {
|
||||||
|
_, err := w.Write([]byte(m.output))
|
||||||
|
|
||||||
|
if m.err != nil {
|
||||||
|
return m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
28
stringer.go
Normal file
28
stringer.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stringer is a renderer that renders a value to an io.Writer using the
|
||||||
|
// String method.
|
||||||
|
type Stringer struct{}
|
||||||
|
|
||||||
|
var _ Renderer = (*Stringer)(nil)
|
||||||
|
|
||||||
|
// Render renders a value to an io.Writer using the String method. If the value
|
||||||
|
// does not implement fmt.Stringer, ErrCannotRender is returned.
|
||||||
|
func (s *Stringer) Render(w io.Writer, v any) error {
|
||||||
|
x, ok := v.(fmt.Stringer)
|
||||||
|
if !ok {
|
||||||
|
return ErrCannotRender
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := fmt.Fprint(w, x.String())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %w", Err, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
72
stringer_test.go
Normal file
72
stringer_test.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package render_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jimeh/go-render"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockStringer struct {
|
||||||
|
value string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ms *mockStringer) String() string {
|
||||||
|
return ms.value
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringer_Render(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
writeErr error
|
||||||
|
value any
|
||||||
|
want string
|
||||||
|
wantErr string
|
||||||
|
wantErrIs []error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "implements fmt.Stringer",
|
||||||
|
value: &mockStringer{value: "test string"},
|
||||||
|
want: "test string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "does not implement fmt.Stringer",
|
||||||
|
value: struct{}{},
|
||||||
|
wantErrIs: []error{render.Err, render.ErrCannotRender},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error writing to writer",
|
||||||
|
writeErr: errors.New("write error!!1"),
|
||||||
|
value: &mockStringer{value: "test string"},
|
||||||
|
wantErr: "render: write error!!1",
|
||||||
|
wantErrIs: []error{render.Err},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
s := &render.Stringer{}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
var got string
|
||||||
|
w := &mockWriter{WriteErr: tt.writeErr}
|
||||||
|
|
||||||
|
err = s.Render(w, tt.value)
|
||||||
|
got = w.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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
28
writer_to.go
Normal file
28
writer_to.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriterTo is a renderer that renders a value to an io.Writer using the
|
||||||
|
// WriteTo method.
|
||||||
|
type WriterTo struct{}
|
||||||
|
|
||||||
|
var _ Renderer = (*WriterTo)(nil)
|
||||||
|
|
||||||
|
// Render renders a value to an io.Writer using the WriteTo method. If the value
|
||||||
|
// does not implement io.WriterTo, ErrCannotRender is returned.
|
||||||
|
func (wt *WriterTo) Render(w io.Writer, v any) error {
|
||||||
|
x, ok := v.(io.WriterTo)
|
||||||
|
if !ok {
|
||||||
|
return ErrCannotRender
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := x.WriteTo(w)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %w", Err, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
84
writer_to_test.go
Normal file
84
writer_to_test.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package render_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jimeh/go-render"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockWriterTo struct {
|
||||||
|
value string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockWriterTo) WriteTo(w io.Writer) (int64, error) {
|
||||||
|
n, err := w.Write([]byte(m.value))
|
||||||
|
|
||||||
|
if m.err != nil {
|
||||||
|
return int64(n), m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
return int64(n), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriterTo_Render(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
writeErr error
|
||||||
|
value any
|
||||||
|
want string
|
||||||
|
wantErr string
|
||||||
|
wantErrIs []error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "implements io.WriterTo",
|
||||||
|
value: &mockWriterTo{value: "test string"},
|
||||||
|
want: "test string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "does not implement io.WriterTo",
|
||||||
|
value: struct{}{},
|
||||||
|
wantErr: "render: cannot render",
|
||||||
|
wantErrIs: []error{render.Err, render.ErrCannotRender},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error writing to writer",
|
||||||
|
value: &mockWriterTo{
|
||||||
|
value: "test string",
|
||||||
|
err: errors.New("WriteTo error!!1"),
|
||||||
|
},
|
||||||
|
wantErr: "render: WriteTo error!!1",
|
||||||
|
wantErrIs: []error{render.Err},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
wt := &render.WriterTo{}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
var got string
|
||||||
|
w := &bytes.Buffer{}
|
||||||
|
|
||||||
|
err = wt.Render(w, tt.value)
|
||||||
|
got = w.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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
45
xml.go
Normal file
45
xml.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 string
|
||||||
|
|
||||||
|
// Indent is the string added to each level of indentation when Pretty is
|
||||||
|
// true. If empty, two spaces will be used instead.
|
||||||
|
Indent string
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Renderer = (*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 := enc.Encode(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %w", Err, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
89
xml_test.go
Normal file
89
xml_test.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package render_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/xml"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jimeh/go-render"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestXML_Render(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
pretty bool
|
||||||
|
prefix string
|
||||||
|
indent string
|
||||||
|
value any
|
||||||
|
want string
|
||||||
|
wantErr string
|
||||||
|
wantErrIs []error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple object without pretty",
|
||||||
|
pretty: false,
|
||||||
|
value: struct {
|
||||||
|
XMLName xml.Name `xml:"user"`
|
||||||
|
Age int `xml:"age"`
|
||||||
|
}{Age: 30},
|
||||||
|
want: `<user><age>30</age></user>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple object with pretty",
|
||||||
|
pretty: true,
|
||||||
|
indent: " ",
|
||||||
|
value: struct {
|
||||||
|
XMLName xml.Name `xml:"user"`
|
||||||
|
Age int `xml:"age"`
|
||||||
|
}{Age: 30},
|
||||||
|
want: "<user>\n <age>30</age>\n</user>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "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: "invalid value",
|
||||||
|
pretty: false,
|
||||||
|
value: make(chan int),
|
||||||
|
wantErr: "render: xml: unsupported type: chan int",
|
||||||
|
wantErrIs: []error{render.Err},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
x := &render.XML{
|
||||||
|
Pretty: tt.pretty,
|
||||||
|
Prefix: tt.prefix,
|
||||||
|
Indent: tt.indent,
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := x.Render(&buf, tt.value)
|
||||||
|
|
||||||
|
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)
|
||||||
|
got := buf.String()
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
36
yaml.go
Normal file
36
yaml.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// YAML is a renderer 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.
|
||||||
|
Indent int
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Renderer = (*YAML)(nil)
|
||||||
|
|
||||||
|
// Render marshals the given value to YAML.
|
||||||
|
func (j *YAML) Render(w io.Writer, v any) error {
|
||||||
|
enc := yaml.NewEncoder(w)
|
||||||
|
|
||||||
|
indent := j.Indent
|
||||||
|
if indent == 0 {
|
||||||
|
indent = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
enc.SetIndent(indent)
|
||||||
|
|
||||||
|
err := enc.Encode(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %w", Err, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
82
yaml_test.go
Normal file
82
yaml_test.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package render_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jimeh/go-render"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestYAML_Render(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
indent int
|
||||||
|
value interface{}
|
||||||
|
want string
|
||||||
|
wantPanic string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple object default indent",
|
||||||
|
value: map[string]int{"age": 30},
|
||||||
|
want: "age: 30\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nested structure",
|
||||||
|
indent: 0, // This will use the default indent of 2 spaces
|
||||||
|
value: map[string]any{
|
||||||
|
"user": map[string]any{
|
||||||
|
"age": 30,
|
||||||
|
"name": "John Doe",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: "user:\n age: 30\n name: John Doe\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple object custom indent",
|
||||||
|
indent: 4,
|
||||||
|
value: map[string]any{
|
||||||
|
"user": map[string]any{
|
||||||
|
"age": 30,
|
||||||
|
"name": "John Doe",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: "user:\n age: 30\n name: John Doe\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid value",
|
||||||
|
indent: 0,
|
||||||
|
value: make(chan int),
|
||||||
|
wantPanic: "cannot marshal type: chan int",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
j := &render.YAML{
|
||||||
|
Indent: tt.indent,
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
var err error
|
||||||
|
var panicRes any
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
panicRes = r
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
err = j.Render(&buf, tt.value)
|
||||||
|
}()
|
||||||
|
|
||||||
|
if tt.wantPanic != "" {
|
||||||
|
assert.Equal(t, tt.wantPanic, panicRes)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
got := buf.String()
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user