From 17574e7f1818f6ec0e32ea2b0a91495c8330a9ed Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Thu, 23 May 2024 17:23:21 -0400 Subject: [PATCH] [sitecore-jss][nextjs] Use site query to retrieve dictionary data in XMCloud --- .../src/lib/dictionary-service-factory.ts | 53 ++++++++++++ .../src/graphql/search-service.ts | 2 + .../i18n/graphql-dictionary-service.test.ts | 59 ++++++++++++- .../src/i18n/graphql-dictionary-service.ts | 82 ++++++++++++++++++- .../mockDictionarySiteQueryResponse.json | 18 ++++ 5 files changed, 209 insertions(+), 5 deletions(-) create mode 100644 packages/create-sitecore-jss/src/templates/nextjs-xmcloud/src/lib/dictionary-service-factory.ts create mode 100644 packages/sitecore-jss/src/test-data/mockDictionarySiteQueryResponse.json diff --git a/packages/create-sitecore-jss/src/templates/nextjs-xmcloud/src/lib/dictionary-service-factory.ts b/packages/create-sitecore-jss/src/templates/nextjs-xmcloud/src/lib/dictionary-service-factory.ts new file mode 100644 index 0000000000..436b86cce6 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/nextjs-xmcloud/src/lib/dictionary-service-factory.ts @@ -0,0 +1,53 @@ +import { + DictionaryService, + RestDictionaryService, + GraphQLDictionaryService, + constants, +} from '@sitecore-jss/sitecore-jss-nextjs'; +import config from 'temp/config'; +import clientFactory from 'lib/graphql-client-factory'; + +/** + * Factory responsible for creating a DictionaryService instance + */ +export class DictionaryServiceFactory { + /** + * @param {string} siteName site name + * @returns {DictionaryService} service instance + */ + create(siteName: string): DictionaryService { + return process.env.FETCH_WITH === constants.FETCH_WITH.GRAPHQL + ? new GraphQLDictionaryService({ + siteName, + clientFactory, + /* + The Dictionary Service needs a root item ID in order to fetch dictionary phrases for the current app. + When not provided, the service will attempt to figure out the root item for the current JSS App using GraphQL and app name. + For SXA site(s) and multisite setup there's no need to specify it - it will be autoresolved. + Otherwise, if your Sitecore instance only has 1 JSS App (i.e. in a Sitecore XP setup), you can specify the root item ID here. + rootItemId: '{GUID}' + */ + /* + GraphQL endpoint may reach its rate limit with the amount of requests it receives and throw a rate limit error. + GraphQL Dictionary and Layout Services can handle rate limit errors from server and attempt a retry on requests. + For this, specify the number of 'retries' the GraphQL client will attempt. + By default it is set to 3. You can disable it by configuring it to 0 for this service. + + Additionally, you have the flexibility to customize the retry strategy by passing a 'retryStrategy'. + By default it uses the `DefaultRetryStrategy` with exponential back-off factor of 2 and handles error codes 429, + 502, 503, 504, 520, 521, 522, 523, 524, 'ECONNRESET', 'ETIMEDOUT' and 'EPROTO' . You can use this class or your own implementation of `RetryStrategy`. + */ + retries: (process.env.GRAPH_QL_SERVICE_RETRIES && + parseInt(process.env.GRAPH_QL_SERVICE_RETRIES, 10)) as number, + useSiteQuery: true, + }) + : new RestDictionaryService({ + apiHost: config.sitecoreApiHost, + apiKey: config.sitecoreApiKey, + siteName, + }); + } +} + +/** DictionaryServiceFactory singleton */ +export const dictionaryServiceFactory = new DictionaryServiceFactory(); diff --git a/packages/sitecore-jss/src/graphql/search-service.ts b/packages/sitecore-jss/src/graphql/search-service.ts index 4db6e44d71..35cf443eb9 100644 --- a/packages/sitecore-jss/src/graphql/search-service.ts +++ b/packages/sitecore-jss/src/graphql/search-service.ts @@ -61,6 +61,7 @@ export interface SearchQueryVariables { } /** + * @deprecated will be removed with SearchQueryService. Use GraphQLClient and supporting types * Configuration options for service classes that extend @see SearchQueryService. * This extends @see SearchQueryVariables because properties that can be passed to the search query * as predicates should be configurable. 'language' is excluded because, normally, all properties @@ -76,6 +77,7 @@ export interface SearchServiceConfig extends Omit { +describe.only('GraphQLDictionaryService', () => { const endpoint = 'http://site'; const siteName = 'site-name'; const apiKey = 'api-key'; @@ -270,4 +271,60 @@ describe('GraphQLDictionaryService', () => { expect(calledWithArgs.retries).to.equal(mockServiceConfig.retries); expect(calledWithArgs.retryStrategy).to.deep.equal(mockServiceConfig.retryStrategy); }); + + describe('with site query', () => { + it('should fetch dictionary phrases using clientFactory', async () => { + nock(endpoint, { reqheaders: { sc_apikey: apiKey } }) + .post('/', /DictionarySiteQuery/gi) + .reply(200, dictionarySiteQueryResponse); + + const service = new GraphQLDictionaryService({ + siteName, + cacheEnabled: false, + clientFactory, + useSiteQuery: true, + }); + const result = await service.fetchDictionaryData('en'); + expect(result.foo).to.equal('foo'); + expect(result.bar).to.equal('bar'); + }); + + it('should use default pageSize, if pageSize not provided', async () => { + nock(endpoint) + .post( + '/', + (body) => + body.query.indexOf('$pageSize: Int = 10') > 0 && body.variables.pageSize === undefined + ) + .reply(200, dictionarySiteQueryResponse); + + const service = new GraphQLDictionaryService({ + clientFactory, + siteName, + cacheEnabled: false, + pageSize: undefined, + useSiteQuery: true, + }); + const result = await service.fetchDictionaryData('en'); + expect(result).to.have.all.keys('foo', 'bar'); + }); + + it('should use a custom pageSize, if provided', async () => { + const customPageSize = 2; + + nock(endpoint) + .post('/', (body) => body.variables.pageSize === customPageSize) + .reply(200, dictionarySiteQueryResponse); + + const service = new GraphQLDictionaryService({ + clientFactory, + siteName, + cacheEnabled: false, + pageSize: customPageSize, + useSiteQuery: true, + }); + const result = await service.fetchDictionaryData('en'); + expect(result).to.have.all.keys('foo', 'bar'); + }); + }); }); diff --git a/packages/sitecore-jss/src/i18n/graphql-dictionary-service.ts b/packages/sitecore-jss/src/i18n/graphql-dictionary-service.ts index c4ec11bd95..aed0e776d7 100644 --- a/packages/sitecore-jss/src/i18n/graphql-dictionary-service.ts +++ b/packages/sitecore-jss/src/i18n/graphql-dictionary-service.ts @@ -49,13 +49,39 @@ const query = /* GraphQL */ ` } `; +const siteQuery = /* GraphQL */ ` + query DictionarySiteQuery( + $siteName: String! + $language: String! + $pageSize: Int = 10 + $after: String + ) { + site { + siteInfo(site: $siteName) { + dictionary(language: $language, first: $pageSize, after: $after) [ + { + key + value + } + ] + } + } + } +`; + /** * Configuration options for @see GraphQLDictionaryService instances */ export interface GraphQLDictionaryServiceConfig - extends SearchServiceConfig, + extends Omit, CacheOptions, Pick { + /** + * The name of the current Sitecore site. This is used to to determine the search query root + * in cases where one is not specified by the caller. + */ + siteName: string; + /** * A GraphQL Request Client Factory is a function that accepts configuration and returns an instance of a GraphQLRequestClient. * This factory function is used to create and configure GraphQL clients for making GraphQL API requests. @@ -73,6 +99,11 @@ export interface GraphQLDictionaryServiceConfig * @default '061cba1554744b918a0617903b102b82' (/sitecore/templates/Foundation/JavaScript Services/App) */ jssAppTemplateId?: string; + + /** + * Optional. Use site query for dictionary fetch instead of search query (XM Cloud only) + */ + useSiteQuery?: boolean; } /** @@ -83,6 +114,14 @@ export type DictionaryQueryResult = { phrase: { value: string }; }; +export type DistionarySiteQueryResponse = { + site: { + siteInfo: { + dictionary: { key: string; value: string }[]; + }; + }; +}; + /** * Service that fetch dictionary data using Sitecore's GraphQL API. * @augments DictionaryServiceBase @@ -103,9 +142,8 @@ export class GraphQLDictionaryService extends DictionaryServiceBase { } /** - * Fetches dictionary data for internalization. + * Fetches dictionary data for internalization. Uses search query by default * @param {string} language the language to fetch - * @default query (@see query) * @returns {Promise} dictionary phrases * @throws {Error} if the app root was not found for the specified site and language. */ @@ -117,6 +155,24 @@ export class GraphQLDictionaryService extends DictionaryServiceBase { return cachedValue; } + debug.dictionary('fetching site root for %s %s', language, this.options.siteName); + const phrases = this.options.useSiteQuery + ? await this.fetchWithSiteQuery(language) + : await this.fetchWithSearchQuery(language); + + this.setCacheValue(cacheKey, phrases); + return phrases; + } + + /** + * Fetches dictionary data with search query + * This is the default behavior for non-XMCloud deployments + * @param {string} language the language to fetch + * @default query (@see query) + * @returns {Promise} dictionary phrases + * @throws {Error} if the app root was not found for the specified site and language. + */ + async fetchWithSearchQuery(language: string): Promise { debug.dictionary('fetching site root for %s %s', language, this.options.siteName); // If the caller does not specify a root item ID, then we try to figure it out @@ -146,7 +202,25 @@ export class GraphQLDictionaryService extends DictionaryServiceBase { results.forEach((item) => (phrases[item.key.value] = item.phrase.value)); }); - this.setCacheValue(cacheKey, phrases); + return phrases; + } + + /** + * Fetches dictionary data with site query + * This is the default behavior for XMCloud deployments + * @param {string} language the language to fetch + * @default siteQuery (@see siteQuery) + * @returns {Promise} dictionary phrases + */ + async fetchWithSiteQuery(language: string): Promise { + const phrases: DictionaryPhrases = {}; + debug.dictionary('fetching dictionary data for %s %s', language, this.options.siteName); + const results = await this.graphQLClient.request(siteQuery, { + siteName: this.options.siteName, + language, + pageSize: this.options.pageSize, + }); + results.site.siteInfo.dictionary.forEach((item) => (phrases[item.key] = item.value)); return phrases; } diff --git a/packages/sitecore-jss/src/test-data/mockDictionarySiteQueryResponse.json b/packages/sitecore-jss/src/test-data/mockDictionarySiteQueryResponse.json new file mode 100644 index 0000000000..5ca431aa4e --- /dev/null +++ b/packages/sitecore-jss/src/test-data/mockDictionarySiteQueryResponse.json @@ -0,0 +1,18 @@ +{ + "data": { + "site": { + "siteInfo": { + "dictionary": [ + { + "key": "foo", + "value": "foo" + }, + { + "key": "bar", + "value": "bar" + } + ] + } + } + } + } \ No newline at end of file