Skip to content
Merged
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
13 changes: 10 additions & 3 deletions docs/PRINCIPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,15 @@ a **single portable artifact** you can move between machines and sessions. Large
are fine — Smocker handles them. ([#161])

### 3.4 Matching is faithful to real HTTP, not lenient
- Header and query-parameter matching is **case-sensitive by design**. Real servers in the
wild are case-sensitive (e.g. AWS API Gateway), and being lenient would hide real bugs in
the system under test. ([#179])
- **Header names** are matched **case-insensitively** (RFC 7230 §3.2: header field names are
case-insensitive), so a mock declaring `content-type` matches a request's `Content-Type`.
**Header values** and **query-parameter names** stay **case-sensitive by design**: real
servers are case-sensitive there (e.g. AWS API Gateway), and being lenient would hide real
bugs in the system under test. ([#179], [#281])
- Smocker never rewrites the casing it is given: response headers are returned with the exact
case the mock declares (or the upstream sends, for proxies). Note that Go's `net/http`
canonicalizes header names when it parses an incoming request or an upstream proxy response,
so those reach Smocker already canonicalized regardless.
- The default matcher is `ShouldEqual`; a bare string is shorthand for it. The matcher set
(`ShouldMatch`, `ShouldContainSubstring`, `ShouldEqualJSON`, `ShouldNotBeEmpty`, …) is the
vocabulary — prefer extending existing semantics over inventing parallel ones.
Expand Down Expand Up @@ -166,6 +172,7 @@ These are recognized gaps, not oversights — worth improving, but not regressio
[#161]: https://github.com/smocker-dev/smocker/issues/161
[#162]: https://github.com/smocker-dev/smocker/issues/162
[#179]: https://github.com/smocker-dev/smocker/issues/179
[#281]: https://github.com/smocker-dev/smocker/issues/281
[#219]: https://github.com/smocker-dev/smocker/issues/219
[#231]: https://github.com/smocker-dev/smocker/issues/231
[#266]: https://github.com/smocker-dev/smocker/issues/266
Expand Down
16 changes: 16 additions & 0 deletions server/types/matchers.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,22 @@ func (mmm MultiMapMatcher) Match(values map[string][]string) bool {
return true
}

// MatchHeaders is like Match but resolves header names case-insensitively (RFC 7230 §3.2: header
// field names are case-insensitive), so a mock declaring "content-type" matches a request's
// "Content-Type". Header *values* are still matched exactly. The declared casing is never altered —
// http.Header.Values only canonicalizes the key it looks up with, not the stored keys.
func (mmm MultiMapMatcher) MatchHeaders(headers http.Header) bool {
if len(mmm) > len(headers) {
return false
}
for key, matcherValue := range mmm {
if value := headers.Values(key); len(value) == 0 || !matcherValue.Match(value) {
return false
}
}
return true
}

type BodyMatcher struct {
bodyString *StringMatcher
bodyJson map[string]StringMatcher
Expand Down
40 changes: 40 additions & 0 deletions server/types/matchers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,52 @@ package types

import (
"encoding/json"
"net/http"
"reflect"
"testing"

"gopkg.in/yaml.v3"
)

func mapMatcher(t *testing.T, y string) MultiMapMatcher {
t.Helper()
var mmm MultiMapMatcher
if err := yaml.Unmarshal([]byte(y), &mmm); err != nil {
t.Fatalf("invalid matcher yaml: %v", err)
}
return mmm
}

// TestMatchHeadersCaseInsensitive covers #281: header *names* match case-insensitively (RFC 7230),
// while header *values* — and query-parameter names via Match — stay case-sensitive.
func TestMatchHeadersCaseInsensitive(t *testing.T) {
// A mock declaring a lowercase name matches the canonical request header, and vice versa.
req := http.Header{}
req.Set("Content-Type", "application/json") // net/http canonicalizes incoming names

if !mapMatcher(t, "content-type: application/json").MatchHeaders(req) {
t.Error(`"content-type" should match request header "Content-Type"`)
}
if !mapMatcher(t, "Content-Type: application/json").MatchHeaders(req) {
t.Error(`"Content-Type" should match request header "Content-Type"`)
}

// Header values remain case-sensitive.
if mapMatcher(t, "Content-Type: application/json").MatchHeaders(http.Header{"Content-Type": {"Application/JSON"}}) {
t.Error("header values must stay case-sensitive")
}

// A missing header still fails to match.
if mapMatcher(t, "X-Absent: x").MatchHeaders(req) {
t.Error("a header absent from the request must not match")
}

// Query-parameter matching (Match) stays case-sensitive on names.
if mapMatcher(t, "Token: abc").Match(map[string][]string{"token": {"abc"}}) {
t.Error("query-parameter names must stay case-sensitive")
}
}

func TestStringMatcherJSON(t *testing.T) {
test := `"test"`
serialized := `{"matcher":"ShouldEqual","value":"test"}`
Expand Down
2 changes: 1 addition & 1 deletion server/types/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ func (mr MockRequest) Match(req Request) bool {
slog.Debug("Path did not match")
return false
}
matchHeaders := mr.Headers == nil || mr.Headers.Match(req.Headers)
matchHeaders := mr.Headers == nil || mr.Headers.MatchHeaders(req.Headers)
if !matchHeaders {
slog.Debug("Headers did not match")
return false
Expand Down
Loading