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

View File

@@ -17,6 +17,7 @@ import {
PointOfInterestCategoryNameEnum, PointOfInterestCategoryNameEnum,
PointOfInterestGroupEnum, PointOfInterestGroupEnum,
} from "@/types/hotel" } from "@/types/hotel"
import { HotelLocation } from "@/types/trpc/routers/hotel/locations"
import type { Lang } from "@/constants/languages" import type { Lang } from "@/constants/languages"
import type { Endpoint } from "@/lib/api/endpoints" import type { Endpoint } from "@/lib/api/endpoints"
@@ -54,89 +55,119 @@ export const locationsAffix = "locations"
export const TWENTYFOUR_HOURS = 60 * 60 * 24 export const TWENTYFOUR_HOURS = 60 * 60 * 24
export async function getCity( export async function getCity(
cityUrl: string, cityUrl: string,
options: RequestOptionsWithOutBody options: RequestOptionsWithOutBody,
lang: Lang,
relationshipCity: HotelLocation["relationships"]["city"]
) { ) {
const url = new URL(cityUrl) return unstable_cache(
const cityResponse = await api.get( async function (locationCityUrl: string) {
url.pathname as Endpoint, const url = new URL(locationCityUrl)
options, const cityResponse = await api.get(
url.searchParams url.pathname as Endpoint,
) options,
url.searchParams
)
if (!cityResponse.ok) { if (!cityResponse.ok) {
return null return null
} }
const cityJson = await cityResponse.json() const cityJson = await cityResponse.json()
const city = apiCitySchema.safeParse(cityJson) const city = apiCitySchema.safeParse(cityJson)
if (!city.success) { if (!city.success) {
console.info(`Validation of city failed`) console.info(`Validation of city failed`)
console.info(`cityUrl: ${cityUrl}`) console.info(`cityUrl: ${locationCityUrl}`)
console.error(city.error) console.error(city.error)
return null return null
} }
return city.data return city.data
},
[cityUrl, `${lang}:${relationshipCity}`],
{ revalidate: TWENTYFOUR_HOURS }
)(cityUrl)
} }
export async function getCountries( export async function getCountries(
options: RequestOptionsWithOutBody, options: RequestOptionsWithOutBody,
params: URLSearchParams params: URLSearchParams,
lang: Lang
) { ) {
const countryResponse = await api.get( return unstable_cache(
api.endpoints.v1.countries, async function (searchParams) {
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) => {
const countryResponse = await api.get( const countryResponse = await api.get(
`${api.endpoints.v1.citiesCountry}/${country.name}`, api.endpoints.v1.countries,
options, options,
params searchParams
) )
if (!countryResponse.ok) { if (!countryResponse.ok) {
return null return null
} }
const countryJson = await countryResponse.json() const countriesJson = await countryResponse.json()
const citiesByCountry = apiCitiesByCountrySchema.safeParse(countryJson) const countries = apiCountriesSchema.safeParse(countriesJson)
if (!citiesByCountry.success) { if (!countries.success) {
console.info(`Failed to validate Cities by Country payload`) console.info(`Validation for countries failed`)
console.error(citiesByCountry.error) console.error(countries.error)
return null return null
} }
citiesGroupedByCountry[country.name] = citiesByCountry.data.data return countries.data
return true },
}) [`${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( export async function getLocations(
@@ -145,72 +176,89 @@ export async function getLocations(
params: URLSearchParams, params: URLSearchParams,
citiesByCountry: CitiesGroupedByCountry | null 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.ok) {
if (apiResponse.status === 401) { if (apiResponse.status === 401) {
return { error: true, cause: "unauthorized" } as const return { error: true, cause: "unauthorized" } as const
} else if (apiResponse.status === 403) { } else if (apiResponse.status === 403) {
return { error: true, cause: "forbidden" } as const 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,
},
})
}
} }
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 { generateServiceTokenTag } from "@/utils/generateTag"
import { ServiceTokenScopeEnum } from "@/types/enums/serviceToken"
import { ServiceTokenResponse } from "@/types/tokens" import { ServiceTokenResponse } from "@/types/tokens"
// OpenTelemetry metrics: Service token // OpenTelemetry metrics: Service token
const meter = metrics.getMeter("trpc.context.serviceToken") const meter = metrics.getMeter("trpc.context.serviceToken")
const getServiceTokenCounter = meter.createCounter( const fetchServiceTokenCounter = meter.createCounter(
"trpc.context.serviceToken.get-new-token" "trpc.context.serviceToken.fetch-new-token"
) )
const getTempServiceTokenCounter = meter.createCounter( const fetchTempServiceTokenCounter = meter.createCounter(
"trpc.context.serviceToken.get-temporary" "trpc.context.serviceToken.fetch-temporary"
) )
const getServiceTokenFailCounter = meter.createCounter( const fetchServiceTokenFailCounter = meter.createCounter(
"trpc.context.serviceToken.get-fail" "trpc.context.serviceToken.fetch-fail"
) )
async function getServiceToken() { async function fetchServiceToken(scopes: string[]) {
getServiceTokenCounter.add(1) fetchServiceTokenCounter.add(1)
const scopes = Object.keys(ServiceTokenScopeEnum)
const response = await fetch(`${env.CURITY_ISSUER_USER}/oauth/v2/token`, { const response = await fetch(`${env.CURITY_ISSUER_USER}/oauth/v2/token`, {
method: "POST", method: "POST",
headers: { headers: {
@@ -38,7 +36,7 @@ async function getServiceToken() {
}) })
if (!response.ok) { if (!response.ok) {
getServiceTokenFailCounter.add(1, { fetchServiceTokenFailCounter.add(1, {
error_type: "http_error", error_type: "http_error",
error: JSON.stringify({ error: JSON.stringify({
status: response.status, status: response.status,
@@ -51,21 +49,22 @@ async function getServiceToken() {
return response.json() return response.json()
} }
export async function fetchServiceToken(): Promise<ServiceTokenResponse> { export async function getServiceToken(): Promise<ServiceTokenResponse> {
try { try {
const tag = generateServiceTokenTag() const scopes = ["profile", "hotel", "booking"]
const tag = generateServiceTokenTag(scopes)
const getCachedJwt = unstable_cache( const getCachedJwt = unstable_cache(
async () => { async (scopes) => {
const jwt = await getServiceToken() const jwt = await fetchServiceToken(scopes)
const expiresAt = Date.now() + jwt.expires_in * 1000 const expiresAt = Date.now() + jwt.expires_in * 1000
return { expiresAt, jwt } return { expiresAt, jwt }
}, },
[], [tag],
{ tags: [tag] } { tags: [tag] }
) )
const cachedJwt = await getCachedJwt() const cachedJwt = await getCachedJwt(scopes)
if (cachedJwt.expiresAt < Date.now()) { if (cachedJwt.expiresAt < Date.now()) {
console.log( console.log(
"trpc.context.serviceToken: Service token expired, revalidating tag" "trpc.context.serviceToken: Service token expired, revalidating tag"
@@ -75,8 +74,8 @@ export async function fetchServiceToken(): Promise<ServiceTokenResponse> {
console.log( console.log(
"trpc.context.serviceToken: Fetching new temporary service token." "trpc.context.serviceToken: Fetching new temporary service token."
) )
getTempServiceTokenCounter.add(1) fetchTempServiceTokenCounter.add(1)
const newToken = await getServiceToken() const newToken = await fetchServiceToken(scopes)
return newToken return newToken
} }

View File

@@ -11,16 +11,12 @@ import {
unauthorizedError, unauthorizedError,
} from "./errors/trpc" } from "./errors/trpc"
import { type Context, createContext } from "./context" import { type Context, createContext } from "./context"
import { fetchServiceToken } from "./tokenManager" import { getServiceToken } from "./tokenManager"
import { transformer } from "./transformer" import { transformer } from "./transformer"
import { langInput } from "./utils" import { langInput } from "./utils"
import type { Session } from "next-auth" import type { Session } from "next-auth"
import {
ServiceTokenScope,
ServiceTokenScopeEnum,
} from "@/types/enums/serviceToken"
import type { Meta } from "@/types/trpc/meta" import type { Meta } from "@/types/trpc/meta"
const t = initTRPC const t = initTRPC
@@ -126,7 +122,7 @@ export const safeProtectedProcedure = t.procedure.use(async function (opts) {
}) })
export const serviceProcedure = t.procedure.use(async (opts) => { export const serviceProcedure = t.procedure.use(async (opts) => {
const { access_token } = await fetchServiceToken() const { access_token } = await getServiceToken()
if (!access_token) { if (!access_token) {
throw internalServerError(`Failed to obtain service 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 Locations = LocationSchema["data"]
export type Location = Locations[number] 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 { System } from "@/types/requests/system"
import type { Edges } from "@/types/requests/utils/edges" import type { Edges } from "@/types/requests/utils/edges"
import type { NodeRefs } from "@/types/requests/utils/refs" import type { NodeRefs } from "@/types/requests/utils/refs"
@@ -107,7 +106,6 @@ export function generateLoyaltyConfigTag(
* @param serviceTokenScope scope of service token * @param serviceTokenScope scope of service token
* @returns string * @returns string
*/ */
export function generateServiceTokenTag() { export function generateServiceTokenTag(scopes: string[]) {
const scopes = Object.keys(ServiceTokenScopeEnum).join("-") return `service_token:${scopes.join("-")}`
return `service_token:${scopes}`
} }