Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[sitecore-jss][nextjs] Use site query to retrieve dictionary data in XMCloud #1804

Merged
merged 12 commits into from
Jul 18, 2024
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Our versioning strategy is as follows:

* `[sitecore-jss-nextjs]` Enforce CORS policy that matches Sitecore Pages domains for editing middleware API endpoints ([#1798](https://github.com/Sitecore/jss/pull/1798)[#1801](https://github.com/Sitecore/jss/pull/1801))

* `[nextjs-xmcloud]` DictionaryService can now use a `site` GraphQL query instead of `search` one to improve performance. This is currently only available for XMCloud deployments and is enabled with `nextjs-xmcloud` add-on by default ([#1804](https://github.com/Sitecore/jss/pull/1804))
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved

### 🛠 Breaking Change

* Editing Integration Support: ([#1776](https://github.com/Sitecore/jss/pull/1776))([#1792](https://github.com/Sitecore/jss/pull/1792))([#1773](https://github.com/Sitecore/jss/pull/1773))([#1797](https://github.com/Sitecore/jss/pull/1797))([#1800](https://github.com/Sitecore/jss/pull/1800))
Expand Down
10 changes: 10 additions & 0 deletions docs/upgrades/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,13 @@
</>
);
```

* Add a `useSiteQuery` parameter when `GraphQLDictionaryService` is initialized in `/src/lib/dictionary-service-factory.ts` :
```
new GraphQLDictionaryService({
siteName,
clientFactory,
.....
useSiteQuery: true,
})
```
Original file line number Diff line number Diff line change
@@ -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}'
*/
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
/*
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,
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
})
: new RestDictionaryService({
apiHost: config.sitecoreApiHost,
apiKey: config.sitecoreApiKey,
siteName,
});
}
}

/** DictionaryServiceFactory singleton */
export const dictionaryServiceFactory = new DictionaryServiceFactory();
2 changes: 2 additions & 0 deletions packages/sitecore-jss/src/graphql/search-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -76,6 +77,7 @@ export interface SearchServiceConfig extends Omit<SearchQueryVariables, 'languag
}

/**
* @deprecated use GraphQLClient instead
* Provides functionality for performing GraphQL 'search' operations, including handling pagination.
* This class is meant to be extended or used as a mixin; it's not meant to be used directly.
* @template T The type of objects being requested.
Expand Down
84 changes: 84 additions & 0 deletions packages/sitecore-jss/src/i18n/graphql-dictionary-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { GraphQLClient, GraphQLRequestClient } from '../graphql-request-client';
import { queryError, GraphQLDictionaryServiceConfig } from './graphql-dictionary-service';
import { GraphQLDictionaryService } from '.';
import dictionaryQueryResponse from '../test-data/mockDictionaryQueryResponse.json';
import dictionarySiteQueryResponse from '../test-data/mockDictionarySiteQueryResponse.json';
import appRootQueryResponse from '../test-data/mockAppRootQueryResponse.json';

class TestService extends GraphQLDictionaryService {
Expand Down Expand Up @@ -270,4 +271,87 @@ describe('GraphQLDictionaryService', () => {
expect(calledWithArgs.retries).to.equal(mockServiceConfig.retries);
expect(calledWithArgs.retryStrategy).to.deep.equal(mockServiceConfig.retryStrategy);
});

describe('with site query', () => {
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
it('should fetch dictionary phrases using clientFactory', async () => {
nock(endpoint, { reqheaders: { sc_apikey: apiKey } })
.post('/')
.reply(200, dictionarySiteQueryResponse.singlepage);

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)
.persist()
.post(
'/',
(body) =>
body.query.indexOf('$pageSize: Int = 10') > 0 && body.variables.pageSize === undefined
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
)
.reply(200, dictionarySiteQueryResponse.singlepage);

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 = 1;

nock(endpoint)
.post('/', (body) => body.variables.pageSize === customPageSize)
.reply(200, dictionarySiteQueryResponse.multipage.page1)
.post(
'/',
(body) =>
body.variables.pageSize === customPageSize && body.variables.after === 'nextpage'
)
.reply(200, dictionarySiteQueryResponse.multipage.page2);

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', 'baz');
});

it('should throw when getting http errors', async () => {
nock(endpoint)
.post('/')
.reply(401, {
error: 'whoops',
});

const service = new GraphQLDictionaryService({
clientFactory,
siteName,
cacheEnabled: false,
useSiteQuery: true,
});

await service.fetchDictionaryData('en').catch((error) => {
expect(error.response.status).to.equal(401);
expect(error.response.error).to.equal('whoops');
});
});
});
});
106 changes: 101 additions & 5 deletions packages/sitecore-jss/src/i18n/graphql-dictionary-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
import { SitecoreTemplateId } from '../constants';
import { DictionaryPhrases, DictionaryServiceBase } from './dictionary-service';
import { CacheOptions } from '../cache-client';
import { getAppRootId, SearchServiceConfig, SearchQueryService } from '../graphql';
import { getAppRootId, SearchServiceConfig, SearchQueryService, PageInfo } from '../graphql';
import debug from '../debug';

/** @private */
Expand Down Expand Up @@ -49,13 +49,44 @@ 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) {
total
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
pageInfo {
endCursor
hasNext
}
results {
key
value
}
}
}
}
}
`;

/**
* Configuration options for @see GraphQLDictionaryService instances
*/
export interface GraphQLDictionaryServiceConfig
extends SearchServiceConfig,
extends Omit<SearchServiceConfig, 'siteName'>,
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
CacheOptions,
Pick<GraphQLRequestClientConfig, 'retries' | 'retryStrategy'> {
/**
* 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.
Expand All @@ -73,6 +104,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;
}

/**
Expand All @@ -83,6 +119,17 @@ export type DictionaryQueryResult = {
phrase: { value: string };
};

export type DictionarySiteQueryResponse = {
site: {
siteInfo: {
dictionary: {
results: { key: string; value: string }[];
pageInfo: PageInfo;
};
};
};
};

/**
* Service that fetch dictionary data using Sitecore's GraphQL API.
* @augments DictionaryServiceBase
Expand All @@ -103,9 +150,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<DictionaryPhrases>} dictionary phrases
* @throws {Error} if the app root was not found for the specified site and language.
*/
Expand All @@ -117,6 +163,23 @@ export class GraphQLDictionaryService extends DictionaryServiceBase {
return cachedValue;
}

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<DictionaryPhrases>} dictionary phrases
* @throws {Error} if the app root was not found for the specified site and language.
*/
async fetchWithSearchQuery(language: string): Promise<DictionaryPhrases> {
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
Expand Down Expand Up @@ -146,7 +209,40 @@ 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<DictionaryPhrases>} dictionary phrases
*/
async fetchWithSiteQuery(language: string): Promise<DictionaryPhrases> {
const phrases: DictionaryPhrases = {};
debug.dictionary('fetching dictionary data for %s %s', language, this.options.siteName);
let results: { key: string; value: string }[] = [];
let hasNext = true;
let after = '';

while (hasNext) {
const fetchResponse = await this.graphQLClient.request<DictionarySiteQueryResponse>(
siteQuery,
{
siteName: this.options.siteName,
language,
pageSize: this.options.pageSize,
after,
}
);

results = results.concat(fetchResponse?.site.siteInfo.dictionary.results);
hasNext = fetchResponse.site.siteInfo.dictionary.pageInfo.hasNext;
after = fetchResponse.site.siteInfo.dictionary.pageInfo.endCursor;
}
results.forEach((item) => (phrases[item.key] = item.value));

return phrases;
}

Expand Down
Loading