fix: add args as keyParts for unstable_cache

This commit is contained in:
Christel Westerberg
2024-10-09 11:27:16 +02:00
parent 71b03143ce
commit 95ff011bdb
7 changed files with 204 additions and 186 deletions

View File

@@ -1,5 +1,4 @@
import { metrics } from "@opentelemetry/api"
import { unstable_cache } from "next/cache"
import * as api from "@/lib/api"
import { GetHotelPage } from "@/lib/graphql/Query/HotelPage/HotelPage.graphql"
@@ -38,7 +37,6 @@ import {
getCitiesByCountry,
getCountries,
getLocations,
locationsAffix,
TWENTYFOUR_HOURS,
} from "./utils"
@@ -657,36 +655,19 @@ export const hotelQueryRouter = router({
},
}
const getCachedCountries = unstable_cache(
getCountries,
[`${ctx.lang}:${locationsAffix}:countries`],
{ revalidate: TWENTYFOUR_HOURS }
)
const countries = await getCachedCountries(options, searchParams)
const getCachedCitiesByCountry = unstable_cache(
getCitiesByCountry,
[`${ctx.lang}:${locationsAffix}:cities-by-country`],
{ revalidate: TWENTYFOUR_HOURS }
)
const countries = await getCountries(options, searchParams, ctx.lang)
let citiesByCountry = null
if (countries) {
citiesByCountry = await getCachedCitiesByCountry(
citiesByCountry = await getCitiesByCountry(
countries,
options,
searchParams
searchParams,
ctx.lang
)
}
const getCachedLocations = unstable_cache(
getLocations,
[`${ctx.lang}:${locationsAffix}`],
{ revalidate: TWENTYFOUR_HOURS }
)
const locations = await getCachedLocations(
const locations = await getLocations(
ctx.lang,
options,
searchParams,

View File

@@ -17,6 +17,7 @@ import {
PointOfInterestCategoryNameEnum,
PointOfInterestGroupEnum,
} from "@/types/hotel"
import { HotelLocation } from "@/types/trpc/routers/hotel/locations"
import type { Lang } from "@/constants/languages"
import type { Endpoint } from "@/lib/api/endpoints"
@@ -54,89 +55,119 @@ export const locationsAffix = "locations"
export const TWENTYFOUR_HOURS = 60 * 60 * 24
export async function getCity(
cityUrl: string,
options: RequestOptionsWithOutBody
options: RequestOptionsWithOutBody,
lang: Lang,
relationshipCity: HotelLocation["relationships"]["city"]
) {
const url = new URL(cityUrl)
const cityResponse = await api.get(
url.pathname as Endpoint,
options,
url.searchParams
)
return unstable_cache(
async function (locationCityUrl: string) {
const url = new URL(locationCityUrl)
const cityResponse = await api.get(
url.pathname as Endpoint,
options,
url.searchParams
)
if (!cityResponse.ok) {
return null
}
if (!cityResponse.ok) {
return null
}
const cityJson = await cityResponse.json()
const city = apiCitySchema.safeParse(cityJson)
if (!city.success) {
console.info(`Validation of city failed`)
console.info(`cityUrl: ${cityUrl}`)
console.error(city.error)
return null
}
const cityJson = await cityResponse.json()
const city = apiCitySchema.safeParse(cityJson)
if (!city.success) {
console.info(`Validation of city failed`)
console.info(`cityUrl: ${locationCityUrl}`)
console.error(city.error)
return null
}
return city.data
return city.data
},
[cityUrl, `${lang}:${relationshipCity}`],
{ revalidate: TWENTYFOUR_HOURS }
)(cityUrl)
}
export async function getCountries(
options: RequestOptionsWithOutBody,
params: URLSearchParams
params: URLSearchParams,
lang: Lang
) {
const countryResponse = await api.get(
api.endpoints.v1.countries,
options,
params
)
if (!countryResponse.ok) {
return null
}
const countriesJson = await countryResponse.json()
const countries = apiCountriesSchema.safeParse(countriesJson)
if (!countries.success) {
console.info(`Validation for countries failed`)
console.error(countries.error)
return null
}
return countries.data
}
export async function getCitiesByCountry(
countries: Countries,
options: RequestOptionsWithOutBody,
params: URLSearchParams
) {
const citiesGroupedByCountry: CitiesGroupedByCountry = {}
await Promise.all(
countries.data.map(async (country) => {
return unstable_cache(
async function (searchParams) {
const countryResponse = await api.get(
`${api.endpoints.v1.citiesCountry}/${country.name}`,
api.endpoints.v1.countries,
options,
params
searchParams
)
if (!countryResponse.ok) {
return null
}
const countryJson = await countryResponse.json()
const citiesByCountry = apiCitiesByCountrySchema.safeParse(countryJson)
if (!citiesByCountry.success) {
console.info(`Failed to validate Cities by Country payload`)
console.error(citiesByCountry.error)
const countriesJson = await countryResponse.json()
const countries = apiCountriesSchema.safeParse(countriesJson)
if (!countries.success) {
console.info(`Validation for countries failed`)
console.error(countries.error)
return null
}
citiesGroupedByCountry[country.name] = citiesByCountry.data.data
return true
})
)
return countries.data
},
[`${lang}:${locationsAffix}:countries`, params.toString()],
{ revalidate: TWENTYFOUR_HOURS }
)(params)
}
return citiesGroupedByCountry
export async function getCitiesByCountry(
countries: Countries,
options: RequestOptionsWithOutBody,
params: URLSearchParams,
lang: Lang
) {
return unstable_cache(
async function (
searchParams: URLSearchParams,
searchedCountries: Countries
) {
const citiesGroupedByCountry: CitiesGroupedByCountry = {}
await Promise.all(
searchedCountries.data.map(async (country) => {
const countryResponse = await api.get(
`${api.endpoints.v1.citiesCountry}/${country.name}`,
options,
searchParams
)
if (!countryResponse.ok) {
return null
}
const countryJson = await countryResponse.json()
const citiesByCountry =
apiCitiesByCountrySchema.safeParse(countryJson)
if (!citiesByCountry.success) {
console.info(`Failed to validate Cities by Country payload`)
console.error(citiesByCountry.error)
return null
}
citiesGroupedByCountry[country.name] = citiesByCountry.data.data
return true
})
)
return citiesGroupedByCountry
},
[
`${lang}:${locationsAffix}:cities-by-country`,
params.toString(),
JSON.stringify(countries),
],
{ revalidate: TWENTYFOUR_HOURS }
)(params, countries)
}
export async function getLocations(
@@ -145,72 +176,89 @@ export async function getLocations(
params: URLSearchParams,
citiesByCountry: CitiesGroupedByCountry | null
) {
const apiResponse = await api.get(api.endpoints.v1.locations, options, params)
return unstable_cache(
async function (
searchParams: URLSearchParams,
groupedCitiesByCountry: CitiesGroupedByCountry | null
) {
const apiResponse = await api.get(
api.endpoints.v1.locations,
options,
searchParams
)
if (!apiResponse.ok) {
if (apiResponse.status === 401) {
return { error: true, cause: "unauthorized" } as const
} else if (apiResponse.status === 403) {
return { error: true, cause: "forbidden" } as const
}
return null
}
const apiJson = await apiResponse.json()
const verifiedLocations = apiLocationsSchema.safeParse(apiJson)
if (!verifiedLocations.success) {
console.info(`Locations Verification Failed`)
console.error(verifiedLocations.error)
return null
}
return await Promise.all(
verifiedLocations.data.data.map(async (location) => {
if (location.type === "cities") {
if (citiesByCountry) {
const country = Object.keys(citiesByCountry).find((country) => {
if (
citiesByCountry[country].find((loc) => loc.name === location.name)
) {
return true
}
return false
})
if (country) {
return {
...location,
country,
}
} else {
console.info(
`Location cannot be found in any of the countries cities`
)
console.info(location)
}
}
} else if (location.type === "hotels") {
if (location.relationships.city?.url) {
const getCachedCity = unstable_cache(
getCity,
[`${lang}:${location.relationships.city}`],
{ revalidate: TWENTYFOUR_HOURS }
)
const city = await getCachedCity(
location.relationships.city.url,
options
)
if (city) {
return deepmerge(location, {
relationships: {
city,
},
})
}
if (!apiResponse.ok) {
if (apiResponse.status === 401) {
return { error: true, cause: "unauthorized" } as const
} else if (apiResponse.status === 403) {
return { error: true, cause: "forbidden" } as const
}
return null
}
return location
})
)
const apiJson = await apiResponse.json()
const verifiedLocations = apiLocationsSchema.safeParse(apiJson)
if (!verifiedLocations.success) {
console.info(`Locations Verification Failed`)
console.error(verifiedLocations.error)
return null
}
return await Promise.all(
verifiedLocations.data.data.map(async (location) => {
if (location.type === "cities") {
if (groupedCitiesByCountry) {
const country = Object.keys(groupedCitiesByCountry).find(
(country) => {
if (
groupedCitiesByCountry[country].find(
(loc) => loc.name === location.name
)
) {
return true
}
return false
}
)
if (country) {
return {
...location,
country,
}
} else {
console.info(
`Location cannot be found in any of the countries cities`
)
console.info(location)
}
}
} else if (location.type === "hotels") {
if (location.relationships.city?.url) {
const city = await getCity(
location.relationships.city.url,
options,
lang,
location.relationships.city
)
if (city) {
return deepmerge(location, {
relationships: {
city,
},
})
}
}
}
return location
})
)
},
[
`${lang}:${locationsAffix}`,
params.toString(),
JSON.stringify(citiesByCountry),
],
{ revalidate: TWENTYFOUR_HOURS }
)(params, citiesByCountry)
}

View File

@@ -5,24 +5,22 @@ import { env } from "@/env/server"
import { generateServiceTokenTag } from "@/utils/generateTag"
import { ServiceTokenScopeEnum } from "@/types/enums/serviceToken"
import { ServiceTokenResponse } from "@/types/tokens"
// OpenTelemetry metrics: Service token
const meter = metrics.getMeter("trpc.context.serviceToken")
const getServiceTokenCounter = meter.createCounter(
"trpc.context.serviceToken.get-new-token"
const fetchServiceTokenCounter = meter.createCounter(
"trpc.context.serviceToken.fetch-new-token"
)
const getTempServiceTokenCounter = meter.createCounter(
"trpc.context.serviceToken.get-temporary"
const fetchTempServiceTokenCounter = meter.createCounter(
"trpc.context.serviceToken.fetch-temporary"
)
const getServiceTokenFailCounter = meter.createCounter(
"trpc.context.serviceToken.get-fail"
const fetchServiceTokenFailCounter = meter.createCounter(
"trpc.context.serviceToken.fetch-fail"
)
async function getServiceToken() {
getServiceTokenCounter.add(1)
const scopes = Object.keys(ServiceTokenScopeEnum)
async function fetchServiceToken(scopes: string[]) {
fetchServiceTokenCounter.add(1)
const response = await fetch(`${env.CURITY_ISSUER_USER}/oauth/v2/token`, {
method: "POST",
headers: {
@@ -38,7 +36,7 @@ async function getServiceToken() {
})
if (!response.ok) {
getServiceTokenFailCounter.add(1, {
fetchServiceTokenFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: response.status,
@@ -51,21 +49,22 @@ async function getServiceToken() {
return response.json()
}
export async function fetchServiceToken(): Promise<ServiceTokenResponse> {
export async function getServiceToken(): Promise<ServiceTokenResponse> {
try {
const tag = generateServiceTokenTag()
const scopes = ["profile", "hotel", "booking"]
const tag = generateServiceTokenTag(scopes)
const getCachedJwt = unstable_cache(
async () => {
const jwt = await getServiceToken()
async (scopes) => {
const jwt = await fetchServiceToken(scopes)
const expiresAt = Date.now() + jwt.expires_in * 1000
return { expiresAt, jwt }
},
[],
[tag],
{ tags: [tag] }
)
const cachedJwt = await getCachedJwt()
const cachedJwt = await getCachedJwt(scopes)
if (cachedJwt.expiresAt < Date.now()) {
console.log(
"trpc.context.serviceToken: Service token expired, revalidating tag"
@@ -75,8 +74,8 @@ export async function fetchServiceToken(): Promise<ServiceTokenResponse> {
console.log(
"trpc.context.serviceToken: Fetching new temporary service token."
)
getTempServiceTokenCounter.add(1)
const newToken = await getServiceToken()
fetchTempServiceTokenCounter.add(1)
const newToken = await fetchServiceToken(scopes)
return newToken
}

View File

@@ -11,16 +11,12 @@ import {
unauthorizedError,
} from "./errors/trpc"
import { type Context, createContext } from "./context"
import { fetchServiceToken } from "./tokenManager"
import { getServiceToken } from "./tokenManager"
import { transformer } from "./transformer"
import { langInput } from "./utils"
import type { Session } from "next-auth"
import {
ServiceTokenScope,
ServiceTokenScopeEnum,
} from "@/types/enums/serviceToken"
import type { Meta } from "@/types/trpc/meta"
const t = initTRPC
@@ -126,7 +122,7 @@ export const safeProtectedProcedure = t.procedure.use(async function (opts) {
})
export const serviceProcedure = t.procedure.use(async (opts) => {
const { access_token } = await fetchServiceToken()
const { access_token } = await getServiceToken()
if (!access_token) {
throw internalServerError(`Failed to obtain service token`)
}

View File

@@ -1,7 +0,0 @@
export enum ServiceTokenScopeEnum {
profile = "profile",
hotel = "hotel",
booking = "booking",
}
export type ServiceTokenScope = keyof typeof ServiceTokenScopeEnum

View File

@@ -6,3 +6,6 @@ export interface LocationSchema extends z.output<typeof apiLocationsSchema> {}
export type Locations = LocationSchema["data"]
export type Location = Locations[number]
export type CityLocation = Location & { type: "cities" }
export type HotelLocation = Location & { type: "hotels" }

View File

@@ -1,4 +1,3 @@
import { ServiceTokenScopeEnum } from "@/types/enums/serviceToken"
import { System } from "@/types/requests/system"
import type { Edges } from "@/types/requests/utils/edges"
import type { NodeRefs } from "@/types/requests/utils/refs"
@@ -107,7 +106,6 @@ export function generateLoyaltyConfigTag(
* @param serviceTokenScope scope of service token
* @returns string
*/
export function generateServiceTokenTag() {
const scopes = Object.keys(ServiceTokenScopeEnum).join("-")
return `service_token:${scopes}`
export function generateServiceTokenTag(scopes: string[]) {
return `service_token:${scopes.join("-")}`
}