commit 2b28f96bad885b3e989fd4e6e2eee237d599ff28 Author: Jim Myhrberg Date: Sun Mar 17 20:40:14 2024 +0000 feat(render): experimental package to render arbitrary values to different formats diff --git a/binary.go b/binary.go new file mode 100644 index 0000000..e9f373e --- /dev/null +++ b/binary.go @@ -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 +} diff --git a/binary_test.go b/binary_test.go new file mode 100644 index 0000000..4809ce6 --- /dev/null +++ b/binary_test.go @@ -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) + } + }) + } +} diff --git a/format.go b/format.go new file mode 100644 index 0000000..febe1f8 --- /dev/null +++ b/format.go @@ -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 +} diff --git a/format_test.go b/format_test.go new file mode 100644 index 0000000..4ceaea0 --- /dev/null +++ b/format_test.go @@ -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) + } + }) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d1dc7b3 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..60ce688 --- /dev/null +++ b/go.sum @@ -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= diff --git a/json.go b/json.go new file mode 100644 index 0000000..a71051c --- /dev/null +++ b/json.go @@ -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 +} diff --git a/json_test.go b/json_test.go new file mode 100644 index 0000000..68af345 --- /dev/null +++ b/json_test.go @@ -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) + } + }) + } +} diff --git a/multi.go b/multi.go new file mode 100644 index 0000000..d0f5698 --- /dev/null +++ b/multi.go @@ -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 +} diff --git a/multi_test.go b/multi_test.go new file mode 100644 index 0000000..6774d9f --- /dev/null +++ b/multi_test.go @@ -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) + } + }) + } +} diff --git a/render.go b/render.go new file mode 100644 index 0000000..c0ee7fc --- /dev/null +++ b/render.go @@ -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) +} diff --git a/render_test.go b/render_test.go new file mode 100644 index 0000000..c9991bf --- /dev/null +++ b/render_test.go @@ -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 +} diff --git a/stringer.go b/stringer.go new file mode 100644 index 0000000..f76b21e --- /dev/null +++ b/stringer.go @@ -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 +} diff --git a/stringer_test.go b/stringer_test.go new file mode 100644 index 0000000..2096b39 --- /dev/null +++ b/stringer_test.go @@ -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) + } + }) + } +} diff --git a/writer_to.go b/writer_to.go new file mode 100644 index 0000000..80f023b --- /dev/null +++ b/writer_to.go @@ -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 +} diff --git a/writer_to_test.go b/writer_to_test.go new file mode 100644 index 0000000..0183ad1 --- /dev/null +++ b/writer_to_test.go @@ -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) + } + }) + } +} diff --git a/xml.go b/xml.go new file mode 100644 index 0000000..a601a3e --- /dev/null +++ b/xml.go @@ -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 +} diff --git a/xml_test.go b/xml_test.go new file mode 100644 index 0000000..1209270 --- /dev/null +++ b/xml_test.go @@ -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: `30`, + }, + { + name: "simple object with pretty", + pretty: true, + indent: " ", + value: struct { + XMLName xml.Name `xml:"user"` + Age int `xml:"age"` + }{Age: 30}, + want: "\n 30\n", + }, + { + name: "with prefix and indent", + pretty: true, + prefix: "//", + indent: "\t", + value: struct { + XMLName xml.Name `xml:"user"` + Age int `xml:"age"` + }{Age: 30}, + want: "//\n//\t30\n//", + }, + { + name: "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) + } + }) + } +} diff --git a/yaml.go b/yaml.go new file mode 100644 index 0000000..fea91be --- /dev/null +++ b/yaml.go @@ -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 +} diff --git a/yaml_test.go b/yaml_test.go new file mode 100644 index 0000000..4065875 --- /dev/null +++ b/yaml_test.go @@ -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) + } + }) + } +}