Repository: danielgtaylor/huma
Pull request #51 (2022-05-26 23:42:09)

feat: add RawBody support and UnsupportedMediaType response
This enables you to have access to the unparsed input in scenarios where that might be useful while still having the benefits of documented/validated structs for inputs.


Patch:
diff --git a/README.md b/README.md
index f1b2a04..48a1b25 100644
--- a/README.md
+++ b/README.md
@@ -374,7 +374,7 @@ The following types are supported out of the box:
 
 For example, if the parameter is a query param and the type is `[]string` it might look like `?tags=tag1,tag2` in the URI.
 
-The special struct field `Body` will be treated as the input request body and can refer to another struct or you can embed a struct inline.
+The special struct field `Body` will be treated as the input request body and can refer to another struct or you can embed a struct inline. `RawBody` can also be used to provide access to the `[]byte` used to validate & load `Body`.
 
 Here is an example:
 
@@ -472,6 +472,18 @@ op.NoBodyReadTimeout()
 op.Run(...)
 ```
 
+If you just need access to the input body bytes and still want to use the built-in JSON Schema validation, then you can instead use the `RawBody` input struct field.
+
+```go
+type MyBody struct {
+	// This will generate JSON Schema, validate the input, and parse it.
+	Body MyStruct
+
+	// This will contain the raw bytes used to load the above.
+	RawBody []byte
+}
+```
+
 ### Resolvers
 
 Sometimes the built-in validation isn't sufficient for your use-case, or you want to do something more complex with the incoming request object. This is where resolvers come in.
diff --git a/resolver.go b/resolver.go
index d16f40c..9f81e65 100644
--- a/resolver.go
+++ b/resolver.go
@@ -251,6 +251,12 @@ func setFields(ctx *hcontext, req *http.Request, input reflect.Value, t reflect.
 					Value:    string(data),
 				})
 			}
+
+			// If requested, also provide access to the raw body bytes.
+			if _, ok := t.FieldByName("RawBody"); ok {
+				input.FieldByName("RawBody").Set(reflect.ValueOf(data))
+			}
+
 			continue
 		}
 
diff --git a/resource.go b/resource.go
index 03294a2..31778d7 100644
--- a/resource.go
+++ b/resource.go
@@ -20,15 +20,9 @@ type Resource struct {
 	operations   []*Operation
 
 	tags []string
-
-	hidden bool
 }
 
 func (r *Resource) toOpenAPI(components *oaComponents) *gabs.Container {
-	if r.hidden {
-		return nil
-	}
-
 	doc := gabs.New()
 
 	for _, sub := range r.subResources {
@@ -116,9 +110,3 @@ func (r *Resource) SubResource(path string) *Resource {
 func (r *Resource) Tags(names ...string) {
 	r.tags = append(r.tags, names...)
 }
-
-// Hidden removes this resource from the OpenAPI spec and documentation. It
-// is intended to be used for things like health check endpoints.
-func (r *Resource) Hidden() {
-	r.hidden = true
-}
diff --git a/responses/responses.go b/responses/responses.go
index b6c8f8f..52b5dc1 100644
--- a/responses/responses.go
+++ b/responses/responses.go
@@ -113,6 +113,11 @@ func RequestEntityTooLarge() huma.Response {
 	return errorResponse(http.StatusRequestEntityTooLarge)
 }
 
+// UnsupportedMediaType HTTP 415 response with a structured error body (e.g. JSON).
+func UnsupportedMediaType() huma.Response {
+	return errorResponse(http.StatusUnsupportedMediaType)
+}
+
 // UnprocessableEntity HTTP 422 response with a structured error body (e.g. JSON).
 func UnprocessableEntity() huma.Response {
 	return errorResponse(http.StatusUnprocessableEntity)


Test patch:
diff --git a/resolver_test.go b/resolver_test.go
index 8f668e8..ce6088d 100644
--- a/resolver_test.go
+++ b/resolver_test.go
@@ -327,3 +327,36 @@ func TestStringQueryEmpty(t *testing.T) {
 	assert.Equal(t, o.BooleanParam, true)
 	assert.Equal(t, o.OtherParam, "")
 }
+
+func TestRawBody(t *testing.T) {
+	app := newTestRouter()
+
+	app.Resource("/").Get("test", "Test",
+		NewResponse(http.StatusOK, "desc"),
+	).Run(func(ctx Context, input struct {
+		Body struct {
+			Name string   `json:"name"`
+			Tags []string `json:"tags"`
+		}
+		RawBody []byte
+	}) {
+		ctx.Write(input.RawBody)
+	})
+
+	// Note the weird formatting
+	body := `{  "name" : "Huma","tags": [ "one"  ,"two"]}`
+
+	w := httptest.NewRecorder()
+	r, _ := http.NewRequest(http.MethodGet, "/", strings.NewReader(body))
+	app.ServeHTTP(w, r)
+
+	assert.Equal(t, http.StatusOK, w.Result().StatusCode)
+	assert.Equal(t, body, w.Body.String())
+
+	// Invalid input should still fail validation!
+	w = httptest.NewRecorder()
+	r, _ = http.NewRequest(http.MethodGet, "/", strings.NewReader("{}"))
+	app.ServeHTTP(w, r)
+
+	assert.Equal(t, http.StatusUnprocessableEntity, w.Result().StatusCode)
+}
diff --git a/resource_test.go b/resource_test.go
deleted file mode 100644
index 8c52d85..0000000
--- a/resource_test.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package huma
-
-import (
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-)
-
-func TestResource(t *testing.T) {
-	r := Resource{
-		path: "/test",
-	}
-
-	assert.NotNil(t, r.toOpenAPI(&oaComponents{}))
-}
-
-func TestHiddenResource(t *testing.T) {
-	r := Resource{
-		path: "/test",
-	}
-	r.Hidden()
-
-	assert.Nil(t, r.toOpenAPI(&oaComponents{}))
-}
diff --git a/responses/responses_test.go b/responses/responses_test.go
index f037580..cfef99d 100644
--- a/responses/responses_test.go
+++ b/responses/responses_test.go
@@ -33,6 +33,7 @@ var funcs = struct {
 		RequestTimeout,
 		Conflict,
 		PreconditionFailed,
+		UnsupportedMediaType,
 		RequestEntityTooLarge,
 		UnprocessableEntity,
 		PreconditionRequired,
@@ -72,6 +73,7 @@ func TestResponses(t *testing.T) {
 		http.StatusConflict,
 		http.StatusPreconditionFailed,
 		http.StatusRequestEntityTooLarge,
+		http.StatusUnsupportedMediaType,
 		http.StatusUnprocessableEntity,
 		http.StatusPreconditionRequired,
 		http.StatusInternalServerError,