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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ Keep accepted direction, proposed API contracts and implemented behavior clearly

## Repository map

- Root Go files: raw/typed Lambda adapters, Gateway event decoding, HTTP request translation, buffered/streaming responses, gateway identity and action headers.
- Root Go files: raw/typed Lambda adapters, Gateway event decoding, HTTP request translation, buffered/streaming responses and gateway identity.
- `actionheader/`: strict single-value action selection from a request header;
standard library only.
- `identity/`: immutable callers, owned claims and context transport;
standard library only, with no AWS dependencies.
- `authn/`: credential selection, HTTP authentication middleware, Cognito token verification and JWKS caching.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,12 @@ no live API Gateway streaming test has run.

## Header-selected actions

`edge.NewActionHeader("Action")` creates an immutable selector whose `Parse(r.Header)` method returns an action string or an error.
`actionheader.NewSelector("Action")` creates an immutable selector whose `Parse(r.Header)` method returns an action string or an error.
The `actionheader` package uses only the standard library, so native HTTP servers do not link the Lambda adapter.
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 `edge.ErrActionMissing`, `edge.ErrActionAmbiguous`, or `edge.ErrActionInvalid` to distinguish request-input failures.
Use `errors.Is` with `actionheader.ErrActionMissing`, `actionheader.ErrActionAmbiguous`, or `actionheader.ErrActionInvalid` 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
44 changes: 5 additions & 39 deletions action_example_test.go → action_composition_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@ package edge_test

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"testing"

"github.com/asteroid-computing/go-lambda-edge"
"github.com/asteroid-computing/go-lambda-edge/actionheader"
)

// These are consumer-owned middleware and metadata, not edge API types.
// These are consumer-owned middleware and metadata, not edge or actionheader API types.
type actionKey struct{}

func processActionHeaders(selector edge.ActionHeader, next http.Handler) http.Handler {
func processActionHeaders(selector actionheader.Selector, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
action, err := selector.Parse(r.Header)
if err != nil {
Expand All @@ -26,42 +26,8 @@ func processActionHeaders(selector edge.ActionHeader, next http.Handler) http.Ha
})
}

func ExampleActionHeader() {
selector, err := edge.NewActionHeader("Action")
if err != nil {
fmt.Println(err)
return
}
dispatcher := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
action, ok := r.Context().Value(actionKey{}).(string)
if !ok {
http.Error(w, "missing action metadata", http.StatusInternalServerError)
return
}
// This example operation is public.
// Protected operations must authorize this same selection before executing or publishing streaming output.
switch action {
case "service.ping":
fmt.Fprintln(w, "pong")
default:
http.Error(w, "unknown action", http.StatusBadRequest)
}
})
// The composed http.Handler also goes directly to edge.New.
handler := processActionHeaders(selector, dispatcher)
r := httptest.NewRequest(http.MethodPost, "/", nil)
r.Header.Set("Action", "service.ping")
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
fmt.Println(w.Code)
fmt.Print(w.Body.String())
// Output:
// 200
// pong
}

