Skip to content

Commit

Permalink
Add tests for graphql with cache
Browse files Browse the repository at this point in the history
  • Loading branch information
isaacroldan committed Feb 3, 2025
1 parent b959a7a commit 162fead
Show file tree
Hide file tree
Showing 5 changed files with 159 additions and 15 deletions.
6 changes: 4 additions & 2 deletions packages/cli-kit/src/private/node/conf-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,19 @@ export type IntrospectionUrlKey = `identity-introspection-url-${string}`
export type PackageVersionKey = `npm-package-${string}`
export type NotificationsKey = `notifications-${string}`
export type NotificationKey = `notification-${string}`
export type GraphQLRequestKey = `q-${string}-${string}-${string}`
type MostRecentOccurrenceKey = `most-recent-occurrence-${string}`
type RateLimitKey = `rate-limited-occurrences-${string}`

type ExportedKey = IntrospectionUrlKey | PackageVersionKey | NotificationsKey | NotificationKey
type ExportedKey = IntrospectionUrlKey | PackageVersionKey | NotificationsKey | NotificationKey | GraphQLRequestKey

interface Cache {
[introspectionUrlKey: IntrospectionUrlKey]: CacheValue<string>
[packageVersionKey: PackageVersionKey]: CacheValue<string>
[notifications: NotificationsKey]: CacheValue<string>
[notification: NotificationKey]: CacheValue<string>
[MostRecentOccurrenceKey: MostRecentOccurrenceKey]: CacheValue<boolean>
[graphQLRequestKey: GraphQLRequestKey]: CacheValue<string>
[mostRecentOccurrenceKey: MostRecentOccurrenceKey]: CacheValue<boolean>
[rateLimitKey: RateLimitKey]: CacheValue<number[]>
}

