@dotcms/client 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.eslintrc.json +18 -0
  2. package/CLAUDE.md +253 -0
  3. package/MIGRATION.md +329 -0
  4. package/README.md +250 -434
  5. package/jest.config.ts +15 -0
  6. package/package.json +8 -25
  7. package/project.json +73 -0
  8. package/src/lib/client/client.spec.ts +147 -0
  9. package/src/lib/client/client.ts +125 -0
  10. package/src/lib/client/content/builders/collection/collection.spec.ts +514 -0
  11. package/src/lib/client/content/builders/collection/{collection.d.ts → collection.ts} +210 -19
  12. package/src/lib/client/content/builders/query/lucene-syntax/{Equals.d.ts → Equals.ts} +45 -11
  13. package/src/lib/client/content/builders/query/lucene-syntax/{Field.d.ts → Field.ts} +13 -5
  14. package/src/lib/client/content/builders/query/lucene-syntax/{NotOperand.d.ts → NotOperand.ts} +13 -5
  15. package/src/lib/client/content/builders/query/lucene-syntax/{Operand.d.ts → Operand.ts} +21 -7
  16. package/src/lib/client/content/builders/query/query.spec.ts +159 -0
  17. package/src/lib/client/content/builders/query/{query.d.ts → query.ts} +16 -5
  18. package/src/lib/client/content/builders/query/utils/{index.d.ts → index.ts} +49 -12
  19. package/src/lib/client/content/{content-api.d.ts → content-api.ts} +14 -4
  20. package/src/lib/client/content/shared/{const.d.ts → const.ts} +5 -3
  21. package/src/lib/client/content/shared/{types.d.ts → types.ts} +18 -2
  22. package/src/lib/client/content/shared/{utils.d.ts → utils.ts} +9 -1
  23. package/src/lib/client/models/{index.d.ts → index.ts} +8 -1
  24. package/src/lib/client/navigation/navigation-api.spec.ts +167 -0
  25. package/src/lib/client/navigation/navigation-api.ts +62 -0
  26. package/src/lib/client/page/page-api.spec.ts +359 -0
  27. package/src/lib/client/page/page-api.ts +197 -0
  28. package/src/lib/client/page/utils.ts +291 -0
  29. package/src/lib/utils/graphql/transforms.spec.ts +250 -0
  30. package/src/lib/utils/graphql/transforms.ts +128 -0
  31. package/tsconfig.json +22 -0
  32. package/tsconfig.lib.json +13 -0
  33. package/tsconfig.spec.json +9 -0
  34. package/index.cjs.d.ts +0 -1
  35. package/index.cjs.default.js +0 -1
  36. package/index.cjs.js +0 -1591
  37. package/index.cjs.mjs +0 -2
  38. package/index.esm.d.ts +0 -1
  39. package/index.esm.js +0 -1589
  40. package/internal.cjs.d.ts +0 -1
  41. package/internal.cjs.default.js +0 -1
  42. package/internal.cjs.js +0 -85
  43. package/internal.cjs.mjs +0 -2
  44. package/internal.esm.d.ts +0 -1
  45. package/internal.esm.js +0 -83
  46. package/src/lib/client/client.d.ts +0 -56
  47. package/src/lib/client/navigation/navigation-api.d.ts +0 -14
  48. package/src/lib/client/page/page-api.d.ts +0 -95
  49. package/src/lib/client/page/utils.d.ts +0 -41
  50. package/src/lib/utils/graphql/transforms.d.ts +0 -13
  51. /package/src/{index.d.ts → index.ts} +0 -0
  52. /package/src/{internal.d.ts → internal.ts} +0 -0
  53. /package/src/lib/client/content/builders/query/lucene-syntax/{index.d.ts → index.ts} +0 -0
  54. /package/src/lib/utils/{index.d.ts → index.ts} +0 -0
