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
121 changes: 119 additions & 2 deletions internal/engine/adapter_dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"

Expand Down Expand Up @@ -379,12 +380,128 @@ func parseFormBody(raw string) map[string]any {
}
out := make(map[string]any, len(vals))
for k, vs := range vals {
if len(vs) > 0 {
out[k] = vs[0]
if len(vs) == 0 {
continue
}
// Bracket-notation keys (a[b]=v, a[]=v, a[0][b]=v) expand into nested
// dicts/lists — provider SDKs (stripe-node, Octokit, …) POST
// urlencoded bodies in exactly this Rails/PHP shape, and handlers
// expect req["body"]["line_items"] to be a real list. Every value of
// a repeated bracket key appends (a[]=1&a[]=2). Malformed bracketing
// falls back to the flat first-value behavior.
if segs, ok := splitFormKey(k); ok && len(segs) > 1 {
for _, v := range vs {
assignFormValue(out, segs, v)
}
continue
}
out[k] = vs[0]
}
if len(out) == 0 {
return nil
}
return out
}

// splitFormKey breaks "a[b][c]" into ["a","b","c"] and "a[]" into ["a",""].
// ok is false when the brackets are malformed (unbalanced, trailing junk,
// missing bare key) — the caller then treats the whole key as flat.
func splitFormKey(k string) ([]string, bool) {
i := strings.IndexByte(k, '[')
if i < 0 {
return []string{k}, true
}
if i == 0 {
return nil, false // no bare key before the first bracket
}
segs := []string{k[:i]}
rest := k[i:]
for rest != "" {
if rest[0] != '[' {
return nil, false
}
j := strings.IndexByte(rest, ']')
if j < 1 {
return nil, false
}
segs = append(segs, rest[1:j])
rest = rest[j+1:]
}
return segs, true
}

// assignFormValue walks segs, materializing nested dicts and lists, and sets
// the final segment to val. "" means "append to a list"; a numeric segment
// indexes one (gaps become nil). Conflicting shapes at a path are skipped
// (first writer wins) rather than crashing the handler.
func assignFormValue(cur map[string]any, segs []string, val string) {
head := segs[0]
if len(segs) == 1 {
// A terminal scalar never clobbers an existing structure at the same
// path (ParseQuery's map iteration order makes "first writer"
// unenforceable; skip-instead-of-clobber is order-independent).
switch cur[head].(type) {
case map[string]any, []any:
return
}
cur[head] = val
return
}
tail := segs[1:]
switch {
case tail[0] == "":
l, _ := cur[head].([]any)
l = append(l, buildFormValue(tail[1:], val))
cur[head] = l
case isNumericSegment(tail[0]):
idx, _ := strconv.Atoi(tail[0])
l, _ := cur[head].([]any)
for len(l) <= idx {
l = append(l, nil)
}
if em, ok := l[idx].(map[string]any); ok {
assignFormValue(em, tail[1:], val)
} else if l[idx] == nil {
sub := map[string]any{}
assignFormValue(sub, tail[1:], val)
l[idx] = sub
}
cur[head] = l
default:
sub, ok := cur[head].(map[string]any)
if !ok {
if cur[head] != nil {
return // shape conflict; skip
}
sub = map[string]any{}
cur[head] = sub
}
assignFormValue(sub, tail, val)
}
}

// buildFormValue materializes the value under a bare "a[]" append: the
// remaining segments nest inside the appended element.
func buildFormValue(segs []string, val string) any {
if len(segs) == 0 {
return val
}
if len(segs) == 1 {
return map[string]any{segs[0]: val}
}
sub := map[string]any{}
assignFormValue(sub, segs, val)
return sub
}

