From a68ad3302f8ae1a09a95700d1b7cc0994b19eea5 Mon Sep 17 00:00:00 2001 From: xqcxx Date: Thu, 28 May 2026 19:02:33 +0100 Subject: [PATCH] Handle creator query encoding and cache observability --- docs/api-inventory.md | 2 + docs/creator-request-lifecycle.md | 38 +++++ src/modules/creator/README.md | 2 + .../creators/creators-cache-key.utils.ts | 84 ++++++----- src/modules/creators/creators.cache.ts | 140 ++++++++++++++++++ .../creators/creators.query-string.utils.ts | 46 ++++++ .../creators/creators.sort-field.utils.ts | 12 +- src/modules/creators/creators.utils.ts | 13 +- 8 files changed, 295 insertions(+), 42 deletions(-) create mode 100644 docs/creator-request-lifecycle.md create mode 100644 src/modules/creators/creators.cache.ts diff --git a/docs/api-inventory.md b/docs/api-inventory.md index ef60829..62c35e6 100644 --- a/docs/api-inventory.md +++ b/docs/api-inventory.md @@ -35,6 +35,8 @@ Public bootstrap configuration. Public creator discovery and stats endpoints. +- Request lifecycle reference: [`docs/creator-request-lifecycle.md`](./creator-request-lifecycle.md). + | Method | Path | Description | | :----- | :-------------------- | :------------------------------------------- | | `GET` | `/creators` | List creators with pagination and filtering. | diff --git a/docs/creator-request-lifecycle.md b/docs/creator-request-lifecycle.md new file mode 100644 index 0000000..6eefc1d --- /dev/null +++ b/docs/creator-request-lifecycle.md @@ -0,0 +1,38 @@ +# Creator Request Lifecycle + +This reference describes the request flow for the public creator routes mounted +under `/api/v1/creators`. + +## Route registration + +- `src/modules/creators/creators.routes.ts` registers the public creator list + and stats routes. +- `src/modules/creator/creator.routes.ts` keeps the legacy scaffolded creator + routes aligned with the same handler conventions. + +## Request order + +1. The request enters the creator router. +2. `normalizeTrailingSlash` removes an optional trailing slash so the same + handler serves `/api/v1/creators` and `/api/v1/creators/`. +3. `createCreatorReadMetricsMiddleware(...)` starts request timing and records + success/error counts when the response finishes. +4. `cacheControl(...)` applies the public cache headers for GET responses. +5. The controller builds a request context with the raw query object. +6. The controller logs unexpected sort fields at debug/warn level without + mutating the request payload used for validation. +7. `parsePublicQuery(...)` validates and normalizes the query parameters. +8. `fetchCreatorList(...)` builds the Prisma filter and either serves a cached + response or queries the database. +9. `serializeCreatorListResponse(...)` wraps the list and pagination metadata. +10. `attachTimestampHeader(...)` adds the response timestamp header. +11. `sendSuccess(...)` writes the JSON response body. + +## Validation and logging points + +- Validation happens inside the controller before the database query runs. +- Cache lookup and cache hit/miss ratio logging happen in the list service. +- Query normalization debug snapshots are emitted only when debug logging is + enabled. +- The cache-key helper percent-encodes special characters before the key is + logged or used for cache storage. diff --git a/src/modules/creator/README.md b/src/modules/creator/README.md index fc5b12f..aa2bc28 100644 --- a/src/modules/creator/README.md +++ b/src/modules/creator/README.md @@ -38,3 +38,5 @@ All routes are mounted under `/api/v1/creators`. - Authentication and authorization are intentionally deferred. - Persistence/indexing integration is intentionally deferred. - Current handlers are designed so storage/indexing can be added without changing route contracts. +- The request lifecycle for the public creator routes is documented in + [`docs/creator-request-lifecycle.md`](../../docs/creator-request-lifecycle.md). diff --git a/src/modules/creators/creators-cache-key.utils.ts b/src/modules/creators/creators-cache-key.utils.ts index 83570e3..3a7e23d 100644 --- a/src/modules/creators/creators-cache-key.utils.ts +++ b/src/modules/creators/creators-cache-key.utils.ts @@ -6,6 +6,7 @@ */ import { CreatorListQueryType } from './creators.schemas'; import { buildCanonicalParamString } from '../../utils/cache-key-params.utils'; +import { encodeCreatorListQueryStringValue } from './creators.query-string.utils'; /** * Builds a cache key for the creator feed endpoint. @@ -32,21 +33,24 @@ import { buildCanonicalParamString } from '../../utils/cache-key-params.utils'; * ``` */ export function buildCreatorFeedCacheKey(query: CreatorListQueryType): string { - const params: Record = { - limit: query.limit, - offset: query.offset, - sort: query.sort, - order: query.order, - verified: query.verified, - search: query.search !== '' ? query.search : undefined, - include: - query.include !== undefined && query.include.length > 0 - ? query.include.join(',') - : undefined, - }; + const params: Record = { + limit: query.limit, + offset: query.offset, + sort: query.sort, + order: query.order, + verified: query.verified, + search: + query.search !== '' + ? encodeCreatorListQueryStringValue(query.search) + : undefined, + include: + query.include !== undefined && query.include.length > 0 + ? query.include.join(',') + : undefined, + }; - const canonical = buildCanonicalParamString(params); - return canonical ? `creators:${canonical}` : 'creators'; + const canonical = buildCanonicalParamString(params); + return canonical ? `creators:${canonical}` : 'creators'; } /** @@ -68,11 +72,11 @@ export function buildCreatorFeedCacheKey(query: CreatorListQueryType): string { * creator or filter combinations. */ export const CREATOR_FEED_CACHE_INVALIDATION_TOUCHPOINTS = { - CREATOR_REGISTERED: 'creator:registered', - CREATOR_PROFILE_UPDATED: 'creator:profile:updated', - CREATOR_VERIFICATION_CHANGED: 'creator:verification:changed', - CREATOR_KEYS_UPDATED: 'creator:keys:updated', - CREATOR_STATS_UPDATED: 'creator:stats:updated', + CREATOR_REGISTERED: 'creator:registered', + CREATOR_PROFILE_UPDATED: 'creator:profile:updated', + CREATOR_VERIFICATION_CHANGED: 'creator:verification:changed', + CREATOR_KEYS_UPDATED: 'creator:keys:updated', + CREATOR_STATS_UPDATED: 'creator:stats:updated', } as const; /** @@ -91,11 +95,13 @@ export const CREATOR_FEED_CACHE_INVALIDATION_TOUCHPOINTS = { * // Returns: ['creators:*:*:*:*:*:*:*'] (all creator feed entries) * ``` */ -export function buildCreatorFeedInvalidationKeys(_creatorId?: string): string[] { - // Since the creator feed includes all creators and supports various filters, - // we invalidate all creator feed entries when any creator changes. - // This is a conservative approach that ensures cache consistency. - return ['creators:*']; +export function buildCreatorFeedInvalidationKeys( + _creatorId?: string +): string[] { + // Since the creator feed includes all creators and supports various filters, + // we invalidate all creator feed entries when any creator changes. + // This is a conservative approach that ensures cache consistency. + return ['creators:*']; } /** @@ -114,23 +120,25 @@ export function buildCreatorFeedInvalidationKeys(_creatorId?: string): string[] * ``` */ export function buildCreatorFeedFilterInvalidationKeys(filters: { - verified?: boolean; - search?: string; + verified?: boolean; + search?: string; }): string[] { - const patterns: string[] = []; + const patterns: string[] = []; - if (filters.verified !== undefined) { - patterns.push(`creators:*:*:*:*:verified:${filters.verified}:*`); - } + if (filters.verified !== undefined) { + patterns.push(`creators:*:*:*:*:verified:${filters.verified}:*`); + } - if (filters.search !== undefined) { - patterns.push(`creators:*:*:*:*:search:${filters.search}:*`); - } + if (filters.search !== undefined) { + patterns.push( + `creators:*:*:*:*:search:${encodeCreatorListQueryStringValue(filters.search) ?? filters.search}:*` + ); + } - // If no specific filters, invalidate all - if (patterns.length === 0) { - return ['creators:*']; - } + // If no specific filters, invalidate all + if (patterns.length === 0) { + return ['creators:*']; + } - return patterns; + return patterns; } diff --git a/src/modules/creators/creators.cache.ts b/src/modules/creators/creators.cache.ts new file mode 100644 index 0000000..2290b3b --- /dev/null +++ b/src/modules/creators/creators.cache.ts @@ -0,0 +1,140 @@ +import { CREATOR_PUBLIC_ROUTE_CACHE_MAX_AGE_SECONDS } from '../../constants/creator-public-cache.constants'; +import { logger } from '../../utils/logger.utils'; +import { CreatorProfile } from '../../types/profile.types'; +import { CreatorListQueryType } from './creators.schemas'; +import { buildCreatorFeedCacheKey } from './creators-cache-key.utils'; + +type CreatorListCacheEntry = { + creators: CreatorProfile[]; + total: number; + expiresAt: number; +}; + +type CreatorListCacheStats = { + hits: number; + misses: number; +}; + +const creatorListCache = new Map(); +const creatorListCacheStats: CreatorListCacheStats = { + hits: 0, + misses: 0, +}; +const MAX_CREATOR_LIST_CACHE_ENTRIES = 250; + +function getCreatorListCacheTtlMs(): number { + return CREATOR_PUBLIC_ROUTE_CACHE_MAX_AGE_SECONDS.publicRead * 1000; +} + +function pruneCreatorListCache(now: number): void { + for (const [cacheKey, entry] of creatorListCache.entries()) { + if (entry.expiresAt <= now) { + creatorListCache.delete(cacheKey); + } + } + + if (creatorListCache.size <= MAX_CREATOR_LIST_CACHE_ENTRIES) { + return; + } + + const overflow = creatorListCache.size - MAX_CREATOR_LIST_CACHE_ENTRIES; + const oldestEntries = [...creatorListCache.entries()] + .sort((left, right) => left[1].expiresAt - right[1].expiresAt) + .slice(0, overflow); + + for (const [cacheKey] of oldestEntries) { + creatorListCache.delete(cacheKey); + } +} + +function logCreatorListCacheLookup(input: { + cacheKey: string; + hit: boolean; + query: CreatorListQueryType; +}): void { + if (!logger.isLevelEnabled('debug')) { + return; + } + + const totalLookups = + creatorListCacheStats.hits + creatorListCacheStats.misses; + const hitRatio = + totalLookups === 0 ? 0 : creatorListCacheStats.hits / totalLookups; + + logger.debug({ + msg: 'Creator list cache lookup', + event: 'creator_list_cache_lookup', + cacheKey: input.cacheKey, + cacheHit: input.hit, + cacheHits: creatorListCacheStats.hits, + cacheMisses: creatorListCacheStats.misses, + cacheHitRatio: hitRatio, + ttlMs: getCreatorListCacheTtlMs(), + limit: input.query.limit, + offset: input.query.offset, + sort: input.query.sort, + order: input.query.order, + hasSearch: input.query.search !== undefined, + hasVerifiedFilter: input.query.verified !== undefined, + }); +} + +export function getCachedCreatorList( + query: CreatorListQueryType +): { creators: CreatorProfile[]; total: number } | null { + const cacheKey = buildCreatorFeedCacheKey(query); + const cachedEntry = creatorListCache.get(cacheKey); + const now = Date.now(); + + if (cachedEntry && cachedEntry.expiresAt > now) { + creatorListCacheStats.hits += 1; + logCreatorListCacheLookup({ + cacheKey, + hit: true, + query, + }); + + return { + creators: [...cachedEntry.creators], + total: cachedEntry.total, + }; + } + + if (cachedEntry) { + creatorListCache.delete(cacheKey); + } + + pruneCreatorListCache(now); + + creatorListCacheStats.misses += 1; + logCreatorListCacheLookup({ + cacheKey, + hit: false, + query, + }); + + return null; +} + +export function setCachedCreatorList( + query: CreatorListQueryType, + creators: CreatorProfile[], + total: number +): void { + const cacheKey = buildCreatorFeedCacheKey(query); + const now = Date.now(); + + creatorListCache.set(cacheKey, { + creators: [...creators], + total, + expiresAt: now + getCreatorListCacheTtlMs(), + }); + + pruneCreatorListCache(now); +} + +export function resetCreatorListCache(): void { + creatorListCache.clear(); + creatorListCacheStats.hits = 0; + creatorListCacheStats.misses = 0; +} diff --git a/src/modules/creators/creators.query-string.utils.ts b/src/modules/creators/creators.query-string.utils.ts index eee3229..7afc8a9 100644 --- a/src/modules/creators/creators.query-string.utils.ts +++ b/src/modules/creators/creators.query-string.utils.ts @@ -30,3 +30,49 @@ export function withCreatorListQueryStringNormalization( ) { return z.preprocess(normalizeCreatorListQueryStringValue, schema); } + +const UNRESERVED_QUERY_VALUE_PATTERN = /^[A-Za-z0-9._~-]$/; +const HEX_PAIR_PATTERN = /^[0-9A-Fa-f]{2}$/; + +/** + * Percent-encodes a creator query string value for safe logging or forwarding. + * + * Existing percent-encoded sequences are preserved as-is so already encoded + * values are not double-encoded. + */ +export function encodeCreatorListQueryStringValue( + value: string | null | undefined +): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + let encoded = ''; + + for (let index = 0; index < value.length; ) { + const currentChar = value[index]; + + if ( + currentChar === '%' && + index + 2 < value.length && + HEX_PAIR_PATTERN.test(value.slice(index + 1, index + 3)) + ) { + encoded += value.slice(index, index + 3); + index += 3; + continue; + } + + const codePoint = value.codePointAt(index); + const char = String.fromCodePoint(codePoint ?? 0); + + if (UNRESERVED_QUERY_VALUE_PATTERN.test(char)) { + encoded += char; + } else { + encoded += encodeURIComponent(char); + } + + index += char.length; + } + + return encoded; +} diff --git a/src/modules/creators/creators.sort-field.utils.ts b/src/modules/creators/creators.sort-field.utils.ts index b9a6b4c..d018c49 100644 --- a/src/modules/creators/creators.sort-field.utils.ts +++ b/src/modules/creators/creators.sort-field.utils.ts @@ -3,7 +3,10 @@ import { CREATOR_LIST_SORT_FIELDS, type CreatorListSortField, } from '../../constants/creator-list-sort.constants'; -import { normalizeCreatorListQueryStringValue } from './creators.query-string.utils'; +import { + encodeCreatorListQueryStringValue, + normalizeCreatorListQueryStringValue, +} from './creators.query-string.utils'; import { logger } from '../../utils/logger.utils'; /** @@ -45,13 +48,16 @@ export function warnIfUnrecognizedCreatorListSort( } const normalized = normalizeCreatorListQueryStringValue(rawSort); - if (typeof normalized !== 'string' || isRecognizedCreatorListSortField(normalized)) { + if ( + typeof normalized !== 'string' || + isRecognizedCreatorListSortField(normalized) + ) { return; } logger.warn({ msg: 'Unrecognized creator list sort field', - sort: normalized, + sort: encodeCreatorListQueryStringValue(normalized) ?? normalized, ...(requestId ? { requestId } : {}), }); } diff --git a/src/modules/creators/creators.utils.ts b/src/modules/creators/creators.utils.ts index 3ceb7c3..041689e 100644 --- a/src/modules/creators/creators.utils.ts +++ b/src/modules/creators/creators.utils.ts @@ -2,12 +2,16 @@ import { prisma } from '../../utils/prisma.utils'; import { CreatorProfile } from '../../types/profile.types'; import { CreatorListQueryType } from './creators.schemas'; import { mapCreatorListSort } from './creators.sort'; -import { serializeCreatorListResponse, CreatorListResponse } from './creators.serializers'; +import { + serializeCreatorListResponse, + CreatorListResponse, +} from './creators.serializers'; import { buildOffsetPaginationMeta } from '../../utils/pagination.utils'; import { logger } from '../../utils/logger.utils'; import { envConfig } from '../../config'; import { buildCreatorFeedWhere } from './creator-feed-filter-combinator.utils'; import { CREATOR_LIST_DEFAULT_SELECT } from '../../constants/creator-list-projection.constants'; +import { getCachedCreatorList, setCachedCreatorList } from './creators.cache'; /** * Fetch paginated list of creators from the database. @@ -18,6 +22,11 @@ import { CREATOR_LIST_DEFAULT_SELECT } from '../../constants/creator-list-projec export async function fetchCreatorList( query: CreatorListQueryType ): Promise<[CreatorProfile[], number]> { + const cached = getCachedCreatorList(query); + if (cached) { + return [cached.creators, cached.total]; + } + const { limit, offset, sort, order, verified, search } = query; const where = buildCreatorFeedWhere({ verified, search }); @@ -51,6 +60,8 @@ export async function fetchCreatorList( }); } + setCachedCreatorList(query, creators as unknown as CreatorProfile[], total); + return [creators as unknown as CreatorProfile[], total]; }