Skip to content

Commit 8a1cda1

Browse files
hkotianclaude
andauthored
feat: [FD-5561] implement FDv2 polling endpoint GET /sdk/poll (#701)
* Adding payload version and ID in the project table * format: * Update min payload version * test fix * feat: implement FDv2 polling endpoint GET /sdk/poll Adds the FDv2 flag-delivery-v2 polling endpoint used by Go SDK v7.13+. The basis param (the client's current payload version) is used only to skip an unnecessary full transfer when the client is already up-to-date. Stale clients always receive a full payload (xfer-full/cant-catchup) — delta transfers are intentionally not supported, as that would require persisting a change history which is overkill for a local dev server. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: use SDK subsystems types in FDv2 polling Replace locally-defined FDv2 wire-format types with the equivalent types from go-server-sdk/v7/subsystems: RawEvent, PollingPayload, PutObject, ServerIntent, Selector, and the IntentTransferFull/IntentNone/FlagKind constants. The three reason strings (up-to-date, cant-catchup, payload-missing) have no SDK equivalents and remain as local constants. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Unit tests and fixes * fix --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f701893 commit 8a1cda1

4 files changed

Lines changed: 424 additions & 0 deletions

File tree

internal/dev_server/sdk/fdv2.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package sdk
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"strconv"
7+
"strings"
8+
9+
"github.com/launchdarkly/go-server-sdk/v7/subsystems"
10+
"github.com/launchdarkly/ldcli/internal/dev_server/model"
11+
)
12+
13+
const (
14+
fdv2ReasonUpToDate = "up-to-date"
15+
fdv2ReasonCantCatchup = "cant-catchup"
16+
fdv2ReasonPayloadMissing = "payload-missing"
17+
)
18+
19+
// parseBasis extracts the payload ID and version from a basis state string of the
20+
// form "(p:<payloadId>:<version>)". Returns ("", 0) if the string is absent or unparseable.
21+
//
22+
// Note: in production LD selectors the payload ID is an opaque server-assigned value.
23+
// The dev server uses the project key as the payload ID (see makePayloadTransferredEvent).
24+
// This is a dev-server-specific convention and should not be assumed elsewhere.
25+
func parseBasis(basis string) (string, int) {
26+
if !strings.HasPrefix(basis, "(p:") || !strings.HasSuffix(basis, ")") {
27+
return "", 0
28+
}
29+
// Strip the "(p:" prefix and ")" suffix to get "<payloadId>:<version>".
30+
inner := basis[3 : len(basis)-1]
31+
lastColon := strings.LastIndex(inner, ":")
32+
if lastColon == -1 {
33+
return "", 0
34+
}
35+
version, err := strconv.Atoi(inner[lastColon+1:])
36+
if err != nil || version < 0 {
37+
return "", 0
38+
}
39+
return inner[:lastColon], version
40+
}
41+
42+
// buildPollResponse constructs the FDv2 polling response.
43+
//
44+
// payloadID is the stable identifier for this payload (the project key).
45+
// currentVersion is the project's current PayloadVersion.
46+
// flags is the current flag state with overrides applied.
47+
// basis is the raw ?basis query param from the SDK (empty string = no basis provided).
48+
//
49+
// Delta transfers are not supported: stale clients always receive a full payload.
50+
// Tracking the change history required for deltas is overkill for a local dev server.
51+
func buildPollResponse(payloadID string, currentVersion int, flags model.FlagsState, basis string) (subsystems.PollingPayload, error) {
52+
basisPayloadID, basisVersion := parseBasis(basis)
53+
switch {
54+
case basisVersion == 0:
55+
return buildFullTransferResponse(payloadID, currentVersion, flags, fdv2ReasonPayloadMissing)
56+
case basisPayloadID == payloadID && basisVersion == currentVersion:
57+
event, err := makeServerIntentEvent(payloadID, currentVersion, subsystems.IntentNone, fdv2ReasonUpToDate)
58+
if err != nil {
59+
return subsystems.PollingPayload{}, err
60+
}
61+
return subsystems.PollingPayload{Events: []subsystems.RawEvent{event}}, nil
62+
default:
63+
// Payload ID mismatch, stale version, or version ahead of current (e.g. project recreated):
64+
// we can't compute a delta — send the full payload.
65+
return buildFullTransferResponse(payloadID, currentVersion, flags, fdv2ReasonCantCatchup)
66+
}
67+
}
68+
69+
func buildFullTransferResponse(payloadID string, version int, flags model.FlagsState, reason string) (subsystems.PollingPayload, error) {
70+
intentEvent, err := makeServerIntentEvent(payloadID, version, subsystems.IntentTransferFull, reason)
71+
if err != nil {
72+
return subsystems.PollingPayload{}, err
73+
}
74+
events := []subsystems.RawEvent{intentEvent}
75+
76+
for key, flagState := range flags {
77+
event, err := makePutObjectEvent(version, key, flagState)
78+
if err != nil {
79+
return subsystems.PollingPayload{}, err
80+
}
81+
events = append(events, event)
82+
}
83+
84+
transferredEvent, err := makePayloadTransferredEvent(payloadID, version)
85+
if err != nil {
86+
return subsystems.PollingPayload{}, err
87+
}
88+
events = append(events, transferredEvent)
89+
90+
return subsystems.PollingPayload{Events: events}, nil
91+
}
92+
93+
func makeServerIntentEvent(payloadID string, target int, intentCode subsystems.IntentCode, reason string) (subsystems.RawEvent, error) {
94+
data, err := json.Marshal(subsystems.ServerIntent{
95+
Payload: subsystems.Payload{
96+
ID: payloadID,
97+
Target: target,
98+
Code: intentCode,
99+
Reason: reason,
100+
},
101+
})
102+
if err != nil {
103+
return subsystems.RawEvent{}, err
104+
}
105+
return subsystems.RawEvent{Name: subsystems.EventServerIntent, Data: data}, nil
106+
}
107+
108+
func makePutObjectEvent(version int, key string, flagState model.FlagState) (subsystems.RawEvent, error) {
109+
object, err := json.Marshal(serverFlagFromFlagState(key, flagState))
110+
if err != nil {
111+
return subsystems.RawEvent{}, err
112+
}
113+
data, err := json.Marshal(subsystems.PutObject{
114+
Version: version,
115+
Kind: subsystems.FlagKind,
116+
Key: key,
117+
Object: object,
118+
})
119+
if err != nil {
120+
return subsystems.RawEvent{}, err
121+
}
122+
return subsystems.RawEvent{Name: subsystems.EventPutObject, Data: data}, nil
123+
}
124+
125+
func makePayloadTransferredEvent(payloadID string, version int) (subsystems.RawEvent, error) {
126+
// The selector state is synthetic and dev-server-specific: the dev server uses the
127+
// project key as the payload ID rather than a server-assigned opaque value. The SDK
128+
// echoes this selector back as ?basis on subsequent polls, where parseBasisVersion
129+
// extracts the version from it.
130+
selector := subsystems.NewSelector(fmt.Sprintf("(p:%s:%d)", payloadID, version), version)
131+
data, err := json.Marshal(selector)
132+
if err != nil {
133+
return subsystems.RawEvent{}, err
134+
}
135+
return subsystems.RawEvent{Name: subsystems.EventPayloadTransferred, Data: data}, nil
136+
}
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
package sdk
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"net/url"
9+
"testing"
10+
"time"
11+
12+
"github.com/gorilla/mux"
13+
"github.com/launchdarkly/go-sdk-common/v3/ldcontext"
14+
"github.com/launchdarkly/go-sdk-common/v3/ldvalue"
15+
"github.com/launchdarkly/go-server-sdk/v7/subsystems"
16+
"github.com/launchdarkly/ldcli/internal/dev_server/model"
17+
"github.com/launchdarkly/ldcli/internal/dev_server/model/mocks"
18+
"github.com/stretchr/testify/assert"
19+
"github.com/stretchr/testify/require"
20+
"go.uber.org/mock/gomock"
21+
)
22+
23+
func TestParseBasis(t *testing.T) {
24+
tests := []struct {
25+
basis string
26+
expectedID string
27+
expectedVersion int
28+
}{
29+
{"", "", 0},
30+
{"(p:my-project:5)", "my-project", 5},
31+
{"(p:my-project:1)", "my-project", 1},
32+
{"(p:complex:key:with:colons:99)", "complex:key:with:colons", 99},
33+
{"not-valid", "", 0},
34+
{"(p:no-version)", "", 0},
35+
{"(p:negative:-1)", "", 0},
36+
{"(p:nan:abc)", "", 0},
37+
}
38+
39+
for _, tt := range tests {
40+
t.Run(fmt.Sprintf("basis=%q", tt.basis), func(t *testing.T) {
41+
id, version := parseBasis(tt.basis)
42+
assert.Equal(t, tt.expectedID, id)
43+
assert.Equal(t, tt.expectedVersion, version)
44+
})
45+
}
46+
}
47+
48+
func TestBuildPollResponse(t *testing.T) {
49+
payloadID := "test-project"
50+
currentVersion := 5
51+
flags := model.FlagsState{
52+
"flag-1": model.FlagState{Value: ldvalue.Bool(true), Version: 2},
53+
}
54+
55+
t.Run("no basis sends xfer-full with payload-missing", func(t *testing.T) {
56+
resp, err := buildPollResponse(payloadID, currentVersion, flags, "")
57+
require.NoError(t, err)
58+
59+
require.GreaterOrEqual(t, len(resp.Events), 3) // server-intent + put-objects + payload-transferred
60+
61+
assertServerIntentEvent(t, resp.Events[0], payloadID, currentVersion, subsystems.IntentTransferFull, fdv2ReasonPayloadMissing)
62+
assertPayloadTransferredEvent(t, resp.Events[len(resp.Events)-1], payloadID, currentVersion)
63+
})
64+
65+
t.Run("up-to-date basis sends none with up-to-date", func(t *testing.T) {
66+
basis := fmt.Sprintf("(p:%s:%d)", payloadID, currentVersion)
67+
resp, err := buildPollResponse(payloadID, currentVersion, flags, basis)
68+
require.NoError(t, err)
69+
70+
require.Len(t, resp.Events, 1)
71+
assertServerIntentEvent(t, resp.Events[0], payloadID, currentVersion, subsystems.IntentNone, fdv2ReasonUpToDate)
72+
})
73+
74+
t.Run("basis ahead of current version sends full transfer (e.g. project recreated)", func(t *testing.T) {
75+
basis := fmt.Sprintf("(p:%s:%d)", payloadID, currentVersion+10)
76+
resp, err := buildPollResponse(payloadID, currentVersion, flags, basis)
77+
require.NoError(t, err)
78+
79+
require.GreaterOrEqual(t, len(resp.Events), 3)
80+
assertServerIntentEvent(t, resp.Events[0], payloadID, currentVersion, subsystems.IntentTransferFull, fdv2ReasonCantCatchup)
81+
assertPayloadTransferredEvent(t, resp.Events[len(resp.Events)-1], payloadID, currentVersion)
82+
})
83+
84+
t.Run("stale basis sends xfer-full with cant-catchup", func(t *testing.T) {
85+
basis := fmt.Sprintf("(p:%s:%d)", payloadID, currentVersion-1)
86+
resp, err := buildPollResponse(payloadID, currentVersion, flags, basis)
87+
require.NoError(t, err)
88+
89+
require.GreaterOrEqual(t, len(resp.Events), 3)
90+
assertServerIntentEvent(t, resp.Events[0], payloadID, currentVersion, subsystems.IntentTransferFull, fdv2ReasonCantCatchup)
91+
assertPayloadTransferredEvent(t, resp.Events[len(resp.Events)-1], payloadID, currentVersion)
92+
})
93+
94+
t.Run("basis with wrong payload ID sends xfer-full", func(t *testing.T) {
95+
basis := fmt.Sprintf("(p:%s:%d)", "other-project", currentVersion)
96+
resp, err := buildPollResponse(payloadID, currentVersion, flags, basis)
97+
require.NoError(t, err)
98+
99+
require.GreaterOrEqual(t, len(resp.Events), 3)
100+
assertServerIntentEvent(t, resp.Events[0], payloadID, currentVersion, subsystems.IntentTransferFull, fdv2ReasonCantCatchup)
101+
assertPayloadTransferredEvent(t, resp.Events[len(resp.Events)-1], payloadID, currentVersion)
102+
})
103+
104+
t.Run("full transfer includes a put-object for each flag", func(t *testing.T) {
105+
multiFlags := model.FlagsState{
106+
"flag-a": model.FlagState{Value: ldvalue.Bool(true), Version: 1},
107+
"flag-b": model.FlagState{Value: ldvalue.String("hello"), Version: 2},
108+
}
109+
resp, err := buildPollResponse(payloadID, currentVersion, multiFlags, "")
110+
require.NoError(t, err)
111+
112+
// server-intent + 2 put-objects + payload-transferred
113+
assert.Len(t, resp.Events, 4)
114+
putKeys := make(map[string]bool)
115+
for _, event := range resp.Events {
116+
if event.Name == subsystems.EventPutObject {
117+
var put subsystems.PutObject
118+
require.NoError(t, json.Unmarshal(event.Data, &put))
119+
putKeys[put.Key] = true
120+
assert.Equal(t, currentVersion, put.Version)
121+
assert.Equal(t, subsystems.FlagKind, put.Kind)
122+
}
123+
}
124+
assert.True(t, putKeys["flag-a"])
125+
assert.True(t, putKeys["flag-b"])
126+
})
127+
}
128+
129+
func TestPollV2Handler(t *testing.T) {
130+
mockController := gomock.NewController(t)
131+
store := mocks.NewMockStore(mockController)
132+
observers := model.NewObservers()
133+
134+
router := mux.NewRouter()
135+
router.Use(model.ObserversMiddleware(observers))
136+
router.Use(model.StoreMiddleware(store))
137+
BindRoutes(router)
138+
139+
project := &model.Project{
140+
Key: exampleProjectKey,
141+
SourceEnvironmentKey: "my-environment",
142+
Context: ldcontext.Context{},
143+
LastSyncTime: time.Unix(0, 0),
144+
AllFlagsState: model.FlagsState{
145+
"flag-1": model.FlagState{Value: ldvalue.Bool(true), Version: 1},
146+
},
147+
AvailableVariations: nil,
148+
PayloadVersion: 3,
149+
}
150+
151+
t.Run("no basis returns full payload", func(t *testing.T) {
152+
store.EXPECT().GetDevProject(gomock.Any(), exampleProjectKey).Return(project, nil)
153+
store.EXPECT().GetOverridesForProject(gomock.Any(), exampleProjectKey).Return(nil, nil)
154+
155+
req := httptest.NewRequest(http.MethodGet, "/sdk/poll", nil)
156+
req.Header.Set("Authorization", exampleProjectKey)
157+
rec := httptest.NewRecorder()
158+
router.ServeHTTP(rec, req)
159+
160+
require.Equal(t, http.StatusOK, rec.Code)
161+
assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
162+
163+
var resp subsystems.PollingPayload
164+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
165+
require.GreaterOrEqual(t, len(resp.Events), 3)
166+
assertServerIntentEvent(t, resp.Events[0], exampleProjectKey, 3, subsystems.IntentTransferFull, fdv2ReasonPayloadMissing)
167+
assertPayloadTransferredEvent(t, resp.Events[len(resp.Events)-1], exampleProjectKey, 3)
168+
})
169+
170+
t.Run("up-to-date basis returns none intent", func(t *testing.T) {
171+
store.EXPECT().GetDevProject(gomock.Any(), exampleProjectKey).Return(project, nil)
172+
store.EXPECT().GetOverridesForProject(gomock.Any(), exampleProjectKey).Return(nil, nil)
173+
174+
basisState := fmt.Sprintf("(p:%s:%d)", exampleProjectKey, project.PayloadVersion)
175+
req := httptest.NewRequest(http.MethodGet, "/sdk/poll?basis="+basisState, nil)
176+
req.Header.Set("Authorization", exampleProjectKey)
177+
rec := httptest.NewRecorder()
178+
router.ServeHTTP(rec, req)
179+
180+
require.Equal(t, http.StatusOK, rec.Code)
181+
182+
var resp subsystems.PollingPayload
183+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
184+
require.Len(t, resp.Events, 1)
185+
assertServerIntentEvent(t, resp.Events[0], exampleProjectKey, 3, subsystems.IntentNone, fdv2ReasonUpToDate)
186+
})
187+
188+
t.Run("url-encoded basis is decoded correctly", func(t *testing.T) {
189+
store.EXPECT().GetDevProject(gomock.Any(), exampleProjectKey).Return(project, nil)
190+
store.EXPECT().GetOverridesForProject(gomock.Any(), exampleProjectKey).Return(nil, nil)
191+
192+
basisState := fmt.Sprintf("(p:%s:%d)", exampleProjectKey, project.PayloadVersion)
193+
req := httptest.NewRequest(http.MethodGet, "/sdk/poll?basis="+url.QueryEscape(basisState), nil)
194+
req.Header.Set("Authorization", exampleProjectKey)
195+
rec := httptest.NewRecorder()
196+
router.ServeHTTP(rec, req)
197+
198+
require.Equal(t, http.StatusOK, rec.Code)
199+
200+
var resp subsystems.PollingPayload
201+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
202+
require.Len(t, resp.Events, 1)
203+
assertServerIntentEvent(t, resp.Events[0], exampleProjectKey, 3, subsystems.IntentNone, fdv2ReasonUpToDate)
204+
})
205+
206+
t.Run("stale basis returns full payload with cant-catchup", func(t *testing.T) {
207+
store.EXPECT().GetDevProject(gomock.Any(), exampleProjectKey).Return(project, nil)
208+
store.EXPECT().GetOverridesForProject(gomock.Any(), exampleProjectKey).Return(nil, nil)
209+
210+
basisState := fmt.Sprintf("(p:%s:%d)", exampleProjectKey, project.PayloadVersion-1)
211+
req := httptest.NewRequest(http.MethodGet, "/sdk/poll?basis="+basisState, nil)
212+
req.Header.Set("Authorization", exampleProjectKey)
213+
rec := httptest.NewRecorder()
214+
router.ServeHTTP(rec, req)
215+
216+
require.Equal(t, http.StatusOK, rec.Code)
217+
218+
var resp subsystems.PollingPayload
219+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
220+
require.GreaterOrEqual(t, len(resp.Events), 3)
221+
assertServerIntentEvent(t, resp.Events[0], exampleProjectKey, 3, subsystems.IntentTransferFull, fdv2ReasonCantCatchup)
222+
assertPayloadTransferredEvent(t, resp.Events[len(resp.Events)-1], exampleProjectKey, 3)
223+
})
224+
225+
t.Run("unknown project returns 404", func(t *testing.T) {
226+
store.EXPECT().GetDevProject(gomock.Any(), exampleProjectKey).Return(nil, model.NewErrNotFound("project", exampleProjectKey))
227+
228+
req := httptest.NewRequest(http.MethodGet, "/sdk/poll", nil)
229+
req.Header.Set("Authorization", exampleProjectKey)
230+
rec := httptest.NewRecorder()
231+
router.ServeHTTP(rec, req)
232+
233+
assert.Equal(t, http.StatusNotFound, rec.Code)
234+
})
235+
}
236+
237+
// assertServerIntentEvent unmarshals a server-intent event and checks its fields.
238+
func assertServerIntentEvent(t *testing.T, event subsystems.RawEvent, payloadID string, target int, intentCode subsystems.IntentCode, reason string) {
239+
t.Helper()
240+
assert.Equal(t, subsystems.EventServerIntent, event.Name)
241+
var data subsystems.ServerIntent
242+
require.NoError(t, json.Unmarshal(event.Data, &data))
243+
assert.Equal(t, payloadID, data.Payload.ID)
244+
assert.Equal(t, target, data.Payload.Target)
245+
assert.Equal(t, intentCode, data.Payload.Code)
246+
assert.Equal(t, reason, data.Payload.Reason)
247+
}
248+
249+
// assertPayloadTransferredEvent unmarshals a payload-transferred event and checks its fields.
250+
func assertPayloadTransferredEvent(t *testing.T, event subsystems.RawEvent, payloadID string, version int) {
251+
t.Helper()
252+
assert.Equal(t, subsystems.EventPayloadTransferred, event.Name)
253+
var data subsystems.Selector
254+
require.NoError(t, json.Unmarshal(event.Data, &data))
255+
assert.Equal(t, version, data.Version())
256+
assert.Equal(t, fmt.Sprintf("(p:%s:%d)", payloadID, version), data.State())
257+
}

0 commit comments

Comments
 (0)