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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ The `actionheader` package uses only the standard library, so native HTTP server
Configure the header name explicitly.
Selection preserves case, trims outer spaces/tabs, and requires a nonempty HTTP token.
Repeated values and comma-bearing values are rejected, including Gateway payload 2.0's comma-combined duplicates.
Use `errors.Is` with `actionheader.ErrActionMissing`, `actionheader.ErrActionAmbiguous`, or `actionheader.ErrActionInvalid` to distinguish request-input failures.
Use `errors.Is` with `actionheader.ErrMissing`, `actionheader.ErrAmbiguous`, or `actionheader.ErrInvalid` to distinguish request-input failures.

Use ordinary `http.Handler` middleware to process this and other custom headers.
The consumer owns its action registry, metadata, authorization and HTTP error responses.
Expand Down
12 changes: 6 additions & 6 deletions action_gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,12 @@ func TestActionSelectorAcrossRequestFormats(t *testing.T) {
}{
{name: "single", values: []string{"Orders.Create"}, want: "Orders.Create"},
{name: "outer_whitespace", values: []string{" \tOrders.Create\t "}, want: "Orders.Create"},
{name: "missing", wantErr: actionheader.ErrActionMissing},
{name: "empty", values: []string{""}, wantErr: actionheader.ErrActionInvalid},
{name: "invalid", values: []string{"orders/create"}, wantErr: actionheader.ErrActionInvalid},
{name: "duplicates", values: []string{"read", "delete"}, wantErr: actionheader.ErrActionAmbiguous},
{name: "identical_duplicates", values: []string{"read", "read"}, wantErr: actionheader.ErrActionAmbiguous},
{name: "literal_comma", values: []string{"read,delete"}, wantErr: actionheader.ErrActionAmbiguous},
{name: "missing", wantErr: actionheader.ErrMissing},
{name: "empty", values: []string{""}, wantErr: actionheader.ErrInvalid},
{name: "invalid", values: []string{"orders/create"}, wantErr: actionheader.ErrInvalid},
{name: "duplicates", values: []string{"read", "delete"}, wantErr: actionheader.ErrAmbiguous},
{name: "identical_duplicates", values: []string{"read", "read"}, wantErr: actionheader.ErrAmbiguous},
{name: "literal_comma", values: []string{"read,delete"}, wantErr: actionheader.ErrAmbiguous},
} {
t.Run(tt.name, func(t *testing.T) {
for _, format := range []string{"typed_v1", "raw_rest", "raw_http_v1", "typed_v2", "raw_http_v2", "native_http"} {
Expand Down
24 changes: 12 additions & 12 deletions actionheader/actionheader.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@ import (
)

var (
// ErrActionMissing means the configured header has no represented values.
// ErrMissing means the configured header has no represented values.
// An absent header and a nil or empty value slice are all missing.
ErrActionMissing = errors.New("actionheader: missing action")
ErrMissing = errors.New("actionheader: missing action")

// ErrActionAmbiguous means the header has multiple represented values or a value containing a comma.
// ErrAmbiguous means the header has multiple represented values or a value containing a comma.
// Gateway payload 2.0 combines duplicates with commas;
// a literal comma cannot be distinguished from combined values.
ErrActionAmbiguous = errors.New("actionheader: ambiguous action")
ErrAmbiguous = errors.New("actionheader: ambiguous action")

// ErrActionInvalid means the single action value is empty after trimming outer spaces and tabs, or does not satisfy the HTTP token grammar.
ErrActionInvalid = errors.New("actionheader: invalid action")
// ErrInvalid means the single action value is empty after trimming outer spaces and tabs, or does not satisfy the HTTP token grammar.
ErrInvalid = errors.New("actionheader: invalid action")
)

// Selector selects an action from a configured request header.
Expand All @@ -35,7 +35,7 @@ type Selector struct {
// The name is required and matched case-insensitively;
// no default is inferred.
// On error, the returned selector is the zero value.
// Configuration errors are separate from [ErrActionMissing], [ErrActionAmbiguous] and [ErrActionInvalid].
// Configuration errors are separate from [ErrMissing], [ErrAmbiguous] and [ErrInvalid].
func NewSelector(name string) (Selector, error) {
if !validToken(name) {
return Selector{}, errors.New("actionheader: invalid header name")
Expand All @@ -51,7 +51,7 @@ func NewSelector(name string) (Selector, error) {
// Multiplicity and comma checks take precedence over token validation.
// Duplicates already lost by a gateway or earlier proxy cannot be detected.
//
// On failure Parse returns an empty string and [ErrActionMissing], [ErrActionAmbiguous] or [ErrActionInvalid], matchable with [errors.Is].
// On failure Parse returns an empty string and [ErrMissing], [ErrAmbiguous] or [ErrInvalid], matchable with [errors.Is].
// An unconfigured selector instead returns a configuration error.
// Errors do not include supplied header values.
// Parse does not modify headers or write an HTTP response.
Expand All @@ -70,19 +70,19 @@ func (s Selector) Parse(headers http.Header) (string, error) {
continue
}
if found || len(values) > 1 {
return "", ErrActionAmbiguous
return "", ErrAmbiguous
}
value, found = values[0], true
}
if !found {
return "", ErrActionMissing
return "", ErrMissing
}
if strings.Contains(value, ",") {
return "", ErrActionAmbiguous
return "", ErrAmbiguous
}
value = strings.Trim(value, " \t")
if !validToken(value) {
return "", ErrActionInvalid
return "", ErrInvalid
}
return value, nil
}
Expand Down
52 changes: 26 additions & 26 deletions actionheader/actionheader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,34 +22,34 @@ func TestSelectorParse(t *testing.T) {
want string
wantErr error
}{
{name: "nil", wantErr: actionheader.ErrActionMissing},
{name: "unrelated", headers: http.Header{"Else": {"orders.create"}}, wantErr: actionheader.ErrActionMissing},
{name: "nil_slice", headers: http.Header{"Action": nil}, wantErr: actionheader.ErrActionMissing},
{name: "empty_slice", headers: http.Header{"Action": {}}, wantErr: actionheader.ErrActionMissing},
{name: "empty", headers: http.Header{"Action": {""}}, wantErr: actionheader.ErrActionInvalid},
{name: "whitespace", headers: http.Header{"Action": {" \t "}}, wantErr: actionheader.ErrActionInvalid},
{name: "nil", wantErr: actionheader.ErrMissing},
{name: "unrelated", headers: http.Header{"Else": {"orders.create"}}, wantErr: actionheader.ErrMissing},
{name: "nil_slice", headers: http.Header{"Action": nil}, wantErr: actionheader.ErrMissing},
{name: "empty_slice", headers: http.Header{"Action": {}}, wantErr: actionheader.ErrMissing},
{name: "empty", headers: http.Header{"Action": {""}}, wantErr: actionheader.ErrInvalid},
{name: "whitespace", headers: http.Header{"Action": {" \t "}}, wantErr: actionheader.ErrInvalid},
{name: "trim_and_preserve_case", headers: http.Header{"Action": {" \tOrders.Create\t "}}, want: "Orders.Create"},
{name: "direct_assignment", headers: http.Header{"aCtIoN": {"orders.create"}}, want: "orders.create"},
{name: "empty_alias", headers: http.Header{"ACTION": {}, "action": {"orders.create"}}, want: "orders.create"},
{name: "conflict", headers: http.Header{"Action": {"read", "delete"}}, wantErr: actionheader.ErrActionAmbiguous},
{name: "identical", headers: http.Header{"Action": {"read", "read"}}, wantErr: actionheader.ErrActionAmbiguous},
{name: "case_aliases", headers: http.Header{"Action": {"read"}, "ACTION": {"delete"}}, wantErr: actionheader.ErrActionAmbiguous},
{name: "identical_aliases", headers: http.Header{"Action": {"read"}, "action": {"read"}}, wantErr: actionheader.ErrActionAmbiguous},
{name: "invalid_plus_alias", headers: http.Header{"Action": {""}, "action": {"read"}}, wantErr: actionheader.ErrActionAmbiguous},
{name: "multiple_empty", headers: http.Header{"Action": {"", ""}}, wantErr: actionheader.ErrActionAmbiguous},
{name: "joined", headers: http.Header{"Action": {"read,delete"}}, wantErr: actionheader.ErrActionAmbiguous},
{name: "identical_joined", headers: http.Header{"Action": {"read,read"}}, wantErr: actionheader.ErrActionAmbiguous},
{name: "comma_before_invalid", headers: http.Header{"Action": {",\r\n"}}, wantErr: actionheader.ErrActionAmbiguous},
{name: "space_in_token", headers: http.Header{"Action": {"orders create"}}, wantErr: actionheader.ErrActionInvalid},
{name: "slash", headers: http.Header{"Action": {"orders/create"}}, wantErr: actionheader.ErrActionInvalid},
{name: "colon", headers: http.Header{"Action": {"orders:create"}}, wantErr: actionheader.ErrActionInvalid},
{name: "quoted", headers: http.Header{"Action": {`"read"`}}, wantErr: actionheader.ErrActionInvalid},
{name: "unicode_space", headers: http.Header{"Action": {"\u00a0read"}}, wantErr: actionheader.ErrActionInvalid},
{name: "unicode", headers: http.Header{"Action": {"créer"}}, wantErr: actionheader.ErrActionInvalid},
{name: "invalid_utf8", headers: http.Header{"Action": {"\xff"}}, wantErr: actionheader.ErrActionInvalid},
{name: "newline", headers: http.Header{"Action": {"read\n"}}, wantErr: actionheader.ErrActionInvalid},
{name: "nul", headers: http.Header{"Action": {"read\x00"}}, wantErr: actionheader.ErrActionInvalid},
{name: "del", headers: http.Header{"Action": {"read\x7f"}}, wantErr: actionheader.ErrActionInvalid},
{name: "conflict", headers: http.Header{"Action": {"read", "delete"}}, wantErr: actionheader.ErrAmbiguous},
{name: "identical", headers: http.Header{"Action": {"read", "read"}}, wantErr: actionheader.ErrAmbiguous},
{name: "case_aliases", headers: http.Header{"Action": {"read"}, "ACTION": {"delete"}}, wantErr: actionheader.ErrAmbiguous},
{name: "identical_aliases", headers: http.Header{"Action": {"read"}, "action": {"read"}}, wantErr: actionheader.ErrAmbiguous},
{name: "invalid_plus_alias", headers: http.Header{"Action": {""}, "action": {"read"}}, wantErr: actionheader.ErrAmbiguous},
{name: "multiple_empty", headers: http.Header{"Action": {"", ""}}, wantErr: actionheader.ErrAmbiguous},
{name: "joined", headers: http.Header{"Action": {"read,delete"}}, wantErr: actionheader.ErrAmbiguous},
{name: "identical_joined", headers: http.Header{"Action": {"read,read"}}, wantErr: actionheader.ErrAmbiguous},
{name: "comma_before_invalid", headers: http.Header{"Action": {",\r\n"}}, wantErr: actionheader.ErrAmbiguous},
{name: "space_in_token", headers: http.Header{"Action": {"orders create"}}, wantErr: actionheader.ErrInvalid},
{name: "slash", headers: http.Header{"Action": {"orders/create"}}, wantErr: actionheader.ErrInvalid},
{name: "colon", headers: http.Header{"Action": {"orders:create"}}, wantErr: actionheader.ErrInvalid},
{name: "quoted", headers: http.Header{"Action": {`"read"`}}, wantErr: actionheader.ErrInvalid},
{name: "unicode_space", headers: http.Header{"Action": {"\u00a0read"}}, wantErr: actionheader.ErrInvalid},
{name: "unicode", headers: http.Header{"Action": {"créer"}}, wantErr: actionheader.ErrInvalid},
{name: "invalid_utf8", headers: http.Header{"Action": {"\xff"}}, wantErr: actionheader.ErrInvalid},
{name: "newline", headers: http.Header{"Action": {"read\n"}}, wantErr: actionheader.ErrInvalid},
{name: "nul", headers: http.Header{"Action": {"read\x00"}}, wantErr: actionheader.ErrInvalid},
{name: "del", headers: http.Header{"Action": {"read\x7f"}}, wantErr: actionheader.ErrInvalid},
{name: "all_token_punctuation", headers: http.Header{"Action": {"!#$%&'*+-.^_`|~AZaz09"}}, want: "!#$%&'*+-.^_`|~AZaz09"},
} {
t.Run(tt.name, func(t *testing.T) {
Expand Down Expand Up @@ -80,7 +80,7 @@ func TestSelectorConfiguration(t *testing.T) {
if parseErr == nil {
t.Fatal("zero selector Parse succeeded")
}
for _, category := range []error{actionheader.ErrActionMissing, actionheader.ErrActionAmbiguous, actionheader.ErrActionInvalid} {
for _, category := range []error{actionheader.ErrMissing, actionheader.ErrAmbiguous, actionheader.ErrInvalid} {
if errors.Is(err, category) || errors.Is(parseErr, category) {
t.Errorf("configuration error matched input category %v", category)
}
Expand Down
2 changes: 1 addition & 1 deletion docs/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ Behavior, error semantics and trust boundaries do not change;
these are placement, naming, typing and documentation changes made before the first release.

- [x] E1: move the action-header selector to the standard-library-only `actionheader` package as `Selector`/`NewSelector`.
- [ ] E2: rename the moved sentinels to `ErrMissing`, `ErrAmbiguous` and `ErrInvalid`.
- [x] E2: rename the moved sentinels to `ErrMissing`, `ErrAmbiguous` and `ErrInvalid`.
- [ ] E3: rename `identity.WithCaller` to `identity.NewContext`.
- [ ] E4: type the `InvocationError` operation and limit names with exported constants.
- [ ] E5: add `String` methods to the `identity` enumerations.
Expand Down
Loading