Skip to content

Commit 11f10da

Browse files
nicklaslclaude
andauthored
feat(go): add Resolve and ApplyFlags API to provider (#321)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f066fc6 commit 11f10da

File tree

9 files changed

+171
-54
lines changed

9 files changed

+171
-54
lines changed

openfeature-provider/go/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,59 @@ With `nil` default, you get the resolved value as-is using natural Go types (num
395395
- Negative numbers cannot convert to unsigned integers
396396
- Null values in the flag use the corresponding default value for that field
397397

398+
## Direct Resolve API
399+
400+
In addition to the standard OpenFeature evaluation methods, the provider exposes a lower-level `Resolve` / `ApplyFlags` API for use cases that need more control — for example, resolving multiple flags in a single call or deferring exposure logging.
401+
402+
### Resolve with immediate apply
403+
404+
```go
405+
provider, _ := confidence.NewProvider(ctx, confidence.ProviderConfig{
406+
ClientSecret: "your-client-secret",
407+
})
408+
openfeature.SetProviderAndWait(provider)
409+
410+
evalCtx := openfeature.FlattenedContext{
411+
"targeting_key": "user-123",
412+
"country": "US",
413+
}
414+
415+
// Resolve one or more flags at once with apply=true to record exposures immediately.
416+
// Pass an empty slice to resolve all flags available to the client.
417+
resp, err := provider.Resolve(ctx, evalCtx, []string{"flag-a", "flag-b"}, true)
418+
if err != nil {
419+
log.Fatal(err)
420+
}
421+
for _, f := range resp.ResolvedFlags {
422+
log.Printf("%s%s", f.Flag, f.Variant)
423+
}
424+
```
425+
426+
### Deferred apply
427+
428+
When `apply` is `false`, the response contains a `ResolveToken`. Pass it to `ApplyFlags` later to record exposure events — useful when you resolve flags on the server but only want to log exposure once the client actually renders the experience.
429+
430+
```go
431+
// 1. Resolve without applying
432+
resp, err := provider.Resolve(ctx, evalCtx, []string{"checkout-flow"}, false)
433+
if err != nil {
434+
log.Fatal(err)
435+
}
436+
437+
// 2. … later, after the user has been exposed …
438+
err = provider.ApplyFlags(&resolver.ApplyFlagsRequest{
439+
Flags: []*resolver.AppliedFlag{
440+
{Flag: "flags/checkout-flow", ApplyTime: timestamppb.Now()},
441+
},
442+
ClientSecret: "your-client-secret",
443+
ResolveToken: resp.ResolveToken,
444+
SendTime: timestamppb.Now(),
445+
})
446+
if err != nil {
447+
log.Printf("apply failed: %v", err)
448+
}
449+
```
450+
398451
## Logging
399452

400453
The provider uses `log/slog` for structured logging. By default, logs at `Info` level and above are written to `stderr`.

openfeature-provider/go/confidence/internal/local_resolver/local_resolver.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66

7+
"github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolver"
78
"github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/wasm"
89
)
910

@@ -19,6 +20,7 @@ type LocalResolverFactory interface {
1920
type LocalResolver interface {
2021
SetResolverState(*wasm.SetResolverStateRequest) error
2122
ResolveProcess(*wasm.ResolveProcessRequest) (*wasm.ResolveProcessResponse, error)
23+
ApplyFlags(*resolver.ApplyFlagsRequest) error
2224
FlushAllLogs() error
2325
FlushAssignLogs() error
2426
Close(context.Context) error

openfeature-provider/go/confidence/internal/local_resolver/pool.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"sync"
99
"sync/atomic"
1010

11+
"github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolver"
1112
"github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/wasm"
1213
)
1314

@@ -75,6 +76,18 @@ func (s *PooledResolver) ResolveProcess(request *wasm.ResolveProcessRequest) (*w
7576
return slot.lr.ResolveProcess(request)
7677
}
7778

79+
// ApplyFlags implements LocalResolver.
80+
func (s *PooledResolver) ApplyFlags(request *resolver.ApplyFlagsRequest) error {
81+
n := uint64(len(s.slots))
82+
idx := s.rr.Add(1)
83+
for !s.slots[idx%n].rw.TryRLock() {
84+
idx = s.rr.Add(1)
85+
}
86+
slot := &s.slots[idx%n]
87+
defer slot.rw.RUnlock()
88+
return slot.lr.ApplyFlags(request)
89+
}
90+
7891
// SetResolverState implements LocalResolver.
7992
func (s *PooledResolver) SetResolverState(request *wasm.SetResolverStateRequest) error {
8093
return s.maintenance(func(lr LocalResolver) error {

openfeature-provider/go/confidence/internal/local_resolver/recover.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"sync/atomic"
77
"time"
88

9+
"github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolver"
910
"github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/wasm"
1011
)
1112

@@ -96,6 +97,13 @@ func (r *RecoveringResolver) SetResolverState(request *wasm.SetResolverStateRequ
9697
return
9798
}
9899

100+
func (r *RecoveringResolver) ApplyFlags(request *resolver.ApplyFlagsRequest) (err error) {
101+
r.withRecover("ApplyFlags", &err, func(lr LocalResolver) {
102+
err = lr.ApplyFlags(request)
103+
})
104+
return
105+
}
106+
99107
func (r *RecoveringResolver) ResolveProcess(request *wasm.ResolveProcessRequest) (resp *wasm.ResolveProcessResponse, err error) {
100108
r.withRecover("ResolveProcess", &err, func(lr LocalResolver) {
101109
resp, err = lr.ResolveProcess(request)

openfeature-provider/go/confidence/internal/local_resolver/wasm.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/wasm"
1313

14+
"github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolver"
1415
resolverv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal"
1516
"github.com/tetratelabs/wazero"
1617
"github.com/tetratelabs/wazero/api"
@@ -49,6 +50,10 @@ func (r *WasmResolver) ResolveProcess(request *wasm.ResolveProcessRequest) (*was
4950
return resp, err
5051
}
5152

53+
func (r *WasmResolver) ApplyFlags(request *resolver.ApplyFlagsRequest) error {
54+
return r.call("wasm_msg_guest_apply_flags", request, nil)
55+
}
56+
5257
func (r *WasmResolver) FlushAllLogs() error {
5358
resp := &resolverv1.WriteFlagLogsRequest{}
5459
err := r.call("wasm_msg_guest_bounded_flush_logs", nil, resp)

openfeature-provider/go/confidence/internal/testutil/helpers.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,7 @@ func (m *MockedLocalResolver) ResolveProcess(*wasm.ResolveProcessRequest) (*wasm
428428
return m.Response, m.Err
429429
}
430430
func (m MockedLocalResolver) SetResolverState(*wasm.SetResolverStateRequest) error { return nil }
431+
func (m MockedLocalResolver) ApplyFlags(*resolver.ApplyFlagsRequest) error { return nil }
431432

432433
func MustJSONToProto(jsonString string) *structpb.Value {
433434
var v structpb.Value

openfeature-provider/go/confidence/materialization.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66

77
lr "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/local_resolver"
8+
"github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolver"
89
"github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/wasm"
910
)
1011

@@ -168,6 +169,10 @@ func readResultsToMaterializationRecords(results []ReadResult) []*wasm.Materiali
168169
return records
169170
}
170171

172+
func (m *materializationSupportedResolver) ApplyFlags(request *resolver.ApplyFlagsRequest) error {
173+
return m.current.ApplyFlags(request)
174+
}
175+
171176
func (m *materializationSupportedResolver) FlushAllLogs() (err error) {
172177
return m.current.FlushAllLogs()
173178
}

openfeature-provider/go/confidence/provider.go

Lines changed: 79 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -168,48 +168,24 @@ func (p *LocalResolverProvider) ObjectEvaluation(
168168
return evaluate(p, ctx, flag, defaultValue, evalCtx)
169169
}
170170

171-
// generic evaluation not a member since members can't be generic
172-
func evaluate[T any](
173-
p *LocalResolverProvider,
174-
ctx context.Context,
175-
flag string,
176-
defaultValue T,
171+
// resolveFlags is the shared resolve implementation used by both the OpenFeature
172+
// evaluate methods and the direct Resolve API. It converts the evaluation context,
173+
// builds the protobuf request, calls the WASM resolver, and extracts the response.
174+
func (p *LocalResolverProvider) resolveFlags(
177175
evalCtx openfeature.FlattenedContext,
178-
) openfeature.GenericResolutionDetail[T] {
179-
// TODO this needs better proper handling, thread safety etc.
180-
if p.resolver == nil {
181-
return openfeature.GenericResolutionDetail[T]{
182-
Value: defaultValue,
183-
ProviderResolutionDetail: openfeature.ProviderResolutionDetail{
184-
Reason: openfeature.ErrorReason,
185-
ResolutionError: openfeature.NewProviderNotReadyResolutionError("provider not initialized"),
186-
},
187-
}
188-
}
189-
// Parse flag path (supports "flag.path.to.value" syntax)
190-
flagName, path := parseFlagPath(flag)
191-
192-
// Process targeting key (convert "targetingKey" to "targeting_key")
176+
flagNames []string,
177+
apply bool,
178+
) (*resolver.ResolveFlagsResponse, error) {
193179
processedCtx := processTargetingKey(evalCtx)
194180

195-
// Convert evaluation context to protobuf Struct
196181
protoCtx, err := flattenedContextToProto(processedCtx)
197182
if err != nil {
198-
p.logger.Error("Failed to convert evaluation context to proto", "error", err)
199-
return openfeature.GenericResolutionDetail[T]{
200-
Value: defaultValue,
201-
ProviderResolutionDetail: openfeature.ProviderResolutionDetail{
202-
Reason: openfeature.ErrorReason,
203-
ResolutionError: openfeature.NewGeneralResolutionError(fmt.Sprintf("failed to convert context: %v", err)),
204-
},
205-
}
183+
return nil, fmt.Errorf("failed to convert context: %w", err)
206184
}
207185

208-
// Build resolve request
209-
requestFlagName := "flags/" + flagName
210186
request := &resolver.ResolveFlagsRequest{
211-
Flags: []string{requestFlagName},
212-
Apply: true,
187+
Flags: flagNames,
188+
Apply: apply,
213189
ClientSecret: p.clientSecret,
214190
EvaluationContext: protoCtx,
215191
Sdk: &resolvertypes.Sdk{
@@ -220,10 +196,6 @@ func evaluate[T any](
220196
},
221197
}
222198

223-
// Create ResolveProcess request without materialization support.
224-
// Flags that require materializations will error gracefully.
225-
// When materialization support is enabled, the materializationSupportedResolver
226-
// wrapper overrides this with DeferredMaterializations and handles suspend/resume.
227199
processRequest := &wasm.ResolveProcessRequest{
228200
Resolve: &wasm.ResolveProcessRequest_WithoutMaterializations{
229201
WithoutMaterializations: request,
@@ -232,37 +204,48 @@ func evaluate[T any](
232204

233205
processResponse, err := p.resolver.ResolveProcess(processRequest)
234206
if err != nil {
235-
p.logger.Error("Failed to resolve flag", "flag", flagName, "error", err)
236-
return openfeature.GenericResolutionDetail[T]{
237-
Value: defaultValue,
238-
ProviderResolutionDetail: openfeature.ProviderResolutionDetail{
239-
Reason: openfeature.ErrorReason,
240-
ResolutionError: openfeature.NewGeneralResolutionError(fmt.Sprintf("resolve failed: %v", err)),
241-
},
242-
}
207+
return nil, fmt.Errorf("resolve failed: %w", err)
243208
}
244209

245-
// Extract the actual resolve response
246-
var response *resolver.ResolveFlagsResponse
247210
switch result := processResponse.Result.(type) {
248211
case *wasm.ResolveProcessResponse_Resolved_:
249-
response = result.Resolved.Response
212+
return result.Resolved.Response, nil
250213
case *wasm.ResolveProcessResponse_Suspended_:
251-
p.logger.Error("Unexpected suspended response for flag", "flag", flagName)
214+
return nil, fmt.Errorf("unexpected suspended response")
215+
default:
216+
return nil, fmt.Errorf("unexpected resolve result type")
217+
}
218+
}
219+
220+
// generic evaluation not a member since members can't be generic
221+
func evaluate[T any](
222+
p *LocalResolverProvider,
223+
ctx context.Context,
224+
flag string,
225+
defaultValue T,
226+
evalCtx openfeature.FlattenedContext,
227+
) openfeature.GenericResolutionDetail[T] {
228+
if p.resolver == nil {
252229
return openfeature.GenericResolutionDetail[T]{
253230
Value: defaultValue,
254231
ProviderResolutionDetail: openfeature.ProviderResolutionDetail{
255232
Reason: openfeature.ErrorReason,
256-
ResolutionError: openfeature.NewGeneralResolutionError("unexpected suspended response"),
233+
ResolutionError: openfeature.NewProviderNotReadyResolutionError("provider not initialized"),
257234
},
258235
}
259-
default:
260-
p.logger.Error("Unexpected resolve result type for flag", "flag", flagName)
236+
}
237+
238+
flagName, path := parseFlagPath(flag)
239+
requestFlagName := "flags/" + flagName
240+
241+
response, err := p.resolveFlags(evalCtx, []string{requestFlagName}, true)
242+
if err != nil {
243+
p.logger.Error("Failed to resolve flag", "flag", flagName, "error", err)
261244
return openfeature.GenericResolutionDetail[T]{
262245
Value: defaultValue,
263246
ProviderResolutionDetail: openfeature.ProviderResolutionDetail{
264247
Reason: openfeature.ErrorReason,
265-
ResolutionError: openfeature.NewGeneralResolutionError("unexpected resolve result"),
248+
ResolutionError: openfeature.NewGeneralResolutionError(err.Error()),
266249
},
267250
}
268251
}
@@ -338,6 +321,48 @@ func evaluate[T any](
338321
}
339322
}
340323

324+
// Resolve resolves multiple flags for the given context. If flagNames is empty,
325+
// all flags available to the client are resolved. When apply is true, exposure
326+
// events are recorded immediately. When apply is false, the response contains a
327+
// resolve_token that must be passed to ApplyFlags later to record exposures.
328+
//
329+
// Returns an error if the provider has not been initialized (Init not called),
330+
// the evaluation context cannot be converted, or the WASM resolver fails.
331+
// On success the returned ResolveFlagsResponse contains the resolved flag
332+
// values and, when apply is false, the resolve_token for deferred application.
333+
func (p *LocalResolverProvider) Resolve(
334+
ctx context.Context,
335+
evalCtx openfeature.FlattenedContext,
336+
flagNames []string,
337+
apply bool,
338+
) (*resolver.ResolveFlagsResponse, error) {
339+
if p.resolver == nil {
340+
return nil, fmt.Errorf("provider not initialized")
341+
}
342+
343+
requestFlags := make([]string, len(flagNames))
344+
for i, name := range flagNames {
345+
requestFlags[i] = "flags/" + name
346+
}
347+
348+
return p.resolveFlags(evalCtx, requestFlags, apply)
349+
}
350+
351+
// ApplyFlags records exposure events for flags previously resolved with
352+
// apply=false. The request must contain the resolve_token from the original
353+
// resolve response.
354+
//
355+
// Returns an error if the provider has not been initialized or the WASM
356+
// resolver fails to process the apply request.
357+
func (p *LocalResolverProvider) ApplyFlags(
358+
request *resolver.ApplyFlagsRequest,
359+
) error {
360+
if p.resolver == nil {
361+
return fmt.Errorf("provider not initialized")
362+
}
363+
return p.resolver.ApplyFlags(request)
364+
}
365+
341366
// Hooks returns provider hooks (none for this implementation)
342367
func (p *LocalResolverProvider) Hooks() []openfeature.Hook {
343368
return []openfeature.Hook{}

openfeature-provider/go/confidence/provider_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/open-feature/go-sdk/openfeature"
1010
lr "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/local_resolver"
11+
"github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolver"
1112
"github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/wasm"
1213
tu "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/testutil"
1314
"google.golang.org/protobuf/types/known/structpb"
@@ -498,6 +499,10 @@ func (m *mockResolverAPIForInit) FlushAssignLogs() error {
498499
return nil
499500
}
500501

502+
func (m *mockResolverAPIForInit) ApplyFlags(request *resolver.ApplyFlagsRequest) error {
503+
return nil
504+
}
505+
501506
// TestLocalResolverProvider_Init_NilStateProvider verifies Init fails when stateProvider is nil
502507
func TestLocalResolverProvider_Init_NilStateProvider(t *testing.T) {
503508
provider := NewLocalResolverProvider(

0 commit comments

Comments
 (0)