Expand Down
7 changes: 6 additions & 1 deletion packages/cli-kit/src/public/node/api/app-management.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {GraphQLResponse, graphqlRequestDoc} from './graphql.js'
import {CacheOptions, GraphQLResponse, graphqlRequestDoc} from './graphql.js'
import {appManagementFqdn} from '../context/fqdn.js'
import {setNextDeprecationDate} from '../../../private/node/context/deprecations-store.js'
import Bottleneck from 'bottleneck'
Expand Down Expand Up @@ -32,24 +32,29 @@ async function setupRequest(orgId: string, token: string) {
* @param query - GraphQL query to execute.
* @param token - Partners token.
* @param variables - GraphQL variables to pass to the query.
* @param cacheOptions - Cache options for the request. If not present, the request will not be cached.
* @returns The response of the query of generic type <T>.
*/
export async function appManagementRequestDoc<TResult, TVariables extends Variables>(
orgId: string,
query: TypedDocumentNode<TResult, TVariables>,
token: string,
variables?: TVariables,
cacheOptions?: CacheOptions,
): Promise<TResult> {
const result = limiter.schedule<TResult>(async () =>
graphqlRequestDoc<TResult, TVariables>({
...(await setupRequest(orgId, token)),
query,
variables,
cacheTTL: cacheOptions?.cacheTTL,
cacheExtraKey: cacheOptions?.cacheExtraKey,
}),
)

return result
}

interface Deprecation {
supportedUntilDate?: string
}
Expand Down
10 changes: 9 additions & 1 deletion packages/cli-kit/src/public/node/api/business-platform.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Exact, GraphQLVariables, graphqlRequest, graphqlRequestDoc} from './graphql.js'
import {CacheOptions, Exact, GraphQLVariables, graphqlRequest, graphqlRequestDoc} from './graphql.js'
import {handleDeprecations} from './partners.js'
import {businessPlatformFqdn} from '../context/fqdn.js'
import {TypedDocumentNode} from '@graphql-typed-document-node/core'
Expand Down Expand Up @@ -27,17 +27,21 @@ async function setupRequest(token: string) {
* @param query - GraphQL query to execute.
* @param token - Business Platform token.
* @param variables - GraphQL variables to pass to the query.
* @param cacheOptions - Cache options for the request. If not present, the request will not be cached.
* @returns The response of the query of generic type <T>.
*/
export async function businessPlatformRequest<T>(
query: string,
token: string,
variables?: GraphQLVariables,
cacheOptions?: CacheOptions,
): Promise<T> {
return graphqlRequest<T>({
...(await setupRequest(token)),
query,
variables,
cacheTTL: cacheOptions?.cacheTTL,
cacheExtraKey: cacheOptions?.cacheExtraKey,
})
}

Expand All @@ -47,17 +51,21 @@ export async function businessPlatformRequest<T>(
* @param query - GraphQL query to execute.
* @param token - Business Platform token.
* @param variables - GraphQL variables to pass to the query.
* @param cacheOptions - Cache options for the request. If not present, the request will not be cached.
* @returns The response of the query of generic type <TResult>.
*/
export async function businessPlatformRequestDoc<TResult, TVariables extends Variables>(
query: TypedDocumentNode<TResult, TVariables>,
token: string,
variables?: TVariables,
cacheOptions?: CacheOptions,
): Promise<TResult> {
return graphqlRequestDoc<TResult, TVariables>({
...(await setupRequest(token)),
query,
variables,
cacheTTL: cacheOptions?.cacheTTL,
cacheExtraKey: cacheOptions?.cacheExtraKey,
})
}

Expand Down
72 changes: 72 additions & 0 deletions packages/cli-kit/src/public/node/api/graphql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as debugRequest from '../../../private/node/api/graphql.js'
import {buildHeaders} from '../../../private/node/api/headers.js'
import {requestIdsCollection} from '../../../private/node/request-ids.js'
import * as metadata from '../metadata.js'
import {cacheRetrieveOrRepopulate} from '../../../private/node/conf-store.js'
import {GraphQLClient} from 'graphql-request'
import {test, vi, describe, expect, beforeEach} from 'vitest'
import {TypedDocumentNode} from '@graphql-typed-document-node/core'
Expand All @@ -29,6 +30,10 @@ vi.mock('graphql-request', async () => {
})
vi.spyOn(debugRequest, 'debugLogRequestInfo').mockResolvedValue(undefined)

vi.mock('../../../private/node/conf-store.js', () => ({
cacheRetrieveOrRepopulate: vi.fn(),
}))

const mockedAddress = 'http://localhost:3000'
const mockVariables = {some: 'variables'}
const mockToken = 'token'
Expand Down Expand Up @@ -149,3 +154,70 @@ describe('graphqlRequestDoc', () => {
)
})
})

