From 6d6d74dd4b1296d94a416f713dab6e2e6cc358b5 Mon Sep 17 00:00:00 2001 From: Ertugrul Akbuga Date: Thu, 30 Jul 2026 19:49:19 +0300 Subject: [PATCH] fix: match Content-Type by media type, not exact string, in body parsers The json, msgpack and protobuf middlewares each installed their request body unmarshaller only when Content-Type was exactly equal to the media type: contentType := ctx.RequestHeaders().Get("Content-Type") if contentType != "application/json" { return } RFC 9110 section 8.3.1 makes the type and subtype 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, because RFC 8259 section 11 registers no parameters at all for application/json. So "application/json; charset=utf-8" names application/json, and under exact comparison it did not match: no unmarshaller was installed, and the request reached the handler where UnmarshalRequestBody failed with navaros' own "no request body unmarshaller set" message about a request that was well formed. OkHttp appends "; charset=utf-8" to any string request body, so a client using it could not send a body at all. This hit us in production, where an Android fleet could not fetch advertising for a week. internal/mediatype.Is now performs the comparison and all three call sites use it. It cuts the header at the first ";" -- RFC 9110 defines a media type as token "/" token, so no quoted string can precede it and parsing parameters was never necessary to find the media type -- then trims, folds case, and applies four guards, each of which changes an answer when removed: - Both halves must be tokens. Otherwise a value that is not a single media type gets matched on its tail: "application/pdf, junk+json" reads as JSON, and "application/protobuf, evil+msgpack" installs the msgpack unmarshaller for a header naming protobuf. - No wildcards. "*" is a valid tchar, so without an explicit rejection "application/*+json" reaches the suffix rule and matches. A wildcard names a range rather than one type and belongs in Accept. - The type must match, not only the suffix, or "text/x+json" names application/json. - At most 255 characters, checked before folding case. RFC 6838 section 4.2 caps a type or subtype name at 127 characters, so nothing longer names a media type. Content-Type is client-supplied and net/http allows 1 MB of headers by default; without the limit a 1 MB value cost ~2.3 ms and a 1 MB allocation per request, against ~1 ns for the compare this replaces. The structured syntax suffix convention (RFC 6838 section 4.2.8) also matches, so "application/problem+json" and "application/vnd.api+json" 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. That is a choice rather than a requirement, and the pull request offers to narrow it. Behaviour for genuinely non-matching content types is unchanged; the pre-existing text/plain test still passes. Aliases some clients send -- application/x-msgpack, application/x-protobuf, text/json, application/vnd.google.protobuf -- are explicitly tested as not matching, since which of those to accept is a decision about what the library accepts rather than a spec correction. Tests cover the helper and, per middleware, both a parameterized-header positive case and a non-matching-Content-Type negative case. The negative direction was worth adding explicitly: hardcoding the comparison to true passed all three middleware suites beforehand, because the existing TestMiddleware_NonJSONContentType reads the raw body without calling UnmarshalRequestBody and so cannot observe a wrongly-installed unmarshaller. With these tests that mutation fails all four packages, and so does removing any one of the four guards above. The three README middleware sections are updated; they documented exact-string handling. --- README.md | 8 +- internal/mediatype/mediatype.go | 107 +++++++++++++++++++++++ internal/mediatype/mediatype_test.go | 114 +++++++++++++++++++++++++ middleware/json/middleware.go | 4 +- middleware/json/middleware_test.go | 82 ++++++++++++++++++ middleware/msgpack/middleware.go | 4 +- middleware/msgpack/middleware_test.go | 81 ++++++++++++++++++ middleware/protobuf/middleware.go | 4 +- middleware/protobuf/middleware_test.go | 81 ++++++++++++++++++ 9 files changed, 476 insertions(+), 9 deletions(-) create mode 100644 internal/mediatype/mediatype.go create mode 100644 internal/mediatype/mediatype_test.go diff --git a/README.md b/README.md index 6ae0865..f7dda59 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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. diff --git a/internal/mediatype/mediatype.go b/internal/mediatype/mediatype.go new file mode 100644 index 0000000..2aff57c --- /dev/null +++ b/internal/mediatype/mediatype.go @@ -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' +} diff --git a/internal/mediatype/mediatype_test.go b/internal/mediatype/mediatype_test.go new file mode 100644 index 0000000..d627e69 --- /dev/null +++ b/internal/mediatype/mediatype_test.go @@ -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) + } + } +} diff --git a/middleware/json/middleware.go b/middleware/json/middleware.go index 5325d4f..9f6e8a3 100644 --- a/middleware/json/middleware.go +++ b/middleware/json/middleware.go @@ -6,6 +6,7 @@ import ( "io" "github.com/RobertWHurst/navaros" + "github.com/RobertWHurst/navaros/internal/mediatype" ) type Options struct { @@ -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 } diff --git a/middleware/json/middleware_test.go b/middleware/json/middleware_test.go index 79a12cf..df026de 100644 --- a/middleware/json/middleware_test.go +++ b/middleware/json/middleware_test.go @@ -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) + } + }) + } +} diff --git a/middleware/msgpack/middleware.go b/middleware/msgpack/middleware.go index ca43ce2..c326780 100644 --- a/middleware/msgpack/middleware.go +++ b/middleware/msgpack/middleware.go @@ -5,6 +5,7 @@ import ( "io" "github.com/RobertWHurst/navaros" + "github.com/RobertWHurst/navaros/internal/mediatype" "github.com/vmihailenco/msgpack/v5" ) @@ -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 } diff --git a/middleware/msgpack/middleware_test.go b/middleware/msgpack/middleware_test.go index d1a185c..bcb2bbb 100644 --- a/middleware/msgpack/middleware_test.go +++ b/middleware/msgpack/middleware_test.go @@ -175,3 +175,84 @@ func TestMiddleware_AlternateContentType(t *testing.T) { t.Errorf("expected status 200, got %d", w.Code) } } + +func TestMiddleware_RequestUnmarshallingWithContentTypeParameters(t *testing.T) { + // Parameters are not part of a media type's identity (RFC 9110 section 8.3), + // and type and subtype are case-insensitive. + headers := []string{ + "application/msgpack; charset=binary", + "Application/MsgPack", + "application/vnd.custom+msgpack", + } + + for _, header := range headers { + t.Run(header, func(t *testing.T) { + router := navaros.NewRouter() + router.Use(msgpack.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, _ := msgpacklib.Marshal(testRequest{Name: "test", Value: 42}) + req := httptest.NewRequest("POST", "/test", bytes.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/json", + "application/protobuf, evil+msgpack", + "application/+msgpack", + "*/*", + } + + for _, header := range headers { + t.Run(header, func(t *testing.T) { + router := navaros.NewRouter() + router.Use(msgpack.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 msgpack media type", header) + } + + ctx.Status = http.StatusOK + }) + + reqBody, _ := msgpacklib.Marshal(testRequest{Name: "test", Value: 42}) + req := httptest.NewRequest("POST", "/test", bytes.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) + } + }) + } +} diff --git a/middleware/protobuf/middleware.go b/middleware/protobuf/middleware.go index bf93a5b..b424920 100644 --- a/middleware/protobuf/middleware.go +++ b/middleware/protobuf/middleware.go @@ -6,6 +6,7 @@ import ( "io" "github.com/RobertWHurst/navaros" + "github.com/RobertWHurst/navaros/internal/mediatype" "google.golang.org/protobuf/proto" ) @@ -33,8 +34,7 @@ func Middleware(options *Options) func(ctx *navaros.Context) { } func unmarshalRequestBody(ctx *navaros.Context) { - contentType := ctx.RequestHeaders().Get("Content-Type") - if contentType != "application/protobuf" { + if !mediatype.Is(ctx.RequestHeaders().Get("Content-Type"), "application/protobuf") { return } diff --git a/middleware/protobuf/middleware_test.go b/middleware/protobuf/middleware_test.go index 2d65bc9..53f9c31 100644 --- a/middleware/protobuf/middleware_test.go +++ b/middleware/protobuf/middleware_test.go @@ -140,3 +140,84 @@ func TestMiddleware_InvalidProtoType(t *testing.T) { t.Errorf("expected status 400, got %d", w.Code) } } + +func TestMiddleware_RequestUnmarshallingWithContentTypeParameters(t *testing.T) { + // Parameters are not part of a media type's identity (RFC 9110 section 8.3), + // and type and subtype are case-insensitive. + headers := []string{ + "application/protobuf; proto=TestRequest", + "Application/ProtoBuf", + "application/vnd.custom+protobuf", + } + + for _, header := range headers { + t.Run(header, func(t *testing.T) { + router := navaros.NewRouter() + router.Use(protobuf.Middleware(nil)) + + router.Post("/test", func(ctx *navaros.Context) { + var req protobuf.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 = &protobuf.TestResponse{Message: "ok", Success: true} + }) + + reqBody, _ := proto.Marshal(&protobuf.TestRequest{Name: "test", Value: 42}) + req := httptest.NewRequest("POST", "/test", bytes.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/json", + "application/msgpack, evil+protobuf", + "application/+protobuf", + "*/*", + } + + for _, header := range headers { + t.Run(header, func(t *testing.T) { + router := navaros.NewRouter() + router.Use(protobuf.Middleware(nil)) + + router.Post("/test", func(ctx *navaros.Context) { + var req protobuf.TestRequest + if err := ctx.UnmarshalRequestBody(&req); err == nil { + t.Errorf("Content-Type %q installed an unmarshaller; it names no protobuf media type", header) + } + + ctx.Status = http.StatusOK + }) + + reqBody, _ := proto.Marshal(&protobuf.TestRequest{Name: "test", Value: 42}) + req := httptest.NewRequest("POST", "/test", bytes.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) + } + }) + } +}