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