@@ -0,0 +1,62 @@
1
+ import {
2
+ DotCMSClientConfig,
3
+ DotCMSNavigationRequestParams,
4
+ RequestOptions,
5
+ DotCMSNavigationItem
6
+ } from '@dotcms/types';
7
+
8
+ export class NavigationClient {
9
+ private requestOptions: RequestOptions;
10
+
11
+ private BASE_URL: string;
12
+
13
+ constructor(config: DotCMSClientConfig, requestOptions: RequestOptions) {
14
+ this.requestOptions = requestOptions;
15
+ this.BASE_URL = `${config?.dotcmsUrl}/api/v1/nav`;
16
+ }
17
+
18
+ /**
19
+ * Retrieves information about the dotCMS file and folder tree.
20
+ * @param {NavigationApiOptions} options - The options for the Navigation API call. Defaults to `{ depth: 0, path: '/', languageId: 1 }`.
21
+ * @returns {Promise<DotCMSNavigationItem[]>} - A Promise that resolves to the response from the DotCMS API.
22
+ * @throws {Error} - Throws an error if the options are not valid.
23
+ */
24
+ async get(
25
+ path: string,
26
+ params?: DotCMSNavigationRequestParams
27
+ ): Promise<DotCMSNavigationItem[]> {
28
+ if (!path) {
29
+ throw new Error("The 'path' parameter is required for the Navigation API");
30
+ }
31
+
32
+ const navParams = params ? this.mapToBackendParams(params) : {};
33
+ const urlParams = new URLSearchParams(navParams).toString();
34
+
35
+ const parsedPath = path.replace(/^\/+/, '/').replace(/\/+$/, '/');
36
+ const url = `${this.BASE_URL}${parsedPath}${urlParams ? `?${urlParams}` : ''}`;
37
+
38
+ const response = await fetch(url, this.requestOptions);
39
+
40
+ if (!response.ok) {
41
+ throw new Error(
42
+ `Failed to fetch navigation data: ${response.statusText} - ${response.status}`
43
+ );
44
+ }
45
+
46
+ return response.json().then((data) => data.entity);
47
+ }
48
+
49
+ private mapToBackendParams(params: DotCMSNavigationRequestParams): Record<string, string> {
50
+ const backendParams: Record<string, string> = {};
51
+
52
+ if (params.depth) {
53
+ backendParams['depth'] = String(params.depth);
54
+ }
55
+
56
+ if (params.languageId) {
57
+ backendParams['language_id'] = String(params.languageId);
58
+ }
59
+
60
+ return backendParams;
61
+ }
62
+ }
@@ -0,0 +1,359 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ jest.mock('consola');
3
+
4
+ import * as consola from 'consola';
5
+
6
+ import {
7
+ DotCMSClientConfig,
8
+ DotCMSPageRequestParams,
9
+ RequestOptions,
10
+ DotCMSGraphQLPageResponse,
11
+ DotCMSPageResponse
12
+ } from '@dotcms/types';
13
+
14
+ import { PageClient } from './page-api';
15
+ import * as utils from './utils';
16
+
17
+ import { graphqlToPageEntity } from '../../utils';
18
+
19
+ describe('PageClient', () => {
20
+ const mockFetchGraphQL = jest.fn();
21
+
22
+ const validConfig: DotCMSClientConfig = {
23
+ dotcmsUrl: 'https://demo.dotcms.com',
24
+ authToken: 'test-token',
25
+ siteId: 'test-site'
26
+ };
27
+
28
+ const requestOptions: RequestOptions = {
29
+ headers: {
30
+ Authorization: 'Bearer test-token'
31
+ }
32
+ };
33
+
34
+ const mockGraphQLResponse = {
35
+ data: {
36
+ page: {
37
+ title: 'GraphQL Page',
38
+ url: '/graphql-page'
39
+ },
40
+ testContent: {
41
+ items: [{ title: 'Content Item' }]
42
+ },
43
+ testNav: {
44
+ items: [{ label: 'Nav Item', url: '/nav' }]
45
+ }
46
+ },
47
+ errors: null
48
+ };
49
+
50
+ beforeEach(() => {
51
+ mockFetchGraphQL.mockReset();
52
+ global.console.error = jest.fn(); // Mock console.error to prevent actual errors from being logged in the console when running tests
53
+
54
+ jest.spyOn(utils, 'fetchGraphQL').mockImplementation(mockFetchGraphQL);
55
+ mockFetchGraphQL.mockResolvedValue(mockGraphQLResponse);
56
+
57
+ jest.spyOn(utils, 'buildPageQuery').mockReturnValue('mock-page-query');
58
+ jest.spyOn(utils, 'buildQuery').mockReturnValue('mock-query');
59
+ jest.spyOn(utils, 'mapResponseData').mockImplementation((data, keys) => {
60
+ const result: Record<string, any> = {};
61
+
62
+ keys.forEach((key) => {
63
+ result[key] = data[`test${key.charAt(0).toUpperCase() + key.slice(1)}`];
64
+ });
65
+
66
+ return result;
67
+ });
68
+ });
69
+
70
+ afterEach(() => {
71
+ jest.restoreAllMocks();
72
+ });
73
+
74
+ describe('GraphQL API', () => {
75
+ it('should fetch page using GraphQL when query option is provided', async () => {
76
+ const pageClient = new PageClient(validConfig, requestOptions);
77
+
78
+ const graphQLOptions: DotCMSPageRequestParams = {
79
+ graphql: {
80
+ page: `containers {
81
+ containerContentlets {
82
+ contentlets {
83
+ ... on Banner {
84
+ title
85
+ }
86
+ }
87
+ }
88
+ }`,
89
+ content: { content: 'query Content { items { title } }' }
90
+ },
91
+ languageId: '1',
92
+ mode: 'LIVE'
93
+ };
94
+
95
+ const result = await pageClient.get('/graphql-page', graphQLOptions);
96
+
97
+ expect(utils.buildPageQuery).toHaveBeenCalled();
98
+ expect(utils.buildQuery).toHaveBeenCalledTimes(1);
99
+ expect(utils.fetchGraphQL).toHaveBeenCalledWith({
100
+ body: expect.any(String),
101
+ headers: requestOptions.headers,
102
+ baseURL: 'https://demo.dotcms.com'
103
+ });
104
+
105
+ expect(result).toEqual({
106
+ pageAsset: graphqlToPageEntity(
107
+ mockGraphQLResponse.data as unknown as DotCMSGraphQLPageResponse
108
+ ),
109
+ content: { content: mockGraphQLResponse.data.testContent },
110
+ graphql: {
111
+ query: expect.any(String),
112
+ variables: expect.any(Object)
113
+ },
114
+ vanityUrl: undefined,
115
+ runningExperimentId: undefined
116
+ });
117
+ });
118
+
119
+ it('should print graphql errors ', async () => {
120
+ const consolaSpy = jest.spyOn(consola, 'error');
121
+ const pageClient = new PageClient(validConfig, requestOptions);
122
+ const graphQLOptions = {
123
+ graphql: {
124
+ page: `containers {
125
+ containerContentlets {
126
+ contentlets {
127
+ ... on Banner {
128
+ title
129
+ }
130
+ }
131
+ }
132
+ }`,
133
+ content: { content: 'query Content { items { title } }' }
134
+ }
135
+ };
136
+
137
+ mockFetchGraphQL.mockResolvedValue({
138
+ data: {
139
+ page: {
140
+ title: 'GraphQL Page'
141
+ }
142
+ },
143
+ errors: [{ message: 'Some internal server error' }]
144
+ });
145
+
146
+ await pageClient.get('/graphql-page', graphQLOptions);
147
+
148
+ expect(consolaSpy).toHaveBeenCalledWith(
149
+ '[DotCMS GraphQL Error]: ',
150
+ 'Some internal server error'
151
+ );
152
+ });
153
+
154
+ it('should return an error if the page is not found', async () => {
155
+ const pageClient = new PageClient(validConfig, requestOptions);
156
+ const graphQLOptions = {
157
+ graphql: {
158
+ page: `containers {
159
+ containerContentlets {
160
+ contentlets {
161
+ ... on Banner {
162
+ title
163
+ }
164
+ }
165
+ }
166
+ }`,
167
+ content: { content: 'query Content { items { title } }' }
168
+ }
169
+ };
170
+
171
+ mockFetchGraphQL.mockResolvedValue({
172
+ data: {
173
+ page: null
174
+ },
175
+ errors: [{ message: 'No page data found' }]
176
+ });
177
+
178
+ try {
179
+ await pageClient.get('/graphql-page', graphQLOptions);
180
+ } catch (response: unknown) {
181
+ const responseData = response as DotCMSPageResponse;
182
+
183
+ expect(responseData.error?.message).toBe('No page data found');
184
+ }
185
+ });
186
+
187
+ it('should add leading slash to url if it does not have it', async () => {
188
+ const pageClient = new PageClient(validConfig, requestOptions);
189
+ const graphQLOptions = {
190
+ graphql: {
191
+ page: `containers {
192
+ containerContentlets {
193
+ contentlets {
194
+ ... on Banner {
195
+ title
196
+ }
197
+ }
198
+ }
199
+ }`,
200
+ content: { content: 'query Content { items { title } }' }
201
+ }
202
+ };
203
+
204
+ // No leading slash
205
+ const result = await pageClient.get('graphql-page', graphQLOptions as any);
206
+
207
+ expect(result).toEqual({
208
+ pageAsset: graphqlToPageEntity(
209
+ mockGraphQLResponse.data as unknown as DotCMSGraphQLPageResponse
210
+ ),
211
+ content: { content: mockGraphQLResponse.data.testContent },
212
+ graphql: {
213
+ query: expect.any(String),
214
+ variables: expect.objectContaining({
215
+ url: '/graphql-page'
216
+ })
217
+ },
218
+ vanityUrl: undefined,
219
+ runningExperimentId: undefined
220
+ });
221
+ });
222
+
223
+ it('should pass correct variables to GraphQL query', async () => {
224
+ const pageClient = new PageClient(validConfig, requestOptions);
225
+ const graphQLOptions = {
226
+ graphql: {
227
+ page: `containers {
228
+ containerContentlets {
229
+ contentlets {
230
+ ... on Banner {
231
+ title
232
+ }
233
+ }
234
+ }
235
+ }`,
236
+ content: { content: 'query Content { items { title } }' }
237
+ },
238
+ languageId: '2',
239
+ mode: 'PREVIEW_MODE'
240
+ };
241
+
242
+ await pageClient.get('/custom-page', graphQLOptions as any);
243
+
244
+ const requestBody = JSON.parse(mockFetchGraphQL.mock.calls[0][0].body);
245
+ expect(requestBody.variables).toEqual({
246
+ url: '/custom-page',
247
+ mode: 'PREVIEW_MODE',
248
+ languageId: '2',
249
+ fireRules: false,
250
+ siteId: 'test-site'
251
+ });
252
+ });
253
+
254
+ it('should handle GraphQL errors', async () => {
255
+ mockFetchGraphQL.mockRejectedValue(new Error('GraphQL error'));
256
+
257
+ const pageClient = new PageClient(validConfig, requestOptions);
258
+ const graphQLOptions = {
259
+ graphql: {
260
+ page: `containers {
261
+ containerContentlets {
262
+ contentlets {
263
+ ... on Banner {
264
+ title
265
+ }
266
+ }
267
+ }
268
+ }`,
269
+ content: { content: 'query Content { items { title } }' }
270
+ }
271
+ };
272
+ try {
273
+ await pageClient.get('/page', graphQLOptions);
274
+ } catch (error: any) {
275
+ expect(error.message).toBe('Failed to retrieve page data');
276
+ }
277
+ });
278
+
279
+ it('should throw errors from GraphQL', async () => {
280
+ mockFetchGraphQL.mockResolvedValue({
281
+ errors: [{ message: 'GraphQL error' }]
282
+ });
283
+
284
+ const pageClient = new PageClient(validConfig, requestOptions);
285
+ const graphQLOptions = {
286
+ graphql: {
287
+ page: `containers {
288
+ containerContentlets {
289
+ contentlets {
290
+ ... on Banner {
291
+ title
292
+ }
293
+ }`,
294
+ content: { content: 'query Content { items { title } }' }
295
+ }
296
+ };
297
+ try {
298
+ await pageClient.get('/page', graphQLOptions);
299
+ } catch (error: any) {
300
+ expect(error.message).toBe('Failed to retrieve page data');
301
+ }
302
+ });
303
+
304
+ it('should use default values for languageId and mode if not provided', async () => {
305
+ const pageClient = new PageClient(validConfig, requestOptions);
306
+ const graphQLOptions = {
307
+ graphql: {
308
+ page: `containers {
309
+ containerContentlets {
310
+ contentlets {
311
+ ... on Banner {
312
+ title
313
+ }
314
+ }`,
315
+ content: { content: 'query Content { items { title } }' }
316
+ }
317
+ };
318
+ await pageClient.get('/default-page', graphQLOptions);
319
+
320
+ const requestBody = JSON.parse(mockFetchGraphQL.mock.calls[0][0].body);
321
+ expect(requestBody.variables).toEqual({
322
+ url: '/default-page',
323
+ mode: 'LIVE',
324
+ languageId: '1',
325
+ fireRules: false,
326
+ siteId: 'test-site'
327
+ });
328
+ });
329
+
330
+ it('should fetch using graphQL even if there is no graphql option', async () => {
331
+ const pageClient = new PageClient(validConfig, requestOptions);
332
+
333
+ await pageClient.get('/why-obi-wan-had-the-high-ground');
334
+
335
+ expect(mockFetchGraphQL).toHaveBeenCalled();
336
+ expect(utils.buildPageQuery).toHaveBeenCalled();
337
+ expect(utils.buildQuery).toHaveBeenCalledTimes(1);
338
+ expect(utils.fetchGraphQL).toHaveBeenCalledWith({
339
+ body: expect.stringContaining('"url":"/why-obi-wan-had-the-high-ground"'),
340
+ headers: requestOptions.headers,
341
+ baseURL: 'https://demo.dotcms.com'
342
+ });
343
+ });
344
+ });
345
+
346
+ describe('Client initialization', () => {
347
+ it('should use siteId from config when not provided in params', async () => {
348
+ const pageClient = new PageClient(validConfig, requestOptions);
349
+
350
+ await pageClient.get('/test-page');
351
+
352
+ expect(mockFetchGraphQL).toHaveBeenCalledWith({
353
+ baseURL: 'https://demo.dotcms.com',
354
+ body: expect.stringContaining('"siteId":"test-site"'),
355
+ headers: requestOptions.headers
356
+ });
357
+ });
358
+ });
359
+ });
@@ -0,0 +1,197 @@
1
+ import consola from 'consola';
2
+
3
+ import {
4
+ DotCMSClientConfig,
5
+ DotCMSComposedPageResponse,
6
+ DotCMSExtendedPageResponse,
7
+ DotCMSPageResponse,
8
+ DotCMSPageRequestParams,
9
+ RequestOptions
10
+ } from '@dotcms/types';
11
+
12
+ import { buildPageQuery, buildQuery, fetchGraphQL, mapResponseData } from './utils';
13
+
14
+ import { graphqlToPageEntity } from '../../utils';
15
+
16
+ /**
17
+ * Client for interacting with the DotCMS Page API.
18
+ * Provides methods to retrieve and manipulate pages.
19
+ */
20
+ export class PageClient {
21
+ /**
22
+ * Request options including authorization headers.
23
+ * @private
24
+ */
25
+ private requestOptions: RequestOptions;
26
+
27
+ /**
28
+ * Site ID for page requests.
29
+ * @private
30
+ */
31
+ private siteId: string;
32
+
33
+ /**
34
+ * DotCMS URL for page requests.
35
+ * @private
36
+ */
37
+ private dotcmsUrl: string;
38
+
39
+ /**
40
+ * Creates a new PageClient instance.
41
+ *
42
+ * @param {DotCMSClientConfig} config - Configuration options for the DotCMS client
43
+ * @param {RequestOptions} requestOptions - Options for fetch requests including authorization headers
44
+ * @example
45
+ * ```typescript
46
+ * const pageClient = new PageClient(
47
+ * {
48
+ * dotcmsUrl: 'https://demo.dotcms.com',
49
+ * authToken: 'your-auth-token',
50
+ * siteId: 'demo.dotcms.com'
51
+ * },
52
+ * {
53
+ * headers: {
54
+ * Authorization: 'Bearer your-auth-token'
55
+ * }
56
+ * }
57
+ * );
58
+ * ```
59
+ */
60
+ constructor(config: DotCMSClientConfig, requestOptions: RequestOptions) {
61
+ this.requestOptions = requestOptions;
62
+ this.siteId = config.siteId || '';
63
+ this.dotcmsUrl = config.dotcmsUrl;
64
+ }
65
+
66
+ /**
67
+ * Retrieves a page from DotCMS using GraphQL.
68
+ *
69
+ * @param {string} url - The URL of the page to retrieve
70
+ * @param {DotCMSPageRequestParams} [options] - Options for the request
71
+ * @template T - The type of the page and content, defaults to DotCMSBasicPage and Record<string, unknown> | unknown
72
+ * @returns {Promise<DotCMSComposedPageResponse<T>>} A Promise that resolves to the page data
73
+ *
74
+ * @example Using GraphQL
75
+ * ```typescript
76
+ * const page = await pageClient.get<{ page: MyPageWithBanners; content: { blogPosts: { blogTitle: string } } }>(
77
+ * '/index',
78
+ * {
79
+ * languageId: '1',
80
+ * mode: 'LIVE',
81
+ * graphql: {
82
+ * page: `
83
+ * containers {
84
+ * containerContentlets {
85
+ * contentlets {
86
+ * ... on Banner {
87
+ * ...bannerFragment
88
+ * }
89
+ * }
90
+ * }
91
+ * `,
92
+ * content: {
93
+ * blogPosts: `
94
+ * BlogCollection(limit: 3) {
95
+ * ...blogFragment
96
+ * }
97
+ * `,
98
+ * },
99
+ * fragments: [
100
+ * `
101
+ * fragment bannerFragment on Banner {
102
+ * caption
103
+ * }
104
+ * `,
105
+ * `
106
+ * fragment blogFragment on Blog {
107
+ * title
108
+ * urlTitle
109
+ * }
110
+ * `
111
+ * ]
112
+ * }
113
+ * });
114
+ * ```
115
+ */
116
+ async get<T extends DotCMSExtendedPageResponse = DotCMSPageResponse>(
117
+ url: string,
118
+ options?: DotCMSPageRequestParams
119
+ ): Promise<DotCMSComposedPageResponse<T>> {
120
+ const {
121
+ languageId = '1',
122
+ mode = 'LIVE',
123
+ siteId = this.siteId,
124
+ fireRules = false,
125
+ personaId,
126
+ publishDate,
127
+ variantName,
128
+ graphql = {}
129
+ } = options || {};
130
+ const { page, content = {}, variables, fragments } = graphql;
131
+
132
+ const contentQuery = buildQuery(content);
133
+ const completeQuery = buildPageQuery({
134
+ page,
135
+ fragments,
136
+ additionalQueries: contentQuery
137
+ });
138
+
139
+ const requestVariables: Record<string, unknown> = {
140
+ // The url is expected to have a leading slash to comply on VanityURL Matching, some frameworks like Angular will not add the leading slash
141
+ url: url.startsWith('/') ? url : `/${url}`,
142
+ mode,
143
+ languageId,
144
+ personaId,
145
+ fireRules,
146
+ publishDate,
147
+ siteId,
148
+ variantName,
149
+ ...variables
150
+ };
151
+
152
+ const requestHeaders = this.requestOptions.headers as Record<string, string>;
153
+ const requestBody = JSON.stringify({ query: completeQuery, variables: requestVariables });
154
+
155
+ try {
156
+ const { data, errors } = await fetchGraphQL({
157
+ baseURL: this.dotcmsUrl,
158
+ body: requestBody,
159
+ headers: requestHeaders
160
+ });
161
+
162
+ if (errors) {
163
+ errors.forEach((error: { message: string }) => {
164
+ consola.error('[DotCMS GraphQL Error]: ', error.message);
165
+ });
166
+ }
167
+
168
+ const pageResponse = graphqlToPageEntity(data);
169
+
170
+ if (!pageResponse) {
171
+ throw new Error('No page data found');
172
+ }
173
+
174
+ const contentResponse = mapResponseData(data, Object.keys(content));
175
+
176
+ return {
177
+ pageAsset: pageResponse,
178
+ content: contentResponse,
179
+ graphql: {
180
+ query: completeQuery,
181
+ variables: requestVariables
182
+ }
183
+ };
184
+ } catch (error) {
185
+ const errorMessage = {
186
+ error,
187
+ message: 'Failed to retrieve page data',
188
+ graphql: {
189
+ query: completeQuery,
190
+ variables: requestVariables
191
+ }
192
+ };
193
+
194
+ throw errorMessage;
195
+ }
196
+ }
197
+ }