From 5f5fa248203aaf35a86e03102b4fd232cc6d8170 Mon Sep 17 00:00:00 2001 From: AJ Beck Date: Sun, 27 Sep 2026 15:28:51 +0100 Subject: [PATCH] refactor!: rename action sentinels to drop the repeated context Rename actionheader.ErrActionMissing, ErrActionAmbiguous and ErrActionInvalid to ErrMissing, ErrAmbiguous and ErrInvalid. The package name already supplies the action context, and the new names match the module's adjective-first sentinel convention. Error strings and meanings are unchanged. BREAKING CHANGE: actionheader.ErrAction* sentinels are now actionheader.ErrMissing, ErrAmbiguous and ErrInvalid. --- README.md | 2 +- action_gateway_test.go | 12 +++---- actionheader/actionheader.go | 24 +++++++------- actionheader/actionheader_test.go | 52 +++++++++++++++---------------- docs/plan.md | 2 +- 5 files changed, 46 insertions(+), 46 deletions(-) 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.