forked from launchdarkly/ld-relay
-
Notifications
You must be signed in to change notification settings - Fork 0
/
endpoints.go
317 lines (287 loc) · 10.8 KB
/
endpoints.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
package relay
import (
"crypto/sha1" //nolint:gosec // we're not using SHA1 for encryption, just for generating an insecure hash
"encoding/hex"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"regexp"
"sort"
"strconv"
"time"
"github.com/gorilla/mux"
ld "gopkg.in/launchdarkly/go-server-sdk.v4"
"gopkg.in/launchdarkly/ld-relay.v5/internal/events"
"gopkg.in/launchdarkly/ld-relay.v5/internal/util"
"gopkg.in/launchdarkly/ld-relay.v5/logging"
)
// Old stream endpoint that just sends "ping" events: clientstream.ld.com/mping (mobile)
// or clientstream.ld.com/ping/{envId} (JS)
func pingStreamHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
clientCtx := getClientContext(req)
clientCtx.getLoggers().Debug("Application requested client-side ping stream")
clientCtx.getHandlers().pingStreamHandler.ServeHTTP(w, req)
})
}
// Server-side SDK streaming endpoint for both flags and segments: stream.ld.com/all
func allStreamHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
clientCtx := getClientContext(req)
clientCtx.getLoggers().Debug("Application requested server-side /all stream")
clientCtx.getHandlers().allStreamHandler.ServeHTTP(w, req)
})
}
// Old server-side SDK streaming endpoint for just flags: stream.ld.com/flags
func flagsStreamHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
clientCtx := getClientContext(req)
clientCtx.getLoggers().Debug("Application requested server-side /flags stream")
clientCtx.getHandlers().flagsStreamHandler.ServeHTTP(w, req)
})
}
// PHP SDK polling endpoint for all flags: app.ld.com/sdk/flags
func pollAllFlagsHandler(w http.ResponseWriter, req *http.Request) {
clientCtx := getClientContext(req)
data, err := clientCtx.getStore().All(ld.Features)
if err != nil {
clientCtx.getLoggers().Errorf("Error reading feature store: %s", err)
w.WriteHeader(500)
return
}
// Compute an overall Etag for the data set by hashing flag keys and versions
hash := sha1.New() // nolint:gas // just used for insecure hashing
keys := make([]string, 0, len(data))
for _, flag := range data {
keys = append(keys, flag.GetKey())
}
sort.Strings(keys) // makes the hash deterministic
for _, key := range keys {
flag := data[key]
_, _ = io.WriteString(hash, fmt.Sprintf("%s:%d", flag.GetKey(), flag.GetVersion()))
}
etag := hex.EncodeToString(hash.Sum(nil))[:15]
writeCacheableJSONResponse(w, req, clientCtx, data, etag)
}
// PHP SDK polling endpoint for a flag: app.ld.com/sdk/flags/{key}
func pollFlagHandler(w http.ResponseWriter, req *http.Request) {
pollFlagOrSegment(getClientContext(req), ld.Features)(w, req)
}
// PHP SDK polling endpoint for a segment: app.ld.com/sdk/segments/{key}
func pollSegmentHandler(w http.ResponseWriter, req *http.Request) {
pollFlagOrSegment(getClientContext(req), ld.Segments)(w, req)
}
// Event-recorder endpoints:
// events.ld.com/bulk (server-side)
// events.ld.com/diagnostic (server-side diagnostic)
// events.ld.com/mobile, events.ld.com/mobile/events, events.ld.com/mobileevents/bulk (mobile)
// events.ld.com/mobile/events/diagnostic (mobile diagnostic)
// events.ld.com/events/bulk/{envId} (JS)
// events.ld.com/events/diagnostic/{envId} (JS)
func bulkEventHandler(endpoint events.Endpoint) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
clientCtx := getClientContext(req)
dispatcher := clientCtx.getHandlers().eventDispatcher
if dispatcher == nil {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write(util.ErrorJsonMsg("Event proxy is not enabled for this environment"))
return
}
handler := dispatcher.GetHandler(endpoint)
if handler == nil {
// Note, if this ever happens, it is a programming error since we are only supposed to
// be using a fixed set of Endpoint values that the dispatcher knows about.
w.WriteHeader(http.StatusServiceUnavailable)
w.Write(util.ErrorJsonMsg("Internal error in event proxy"))
logging.GlobalLoggers.Errorf("Tried to proxy events for unsupported endpoint '%s'", endpoint)
return
}
handler(w, req)
})
}
// Client-side evaluation endpoint, new schema with metadata:
// app.ld.com/sdk/evalx/{envId}/users/{user} (GET)
// app.ld.com/sdk/evalx/{envId}/user (REPORT)
// app.ld/com/sdk/evalx/users/{user} (GET - with SDK key auth)
// app.ld/com/sdk/evalx/user (REPORT - with SDK key auth)
func evaluateAllFeatureFlags(sdkKind sdkKind) func(w http.ResponseWriter, req *http.Request) {
return func(w http.ResponseWriter, req *http.Request) {
evaluateAllShared(w, req, false, sdkKind)
}
}
// Client-side evaluation endpoint, old schema with only values:
// app.ld.com/sdk/eval/{envId}/users/{user} (GET)
// app.ld.com/sdk/eval/{envId}/user (REPORT)
// app.ld/com/sdk/eval/users/{user} (GET - with SDK key auth)
// app.ld/com/sdk/eval/user (REPORT - with SDK key auth)
func evaluateAllFeatureFlagsValueOnly(sdkKind sdkKind) func(w http.ResponseWriter, req *http.Request) {
return func(w http.ResponseWriter, req *http.Request) {
evaluateAllShared(w, req, true, sdkKind)
}
}
func evaluateAllShared(w http.ResponseWriter, req *http.Request, valueOnly bool, sdkKind sdkKind) {
var user *ld.User
var userDecodeErr error
if req.Method == "REPORT" {
if req.Header.Get("Content-Type") != "application/json" {
w.WriteHeader(http.StatusUnsupportedMediaType)
w.Write([]byte("Content-Type must be application/json."))
return
}
body, _ := ioutil.ReadAll(req.Body)
userDecodeErr = json.Unmarshal(body, &user)
} else {
base64User := mux.Vars(req)["user"]
user, userDecodeErr = UserV2FromBase64(base64User)
}
if userDecodeErr != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write(util.ErrorJsonMsg(userDecodeErr.Error()))
return
}
withReasons := req.URL.Query().Get("withReasons") == "true"
clientCtx := getClientContext(req)
client := clientCtx.getClient()
store := clientCtx.getStore()
loggers := clientCtx.getLoggers()
w.Header().Set("Content-Type", "application/json")
if !client.Initialized() {
if store.Initialized() {
loggers.Warn("Called before client initialization; using last known values from feature store")
} else {
w.WriteHeader(http.StatusServiceUnavailable)
loggers.Warn("Called before client initialization. Feature store not available")
w.Write(util.ErrorJsonMsg("Service not initialized"))
return
}
}
if user.Key == nil { //nolint:staticcheck // direct access to User.Key is deprecated
w.WriteHeader(http.StatusBadRequest)
w.Write(util.ErrorJsonMsg("User must have a 'key' attribute"))
return
}
loggers.Debugf("Application requested client-side flags (%s) for user: %s", sdkKind, user.GetKey())
items, err := store.All(ld.Features)
if err != nil {
loggers.Warnf("Unable to fetch flags from feature store. Returning nil map. Error: %s", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write(util.ErrorJsonMsgf("Error fetching flags from feature store: %s", err))
return
}
response := make(map[string]interface{}, len(items))
for _, item := range items {
if flag, ok := item.(*ld.FeatureFlag); ok {
switch sdkKind {
case mobileSdk:
if flag.ClientSideAvailability != nil && !flag.ClientSideAvailability.UsingMobileKey {
continue
}
case jsClientSdk:
if (flag.ClientSideAvailability == nil && !flag.ClientSide) ||
(flag.ClientSideAvailability != nil && !flag.ClientSideAvailability.UsingEnvironmentID) {
continue
}
}
detail, _ := flag.EvaluateDetail(*user, store, false)
var result interface{}
if valueOnly {
result = detail.JSONValue
} else {
isExperiment := isExperiment(flag, detail.Reason)
value := evalXResult{
Value: detail.JSONValue,
Variation: detail.VariationIndex,
Version: flag.Version,
TrackEvents: flag.TrackEvents || isExperiment,
TrackReason: isExperiment,
DebugEventsUntilDate: flag.DebugEventsUntilDate,
}
if withReasons || isExperiment {
value.Reason = &ld.EvaluationReasonContainer{Reason: detail.Reason}
}
result = value
}
response[flag.Key] = result
}
}
result, _ := json.Marshal(response)
w.WriteHeader(http.StatusOK)
w.Write(result)
}
// This logic is copied from the Go SDK; eventually we'll provide a different way to reuse it
func isExperiment(flag *ld.FeatureFlag, reason ld.EvaluationReason) bool {
if reason == nil {
return false
}
switch reason.GetKind() {
case ld.EvalReasonFallthrough:
return flag.TrackEventsFallthrough
case ld.EvalReasonRuleMatch:
i := reason.GetRuleIndex()
if i >= 0 && i < len(flag.Rules) {
return flag.Rules[i].TrackEvents
}
}
return false
}
func pollFlagOrSegment(clientContext clientContext, kind ld.VersionedDataKind) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, req *http.Request) {
key := mux.Vars(req)["key"]
item, err := clientContext.getStore().Get(kind, key)
if err != nil {
clientContext.getLoggers().Errorf("Error reading feature store: %s", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
if item == nil {
w.WriteHeader(http.StatusNotFound)
} else {
writeCacheableJSONResponse(w, req, clientContext, item, strconv.Itoa(item.GetVersion()))
}
}
}
func writeCacheableJSONResponse(w http.ResponseWriter, req *http.Request, clientContext clientContext,
entity interface{}, etagValue string) {
etag := fmt.Sprintf("relay-%s", etagValue) // just to make it extra clear that these are relay-specific etags
if cachedEtag := req.Header.Get("If-None-Match"); cachedEtag != "" {
if cachedEtag == etag {
w.WriteHeader(http.StatusNotModified)
return
}
}
bytes, err := json.Marshal(entity)
if err != nil {
clientContext.getLoggers().Errorf("Error marshaling JSON: %s", err)
w.WriteHeader(http.StatusInternalServerError)
} else {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Etag", etag)
ttl := clientContext.getTtl()
if ttl > 0 {
w.Header().Set("Vary", "Authorization")
expiresAt := time.Now().UTC().Add(ttl)
w.Header().Set("Expires", expiresAt.Format(http.TimeFormat))
// We're setting "Expires:" instead of "Cache-Control:max-age=" so that if someone puts an
// HTTP cache in front of ld-relay, multiple clients hitting the cache at different times
// will all see the same expiration time.
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(bytes)
}
}
// getUserAgent returns the X-LaunchDarkly-User-Agent if available, falling back to the normal "User-Agent" header
func getUserAgent(req *http.Request) string {
if agent := req.Header.Get(ldUserAgentHeader); agent != "" {
return agent
}
return req.Header.Get(userAgentHeader)
}
var hexdigit = regexp.MustCompile(`[a-fA-F\d]`)
func obscureKey(key string) string {
if len(key) > 8 {
return key[0:4] + hexdigit.ReplaceAllString(key[4:len(key)-5], "*") + key[len(key)-5:]
}
return key
}