diff --git a/README.md b/README.md index c9e744a..4124e9c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/action_gateway_test.go b/action_gateway_test.go index 22bc0b8..173e3e9 100644 --- a/action_gateway_test.go +++ b/action_gateway_test.go @@ -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"} { diff --git a/actionheader/actionheader.go b/actionheader/actionheader.go index 4eccef2..c9ed75f 100644 --- a/actionheader/actionheader.go +++ b/actionheader/actionheader.go @@ -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. @@ -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") @@ -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. @@ -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 } diff --git a/actionheader/actionheader_test.go b/actionheader/actionheader_test.go index 5afaec0..f1c73fc 100644 --- a/actionheader/actionheader_test.go +++ b/actionheader/actionheader_test.go @@ -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) { @@ -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) } diff --git a/docs/plan.md b/docs/plan.md index 86fdaa9..c1e84ed 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -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.