diff --git a/README.md b/README.md index 01fd4c9..f52e8e4 100644 --- a/README.md +++ b/README.md @@ -1190,6 +1190,25 @@ s.data('level1', 'level2'); // 'data.level1.level2' ``` +##### compareToField(field) + +Build value as field to facilitate field to field comparison. Can only be used in context of a value in a search dsl +predicate + +```js +s.greaterThan('field1', s.compareToField('field2')); +// greaterThan: {field: field1, value: {field: field2}} +``` + +##### computedField(field) + +Prefix field with identifier for a computed field. + +```js +s.computedField('computedField'); +// ':computedField' +``` + ##### contains(field, value) Build a criterion matching `MultiSelectList` and `Collection` fields which contains the provided value. @@ -1232,6 +1251,26 @@ Dynamic field names must be prefixed with `data.`. Build a criterion matching `YesOrNo` fields that are either `true` or `false`. Dynamic field names must be prefixed with `data.`. +##### greaterThan(field, value) + +Build a criterion matching on field greater than provided value. +Dynamic field names must be prefixed with `data.`. + +##### greaterThanOrEquals(field, value) + +Build a criterion matching on field greater than or equals provided value. +Dynamic field names must be prefixed with `data.`. + +##### lessThan(field, value) + +Build a criterion matching on field less than provided value. +Dynamic field names must be prefixed with `data.`. + +##### lessThanOrEquals(field, value) + +Build a criterion matching on field less than or equals provided value. +Dynamic field names must be prefixed with `data.`. + ##### sort(...instructions) Build the top-level `sort` object by combining one or many sorting instructions. @@ -1255,6 +1294,19 @@ Build the top-level `page` object. * `index` is the number of the results page to retrieve and starts at `1` * `size` is the maximum number of results to retrieve per page and is capped at `100` +#### searchParams(additionalProperties) + +Build optional search request parameters + +* `additionalProperties` - additional properties to be passed as request parameters + +```js +s.searchParams() + .withLinks() + .withComputedFields('computedField') + .build(); +``` + #### Example ```javascript @@ -1273,7 +1325,11 @@ const query = s.query( s.equalsAny(s.data('field2'), ['VALUE_1', 'VALUE_2']), s.equalsAnyIgnoreCase(s.data('field2'), ['value_1', 'value_2']), s.hasValue(s.data('field3'), true), - s.is(s.data('yesOrNoField'), true), + s.is(s.data('yesOrNoField'), true), + s.greaterThan(s.data('numberField1'), 10), + s.greaterThanOrEquals(s.data('numberField2'), s.compareToField('numberField1')), + s.lessThan(s.data('numberField3'), 10), + s.lessThanOrEquals(s.computedField('numberField4'), 10), ) ); @@ -1284,7 +1340,12 @@ const sort = s.sort( const page = s.page(1, 30); -const response = await search(searchClient)('CaseType1')(query)(sort)(page); +const params = s.searchParams() + .withLinks() + .withComputedFields('computedField') + .build(); + +const response = await search(searchClient)('CaseType1')(query)(sort)(page, params); ``` ### Redis Gateway diff --git a/package-lock.json b/package-lock.json index 28e1546..10ffc54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3476,10 +3476,11 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -9515,9 +9516,9 @@ } }, "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "requires": { "path-key": "^3.1.0", diff --git a/src/modules/http-client.js b/src/modules/http-client.js index 064772a..6b38945 100644 --- a/src/modules/http-client.js +++ b/src/modules/http-client.js @@ -18,9 +18,15 @@ export const httpClient = (baseUrl, axiosInstance = axios) => (accessTokenProvid put: bodyRequest(axiosInstance.put)(urlBuilder(baseUrl))(accessTokenProvider), }); -const emptyRequest = (axiosFn) => (url) => (accessTokenProvider) => async (relativeUrl) => axiosFn(url(relativeUrl), headers(await authorization(accessTokenProvider))); +const emptyRequest = (axiosFn) => (url) => (accessTokenProvider) => async (relativeUrl, params) => axiosFn(url(relativeUrl), { + ...headers(await authorization(accessTokenProvider)), + params, +}); -const bodyRequest = (axiosFn) => (url) => (accessTokenProvider) => async (relativeUrl, body) => axiosFn(url(relativeUrl), body, headers(await authorization(accessTokenProvider))); +const bodyRequest = (axiosFn) => (url) => (accessTokenProvider) => async (relativeUrl, body, params) => axiosFn(url(relativeUrl), body, { + ...headers(await authorization(accessTokenProvider)), + params, +}); const urlBuilder = (baseUrl) => (relativeUrl) => baseUrl + relativeUrl; diff --git a/src/modules/http-client.test.js b/src/modules/http-client.test.js index 7f2db92..333999d 100644 --- a/src/modules/http-client.test.js +++ b/src/modules/http-client.test.js @@ -17,6 +17,21 @@ describe('httpClient', () => { expect(axios.get).toHaveBeenCalledWith('http://data-store:4452/path/to/resource', {headers: {Authorization: 'Bearer access-token-123'}}); }); + test('should get resource with request parameters', async () => { + const expectedResp = {data: {foo: 'bar'}}; + axios.get.mockResolvedValue(expectedResp); + + const resp = await httpClient(baseUrl)(tokenProviderStub).get('/path/to/resource', {['with-links']: 'true'}); + + expect(resp).toEqual(expectedResp); + expect(axios.get).toHaveBeenCalledWith('http://data-store:4452/path/to/resource', + { + headers: {Authorization: 'Bearer access-token-123'}, + params: {['with-links']: 'true'}, + } + ); + }); + test('should securely post resource', async () => { const body = {foo: 'bar'}; const expectedResp = {status: 201}; @@ -25,7 +40,24 @@ describe('httpClient', () => { const resp = await httpClient(baseUrl)(tokenProviderStub).post('/path/to/resource', body); expect(resp).toEqual(expectedResp); - expect(axios.post).toHaveBeenCalledWith('http://data-store:4452/path/to/resource', body,{headers: {Authorization: 'Bearer access-token-123'}}); + expect(axios.post).toHaveBeenCalledWith('http://data-store:4452/path/to/resource', body, {headers: {Authorization: 'Bearer access-token-123'}}); + }); + + test('should post resource with request parameters', async () => { + const body = {foo: 'bar'}; + const expectedResp = {status: 201}; + axios.post.mockResolvedValue(expectedResp); + + const resp = await httpClient(baseUrl)(tokenProviderStub).post('/path/to/resource', body, {['computed-fields']: ['computedField1']}); + + expect(resp).toEqual(expectedResp); + expect(axios.post).toHaveBeenCalledWith('http://data-store:4452/path/to/resource', + body, + { + headers: {Authorization: 'Bearer access-token-123'}, + params: {['computed-fields']: ['computedField1']}, + } + ); }); test('should securely put resource', async () => { @@ -36,7 +68,24 @@ describe('httpClient', () => { const resp = await httpClient(baseUrl)(tokenProviderStub).put('/path/to/resource', body); expect(resp).toEqual(expectedResp); - expect(axios.put).toHaveBeenCalledWith('http://data-store:4452/path/to/resource', body,{headers: {Authorization: 'Bearer access-token-123'}}); + expect(axios.put).toHaveBeenCalledWith('http://data-store:4452/path/to/resource', body, {headers: {Authorization: 'Bearer access-token-123'}}); + }); + + test('should put resource with request parameters', async () => { + const body = {foo: 'bar'}; + const expectedResp = {status: 200}; + axios.put.mockResolvedValue(expectedResp); + + const resp = await httpClient(baseUrl)(tokenProviderStub).put('/path/to/resource', body, {filter: 'test'}); + + expect(resp).toEqual(expectedResp); + expect(axios.put).toHaveBeenCalledWith('http://data-store:4452/path/to/resource', + body, + { + headers: {Authorization: 'Bearer access-token-123'}, + params: {filter: 'test'}, + } + ); }); test('should accept custom axios instance', async () => { diff --git a/src/modules/search.js b/src/modules/search.js index 01fbaa8..dbbd8ee 100644 --- a/src/modules/search.js +++ b/src/modules/search.js @@ -2,14 +2,14 @@ * Search */ - export const search = (http) => (caseTypeId) => (query) => (sort) => async (page) => { + export const search = (http) => (caseTypeId) => (query) => (sort) => async (page, params) => { const url = `/case-types/${caseTypeId}/cases/search`; const body = { ...query, ...sort, ...page, }; - const res = await http.post(url, body); + const res = await http.post(url, body, params); return res.data; }; @@ -33,6 +33,10 @@ const hasValue = matcher('hasValue'); const dateRange = matcher('dateRange'); const contains = matcher('contains'); const is = matcher('is'); +const greaterThan = matcher('greaterThan'); +const greaterThanOrEquals = matcher('greaterThanOrEquals'); +const lessThan = matcher('lessThan'); +const lessThanOrEquals = matcher('lessThanOrEquals'); const and = (...criteria) => ({ and: criteria, @@ -83,9 +87,51 @@ const collectionItem = (field, value) => ({ field, value }); +/** + * Build value as field in field to field comparison. Can only be used in context of value for a given querydsl. + * @param field - field name to compare with + * @returns {{field}} object representing field to be compared + */ +const compareToField = (field) => ({ + field, +}); + +/** + * Return field name with a prefix to identify a computed field in querydsl + * @param computedFieldName - computed field name + * @returns {String} field name prefixed with identifier for computed field + */ +const computedField = (computedFieldName) => ':' + computedFieldName; + +/** + * Build optional search request parameters + * @param additionalParams - optional additional parameters to be included in the search query parameters + * @returns object with optional parameters to build + */ +const searchParams = (additionalParams) => { + const params = {...additionalParams}; + return { + withLinks: function () { + Object.assign(params, {['with-links']: 'true'}); + return this; + }, + withComputedFields: function (...computedFields) { + if (computedFields?.length > 0) { + Object.assign(params, {['computed-fields']: computedFields.join(',')}); + } + return this; + }, + build: function () { + return params; + }, + } +}; + export const searchDsl = Object.freeze({ not, and, + compareToField, + computedField, data, equals, equalsIgnoreCase, @@ -95,9 +141,14 @@ export const searchDsl = Object.freeze({ dateRange, contains, is, + greaterThan, + greaterThanOrEquals, + lessThan, + lessThanOrEquals, page, query, or, + searchParams, sort, sortAsc, sortDesc, diff --git a/src/modules/search.test.js b/src/modules/search.test.js index 0ab1c18..fb57dc0 100644 --- a/src/modules/search.test.js +++ b/src/modules/search.test.js @@ -3,8 +3,8 @@ import {search, searchDsl as dsl} from './search'; describe('search', () => { test('should perform search', async () => { const resData = { - total: { count: 2, pages: 1 }, - page: { index: 1, size: 25 }, + total: {count: 2, pages: 1}, + page: {index: 1, size: 25}, results: [ {}, {} ], @@ -26,6 +26,35 @@ describe('search', () => { expect(data).toEqual(resData); }); + + test('should perform search with parameters', async () => { + const resData = { + total: {count: 2, pages: 1}, + page: {index: 1, size: 25}, + results: [ + {}, {} + ], + }; + + const httpStub = { + post: (url, body, params) => { + expect(url).toEqual('/case-types/CaseType1/cases/search'); + expect(body).toEqual({ + query: 'criteria', + sort: 'order', + page: 'limit', + }); + expect(params).toEqual({ + filter: 'test', + }); + return Promise.resolve({data: resData}); + }, + }; + + const data = await search(httpStub)('CaseType1')({query: 'criteria'})({sort: 'order'})({page: 'limit'}, {filter: 'test'}); + + expect(data).toEqual(resData); + }); }); describe('searchDsl', () => { @@ -218,5 +247,101 @@ describe('searchDsl', () => { expect(dsl.data('level1.level2')).toEqual('data.level1.level2'); expect(dsl.data('level1', 'level2')).toEqual('data.level1.level2'); expect(dsl.data(...['level1', 'level2'])).toEqual('data.level1.level2'); + }); + + test('should return computed field prefixed with identifier', () => { + expect(dsl.computedField('computedField')).toEqual(':computedField'); }) + + test('should return `greaterThan` matcher', () => { + const greaterThan = dsl.greaterThan('field1', 1); + expect(greaterThan).toEqual({ + greaterThan: { + field: 'field1', + value: 1 + } + }); + }); + + test('should return `greaterThanOrEquals` matcher', () => { + const greaterThanOrEquals = dsl.greaterThanOrEquals('field1', 1); + expect(greaterThanOrEquals).toEqual({ + greaterThanOrEquals: { + field: 'field1', + value: 1 + } + }); + }); + + test('should return `lessThan` matcher', () => { + const lessThan = dsl.lessThan('field1', 1); + expect(lessThan).toEqual({ + lessThan: { + field: 'field1', + value: 1 + } + }); + }); + + test('should return `lessThanOrEquals` matcher', () => { + const lessThanOrEquals = dsl.lessThanOrEquals('field1', 1); + expect(lessThanOrEquals).toEqual({ + lessThanOrEquals: { + field: 'field1', + value: 1 + } + }); + }); + + test('should return `lessThanOrEquals` matcher for field to field comparison', () => { + const lessThanOrEquals = dsl.lessThanOrEquals('field1', dsl.compareToField('field2')); + expect(lessThanOrEquals).toEqual({ + lessThanOrEquals: { + field: 'field1', + value: { + field: 'field2', + } + } + }); + }); +}); + +describe('searchParams', () => { + test('should build search parameter for links', () => { + const params = dsl.searchParams().withLinks().build(); + expect(params).toEqual({ + ['with-links']: 'true' + }); + }); + + test('should build search parameter for a single computed field', () => { + const params = dsl.searchParams().withComputedFields('computedField1').build(); + expect(params).toEqual({ + ['computed-fields']: 'computedField1', + }); + }); + + test('should build search parameter for multiple computed fields', () => { + const params = dsl.searchParams().withComputedFields('computedField1', 'computedField2').build(); + expect(params).toEqual({ + ['computed-fields']: 'computedField1,computedField2', + }); + }); + + test('should not build search parameter for computed fields when none are provided', () => { + const params = dsl.searchParams().withComputedFields().build(); + expect(params).toEqual({}); + }); + + test('should build search parameters for provided additional parameters', () => { + const params = dsl.searchParams({param1: 'test'}) + .withLinks() + .withComputedFields('computedField1') + .build(); + expect(params).toEqual({ + param1: 'test', + ['with-links']: 'true', + ['computed-fields']: 'computedField1', + }); + }); });