func TestActionHeaderMiddlewareComposition(t *testing.T) {
selector, err := edge.NewActionHeader("Action")
func TestActionSelectorMiddlewareComposition(t *testing.T) {
selector, err := actionheader.NewSelector("Action")
if err != nil {
t.Fatal(err)
}
Expand Down
18 changes: 10 additions & 8 deletions action_gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import (
"net/http/httptest"
"strings"
"testing"

"github.com/asteroid-computing/go-lambda-edge/actionheader"
)

func TestActionHeaderAcrossRequestFormats(t *testing.T) {
selector, err := NewActionHeader("Action")
func TestActionSelectorAcrossRequestFormats(t *testing.T) {
selector, err := actionheader.NewSelector("Action")
if err != nil {
t.Fatal(err)
}
Expand All @@ -35,12 +37,12 @@ func TestActionHeaderAcrossRequestFormats(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: ErrActionMissing},
{name: "empty", values: []string{""}, wantErr: ErrActionInvalid},
{name: "invalid", values: []string{"orders/create"}, wantErr: ErrActionInvalid},
{name: "duplicates", values: []string{"read", "delete"}, wantErr: ErrActionAmbiguous},
{name: "identical_duplicates", values: []string{"read", "read"}, wantErr: ErrActionAmbiguous},
{name: "literal_comma", values: []string{"read,delete"}, wantErr: ErrActionAmbiguous},
{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},
} {
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
50 changes: 34 additions & 16 deletions action.go → actionheader/actionheader.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
package edge
// Package actionheader selects one application action from a configured HTTP request header.
// It uses only the standard library, so native HTTP servers and Lambda adapters can share the same selection rules.
// Consumers own action metadata, registry lookup, authorization and HTTP responses.
package actionheader

import (
"errors"
Expand All @@ -9,35 +12,35 @@ import (
var (
// ErrActionMissing means the configured header has no represented values.
// An absent header and a nil or empty value slice are all missing.
ErrActionMissing = errors.New("edge: missing action")
ErrActionMissing = errors.New("actionheader: missing action")

// ErrActionAmbiguous 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("edge: ambiguous action")
ErrActionAmbiguous = 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("edge: invalid action")
ErrActionInvalid = errors.New("actionheader: invalid action")
)

// ActionHeader selects an action from a configured request header.
// Construct it with [NewActionHeader];
// Selector selects an action from a configured request header.
// Construct it with [NewSelector];
// its zero value is unconfigured.
// An ActionHeader is immutable, safe to copy, and safe for concurrent use.
type ActionHeader struct {
// A Selector is immutable, safe to copy, and safe for concurrent use.
type Selector struct {
name string
}

// NewActionHeader validates name as an HTTP field name and returns a selector.
// NewSelector validates name as an HTTP field name and returns a selector.
// 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].
func NewActionHeader(name string) (ActionHeader, error) {
func NewSelector(name string) (Selector, error) {
if !validToken(name) {
return ActionHeader{}, errors.New("edge: invalid action header name")
return Selector{}, errors.New("actionheader: invalid header name")
}
return ActionHeader{name: http.CanonicalHeaderKey(name)}, nil
return Selector{name: http.CanonicalHeaderKey(name)}, nil
}

// Parse returns the single action in headers, trimming only outer SP/HTAB and preserving case.
Expand All @@ -54,16 +57,16 @@ func NewActionHeader(name string) (ActionHeader, error) {
// Parse does not modify headers or write an HTTP response.
// Callers must not mutate headers concurrently with Parse.
// Select once and use that same action for authorization and execution.
func (h ActionHeader) Parse(headers http.Header) (string, error) {
if h.name == "" {
return "", errors.New("edge: unconfigured action header")
func (s Selector) Parse(headers http.Header) (string, error) {
if s.name == "" {
return "", errors.New("actionheader: unconfigured selector")
}
var value string
found := false
for name, values := range headers {
// CanonicalHeaderKey folds only valid ASCII field names.
// In particular, Unicode case equivalents must not alias the configured HTTP name.
if http.CanonicalHeaderKey(name) != h.name || len(values) == 0 {
if http.CanonicalHeaderKey(name) != s.name || len(values) == 0 {
continue
}
if found || len(values) > 1 {
Expand All @@ -83,3 +86,18 @@ func (h ActionHeader) Parse(headers http.Header) (string, error) {
}
return value, nil
}

// validToken reports whether s is a nonempty HTTP token (RFC 9110 §5.6.2).
func validToken(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("!#$%&'*+-.^_`|~", rune(c)) {
continue
}
return false
}
return true
}
74 changes: 37 additions & 37 deletions action_test.go → actionheader/actionheader_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package edge_test
package actionheader_test

import (
"errors"
Expand All @@ -8,11 +8,11 @@ import (
"strings"
"testing"

"github.com/asteroid-computing/go-lambda-edge"
"github.com/asteroid-computing/go-lambda-edge/actionheader"
)

func TestActionHeaderParse(t *testing.T) {
selector, err := edge.NewActionHeader("aCtIoN")
func TestSelectorParse(t *testing.T) {
selector, err := actionheader.NewSelector("aCtIoN")
if err != nil {
t.Fatal(err)
}
Expand All @@ -22,34 +22,34 @@ func TestActionHeaderParse(t *testing.T) {
want string
wantErr error
}{
{name: "nil", wantErr: edge.ErrActionMissing},
{name: "unrelated", headers: http.Header{"Else": {"orders.create"}}, wantErr: edge.ErrActionMissing},
{name: "nil_slice", headers: http.Header{"Action": nil}, wantErr: edge.ErrActionMissing},
{name: "empty_slice", headers: http.Header{"Action": {}}, wantErr: edge.ErrActionMissing},
{name: "empty", headers: http.Header{"Action": {""}}, wantErr: edge.ErrActionInvalid},
{name: "whitespace", headers: http.Header{"Action": {" \t "}}, wantErr: edge.ErrActionInvalid},
{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: "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: edge.ErrActionAmbiguous},
{name: "identical", headers: http.Header{"Action": {"read", "read"}}, wantErr: edge.ErrActionAmbiguous},
{name: "case_aliases", headers: http.Header{"Action": {"read"}, "ACTION": {"delete"}}, wantErr: edge.ErrActionAmbiguous},
{name: "identical_aliases", headers: http.Header{"Action": {"read"}, "action": {"read"}}, wantErr: edge.ErrActionAmbiguous},
{name: "invalid_plus_alias", headers: http.Header{"Action": {""}, "action": {"read"}}, wantErr: edge.ErrActionAmbiguous},
{name: "multiple_empty", headers: http.Header{"Action": {"", ""}}, wantErr: edge.ErrActionAmbiguous},
{name: "joined", headers: http.Header{"Action": {"read,delete"}}, wantErr: edge.ErrActionAmbiguous},
{name: "identical_joined", headers: http.Header{"Action": {"read,read"}}, wantErr: edge.ErrActionAmbiguous},
{name: "comma_before_invalid", headers: http.Header{"Action": {",\r\n"}}, wantErr: edge.ErrActionAmbiguous},
{name: "space_in_token", headers: http.Header{"Action": {"orders create"}}, wantErr: edge.ErrActionInvalid},
{name: "slash", headers: http.Header{"Action": {"orders/create"}}, wantErr: edge.ErrActionInvalid},
{name: "colon", headers: http.Header{"Action": {"orders:create"}}, wantErr: edge.ErrActionInvalid},
{name: "quoted", headers: http.Header{"Action": {`"read"`}}, wantErr: edge.ErrActionInvalid},
{name: "unicode_space", headers: http.Header{"Action": {"\u00a0read"}}, wantErr: edge.ErrActionInvalid},
{name: "unicode", headers: http.Header{"Action": {"créer"}}, wantErr: edge.ErrActionInvalid},
{name: "invalid_utf8", headers: http.Header{"Action": {"\xff"}}, wantErr: edge.ErrActionInvalid},
{name: "newline", headers: http.Header{"Action": {"read\n"}}, wantErr: edge.ErrActionInvalid},
{name: "nul", headers: http.Header{"Action": {"read\x00"}}, wantErr: edge.ErrActionInvalid},
{name: "del", headers: http.Header{"Action": {"read\x7f"}}, wantErr: edge.ErrActionInvalid},
{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: "all_token_punctuation", headers: http.Header{"Action": {"!#$%&'*+-.^_`|~AZaz09"}}, want: "!#$%&'*+-.^_`|~AZaz09"},
} {
t.Run(tt.name, func(t *testing.T) {
Expand All @@ -69,25 +69,25 @@ func TestActionHeaderParse(t *testing.T) {
}
}

func TestActionHeaderConfiguration(t *testing.T) {
func TestSelectorConfiguration(t *testing.T) {
for _, name := range []string{"", " Action", "Action ", "Action:Name", "Action\r\n", "Àction", "Action\xff"} {
t.Run(fmt.Sprintf("%q", name), func(t *testing.T) {
h, err := edge.NewActionHeader(name)
if err == nil || h != (edge.ActionHeader{}) {
t.Fatalf("NewActionHeader(%q) = %v, %v; want zero selector and error", name, h, err)
h, err := actionheader.NewSelector(name)
if err == nil || h != (actionheader.Selector{}) {
t.Fatalf("NewSelector(%q) = %v, %v; want zero selector and error", name, h, err)
}
_, parseErr := h.Parse(http.Header{"Action": {"read"}})
if parseErr == nil {
t.Fatal("zero selector Parse succeeded")
}
for _, category := range []error{edge.ErrActionMissing, edge.ErrActionAmbiguous, edge.ErrActionInvalid} {
for _, category := range []error{actionheader.ErrActionMissing, actionheader.ErrActionAmbiguous, actionheader.ErrActionInvalid} {
if errors.Is(err, category) || errors.Is(parseErr, category) {
t.Errorf("configuration error matched input category %v", category)
}
}
})
}
selector, err := edge.NewActionHeader("Task")
selector, err := actionheader.NewSelector("Task")
if err != nil {
t.Fatal(err)
}
Expand All @@ -98,8 +98,8 @@ func TestActionHeaderConfiguration(t *testing.T) {
}
}

func TestActionHeaderErrorsDoNotDiscloseValues(t *testing.T) {
selector, err := edge.NewActionHeader("Action")
func TestSelectorErrorsDoNotDiscloseValues(t *testing.T) {
selector, err := actionheader.NewSelector("Action")
if err != nil {
t.Fatal(err)
}
Expand Down
Loading
Loading