diff --git a/docs/reference/flagd-ofrep.md b/docs/reference/flagd-ofrep.md index f28ac3b85..fc516165a 100644 --- a/docs/reference/flagd-ofrep.md +++ b/docs/reference/flagd-ofrep.md @@ -23,4 +23,13 @@ To evaluate all flags currently configured at flagd, use OFREP bulk evaluation r curl -X POST 'http://localhost:8016/ofrep/v1/evaluate/flags' ``` +## HTTP Caching + +The bulk evaluation endpoint supports HTTP caching via ETags (MD5-based). Clients can use the `If-None-Match` header with a previously received `ETag` to validate cached responses. When the response hasn't changed, the server returns `304 Not Modified` without a body, reducing bandwidth. + +```shell +curl -X POST 'http://localhost:8016/ofrep/v1/evaluate/flags' \ + -H 'If-None-Match: "a1b2c3d4e5f6..."' +``` + See the [cheat sheet](./cheat-sheet.md#ofrep-api-http) for more OFREP examples including context-sensitive evaluation and selectors. diff --git a/flagd/pkg/service/flag-evaluation/ofrep/handler.go b/flagd/pkg/service/flag-evaluation/ofrep/handler.go index cbc8d435b..9fb56d0f3 100644 --- a/flagd/pkg/service/flag-evaluation/ofrep/handler.go +++ b/flagd/pkg/service/flag-evaluation/ofrep/handler.go @@ -2,6 +2,7 @@ package ofrep import ( "context" + "crypto/md5" "encoding/json" "fmt" "net/http" @@ -19,12 +20,17 @@ import ( "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" + "go.uber.org/zap" ) const ( - key = "key" - singleEvaluation = "/ofrep/v1/evaluate/flags/{key}" - bulkEvaluation = "/ofrep/v1/evaluate/{path:flags\\/|flags}" + key = "key" + singleEvaluation = "/ofrep/v1/evaluate/flags/{key}" + bulkEvaluation = "/ofrep/v1/evaluate/{path:flags\\/|flags}" + headerETag = "ETag" + headerIfNoneMatch = "If-None-Match" + headerContentType = "Content-Type" + contentTypeJSON = "application/json" ) type handler struct { @@ -128,7 +134,37 @@ func (h *handler) HandleBulkEvaluation(w http.ResponseWriter, r *http.Request) { fmt.Sprintf("Bulk evaluation failed. Tracking ID: %s", requestID)) h.writeJSONToResponse(http.StatusInternalServerError, res, w) } else { - h.writeJSONToResponse(http.StatusOK, ofrep.BulkEvaluationResponseFrom(evaluations, metadata), w) + response := ofrep.BulkEvaluationResponseFrom(evaluations, metadata) + h.writeBulkEvaluationResponse(w, r, response) + } +} + +// writes the bulk evaluation response with ETag support +func (h *handler) writeBulkEvaluationResponse(w http.ResponseWriter, r *http.Request, response ofrep.BulkEvaluationResponse) { + // calculate ETag and marshal response in one operation + eTag, body, err := calculateETag(response) + if err != nil { + h.Logger.Warn("error calculating ETag", zap.Error(err)) + w.WriteHeader(http.StatusInternalServerError) + return + } + + // check If-None-Match header for cache validation + ifNoneMatch := r.Header.Get(headerIfNoneMatch) + if ifNoneMatch == eTag { + // ETag matches, return 304 Not Modified + w.Header().Add(headerETag, eTag) + w.WriteHeader(http.StatusNotModified) + return + } + + // ETag doesn't match or missing, return the response with the new ETag + w.Header().Add(headerContentType, contentTypeJSON) + w.Header().Add(headerETag, eTag) + w.WriteHeader(http.StatusOK) + _, err = w.Write(body) + if err != nil { + h.Logger.Warn("error while writing response", zap.Error(err)) } } @@ -137,17 +173,29 @@ func (h *handler) writeJSONToResponse(status int, payload interface{}, w http.Re marshal, err := json.Marshal(payload) if err != nil { // always a 500 - h.Logger.Warn(fmt.Sprintf("error marshelling the response: %v", err)) + h.Logger.Warn("error marshalling the response", zap.Error(err)) w.WriteHeader(http.StatusInternalServerError) return } - w.Header().Add("Content-Type", "application/json") + w.Header().Add(headerContentType, contentTypeJSON) w.WriteHeader(status) _, err = w.Write(marshal) if err != nil { - h.Logger.Warn(fmt.Sprintf("error while writing response: %v", err)) + h.Logger.Warn("error while writing response", zap.Error(err)) + } +} + +// calculateETag generates an ETag from the bulk evaluation response +func calculateETag(response ofrep.BulkEvaluationResponse) (string, []byte, error) { + // marshal the response to JSON + data, err := json.Marshal(response) + if err != nil { + return "", nil, fmt.Errorf("failed to marshal response for ETag calculation: %w", err) } + + hash := md5.Sum(data) + return fmt.Sprintf("\"%x\"", hash), data, nil } func extractOfrepRequest(req *http.Request) (ofrep.Request, error) { diff --git a/flagd/pkg/service/flag-evaluation/ofrep/handler_test.go b/flagd/pkg/service/flag-evaluation/ofrep/handler_test.go index 2ef114d41..4154dcdf9 100644 --- a/flagd/pkg/service/flag-evaluation/ofrep/handler_test.go +++ b/flagd/pkg/service/flag-evaluation/ofrep/handler_test.go @@ -281,7 +281,7 @@ func TestWriteJSONResponse(t *testing.T) { var rsp evaluator.AnyValue err = json.Unmarshal(b, &rsp) if err != nil { - t.Errorf("error unmarshelling body: %v", err) + t.Errorf("error unmarshaling body: %v", err) } if !reflect.DeepEqual(test.payload, rsp) { @@ -290,3 +290,176 @@ func TestWriteJSONResponse(t *testing.T) { }) } } + +func TestWriteBulkEvaluationResponse_ETag(t *testing.T) { + log := logger.NewLogger(nil, false) + h := handler{Logger: log} + + // test response + response := ofrep.BulkEvaluationResponse{ + Flags: []interface{}{ + ofrep.EvaluationSuccess{ + Key: "test-flag", + Value: true, + Reason: model.StaticReason, + Variant: "on", + }, + }, + Metadata: model.Metadata{}, + } + + tests := []struct { + name string + eTagGenerator func() (string, error) // Optional function to generate the ETag + expectedStatus int + expectedHasETag bool + expectedHasBody bool + }{ + { + name: "no If-None-Match header returns 200 with body and ETag", + eTagGenerator: nil, // Will use no ETag in request + expectedStatus: http.StatusOK, + expectedHasETag: true, + expectedHasBody: true, + }, + { + name: "matching If-None-Match header returns 304 Not Modified", + eTagGenerator: func() (string, error) { + eTag, _, err := calculateETag(response) + return eTag, err + }, + expectedStatus: http.StatusNotModified, + expectedHasETag: true, + expectedHasBody: false, + }, + { + name: "non-matching If-None-Match header returns 200 with body and ETag", + eTagGenerator: func() (string, error) { + return "\"some-invalid-etag-lmao\"", nil + }, + expectedStatus: http.StatusOK, + expectedHasETag: true, + expectedHasBody: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Calculate the ETag for this specific test case + var ifNoneMatchHeader string + if test.eTagGenerator != nil { + eTag, err := test.eTagGenerator() + if err != nil { + t.Fatalf("error generating ETag: %v", err) + } + ifNoneMatchHeader = eTag + } + + request := httptest.NewRequest(http.MethodPost, "/ofrep/v1/evaluate/flags", bytes.NewReader([]byte{})) + if ifNoneMatchHeader != "" { + request.Header.Set("If-None-Match", ifNoneMatchHeader) + } + + recorder := httptest.NewRecorder() + h.writeBulkEvaluationResponse(recorder, request, response) + + if test.expectedStatus != recorder.Code { + t.Errorf("expected status code %d, but got %d", test.expectedStatus, recorder.Code) + } + + eTagHeader := recorder.Header().Get("ETag") + if test.expectedHasETag && eTagHeader == "" { + t.Error("expected ETag header to be present, but it was missing") + } + if !test.expectedHasETag && eTagHeader != "" { + t.Error("expected ETag header to be absent, but it was present") + } + + body := recorder.Body.String() + if test.expectedHasBody && body == "" { + t.Error("expected response body, but got empty body") + } + if !test.expectedHasBody && body != "" { + t.Errorf("expected no response body, but got: %s", body) + } + + // Verify ETag behavior: if matching ETag in request, should get 304 + if ifNoneMatchHeader != "" && eTagHeader != "" && ifNoneMatchHeader == eTagHeader { + if test.expectedStatus != http.StatusNotModified { + t.Errorf("expected 304 status when ETag matches, but got %d", test.expectedStatus) + } + } + + // For 200 responses, verify Content-Type header is present + if test.expectedStatus == http.StatusOK && recorder.Header().Get("Content-Type") != "application/json" { + t.Error("expected Content-Type header to be application/json, but it was missing") + } + }) + } +} + +func TestCalculateETag(t *testing.T) { + tests := []struct { + name string + response ofrep.BulkEvaluationResponse + expectedErrNil bool + }{ + { + name: "valid response produces ETag", + response: ofrep.BulkEvaluationResponse{ + Flags: []interface{}{ + ofrep.EvaluationSuccess{ + Key: "test-flag", + Value: true, + Reason: model.StaticReason, + Variant: "on", + }, + }, + Metadata: model.Metadata{}, + }, + expectedErrNil: true, + }, + { + name: "empty response produces ETag", + response: ofrep.BulkEvaluationResponse{ + Flags: []interface{}{}, + Metadata: model.Metadata{}, + }, + expectedErrNil: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + eTag, body, err := calculateETag(test.response) + + if test.expectedErrNil && err != nil { + t.Errorf("expected no error, but got: %v", err) + } + if !test.expectedErrNil && err == nil { + t.Error("expected error, but got none") + } + + if test.expectedErrNil { + // Verify ETag format: quoted hex string + if len(eTag) < 2 || eTag[0] != '"' || eTag[len(eTag)-1] != '"' { + t.Errorf("expected ETag to be quoted, but got: %s", eTag) + } + + // verify body is not empty + if len(body) == 0 { + t.Error("expected marshaled body, but got empty bytes") + } + + // calculate again to ensure deterministic output + eTag2, body2, _ := calculateETag(test.response) + if eTag != eTag2 { + t.Errorf("expected deterministic ETag, but got different values: %s vs %s", eTag, eTag2) + } + if !bytes.Equal(body, body2) { + t.Errorf("expected deterministic body, but got different values") + } + } + }) + } +}