diff --git a/cmd/run.go b/cmd/run.go index b5028bf..3a9882d 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -28,6 +28,9 @@ func runCommandFunction(bldr *builder.Builder) { os.Exit(1) } - prntr := bldr.BuildPrinter() - prntr.Print(result) + outputter := bldr.BuildOutputter() + if err := outputter.Write(result); err != nil { + fmt.Println(err) + os.Exit(1) + } } diff --git a/pkg/builder/builder.go b/pkg/builder/builder.go index 8d35cef..af2560f 100644 --- a/pkg/builder/builder.go +++ b/pkg/builder/builder.go @@ -58,3 +58,8 @@ func (b *Builder) BuildPrinter() *printer.Printer { p := printer.NewPrinter(b.ClientConfig.PrinterConfig) return &p } + +func (b *Builder) BuildOutputter() *printer.Outputter { + o := printer.NewOutputter(b.ClientConfig.PrinterConfig) + return o +} diff --git a/pkg/printer/formatter.go b/pkg/printer/formatter.go index 1db3a2b..9f6b361 100644 --- a/pkg/printer/formatter.go +++ b/pkg/printer/formatter.go @@ -1,6 +1,7 @@ package printer import ( + "encoding/json" "fmt" "net/http" "sort" @@ -11,24 +12,132 @@ import ( "github.com/eynopv/lac/pkg/result" ) -type Formatter struct { - colored bool +type Formatter interface { + Format(res *result.Result) (string, error) } -func (f Formatter) Headers(headers http.Header) string { - return formatHeaders(headers, f.colored) +type JsonFormatter struct { + includes Includes } -func (f Formatter) StatusLine(line result.StatusLine) string { - return formatStatusLine(line, f.colored) +func (f *JsonFormatter) Format(res *result.Result) (string, error) { + m := map[string]any{} + + if f.includes.RequestHeaders || f.includes.RequestBody || f.includes.RequestMeta { + rm := map[string]any{} + + if f.includes.RequestMeta { + rm["meta"] = res.RequestLine() + } + + if f.includes.RequestHeaders { + rm["headers"] = res.Response.Request.Header + } + + if f.includes.RequestBody { + rm["body"] = res.RequestBody.Data() + } + + m["request"] = rm + } + + if f.includes.ResponseHeaders || f.includes.ResponseBody || f.includes.ResponseMeta { + rm := map[string]any{} + + if f.includes.ResponseMeta { + rm["meta"] = res.StatusLine() + } + + if f.includes.ResponseHeaders { + rm["headers"] = res.Response.Header + } + + if f.includes.ResponseBody { + rm["body"] = res.ResponseBody.Data() + } + + m["response"] = rm + } + + b, err := json.MarshalIndent(m, "", " ") + + if err != nil { + return "", err + } + + return string(b), nil +} + +type PrettyFormatter struct { + includes Includes +} + +func (f *PrettyFormatter) Format(res *result.Result) (string, error) { + sections := []string{} + + if f.includes.RequestHeaders { + sections = append(sections, f.printRequestHeaders(res)) + } + + if f.includes.RequestBody { + sections = append(sections, f.printBody(&res.RequestBody)) + } + + if f.includes.ResponseHeaders { + sections = append(sections, f.printResponseHeaders(res)) + } + + if f.includes.ResponseBody { + sections = append(sections, f.printBody(&res.ResponseBody)) + } + + return strings.Join(sections, "\n"), nil +} + +func (f *PrettyFormatter) printRequestHeaders(res *result.Result) string { + req := *res.Response.Request + + if f.includes.RequestMeta { + return f.requestLine(*res.RequestLine()) + f.headers(req.Header) + } + + return f.headers(req.Header) +} + +func (f *PrettyFormatter) printResponseHeaders(res *result.Result) string { + if f.includes.ResponseMeta { + return f.statusLine(*res.StatusLine()) + f.headers(res.Response.Header) + } + + return f.headers(res.Response.Header) +} + +func (f *PrettyFormatter) printBody(body *result.Body) string { + if jsonBody := body.Json(); jsonBody != nil { + return fmt.Sprintf("%v\n", f.json(jsonBody)) + } + + if textBody := body.Text(); textBody != "" { + return fmt.Sprintf("%v\n", textBody) + } + + return "" +} + +func (f *PrettyFormatter) headers(headers http.Header) string { + return formatHeaders(headers, true) +} + +func (f *PrettyFormatter) statusLine(line result.StatusLine) string { + return formatStatusLine(line, true) } -func (f Formatter) RequestLine(line result.RequestLine) string { - return formatRequestLine(line, f.colored) +func (f *PrettyFormatter) requestLine(line result.RequestLine) string { + return formatRequestLine(line, true) } -func (f Formatter) Json(j map[string]any) string { - return formatJson(j, f.colored) +func (f *PrettyFormatter) json(j map[string]any) string { + return formatJson(j, true) } func formatHeaders(headers http.Header, colorized bool) string { diff --git a/pkg/printer/formatter_test.go b/pkg/printer/formatter_test.go deleted file mode 100644 index a4feee4..0000000 --- a/pkg/printer/formatter_test.go +++ /dev/null @@ -1,322 +0,0 @@ -package printer - -import ( - "fmt" - "net/http" - "testing" - "time" - - "github.com/eynopv/lac/internal/assert" - "github.com/eynopv/lac/pkg/result" -) - -func Test_formatStatusLine(t *testing.T) { - tests := []struct { - name string - line result.StatusLine - useColor bool - want string - }{ - { - name: "non-colorized 200", - line: result.StatusLine{ - Protocol: "HTTP/1.1", - Status: "200 OK", - Time: 300 * time.Millisecond, - }, - useColor: false, - want: "HTTP/1.1 200 OK [300ms]\n", - }, - { - name: "colorized 200 fast response", - line: result.StatusLine{ - Protocol: "HTTP/2", - Status: "200 OK", - Time: 300 * time.Millisecond, - }, - useColor: true, - want: fmt.Sprintf( - "%v %v [%v]\n", - "HTTP/2", - Colorize("200 OK", ColorGreen), - Colorize("300ms", ColorReset), - ), - }, - { - name: "colorized 301 medium response", - line: result.StatusLine{ - Protocol: "HTTP/1.1", - Status: "301 Moved Permanently", - Time: 800 * time.Millisecond, - }, - useColor: true, - want: fmt.Sprintf( - "%v %v [%v]\n", - "HTTP/1.1", - Colorize("301 Moved Permanently", ColorCyan), - Colorize("800ms", ColorYellow), - ), - }, - { - name: "colorized 500 slow response", - line: result.StatusLine{ - Protocol: "HTTP/1.1", - Status: "500 Internal Server Error", - Time: 1500 * time.Millisecond, - }, - useColor: true, - want: fmt.Sprintf( - "%v %v [%v]\n", - "HTTP/1.1", - Colorize("500 Internal Server Error", ColorRed), - Colorize("1.5s", ColorRed), - ), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - formatter := Formatter{colored: tt.useColor} - got := formatter.StatusLine(tt.line) - assert.Equal(t, tt.want, got) - }) - } -} - -func Test_formatRequestLine(t *testing.T) { - testUrl := "https://example.com/dogs" - tests := []struct { - name string - line result.RequestLine - useColor bool - want string - }{ - { - name: "non-colorized GET", - line: result.RequestLine{ - Method: "GET", - Url: testUrl, - Protocol: "HTTP/1.1", - }, - useColor: false, - want: fmt.Sprintf("GET %v HTTP/1.1\n", testUrl), - }, - { - name: "colorized GET", - line: result.RequestLine{ - Method: "GET", - Url: testUrl, - Protocol: "HTTP/1.1", - }, - useColor: true, - want: fmt.Sprintf( - "%v %v %v\n", - Colorize("GET", ColorGreen), - testUrl, - "HTTP/1.1", - ), - }, - { - name: "colorized POST", - line: result.RequestLine{ - Method: "POST", - Url: testUrl, - Protocol: "HTTP/2", - }, - useColor: true, - want: fmt.Sprintf( - "%v %v %v\n", - Colorize("POST", ColorYellow), - testUrl, - "HTTP/2", - ), - }, - { - name: "colorized DELETE", - line: result.RequestLine{ - Method: "DELETE", - Url: testUrl, - Protocol: "HTTP/1.1", - }, - useColor: true, - want: fmt.Sprintf( - "%v %v %v\n", - Colorize("DELETE", ColorRed), - testUrl, - "HTTP/1.1", - ), - }, - { - name: "colorized default", - line: result.RequestLine{ - Method: "OPTIONS", - Url: testUrl, - Protocol: "HTTP/1.0", - }, - useColor: true, - want: fmt.Sprintf( - "%v %v %v\n", - Colorize("OPTIONS", ColorMagenta), - testUrl, - "HTTP/1.0", - ), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - formatter := Formatter{colored: tt.useColor} - got := formatter.RequestLine(tt.line) - assert.Equal(t, tt.want, got) - }) - } -} - -func Test_formatJson(t *testing.T) { - tests := []struct { - name string - json map[string]any - useColor bool - want string - }{ - { - name: "simple", - json: map[string]any{ - "name": "Alice", - "age": 30, - }, - useColor: false, - want: "{\n \"age\": 30,\n \"name\": \"Alice\"\n}", - }, - { - name: "nested", - json: map[string]any{ - "user": map[string]any{ - "id": 1, - "name": "Bob", - }, - }, - useColor: false, - want: "{\n \"user\": {\n \"id\": 1,\n \"name\": \"Bob\"\n }\n}", - }, - { - name: "empty", - json: map[string]any{}, - useColor: false, - want: "{}", - }, - { - name: "colored int", - json: map[string]any{ - "int": 1, - }, - useColor: true, - want: fmt.Sprintf("{\n %v: %v\n}", Yellow(`"int"`), Cyan("1")), - }, - { - name: "colored string", - json: map[string]any{ - "string": "Hello, World", - }, - useColor: true, - want: fmt.Sprintf("{\n %v: %v\n}", Yellow(`"string"`), Green(`"Hello, World"`)), - }, - { - name: "colored boolean", - json: map[string]any{ - "boolean": true, - }, - useColor: true, - want: fmt.Sprintf("{\n %v: %v\n}", Yellow(`"boolean"`), Magenta("true")), - }, - { - name: "colored float", - json: map[string]any{ - "float": 1.23, - }, - useColor: true, - want: fmt.Sprintf("{\n %v: %v\n}", Yellow(`"float"`), Cyan("1.23")), - }, - { - name: "colored nil", - json: map[string]any{ - "nil": nil, - }, - useColor: true, - want: fmt.Sprintf("{\n %v: %v\n}", Yellow(`"nil"`), Red("null")), - }, - { - name: "colored list", - json: map[string]any{ - "list": []any{1, "string", false, nil, map[string]any{"one": 1}}, - }, - useColor: true, - want: fmt.Sprintf("{\n %v: [%v, %v, %v, %v, {\n %v: %v\n }]\n}", - Yellow(`"list"`), - Cyan("1"), - Green(`"string"`), - Magenta("false"), - Red("null"), - Yellow(`"one"`), - Cyan("1"), - ), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - formatter := Formatter{colored: tt.useColor} - got := formatter.Json(tt.json) - assert.Equal(t, tt.want, got) - }) - } -} - -func Test_formatHeaders(t *testing.T) { - tests := []struct { - name string - headers http.Header - useColor bool - want string - }{ - { - name: "no headers", - headers: http.Header{}, - useColor: false, - want: "", - }, - { - name: "single header non-colorized", - headers: http.Header{ - "Content-Type": {"application/json"}, - }, - useColor: false, - want: "Content-Type: application/json\n", - }, - { - name: "single header colorized", - headers: http.Header{ - "Content-Type": {"application/json"}, - }, - useColor: true, - want: fmt.Sprintf("%v: %v\n", Cyan("Content-Type"), "application/json"), - }, - { - name: "multiple headers", - headers: http.Header{ - "Content-Type": {"application/json"}, - "X-Custom": {"value1", "value2"}, - }, - useColor: false, - want: "Content-Type: application/json\nX-Custom: value1, value2\n", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - formatter := Formatter{colored: tt.useColor} - got := formatter.Headers(tt.headers) - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/pkg/printer/outputter.go b/pkg/printer/outputter.go new file mode 100644 index 0000000..b64d10a --- /dev/null +++ b/pkg/printer/outputter.go @@ -0,0 +1,63 @@ +package printer + +import ( + "fmt" + "io" + "os" + + "github.com/eynopv/lac/pkg/result" +) + +type Includes struct { + ResponseBody bool + ResponseHeaders bool + ResponseMeta bool + RequestBody bool + RequestHeaders bool + RequestMeta bool +} + +type Outputter struct { + includes Includes + destination io.Writer + formatter Formatter +} + +func NewOutputter(config PrinterConfig) *Outputter { + includes := Includes{ + ResponseBody: config.PrintRequestBody, + ResponseHeaders: config.PrintResponseHeaders, + ResponseMeta: config.PrintResponseMeta, + RequestBody: config.PrintRequestBody, + RequestHeaders: config.PrintRequestHeaders, + RequestMeta: config.PrintRequestMeta, + } + + var formatter Formatter + if IsTerminal(int(os.Stdout.Fd())) { + formatter = &PrettyFormatter{ + includes: includes, + } + } else { + formatter = &JsonFormatter{ + includes: includes, + } + } + + return &Outputter{ + includes: includes, + destination: os.Stdout, + formatter: formatter, + } +} + +func (o *Outputter) Write(res *result.Result) error { + formatted, err := o.formatter.Format(res) + if err != nil { + return err + } + + _, err = fmt.Fprint(o.destination, formatted) + + return err +} diff --git a/pkg/printer/printer.go b/pkg/printer/printer.go index f66bb79..4ad2a8e 100644 --- a/pkg/printer/printer.go +++ b/pkg/printer/printer.go @@ -1,14 +1,10 @@ package printer import ( - "fmt" "io" "os" - "strings" "golang.org/x/term" - - "github.com/eynopv/lac/pkg/result" ) var IsTerminal = term.IsTerminal @@ -29,70 +25,8 @@ type Printer struct { } func NewPrinter(config PrinterConfig) Printer { - formatter := Formatter{ - colored: IsTerminal(int(os.Stdout.Fd())), - } - return Printer{ config: config, destination: os.Stdout, - formatter: formatter, - } -} - -func (p *Printer) Print(res *result.Result) { - if res.Response == nil { - fmt.Fprint(p.destination, "No HTTP response available\n") - return - } - - sections := []string{} - - if p.config.PrintRequestHeaders { - sections = append(sections, p.printRequestHeaders(res)) - } - - if p.config.PrintRequestBody { - sections = append(sections, p.printBody(&res.RequestBody)) - } - - if p.config.PrintResponseHeaders { - sections = append(sections, p.printResponseHeaders(res)) - } - - if p.config.PrintResponseBody { - sections = append(sections, p.printBody(&res.ResponseBody)) } - - fmt.Fprint(p.destination, strings.Join(sections, "\n")) -} - -func (p *Printer) printRequestHeaders(res *result.Result) string { - req := *res.Response.Request - - if p.config.PrintRequestMeta { - return p.formatter.RequestLine(*res.RequestLine()) + p.formatter.Headers(req.Header) - } - - return p.formatter.Headers(req.Header) -} - -func (p *Printer) printResponseHeaders(res *result.Result) string { - if p.config.PrintResponseMeta { - return p.formatter.StatusLine(*res.StatusLine()) + p.formatter.Headers(res.Response.Header) - } - - return p.formatter.Headers(res.Response.Header) -} - -func (p *Printer) printBody(body *result.Body) string { - if jsonBody := body.Json(); jsonBody != nil { - return fmt.Sprintf("%v\n", p.formatter.Json(jsonBody)) - } - - if textBody := body.Text(); textBody != "" { - return fmt.Sprintf("%v\n", textBody) - } - - return "" } diff --git a/pkg/printer/printer_test.go b/pkg/printer/printer_test.go deleted file mode 100644 index 6c96cad..0000000 --- a/pkg/printer/printer_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package printer - -import ( - "bytes" - "net/http" - "net/url" - "strings" - "testing" - "time" - - "github.com/eynopv/lac/internal/assert" - "github.com/eynopv/lac/pkg/result" -) - -func TestNewPrinter(t *testing.T) { - tests := []struct { - name string - isTerminal bool - expectColor bool - }{ - {"TerminalOutput", true, true}, - {"NonTerminalOutput", false, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - originalIsTerminal := IsTerminal - defer func() { IsTerminal = originalIsTerminal }() - - IsTerminal = func(fd int) bool { return tt.isTerminal } - - p := NewPrinter(PrinterConfig{}) - - assert.Equal(t, p.formatter.colored, tt.isTerminal) - }) - } -} - -func TestPrinter_Print(t *testing.T) { - var buf bytes.Buffer - - res := result.Result{ - Response: &http.Response{ - Status: "200 OK", - Proto: "HTTP/1.1", - Request: &http.Request{ - Method: "GET", - Proto: "HTTP/1.1", - URL: &url.URL{Scheme: "https", Host: "example.com", Path: "/"}, - Header: http.Header{"Req-H": []string{"v"}}, - }, - Header: http.Header{"Res-H": []string{"x"}}, - }, - RequestBody: []byte(`request body`), - ResponseBody: []byte(`response body`), - Metadata: result.Metadata{ElapsedTime: 123 * time.Millisecond}, - } - - cases := []struct { - name string - config PrinterConfig - expect []string - }{ - { - "OnlyResponseBody", - PrinterConfig{PrintResponseBody: true}, - []string{`response body`}, - }, - { - "RequestHeadersAndBody", - PrinterConfig{PrintRequestHeaders: true, PrintRequestBody: true}, - []string{`request body`, "Req-H"}, - }, - { - "RequestAllSections", - PrinterConfig{PrintRequestHeaders: true, PrintRequestBody: true, PrintRequestMeta: true}, - []string{"GET https://example.com/", `request body`, "Req-H"}, - }, - { - "AllSections", - PrinterConfig{ - PrintRequestHeaders: true, - PrintRequestBody: true, - PrintRequestMeta: true, - PrintResponseHeaders: true, - PrintResponseBody: true, - PrintResponseMeta: true, - }, - []string{ - "GET https://example.com/ HTTP/1.1", - "Req-H", - "request body", - "HTTP/1.1 200 OK [123ms]", - "Res-H", - "response body", - }, - }, - } - - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - buf.Reset() - - p := Printer{ - config: tt.config, - destination: &buf, - formatter: Formatter{}, - } - - p.Print(&res) - - out := buf.String() - for _, expect := range tt.expect { - if !strings.Contains(out, expect) { - t.Errorf("expected output to contain %q: %v", expect, out) - } - } - }) - } -} diff --git a/pkg/result/result.go b/pkg/result/result.go index c14daa5..47596e3 100644 --- a/pkg/result/result.go +++ b/pkg/result/result.go @@ -18,15 +18,15 @@ type Metadata struct { } type StatusLine struct { - Protocol string - Status string - Time time.Duration + Protocol string `json:"protocol"` + Status string `json:"status"` + Time time.Duration `json:"time"` } type RequestLine struct { - Protocol string - Url string - Method string + Protocol string `json:"protocol"` + Url string `json:"url"` + Method string `json:"method"` } type Body []byte @@ -39,6 +39,10 @@ func (r Result) StatusLine() *StatusLine { } } +func (r Result) ResponseLine() *StatusLine { + return r.StatusLine() +} + func (r Result) RequestLine() *RequestLine { return &RequestLine{ Protocol: r.Response.Request.Proto, @@ -47,6 +51,14 @@ func (r Result) RequestLine() *RequestLine { } } +func (b Body) Data() any { + if body := b.Json(); body != nil { + return body + } + + return b.Text() +} + func (b Body) Json() map[string]any { if len(b) == 0 { return nil