Skip to content

Commit

Permalink
Raw file interfaces are composition-based (#5)
Browse files Browse the repository at this point in the history
  • Loading branch information
robsignorelli authored May 7, 2021
1 parent ced9d99 commit 40c4f8c
Show file tree
Hide file tree
Showing 3 changed files with 144 additions and 64 deletions.
44 changes: 24 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,19 +330,21 @@ In addition to writing the bytes, `respond` will apply the correct
`Content-Type` and `Content-Disposition` headers based on the name/extension
of the file you provide.
### Raw Files By Implementing io.Reader
### Raw Files By Implementing ContentReader
If you'd like to decouple yourself further from the `respond`
library when serving up raw files, you can continue to respond
using `Ok()` with your own structs/values as long as it implements
`io.Reader`. When `respond` comes across a result that also
implements the reader interface, it will assume that you want
to return that reader's raw bytes rather than marshaling the
struct as JSON:
`ContentReader` - which basically means that it has a `Content()` method
that returns an `io.Reader` with the raw data.
Instead of marshaling the result value as JSON and responding
with those bytes, it will respond with the raw bytes your
reader supplies.
```go
func ExportCSV(w http.ResponseWriter, req *http.Request) {
// This is an *Export which implements io.Reader
// This is an *Export which implements ContentReader
export := crunchTheNumbers()

// Respond with the raw CSV reader data and the following:
Expand All @@ -354,30 +356,33 @@ func ExportCSV(w http.ResponseWriter, req *http.Request) {
}

type Export struct {
RawData *bytes.Buffer
csvData *bytes.Buffer
}

func (e Export) Read(b []byte) (int, error) {
return e.RawData.Read(b)
func (e Export) Content() io.Reader {
return e.csvData
}
```
Most of the time you probably don't want that generic
content type. In other instances you may want to trigger a download, instead. To
rectify that, you can implement two optional interfaces to
Most of the time, however, you probably don't want that generic
content type. Additionally, there may be instances where you'd
rather have the client trigger a download rather than consume
the content inline.
To rectify that, you can implement two optional interfaces to
customize both behaviors:
```go
// Implement this to customize the "Content-Type" header.
type ContentTypeSpecified interface {
type ContentTypeReader interface {
ContentType() string
}

// Implement this to allow an "attachment" disposition instead.
// The value you return will be the default file name offered to
// the client/user when downloading.
type FileNameSpecified interface {
FileName() string
type ContentFileNameReader interface {
ContentFileName() string
}
```
Expand All @@ -386,8 +391,7 @@ with the following:
```go
func ExportCSV(w http.ResponseWriter, req *http.Request) {
// This is an *Export which implements io.Reader,
// ContentTypeSpecifier, and FileNameSpecifier.
// This is an *Export which implements all 3 Content-based interfaces
export := crunchTheNumbers()

// Respond with the raw CSV reader data and the following:
Expand All @@ -404,15 +408,15 @@ type Export struct {
RawData *bytes.Buffer
}

func (e Export) Read(b []byte) (int, error) {
return e.RawData.Read(b)
func (e Export) Content() io.Reader {
return e.csvData
}

func (e Export) ContentType() string {
return "text/csv"
}

func (e Export) FileName() string {
func (e Export) ContentFileName() string {
return "super-important-report.csv"
}
```
Expand Down
54 changes: 34 additions & 20 deletions respond.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,33 +25,41 @@ type Redirector interface {
Redirect() string
}

// ContentTypeSpecified provides details about a file-based response to indicate what we should
// ContentReader indicates that the value you're responding with is actually raw byte content and
// not something that should be JSON-marshaled. The data read from the resulting io.Reader is what
// we will send back to the caller.
type ContentReader interface {
// Content supplies the raw data that should be sent to the caller when responding.
Content() io.Reader
}

// ContentTypeReader provides details about a file-based response to indicate what we should
// use as the "Content-Type" header. Any io.Reader that 'respond' comes across will be
// treated as raw bytes, not a JSON-marshaled payload. By default, the Content-Type of the response
// will be "application/octet-stream", but if your result implements this interface, you can tell the
// responder what type to use instead. For instance, if the result is a JPG, you can have your result
// return "image/jpeg" and 'respond' will use that in the header instead of octet-stream.
type ContentTypeSpecified interface {
type ContentTypeReader interface {
// ContentType returns the "Content-Type" header you want to apply to the HTTP response. This
// only applies when the result is an io.Reader, so you're returning raw results.
// only applies when the result is a ContentReader, so you're returning raw results.
ContentType() string
}

// FileNameSpecified provides the 'filename' details to use when filling out the HTTP Content-Disposition
// ContentFileNameReader provides the 'filename' details to use when filling out the HTTP Content-Disposition
// header. Any io.Reader that 'respond' comes across will be treated as raw bytes, not a JSON-marshaled
// payload. By default, 'respond' will specify "inline" for all raw responses (great for images and
// scripts you want to display inline in your UI).
//
// If you implement this interface, you can change the behavior to have the browser/client trigger a
// download of this asset instead. The file name you return here will dictate the default file name
// proposed by the save dialog.
type FileNameSpecified interface {
// FileName triggers an attachment-style value for the Content-Disposition header when writing
type ContentFileNameReader interface {
// ContentFileName triggers an attachment-style value for the Content-Disposition header when writing
// raw HTTP responses. When this returns an empty string, the response's disposition should
// be "inline". When it's any other value, it will be "attachment; filename=" with this value.
//
// This only applies when the result is an io.Reader, so you're returning raw results.
FileName() string
// This only applies when the result is a ContentReader, so you're returning raw results.
ContentFileName() string
}

// Responder provides helper functions for marshaling Go values/streams to send back to the user as well as
Expand All @@ -73,7 +81,7 @@ func (r Responder) Reply(status int, value interface{}, errs ...error) {
case Redirector:
// The value you're returning is telling us redirect to another URL instead.
r.Redirect(v.Redirect())
case io.Reader:
case ContentReader:
// The value looks like a file or some other raw, non-JSON content
writeRaw(r.writer, status, v)
default:
Expand Down Expand Up @@ -379,22 +387,28 @@ func writeJSON(res http.ResponseWriter, status int, value interface{}) {

// writeRaw accepts a reader containing the bytes of some file or raw set of data that the
// user wants to write to the caller.
func writeRaw(res http.ResponseWriter, status int, value io.Reader) {
if closer, ok := value.(io.Closer); ok {
defer func() { _ = closer.Close() }()
func writeRaw(res http.ResponseWriter, status int, value ContentReader) {
content := value.Content()
if content == nil {
res.WriteHeader(status)
return
}

if contentCloser, ok := content.(io.Closer); ok {
defer func() { _ = contentCloser.Close() }()
}

res.Header().Set("Content-Type", rawContentType(value))
res.Header().Set("Content-Disposition", rawContentDisposition(value))
res.WriteHeader(status)
_, _ = io.Copy(res, value)
_, _ = io.Copy(res, content)
}

// rawContentType assumes "application/octet-stream" unless the return value implements
// the ContentTypeSpecified interface. In that case, this will return the content type
// the ContentTypeReader interface. In that case, this will return the content type
// that the reader specifies. The result is a valid value for the HTTP "Content-Type" header.
func rawContentType(value io.Reader) string {
contentTyped, ok := value.(ContentTypeSpecified)
func rawContentType(value ContentReader) string {
contentTyped, ok := value.(ContentTypeReader)
if !ok {
return "application/octet-stream"
}
Expand All @@ -409,15 +423,15 @@ func rawContentType(value io.Reader) string {

// rawContentDisposition returns an appropriate value for the "Content-Disposition"
// HTTP header. In most cases, this will return "inline", but if the reader implements
// the FileNameSpecified interface, this will return "attachment; filename=" with the
// the ContentFileNameReader interface, this will return "attachment; filename=" with the
// reader's name specified.
func rawContentDisposition(value io.Reader) string {
named, ok := value.(FileNameSpecified)
func rawContentDisposition(value ContentReader) string {
named, ok := value.(ContentFileNameReader)
if !ok {
return "inline"
}

fileName := named.FileName()
fileName := named.ContentFileName()
if fileName == "" {
return "inline"
}
Expand Down
Loading

0 comments on commit 40c4f8c

Please sign in to comment.