diff --git a/CHANGELOG.md b/CHANGELOG.md index 8502398..6fe5b33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,21 @@ The format is based on [Keep a Changelog][keepachangelog] and adheres to [Semant ### Deprecated ### Fixed +## [0.57.0] - 2025-11-07 + +### Added +- Added a new endpoint `/v1/cache/mget` to retrieve multiple keys from the distributed cache in a single request. + +### Changed +- Improved API key extraction logic to prioritize the `X-API-Key` header and better handle different authorization schemes (Bearer, ApiKey) while avoiding confusion with JWTs. +- Refactored cache retrieval logic to use a dedicated function for decoding values from the distributed cache. + +### Deprecated + +### Removed + +### Fixed +\n ## [0.56.0] - 2025-11-05 diff --git a/Makefile b/Makefile index 27e3b85..ca4347b 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ test-e2e: .PHONY: build clean test run-node run-node2 run-node3 run-example deps tidy fmt vet lint clear-ports install-hooks kill -VERSION := 0.56.0 +VERSION := 0.57.0 COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)' -X 'main.date=$(DATE)' diff --git a/pkg/gateway/cache_handlers.go b/pkg/gateway/cache_handlers.go index 1796b7e..58c2beb 100644 --- a/pkg/gateway/cache_handlers.go +++ b/pkg/gateway/cache_handlers.go @@ -80,9 +80,22 @@ func (g *Gateway) cacheGetHandler(w http.ResponseWriter, r *http.Request) { return } - // Try to decode the value from Olric - // Values stored as JSON bytes need to be deserialized, while basic types - // (strings, numbers, bools) can be retrieved directly + value, err := decodeValueFromOlric(gr) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to decode value: %v", err)) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "key": req.Key, + "value": value, + "dmap": req.DMap, + }) +} + +// decodeValueFromOlric decodes a value from Olric GetResponse +// Handles JSON-serialized complex types and basic types (string, number, bool) +func decodeValueFromOlric(gr *olriclib.GetResponse) (any, error) { var value any // First, try to get as bytes (for JSON-serialized complex types) @@ -113,10 +126,84 @@ func (g *Gateway) cacheGetHandler(w http.ResponseWriter, r *http.Request) { } } + return value, nil +} + +func (g *Gateway) cacheMultiGetHandler(w http.ResponseWriter, r *http.Request) { + if g.olricClient == nil { + writeError(w, http.StatusServiceUnavailable, "Olric cache client not initialized") + return + } + + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req struct { + DMap string `json:"dmap"` // Distributed map name + Keys []string `json:"keys"` // Keys to retrieve + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + + if strings.TrimSpace(req.DMap) == "" { + writeError(w, http.StatusBadRequest, "dmap is required") + return + } + + if len(req.Keys) == 0 { + writeError(w, http.StatusBadRequest, "keys array is required and cannot be empty") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + client := g.olricClient.GetClient() + dm, err := client.NewDMap(req.DMap) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to create DMap: %v", err)) + return + } + + // Get all keys and collect results + var results []map[string]any + for _, key := range req.Keys { + if strings.TrimSpace(key) == "" { + continue // Skip empty keys + } + + gr, err := dm.Get(ctx, key) + if err != nil { + // Skip keys that are not found - don't include them in results + // This matches the SDK's expectation that only found keys are returned + if err == olriclib.ErrKeyNotFound { + continue + } + // For other errors, log but continue with other keys + // We don't want one bad key to fail the entire request + continue + } + + value, err := decodeValueFromOlric(gr) + if err != nil { + // If we can't decode, skip this key + continue + } + + results = append(results, map[string]any{ + "key": key, + "value": value, + }) + } + writeJSON(w, http.StatusOK, map[string]any{ - "key": req.Key, - "value": value, - "dmap": req.DMap, + "results": results, + "dmap": req.DMap, }) } diff --git a/pkg/gateway/middleware.go b/pkg/gateway/middleware.go index 0924488..d92f9ac 100644 --- a/pkg/gateway/middleware.go +++ b/pkg/gateway/middleware.go @@ -131,27 +131,40 @@ func (g *Gateway) authMiddleware(next http.Handler) http.Handler { } // extractAPIKey extracts API key from Authorization, X-API-Key header, or query parameters +// Note: Bearer tokens that look like JWTs (have 2 dots) are skipped (they're JWTs, handled separately) +// X-API-Key header is preferred when both Authorization and X-API-Key are present func extractAPIKey(r *http.Request) string { - // Prefer Authorization header - auth := r.Header.Get("Authorization") - if auth != "" { - // Support "Bearer " and "ApiKey " - lower := strings.ToLower(auth) - if strings.HasPrefix(lower, "bearer ") { - return strings.TrimSpace(auth[len("Bearer "):]) - } - if strings.HasPrefix(lower, "apikey ") { - return strings.TrimSpace(auth[len("ApiKey "):]) - } - // If header has no scheme, treat the whole value as token (lenient for dev) - if !strings.Contains(auth, " ") { - return strings.TrimSpace(auth) - } - } - // Fallback to X-API-Key header + // Prefer X-API-Key header (most explicit) - check this first if v := strings.TrimSpace(r.Header.Get("X-API-Key")); v != "" { return v } + + // Check Authorization header for ApiKey scheme or non-JWT Bearer tokens + auth := r.Header.Get("Authorization") + if auth != "" { + lower := strings.ToLower(auth) + if strings.HasPrefix(lower, "bearer ") { + tok := strings.TrimSpace(auth[len("Bearer "):]) + // Skip Bearer tokens that look like JWTs (have 2 dots) - they're JWTs + // But allow Bearer tokens that don't look like JWTs (for backward compatibility) + if strings.Count(tok, ".") == 2 { + // This is a JWT, skip it + } else { + // This doesn't look like a JWT, treat as API key (backward compatibility) + return tok + } + } else if strings.HasPrefix(lower, "apikey ") { + return strings.TrimSpace(auth[len("ApiKey "):]) + } else if !strings.Contains(auth, " ") { + // If header has no scheme, treat the whole value as token (lenient for dev) + // But skip if it looks like a JWT (has 2 dots) + tok := strings.TrimSpace(auth) + if strings.Count(tok, ".") != 2 { + return tok + } + } + } + // Fallback to query parameter (for WebSocket support) if v := strings.TrimSpace(r.URL.Query().Get("api_key")); v != "" { return v diff --git a/pkg/gateway/routes.go b/pkg/gateway/routes.go index 7ab103a..531bf24 100644 --- a/pkg/gateway/routes.go +++ b/pkg/gateway/routes.go @@ -50,6 +50,7 @@ func (g *Gateway) Routes() http.Handler { // cache endpoints (Olric) mux.HandleFunc("/v1/cache/health", g.cacheHealthHandler) mux.HandleFunc("/v1/cache/get", g.cacheGetHandler) + mux.HandleFunc("/v1/cache/mget", g.cacheMultiGetHandler) mux.HandleFunc("/v1/cache/put", g.cachePutHandler) mux.HandleFunc("/v1/cache/delete", g.cacheDeleteHandler) mux.HandleFunc("/v1/cache/scan", g.cacheScanHandler)