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: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ go tool gsx fmt -w .
- Apply globally via `WithMiddlewares` or per-page via a `Middlewares()` method (also applies to descendants)

6. **Dependency Injection**: Type-based DI via `WithArgs(...)`
- `args.go`: `argRegistry` matches by type; a registered pointer fills a value param (not the reverse), with an assignability fallback
- `args.go`: `argRegistry` matches by type; a registered pointer fills a value param (not the reverse); an interface param takes the one registration implementing it, and two are an error
- Each registered type appears once; use named types to disambiguate
- Generic types are supported; interface-typed params are not filled by a registered implementation (see `generics_injection_test.go`)
- `Mount` checks every `Props`/extended `ServeHTTP` param for ambiguity up front (`checkInjection`); `Parse` skips it. A missing dependency stays a request-time error, since pages may be mounted in configurations that don't register their services

### Key Design Patterns

Expand Down
49 changes: 47 additions & 2 deletions args.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package structpages
import (
"fmt"
"reflect"
"slices"
"strings"
)

type argRegistry map[reflect.Type]reflect.Value
Expand All @@ -20,8 +22,51 @@ func (args argRegistry) addArg(v any) error {
return nil
}

// note that p.args are always pointers
func (args argRegistry) getArg(pt reflect.Type) (reflect.Value, bool) {
// getArg finds the registered value for a parameter of type pt. A
// registration of exactly that type wins (a registered pointer also fills
// its value type). Failing that, an interface parameter takes the one
// registered value whose type implements it; more than one is an error,
// since picking one would be a guess. found is false when nothing matches.
func (args argRegistry) getArg(pt reflect.Type) (v reflect.Value, found bool, err error) {
if pt.Kind() == reflect.Interface {
if iv, ok := args[pt]; ok {
return iv, true, nil
}
return args.implementationOf(pt)
}
v, found = args.getConcreteArg(pt)
return v, found, nil
}

// implementationOf returns the single registered value implementing
// iface.
func (args argRegistry) implementationOf(iface reflect.Type) (reflect.Value, bool, error) {
var impls []reflect.Type
for t := range args {
if t.Implements(iface) {
impls = append(impls, t)
}
}
switch len(impls) {
case 0:
return reflect.Value{}, false, nil
case 1:
return args[impls[0]], true, nil
}
names := make([]string, len(impls))
for i, t := range impls {
names[i] = t.String()
}
slices.Sort(names)
return reflect.Value{}, false, fmt.Errorf(
"%s is implemented by more than one registered value (%s); "+
"declare the parameter as the concrete type, or narrow the interface",
iface, strings.Join(names, ", "))
}

// getConcreteArg finds the registered value for a non-interface
// parameter type. Note that p.args are always pointers.
func (args argRegistry) getConcreteArg(pt reflect.Type) (reflect.Value, bool) {
st := pt
needsElem, needsPtr := false, false
if pt.Kind() != reflect.Pointer {
Expand Down
69 changes: 40 additions & 29 deletions args_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,12 @@ func TestArgRegistry_getArg(t *testing.T) {
wantFound: true,
},
{
name: "implementation not assignable to interface",
name: "registered implementation fills an interface parameter",
registry: argRegistry{
reflect.TypeOf(implVal): reflect.ValueOf(implVal),
},
lookupType: reflect.TypeOf((*testInterface)(nil)).Elem(),
wantFound: false,
wantFound: true,
},
{
name: "empty registry returns not found",
Expand Down Expand Up @@ -175,7 +175,7 @@ func TestArgRegistry_getArg(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotValue, gotFound := tt.registry.getArg(tt.lookupType)
gotValue, gotFound := lookupArg(t, tt.registry, tt.lookupType)
if gotFound != tt.wantFound {
t.Errorf("argRegistry.getArg() found = %v, want %v", gotFound, tt.wantFound)
return
Expand Down Expand Up @@ -257,7 +257,7 @@ func TestArgRegistry_getArg_coverageGaps(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, gotFound := tt.registry.getArg(tt.lookupType)
_, gotFound := lookupArg(t, tt.registry, tt.lookupType)
if gotFound != tt.wantFound {
t.Errorf("argRegistry.getArg() found = %v, want %v", gotFound, tt.wantFound)
}
Expand Down Expand Up @@ -316,12 +316,12 @@ func TestArgRegistry_getArg_edgeCases(t *testing.T) {
wantFound: false,
},
{
name: "interface assignability - value types not directly assignable",
name: "registered wider interface fills a narrower interface parameter",
registry: argRegistry{
reflect.TypeOf((*derivedInterface)(nil)).Elem(): reflect.ValueOf(fullImpl{}),
},
lookupType: reflect.TypeOf((*baseInterface)(nil)).Elem(),
wantFound: false,
wantFound: true,
},
{
name: "type stored with exact match in loop",
Expand All @@ -347,7 +347,7 @@ func TestArgRegistry_getArg_edgeCases(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, gotFound := tt.registry.getArg(tt.lookupType)
_, gotFound := lookupArg(t, tt.registry, tt.lookupType)
if gotFound != tt.wantFound {
t.Errorf("argRegistry.getArg() found = %v, want %v", gotFound, tt.wantFound)
}
Expand Down Expand Up @@ -387,13 +387,13 @@ func TestArgRegistry_integration(t *testing.T) {
}

// Verify retrieval
if v, ok := registry.getArg(reflect.TypeOf(s1)); !ok || v.Interface().(*testStruct).Value != "first" {
if v, ok := lookupArg(t, registry, reflect.TypeOf(s1)); !ok || v.Interface().(*testStruct).Value != "first" {
t.Error("Failed to retrieve pointer type")
}

// When we ask for testStruct, getArg will find *testStruct first and dereference it
// This is the designed behavior - it prefers finding compatible types through conversion
if v, ok := registry.getArg(reflect.TypeOf(s2)); ok {
if v, ok := lookupArg(t, registry, reflect.TypeOf(s2)); ok {
// Check if value is correct
if v.CanInterface() && v.Type() == reflect.TypeOf(s2) {
val := v.Interface().(testStruct)
Expand All @@ -408,9 +408,9 @@ func TestArgRegistry_integration(t *testing.T) {
t.Error("Failed to retrieve non-pointer type")
}

// Test that interface retrieval fails because we didn't store interface type
if _, ok := registry.getArg(reflect.TypeOf((*testInterface)(nil)).Elem()); ok {
t.Error("Should not retrieve interface type when implementation was stored")
// An interface parameter takes the registered implementation
if _, ok := lookupArg(t, registry, reflect.TypeOf((*testInterface)(nil)).Elem()); !ok {
t.Error("Should retrieve the registered implementation for its interface type")
}
}

Expand All @@ -427,7 +427,7 @@ func TestArgRegistry_assignability(t *testing.T) {

// Try to retrieve as pointer to interface - should trigger pt.AssignableTo(t) path
ptrType := reflect.TypeOf((*customInterface)(nil))
if v, ok := registry.getArg(ptrType); ok {
if v, ok := lookupArg(t, registry, ptrType); ok {
t.Logf("Successfully retrieved pointer to interface: %v", v.Type())
}

Expand All @@ -440,7 +440,7 @@ func TestArgRegistry_assignability(t *testing.T) {

// Try to retrieve as interface - should trigger st.AssignableTo(t) path
ifaceType := reflect.TypeOf((*customInterface)(nil)).Elem()
if v, ok := registry2.getArg(ifaceType); ok {
if v, ok := lookupArg(t, registry2, ifaceType); ok {
t.Logf("Successfully retrieved as interface: %v", v.Type())
}
}
Expand All @@ -454,7 +454,7 @@ func TestArgRegistry_getArg_unaddressable(t *testing.T) {

// Try to get a pointer to int - should fail because value is not addressable
ptrType := reflect.TypeOf((*int)(nil))
_, ok := registry.getArg(ptrType)
_, ok := lookupArg(t, registry, ptrType)
if ok {
t.Error("Expected not to find pointer type for unaddressable value")
}
Expand All @@ -469,7 +469,7 @@ func TestArgRegistry_getArg_extendedAssignability(t *testing.T) {
registry[reflect.TypeOf(ch)] = reflect.ValueOf(ch)

// Exact match should work
v, ok := registry.getArg(reflect.TypeOf(ch))
v, ok := lookupArg(t, registry, reflect.TypeOf(ch))
if !ok {
t.Error("Expected to find channel type")
}
Expand All @@ -489,7 +489,7 @@ func TestArgRegistry_getArg_needsPtr(t *testing.T) {

// Now look up a pointer type - this should trigger needsPtr = true and v.Addr()
ptrType := reflect.TypeOf(&testStruct{})
v, ok := registry.getArg(ptrType)
v, ok := lookupArg(t, registry, ptrType)
if !ok {
t.Fatal("Expected to find value")
}
Expand Down Expand Up @@ -531,7 +531,7 @@ func TestArgRegistry_getArg_assignabilityLoop(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, found := registry.getArg(tt.lookupType)
_, found := lookupArg(t, registry, tt.lookupType)
if found != tt.wantFound {
t.Errorf("getArg() found = %v, want %v", found, tt.wantFound)
}
Expand Down Expand Up @@ -566,7 +566,7 @@ func TestArgRegistry_getArg_assignabilityPaths(t *testing.T) {
// Since we're looking for a pointer (but needsPtr=false because it's already a pointer type)
// This should trigger line 64: return v, true
ifacePtrType := reflect.TypeOf((*simpleInterface)(nil))
v, ok := registry.getArg(ifacePtrType)
v, ok := lookupArg(t, registry, ifacePtrType)

// This test demonstrates that the current logic doesn't find matches
// when looking for interface pointers from concrete implementations
Expand All @@ -587,7 +587,7 @@ func TestArgRegistry_getArg_assignabilityPaths(t *testing.T) {
// st = simpleInterface, the loop will check if simpleInterface is assignable to simpleImpl
// Since needsElem=false, this should trigger line 70: return v, true
ifaceType := reflect.TypeOf((*simpleInterface)(nil)).Elem()
v, ok := registry.getArg(ifaceType)
v, ok := lookupArg(t, registry, ifaceType)

if ok {
t.Logf("Found value of type %v", v.Type())
Expand All @@ -610,7 +610,7 @@ func TestArgRegistry_getArg_assignabilityPaths(t *testing.T) {
// The loop will find the string type and check if *string is assignable to string
// Since needsPtr=true but the value is not addressable, it will continue
stringPtrType := reflect.TypeOf((*string)(nil))
_, ok := registry.getArg(stringPtrType)
_, ok := lookupArg(t, registry, stringPtrType)
if ok {
t.Error("Should not find pointer type for unaddressable value")
}
Expand All @@ -627,7 +627,7 @@ func TestArgRegistry_getArg_assignabilityPaths(t *testing.T) {
registry[reflect.TypeOf((*simpleInterface)(nil))] = reflect.ValueOf(&super).Elem()

// Look up by pointer to SubType
v, ok := registry.getArg(reflect.TypeOf(subPtr))
v, ok := lookupArg(t, registry, reflect.TypeOf(subPtr))
if ok {
t.Logf("Line 64 path: found %v", v.Type())
}
Expand All @@ -637,7 +637,7 @@ func TestArgRegistry_getArg_assignabilityPaths(t *testing.T) {
var superVal simpleInterface = simpleImpl{data: "val"}
registry2[reflect.TypeOf((*simpleInterface)(nil)).Elem()] = reflect.ValueOf(superVal)

v2, ok2 := registry2.getArg(reflect.TypeOf(simpleImpl{}))
v2, ok2 := lookupArg(t, registry2, reflect.TypeOf(simpleImpl{}))
if ok2 {
t.Logf("Line 70 path: found %v", v2.Type())
}
Expand Down Expand Up @@ -668,7 +668,7 @@ func TestArgRegistry_getArg_assignability(t *testing.T) {
// *simpleImpl is assignable to simpleInterface, but the stored value
// is not addressable, so it should skip and return not found
ptrType := reflect.TypeOf(&simpleImpl{})
_, ok := registry.getArg(ptrType)
_, ok := lookupArg(t, registry, ptrType)
if ok {
t.Error("Should not find pointer when stored value is not addressable")
}
Expand All @@ -685,7 +685,7 @@ func TestArgRegistry_getArg_assignability(t *testing.T) {

// Look up the same interface type - should find via exact match
ifaceType := reflect.TypeOf((*simpleInterface)(nil)).Elem()
_, ok := registry.getArg(ifaceType)
_, ok := lookupArg(t, registry, ifaceType)
if !ok {
t.Error("Should find interface type")
}
Expand All @@ -699,7 +699,7 @@ func TestArgRegistry_getArg_assignability(t *testing.T) {

// Now try to find by interface - this won't work because
// interface is not assignable to *simpleImpl
v2, ok2 := registry2.getArg(ifaceType)
v2, ok2 := lookupArg(t, registry2, ifaceType)
if ok2 {
// If found, verify it implements the interface
if _, implements := v2.Interface().(simpleInterface); !implements {
Expand All @@ -725,7 +725,7 @@ func TestArgRegistry_getArg_remainingPaths(t *testing.T) {
// Look up pointer to same type - this goes through assignability loop
// because exact match fails, then it checks assignability
ptrType := reflect.TypeOf(&simpleImpl{})
v, ok := registry.getArg(ptrType)
v, ok := lookupArg(t, registry, ptrType)
if !ok {
t.Error("Expected to find pointer via assignability")
} else if v.Kind() != reflect.Pointer {
Expand All @@ -748,7 +748,7 @@ func TestArgRegistry_getArg_remainingPaths(t *testing.T) {
// The loop will find baseType first, check pt.AssignableTo(t) = true
// But v.CanAddr() = false, so it continues
ptrType := reflect.TypeOf(&baseType{})
_, ok := registry.getArg(ptrType)
_, ok := lookupArg(t, registry, ptrType)
if ok {
t.Error("Should not find pointer when only non-addressable value exists")
}
Expand Down Expand Up @@ -783,7 +783,7 @@ func TestArgRegistry_getArg_remainingPaths(t *testing.T) {
// Actually, we need to find a case where a non-pointer type is assignable to something
// This is rare in Go, but let's try with type aliases
ifaceType := reflect.TypeOf((*simpleInterface)(nil)).Elem()
v, ok := registry2.getArg(ifaceType)
v, ok := lookupArg(t, registry2, ifaceType)
if ok {
t.Logf("Found: %v", v.Type())
if v.Kind() == reflect.Pointer {
Expand Down Expand Up @@ -813,3 +813,14 @@ func TestArgRegistry_getArg_remainingPaths(t *testing.T) {
t.Log("Remaining uncovered paths are theoretical edge cases in Go's type system")
})
}

// lookupArg calls getArg and fails the test on an ambiguity error, for
// lookups that expect a single answer or none.
func lookupArg(t *testing.T, args argRegistry, typ reflect.Type) (reflect.Value, bool) {
t.Helper()
v, ok, err := args.getArg(typ)
if err != nil {
t.Fatalf("getArg(%s): %v", typ, err)
}
return v, ok
}
Loading
Loading