diff --git a/.eslintrc.json b/.eslintrc.json
index dbede00..8e64529 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -1,6 +1,7 @@
{
"extends": ["@adobe/eslint-config-aio-lib-config"],
"rules": {
+ "jsdoc/no-undefined-types": 0,
"jsdoc/check-tag-names": [
"error",
{
diff --git a/README.md b/README.md
index c2a0e2e..025348d 100644
--- a/README.md
+++ b/README.md
@@ -105,6 +105,77 @@ aemHeadlessClient.runPersistedQuery('wknd/persist-query-name-with-variables', {
})()
```
+#### Pagination:
+```javascript
+(async () => {
+ const model = 'article'
+ const fields = `{
+ title
+ _path
+ authorFragment {
+ firstName
+ profilePicture {
+ ...on ImageRef {
+ _authorUrl
+ }
+ }
+ }
+ }`
+
+ // Cursor based: Loop all pages
+ try {
+ const cursorQueryAll = await aemHeadlessClient.runPaginatedQuery(model, fields, { first: 4 })
+ for await (let value of cursorQueryAll) {
+ console.log('cursorQueryAll', value)
+ }
+ } catch (e) {
+ console.warn(e)
+ }
+ // Cursor based: Manually get next page
+ try {
+ const cursorQuery = await aemHeadlessClient.runPaginatedQuery(model, fields, { first: 4 })
+ let isDone = false
+ while (!isDone) {
+ const { done, value } = await cursorQuery.next();
+ isDone = done
+ console.log('cursorQuery', value)
+ }
+ } catch (e) {
+ console.warn(e)
+ }
+ // Offset based: Loop all pages
+ try {
+ const offsetQueryAll = await aemHeadlessClient.runPaginatedQuery(model, fields, { limit: 3 })
+ for await (let value of offsetQueryAll) {
+ console.log('offsetQueryAll', value)
+ }
+ } catch (e) {
+ console.warn(e)
+ }
+ // Offset based: Manually get next page
+ try {
+ const offsetQuery = await aemHeadlessClient.runPaginatedQuery(model, fields, { limit: 3 })
+
+ let isDone = false
+ while (!isDone) {
+ const { done, value } = await offsetQuery.next();
+ isDone = done
+ console.log('offsetQuery', value)
+ }
+ } catch (e) {
+ console.warn(e)
+ }
+ // By path
+ try {
+ const pathQuery = await aemHeadlessClient.runPaginatedQuery(model, fields, { _path: '/content/dam/wknd-shared/en/magazine/alaska-adventure/alaskan-adventures' })
+ const result = pathQuery.next()
+ console.log('pathQuery', result)
+ } catch (e) {
+ console.warn(e)
+ }
+})()
+```
+
## Authorization
If `auth` param is a string, it's treated as a Bearer token
diff --git a/api-reference.md b/api-reference.md
index 349843e..6230571 100644
--- a/api-reference.md
+++ b/api-reference.md
@@ -11,6 +11,44 @@ governing permissions and limitations under the License.
-->
# AEM HEADLESS SDK API Reference
+## Classes
+
+
+AEMHeadless
+This class provides methods to call AEM GraphQL APIs.
+Before calling any method initialize the instance
+with GraphQL endpoint, GraphQL serviceURL and auth if needed
+
+
+
+## Typedefs
+
+
+Model : object
+GraphQL Model type
+
+ModelResult : object
+
+ModelResults : object
+
+ModelEdge : object
+
+PageInfo : object
+
+ModelConnection : object
+
+ModelByPathArgs : object
+
+ModelListArgs : object
+
+ModelPaginatedArgs : object
+
+ModelArgs : ModelByPathArgs | ModelListArgs | ModelPaginatedArgs
+
+QueryBuilderResult : object
+
+
+
## AEMHeadless
@@ -26,6 +64,8 @@ with GraphQL endpoint, GraphQL serviceURL and auth if needed
* [.persistQuery(query, path, [options], [retryOptions])](#AEMHeadless+persistQuery) ⇒ Promise.<any>
* [.listPersistedQueries([options], [retryOptions])](#AEMHeadless+listPersistedQueries) ⇒ Promise.<any>
* [.runPersistedQuery(path, [variables], [options], [retryOptions])](#AEMHeadless+runPersistedQuery) ⇒ Promise.<any>
+ * [.runPaginatedQuery(model, fields, [args], [options], [retryOptions])](#AEMHeadless+runPaginatedQuery)
+ * [.buildQuery(model, fields, [args])](#AEMHeadless+buildQuery) ⇒ [QueryBuilderResult](#QueryBuilderResult)
@@ -170,3 +210,322 @@ Returns a Promise that resolves with a GET request JSON data.
+
+
+### aemHeadless.runPaginatedQuery(model, fields, [args], [options], [retryOptions])
+Returns a Generator Function.
+
+**Kind**: instance method of [AEMHeadless](#AEMHeadless)
+
+
+
+ Param Type Default Description
+
+
+
+
+ model stringcontentFragment model name
+
+
+ fields stringquery string for item fields
+
+
+ [args] object{}paginated query arguments
+
+
+ [options] object{}additional POST request options
+
+
+ [retryOptions] object{}retry options for @adobe/aio-lib-core-networking
+
+
+
+
+
+
+### aemHeadless.buildQuery(model, fields, [args]) ⇒ [QueryBuilderResult](#QueryBuilderResult)
+Builds a GraphQL query string for the given parameters.
+
+**Kind**: instance method of [AEMHeadless](#AEMHeadless)
+**Returns**: [QueryBuilderResult](#QueryBuilderResult) - object with The GraphQL query string and type
+
+
+
+ Param Type Default Description
+
+
+
+
+ model stringThe contentFragment model name
+
+
+ fields stringThe query string for item fields
+
+
+ [args] object{}Query arguments
+
+
+
+
+
+
+## Model : object
+GraphQL Model type
+
+**Kind**: global typedef
+**Properties**
+
+
+
+
+ Name Type Description
+
+
+
+
+ * anymodel properties
+
+
+
+
+
+
+## ModelResult : object
+**Kind**: global typedef
+**Properties**
+
+
+
+
+ Name Type Description
+
+
+
+
+ item Model response item
+
+
+
+
+
+
+## ModelResults : object
+**Kind**: global typedef
+**Properties**
+
+
+
+
+ Name Type Description
+
+
+
+
+ items Array.<Model> response items
+
+
+
+
+
+
+## ModelEdge : object
+**Kind**: global typedef
+**Properties**
+
+
+
+
+ Name Type Description
+
+
+
+
+ cursor stringitem cursor
+
+
+ node Model item node
+
+
+
+
+
+
+## PageInfo : object
+**Kind**: global typedef
+**Properties**
+
+
+
+
+ Name Type Description
+
+
+
+
+ endCursor stringendCursor
+
+
+ hasNextPage booleanhasNextPage
+
+
+ hasPreviousPage booleanhasPreviousPage
+
+
+ startCursor stringstartCursor
+
+
+
+
+
+
+## ModelConnection : object
+**Kind**: global typedef
+**Properties**
+
+
+
+
+
+## ModelByPathArgs : object
+**Kind**: global typedef
+**Properties**
+
+
+
+
+ Name Type Description
+
+
+
+
+ _path stringcontentFragment path
+
+
+ variation stringcontentFragment variation
+
+
+
+
+
+
+## ModelListArgs : object
+**Kind**: global typedef
+**Properties**
+
+
+
+
+ Name Type Description
+
+
+
+
+ [_locale] stringcontentFragment locale
+
+
+ [variation] stringcontentFragment variation
+
+
+ [filter] objectlist filter options
+
+
+ [sort] stringlist sort options
+
+
+ [offset] numberlist offset
+
+
+ [limit] numberlist limit
+
+
+
+
+
+
+## ModelPaginatedArgs : object
+**Kind**: global typedef
+**Properties**
+
+
+
+
+ Name Type Description
+
+
+
+
+ [_locale] stringcontentFragment locale
+
+
+ [variation] stringcontentFragment variation
+
+
+ [filter] objectlist filter options
+
+
+ [sort] stringlist sort options
+
+
+ [first] numbernumber of list items
+
+
+ [after] stringlist starting cursor
+
+
+
+
+
+
+## ModelArgs : [ModelByPathArgs](#ModelByPathArgs) \| [ModelListArgs](#ModelListArgs) \| [ModelPaginatedArgs](#ModelPaginatedArgs)
+**Kind**: global typedef
+**Properties**
+
+
+
+
+ Name Type Description
+
+
+
+
+ * anyplaceholder property
+
+
+
+
+
+
+## QueryBuilderResult : object
+**Kind**: global typedef
+**Properties**
+
+
+
+
+ Name Type Description
+
+
+
+
+ type stringQuery type
+
+
+ query QueryStringQuery string
+
+
+
+
diff --git a/package.json b/package.json
index 57cdf89..1373e83 100644
--- a/package.json
+++ b/package.json
@@ -12,11 +12,12 @@
"name": "@adobe/aem-headless-client-js",
"repository": "https://github.com/adobe/aem-headless-client-js",
"scripts": {
- "docs": "jsdoc2md --no-gfm -t ./docs/readme_template.md src/index.js > api-reference.md",
+ "docs": "jsdoc2md --no-gfm -t ./docs/readme_template.md src/types.js src/index.js > api-reference.md",
"lint": "eslint src test",
"unit-tests": "jest --config test/jest.config.js --maxWorkers=2 --verbose",
"test": "npm run lint && npm run unit-tests",
- "e2e": "jest --config e2e/jest.config.js"
+ "e2e": "jest --config e2e/jest.config.js",
+ "types": "tsc"
},
"publishConfig": {
"access": "public"
diff --git a/src/index.d.ts b/src/index.d.ts
new file mode 100644
index 0000000..ab4ac7b
--- /dev/null
+++ b/src/index.d.ts
@@ -0,0 +1,134 @@
+import { QueryBuilderResult } from './types';
+
+export = AEMHeadless;
+/**
+ * This class provides methods to call AEM GraphQL APIs.
+ * Before calling any method initialize the instance
+ * with GraphQL endpoint, GraphQL serviceURL and auth if needed
+ */
+declare class AEMHeadless {
+ /**
+ * Constructor.
+ *
+ * If param is a string, it's treated as AEM server URL, default GraphQL endpoint is used.
+ * For granular params, use config object
+ *
+ * @param {string|object} config - Configuration object, or AEM server URL string
+ * @param {string} [config.serviceURL] - AEM server URL
+ * @param {string} [config.endpoint] - GraphQL endpoint
+ * @param {(string|Array)} [config.auth] - Bearer token string or [user,pass] pair array
+ * @param {object} [config.headers] - header { name: value, name: value, ... }
+ * @param {object} [config.fetch] - custom Fetch instance
+ */
+ constructor(config: string | object);
+ auth: any;
+ headers: any;
+ serviceURL: string;
+ endpoint: string;
+ fetch: any;
+ /**
+ * Returns a Promise that resolves with a POST request JSON data.
+ *
+ * @param {string|object} body - the query string or an object with query (and optionally variables) as a property
+ * @param {object} [options={}] - additional POST request options
+ * @param {object} [retryOptions={}] - retry options for @adobe/aio-lib-core-networking
+ * @returns {Promise} - the response body wrapped inside a Promise
+ */
+ runQuery(body: string | object, options?: object, retryOptions?: object): Promise;
+ /**
+ * Returns a Promise that resolves with a PUT request JSON data.
+ *
+ * @param {string} query - the query string
+ * @param {string} path - AEM path to save query, format: configuration_name/endpoint_name
+ * @param {object} [options={}] - additional PUT request options
+ * @param {object} [retryOptions={}] - retry options for @adobe/aio-lib-core-networking
+ * @returns {Promise} - the response body wrapped inside a Promise
+ */
+ persistQuery(query: string, path: string, options?: object, retryOptions?: object): Promise;
+ /**
+ * Returns a Promise that resolves with a GET request JSON data.
+ *
+ * @param {object} [options={}] - additional GET request options
+ * @param {object} [retryOptions={}] - retry options for @adobe/aio-lib-core-networking
+ * @returns {Promise} - the response body wrapped inside a Promise
+ */
+ listPersistedQueries(options?: object, retryOptions?: object): Promise;
+ /**
+ * Returns a Promise that resolves with a GET request JSON data.
+ *
+ * @param {string} path - AEM path for persisted query, format: configuration_name/endpoint_name
+ * @param {object} [variables={}] - query variables
+ * @param {object} [options={}] - additional GET request options
+ * @param {object} [retryOptions={}] - retry options for @adobe/aio-lib-core-networking
+ * @returns {Promise} - the response body wrapped inside a Promise
+ */
+ runPersistedQuery(path: string, variables?: object, options?: object, retryOptions?: object): Promise;
+ /**
+ * Returns a Generator Function.
+ *
+ * @generator
+ * @param {string} model - contentFragment model name
+ * @param {string} fields - query string for item fields
+ * @param {object} [args={}] - paginated query arguments
+ * @param {object} [options={}] - additional POST request options
+ * @param {object} [retryOptions={}] - retry options for @adobe/aio-lib-core-networking
+ * @yields {null | Promise} - the response items wrapped inside a Promise
+ */
+ runPaginatedQuery(model: string, fields: string, args?: object, options?: object, retryOptions?: object): AsyncGenerator;
+ /**
+ * Builds a GraphQL query string for the given parameters.
+ *
+ * @param {string} model - The contentFragment model name
+ * @param {string} fields - The query string for item fields
+ * @param {object} [args={}] - Query arguments
+ * @returns {QueryBuilderResult} object with The GraphQL query string and type
+ */
+ buildQuery(model: string, fields: string, args?: object): QueryBuilderResult;
+ /**
+ * Returns the updated paging arguments based on the current arguments and the response data.
+ *
+ * @private
+ * @param {object} args - The current paging arguments.
+ * @param {object} data - Current page arguments.
+ * @param {string} data.after - The cursor to start after.
+ * @param {number} data.offset - The offset to start from.
+ * @param {number} [data.limit = 10] - The maximum number of items to return per page.
+ * @returns {object} The updated paging arguments.
+ */
+ private __updatePagingArgs;
+ /**
+ * Returns items list and paging info.
+ *
+ * @private
+ * @param {string} model - contentFragment model name
+ * @param {string} type - model query type: byPath, List, Paginated
+ * @param {object} data - raw response data
+ * @returns {object} - object with filtered data and paging info
+ */
+ private __filterData;
+ /**
+ * Returns an object for Request options
+ *
+ * @private
+ * @param {string} [body] - Request body (the query string)
+ * @param {object} [options] Additional Request options
+ * @returns {object} the Request options object
+ */
+ private __getRequestOptions;
+ /**
+ * Returns a Promise that resolves with a PUT request JSON data.
+ *
+ * @private
+ * @param {string} endpoint - Request endpoint
+ * @param {string} [body=''] - Request body (the query string)
+ * @param {object} [options={}] - Request options
+ * @param {object} [retryOptions={}] - retry options for @adobe/aio-lib-core-networking
+ * @returns {Promise} the response body wrapped inside a Promise
+ */
+ private __handleRequest;
+}
+declare namespace AEMHeadless {
+ export { AEMHeadless, ErrorCodes };
+}
+import ErrorCodes_1 = require("./utils/SDKErrors");
+import ErrorCodes = ErrorCodes_1.codes;
diff --git a/src/index.js b/src/index.js
index ba954aa..3846053 100644
--- a/src/index.js
+++ b/src/index.js
@@ -10,7 +10,9 @@ governing permissions and limitations under the License.
*/
const ErrorCodes = require('./utils/SDKErrors').codes
-const { AEM_GRAPHQL_ACTIONS } = require('./utils/config')
+const { AEM_GRAPHQL_ACTIONS, AEM_GRAPHQL_TYPES } = require('./utils/config')
+const { graphQLQueryBuilder, getQueryType } = require('./utils/GraphQLQueryBuilder')
+const { __getUrl, __getPath, __getDomain, __validateUrl, __getFetch, __getAuthHeader } = require('./utils/utils')
const { REQUEST_ERROR, RESPONSE_ERROR, API_ERROR, INVALID_PARAM } = ErrorCodes
/**
@@ -45,9 +47,9 @@ class AEMHeadless {
this.headers = config.headers
}
- this.serviceURL = this.__getDomain(serviceURL)
- this.endpoint = this.__getPath(endpoint)
- this.fetch = this.__getFetch(config.fetch)
+ this.serviceURL = __getDomain(serviceURL)
+ this.endpoint = __getPath(endpoint)
+ this.fetch = __getFetch(config.fetch)
}
/**
@@ -114,35 +116,132 @@ class AEMHeadless {
}
/**
- * Returns Authorization Header value.
+ * Returns a Generator Function.
+ *
+ * @generator
+ * @param {string} model - contentFragment model name
+ * @param {string} fields - query string for item fields
+ * @param {object} [args={}] - paginated query arguments
+ * @param {object} [options={}] - additional POST request options
+ * @param {object} [retryOptions={}] - retry options for @adobe/aio-lib-core-networking
+ * @yields {null | Promise} - the response items wrapped inside a Promise
+ */
+ async * runPaginatedQuery (model, fields, args = {}, options, retryOptions) {
+ if (!model || !fields) {
+ throw new INVALID_PARAM({
+ sdkDetails: {
+ serviceURL: this.serviceURL
+ },
+ messageValues: 'Required param missing: @param {string} fields - query string for item fields'
+ })
+ }
+
+ let isInitial = true
+ let hasNext = true
+ let endCursor = args.after || ''
+ const limit = args.limit || 10
+ let pagingArgs = args
+ while (hasNext) {
+ const offset = pagingArgs.offset || 0
+ if (!isInitial) {
+ pagingArgs = this.__updatePagingArgs(args, { offset, limit, endCursor })
+ }
+
+ isInitial = false
+
+ const { query, type } = this.buildQuery(model, fields, pagingArgs)
+ const { data } = await this.runQuery(query, options, retryOptions)
+
+ let filteredData = {}
+ try {
+ filteredData = this.__filterData(model, type, data)
+ } catch (e) {
+ throw new API_ERROR({
+ sdkDetails: {
+ serviceURL: this.serviceURL
+ },
+ messageValues: `Error while filtering response data. ${e.message}`
+ })
+ }
+
+ hasNext = filteredData.hasNext
+ endCursor = filteredData.endCursor
+
+ yield filteredData.data
+ }
+ }
+
+ /**
+ * Builds a GraphQL query string for the given parameters.
+ *
+ * @param {string} model - The contentFragment model name
+ * @param {string} fields - The query string for item fields
+ * @param {object} [args={}] - Query arguments
+ * @returns {QueryBuilderResult} object with The GraphQL query string and type
+ */
+ buildQuery (model, fields, args = {}) {
+ return graphQLQueryBuilder(model, fields, args)
+ }
+
+ /**
+ * Returns the updated paging arguments based on the current arguments and the response data.
*
* @private
- * @param {string|array} auth - Bearer token string or [user,pass] pair array
- * @returns {string} Authorization Header value
+ * @param {object} args - The current paging arguments.
+ * @param {object} data - Current page arguments.
+ * @param {string} data.after - The cursor to start after.
+ * @param {number} data.offset - The offset to start from.
+ * @param {number} [data.limit = 10] - The maximum number of items to return per page.
+ * @returns {object} The updated paging arguments.
*/
- __getAuthHeader (auth) {
- let authType = 'Bearer'
- let authToken = auth
- // If auth is user, pass pair
- if (Array.isArray(auth) && auth[0] && auth[1]) {
- authType = 'Basic'
- authToken = this.__str2base64(`${auth[0]}:${auth[1]}`)
+ __updatePagingArgs (args = {}, { after, offset, limit = 10 }) {
+ const queryType = getQueryType(args)
+ const pagingArgs = { ...args }
+ if (queryType === AEM_GRAPHQL_TYPES.LIST) {
+ pagingArgs.offset = offset + limit
+ }
+
+ if (queryType === AEM_GRAPHQL_TYPES.PAGINATED) {
+ pagingArgs.after = after
}
- return `${authType} ${authToken}`
+ return pagingArgs
}
/**
- * simple string to base64 implementation
+ * Returns items list and paging info.
*
* @private
- * @param {string} str
+ * @param {string} model - contentFragment model name
+ * @param {string} type - model query type: byPath, List, Paginated
+ * @param {object} data - raw response data
+ * @returns {object} - object with filtered data and paging info
*/
- __str2base64 (str) {
- try {
- return btoa(str)
- } catch (err) {
- return Buffer.from(str, 'utf8').toString('base64')
+ __filterData (model, type, data) {
+ let response
+ let filteredData
+ let hasNext
+ let endCursor
+ switch (type) {
+ case AEM_GRAPHQL_TYPES.BY_PATH:
+ filteredData = data[`${model}${type}`].item
+ hasNext = false
+ break
+ case AEM_GRAPHQL_TYPES.PAGINATED:
+ response = data[`${model}${type}`]
+ hasNext = response.pageInfo.hasNextPage
+ endCursor = response.pageInfo.endCursor
+ filteredData = response.edges.map(item => item.node)
+ break
+ default:
+ filteredData = data[`${model}${type}`].items
+ hasNext = filteredData && filteredData.length > 0
+ }
+
+ return {
+ data: filteredData,
+ hasNext,
+ endCursor
}
}
@@ -173,7 +272,7 @@ class AEMHeadless {
if (this.auth) {
requestOptions.headers = {
...requestOptions.headers,
- Authorization: this.__getAuthHeader(this.auth)
+ Authorization: __getAuthHeader(this.auth)
}
requestOptions.credentials = 'include'
}
@@ -198,8 +297,8 @@ class AEMHeadless {
*/
async __handleRequest (endpoint, body, options, retryOptions) {
const requestOptions = this.__getRequestOptions(body, options)
- const url = this.__getUrl(this.serviceURL, endpoint)
- this.__validateUrl(url)
+ const url = __getUrl(this.serviceURL, endpoint)
+ __validateUrl(url)
let response
// 1. Handle Request
@@ -257,107 +356,18 @@ class AEMHeadless {
messageValues: error.message
})
}
-
- return data
- }
-
- /**
- * Returns valid url.
- *
- * @private
- * @param {string} domain
- * @param {string} path
- * @returns {string} valid url
- */
- __getUrl (domain, path) {
- return `${domain}${path}`
- }
-
- /**
- * Removes first / in a path
- *
- * @private
- * @param {string} path
- * @returns {string} path
- */
- __getPath (path) {
- return path[0] === '/' ? path.substring(1) : path
- }
-
- /**
- * Add last / in domain
- *
- * @private
- * @param {string} domain
- * @returns {string} valid domain
- */
- __getDomain (domain) {
- return domain[domain.length - 1] === '/' ? domain : `${domain}/`
- }
-
- /**
- * get Fetch instance
- *
- * @private
- * @param {object} [fetch]
- * @returns {object} fetch instance
- */
- __getFetch (fetch) {
- if (!fetch) {
- const browserFetch = this.__getBrowserFetch()
- if (!browserFetch) {
- throw new INVALID_PARAM({
- sdkDetails: {
- serviceURL: this.serviceURL
- },
- messageValues: 'Required param missing: config.fetch'
- })
- }
-
- return browserFetch
- }
-
- return fetch
- }
-
- /**
- * get Browser Fetch instance
- *
- * @private
- * @returns {object} fetch instance
- */
- __getBrowserFetch () {
- if (typeof window !== 'undefined') {
- return window.fetch.bind(window)
- }
-
- if (typeof self !== 'undefined') {
- return self.fetch.bind(self) // eslint-disable-line
- }
-
- return null
- }
-
- /**
- * Check valid url or absolute path
- *
- * @private
- * @param {string} url
- * @returns void
- */
- __validateUrl (url) {
- const fullUrl = url[0] === '/' ? `https://domain${url}` : url
-
- try {
- new URL(fullUrl) // eslint-disable-line
- } catch (e) {
- throw new INVALID_PARAM({
+ // 3.2. Response ok: containing errors info
+ if (data && data.errors) {
+ throw new RESPONSE_ERROR({
sdkDetails: {
- serviceURL: this.serviceURL
+ serviceURL: this.serviceURL,
+ endpoint
},
- messageValues: `Invalid URL/path: ${url}`
+ messageValues: data.errors.map((error) => error.message).join('. ')
})
}
+
+ return data
}
}
diff --git a/src/types.d.ts b/src/types.d.ts
new file mode 100644
index 0000000..21d4a40
--- /dev/null
+++ b/src/types.d.ts
@@ -0,0 +1,136 @@
+/**
+ * Query string type
+ */
+export type QueryString = string;
+/**
+ * GraphQL Model type
+ */
+export type Model = {
+ /**
+ * * - model properties
+ */
+ "": any;
+};
+export type ModelResult = {
+ /**
+ * - response item
+ */
+ item: Model;
+};
+export type ModelResults = {
+ /**
+ * - response items
+ */
+ items: Model[];
+};
+export type ModelEdge = {
+ /**
+ * - item cursor
+ */
+ cursor: string;
+ /**
+ * - item node
+ */
+ node: Model;
+};
+export type PageInfo = {
+ /**
+ * - endCursor
+ */
+ endCursor: string;
+ /**
+ * - hasNextPage
+ */
+ hasNextPage: boolean;
+ /**
+ * - hasPreviousPage
+ */
+ hasPreviousPage: boolean;
+ /**
+ * - startCursor
+ */
+ startCursor: string;
+};
+export type ModelConnection = {
+ /**
+ * - edges
+ */
+ edges: ModelEdge[];
+ /**
+ * - pageInfo
+ */
+ pageInfo: PageInfo;
+};
+export type ModelByPathArgs = {
+ /**
+ * - contentFragment path
+ */
+ _path: string;
+ /**
+ * - contentFragment variation
+ */
+ variation: string;
+};
+export type ModelListArgs = {
+ /**
+ * - contentFragment locale
+ */
+ _locale?: string;
+ /**
+ * - contentFragment variation
+ */
+ variation?: string;
+ /**
+ * - list filter options
+ */
+ filter?: object;
+ /**
+ * - list sort options
+ */
+ sort?: string;
+ /**
+ * - list offset
+ */
+ offset?: number;
+ /**
+ * - list limit
+ */
+ limit?: number;
+};
+export type ModelPaginatedArgs = {
+ /**
+ * - contentFragment locale
+ */
+ _locale?: string;
+ /**
+ * - contentFragment variation
+ */
+ variation?: string;
+ /**
+ * - list filter options
+ */
+ filter?: object;
+ /**
+ * - list sort options
+ */
+ sort?: string;
+ /**
+ * - number of list items
+ */
+ first?: number;
+ /**
+ * - list starting cursor
+ */
+ after?: string;
+};
+export type ModelArgs = ModelByPathArgs | ModelListArgs | ModelPaginatedArgs;
+export type QueryBuilderResult = {
+ /**
+ * - Query type
+ */
+ type: string;
+ /**
+ * - Query string
+ */
+ query: QueryString;
+};
diff --git a/src/types.js b/src/types.js
new file mode 100644
index 0000000..50817f6
--- /dev/null
+++ b/src/types.js
@@ -0,0 +1,82 @@
+/**
+ * Query string type
+ *
+ * @private
+ * @typedef {string} QueryString
+ */
+
+/**
+ * GraphQL Model type
+ *
+ * @typedef {object} Model
+ * @property {any} * - model properties
+ */
+
+/**
+ * @typedef {object} ModelResult
+ * @property {Model} item - response item
+ */
+
+/**
+ * @typedef {object} ModelResults
+ * @property {Model[]} items - response items
+ */
+
+/**
+ * @typedef {object} ModelEdge
+ * @property {string} cursor - item cursor
+ * @property {Model} node - item node
+ */
+
+/**
+ * @typedef {object} PageInfo
+ * @property {string} endCursor - endCursor
+ * @property {boolean} hasNextPage - hasNextPage
+ * @property {boolean} hasPreviousPage - hasPreviousPage
+ * @property {string} startCursor - startCursor
+ */
+
+/**
+ * @typedef {object} ModelConnection
+ * @property {ModelEdge[]} edges - edges
+ * @property {PageInfo} pageInfo - pageInfo
+ */
+
+/**
+ * @typedef {object} ModelByPathArgs
+ * @property {string} _path - contentFragment path
+ * @property {string} variation - contentFragment variation
+ */
+
+/**
+ * @typedef {object} ModelListArgs
+ * @property {string} [_locale] - contentFragment locale
+ * @property {string} [variation] - contentFragment variation
+ * @property {object} [filter] - list filter options
+ * @property {string} [sort] - list sort options
+ * @property {number} [offset] - list offset
+ * @property {number} [limit] - list limit
+ */
+
+/**
+ * @typedef {object} ModelPaginatedArgs
+ * @property {string} [_locale] - contentFragment locale
+ * @property {string} [variation] - contentFragment variation
+ * @property {object} [filter] - list filter options
+ * @property {string} [sort] - list sort options
+ * @property {number} [first] - number of list items
+ * @property {string} [after] - list starting cursor
+ */
+
+/**
+ * @typedef {ModelByPathArgs|ModelListArgs|ModelPaginatedArgs} ModelArgs
+ * @property {any} * - placeholder property
+ */
+
+/**
+ * @typedef {object} QueryBuilderResult
+ * @property {string} type - Query type
+ * @property {QueryString} query - Query string
+ */
+
+module.exports = {}
diff --git a/src/utils/GraphQLQueryBuilder.d.ts b/src/utils/GraphQLQueryBuilder.d.ts
new file mode 100644
index 0000000..d32a4d6
--- /dev/null
+++ b/src/utils/GraphQLQueryBuilder.d.ts
@@ -0,0 +1,12 @@
+import { ModelArgs, QueryBuilderResult } from '../types';
+
+/**
+ * Returns a Query for a model and type
+ *
+ * @param {string} model - contentFragment Model Name
+ * @param {string} fields - The query string for item fields
+ * @param {ModelArgs} [args={}] - Query arguments
+ * @returns {QueryBuilderResult} - returns object with query string and type
+ */
+export function graphQLQueryBuilder(model: string, fields: string, args?: ModelArgs): QueryBuilderResult;
+export function getQueryType(args?: {}): string;
diff --git a/src/utils/GraphQLQueryBuilder.js b/src/utils/GraphQLQueryBuilder.js
new file mode 100644
index 0000000..8a7f8ec
--- /dev/null
+++ b/src/utils/GraphQLQueryBuilder.js
@@ -0,0 +1,142 @@
+require('../types') // eslint-disable-line
+const { AEM_GRAPHQL_TYPES } = require('./config')
+
+/**
+ *
+ * @private
+ * @param {object} obj - object representing query arguments
+ * @returns {string} - query args as a string
+ */
+function objToStringArgs (obj) {
+ let str = ''
+ for (const [key, value] of Object.entries(obj)) {
+ let val = typeof value === 'string' ? `"${value}"` : value
+ val = typeof value === 'object' ? `{ ${objToStringArgs(value)} }` : val
+ str += `${key}:${val}\n`
+ }
+ return str
+}
+
+/**
+ * Returns a Query for model by path
+ *
+ * @private
+ * @param {string} model - contentFragment Model Name
+ * @param {string} fields - The query string for item fields.
+ * @param {ModelByPathArgs} args - Query arguments
+ * @returns {QueryBuilderResult}
+ */
+const __modelByPath = (model, fields, args) => {
+ if (!args || !args._path) {
+ throw new Error('Missing required param "_path"')
+ }
+ const type = AEM_GRAPHQL_TYPES.BY_PATH
+ const query = `{
+ ${model}${type}(
+ ${objToStringArgs(args)}
+ ) {
+ item ${fields}
+ }
+ }`
+
+ return {
+ type,
+ query
+ }
+}
+
+/**
+ * Returns a Query for model list
+ *
+ * @private
+ * @param {string} model - contentFragment Model Name
+ * @param {string} fields - The query string for item fields.
+ * @param {ModelListArgs} [args={}] - Query arguments
+ * @returns {QueryBuilderResult}
+ */
+const __modelList = (model, fields, args = {}) => {
+ const type = AEM_GRAPHQL_TYPES.LIST
+ const query = `{
+ ${model}${type}(
+ ${objToStringArgs(args)}
+ ) {
+ items ${fields}
+ }
+ }`
+
+ return {
+ type,
+ query
+ }
+}
+
+/**
+ * Returns a Query for model list
+ *
+ * @private
+ * @param {string} model - contentFragment Model Name
+ * @param {string} fields - The query string for item fields.
+ * @param {ModelPaginatedArgs} [args={}] - Query arguments
+ * @returns {QueryBuilderResult}
+ */
+const __modelPaginated = (model, fields, args = {}) => {
+ const type = AEM_GRAPHQL_TYPES.PAGINATED
+ const query = `{
+ ${model}${type}(
+ ${objToStringArgs(args)}
+ ) {
+ pageInfo {
+ startCursor
+ endCursor
+ hasNextPage
+ hasPreviousPage
+ }
+ edges {
+ node ${fields}
+ cursor
+ }
+ }
+ }`
+
+ return {
+ type,
+ query
+ }
+}
+
+const getQueryType = (args = {}) => {
+ if (args._path) {
+ return AEM_GRAPHQL_TYPES.BY_PATH
+ }
+
+ if (args.first || args.after) {
+ return AEM_GRAPHQL_TYPES.PAGINATED
+ }
+
+ return AEM_GRAPHQL_TYPES.LIST
+}
+
+/**
+ * Returns a Query for a model and type
+ *
+ * @param {string} model - contentFragment Model Name
+ * @param {string} fields - The query string for item fields
+ * @param {ModelArgs} [args={}] - Query arguments
+ * @returns {QueryBuilderResult} - returns object with query string and type
+ */
+const graphQLQueryBuilder = (model, fields, args = {}) => {
+ if (args._path) {
+ return __modelByPath(model, fields, args)
+ }
+
+ if (args.first || args.after) {
+ return __modelPaginated(model, fields, args)
+ }
+
+ return __modelList(model, fields, args)
+}
+
+module.exports = {
+ graphQLQueryBuilder,
+ getQueryType
+}
diff --git a/src/utils/SDKErrors.d.ts b/src/utils/SDKErrors.d.ts
new file mode 100644
index 0000000..3e20a08
--- /dev/null
+++ b/src/utils/SDKErrors.d.ts
@@ -0,0 +1,2 @@
+export const codes: {};
+export const messages: Map;
diff --git a/src/utils/config.d.ts b/src/utils/config.d.ts
new file mode 100644
index 0000000..072d32d
--- /dev/null
+++ b/src/utils/config.d.ts
@@ -0,0 +1,12 @@
+export namespace AEM_GRAPHQL_ACTIONS {
+ const persist: string;
+ const execute: string;
+ const list: string;
+ const endpoint: string;
+ const serviceURL: string;
+}
+export namespace AEM_GRAPHQL_TYPES {
+ const BY_PATH: string;
+ const LIST: string;
+ const PAGINATED: string;
+}
diff --git a/src/utils/config.js b/src/utils/config.js
index 1b467c3..5a65a13 100644
--- a/src/utils/config.js
+++ b/src/utils/config.js
@@ -6,6 +6,13 @@ const AEM_GRAPHQL_ACTIONS = {
serviceURL: '/'
}
+const AEM_GRAPHQL_TYPES = {
+ BY_PATH: 'ByPath',
+ LIST: 'List',
+ PAGINATED: 'Paginated'
+}
+
module.exports = {
- AEM_GRAPHQL_ACTIONS
+ AEM_GRAPHQL_ACTIONS,
+ AEM_GRAPHQL_TYPES
}
diff --git a/src/utils/utils.d.ts b/src/utils/utils.d.ts
new file mode 100644
index 0000000..8bd9c13
--- /dev/null
+++ b/src/utils/utils.d.ts
@@ -0,0 +1,49 @@
+/**
+ * Returns valid url.
+ *
+ * @private
+ * @param {string} domain
+ * @param {string} path
+ * @returns {string} valid url
+ */
+export function __getUrl(domain: string, path: string): string;
+/**
+ * Removes first / in a path
+ *
+ * @private
+ * @param {string} path
+ * @returns {string} path
+ */
+export function __getPath(path: string): string;
+/**
+ * Add last / in domain
+ *
+ * @private
+ * @param {string} domain
+ * @returns {string} valid domain
+ */
+export function __getDomain(domain: string): string;
+/**
+ * Check valid url or absolute path
+ *
+ * @private
+ * @param {string} url
+ * @returns void
+ */
+export function __validateUrl(url: string): void;
+/**
+ * get Fetch instance
+ *
+ * @private
+ * @param {object} [fetch]
+ * @returns {object} fetch instance
+ */
+export function __getFetch(fetch?: object): object;
+/**
+ * Returns Authorization Header value.
+ *
+ * @private
+ * @param {string|array} auth - Bearer token string or [user,pass] pair array
+ * @returns {string} Authorization Header value
+ */
+export function __getAuthHeader(auth: string | any[]): string;
diff --git a/src/utils/utils.js b/src/utils/utils.js
new file mode 100644
index 0000000..3afe5d6
--- /dev/null
+++ b/src/utils/utils.js
@@ -0,0 +1,142 @@
+const { INVALID_PARAM } = require('./SDKErrors').codes
+
+/**
+ * simple string to base64 implementation
+ *
+ * @private
+ * @param {string} str
+ */
+const __str2base64 = (str) => {
+ try {
+ return btoa(str)
+ } catch (err) {
+ return Buffer.from(str, 'utf8').toString('base64')
+ }
+}
+
+/**
+ * Returns valid url.
+ *
+ * @private
+ * @param {string} domain
+ * @param {string} path
+ * @returns {string} valid url
+ */
+const __getUrl = (domain, path) => {
+ return `${domain}${path}`
+}
+
+/**
+ * Removes first / in a path
+ *
+ * @private
+ * @param {string} path
+ * @returns {string} path
+ */
+const __getPath = (path) => {
+ return path[0] === '/' ? path.substring(1) : path
+}
+
+/**
+ * Add last / in domain
+ *
+ * @private
+ * @param {string} domain
+ * @returns {string} valid domain
+ */
+const __getDomain = (domain) => {
+ return domain[domain.length - 1] === '/' ? domain : `${domain}/`
+}
+
+/**
+ * Check valid url or absolute path
+ *
+ * @private
+ * @param {string} url
+ * @returns void
+ */
+const __validateUrl = (url) => {
+ const fullUrl = url[0] === '/' ? `https://domain${url}` : url
+
+ try {
+ new URL(fullUrl) // eslint-disable-line
+ } catch (e) {
+ throw new INVALID_PARAM({
+ sdkDetails: {
+ serviceURL: this.serviceURL
+ },
+ messageValues: `Invalid URL/path: ${url}`
+ })
+ }
+}
+
+/**
+ * get Browser Fetch instance
+ *
+ * @private
+ * @returns {object} fetch instance
+ */
+const __getBrowserFetch = () => {
+ if (typeof window !== 'undefined') {
+ return window.fetch.bind(window)
+ }
+
+ if (typeof self !== 'undefined') {
+ return self.fetch.bind(self) // eslint-disable-line
+ }
+
+ return null
+}
+
+/**
+ * get Fetch instance
+ *
+ * @private
+ * @param {object} [fetch]
+ * @returns {object} fetch instance
+ */
+const __getFetch = (fetch) => {
+ if (!fetch) {
+ const browserFetch = __getBrowserFetch()
+ if (!browserFetch) {
+ throw new INVALID_PARAM({
+ sdkDetails: {
+ serviceURL: this.serviceURL
+ },
+ messageValues: 'Required param missing: config.fetch'
+ })
+ }
+
+ return browserFetch
+ }
+
+ return fetch
+}
+
+/**
+ * Returns Authorization Header value.
+ *
+ * @private
+ * @param {string|array} auth - Bearer token string or [user,pass] pair array
+ * @returns {string} Authorization Header value
+ */
+const __getAuthHeader = (auth) => {
+ let authType = 'Bearer'
+ let authToken = auth
+ // If auth is user, password` pair
+ if (Array.isArray(auth) && auth[0] && auth[1]) {
+ authType = 'Basic'
+ authToken = __str2base64(`${auth[0]}:${auth[1]}`)
+ }
+
+ return `${authType} ${authToken}`
+}
+
+module.exports = {
+ __getUrl,
+ __getPath,
+ __getDomain,
+ __validateUrl,
+ __getFetch,
+ __getAuthHeader
+}
diff --git a/test/shared/index.test.js b/test/shared/index.test.js
index 0c57ac0..80a8911 100644
--- a/test/shared/index.test.js
+++ b/test/shared/index.test.js
@@ -224,3 +224,42 @@ test('API: multiple API custom errors', () => {
const promise = sdk.runPersistedQuery('/test')
return expect(promise).rejects.toThrow('Invalid URL/path:')
})
+
+describe('runPaginatedQuery', () => {
+ const mockModel = 'mockModel'
+ const mockFields = 'mockFields'
+ const mockArgs = { limit: 10 }
+ const mockOptions = {}
+ const mockRetryOptions = {}
+
+ it('should throw an error if model is missing', async () => {
+ const gen = sdk.runPaginatedQuery(null, mockFields, mockArgs, mockOptions, mockRetryOptions)
+ await expect(gen.next()).rejects.toThrow(ErrorCodes.INVALID_PARAM)
+ })
+
+ it('should throw an error if fields are missing', async () => {
+ const gen = sdk.runPaginatedQuery(mockModel, null, mockArgs, mockOptions, mockRetryOptions)
+ await expect(gen.next()).rejects.toThrow(ErrorCodes.INVALID_PARAM)
+ })
+
+ it('should yield the filtered data', async () => {
+ const mockData = [{ id: '1', name: 'foo' }]
+ fetch.resetMocks()
+ fetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ data: {
+ mockModelList: {
+ items: mockData
+ }
+ }
+ })
+ })
+
+ const gen = sdk.runPaginatedQuery(mockModel, mockFields, mockArgs, mockOptions, mockRetryOptions)
+ const result = await gen.next()
+
+ expect(result).toEqual({ done: false, value: mockData })
+ })
+})
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..5370b1f
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "include": ["src/**/*"],
+ "compilerOptions": {
+ "allowJs": true,
+ "declaration": true,
+ "emitDeclarationOnly": true
+ }
+}