Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,9 @@ router.Get("/login", func(ctx *navaros.Context) {

The JSON middleware automatically marshals and unmarshals JSON request and response bodies. It sets up the context's unmarshal and marshal functions to handle JSON encoding.

For requests with `Content-Type: application/json`, it reads the body and provides an unmarshal function that decodes JSON into Go values. For responses, it marshals any non-reader body value to JSON before writing it.
For requests whose `Content-Type` names `application/json`, it reads the body and provides an unmarshal function that decodes JSON into Go values. For responses, it marshals any non-reader body value to JSON before writing it.

The media type is matched per RFC 9110 §8.3.1: the type and subtype are case-insensitive, and parameters are carried separately from the pair that identifies the media type — `application/json` registers no parameters of its own, so `application/json; charset=utf-8` is JSON. Structured syntax suffixes match too, so `application/problem+json` and `application/vnd.api+json` are also handled. Aliases such as `text/json`, and wildcards, are not.

Pass `nil` for default configuration, or use `&json.Options{}` to customize:
- `DisableRequestBodyUnmarshaller` - Skip setting up request unmarshalling
Expand Down Expand Up @@ -489,7 +491,7 @@ router.Post("/api/users", func(ctx *navaros.Context) {

### MessagePack Middleware

The MessagePack middleware provides binary serialization support using MessagePack format. It automatically handles request unmarshalling and response marshalling for `Content-Type: application/msgpack`.
The MessagePack middleware provides binary serialization support using MessagePack format. It automatically handles request unmarshalling and response marshalling for requests whose `Content-Type` names `application/msgpack`, matched the same way as in the JSON middleware above — parameters and case ignored, `+msgpack` suffixes accepted. Aliases such as `application/x-msgpack` are not.

MessagePack is more compact and faster than JSON, making it ideal for high-performance APIs or bandwidth-constrained environments.

Expand Down Expand Up @@ -522,7 +524,7 @@ Like the JSON middleware, MessagePack middleware supports special response types

### Protocol Buffers Middleware

The Protocol Buffers middleware provides efficient binary serialization using Protocol Buffers. It handles `Content-Type: application/protobuf`.
The Protocol Buffers middleware provides efficient binary serialization using Protocol Buffers. It handles requests whose `Content-Type` names `application/protobuf`, matched the same way as in the JSON middleware above — parameters and case ignored, `+protobuf` suffixes accepted. Aliases such as `application/x-protobuf` are not.

Protocol Buffers require you to define `.proto` schemas and generate Go code with `protoc`. The middleware works with any `proto.Message` implementation.

Expand Down
107 changes: 107 additions & 0 deletions internal/mediatype/mediatype.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Package mediatype compares Content-Type header values against media types.
package mediatype

import (
"strings"
)

// Is reports whether a Content-Type header value names the given media type.
//
// Comparison follows RFC 9110 section 8.3.1, which makes the type and subtype
// tokens case-insensitive and carries parameters separately from the
// type/subtype pair that identifies the media type. RFC 9110 does caution that
// a parameter may be significant depending on the media type's own
// registration; for these three it is not — RFC 8259 section 11 defines no
// parameters at all for application/json — so "application/json; charset=utf-8"
// names "application/json".
//
// The structured syntax suffix convention (RFC 6838 section 4.2.8) also
// matches, so "application/problem+json" and "application/vnd.api+json" both
// name "application/json". This is deliberately wider than the IANA structured
// syntax suffix registry: of the three suffixes at issue here, only "+json" is
// registered, and "+msgpack" and "+protobuf" are honoured anyway, because the
// suffix is how a client says its type is structured as that format.
//
// mediaType must be a bare lowercase type/subtype pair, e.g. "application/json".
func Is(header string, mediaType string) bool {
base, ok := baseType(header)
if !ok {
return false
}
if base == mediaType {
return true
}

slash := strings.IndexByte(mediaType, '/')
if slash == -1 {
return false
}
prefix, subtype := mediaType[:slash+1], mediaType[slash+1:]

// A suffix match needs a non-empty subtype root before the "+", so
// "application/+json" does not name "application/json".
if len(base) <= len(prefix)+len(subtype)+1 {
return false
}
return strings.HasPrefix(base, prefix) && strings.HasSuffix(base, "+"+subtype)
}

// baseType extracts the lowercased type/subtype from a Content-Type header,
// discarding parameters.
//
// Only the bytes before the first ";" are ever needed: RFC 9110 section 8.3
// defines the media type as token "/" token, so no quoted string can precede
// the first parameter separator, and a malformed parameter cannot hide the
// media type naming it. Requiring both halves to be tokens is what keeps a
// value that is not a single media type — "application/pdf, junk+json", or a
// comma-separated list — from being read as one and matched on its tail.
func baseType(header string) (string, bool) {
base, _, _ := strings.Cut(header, ";")
base = strings.Trim(base, " \t")

// RFC 6838 section 4.2 limits a type or subtype name to 127 characters, so
// nothing longer than the pair plus its slash can name a media type. Checked
// before folding case so that a megabyte-long header — Content-Type is
// client-supplied, and net/http allows 1 MB of headers by default — cannot
// force a megabyte-long allocation on every request.
if len(base) > 255 {
return "", false
}
base = strings.ToLower(base)

typ, subtype, ok := strings.Cut(base, "/")
if !ok || !isToken(typ) || !isToken(subtype) {
return "", false
}

// A wildcard names a range of media types rather than one, and belongs in
// Accept rather than Content-Type. Rejecting it here is what keeps it out of
// the suffix rule, where "application/*+json" would otherwise name
// "application/json".
if strings.IndexByte(base, '*') != -1 {
return "", false
}
return base, true
}

// isToken reports whether s is a non-empty token, per RFC 9110 section 5.6.2.
func isToken(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if !isTChar(s[i]) {
return false
}
}
return true
}

// isTChar reports whether c is a tchar, per RFC 9110 section 5.6.2.
func isTChar(c byte) bool {
switch c {
case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~':
return true
}
return c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
}
114 changes: 114 additions & 0 deletions internal/mediatype/mediatype_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package mediatype_test

import (
"strings"
"testing"

"github.com/RobertWHurst/navaros/internal/mediatype"
)

func TestIs(t *testing.T) {
tests := []struct {
header string
mediaType string
want bool
}{
// Exact matches still hold.
{"application/json", "application/json", true},
{"application/msgpack", "application/msgpack", true},
{"application/protobuf", "application/protobuf", true},

// Parameters are carried separately from the type/subtype pair that
// identifies the media type, and application/json registers none at all
// (RFC 8259 section 11). The first is what OkHttp sends for any string
// request body, which is how this reached production.
{"application/json; charset=utf-8", "application/json", true},
{"application/json;charset=utf-8", "application/json", true},
{"application/json ; charset=utf-8", "application/json", true},
{"application/msgpack; charset=binary", "application/msgpack", true},
{"application/protobuf; proto=Widget", "application/protobuf", true},

// Type and subtype are case-insensitive.
{"Application/JSON", "application/json", true},
{"APPLICATION/JSON; CHARSET=UTF-8", "application/json", true},

// The structured syntax suffix convention (RFC 6838 section 4.2.8). Of
// the three suffixes at issue here only "+json" is IANA-registered;
// "+msgpack" and "+protobuf" are honoured as a deliberate
// generalization, not as registered suffixes.
{"application/problem+json", "application/json", true},
{"application/vnd.api+json; charset=utf-8", "application/json", true},
{"application/json-patch+json", "application/json", true},
{"application/vnd.custom+msgpack", "application/msgpack", true},

// Malformed parameters must not hide a usable media type.
{"application/json; charset", "application/json", true},
{"application/json;", "application/json", true},
{"application/json; charset=utf-8; charset=utf-16", "application/json", true},
{"application/json; charset=\"utf-8\"", "application/json", true},

// Non-matches.
{"", "application/json", false},
{" ", "application/json", false},
{"text/plain", "application/json", false},
{"text/json", "application/json", false},
{"application/xml", "application/json", false},
{"application/octet-stream", "application/json", false},
{"multipart/form-data; boundary=x", "application/json", false},
{"application/jsonish", "application/json", false},
{"application/json+zip", "application/json", false},
{"application/msgpack", "application/json", false},
{"application/json", "application/msgpack", false},
{"; charset=utf-8", "application/json", false},

// A value that is not a single media type must not be matched on its
// tail. Type and subtype are each a token, so a comma, a space or a
// second slash means this is not one media type, whatever it ends with.
{"application/octet-stream, x+json", "application/json", false},
{"application/pdf, junk+json", "application/json", false},
{"application/octet-stream x+json", "application/json", false},
{"application/x/y+json", "application/json", false},
{"application/json, text/plain", "application/json", false},
{"application/protobuf, evil+msgpack", "application/msgpack", false},

// A suffix needs a subtype root in front of it.
{"application/+json", "application/json", false},

// Wildcards name no concrete media type. The suffixed forms are the ones
// that matter: "*" is a valid token character, so without an explicit
// wildcard rejection they reach the suffix rule and match.
{"*/*", "application/json", false},
{"application/*", "application/json", false},
{"application/*+json", "application/json", false},
{"application/*+msgpack", "application/msgpack", false},
{"application/*+protobuf", "application/protobuf", false},

// A suffix only names the target when the type matches too. Without the
// type-prefix check, "text/x+json" would name "application/json".
{"text/x+json", "application/json", false},
{"text/vnd.custom+json", "application/json", false},
{"image/svg+xml", "application/xml", false},

// A type or subtype name is at most 127 characters (RFC 6838 section
// 4.2), so an over-long value names nothing and is rejected before its
// case is folded. The subtype here is otherwise a well-formed "+json"
// suffix under the right type, so it would match without the limit.
{"application/" + strings.Repeat("a", 250) + "+json", "application/json", false},
{strings.Repeat("a", 130) + "/" + strings.Repeat("b", 130) + "+json", "application/json", false},

// Aliases seen in the wild are deliberately not accepted: which of them
// to honour is a decision about what the library accepts, not something
// RFC 9110 settles.
{"application/x-msgpack", "application/msgpack", false},
{"application/vnd.msgpack", "application/msgpack", false},
{"application/x-protobuf", "application/protobuf", false},
{"application/vnd.google.protobuf", "application/protobuf", false},
}

for _, test := range tests {
got := mediatype.Is(test.header, test.mediaType)
if got != test.want {
t.Errorf("Is(%q, %q) = %v, want %v", test.header, test.mediaType, got, test.want)
}
}
}
4 changes: 2 additions & 2 deletions middleware/json/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"

"github.com/RobertWHurst/navaros"
"github.com/RobertWHurst/navaros/internal/mediatype"
)

type Options struct {
Expand All @@ -32,8 +33,7 @@ func Middleware(options *Options) func(ctx *navaros.Context) {
}

func unmarshalRequestBody(ctx *navaros.Context) {
contentType := ctx.RequestHeaders().Get("Content-Type")
if contentType != "application/json" {
if !mediatype.Is(ctx.RequestHeaders().Get("Content-Type"), "application/json") {
return
}

Expand Down
82 changes: 82 additions & 0 deletions middleware/json/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,85 @@ func TestMiddleware_NonJSONContentType(t *testing.T) {
t.Errorf("expected status 200, got %d", w.Code)
}
}

func TestMiddleware_RequestUnmarshallingWithContentTypeParameters(t *testing.T) {
// Clients that name a charset — Go's own net/http, OkHttp, axios — send
// "application/json; charset=utf-8". RFC 9110 section 8.3 makes parameters
// no part of a media type's identity, so these requests must unmarshal too.
headers := []string{
"application/json; charset=utf-8",
"application/json;charset=UTF-8",
"Application/JSON",
"application/vnd.api+json; charset=utf-8",
}

for _, header := range headers {
t.Run(header, func(t *testing.T) {
router := navaros.NewRouter()
router.Use(json.Middleware(nil))

router.Post("/test", func(ctx *navaros.Context) {
var req testRequest
if err := ctx.UnmarshalRequestBody(&req); err != nil {
t.Errorf("failed to unmarshal: %v", err)
return
}
if req.Name != "test" || req.Value != 42 {
t.Errorf("expected {test 42}, got %+v", req)
}

ctx.Status = http.StatusOK
ctx.Body = testResponse{Message: "ok", Success: true}
})

reqBody := `{"name":"test","value":42}`
req := httptest.NewRequest("POST", "/test", strings.NewReader(reqBody))
req.Header.Set("Content-Type", header)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
})
}
}

func TestMiddleware_NonMatchingContentTypeInstallsNoUnmarshaller(t *testing.T) {
// The mirror of the test above, and the one that actually pins the contract:
// a value that does not name this media type must leave the unmarshaller
// uninstalled. Asserting only the positive direction passes even if the
// comparison matches everything.
headers := []string{
"text/plain",
"application/xml",
"application/octet-stream, x+json",
"application/+json",
"*/*",
}

for _, header := range headers {
t.Run(header, func(t *testing.T) {
router := navaros.NewRouter()
router.Use(json.Middleware(nil))

router.Post("/test", func(ctx *navaros.Context) {
var req testRequest
if err := ctx.UnmarshalRequestBody(&req); err == nil {
t.Errorf("Content-Type %q installed an unmarshaller; it names no JSON media type", header)
}

ctx.Status = http.StatusOK
})

req := httptest.NewRequest("POST", "/test", strings.NewReader(`{"name":"test","value":42}`))
req.Header.Set("Content-Type", header)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
})
}
}
4 changes: 2 additions & 2 deletions middleware/msgpack/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"io"

"github.com/RobertWHurst/navaros"
"github.com/RobertWHurst/navaros/internal/mediatype"
"github.com/vmihailenco/msgpack/v5"
)

Expand Down Expand Up @@ -32,8 +33,7 @@ func Middleware(options *Options) func(ctx *navaros.Context) {
}

func unmarshalRequestBody(ctx *navaros.Context) {
contentType := ctx.RequestHeaders().Get("Content-Type")
if contentType != "application/msgpack" {
if !mediatype.Is(ctx.RequestHeaders().Get("Content-Type"), "application/msgpack") {
return
}

Expand Down
Loading