describe('graphqlRequest with caching', () => {
test('uses cache when TTL is provided', async () => {
// Given
const mockQueryHash = '84f38895-31ed-05b5-0d7b-dbf2f1eecd46e2580db0'
const mockVariablesHash = 'e6959ad8-4a7c-c23d-e7b8-be1ae774e05751514949'

vi.mocked(cacheRetrieveOrRepopulate).mockResolvedValue(JSON.stringify({data: 'cached-response'}))

// When
await graphqlRequest({
query: 'query',
api: 'mockApi',
url: mockedAddress,
token: mockToken,
variables: mockVariables,
cacheTTL: '1h',
cacheExtraKey: 'extra',
})

// Then
expect(cacheRetrieveOrRepopulate).toHaveBeenCalledWith(
`q-${mockQueryHash}-${mockVariablesHash}-extra`,
expect.any(Function),
1000 * 60 * 60,
)
})

test('uses cache key when no extra key provided', async () => {
// Given
const mockQueryHash = '84f38895-31ed-05b5-0d7b-dbf2f1eecd46e2580db0'
const mockVariablesHash = 'e6959ad8-4a7c-c23d-e7b8-be1ae774e05751514949'

vi.mocked(cacheRetrieveOrRepopulate).mockResolvedValue(JSON.stringify({data: 'cached-response'}))

// When
await graphqlRequest({
query: 'query',
api: 'mockApi',
url: mockedAddress,
token: mockToken,
variables: mockVariables,
cacheTTL: '1d',
})

// Then
expect(cacheRetrieveOrRepopulate).toHaveBeenCalledWith(
`q-${mockQueryHash}-${mockVariablesHash}-`,
expect.any(Function),
1000 * 60 * 60 * 24,
)
})

test('skips cache when no TTL is provided', async () => {
// When
await graphqlRequest({
query: 'query',
api: 'mockApi',
url: mockedAddress,
token: mockToken,
variables: mockVariables,
})

// Then
expect(cacheRetrieveOrRepopulate).not.toHaveBeenCalled()
})
})
79 changes: 68 additions & 11 deletions packages/cli-kit/src/public/node/api/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {debugLogRequestInfo, errorHandler} from '../../../private/node/api/graph
import {addPublicMetadata, runWithTimer} from '../metadata.js'
import {retryAwareRequest} from '../../../private/node/api.js'
import {requestIdsCollection} from '../../../private/node/request-ids.js'
import {nonRandomUUID} from '../crypto.js'
import {cacheRetrieveOrRepopulate, GraphQLRequestKey} from '../../../private/node/conf-store.js'
import {
GraphQLClient,
rawRequest,
Expand All @@ -23,12 +25,20 @@ export interface GraphQLVariables {

export type GraphQLResponse<T> = Awaited<ReturnType<typeof rawRequest<T>>>

export interface CacheOptions {
cacheTTL?: CacheTTL
cacheExtraKey?: string
}
export type CacheTTL = '1h' | '6h' | '12h' | '1d' | '3d' | '7d' | '14d' | '30d'

interface GraphQLRequestBaseOptions<TResult> {
api: string
url: string
token?: string
addedHeaders?: {[header: string]: string}
responseOptions?: GraphQLResponseOptions<TResult>
cacheTTL?: CacheTTL
cacheExtraKey?: string
}

type PerformGraphQLRequestOptions<TResult> = GraphQLRequestBaseOptions<TResult> & {
Expand Down Expand Up @@ -88,19 +98,43 @@ async function performGraphQLRequest<TResult>(options: PerformGraphQLRequestOpti
}
}

return runWithTimer('cmd_all_timing_network_ms')(async () => {
const response = await retryAwareRequest(
{request: performRequest, url},
responseOptions?.handleErrors === false ? undefined : errorHandler(api),
unauthorizedHandler,
)
const executeWithTimer = () =>
runWithTimer('cmd_all_timing_network_ms')(async () => {
const response = await retryAwareRequest(
{request: performRequest, url},
responseOptions?.handleErrors === false ? undefined : errorHandler(api),
unauthorizedHandler,
)

if (responseOptions?.onResponse) {
responseOptions.onResponse(response)
}
if (responseOptions?.onResponse) {
responseOptions.onResponse(response)
}

return response.data
})
return response.data
})

const {cacheTTL, cacheExtraKey} = options

// If there is no cache config for this query, just execute it and return the result.
if (cacheTTL === undefined) {
return executeWithTimer()
}

// The cache key is a combination of the hashed query and variables, with an optional extra key provided by the user.
const queryHash = nonRandomUUID(queryAsString)
const variablesHash = nonRandomUUID(JSON.stringify(variables ?? {}))
const cacheKey: GraphQLRequestKey = `q-${queryHash}-${variablesHash}-${cacheExtraKey ?? ''}`

const result = await cacheRetrieveOrRepopulate(
cacheKey,
async () => {
const result = await executeWithTimer()
return JSON.stringify(result)
},
cacheTTLToMs(cacheTTL),
)

return JSON.parse(result) as TResult
}

async function logLastRequestIdFromResponse(response: GraphQLResponse<unknown>) {
Expand Down Expand Up @@ -143,3 +177,26 @@ export async function graphqlRequestDoc<TResult, TVariables extends Variables>(
queryAsString: resolveRequestDocument(options.query).query,
})
}

function cacheTTLToMs(cacheTTL: CacheTTL) {
const oneHour = 1000 * 60 * 60
const oneDay = 1000 * 60 * 60 * 24
switch (cacheTTL) {
case '1h':
return oneHour
case '6h':
return oneHour * 6
case '12h':
return oneHour * 12
case '1d':
return oneDay
case '3d':
return oneDay * 3
case '7d':
return oneDay * 7
case '14d':
return oneDay * 14
case '30d':
return oneDay * 30
}
}

0 comments on commit 162fead

Please sign in to comment.