diff --git a/.golangci.yml b/.golangci.yml index dff052d..b54e1a5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -111,6 +111,9 @@ linters: - path: response_writer(_test)?\.go linters: - wrapcheck + - path: responder(_test)?\.go + linters: + - wrapcheck formatters: enable: - gofmt diff --git a/README.md b/README.md index e719691..9e5ffc1 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ native performance untouched. Simple, not simplistic. context type to learn - Wrapper composition with `Chain` and `ChainFunc` - Conditional wrapper application with `Selector` +- Response helpers: `JSON` (with `EscapeForHTML` / `Indented` options), + `XML`, `Text`, `Bytes`, `Stream` for chunked streaming, and + `Attachment` for file downloads - Graceful shutdown with `Run` **Built-in wrappers** diff --git a/responder.go b/responder.go new file mode 100644 index 0000000..38f4e19 --- /dev/null +++ b/responder.go @@ -0,0 +1,112 @@ +// Copyright (c) 2026 The Sim Authors +// Use of this source code is governed by a MIT license +// that can be found in the LICENSE file. + +package sim + +import ( + "encoding/json" + "encoding/xml" + "io" + "mime" + "net/http" + "path/filepath" +) + +// JSONEncoderOption configures JSON. +type JSONEncoderOption func(*jsonEncoderOptions) + +type jsonEncoderOptions struct { + escapeHTML bool + indent bool +} + +// EscapeForHTML controls HTML character escaping in JSON output. +// Escaping is enabled by default; EscapeForHTML(false) disables it. +func EscapeForHTML(v bool) JSONEncoderOption { + return func(o *jsonEncoderOptions) { o.escapeHTML = v } +} + +// Indented controls indentation in JSON output. +// Indentation is disabled by default; Indented(true) enables it. +func Indented(v bool) JSONEncoderOption { + return func(o *jsonEncoderOptions) { o.indent = v } +} + +// JSON writes data as JSON with the given status code. +func JSON(w http.ResponseWriter, statusCode int, data any, opts ...JSONEncoderOption) error { + o := jsonEncoderOptions{escapeHTML: true} + for _, opt := range opts { + opt(&o) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(statusCode) + enc := json.NewEncoder(w) + if !o.escapeHTML { + enc.SetEscapeHTML(false) + } + if o.indent { + enc.SetIndent("", " ") + } + return enc.Encode(data) +} + +// XML writes data as XML with the given status code. +func XML(w http.ResponseWriter, statusCode int, data any) error { + b, err := xml.Marshal(data) + if err != nil { + return err + } + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + w.WriteHeader(statusCode) + if _, err = w.Write([]byte(xml.Header)); err != nil { + return err + } + _, err = w.Write(b) + return err +} + +// Text writes s as plain text with the given status code. +func Text(w http.ResponseWriter, statusCode int, s string) error { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(statusCode) + _, err := io.WriteString(w, s) + return err +} + +// Bytes writes raw bytes with the given status code and content type. +func Bytes(w http.ResponseWriter, statusCode int, contentType string, b []byte) error { + w.Header().Set("Content-Type", contentType) + w.WriteHeader(statusCode) + _, err := w.Write(b) + return err +} + +// Stream writes r with the given status code and content type. The body is +// copied in chunks, keeping memory use constant, so Stream suits large or +// not-yet-complete bodies: file downloads, proxied upstream responses and +// generated streams. With an *os.File, the copy uses sendfile when possible. +// Prefer http.ServeFile / http.ServeFileFS for disk files that need Range +// and caching support, and Attachment for downloads that prompt the client +// to save the body under a filename. Extra headers such as Content-Length +// can be set on w before calling Stream. +func Stream(w http.ResponseWriter, statusCode int, contentType string, r io.Reader) error { + w.Header().Set("Content-Type", contentType) + w.WriteHeader(statusCode) + _, err := io.Copy(w, r) + return err +} + +// Attachment writes r with status 200 OK as a download attachment, +// prompting browsers to save it as filename. Content-Disposition is set from +// filename, and Content-Type is inferred from filename's extension, falling +// back to application/octet-stream. Prefer http.ServeFile / http.ServeFileFS +// for disk files that need Range and caching support. +func Attachment(w http.ResponseWriter, filename string, r io.Reader) error { + w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename})) + contentType := mime.TypeByExtension(filepath.Ext(filename)) + if contentType == "" { + contentType = "application/octet-stream" + } + return Stream(w, http.StatusOK, contentType, r) +} diff --git a/responder_test.go b/responder_test.go new file mode 100644 index 0000000..1879782 --- /dev/null +++ b/responder_test.go @@ -0,0 +1,334 @@ +// Copyright (c) 2026 The Sim Authors +// Use of this source code is governed by a MIT license +// that can be found in the LICENSE file. + +package sim_test + +import ( + "bytes" + "encoding/xml" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/qm012/sim" +) + +func TestJSON(t *testing.T) { + tests := []struct { + name string + data any + opts []sim.JSONEncoderOption + wantBody string + wantErr bool + }{ + { + name: "escapes HTML by default", + data: map[string]string{"html": ""}, + wantBody: "{\"html\":\"\\u003cb\\u003e\"}\n", + }, + { + name: "EscapeForHTML(false) disables escaping", + data: map[string]string{"html": ""}, + opts: []sim.JSONEncoderOption{sim.EscapeForHTML(false)}, + wantBody: "{\"html\":\"\"}\n", + }, + { + name: "Indented(true) indents output", + data: map[string]string{"name": "sim"}, + opts: []sim.JSONEncoderOption{sim.Indented(true)}, + wantBody: "{\n \"name\": \"sim\"\n}\n", + }, + { + name: "channel fails to encode", + data: make(chan int), + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + err := sim.JSON(rec, http.StatusCreated, tt.data, tt.opts...) + if tt.wantErr { + if err == nil { + t.Fatal("JSON() error = nil, want error") + } + return + } + if err != nil { + t.Fatalf("JSON() error = %v", err) + } + if got := rec.Code; got != http.StatusCreated { + t.Errorf("status = %d, want %d", got, http.StatusCreated) + } + if got := rec.Header().Get("Content-Type"); got != "application/json; charset=utf-8" { + t.Errorf("Content-Type = %q", got) + } + if got := rec.Body.String(); got != tt.wantBody { + t.Errorf("body = %q, want %q", got, tt.wantBody) + } + }) + } +} + +type xmlUser struct { + XMLName xml.Name `xml:"user"` + Name string `xml:"name"` +} + +type errWriter struct { + header http.Header +} + +var errWriteFailed = errors.New("write failed") + +func (w *errWriter) Header() http.Header { return w.header } + +func (w *errWriter) WriteHeader(int) {} + +func (w *errWriter) Write([]byte) (int, error) { return 0, errWriteFailed } + +func TestXML(t *testing.T) { + tests := []struct { + name string + data any + failWrite bool + wantBody string + wantErr bool + }{ + { + name: "ok", + data: xmlUser{Name: "sim"}, + wantBody: xml.Header + "sim", + }, + { + name: "channel fails to marshal", + data: make(chan int), + wantErr: true, + }, + { + name: "write error surfaces", + data: xmlUser{Name: "sim"}, + failWrite: true, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + var w http.ResponseWriter = rec + if tt.failWrite { + w = &errWriter{header: make(http.Header)} + } + err := sim.XML(w, http.StatusOK, tt.data) + if tt.wantErr { + if err == nil { + t.Fatal("XML() error = nil, want error") + } + return + } + if err != nil { + t.Fatalf("XML() error = %v", err) + } + if got := rec.Header().Get("Content-Type"); got != "application/xml; charset=utf-8" { + t.Errorf("Content-Type = %q", got) + } + if got := rec.Body.String(); got != tt.wantBody { + t.Errorf("body = %q, want %q", got, tt.wantBody) + } + }) + } +} + +func TestText(t *testing.T) { + tests := []struct { + name string + statusCode int + s string + }{ + {"ok", http.StatusOK, "hello"}, + {"not found", http.StatusNotFound, "missing"}, + {"empty", http.StatusOK, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + if err := sim.Text(rec, tt.statusCode, tt.s); err != nil { + t.Fatalf("Text() error = %v", err) + } + if got := rec.Code; got != tt.statusCode { + t.Errorf("status = %d, want %d", got, tt.statusCode) + } + if got := rec.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" { + t.Errorf("Content-Type = %q", got) + } + if got := rec.Body.String(); got != tt.s { + t.Errorf("body = %q, want %q", got, tt.s) + } + }) + } +} + +func TestBytes(t *testing.T) { + b := []byte{0x89, 'P', 'N', 'G'} + rec := httptest.NewRecorder() + if err := sim.Bytes(rec, http.StatusOK, "image/png", b); err != nil { + t.Fatalf("Bytes() error = %v", err) + } + if got := rec.Header().Get("Content-Type"); got != "image/png" { + t.Errorf("Content-Type = %q, want %q", got, "image/png") + } + if got := rec.Body.Bytes(); !bytes.Equal(got, b) { + t.Errorf("body = %v, want %v", got, b) + } +} + +type errReader struct{} + +var errReadFailed = errors.New("read failed") + +func (errReader) Read([]byte) (int, error) { + return 0, errReadFailed +} + +func TestStream(t *testing.T) { + tests := []struct { + name string + contentType string + body io.Reader + preHeaders map[string]string + wantBody string + wantErr bool + }{ + { + name: "ok", + contentType: "text/csv", + body: strings.NewReader("a,b,c"), + wantBody: "a,b,c", + }, + { + name: "preserves extra headers", + contentType: "image/png", + body: strings.NewReader("png"), + preHeaders: map[string]string{"Content-Disposition": `attachment; filename="gopher.png"`}, + wantBody: "png", + }, + { + name: "reader error surfaces", + contentType: "image/png", + body: errReader{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + for k, v := range tt.preHeaders { + rec.Header().Set(k, v) + } + err := sim.Stream(rec, http.StatusOK, tt.contentType, tt.body) + if tt.wantErr { + if err == nil { + t.Fatal("Stream() error = nil, want error") + } + return + } + if err != nil { + t.Fatalf("Stream() error = %v", err) + } + if got := rec.Header().Get("Content-Type"); got != tt.contentType { + t.Errorf("Content-Type = %q, want %q", got, tt.contentType) + } + for k, want := range tt.preHeaders { + if got := rec.Header().Get(k); got != want { + t.Errorf("%s = %q, want %q", k, got, want) + } + } + if got := rec.Body.String(); got != tt.wantBody { + t.Errorf("body = %q, want %q", got, tt.wantBody) + } + }) + } +} + +//nolint:funlen // table-driven test +func TestAttachment(t *testing.T) { + tests := []struct { + name string + filename string + body io.Reader + wantDisposition string + wantContentType string + wantBody string + wantErr bool + }{ + { + name: "ascii filename infers type", + filename: "report.txt", + body: strings.NewReader("a,b,c"), + wantDisposition: `attachment; filename=report.txt`, + wantContentType: "text/plain; charset=utf-8", + wantBody: "a,b,c", + }, + { + name: "unicode filename uses RFC 5987 encoding", + filename: "报告.txt", + body: strings.NewReader("a,b,c"), + wantDisposition: `attachment; filename*=utf-8''%E6%8A%A5%E5%91%8A.txt`, + wantContentType: "text/plain; charset=utf-8", + wantBody: "a,b,c", + }, + { + name: "quote in filename is escaped", + filename: `say "hi".txt`, + body: strings.NewReader("hi"), + wantDisposition: `attachment; filename="say \"hi\".txt"`, + wantContentType: "text/plain; charset=utf-8", + wantBody: "hi", + }, + { + name: "unknown extension falls back to octet-stream", + filename: "blob", + body: strings.NewReader("raw"), + wantDisposition: `attachment; filename=blob`, + wantContentType: "application/octet-stream", + wantBody: "raw", + }, + { + name: "reader error surfaces", + filename: "blob", + body: errReader{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + err := sim.Attachment(rec, tt.filename, tt.body) + if tt.wantErr { + if err == nil { + t.Fatal("Attachment() error = nil, want error") + } + return + } + if err != nil { + t.Fatalf("Attachment() error = %v", err) + } + if got := rec.Code; got != http.StatusOK { + t.Errorf("status = %d, want %d", got, http.StatusOK) + } + if got := rec.Header().Get("Content-Disposition"); got != tt.wantDisposition { + t.Errorf("Content-Disposition = %q, want %q", got, tt.wantDisposition) + } + if got := rec.Header().Get("Content-Type"); got != tt.wantContentType { + t.Errorf("Content-Type = %q, want %q", got, tt.wantContentType) + } + if got := rec.Body.String(); got != tt.wantBody { + t.Errorf("body = %q, want %q", got, tt.wantBody) + } + }) + } +}