Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/adapters/elements-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type ElementsResult<T> = {
}

export type ElementsFetcher<T> = IBaseComponent & {
fetchOwnedElements(address: string): Promise<T[]>
fetchOwnedElements(address: string, bypassCache?: boolean): Promise<T[]>
}

export class FetcherError extends Error {
Expand Down Expand Up @@ -39,7 +39,11 @@ export function createElementsFetcherComponent<T>(
})

return {
async fetchOwnedElements(address: string) {
async fetchOwnedElements(address: string, bypassCache = false) {
if (bypassCache) {
return await fetchAllOwnedElements(address)
}

const allElements = await cache.fetch(address)

if (allElements) {
Expand Down
9 changes: 6 additions & 3 deletions src/adapters/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,15 @@ export async function createProfilesComponent(

isDefaultProfile || thirdPartyWearablesOwnershipChecker.addNFTsForAddress(ethAddress, wearables)

// If ifModifiedSinceTimestamp is provided, it means we want fresh data (like a cache bypass)
const bypassCache = !!ifModifiedSinceTimestamp

const [ownedWearables, ownedEmotes, ownedNames] = isDefaultProfile
? [[], [], []]
: await Promise.all([
wearablesFetcher.fetchOwnedElements(ethAddress),
emotesFetcher.fetchOwnedElements(ethAddress),
namesFetcher.fetchOwnedElements(ethAddress),
wearablesFetcher.fetchOwnedElements(ethAddress, bypassCache),
emotesFetcher.fetchOwnedElements(ethAddress, bypassCache),
namesFetcher.fetchOwnedElements(ethAddress, bypassCache),
thirdPartyWearablesOwnershipChecker.checkNFTsOwnership()
])

Expand Down
7 changes: 6 additions & 1 deletion src/controllers/handlers/emotes-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createSorting } from '../../logic/sorting'
import { HandlerContextWithPath, InvalidRequestError, OnChainEmote, OnChainEmoteResponse } from '../../types'
import { createFilters } from './items-commons'
import { GetEmotes200 } from '@dcl/catalyst-api-specs/lib/client'
import { shouldBypassCache } from '../../logic/cache'

function mapItemToItemResponse(
item: OnChainEmote,
Expand Down Expand Up @@ -36,12 +37,16 @@ export async function emotesHandler(
const filter = createFilters(context.url)
const sorting = createSorting(context.url)

// Check if we should bypass cache
// Headers supported: X-Bypass-Cache: true or Cache-Control: no-cache
const bypassCache = shouldBypassCache(context.request)

if (includeDefinitions && includeEntities) {
throw new InvalidRequestError('Cannot use includeEntities and includeDefinitions together')
}

const page = await fetchAndPaginate<OnChainEmote>(
() => emotesFetcher.fetchOwnedElements(address),
() => emotesFetcher.fetchOwnedElements(address, bypassCache),
pagination,
filter,
sorting
Expand Down
7 changes: 6 additions & 1 deletion src/controllers/handlers/names-handler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { fetchAndPaginate, paginationObject } from '../../logic/pagination'
import { HandlerContextWithPath, Name } from '../../types'
import { NamesPaginated } from '@dcl/catalyst-api-specs/lib/client'
import { shouldBypassCache } from '../../logic/cache'

export async function namesHandler(
context: HandlerContextWithPath<'namesFetcher' | 'logs', '/users/:address/names'>
Expand All @@ -9,7 +10,11 @@ export async function namesHandler(
const { namesFetcher } = context.components
const pagination = paginationObject(context.url, Number.MAX_VALUE)

const page = await fetchAndPaginate<Name>(() => namesFetcher.fetchOwnedElements(address), pagination)
// Check if we should bypass cache
// Headers supported: X-Bypass-Cache: true or Cache-Control: no-cache
const bypassCache = shouldBypassCache(context.request)

const page = await fetchAndPaginate<Name>(() => namesFetcher.fetchOwnedElements(address, bypassCache), pagination)
return {
status: 200,
body: {
Expand Down
9 changes: 9 additions & 0 deletions src/controllers/handlers/profiles-handler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { HandlerContextWithPath, InvalidRequestError, NotFoundError } from '../../types'
import { Profile } from '@dcl/catalyst-api-specs/lib/client'
import { shouldBypassCache } from '../../logic/cache'

export async function profilesHandler(
context: Pick<HandlerContextWithPath<'profiles', '/profiles'>, 'components' | 'request'>
Expand All @@ -25,6 +26,14 @@ export async function profilesHandler(
}
}

// Check if we should bypass cache
// Headers supported: X-Bypass-Cache: true or Cache-Control: no-cache
const bypassCache = shouldBypassCache(request)
if (bypassCache) {
// Force fresh data by setting modifiedSince to now
modifiedSince = Date.now()
}

const profiles = await components.profiles.getProfiles(body.ids, modifiedSince)

// The only case in which we receive undefined profiles is when no profile was updated after de If-Modified-Since specified moment.
Expand Down
7 changes: 6 additions & 1 deletion src/controllers/handlers/third-party-wearables-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
GetThirdPartyWearables200,
ThirdPartyIntegrations
} from '@dcl/catalyst-api-specs/lib/client'
import { shouldBypassCache } from '../../logic/cache'

function createFilter(url: URL): (item: ThirdPartyWearable) => boolean {
const categories = url.searchParams.has('category') ? url.searchParams.getAll('category') : []
Expand Down Expand Up @@ -43,8 +44,12 @@ export async function thirdPartyWearablesHandler(
const filter = createFilter(context.url)
const sorting = createBaseSorting(context.url)

// Check if we should bypass cache
// Headers supported: X-Bypass-Cache: true or Cache-Control: no-cache
const bypassCache = shouldBypassCache(context.request)

const page = await fetchAndPaginate<ThirdPartyWearable>(
() => thirdPartyWearablesFetcher.fetchOwnedElements(address),
() => thirdPartyWearablesFetcher.fetchOwnedElements(address, bypassCache),
pagination,
filter,
sorting
Expand Down
7 changes: 6 additions & 1 deletion src/controllers/handlers/wearables-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createSorting } from '../../logic/sorting'
import { HandlerContextWithPath, InvalidRequestError, OnChainWearable, OnChainWearableResponse } from '../../types'
import { createFilters } from './items-commons'
import { GetWearables200 } from '@dcl/catalyst-api-specs/lib/client'
import { shouldBypassCache } from '../../logic/cache'

function mapItemToItemResponse(
item: OnChainWearable,
Expand Down Expand Up @@ -36,12 +37,16 @@ export async function wearablesHandler(
const filter = createFilters(context.url)
const sorting = createSorting(context.url)

// Check if we should bypass cache
// Headers supported: X-Bypass-Cache: true or Cache-Control: no-cache
const bypassCache = shouldBypassCache(context.request)

if (includeDefinitions && includeEntities) {
throw new InvalidRequestError('Cannot use includeEntities and includeDefinitions together')
}

const page = await fetchAndPaginate<OnChainWearable>(
() => wearablesFetcher.fetchOwnedElements(address),
() => wearablesFetcher.fetchOwnedElements(address, bypassCache),
pagination,
filter,
sorting
Expand Down
33 changes: 32 additions & 1 deletion src/logic/cache.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,47 @@
import LRU from 'lru-cache'
import { Request } from 'node-fetch'

/**
* Checks if the request has the bypass cache header
* Supports both X-Bypass-Cache header and Cache-Control: no-cache
*/
export function shouldBypassCache(request: Request): boolean {
// Check for X-Bypass-Cache header
const bypassHeader = request.headers.get('x-bypass-cache')
if (bypassHeader === 'true' || bypassHeader === '1') {
return true
}

// Check for Cache-Control: no-cache
const cacheControl = request.headers.get('cache-control')
if (cacheControl && (cacheControl.includes('no-cache') || cacheControl.includes('no-store'))) {
return true
}

return false
}

/*
* Reads the provided map and returns those nfts that are cached and those that are unknown.
* The cache must be {adress -> {nft -> isOwned} }.
* If bypassCache is true, returns all NFTs as pending check.
*/
export function getCachedNFTsAndPendingCheckNFTs(
ownedNFTsByAddress: Map<string, string[]>,
cache: LRU<string, Map<string, boolean>>
cache: LRU<string, Map<string, boolean>>,
bypassCache = false
) {
const nftsToCheckByAddress: Map<string, string[]> = new Map()
const cachedOwnedNFTsByAddress: Map<string, string[]> = new Map()

// If bypassing cache, return all NFTs as pending check
if (bypassCache) {
return {
nftsToCheckByAddress: ownedNFTsByAddress,
cachedOwnedNFTsByAddress
}
}

for (const [address, nfts] of ownedNFTsByAddress.entries()) {
if (cache.has(address)) {
// Get a map {nft -> isOwned} for address
Expand Down
34 changes: 34 additions & 0 deletions test/integration/emotes-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,40 @@ test('emotes-handler: GET /users/:address/emotes should', function ({ components
expect(theGraph.maticCollectionsSubgraph.query).toHaveBeenCalledTimes(1)
})

describe('when X-Bypass-Cache header is sent', () => {
let wallet: string
let emotes: any[]
let updatedEmotes: any[]

beforeEach(() => {
wallet = generateRandomAddress()
emotes = generateEmotes(2)
updatedEmotes = generateEmotes(3)
})

it('should bypass cache and fetch fresh data', async () => {
const { localFetch, theGraph } = components

// First call - populate cache
theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: emotes })

const r1 = await localFetch.fetch(`/users/${wallet}/emotes`)
expect(r1.status).toBe(200)
const body1 = await r1.json()
expect(body1.elements).toHaveLength(2)

// Second call with bypass header - should fetch fresh data
theGraph.maticCollectionsSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: updatedEmotes })

const r2 = await localFetch.fetch(`/users/${wallet}/emotes`, {
headers: { 'X-Bypass-Cache': 'true' }
})
expect(r2.status).toBe(200)
const body2 = await r2.json()
expect(body2.elements).toHaveLength(3)
})
})

it('return emotes filtering by name', async () => {
const { localFetch, theGraph } = components
const emotes = generateEmotes(17)
Expand Down
34 changes: 34 additions & 0 deletions test/integration/names-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,40 @@ test('names-handler: GET /users/:address/names should', function ({ components }
expect(theGraph.ensSubgraph.query).toHaveBeenCalledTimes(1)
})

describe('when X-Bypass-Cache header is sent', () => {
let wallet: string
let names: any[]
let updatedNames: any[]

beforeEach(() => {
wallet = generateRandomAddress()
names = generateNames(2)
updatedNames = generateNames(3)
})

it('should bypass cache and fetch fresh data', async () => {
const { localFetch, theGraph } = components

// First call - populate cache
theGraph.ensSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: names })

const r1 = await localFetch.fetch(`/users/${wallet}/names`)
expect(r1.status).toBe(200)
const body1 = await r1.json()
expect(body1.elements).toHaveLength(2)

// Second call with bypass header - should fetch fresh data
theGraph.ensSubgraph.query = jest.fn().mockResolvedValueOnce({ nfts: updatedNames })

const r2 = await localFetch.fetch(`/users/${wallet}/names`, {
headers: { 'X-Bypass-Cache': 'true' }
})
expect(r2.status).toBe(200)
const body2 = await r2.json()
expect(body2.elements).toHaveLength(3)
})
})

it('return an error when names cannot be fetched', async () => {
const { localFetch, theGraph } = components

Expand Down
Loading
Loading