AzureAD\AdityaDevarshi
Phase 9: search + filter + pagination on list endpoints
f1dd8f4
Raw
History Blame Contribute Delete
6.63 kB
package api
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"time"
)
// Cache TTLs. Entity reads (settings/products/customers/rate) are long-lived and
// rely on explicit write-invalidation for correctness. Analytics use a short TTL
// instead of precise invalidation, so dashboards stay fresh-enough without a
// per-write hook (writes still best-effort drop the analytics prefix).
const (
entityTTL = 5 * time.Minute
analyticsTTL = 30 * time.Second
)
// ---------- key builders (org-scoped) ----------
//
// Every key begins with "<entity>:<orgID>:..." so DeletePrefix("<entity>:<org>")
// invalidates only that org's data and one org can never read another's bytes.
// orgID here is always a concrete tenant id (> 0); callers must not build keys
// for cross-org / nil-org superadmin reads.
func keySettings(orgID int64) string {
return "settings:" + strconv.FormatInt(orgID, 10)
}
// keyProducts is the cache key for a paginated product list page. It encodes
// every parameter that varies the response (all/q/limit/offset) so distinct
// pages never collide, while keeping the "products:<org>:" prefix so a single
// DeletePrefix invalidates them all.
func keyProducts(orgID int64, includeInactive bool, q string, limit, offset int) string {
return "products:" + strconv.FormatInt(orgID, 10) +
":all=" + strconv.FormatBool(includeInactive) +
":q=" + q +
":l=" + strconv.Itoa(limit) +
":o=" + strconv.Itoa(offset)
}
func keyProductsPrefix(orgID int64) string {
// Trailing ':' so org 5's prefix does not also match org 50's keys.
return "products:" + strconv.FormatInt(orgID, 10) + ":"
}
// keyCustomers is the cache key for a paginated customer list page. See
// keyProducts for the encoding/prefix rationale.
func keyCustomers(orgID int64, includeInactive bool, q string, limit, offset int) string {
return "customers:" + strconv.FormatInt(orgID, 10) +
":all=" + strconv.FormatBool(includeInactive) +
":q=" + q +
":l=" + strconv.Itoa(limit) +
":o=" + strconv.Itoa(offset)
}
func keyCustomersPrefix(orgID int64) string {
return "customers:" + strconv.FormatInt(orgID, 10) + ":"
}
// keyRate builds the resolve-rate key. customerID is "-" when no customer is
// supplied (the default-rate lookup) so it never collides with a real id.
func keyRate(orgID, productID int64, customerID *int64, date string) string {
c := "-"
if customerID != nil {
c = strconv.FormatInt(*customerID, 10)
}
return "rate:" + strconv.FormatInt(orgID, 10) +
":p=" + strconv.FormatInt(productID, 10) +
":c=" + c +
":d=" + date
}
func keyRatePrefix(orgID int64) string {
return "rate:" + strconv.FormatInt(orgID, 10) + ":"
}
// keyAnalytics builds an analytics cache key of the form
// "analytics:<orgID>:<name>:<params>". params should encode every query
// parameter that varies the response (e.g. "from=...:to=...") so two distinct
// requests never collide. All analytics keys share the "analytics:<org>" prefix
// so a single DeletePrefix invalidates every rollup for the org.
func keyAnalytics(orgID int64, name, params string) string {
return "analytics:" + strconv.FormatInt(orgID, 10) + ":" + name + ":" + params
}
func keyAnalyticsPrefix(orgID int64) string {
return "analytics:" + strconv.FormatInt(orgID, 10) + ":"
}
// ---------- helpers ----------
// cacheGetJSON returns the cached marshaled JSON bytes for key, if present.
func (s *Server) cacheGetJSON(key string) ([]byte, bool) {
if s.cache == nil {
return nil, false
}
return s.cache.Get(key)
}
// cacheSetJSON marshals v and stores it under key with ttl. Marshal failures are
// silently ignored (caching is best-effort and must never break a response).
func (s *Server) cacheSetJSON(key string, v any, ttl time.Duration) {
if s.cache == nil {
return
}
b, err := json.Marshal(v)
if err != nil {
return
}
s.cache.Set(key, b, ttl)
}
// writeCachedBytes writes pre-marshaled JSON bytes (a cache hit) as a 200 OK,
// matching writeJSON's content type and trailing newline from json.Encoder.
func writeCachedBytes(w http.ResponseWriter, body []byte) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
// etagFor computes a weak content hash for an ETag: the first 16 hex chars of
// sha256(body), wrapped in quotes per RFC 7232.
func etagFor(body []byte) string {
sum := sha256.Sum256(body)
return `"` + hex.EncodeToString(sum[:])[:16] + `"`
}
// writeJSONCached marshals v, sets Cache-Control + ETag headers, and serves a
// conditional response: if the request's If-None-Match equals the computed ETag
// it returns 304 with no body; otherwise it stores the body under cacheKey
// (best-effort) and writes it with the given status. The TTL drives both the
// cache entry lifetime and the HTTP max-age. A marshal failure falls back to a
// generic 500 (the value should always be JSON-serializable).
func (s *Server) writeJSONCached(w http.ResponseWriter, r *http.Request, status int, v any, cacheKey string, ttl time.Duration) {
body, err := json.Marshal(v)
if err != nil {
internalError(w, err)
return
}
etag := etagFor(body)
w.Header().Set("Cache-Control", "private, max-age="+strconv.Itoa(int(ttl.Seconds())))
w.Header().Set("ETag", etag)
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
if cacheKey != "" && s.cache != nil {
s.cache.Set(cacheKey, body, ttl)
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_, _ = w.Write(body)
}
// writeCachedBytesETag serves pre-marshaled bytes from a cache hit with the same
// ETag/Cache-Control semantics as writeJSONCached, including the 304 short-circuit.
func writeCachedBytesETag(w http.ResponseWriter, r *http.Request, body []byte, ttl time.Duration) {
etag := etagFor(body)
w.Header().Set("Cache-Control", "private, max-age="+strconv.Itoa(int(ttl.Seconds())))
w.Header().Set("ETag", etag)
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
// invalidateAnalytics drops every cached analytics rollup for the org. It is a
// best-effort companion to the short analytics TTL: mutating handlers call it so
// dashboards reflect a write immediately rather than after the TTL lapses.
func (s *Server) invalidateAnalytics(orgID int64) {
if s.cache == nil {
return
}
s.cache.DeletePrefix(keyAnalyticsPrefix(orgID))
}