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) + + + + + + + + + + + + + + + + + + +
ParamTypeDefaultDescription
modelstring

contentFragment model name

+
fieldsstring

query 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 + + + + + + + + + + + + + + +
ParamTypeDefaultDescription
modelstring

The contentFragment model name

+
fieldsstring

The query string for item fields

+
[args]object{}

Query arguments

+
+ + + +## Model : object +GraphQL Model type + +**Kind**: global typedef +**Properties** + + + + + + + + + + + +
NameTypeDescription
*any

model properties

+
+ + + +## ModelResult : object +**Kind**: global typedef +**Properties** + + + + + + + + + + + +
NameTypeDescription
itemModel

response item

+
+ + + +## ModelResults : object +**Kind**: global typedef +**Properties** + + + + + + + + + + + +
NameTypeDescription
itemsArray.<Model>

response items

+
+ + + +## ModelEdge : object +**Kind**: global typedef +**Properties** + + + + + + + + + + + + + +
NameTypeDescription
cursorstring

item cursor

+
nodeModel

item node

+
+ + + +## PageInfo : object +**Kind**: global typedef +**Properties** + + + + + + + + + + + + + + + + + +
NameTypeDescription
endCursorstring

endCursor

+
hasNextPageboolean

hasNextPage

+
hasPreviousPageboolean

hasPreviousPage

+
startCursorstring

startCursor

+
+ + + +## ModelConnection : object +**Kind**: global typedef +**Properties** + + + + + + + + + + + + + +
NameTypeDescription
edgesArray.<ModelEdge>

edges

+
pageInfoPageInfo

pageInfo

+
+ + + +## ModelByPathArgs : object +**Kind**: global typedef +**Properties** + + + + + + + + + + + + + +
NameTypeDescription
_pathstring

contentFragment path

+
variationstring

contentFragment variation

+
+ + + +## ModelListArgs : object +**Kind**: global typedef +**Properties** + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
[_locale]string

contentFragment locale

+
[variation]string

contentFragment variation

+
[filter]object

list filter options

+
[sort]string

list sort options

+
[offset]number

list offset

+
[limit]number

list limit

+
+ + + +## ModelPaginatedArgs : object +**Kind**: global typedef +**Properties** + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
[_locale]string

contentFragment locale

+
[variation]string

contentFragment variation

+
[filter]object

list filter options

+
[sort]string

list sort options

+
[first]number

number of list items

+
[after]string

list starting cursor

+
+ + + +## ModelArgs : [ModelByPathArgs](#ModelByPathArgs) \| [ModelListArgs](#ModelListArgs) \| [ModelPaginatedArgs](#ModelPaginatedArgs) +**Kind**: global typedef +**Properties** + + + + + + + + + + + +
NameTypeDescription
*any

placeholder property

+
+ + + +## QueryBuilderResult : object +**Kind**: global typedef +**Properties** + + + + + + + + + + + + + +
NameTypeDescription
typestring

Query type

+
queryQueryString

Query 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 + } +}