Skip to content

Commit d558c6d

Browse files
committed
Fix browser-based SDK compatibility
- Fix CORS Failure for JavaScript SDK - Fix 404 Not Found for Client-Side Endpoints - Fix incorrect Project Lookup Logic - Add Performance, Caching, and Database migration - Add tests for new functionality
1 parent fdeacc9 commit d558c6d

File tree

9 files changed

+222
-10
lines changed

9 files changed

+222
-10
lines changed

internal/dev_server/db/sqlite.go

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"io"
88
"os"
9+
"strings"
910

1011
_ "github.com/mattn/go-sqlite3"
1112
"github.com/pkg/errors"
@@ -47,12 +48,12 @@ func (s *Sqlite) GetDevProject(ctx context.Context, key string) (*model.Project,
4748
var flagStateData string
4849

4950
row := s.database.QueryRowContext(ctx, `
50-
SELECT key, source_environment_key, context, last_sync_time, flag_state
51+
SELECT key, source_environment_key, client_side_id, context, last_sync_time, flag_state
5152
FROM projects
5253
WHERE key = ?
5354
`, key)
5455

55-
if err := row.Scan(&project.Key, &project.SourceEnvironmentKey, &contextData, &project.LastSyncTime, &flagStateData); err != nil {
56+
if err := row.Scan(&project.Key, &project.SourceEnvironmentKey, &project.ClientSideId, &contextData, &project.LastSyncTime, &flagStateData); err != nil {
5657
if errors.Is(err, sql.ErrNoRows) {
5758
return nil, model.NewErrNotFound("project", key)
5859
}
@@ -72,6 +73,37 @@ func (s *Sqlite) GetDevProject(ctx context.Context, key string) (*model.Project,
7273
return &project, nil
7374
}
7475

76+
func (s *Sqlite) GetDevProjectByClientSideId(ctx context.Context, clientSideId string) (*model.Project, error) {
77+
var project model.Project
78+
var contextData string
79+
var flagStateData string
80+
81+
row := s.database.QueryRowContext(ctx, `
82+
SELECT key, source_environment_key, client_side_id, context, last_sync_time, flag_state
83+
FROM projects
84+
WHERE client_side_id = ? AND client_side_id IS NOT NULL
85+
`, clientSideId)
86+
87+
if err := row.Scan(&project.Key, &project.SourceEnvironmentKey, &project.ClientSideId, &contextData, &project.LastSyncTime, &flagStateData); err != nil {
88+
if errors.Is(err, sql.ErrNoRows) {
89+
return nil, model.NewErrNotFound("project", clientSideId)
90+
}
91+
return nil, err
92+
}
93+
94+
// Parse the context JSON string
95+
if err := json.Unmarshal([]byte(contextData), &project.Context); err != nil {
96+
return nil, errors.Wrap(err, "unable to unmarshal context data")
97+
}
98+
99+
// Parse the flag state JSON string
100+
if err := json.Unmarshal([]byte(flagStateData), &project.AllFlagsState); err != nil {
101+
return nil, errors.Wrap(err, "unable to unmarshal flag state data")
102+
}
103+
104+
return &project, nil
105+
}
106+
75107
func (s *Sqlite) UpdateProject(ctx context.Context, project model.Project) (bool, error) {
76108
flagsStateJson, err := json.Marshal(project.AllFlagsState)
77109
if err != nil {
@@ -89,9 +121,9 @@ func (s *Sqlite) UpdateProject(ctx context.Context, project model.Project) (bool
89121
}()
90122
result, err := tx.ExecContext(ctx, `
91123
UPDATE projects
92-
SET flag_state = ?, last_sync_time = ?, context=?, source_environment_key=?
124+
SET flag_state = ?, last_sync_time = ?, context=?, source_environment_key=?, client_side_id=?
93125
WHERE key = ?;
94-
`, flagsStateJson, project.LastSyncTime, project.Context.JSONString(), project.SourceEnvironmentKey, project.Key)
126+
`, flagsStateJson, project.LastSyncTime, project.Context.JSONString(), project.SourceEnvironmentKey, project.ClientSideId, project.Key)
95127
if err != nil {
96128
return false, errors.Wrap(err, "unable to execute update project")
97129
}
@@ -200,11 +232,12 @@ SELECT 1 FROM projects WHERE key = ?
200232
return
201233
}
202234
_, err = tx.Exec(`
203-
INSERT INTO projects (key, source_environment_key, context, last_sync_time, flag_state)
204-
VALUES (?, ?, ?, ?, ?)
235+
INSERT INTO projects (key, source_environment_key, client_side_id, context, last_sync_time, flag_state)
236+
VALUES (?, ?, ?, ?, ?, ?)
205237
`,
206238
project.Key,
207239
project.SourceEnvironmentKey,
240+
project.ClientSideId,
208241
project.Context.JSONString(),
209242
project.LastSyncTime,
210243
string(flagsStateJson),
@@ -429,6 +462,11 @@ var validationQueries = []string{
429462
"SELECT COUNT(1) from available_variations",
430463
}
431464

465+
func isColumnExistsError(err error) bool {
466+
return err != nil && (strings.Contains(err.Error(), "duplicate column name: client_side_id") ||
467+
strings.Contains(err.Error(), "table projects has no column named client_side_id"))
468+
}
469+
432470
func (s *Sqlite) runMigrations(ctx context.Context) error {
433471
tx, err := s.database.BeginTx(ctx, nil)
434472
if err != nil {
@@ -443,6 +481,7 @@ func (s *Sqlite) runMigrations(ctx context.Context) error {
443481
CREATE TABLE IF NOT EXISTS projects (
444482
key text PRIMARY KEY,
445483
source_environment_key text NOT NULL,
484+
client_side_id text,
446485
context text NOT NULL,
447486
last_sync_time timestamp NOT NULL,
448487
flag_state TEXT NOT NULL
@@ -479,5 +518,14 @@ func (s *Sqlite) runMigrations(ctx context.Context) error {
479518
return err
480519
}
481520

521+
// Add client_side_id column to existing projects table if it doesn't exist
522+
_, err = tx.Exec(`ALTER TABLE projects ADD COLUMN client_side_id text`)
523+
if err != nil {
524+
// Ignore error if column already exists
525+
if !isColumnExistsError(err) {
526+
return err
527+
}
528+
}
529+
482530
return tx.Commit()
483531
}

internal/dev_server/model/mocks/store.go

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/dev_server/model/project.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
type Project struct {
1515
Key string
1616
SourceEnvironmentKey string
17+
ClientSideId *string
1718
Context ldcontext.Context
1819
LastSyncTime time.Time
1920
AllFlagsState FlagsState
@@ -57,6 +58,14 @@ func (project *Project) refreshExternalState(ctx context.Context) error {
5758
return err
5859
}
5960
project.AvailableVariations = availableVariations
61+
62+
// Fetch client-side ID for caching
63+
clientSideId, err := project.fetchClientSideId(ctx)
64+
if err != nil {
65+
return err
66+
}
67+
project.ClientSideId = clientSideId
68+
6069
return nil
6170
}
6271

@@ -156,3 +165,19 @@ func (project Project) fetchFlagState(ctx context.Context) (FlagsState, error) {
156165
flagsState = FromAllFlags(sdkFlags)
157166
return flagsState, nil
158167
}
168+
169+
func (project Project) fetchClientSideId(ctx context.Context) (*string, error) {
170+
apiAdapter := adapters.GetApi(ctx)
171+
environments, err := apiAdapter.GetProjectEnvironments(ctx, project.Key, "", nil)
172+
if err != nil {
173+
return nil, err
174+
}
175+
176+
for _, env := range environments {
177+
if env.Key == project.SourceEnvironmentKey {
178+
return &env.Id, nil
179+
}
180+
}
181+
182+
return nil, errors.New("client-side ID not found for environment " + project.SourceEnvironmentKey)
183+
}

internal/dev_server/model/store.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ type Store interface {
2121
GetDevProjectKeys(ctx context.Context) ([]string, error)
2222
// GetDevProject fetches the project based on the projectKey. If it doesn't exist, ErrNotFound is returned
2323
GetDevProject(ctx context.Context, projectKey string) (*Project, error)
24+
// GetDevProjectByClientSideId fetches the project based on the client-side ID. If it doesn't exist, ErrNotFound is returned
25+
GetDevProjectByClientSideId(ctx context.Context, clientSideId string) (*Project, error)
2426
UpdateProject(ctx context.Context, project Project) (bool, error)
2527
DeleteDevProject(ctx context.Context, projectKey string) (bool, error)
2628
// InsertProject inserts the project. If it already exists, ErrAlreadyExists is returned

internal/dev_server/sdk/cors.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ func CorsHeaders(handler http.Handler) http.Handler {
1010
writer.Header().Set("Access-Control-Allow-Headers", "Cache-Control,Content-Type,Content-Length,Accept-Encoding,X-LaunchDarkly-User-Agent,X-LaunchDarkly-Payload-ID,X-LaunchDarkly-Wrapper,X-LaunchDarkly-Event-Schema,X-LaunchDarkly-Tags")
1111
writer.Header().Set("Access-Control-Expose-Headers", "Date")
1212
writer.Header().Set("Access-Control-Max-Age", "300")
13+
if request.Method == http.MethodOptions {
14+
writer.WriteHeader(http.StatusOK)
15+
return
16+
}
1317
handler.ServeHTTP(writer, request)
1418
})
1519
}

internal/dev_server/sdk/go_sdk_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/stretchr/testify/require"
1414
"go.uber.org/mock/gomock"
1515

16+
ldapi "github.com/launchdarkly/api-client-go/v14"
1617
"github.com/launchdarkly/go-sdk-common/v3/ldcontext"
1718
"github.com/launchdarkly/go-sdk-common/v3/ldvalue"
1819
ldclient "github.com/launchdarkly/go-server-sdk/v7"
@@ -49,6 +50,14 @@ func TestSDKRoutesViaGoSDK(t *testing.T) {
4950
api.EXPECT().GetAllFlags(gomock.Any(), projectKey).
5051
Return(nil, nil). // Available variations are not used for evaluation
5152
AnyTimes()
53+
api.EXPECT().GetProjectEnvironments(gomock.Any(), projectKey, "", nil).
54+
Return([]ldapi.Environment{
55+
{
56+
Key: environmentKey,
57+
Id: "test-client-side-id",
58+
},
59+
}, nil).
60+
AnyTimes()
5261

5362
// Wire up sdk routes in test server
5463
router := mux.NewRouter()

internal/dev_server/sdk/project_key_middleware.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ package sdk
22

33
import (
44
"context"
5+
"fmt"
6+
"log"
57
"net/http"
68
"strings"
79

810
"github.com/gorilla/mux"
11+
"github.com/launchdarkly/ldcli/internal/dev_server/model"
912
)
1013

1114
type ctxKey string
@@ -22,11 +25,14 @@ func GetProjectKeyFromContext(ctx context.Context) string {
2225
func GetProjectKeyFromEnvIdParameter(pathParameter string) func(handler http.Handler) http.Handler {
2326
return func(handler http.Handler) http.Handler {
2427
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
28+
log.Printf("GetProjectKeyFromEnvIdParameter middleware called: %s %s", request.Method, request.URL.Path)
2529
projectKey, ok := mux.Vars(request)[pathParameter]
2630
if !ok {
31+
log.Printf("project key not found in path for parameter: %s", pathParameter)
2732
http.Error(writer, "project key not on path", http.StatusNotFound)
2833
return
2934
}
35+
log.Printf("Extracted project key: %s", projectKey)
3036
ctx := request.Context()
3137
ctx = SetProjectKeyOnContext(ctx, projectKey)
3238
request = request.WithContext(ctx)
@@ -49,3 +55,34 @@ func GetProjectKeyFromAuthorizationHeader(handler http.Handler) http.Handler {
4955
handler.ServeHTTP(writer, request)
5056
})
5157
}
58+
59+
func GetProjectKeyFromClientSideId(pathParameter string) func(handler http.Handler) http.Handler {
60+
return func(handler http.Handler) http.Handler {
61+
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
62+
log.Printf("GetProjectKeyFromClientSideId middleware called: %s %s", request.Method, request.URL.Path)
63+
clientSideId, ok := mux.Vars(request)[pathParameter]
64+
if !ok {
65+
log.Printf("client-side ID not found in path for parameter: %s", pathParameter)
66+
http.Error(writer, "client-side ID not on path", http.StatusNotFound)
67+
return
68+
}
69+
log.Printf("Extracted client-side ID: %s", clientSideId)
70+
71+
ctx := request.Context()
72+
store := model.StoreFromContext(ctx)
73+
74+
// Look up project by client-side ID (fast database lookup)
75+
project, err := store.GetDevProjectByClientSideId(ctx, clientSideId)
76+
if err != nil {
77+
log.Printf("No project found for client-side ID %s: %v", clientSideId, err)
78+
http.Error(writer, fmt.Sprintf("project not found for client-side ID %s", clientSideId), http.StatusNotFound)
79+
return
80+
}
81+
82+
log.Printf("Found project %s for client-side ID %s", project.Key, clientSideId)
83+
ctx = SetProjectKeyOnContext(ctx, project.Key)
84+
request = request.WithContext(ctx)
85+
handler.ServeHTTP(writer, request)
86+
})
87+
}
88+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package sdk
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/gorilla/mux"
10+
"github.com/launchdarkly/ldcli/internal/dev_server/model"
11+
"github.com/launchdarkly/ldcli/internal/dev_server/model/mocks"
12+
"github.com/stretchr/testify/assert"
13+
"go.uber.org/mock/gomock"
14+
)
15+
16+
func TestGetProjectKeyFromClientSideId(t *testing.T) {
17+
mockController := gomock.NewController(t)
18+
store := mocks.NewMockStore(mockController)
19+
20+
router := mux.NewRouter()
21+
router.Use(model.StoreMiddleware(store))
22+
23+
// Create a test handler that will be wrapped by the middleware
24+
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25+
projectKey := GetProjectKeyFromContext(r.Context())
26+
fmt.Fprint(w, projectKey)
27+
})
28+
29+
// Test case: project is found
30+
t.Run("when project is found for client-side ID", func(t *testing.T) {
31+
clientSideId := "test-client-side-id"
32+
expectedProjectKey := "my-project"
33+
34+
// Set up the mock to return a project
35+
store.EXPECT().GetDevProjectByClientSideId(gomock.Any(), clientSideId).Return(&model.Project{
36+
Key: expectedProjectKey,
37+
}, nil)
38+
39+
req := httptest.NewRequest("GET", fmt.Sprintf("/sdk/evalx/%s/some/other/path", clientSideId), nil)
40+
rec := httptest.NewRecorder()
41+
42+
// Create a subrouter with the middleware
43+
subrouter := router.PathPrefix("/sdk/evalx/{envId}").Subrouter()
44+
subrouter.Use(GetProjectKeyFromClientSideId("envId"))
45+
subrouter.PathPrefix("/").Handler(testHandler)
46+
47+
router.ServeHTTP(rec, req)
48+
49+
assert.Equal(t, http.StatusOK, rec.Code)
50+
assert.Equal(t, expectedProjectKey, rec.Body.String())
51+
})
52+
53+
// Test case: project is not found
54+
t.Run("when project is not found for client-side ID", func(t *testing.T) {
55+
clientSideId := "not-found-id"
56+
57+
// Set up the mock to return a not found error
58+
store.EXPECT().GetDevProjectByClientSideId(gomock.Any(), clientSideId).Return(nil, model.NewErrNotFound("project", clientSideId))
59+
60+
req := httptest.NewRequest("GET", fmt.Sprintf("/sdk/evalx/%s/some/other/path", clientSideId), nil)
61+
rec := httptest.NewRecorder()
62+
63+
// Create a subrouter with the middleware
64+
subrouter := router.PathPrefix("/sdk/evalx/{envId}").Subrouter()
65+
subrouter.Use(GetProjectKeyFromClientSideId("envId"))
66+
subrouter.PathPrefix("/").Handler(testHandler)
67+
68+
router.ServeHTTP(rec, req)
69+
70+
assert.Equal(t, http.StatusNotFound, rec.Code)
71+
})
72+
}

internal/dev_server/sdk/routes.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,20 @@ func BindRoutes(router *mux.Router) {
3535
evalRouter := router.PathPrefix("/eval").Subrouter()
3636
evalRouter.Use(CorsHeaders)
3737
evalRouter.Methods(http.MethodOptions).HandlerFunc(ConstantResponseHandler(http.StatusOK, ""))
38-
evalRouter.Use(GetProjectKeyFromEnvIdParameter("envId"))
38+
evalRouter.Use(GetProjectKeyFromClientSideId("envId"))
3939
evalRouter.PathPrefix("/{envId}").
4040
Methods(http.MethodGet, "REPORT").
4141
HandlerFunc(StreamClientFlags)
4242

4343
goalsRouter := router.Path("/sdk/goals/{envId}").Subrouter()
4444
goalsRouter.Use(CorsHeaders)
45-
goalsRouter.Use(GetProjectKeyFromEnvIdParameter("envId"))
45+
goalsRouter.Use(GetProjectKeyFromClientSideId("envId"))
4646
goalsRouter.Methods(http.MethodOptions).HandlerFunc(ConstantResponseHandler(http.StatusOK, ""))
4747
goalsRouter.Methods(http.MethodGet).HandlerFunc(ConstantResponseHandler(http.StatusOK, "[]"))
4848

4949
evalXRouter := router.PathPrefix("/sdk/evalx/{envId}").Subrouter()
5050
evalXRouter.Use(CorsHeaders)
51-
evalXRouter.Use(GetProjectKeyFromEnvIdParameter("envId"))
51+
evalXRouter.Use(GetProjectKeyFromClientSideId("envId"))
5252
evalXRouter.Methods(http.MethodOptions).HandlerFunc(ConstantResponseHandler(http.StatusOK, ""))
53-
evalXRouter.Methods(http.MethodGet, "REPORT").HandlerFunc(GetClientFlags)
53+
evalXRouter.PathPrefix("/").Methods(http.MethodGet, "REPORT").HandlerFunc(GetClientFlags)
5454
}

0 commit comments

Comments
 (0)