Skip to content

Commit ee085ff

Browse files
mromaszewiczclaude
andauthored
Support binding deepObject parameters into map[string]interface{} (#139)
Closes: #138 Binding a deepObject query parameter into map[string]interface{} — the type generated for a schema with `additionalProperties: true` — failed with "unhandled type: interface {}" because assignPathValues had no case for interface destinations. An empty interface destination now receives the generic value shapes encoding/json uses, synthesized from the shape of the parsed fragment: leaves are typed by JSON-scalar inference ("true" -> bool, "12.5" -> float64, "null" -> nil, anything else stays a string — the JSON number grammar rejects leading zeros, so values like "00714" keep their exact form), consecutive integer subscripts "0".."n-1" become []interface{}, and other nodes become map[string]interface{}, recursively. Non-empty interface destinations still report an unhandled type. This also makes MarshalDeepObject output for a map[string]interface{} round-trip through binding, up to the inherent ambiguities of the untyped wire format, which are documented on the new helpers. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7afeea8 commit ee085ff

2 files changed

Lines changed: 152 additions & 0 deletions

File tree

deepobject.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,24 @@ func assignPathValues(dst interface{}, pathValues fieldOrValue) error {
360360
err := assignPathValues(dstPtr, pathValues)
361361
iv.Set(dstVal)
362362
return err
363+
case reflect.Interface:
364+
// An empty interface carries no type information to bind against —
365+
// this is the element type of the map[string]interface{} generated
366+
// for `additionalProperties: true` (issue #138). Synthesize the
367+
// generic value shapes encoding/json uses from the shape of the
368+
// parsed fragment instead.
369+
if it.NumMethod() != 0 {
370+
// A non-empty interface can't be satisfied by a synthesized
371+
// value; report it like any other unbindable destination.
372+
return errors.New("unhandled type: " + it.String())
373+
}
374+
val := interfaceFromFieldOrValue(pathValues)
375+
if val == nil {
376+
iv.Set(reflect.Zero(it))
377+
} else {
378+
iv.Set(reflect.ValueOf(val))
379+
}
380+
return nil
363381
case reflect.Bool:
364382
val, err := strconv.ParseBool(pathValues.value)
365383
if err != nil {
@@ -396,6 +414,68 @@ func assignPathValues(dst interface{}, pathValues fieldOrValue) error {
396414
}
397415
}
398416

417+
// interfaceFromFieldOrValue converts a parsed deepObject fragment into the
418+
// generic value shapes encoding/json produces: map[string]interface{} for
419+
// objects, []interface{} for arrays (consecutive integer subscripts, the
420+
// shape MarshalDeepObject emits), and JSON scalars for leaf values. This
421+
// round-trips a map[string]interface{} through MarshalDeepObject and back,
422+
// up to the inherent ambiguities of the untyped wire format: a string that
423+
// spells a JSON scalar ("true", "12.5") comes back as that scalar, and a
424+
// map whose keys are exactly "0".."n-1" comes back as an array.
425+
func interfaceFromFieldOrValue(fv fieldOrValue) interface{} {
426+
if len(fv.fields) == 0 {
427+
return jsonScalarFromString(fv.value)
428+
}
429+
if keys, ok := consecutiveIndices(fv.fields); ok {
430+
arr := make([]interface{}, len(keys))
431+
for i, key := range keys {
432+
arr[i] = interfaceFromFieldOrValue(fv.fields[key])
433+
}
434+
return arr
435+
}
436+
m := make(map[string]interface{}, len(fv.fields))
437+
for key, value := range fv.fields {
438+
m[key] = interfaceFromFieldOrValue(value)
439+
}
440+
return m
441+
}
442+
443+
// jsonScalarFromString maps raw query text to the value encoding/json would
444+
// produce for the same literal: true/false, null, or a number (float64).
445+
// Text that is not a JSON scalar stays a string — notably "00714", which the
446+
// JSON number grammar rejects (leading zero), so zip-code-like values keep
447+
// their exact form.
448+
func jsonScalarFromString(s string) interface{} {
449+
switch s {
450+
case "true":
451+
return true
452+
case "false":
453+
return false
454+
case "null":
455+
return nil
456+
}
457+
var n float64
458+
if err := json.Unmarshal([]byte(s), &n); err == nil {
459+
return n
460+
}
461+
return s
462+
}
463+
464+
// consecutiveIndices reports whether the field keys are exactly the integer
465+
// subscripts "0".."n-1" — the shape MarshalDeepObject emits for arrays —
466+
// and returns the keys in index order.
467+
func consecutiveIndices(fields map[string]fieldOrValue) ([]string, bool) {
468+
keys := make([]string, len(fields))
469+
for k := range fields {
470+
i, err := strconv.Atoi(k)
471+
if err != nil || i < 0 || i >= len(fields) || keys[i] != "" {
472+
return nil, false
473+
}
474+
keys[i] = k
475+
}
476+
return keys, true
477+
}
478+
399479
func assignSlice(dst reflect.Value, pathValues fieldOrValue) error {
400480
nValues := len(pathValues.fields)
401481

deepobject_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,3 +443,75 @@ func TestDeepObject_URLEncoding(t *testing.T) {
443443
"expected UTF-8 percent-encoded value; got %q", marshaled)
444444
})
445445
}
446+
447+
// TestDeepObject_InterfaceDestination covers binding into
448+
// map[string]interface{}, the type generated for a deepObject parameter
449+
// declared with `additionalProperties: true`. There is no type information
450+
// to bind against, so leaves are typed by JSON-scalar inference and
451+
// consecutive integer subscripts become []interface{}.
452+
// See https://github.com/oapi-codegen/runtime/issues/138 and
453+
// https://github.com/oapi-codegen/oapi-codegen/issues/2177
454+
func TestDeepObject_InterfaceDestination(t *testing.T) {
455+
params, err := url.ParseQuery(strings.Join([]string{
456+
"properties[vaccinated]=true",
457+
"properties[color]=black",
458+
"properties[coat_length]=large",
459+
"properties[weight]=12.5",
460+
"properties[zip]=00714",
461+
"properties[chipped]=null",
462+
"properties[owner][name]=alice",
463+
"properties[owner][phones][0]=555-0100",
464+
"properties[owner][phones][1]=555-0101",
465+
"properties[scores][0]=1",
466+
"properties[scores][2]=3",
467+
}, "&"))
468+
require.NoError(t, err)
469+
470+
want := map[string]interface{}{
471+
"vaccinated": true,
472+
"color": "black",
473+
"coat_length": "large",
474+
"weight": 12.5,
475+
// Not a valid JSON number (leading zero), so it stays a string.
476+
"zip": "00714",
477+
"chipped": nil,
478+
"owner": map[string]interface{}{
479+
"name": "alice",
480+
"phones": []interface{}{"555-0100", "555-0101"},
481+
},
482+
// Non-consecutive subscripts are not an array; they stay a map.
483+
"scores": map[string]interface{}{"0": 1.0, "2": 3.0},
484+
}
485+
486+
var dst map[string]interface{}
487+
require.NoError(t, UnmarshalDeepObject(&dst, "properties", params))
488+
assert.Equal(t, want, dst)
489+
490+
// The generated params struct field for an optional parameter is a
491+
// pointer; make sure that path allocates and binds too.
492+
var pdst *map[string]interface{}
493+
require.NoError(t, UnmarshalDeepObject(&pdst, "properties", params))
494+
require.NotNil(t, pdst)
495+
assert.Equal(t, want, *pdst)
496+
}
497+
498+
// TestDeepObject_InterfaceRoundTrip verifies that a map[string]interface{}
499+
// serialized by MarshalDeepObject binds back to an equal value.
500+
func TestDeepObject_InterfaceRoundTrip(t *testing.T) {
501+
src := map[string]interface{}{
502+
"vaccinated": true,
503+
"color": "black",
504+
"weight": 12.5,
505+
"chipped": nil,
506+
"owner": map[string]interface{}{
507+
"name": "alice",
508+
"phones": []interface{}{"555-0100", "555-0101"},
509+
},
510+
}
511+
512+
marshaled, err := MarshalDeepObject(src, "properties")
513+
require.NoError(t, err)
514+
515+
var dst map[string]interface{}
516+
assertDeepObjectWireSafe(t, marshaled, "properties", &dst, src)
517+
}

0 commit comments

Comments
 (0)