From 82f724a1df85781fbaca9ecc8599b3be24a955f1 Mon Sep 17 00:00:00 2001 From: Andrew Orban Date: Sun, 3 Apr 2022 18:19:08 +0000 Subject: [PATCH 1/7] feat: added 206 Partial Content to huma responses. --- responses/responses.go | 5 +++++ responses/responses_test.go | 2 ++ 2 files changed, 7 insertions(+) diff --git a/responses/responses.go b/responses/responses.go index db69aa1c..3fd653d1 100644 --- a/responses/responses.go +++ b/responses/responses.go @@ -38,6 +38,11 @@ func NoContent() huma.Response { return response(http.StatusNoContent) } +// PartialContent HTTP 206 response +func PartialContent() huma.Response { + return response(http.StatusPartialContent) +} + // MovedPermanently HTTP 301 response. func MovedPermanently() huma.Response { return response(http.StatusMovedPermanently) diff --git a/responses/responses_test.go b/responses/responses_test.go index a61fe425..c390e72d 100644 --- a/responses/responses_test.go +++ b/responses/responses_test.go @@ -19,6 +19,7 @@ var funcs = struct { Created, Accepted, NoContent, + PartialContent, MovedPermanently, Found, NotModified, @@ -55,6 +56,7 @@ func TestResponses(t *testing.T) { http.StatusCreated, http.StatusAccepted, http.StatusNoContent, + http.StatusPartialContent, http.StatusMovedPermanently, http.StatusFound, http.StatusNotModified, From 01fbb5c2201dd512568c14c9d02e7d444f7a99c8 Mon Sep 17 00:00:00 2001 From: Andrew Orban Date: Tue, 5 Apr 2022 03:16:52 +0000 Subject: [PATCH 2/7] added WriteContext --- README.md | 55 ++++++++++++++++++++++++++ context.go | 38 ++++++++++++++++-- context_test.go | 77 +++++++++++++++++++++++++++++++++++++ responses/responses.go | 19 +++++++++ responses/responses_test.go | 54 ++++++++++++++++++++++++++ 5 files changed, 240 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b684e44d..2c9d4c4f 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,61 @@ app.Resource("/exhaustive").Get("exhaustive", "Exhastive errors example", }) ``` +## WriteContent + +Write contents allows you to write content in the provided ReadSeeker in the response. It will handle Range, If-Match, If-Unmodified-Since, If-None-Match, If-Modified-Since, and if-Range requests for caching and large object partial content responses. + +example of a partial content response sending a large video file: +```go +package main + +import ( + "net/http" + "os" + + "github.com/danielgtaylor/huma" + "github.com/danielgtaylor/huma/cli" + "github.com/danielgtaylor/huma/responses" +) + +func main() { + // Create a new router & CLI with default middleware. + app := cli.NewRouter("Minimal Example", "1.0.0") + + // Declare the root resource and a GET operation on it. + app.Resource("/vid").Get("get-vid", "Get video content", + // The only response is HTTP 200 with text/plain + responses.ServeContent()..., + ).Run(func(ctx huma.Context) { + // This is he handler function for the operation. Write the response. + ctx.Header().Set("Content-Type", "video/mp4") + f, err := os.Open("./vid.mp4") + if err != nil { + ctx.WriteError(http.StatusInternalServerError, "Error while opening video") + return + } + defer f.Close() + + fStat, err := os.Stat("./vid.mp4") + if err != nil { + ctx.WriteError(http.StatusInternalServerError, "Error while attempting to get Last Modified time") + return + } + + // Setting last modified time is optional, if not set WriteContent will not return + // Last-Modified or respect If-Modified-Since headers. + ctx.SetContentLastModified(fStat.ModTime()) + + ctx.WriteContent(f) + }) + + // Run the CLI. When passed no arguments, it starts the server. + app.Run() +} +``` + +Note that `WriteContent` does not automatically set the mime type. You should set the `Content-Type` response header directly. Also in order for `WriteContent` to respect the `Modified` headers you must call `SetContentLastModified`. This is optional and if not set `WriteContent` will simply not respect the `Modified` request headers. + ## Request Inputs Requests can have parameters and/or a body as input to the handler function. Like responses, inputs use standard Go structs but the tags are different. Here are the available tags: diff --git a/context.go b/context.go index c84102c5..d21b8ac4 100644 --- a/context.go +++ b/context.go @@ -4,9 +4,11 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "reflect" "strings" + "time" "github.com/danielgtaylor/huma/negotiation" "github.com/fxamacker/cbor/v2" @@ -65,6 +67,17 @@ type Context interface { // content negotiation (e.g. JSON or CBOR). This must match the registered // response status code & type. WriteModel(status int, model interface{}) + + // WriteContent wraps http.ServeContent in order to handle serving streams + // it will handle Range, If-Match, If-Unmodified-Since, If-None-Match, + // If-Modified-Since, and if-headers and set the MIME type (if not provided). + WriteContent(content io.ReadSeeker) + + // SetContentLastModified sets the time the content was last modified for + // WriteContent requests. If set, WriteContent will add it to the + // Last-Modified and will properly respond to If-Modified-Since request + // headers. + SetContentLastModified(modTime time.Time) } type hcontext struct { @@ -77,6 +90,7 @@ type hcontext struct { docsPrefix string urlPrefix string disableSchemaProperty bool + modTime time.Time } func (c *hcontext) WithValue(key, value interface{}) Context { @@ -141,7 +155,7 @@ func (c *hcontext) WriteHeader(status int) { func (c *hcontext) Write(data []byte) (int, error) { if c.closed { - panic(fmt.Errorf("Trying to write to response after WriteModel or WriteError for %s %s", c.r.Method, c.r.URL.Path)) + panic(fmt.Errorf("Trying to write to response after WriteModel, WriteError, or WriteContent for %s %s", c.r.Method, c.r.URL.Path)) } return c.ResponseWriter.Write(data) @@ -149,7 +163,7 @@ func (c *hcontext) Write(data []byte) (int, error) { func (c *hcontext) WriteError(status int, message string, errors ...error) { if c.closed { - panic(fmt.Errorf("Trying to write to response after WriteModel or WriteError for %s %s", c.r.Method, c.r.URL.Path)) + panic(fmt.Errorf("Trying to write to response after WriteModel, WriteError, or WriteContent for %s %s", c.r.Method, c.r.URL.Path)) } details := []*ErrorDetail{} @@ -186,7 +200,7 @@ func (c *hcontext) WriteError(status int, message string, errors ...error) { func (c *hcontext) WriteModel(status int, model interface{}) { if c.closed { - panic(fmt.Errorf("Trying to write to response after WriteModel or WriteError for %s %s", c.r.Method, c.r.URL.Path)) + panic(fmt.Errorf("Trying to write to response after WriteModel, WriteError, or WriteContent for %s %s", c.r.Method, c.r.URL.Path)) } // Get the negotiated content type the client wants and we are willing to @@ -373,3 +387,21 @@ func selectContentType(r *http.Request) string { return ct } + +func (c *hcontext) WriteContent(content io.ReadSeeker) { + if c.closed { + panic(fmt.Errorf("Trying to write to response after WriteModel, WriteError, or WriteContent for %s %s", c.r.Method, c.r.URL.Path)) + } + + // name is left blank, this is used by ServeContent to automatically + // determine Content-Type. Huma has opted to have handlers set Content-Type + // explicitly rather than\ introduce this into the method signature as name + // is not applicable to every ReadSeeker. Leaving this blank or setting the + // Content-Type on the request disables this functionality anyway. + http.ServeContent(c.ResponseWriter, c.r, "", c.modTime, content) + c.closed = true +} + +func (c *hcontext) SetContentLastModified(modTime time.Time) { + c.modTime = modTime +} diff --git a/context_test.go b/context_test.go index 37a835f6..442489a7 100644 --- a/context_test.go +++ b/context_test.go @@ -1,12 +1,14 @@ package huma import ( + "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" + "time" "github.com/fxamacker/cbor/v2" "github.com/goccy/go-yaml" @@ -268,3 +270,78 @@ func TestValue(t *testing.T) { r, _ := http.NewRequest(http.MethodGet, "/", nil) handler(w, r) } + +func TestWriteContent(t *testing.T) { + app := newTestRouter() + + b := []byte("Test Byte Data") + + app.Resource("/content").Get("test", "Test", + NewResponse(200, "desc").Model(Response{}), + ).Run(func(ctx Context) { + ctx.Header().Set("Content-Type", "application/octet-stream") + content := bytes.NewReader(b) + ctx.WriteContent(content) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/content", nil) + app.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Result().StatusCode) + assert.Equal(t, "application/octet-stream", w.Header().Get("Content-Type")) + assert.Equal(t, b, w.Body.Bytes()) +} + +func TestWriteContentRespectsRange(t *testing.T) { + app := newTestRouter() + + b := []byte("Test Byte Data") + + app.Resource("/content").Get("test", "Test", + NewResponse(206, "desc").Model(Response{}), + ).Run(func(ctx Context) { + ctx.Header().Set("Content-Type", "application/octet-stream") + content := bytes.NewReader(b) + ctx.WriteContent(content) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/content", nil) + req.Header.Set("Range", "bytes=0-5") + app.ServeHTTP(w, req) + + // confirms that Range is properly being forwarded to ServeContent. + // we'll assume more advanced range use cases like are properly tested + // in the http library. + assert.Equal(t, w.Result().StatusCode, 206) + assert.Equal(t, []byte("Test B"), w.Body.Bytes()) +} + +func TestWriteContentLastModified(t *testing.T) { + app := newTestRouter() + + b := []byte("Test Byte Data") + modTime := time.Now() + + app.Resource("/content").Get("test", "Test", + NewResponse(206, "desc").Model(Response{}), + ).Run(func(ctx Context) { + ctx.Header().Set("Content-Type", "application/octet-stream") + content := bytes.NewReader(b) + ctx.SetContentLastModified(modTime) + ctx.WriteContent(content) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/content", nil) + app.ServeHTTP(w, req) + + // confirms that modTime is properly being forwarded to ServeContent. + // We'll assume the more advanced modTime use cases are properly tested + // in http library. + strTime := modTime.UTC().Format(http.TimeFormat) + + assert.Equal(t, strTime, w.Header().Get("Last-Modified")) + +} diff --git a/responses/responses.go b/responses/responses.go index 3fd653d1..a25cf58a 100644 --- a/responses/responses.go +++ b/responses/responses.go @@ -148,3 +148,22 @@ func GatewayTimeout() huma.Response { func String(status int) huma.Response { return response(status).ContentType("text/plain") } + +// ServeContent returns a slice containing all valid responses for +// context.ServeContent +func ServeContent() []huma.Response { + return []huma.Response{ + OK().Headers("Last-Modified", "Content-Type"), + PartialContent().Headers( + "Last-Modified", + "Content-Type", + "Content-Range", + "Content-Length", + "multipart/byteranges", + "Accept-Ranges", + "Content-Encoding"), + NotModified().Headers("Last-Modified"), + PreconditionFailed().Headers("Last-Modified", "Content-Type"), + InternalServerError(), + } +} diff --git a/responses/responses_test.go b/responses/responses_test.go index c390e72d..d98b267b 100644 --- a/responses/responses_test.go +++ b/responses/responses_test.go @@ -96,3 +96,57 @@ func TestResponses(t *testing.T) { String(http.StatusOK) assert.Equal(t, 200, status) } + +func TestServeContentResponses(t *testing.T) { + var status int + response = func(s int) huma.Response { + status = s + return newResponse(s) + } + + table := map[string]int{} + for _, s := range []int{ + http.StatusOK, + http.StatusCreated, + http.StatusAccepted, + http.StatusNoContent, + http.StatusPartialContent, + http.StatusMovedPermanently, + http.StatusFound, + http.StatusNotModified, + http.StatusTemporaryRedirect, + http.StatusPermanentRedirect, + http.StatusBadRequest, + http.StatusUnauthorized, + http.StatusForbidden, + http.StatusNotFound, + http.StatusNotAcceptable, + http.StatusRequestTimeout, + http.StatusConflict, + http.StatusPreconditionFailed, + http.StatusRequestEntityTooLarge, + http.StatusPreconditionRequired, + http.StatusInternalServerError, + http.StatusNotImplemented, + http.StatusBadGateway, + http.StatusServiceUnavailable, + http.StatusGatewayTimeout, + } { + table[strings.Replace(http.StatusText(s), " ", "", -1)] = s + } + + for _, f := range funcs.Responses { + parts := strings.Split(runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name(), ".") + name := parts[len(parts)-1] + t.Run(name, func(t *testing.T) { + f() + + // The response we created has the right status code given the creation + // func name. + assert.Equal(t, table[name], status) + }) + } + + String(http.StatusOK) + assert.Equal(t, 200, status) +} From 62d07c82008a1c6c4e40af4b8847fd668a1e64ba Mon Sep 17 00:00:00 2001 From: Andrew Orban Date: Tue, 5 Apr 2022 03:22:26 +0000 Subject: [PATCH 3/7] removed outdated test code --- responses/responses_test.go | 54 ------------------------------------- 1 file changed, 54 deletions(-) diff --git a/responses/responses_test.go b/responses/responses_test.go index d98b267b..c390e72d 100644 --- a/responses/responses_test.go +++ b/responses/responses_test.go @@ -96,57 +96,3 @@ func TestResponses(t *testing.T) { String(http.StatusOK) assert.Equal(t, 200, status) } - -func TestServeContentResponses(t *testing.T) { - var status int - response = func(s int) huma.Response { - status = s - return newResponse(s) - } - - table := map[string]int{} - for _, s := range []int{ - http.StatusOK, - http.StatusCreated, - http.StatusAccepted, - http.StatusNoContent, - http.StatusPartialContent, - http.StatusMovedPermanently, - http.StatusFound, - http.StatusNotModified, - http.StatusTemporaryRedirect, - http.StatusPermanentRedirect, - http.StatusBadRequest, - http.StatusUnauthorized, - http.StatusForbidden, - http.StatusNotFound, - http.StatusNotAcceptable, - http.StatusRequestTimeout, - http.StatusConflict, - http.StatusPreconditionFailed, - http.StatusRequestEntityTooLarge, - http.StatusPreconditionRequired, - http.StatusInternalServerError, - http.StatusNotImplemented, - http.StatusBadGateway, - http.StatusServiceUnavailable, - http.StatusGatewayTimeout, - } { - table[strings.Replace(http.StatusText(s), " ", "", -1)] = s - } - - for _, f := range funcs.Responses { - parts := strings.Split(runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name(), ".") - name := parts[len(parts)-1] - t.Run(name, func(t *testing.T) { - f() - - // The response we created has the right status code given the creation - // func name. - assert.Equal(t, table[name], status) - }) - } - - String(http.StatusOK) - assert.Equal(t, 200, status) -} From d25abae557c9dfe2337171d3c9483be3c1c0e7b1 Mon Sep 17 00:00:00 2001 From: Andrew Orban Date: Tue, 5 Apr 2022 03:27:08 +0000 Subject: [PATCH 4/7] fixed up some coments --- README.md | 6 +++--- context.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2c9d4c4f..30b8e752 100644 --- a/README.md +++ b/README.md @@ -295,11 +295,11 @@ import ( func main() { // Create a new router & CLI with default middleware. - app := cli.NewRouter("Minimal Example", "1.0.0") + app := cli.NewRouter("Video Content", "1.0.0") - // Declare the root resource and a GET operation on it. + // Declare the /vid resource and a GET operation on it. app.Resource("/vid").Get("get-vid", "Get video content", - // The only response is HTTP 200 with text/plain + // ServeContent returns all the responses needed for WriteContent responses.ServeContent()..., ).Run(func(ctx huma.Context) { // This is he handler function for the operation. Write the response. diff --git a/context.go b/context.go index d21b8ac4..4ca0fc78 100644 --- a/context.go +++ b/context.go @@ -395,7 +395,7 @@ func (c *hcontext) WriteContent(content io.ReadSeeker) { // name is left blank, this is used by ServeContent to automatically // determine Content-Type. Huma has opted to have handlers set Content-Type - // explicitly rather than\ introduce this into the method signature as name + // explicitly rather than introduce this into the method signature as name // is not applicable to every ReadSeeker. Leaving this blank or setting the // Content-Type on the request disables this functionality anyway. http.ServeContent(c.ResponseWriter, c.r, "", c.modTime, content) From c54abf5d1a811be754bf9a83748e99cd4d520d37 Mon Sep 17 00:00:00 2001 From: Andrew Orban Date: Tue, 5 Apr 2022 03:35:22 +0000 Subject: [PATCH 5/7] comment grammar and spelling --- context.go | 7 +++---- context_test.go | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/context.go b/context.go index 4ca0fc78..1c944804 100644 --- a/context.go +++ b/context.go @@ -69,13 +69,12 @@ type Context interface { WriteModel(status int, model interface{}) // WriteContent wraps http.ServeContent in order to handle serving streams - // it will handle Range, If-Match, If-Unmodified-Since, If-None-Match, - // If-Modified-Since, and if-headers and set the MIME type (if not provided). + // it will handle Range and Modified (like If-Unmodified-Since) headers. WriteContent(content io.ReadSeeker) // SetContentLastModified sets the time the content was last modified for - // WriteContent requests. If set, WriteContent will add it to the - // Last-Modified and will properly respond to If-Modified-Since request + // WriteContent requests. If set, WriteContent will add it as the + // Last-Modified header and will properly respond to Modified request // headers. SetContentLastModified(modTime time.Time) } diff --git a/context_test.go b/context_test.go index 442489a7..cb58677f 100644 --- a/context_test.go +++ b/context_test.go @@ -312,7 +312,7 @@ func TestWriteContentRespectsRange(t *testing.T) { app.ServeHTTP(w, req) // confirms that Range is properly being forwarded to ServeContent. - // we'll assume more advanced range use cases like are properly tested + // we'll assume more advanced range use cases are properly tested // in the http library. assert.Equal(t, w.Result().StatusCode, 206) assert.Equal(t, []byte("Test B"), w.Body.Bytes()) From 9bb24bd815c006b1a1b814ee51e843eed82cbba9 Mon Sep 17 00:00:00 2001 From: Andrew Orban Date: Wed, 6 Apr 2022 00:06:56 +0000 Subject: [PATCH 6/7] removed SetContentLastModified and added lastModified time and name to WriteContent function --- context.go | 22 +++------------------- context_test.go | 30 ++++++++++++++++++++++++++---- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/context.go b/context.go index 1c944804..b524c479 100644 --- a/context.go +++ b/context.go @@ -70,13 +70,7 @@ type Context interface { // WriteContent wraps http.ServeContent in order to handle serving streams // it will handle Range and Modified (like If-Unmodified-Since) headers. - WriteContent(content io.ReadSeeker) - - // SetContentLastModified sets the time the content was last modified for - // WriteContent requests. If set, WriteContent will add it as the - // Last-Modified header and will properly respond to Modified request - // headers. - SetContentLastModified(modTime time.Time) + WriteContent(name string, content io.ReadSeeker, lastModified time.Time) } type hcontext struct { @@ -89,7 +83,6 @@ type hcontext struct { docsPrefix string urlPrefix string disableSchemaProperty bool - modTime time.Time } func (c *hcontext) WithValue(key, value interface{}) Context { @@ -387,20 +380,11 @@ func selectContentType(r *http.Request) string { return ct } -func (c *hcontext) WriteContent(content io.ReadSeeker) { +func (c *hcontext) WriteContent(name string, content io.ReadSeeker, lastModified time.Time) { if c.closed { panic(fmt.Errorf("Trying to write to response after WriteModel, WriteError, or WriteContent for %s %s", c.r.Method, c.r.URL.Path)) } - // name is left blank, this is used by ServeContent to automatically - // determine Content-Type. Huma has opted to have handlers set Content-Type - // explicitly rather than introduce this into the method signature as name - // is not applicable to every ReadSeeker. Leaving this blank or setting the - // Content-Type on the request disables this functionality anyway. - http.ServeContent(c.ResponseWriter, c.r, "", c.modTime, content) + http.ServeContent(c.ResponseWriter, c.r, name, lastModified, content) c.closed = true } - -func (c *hcontext) SetContentLastModified(modTime time.Time) { - c.modTime = modTime -} diff --git a/context_test.go b/context_test.go index cb58677f..c9d767c0 100644 --- a/context_test.go +++ b/context_test.go @@ -281,7 +281,7 @@ func TestWriteContent(t *testing.T) { ).Run(func(ctx Context) { ctx.Header().Set("Content-Type", "application/octet-stream") content := bytes.NewReader(b) - ctx.WriteContent(content) + ctx.WriteContent("", content, time.Time{}) }) w := httptest.NewRecorder() @@ -303,7 +303,7 @@ func TestWriteContentRespectsRange(t *testing.T) { ).Run(func(ctx Context) { ctx.Header().Set("Content-Type", "application/octet-stream") content := bytes.NewReader(b) - ctx.WriteContent(content) + ctx.WriteContent("", content, time.Time{}) }) w := httptest.NewRecorder() @@ -329,8 +329,7 @@ func TestWriteContentLastModified(t *testing.T) { ).Run(func(ctx Context) { ctx.Header().Set("Content-Type", "application/octet-stream") content := bytes.NewReader(b) - ctx.SetContentLastModified(modTime) - ctx.WriteContent(content) + ctx.WriteContent("", content, modTime) }) w := httptest.NewRecorder() @@ -345,3 +344,26 @@ func TestWriteContentLastModified(t *testing.T) { assert.Equal(t, strTime, w.Header().Get("Last-Modified")) } + +func TestWriteContentName(t *testing.T) { + app := newTestRouter() + + b := []byte("Test Byte Data") + + app.Resource("/content").Get("test", "Test", + NewResponse(206, "desc").Model(Response{}), + ).Run(func(ctx Context) { + + content := bytes.NewReader(b) + ctx.WriteContent("/path/with/content.mp4", content, time.Time{}) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/content", nil) + app.ServeHTTP(w, req) + + // confirms that name is properly being forwarded to ServeContent. + // We'll assume the more advanced modTime use cases are properly tested + // in http library. + assert.Equal(t, "video/mp4", w.Header().Get("Content-Type")) +} From 5416f8ceba23bfd2e36f6c7b8fec37c8d79514cc Mon Sep 17 00:00:00 2001 From: Andrew Orban Date: Wed, 6 Apr 2022 00:12:02 +0000 Subject: [PATCH 7/7] updated WriteContent readme example --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 30b8e752..e39b0bb9 100644 --- a/README.md +++ b/README.md @@ -317,11 +317,10 @@ func main() { return } - // Setting last modified time is optional, if not set WriteContent will not return - // Last-Modified or respect If-Modified-Since headers. - ctx.SetContentLastModified(fStat.ModTime()) - - ctx.WriteContent(f) + // Note that name (ex: vid.mp4) and lastModified (ex: fState.ModTime()) are both optional + // if name is "" Content-Type must be set by the handler + // if lastModified is time.Time{} Modified request headers will not be respected by WriteContent + ctx.WriteContent("vid.mp4", f, fStat.ModTime()) }) // Run the CLI. When passed no arguments, it starts the server.