func isNumericSegment(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
111 changes: 111 additions & 0 deletions internal/engine/adapter_dispatch_parseform_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package engine

import (
"encoding/json"
"reflect"
"testing"
)

// TestParseFormBodyBracketNotation pins the Rails/PHP bracket expansion used
// by provider SDKs (stripe-node POSTs line_items[0][price_data][currency]=…
// urlencoded). Handlers must see real nested dicts/lists in req["body"].
func TestParseFormBodyBracketNotation(t *testing.T) {
cases := []struct {
name string
raw string
want string // JSON of the expected map
}{
{"flat", "a=1&b=two", `{"a":"1","b":"two"}`},
{"nested dict", "a[b]=1", `{"a":{"b":"1"}}`},
{"deep dict", "a[b][c]=1", `{"a":{"b":{"c":"1"}}}`},
{"bare append list", "a[]=1&a[]=2", `{"a":["1","2"]}`},
// NOTE: repeated a[] values arrive under ONE ParseQuery key so their
// order is preserved; a[][k] pairs are separate keys whose relative
// map-iteration order is unspecified — asserted order-insensitively
// in TestParseFormBodyBareAppendDictsUnordered below.
{"numeric indexed merge", "a[0][b]=1&a[0][c]=2&a[1][b]=3", `{"a":[{"b":"1","c":"2"},{"b":"3"}]}`},
{"flat + brackets coexist", "mode=payment&line_items[0][qty]=2", `{"mode":"payment","line_items":[{"qty":"2"}]}`},
{"stripe-shaped", "line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=1000&line_items[0][quantity]=1&success_url=https%3A%2F%2Fx.test%2Fs",
`{"line_items":[{"price_data":{"currency":"usd","unit_amount":"1000"},"quantity":"1"}],"success_url":"https://x.test/s"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := parseFormBody(tc.raw)
if got == nil {
t.Fatalf("parseFormBody(%q) = nil", tc.raw)
}
var want map[string]any
if err := json.Unmarshal([]byte(tc.want), &want); err != nil {
t.Fatalf("bad want JSON: %v", err)
}
if !reflect.DeepEqual(got, want) {
g, _ := json.Marshal(got)
w, _ := json.Marshal(want)
t.Fatalf("parseFormBody(%q)\n got %s\nwant %s", tc.raw, g, w)
}
})
}
}

// TestParseFormBodyBareAppendDictsUnordered proves a[][k] pairs append one
// dict each; their relative order follows ParseQuery's map iteration and is
// unspecified — only membership is asserted.
func TestParseFormBodyBareAppendDictsUnordered(t *testing.T) {
got := parseFormBody("a[][b]=1&a[][c]=2")
l, ok := got["a"].([]any)
if !ok || len(l) != 2 {
t.Fatalf("a = %v, want a 2-element list of dicts", got["a"])
}
seen := map[string]string{}
for _, e := range l {
m, ok := e.(map[string]any)
if !ok || len(m) != 1 {
t.Fatalf("element %v, want single-key dict", e)
}
for k, v := range m {
seen[k] = v.(string)
}
}
if seen["b"] != "1" || seen["c"] != "2" {
t.Fatalf("appended dicts = %v, want {b:1, c:2}", seen)
}
}

// TestParseFormBodyMalformedBracketsFallsBackFlat proves unbalanced bracket
// garbage degrades to the historical flat-key behavior instead of erroring.
func TestParseFormBodyMalformedBracketsFallsBackFlat(t *testing.T) {
got := parseFormBody("a[=1&b]=2")
if got == nil {
t.Fatal("parseFormBody returned nil for malformed brackets")
}
if got["a["] != "1" {
t.Errorf("a[ = %v, want 1 (flat fallback)", got["a["])
}
if got["b]"] != "2" {
t.Errorf("b] = %v, want 2 (flat fallback)", got["b]"])
}
}

// TestParseFormBodyShapeConflictSkipped proves a scalar/structure collision
// at the same path never crashes the handler and never mixes shapes — exactly
// one of the two survives, regardless of ParseQuery's map iteration order.
func TestParseFormBodyShapeConflictSkipped(t *testing.T) {
for _, raw := range []string{"a=1&a[b]=2", "a[b]=1&a=2"} {
got := parseFormBody(raw)
if got == nil {
t.Fatalf("parseFormBody(%q) = nil", raw)
}
switch a := got["a"].(type) {
case string:
if a != "1" && a != "2" {
t.Errorf("parseFormBody(%q): a = %q, want the scalar", raw, a)
}
case map[string]any:
if a["b"] != "1" && a["b"] != "2" {
t.Errorf("parseFormBody(%q): a[b] = %v, want the dict value", raw, a["b"])
}
default:
t.Errorf("parseFormBody(%q): a = %v (%T), want scalar or dict", raw, got["a"], got["a"])
}
}
}
Loading