From 7632b1119c6e3ee7f9fc97e9c95433a764d3f3b6 Mon Sep 17 00:00:00 2001 From: Jim Myhrberg Date: Tue, 19 Mar 2024 03:54:31 +0000 Subject: [PATCH] refactor!: sizeable changes across the board --- binary.go | 6 +- binary_test.go | 8 +- format.go | 32 -- format_test.go | 74 ---- json.go | 4 +- json_test.go | 6 +- multi.go | 4 +- multi_test.go | 14 +- render.go | 135 +++--- render_example_test.go | 11 +- render_test.go | 654 +++++++++++------------------- renderer.go | 53 +++ renderer_test.go | 138 +++++++ stringer.go | 28 -- stringer_test.go | 70 ---- text.go | 28 ++ writer_to_test.go => text_test.go | 40 +- writer_to.go | 28 -- xml.go | 4 +- xml_test.go | 8 +- yaml.go | 4 +- yaml_test.go | 4 +- 22 files changed, 620 insertions(+), 733 deletions(-) delete mode 100644 format.go delete mode 100644 format_test.go create mode 100644 renderer.go create mode 100644 renderer_test.go delete mode 100644 stringer.go delete mode 100644 stringer_test.go create mode 100644 text.go rename writer_to_test.go => text_test.go (58%) delete mode 100644 writer_to.go diff --git a/binary.go b/binary.go index e9f373e..436c760 100644 --- a/binary.go +++ b/binary.go @@ -10,7 +10,7 @@ import ( // interface. type Binary struct{} -var _ Renderer = (*Binary)(nil) +var _ FormatRenderer = (*Binary)(nil) // Render writes result of calling MarshalBinary() on v. If v does not implment // encoding.BinaryMarshaler the ErrCannotRander error will be returned. @@ -22,12 +22,12 @@ func (bm *Binary) Render(w io.Writer, v any) error { b, err := x.MarshalBinary() if err != nil { - return fmt.Errorf("%w: %w", Err, err) + return fmt.Errorf("%w: %w", ErrFailed, err) } _, err = w.Write(b) if err != nil { - return fmt.Errorf("%w: %w", Err, err) + return fmt.Errorf("%w: %w", ErrFailed, err) } return nil diff --git a/binary_test.go b/binary_test.go index 3929b7a..7a7d749 100644 --- a/binary_test.go +++ b/binary_test.go @@ -45,15 +45,15 @@ func TestBinary_Render(t *testing.T) { data: []byte("test string"), err: errors.New("marshal error!!1"), }, - wantErr: "render: marshal error!!1", - wantErrIs: []error{render.Err}, + wantErr: "render: failed: marshal error!!1", + wantErrIs: []error{render.Err, render.ErrFailed}, }, { 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}, + wantErr: "render: failed: write error!!1", + wantErrIs: []error{render.Err, render.ErrFailed}, }, } for _, tt := range tests { diff --git a/format.go b/format.go deleted file mode 100644 index 96efc19..0000000 --- a/format.go +++ /dev/null @@ -1,32 +0,0 @@ -package render - -import ( - "fmt" - "io" -) - -var ErrUnsupportedFormat = fmt.Errorf("%w: unsupported format", Err) - -// FormatRenderer is a renderer that delegates rendering to another renderer -// based on a format value. -type FormatRenderer struct { - // Renderers is a map of format names to renderers. When Render is called, - // the format is used to look up the renderer to use. - Renderers map[string]Renderer -} - -// 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, -// 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 ErrUnsupportedFormat -} diff --git a/format_test.go b/format_test.go deleted file mode 100644 index de94946..0000000 --- a/format_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package render_test - -import ( - "bytes" - "errors" - "testing" - - "github.com/jimeh/go-render" - "github.com/stretchr/testify/assert" -) - -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.ErrUnsupportedFormat}, - }, - } - 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 != "" { - 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) - } - }) - } -} diff --git a/json.go b/json.go index a71051c..6320d0e 100644 --- a/json.go +++ b/json.go @@ -21,7 +21,7 @@ type JSON struct { Indent string } -var _ Renderer = (*JSON)(nil) +var _ FormatRenderer = (*JSON)(nil) // Render marshals the given value to JSON. func (j *JSON) Render(w io.Writer, v any) error { @@ -38,7 +38,7 @@ func (j *JSON) Render(w io.Writer, v any) error { err := enc.Encode(v) if err != nil { - return fmt.Errorf("%w: %w", Err, err) + return fmt.Errorf("%w: %w", ErrFailed, err) } return nil diff --git a/json_test.go b/json_test.go index bd78e0a..9e66ac9 100644 --- a/json_test.go +++ b/json_test.go @@ -69,14 +69,14 @@ func TestJSON_Render(t *testing.T) { { name: "error from json.Marshaler", value: &mockJSONMarshaler{err: errors.New("marshal error!!1")}, - wantErrIs: []error{render.Err}, + wantErrIs: []error{render.Err, render.ErrFailed}, }, { name: "invalid value", pretty: false, value: make(chan int), - wantErr: "render: json: unsupported type: chan int", - wantErrIs: []error{render.Err}, + wantErr: "render: failed: json: unsupported type: chan int", + wantErrIs: []error{render.Err, render.ErrFailed}, }, } for _, tt := range tests { diff --git a/multi.go b/multi.go index d0f5698..1b01201 100644 --- a/multi.go +++ b/multi.go @@ -7,10 +7,10 @@ import ( // MultiRenderer is a renderer that tries multiple renderers until one succeeds. type MultiRenderer struct { - Renderers []Renderer + Renderers []FormatRenderer } -var _ Renderer = (*MultiRenderer)(nil) +var _ FormatRenderer = (*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 diff --git a/multi_test.go b/multi_test.go index 5907802..3f533d3 100644 --- a/multi_test.go +++ b/multi_test.go @@ -16,7 +16,7 @@ func TestMultiRenderer_Render(t *testing.T) { tests := []struct { name string - renderers []render.Renderer + renderers []render.FormatRenderer value interface{} want string wantErr string @@ -24,7 +24,7 @@ func TestMultiRenderer_Render(t *testing.T) { }{ { name: "no renderer can render", - renderers: []render.Renderer{ + renderers: []render.FormatRenderer{ cannotRenderer, cannotRenderer, }, @@ -33,7 +33,7 @@ func TestMultiRenderer_Render(t *testing.T) { }, { name: "one renderer can render", - renderers: []render.Renderer{ + renderers: []render.FormatRenderer{ cannotRenderer, successRenderer, cannotRenderer, @@ -43,7 +43,7 @@ func TestMultiRenderer_Render(t *testing.T) { }, { name: "multiple renderers can render", - renderers: []render.Renderer{ + renderers: []render.FormatRenderer{ &mockRenderer{err: render.ErrCannotRender}, &mockRenderer{output: "first output"}, &mockRenderer{output: "second output"}, @@ -53,7 +53,7 @@ func TestMultiRenderer_Render(t *testing.T) { }, { name: "first renderer fails", - renderers: []render.Renderer{ + renderers: []render.FormatRenderer{ failRenderer, successRenderer, }, @@ -62,7 +62,7 @@ func TestMultiRenderer_Render(t *testing.T) { }, { name: "fails after cannot render", - renderers: []render.Renderer{ + renderers: []render.FormatRenderer{ cannotRenderer, failRenderer, successRenderer, @@ -72,7 +72,7 @@ func TestMultiRenderer_Render(t *testing.T) { }, { name: "fails after success render", - renderers: []render.Renderer{ + renderers: []render.FormatRenderer{ successRenderer, failRenderer, cannotRenderer, diff --git a/render.go b/render.go index 42039d0..cda00da 100644 --- a/render.go +++ b/render.go @@ -1,3 +1,14 @@ +// Package render provides a simple and flexible way to render a value to a +// io.Writer using different formats based on a format string argument. +// +// It allows rendering a custom type which can be marshaled to JSON, YAML, XML, +// while also supporting plain text by implementing fmt.Stringer or io.WriterTo. +// Binary output is also supported by implementing the encoding.BinaryMarshaler +// interface. +// +// Originally intended to easily implement CLI tools which can output their data +// as plain text, as well as JSON/YAML with a simple switch of a format string. +// But it can just as easily render to any io.Writer. package render import ( @@ -6,24 +17,43 @@ import ( ) var ( - Err = fmt.Errorf("render") + // Err is the base error for the package. All errors returned by this + // package are wrapped with this error. + Err = fmt.Errorf("render") + ErrFailed = fmt.Errorf("%w: failed", Err) // 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. + // renderable. Only Renderer implementations should return this error. ErrCannotRender = fmt.Errorf("%w: cannot render", Err) ) -// Renderer is the interface that that individual renderers must implement. -type Renderer interface { +// FormatRenderer interface is for single format renderers, which can only +// render a single format. +type FormatRenderer interface { + // Render writes v into w in the format that the FormatRenderer supports. + // + // If v does not implement a required interface, or otherwise cannot be + // rendered to the format in question, then a ErrCannotRender error must be + // returned. Any other errors should be returned as is. Render(w io.Writer, v any) error } var ( + // DefaultBinary is the default binary marshaler renderer. It + // renders values using the encoding.BinaryMarshaler interface. + DefaultBinary = &Binary{} + // DefaultJSON is the default JSON renderer. It renders values using the // encoding/json package, with pretty printing enabled. DefaultJSON = &JSON{Pretty: true} + // 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 = &Text{} + // DefaultXML is the default XML renderer. It renders values using the // encoding/xml package, with pretty printing enabled. DefaultXML = &XML{Pretty: true} @@ -32,39 +62,10 @@ var ( // 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}, - } - - // DefaultBinary is the default binary marshaler renderer. It - // renders values using the encoding.BinaryMarshaler interface. - DefaultBinary = &Binary{} - - // DefaultRenderer is the default renderer, used by the package level Render - // function. - DefaultRenderer = &FormatRenderer{map[string]Renderer{ - "bin": DefaultBinary, - "binary": DefaultBinary, - "json": DefaultJSON, - "plain": DefaultText, - "text": DefaultText, - "txt": DefaultText, - "xml": DefaultXML, - "yaml": DefaultYAML, - "yml": DefaultYAML, - }} + // DefaultRenderer is used by the package level Render function. It supports + // the text", "json", and "yaml" formats. If you need to support another set + // of formats, use the New function to create a custom FormatRenderer. + DefaultRenderer = MustNew("json", "text", "yaml") ) // Render renders the given value to the given writer using the given format. @@ -72,23 +73,63 @@ var ( // // By default it supports the following formats: // +// - "text": Renders values using the fmt.Stringer and io.WriterTo interfaces. // - "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. -// - "txt": Alias for "text". -// - "plain": Alias for "text". -// - "binary": Renders values using the encoding.BinaryMarshaler interface. -// - "bin": Alias for "binary". // // 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 FormatRenderer with the formats you need. If you need new custom +// renderers, manually create a new FormatRenderer. func Render(w io.Writer, format string, v any) error { return DefaultRenderer.Render(w, format, v) } + +// New creates a new *FormatRenderer with support for the given formats. +// +// Supported formats are: +// +// - "binary": Renders values using DefaultBinary. +// - "json": Renders values using DefaultJSON. +// - "text": Renders values using DefaultText. +// - "xml": Renders values using DefaultXML. +// - "yaml": Renders values using DefaultYAML. +// +// If an unsupported format is given, an ErrUnsupportedFormat error will be +// returned. +func New(formats ...string) (*Renderer, error) { + renderers := map[string]FormatRenderer{} + + for _, format := range formats { + switch format { + case "binary": + renderers[format] = DefaultBinary + case "json": + renderers[format] = DefaultJSON + case "text": + renderers[format] = DefaultText + case "xml": + renderers[format] = DefaultXML + case "yaml": + renderers[format] = DefaultYAML + default: + return nil, fmt.Errorf("%w: %s", ErrUnsupportedFormat, format) + } + } + + return NewFormatRenderer(renderers), nil +} + +// MustNew is like New, but panics if an error occurs. +func MustNew(formats ...string) *Renderer { + r, err := New(formats...) + if err != nil { + panic(err) + } + + return r +} diff --git a/render_example_test.go b/render_example_test.go index e1ec840..64f7d37 100644 --- a/render_example_test.go +++ b/render_example_test.go @@ -39,7 +39,10 @@ func ExampleRender_json() { buf := &bytes.Buffer{} err := render.Render(buf, "json", data) if err != nil { - panic(err) + fmt.Printf("err: %s\n", err) + + return + // panic(err) } fmt.Println(buf.String()) @@ -139,9 +142,13 @@ func ExampleRender_xml() { Tags: []string{"golang", "json", "yaml", "toml"}, } + // Create a new renderer that supports XML in addition to default JSON, YAML + // and Text. + r := render.MustNew("json", "text", "xml", "yaml") + // Render the object to XML. buf := &bytes.Buffer{} - err := render.Render(buf, "xml", data) + err := r.Render(buf, "xml", data) if err != nil { panic(err) } diff --git a/render_test.go b/render_test.go index c5f0f9c..de055e2 100644 --- a/render_test.go +++ b/render_test.go @@ -35,7 +35,7 @@ type mockRenderer struct { err error } -var _ render.Renderer = (*mockRenderer)(nil) +var _ render.FormatRenderer = (*mockRenderer)(nil) func (m *mockRenderer) Render(w io.Writer, _ any) error { _, err := w.Write([]byte(m.output)) @@ -59,23 +59,8 @@ func TestDefaultYAML(t *testing.T) { assert.Equal(t, &render.YAML{Indent: 2}, render.DefaultYAML) } -func TestDefaultWriterTo(t *testing.T) { - assert.Equal(t, &render.WriterTo{}, render.DefaultWriterTo) -} - -func TestDefaultStringer(t *testing.T) { - assert.Equal(t, &render.Stringer{}, render.DefaultStringer) -} - func TestDefaultText(t *testing.T) { - want := &render.MultiRenderer{ - Renderers: []render.Renderer{ - &render.Stringer{}, - &render.WriterTo{}, - }, - } - - assert.Equal(t, want, render.DefaultText) + assert.Equal(t, &render.Text{}, render.DefaultText) } func TestDefaultBinary(t *testing.T) { @@ -83,408 +68,253 @@ func TestDefaultBinary(t *testing.T) { } func TestDefaultRenderer(t *testing.T) { - want := &render.FormatRenderer{ - Renderers: map[string]render.Renderer{ - "bin": render.DefaultBinary, - "binary": render.DefaultBinary, - "json": render.DefaultJSON, - "plain": render.DefaultText, - "text": render.DefaultText, - "txt": render.DefaultText, - "xml": render.DefaultXML, - "yaml": render.DefaultYAML, - "yml": render.DefaultYAML, + want := &render.Renderer{ + Formats: map[string]render.FormatRenderer{ + "json": render.DefaultJSON, + "text": render.DefaultText, + "yaml": render.DefaultYAML, }, } assert.Equal(t, want, render.DefaultRenderer) } +type renderFormatTestCase struct { + name string + writeErr error + format string + value any + want string + wantErr string + wantErrIs []error + wantPanic string +} + +// "binary" format. +var binaryFormattestCases = []renderFormatTestCase{ + { + name: "binary format with binary marshaler", + format: "binary", + value: &mockBinaryMarshaler{data: []byte("test string")}, + want: "test string", + }, + { + name: "binary format without binary marshaler", + format: "binary", + value: struct{}{}, + wantErr: "render: unsupported format: binary", + wantErrIs: []error{render.Err, render.ErrUnsupportedFormat}, + }, + { + name: "binary format with error marshaling", + format: "binary", + value: &mockBinaryMarshaler{ + data: []byte("test string"), + err: errors.New("marshal error!!1"), + }, + wantErr: "render: failed: marshal error!!1", + wantErrIs: []error{render.Err, render.ErrFailed}, + }, + { + name: "binary format with error writing to writer", + format: "binary", + writeErr: errors.New("write error!!1"), + value: &mockBinaryMarshaler{data: []byte("test string")}, + wantErr: "render: failed: write error!!1", + wantErrIs: []error{render.Err, render.ErrFailed}, + }, + { + name: "binary format with invalid type", + format: "binary", + value: make(chan int), + wantErr: "render: unsupported format: binary", + wantErrIs: []error{render.Err, render.ErrUnsupportedFormat}, + }, +} + +// "json" format. +var jsonFormatTestCases = []renderFormatTestCase{ + { + name: "json format", + format: "json", + value: map[string]int{"age": 30}, + want: "{\n \"age\": 30\n}\n", + }, + { + name: "json format with json marshaler", + format: "json", + value: &mockJSONMarshaler{data: []byte(`{"age":30}`)}, + want: "{\n \"age\": 30\n}\n", + }, + { + name: "json format with error from json marshaler", + format: "json", + value: &mockJSONMarshaler{err: errors.New("marshal error!!1")}, + wantErrIs: []error{render.Err}, + }, + { + name: "json format with error writing to writer", + format: "json", + writeErr: errors.New("write error!!1"), + value: map[string]int{"age": 30}, + wantErr: "render: failed: write error!!1", + wantErrIs: []error{render.Err, render.ErrFailed}, + }, + { + name: "json format with invalid type", + format: "json", + value: make(chan int), + wantErr: "render: failed: json: unsupported type: chan int", + wantErrIs: []error{render.Err, render.ErrFailed}, + }, +} + +// "text" format. +var textFormatTestCases = []renderFormatTestCase{ + { + name: "text format with fmt.Stringer", + format: "text", + value: &mockStringer{value: "test string"}, + want: "test string", + }, + { + name: "text format with io.WriterTo", + format: "text", + value: &mockWriterTo{value: "test string"}, + want: "test string", + }, + { + name: "text format without fmt.Stringer or io.WriterTo", + format: "text", + value: struct{}{}, + wantErr: "render: unsupported format: text", + wantErrIs: []error{render.Err, render.ErrUnsupportedFormat}, + }, + { + name: "text format with error writing to writer", + format: "text", + writeErr: errors.New("write error!!1"), + value: &mockStringer{value: "test string"}, + wantErr: "render: failed: write error!!1", + wantErrIs: []error{render.Err, render.ErrFailed}, + }, + { + name: "text format with error from io.WriterTo", + format: "text", + value: &mockWriterTo{ + value: "test string", + err: errors.New("WriteTo error!!1"), + }, + wantErr: "render: failed: WriteTo error!!1", + wantErrIs: []error{render.Err, render.ErrFailed}, + }, + { + name: "text format with invalid type", + format: "text", + value: make(chan int), + wantErr: "render: unsupported format: text", + wantErrIs: []error{render.Err, render.ErrUnsupportedFormat}, + }, +} + +// "xml" format. +var xmlFormatTestCases = []renderFormatTestCase{ + { + name: "xml format", + format: "xml", + value: struct { + XMLName xml.Name `xml:"user"` + Age int `xml:"age"` + }{Age: 30}, + want: "\n 30\n", + }, + { + name: "xml format with xml.Marshaler", + format: "xml", + value: &mockXMLMarshaler{elm: "test string"}, + want: "test string", + }, + { + name: "xml format with error from xml.Marshaler", + format: "xml", + value: &mockXMLMarshaler{err: errors.New("marshal error!!1")}, + wantErr: "render: failed: marshal error!!1", + wantErrIs: []error{render.Err, render.ErrFailed}, + }, + { + name: "xml format with error writing to writer", + format: "xml", + writeErr: errors.New("write error!!1"), + value: struct { + XMLName xml.Name `xml:"user"` + Age int `xml:"age"` + }{Age: 30}, + wantErr: "render: failed: write error!!1", + wantErrIs: []error{render.Err, render.ErrFailed}, + }, + { + name: "xml format with invalid value", + format: "xml", + value: make(chan int), + wantErr: "render: failed: xml: unsupported type: chan int", + wantErrIs: []error{render.Err, render.ErrFailed}, + }, +} + +// "yaml" format. +var yamlFormatTestCases = []renderFormatTestCase{ + { + name: "yaml format", + format: "yaml", + value: map[string]int{"age": 30}, + want: "age: 30\n", + }, + { + name: "yaml format with nested structure", + format: "yaml", + value: map[string]any{ + "user": map[string]any{ + "age": 30, + "name": "John Doe", + }, + }, + want: "user:\n age: 30\n name: John Doe\n", + }, + { + name: "yaml format with yaml.Marshaler", + format: "yaml", + value: &mockYAMLMarshaler{val: map[string]int{"age": 30}}, + want: "age: 30\n", + }, + { + name: "yaml format with error from yaml.Marshaler", + format: "yaml", + value: &mockYAMLMarshaler{err: errors.New("mock error")}, + wantErr: "render: failed: mock error", + wantErrIs: []error{render.Err, render.ErrFailed}, + }, + { + name: "yaml format with error writing to writer", + format: "yaml", + writeErr: errors.New("write error!!1"), + value: map[string]int{"age": 30}, + wantErr: "render: failed: yaml: write error: write error!!1", + wantErrIs: []error{render.Err, render.ErrFailed}, + }, + { + name: "yaml format with invalid type", + format: "yaml", + value: make(chan int), + wantPanic: "cannot marshal type: chan int", + }, +} + func TestRender(t *testing.T) { - tests := []struct { - name string - writeErr error - format string - value any - want string - wantErr string - wantErrIs []error - wantPanic string - }{ - // "bin" format. - { - name: "bin format with binary marshaler", - format: "bin", - value: &mockBinaryMarshaler{data: []byte("test string")}, - want: "test string", - }, - { - name: "bin format without binary marshaler", - format: "bin", - value: struct{}{}, - wantErrIs: []error{render.Err, render.ErrCannotRender}, - }, - { - name: "bin format with error marshaling", - format: "bin", - value: &mockBinaryMarshaler{ - data: []byte("test string"), - err: errors.New("marshal error!!1"), - }, - wantErr: "render: marshal error!!1", - wantErrIs: []error{render.Err}, - }, - { - name: "bin format with error writing to writer", - format: "bin", - writeErr: errors.New("write error!!1"), - value: &mockBinaryMarshaler{data: []byte("test string")}, - wantErr: "render: write error!!1", - wantErrIs: []error{render.Err}, - }, - { - name: "bin format with invalid type", - format: "bin", - value: make(chan int), - wantErr: "render: cannot render", - wantErrIs: []error{render.Err, render.ErrCannotRender}, - }, - // "binary" format. - { - name: "binary format with binary marshaler", - format: "binary", - value: &mockBinaryMarshaler{data: []byte("test string")}, - want: "test string", - }, - { - name: "binary format without binary marshaler", - format: "binary", - value: struct{}{}, - wantErrIs: []error{render.Err, render.ErrCannotRender}, - }, - { - name: "binary format with error marshaling", - format: "binary", - value: &mockBinaryMarshaler{ - data: []byte("test string"), - err: errors.New("marshal error!!1"), - }, - wantErr: "render: marshal error!!1", - wantErrIs: []error{render.Err}, - }, - { - name: "binary format with error writing to writer", - format: "binary", - writeErr: errors.New("write error!!1"), - value: &mockBinaryMarshaler{data: []byte("test string")}, - wantErr: "render: write error!!1", - wantErrIs: []error{render.Err}, - }, - { - name: "binary format with invalid type", - format: "binary", - value: make(chan int), - wantErr: "render: cannot render", - wantErrIs: []error{render.Err, render.ErrCannotRender}, - }, - // "json" format. - { - name: "json format", - format: "json", - value: map[string]int{"age": 30}, - want: "{\n \"age\": 30\n}\n", - }, - { - name: "json format with json marshaler", - format: "json", - value: &mockJSONMarshaler{data: []byte(`{"age":30}`)}, - want: "{\n \"age\": 30\n}\n", - }, - { - name: "json format with error from json marshaler", - format: "json", - value: &mockJSONMarshaler{err: errors.New("marshal error!!1")}, - wantErrIs: []error{render.Err}, - }, - { - name: "json format with error writing to writer", - format: "json", - writeErr: errors.New("write error!!1"), - value: map[string]int{"age": 30}, - wantErr: "render: write error!!1", - wantErrIs: []error{render.Err}, - }, - { - name: "json format with invalid type", - format: "json", - value: make(chan int), - wantErr: "render: json: unsupported type: chan int", - wantErrIs: []error{render.Err}, - }, - // "plain" format. - { - name: "plain format with fmt.Stringer", - format: "plain", - value: &mockStringer{value: "test string"}, - want: "test string", - }, - { - name: "plain format with io.WriterTo", - format: "plain", - value: &mockWriterTo{value: "test string"}, - want: "test string", - }, - { - name: "plain format without fmt.Stringer or io.WriterTo", - format: "plain", - value: struct{}{}, - wantErrIs: []error{render.Err, render.ErrCannotRender}, - }, - { - name: "plain format with error writing to writer", - format: "plain", - writeErr: errors.New("write error!!1"), - value: &mockStringer{value: "test string"}, - wantErr: "render: write error!!1", - wantErrIs: []error{render.Err}, - }, - { - name: "plain format with error from io.WriterTo", - format: "plain", - value: &mockWriterTo{ - value: "test string", - err: errors.New("WriteTo error!!1"), - }, - wantErr: "render: WriteTo error!!1", - wantErrIs: []error{render.Err}, - }, - { - name: "plain format with invalid type", - format: "plain", - value: make(chan int), - wantErr: "render: cannot render", - wantErrIs: []error{render.Err, render.ErrCannotRender}, - }, - // "text" format. - { - name: "text format with fmt.Stringer", - format: "text", - value: &mockStringer{value: "test string"}, - want: "test string", - }, - { - name: "text format with io.WriterTo", - format: "text", - value: &mockWriterTo{value: "test string"}, - want: "test string", - }, - { - name: "text format without fmt.Stringer or io.WriterTo", - format: "text", - value: struct{}{}, - wantErrIs: []error{render.Err, render.ErrCannotRender}, - }, - { - name: "text format with error writing to writer", - format: "text", - writeErr: errors.New("write error!!1"), - value: &mockStringer{value: "test string"}, - wantErr: "render: write error!!1", - wantErrIs: []error{render.Err}, - }, - { - name: "text format with error from io.WriterTo", - format: "text", - value: &mockWriterTo{ - value: "test string", - err: errors.New("WriteTo error!!1"), - }, - wantErr: "render: WriteTo error!!1", - wantErrIs: []error{render.Err}, - }, - { - name: "text format with invalid type", - format: "text", - value: make(chan int), - wantErr: "render: cannot render", - wantErrIs: []error{render.Err, render.ErrCannotRender}, - }, - // "txt" format. - { - name: "txt format with fmt.Stringer", - format: "txt", - value: &mockStringer{value: "test string"}, - want: "test string", - }, - { - name: "txt format with io.WriterTo", - format: "txt", - value: &mockWriterTo{value: "test string"}, - want: "test string", - }, - { - name: "txt format without fmt.Stringer or io.WriterTo", - format: "txt", - value: struct{}{}, - wantErrIs: []error{render.Err, render.ErrCannotRender}, - }, - { - name: "txt format with error writing to writer", - format: "txt", - writeErr: errors.New("write error!!1"), - value: &mockStringer{value: "test string"}, - wantErr: "render: write error!!1", - wantErrIs: []error{render.Err}, - }, - { - name: "txt format with error from io.WriterTo", - format: "txt", - value: &mockWriterTo{ - value: "test string", - err: errors.New("WriteTo error!!1"), - }, - wantErr: "render: WriteTo error!!1", - wantErrIs: []error{render.Err}, - }, - { - name: "txt format with invalid type", - format: "txt", - value: make(chan int), - wantErr: "render: cannot render", - wantErrIs: []error{render.Err, render.ErrCannotRender}, - }, - // "xml" format. - { - name: "xml format", - format: "xml", - value: struct { - XMLName xml.Name `xml:"user"` - Age int `xml:"age"` - }{Age: 30}, - want: "\n 30\n", - }, - { - name: "xml format with xml.Marshaler", - format: "xml", - value: &mockXMLMarshaler{elm: "test string"}, - want: "test string", - }, - { - name: "xml format with error from xml.Marshaler", - format: "xml", - value: &mockXMLMarshaler{err: errors.New("marshal error!!1")}, - wantErr: "render: marshal error!!1", - wantErrIs: []error{render.Err}, - }, - { - name: "xml format with error writing to writer", - format: "xml", - writeErr: errors.New("write error!!1"), - value: struct { - XMLName xml.Name `xml:"user"` - Age int `xml:"age"` - }{Age: 30}, - wantErr: "render: write error!!1", - wantErrIs: []error{render.Err}, - }, - { - name: "xml format with invalid value", - format: "xml", - value: make(chan int), - wantErr: "render: xml: unsupported type: chan int", - wantErrIs: []error{render.Err}, - }, - // "yaml" format. - { - name: "yaml format", - format: "yaml", - value: map[string]int{"age": 30}, - want: "age: 30\n", - }, - { - name: "yaml format with nested structure", - format: "yaml", - value: map[string]any{ - "user": map[string]any{ - "age": 30, - "name": "John Doe", - }, - }, - want: "user:\n age: 30\n name: John Doe\n", - }, - { - name: "yaml format with yaml.Marshaler", - format: "yaml", - value: &mockYAMLMarshaler{val: map[string]int{"age": 30}}, - want: "age: 30\n", - }, - { - name: "yaml format with error from yaml.Marshaler", - format: "yaml", - value: &mockYAMLMarshaler{err: errors.New("mock error")}, - wantErr: "render: mock error", - wantErrIs: []error{render.Err}, - }, - { - name: "yaml format with error writing to writer", - format: "yaml", - writeErr: errors.New("write error!!1"), - value: map[string]int{"age": 30}, - wantErr: "render: yaml: write error: write error!!1", - wantErrIs: []error{render.Err}, - }, - { - name: "yaml format with invalid type", - format: "yaml", - value: make(chan int), - wantPanic: "cannot marshal type: chan int", - }, - // "yml" format. - { - name: "yml format", - format: "yml", - value: map[string]int{"age": 30}, - want: "age: 30\n", - }, - { - name: "yml format with nested structure", - format: "yml", - value: map[string]any{ - "user": map[string]any{ - "age": 30, - "name": "John Doe", - }, - }, - want: "user:\n age: 30\n name: John Doe\n", - }, - { - name: "yml format with yaml.Marshaler", - format: "yml", - value: &mockYAMLMarshaler{val: map[string]int{"age": 30}}, - want: "age: 30\n", - }, - { - name: "yml format with error from yaml.Marshaler", - format: "yml", - value: &mockYAMLMarshaler{err: errors.New("mock error")}, - wantErr: "render: mock error", - wantErrIs: []error{render.Err}, - }, - { - name: "yml format with error writing to writer", - format: "yml", - writeErr: errors.New("write error!!1"), - value: map[string]int{"age": 30}, - wantErr: "render: yaml: write error: write error!!1", - wantErrIs: []error{render.Err}, - }, - { - name: "yml format with invalid type", - format: "yml", - value: make(chan int), - wantPanic: "cannot marshal type: chan int", - }, - } + tests := []renderFormatTestCase{} + tests = append(tests, jsonFormatTestCases...) + tests = append(tests, textFormatTestCases...) + tests = append(tests, yamlFormatTestCases...) + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := &mockWriter{WriteErr: tt.writeErr} diff --git a/renderer.go b/renderer.go new file mode 100644 index 0000000..0acf58a --- /dev/null +++ b/renderer.go @@ -0,0 +1,53 @@ +package render + +import ( + "errors" + "fmt" + "io" +) + +// ErrUnsupportedFormat is returned when a format is not supported by a +// renderer. Any method that accepts a format string may return this error. +var ErrUnsupportedFormat = fmt.Errorf("%w: unsupported format", Err) + +// Renderer is a renderer that delegates rendering to another renderer +// based on a format value. +type Renderer struct { + // Formats is a map of format names to renderers. When Render is called, + // the format is used to look up the renderer to use. + Formats map[string]FormatRenderer +} + +// NewFormatRenderer returns a new FormatRenderer that delegates rendering to +// the specified renderers. +func NewFormatRenderer(formats map[string]FormatRenderer) *Renderer { + return &Renderer{Formats: formats} +} + +// 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, +// the error returned by the renderer is returned. In most cases this will be +// ErrCannotRender, but it could be a different error if the renderer returns +// one. +func (r *Renderer) Render(w io.Writer, format string, v any) error { + renderer, ok := r.Formats[format] + if !ok { + return fmt.Errorf("%w: %s", ErrUnsupportedFormat, format) + } + + err := renderer.Render(w, v) + if err != nil { + if errors.Is(err, ErrCannotRender) { + return fmt.Errorf("%w: %s", ErrUnsupportedFormat, format) + } + if !errors.Is(err, ErrFailed) { + return fmt.Errorf("%w: %w", ErrFailed, err) + } + + return err + } + + return nil +} diff --git a/renderer_test.go b/renderer_test.go new file mode 100644 index 0000000..e971892 --- /dev/null +++ b/renderer_test.go @@ -0,0 +1,138 @@ +package render_test + +import ( + "bytes" + "errors" + "fmt" + "testing" + + "github.com/jimeh/go-render" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderer_Render(t *testing.T) { + tests := []struct { + name string + renderers map[string]render.FormatRenderer + format string + value interface{} + want string + wantErr string + wantErrIs []error + }{ + { + name: "existing renderer", + renderers: map[string]render.FormatRenderer{ + "mock": &mockRenderer{output: "mock output"}, + }, + format: "mock", + value: struct{}{}, + want: "mock output", + }, + { + name: "existing renderer returns error", + renderers: map[string]render.FormatRenderer{ + "other": &mockRenderer{ + output: "mock output", + err: errors.New("mock error"), + }, + }, + format: "other", + value: struct{}{}, + wantErr: "render: failed: mock error", + }, + { + name: "existing renderer returns ErrCannotRender", + renderers: map[string]render.FormatRenderer{ + "other": &mockRenderer{ + output: "mock output", + err: fmt.Errorf("%w: mock", render.ErrCannotRender), + }, + }, + format: "other", + value: struct{}{}, + wantErr: "render: unsupported format: other", + wantErrIs: []error{render.Err, render.ErrUnsupportedFormat}, + }, + { + name: "non-existing renderer", + renderers: map[string]render.FormatRenderer{}, + format: "unknown", + value: struct{}{}, + wantErr: "render: unsupported format: unknown", + wantErrIs: []error{render.Err, render.ErrUnsupportedFormat}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fr := &render.Renderer{ + Formats: tt.renderers, + } + var buf bytes.Buffer + + err := fr.Render(&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_RenderAllFormats(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 { + t.Run(tt.name, func(t *testing.T) { + w := &mockWriter{WriteErr: tt.writeErr} + + var err error + var panicRes any + renderer, err := render.New("binary", "json", "text", "xml", "yaml") + require.NoError(t, err) + + func() { + defer func() { + if r := recover(); r != nil { + panicRes = r + } + }() + err = renderer.Render(w, tt.format, tt.value) + }() + + got := w.String() + + if tt.wantPanic != "" { + assert.Equal(t, tt.wantPanic, panicRes) + } + + if tt.wantErr != "" { + assert.EqualError(t, err, tt.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, tt.want, got) + } + }) + } +} diff --git a/stringer.go b/stringer.go deleted file mode 100644 index 51ee7e0..0000000 --- a/stringer.go +++ /dev/null @@ -1,28 +0,0 @@ -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 := w.Write([]byte(x.String())) - if err != nil { - return fmt.Errorf("%w: %w", Err, err) - } - - return nil -} diff --git a/stringer_test.go b/stringer_test.go deleted file mode 100644 index b328e71..0000000 --- a/stringer_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package render_test - -import ( - "errors" - "fmt" - "testing" - - "github.com/jimeh/go-render" - "github.com/stretchr/testify/assert" -) - -type mockStringer struct { - value string -} - -var _ fmt.Stringer = (*mockStringer)(nil) - -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{} - w := &mockWriter{WriteErr: tt.writeErr} - - err := s.Render(w, tt.value) - got := w.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) - } - }) - } -} diff --git a/text.go b/text.go new file mode 100644 index 0000000..ebad65b --- /dev/null +++ b/text.go @@ -0,0 +1,28 @@ +package render + +import ( + "fmt" + "io" +) + +type Text struct{} + +var _ FormatRenderer = (*Text)(nil) + +func (t *Text) Render(w io.Writer, v any) error { + var err error + switch x := v.(type) { + case fmt.Stringer: + _, err = w.Write([]byte(x.String())) + case io.WriterTo: + _, err = x.WriteTo(w) + default: + return ErrCannotRender + } + + if err != nil { + return fmt.Errorf("%w: %w", ErrFailed, err) + } + + return nil +} diff --git a/writer_to_test.go b/text_test.go similarity index 58% rename from writer_to_test.go rename to text_test.go index 709109b..46edf9a 100644 --- a/writer_to_test.go +++ b/text_test.go @@ -1,8 +1,8 @@ package render_test import ( - "bytes" "errors" + "fmt" "io" "testing" @@ -10,6 +10,16 @@ import ( "github.com/stretchr/testify/assert" ) +type mockStringer struct { + value string +} + +var _ fmt.Stringer = (*mockStringer)(nil) + +func (ms *mockStringer) String() string { + return ms.value +} + type mockWriterTo struct { value string err error @@ -27,7 +37,7 @@ func (m *mockWriterTo) WriteTo(w io.Writer) (int64, error) { return int64(n), err } -func TestWriterTo_Render(t *testing.T) { +func TestText_Render(t *testing.T) { tests := []struct { name string writeErr error @@ -36,33 +46,45 @@ func TestWriterTo_Render(t *testing.T) { wantErr string wantErrIs []error }{ + { + name: "implements fmt.Stringer", + value: &mockStringer{value: "test string"}, + want: "test string", + }, + { + name: "error writing to writer with fmt.Stringer", + writeErr: errors.New("write error!!1"), + value: &mockStringer{value: "test string"}, + wantErr: "render: failed: write error!!1", + wantErrIs: []error{render.Err, render.ErrFailed}, + }, { name: "implements io.WriterTo", value: &mockWriterTo{value: "test string"}, want: "test string", }, { - name: "does not implement io.WriterTo", + name: "does not implement fmt.Stringer or io.WriterTo", value: struct{}{}, wantErr: "render: cannot render", wantErrIs: []error{render.Err, render.ErrCannotRender}, }, { - name: "error writing to writer", + name: "error writing to writer with io.WriterTo", value: &mockWriterTo{ value: "test string", err: errors.New("WriteTo error!!1"), }, - wantErr: "render: WriteTo error!!1", - wantErrIs: []error{render.Err}, + wantErr: "render: failed: WriteTo error!!1", + wantErrIs: []error{render.Err, render.ErrFailed}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - wt := &render.WriterTo{} - w := &bytes.Buffer{} + s := &render.Text{} + w := &mockWriter{WriteErr: tt.writeErr} - err := wt.Render(w, tt.value) + err := s.Render(w, tt.value) got := w.String() if tt.wantErr != "" { diff --git a/writer_to.go b/writer_to.go deleted file mode 100644 index 80f023b..0000000 --- a/writer_to.go +++ /dev/null @@ -1,28 +0,0 @@ -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/xml.go b/xml.go index a601a3e..8fd8c07 100644 --- a/xml.go +++ b/xml.go @@ -21,7 +21,7 @@ type XML struct { Indent string } -var _ Renderer = (*XML)(nil) +var _ FormatRenderer = (*XML)(nil) // Render marshals the given value to XML. func (x *XML) Render(w io.Writer, v any) error { @@ -38,7 +38,7 @@ func (x *XML) Render(w io.Writer, v any) error { err := enc.Encode(v) if err != nil { - return fmt.Errorf("%w: %w", Err, err) + return fmt.Errorf("%w: %w", ErrFailed, err) } return nil diff --git a/xml_test.go b/xml_test.go index 7f28913..2e5b33f 100644 --- a/xml_test.go +++ b/xml_test.go @@ -89,15 +89,15 @@ func TestXML_Render(t *testing.T) { { name: "error from xml.Marshaler", value: &mockXMLMarshaler{err: errors.New("mock error")}, - wantErr: "render: mock error", - wantErrIs: []error{render.Err}, + wantErr: "render: failed: mock error", + wantErrIs: []error{render.Err, render.ErrFailed}, }, { name: "invalid value", pretty: false, value: make(chan int), - wantErr: "render: xml: unsupported type: chan int", - wantErrIs: []error{render.Err}, + wantErr: "render: failed: xml: unsupported type: chan int", + wantErrIs: []error{render.Err, render.ErrFailed}, }, } diff --git a/yaml.go b/yaml.go index fea91be..46b2f3b 100644 --- a/yaml.go +++ b/yaml.go @@ -14,7 +14,7 @@ type YAML struct { Indent int } -var _ Renderer = (*YAML)(nil) +var _ FormatRenderer = (*YAML)(nil) // Render marshals the given value to YAML. func (j *YAML) Render(w io.Writer, v any) error { @@ -29,7 +29,7 @@ func (j *YAML) Render(w io.Writer, v any) error { err := enc.Encode(v) if err != nil { - return fmt.Errorf("%w: %w", Err, err) + return fmt.Errorf("%w: %w", ErrFailed, err) } return nil diff --git a/yaml_test.go b/yaml_test.go index 2b930dd..27f2b38 100644 --- a/yaml_test.go +++ b/yaml_test.go @@ -66,8 +66,8 @@ func TestYAML_Render(t *testing.T) { { name: "error from yaml.Marshaler", value: &mockYAMLMarshaler{err: errors.New("mock error")}, - wantErr: "render: mock error", - wantErrIs: []error{render.Err}, + wantErr: "render: failed: mock error", + wantErrIs: []error{render.Err, render.ErrFailed}, }, { name: "invalid value",