diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 4f5908de4..93a5c1671 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -93,6 +93,24 @@ class InvalidValueError extends Error { const DEFAULT_PAGE_SIZE = 100; +const FilterOperations = [ + 'lt', + 'lte', + 'gt', + 'gte', + 'contains', + 'icontains', + 'search', + 'startsWith', + 'endsWith', + 'has', + 'hasEvery', + 'hasSome', + 'isEmpty', +] as const; + +type FilterOperationType = (typeof FilterOperations)[number] | undefined; + /** * RESTful style API request handler (compliant with JSON:API) */ @@ -1227,6 +1245,7 @@ class RequestHandler { if (!match || !match.groups) { continue; } + const filterKeys = match.groups.match .replaceAll(/[[\]]/g, ' ') .split(' ') @@ -1243,7 +1262,21 @@ class RequestHandler { for (const filterValue of enumerate(value)) { for (let i = 0; i < filterKeys.length; i++) { - const filterKey = filterKeys[i]; + // extract filter operation from (optional) trailing $op + let filterKey = filterKeys[i]; + let filterOp: FilterOperationType | undefined; + const pos = filterKey.indexOf('$'); + if (pos > 0) { + filterOp = filterKey.substring(pos + 1) as FilterOperationType; + filterKey = filterKey.substring(0, pos); + } + + if (!!filterOp && !FilterOperations.includes(filterOp)) { + return { + filter: undefined, + error: this.makeError('invalidFilter', `invalid filter operation: ${filterOp}`), + }; + } const fieldInfo = filterKey === 'id' @@ -1259,11 +1292,11 @@ class RequestHandler { // must be the last segment of a filter return { filter: undefined, error: this.makeError('invalidFilter') }; } - curr[fieldInfo.name] = this.makeFilterValue(fieldInfo, filterValue); + curr[fieldInfo.name] = this.makeFilterValue(fieldInfo, filterValue, filterOp); } else { // relation field if (i === filterKeys.length - 1) { - curr[fieldInfo.name] = this.makeFilterValue(fieldInfo, filterValue); + curr[fieldInfo.name] = this.makeFilterValue(fieldInfo, filterValue, filterOp); } else { // keep going curr = curr[fieldInfo.name] = {}; @@ -1399,18 +1432,42 @@ class RequestHandler { return { select: result, error: undefined, allIncludes }; } - private makeFilterValue(fieldInfo: FieldInfo, value: string): any { + private makeFilterValue(fieldInfo: FieldInfo, value: string, op: FilterOperationType): any { if (fieldInfo.isDataModel) { // relation filter is converted to an ID filter const info = this.typeMap[lowerCaseFirst(fieldInfo.type)]; if (fieldInfo.isArray) { // filtering a to-many relation, imply 'some' operator - return { some: this.makeIdFilter(info.idField, info.idFieldType, value) }; + const values = value.split(',').filter((i) => i); + const filterValue = + values.length > 1 + ? { OR: values.map((v) => this.makeIdFilter(info.idField, info.idFieldType, v)) } + : this.makeIdFilter(info.idField, info.idFieldType, value); + return { some: filterValue }; } else { return { is: this.makeIdFilter(info.idField, info.idFieldType, value) }; } } else { - return this.coerce(fieldInfo.type, value); + const coerced = this.coerce(fieldInfo.type, value); + switch (op) { + case 'icontains': + return { contains: coerced, mode: 'insensitive' }; + case 'hasSome': + case 'hasEvery': { + const values = value + .split(',') + .filter((i) => i) + .map((v) => this.coerce(fieldInfo.type, v)); + return { [op]: values }; + } + case 'isEmpty': + if (value !== 'true' && value !== 'false') { + throw new InvalidValueError(`Not a boolean: ${value}`); + } + return { isEmpty: value === 'true' ? true : false }; + default: + return op ? { [op]: coerced } : { equals: coerced }; + } } } diff --git a/packages/server/tests/api/rest/rest.test.ts b/packages/server/tests/api/rest/rest.test.ts index 5bcee8133..20f936cf2 100644 --- a/packages/server/tests/api/rest/rest.test.ts +++ b/packages/server/tests/api/rest/rest.test.ts @@ -319,7 +319,7 @@ describe('REST server tests', () => { expect((r.body as any).data).toHaveLength(1); expect((r.body as any).data[0]).toMatchObject({ id: 'user2' }); - // attribute filter + // String filter r = await handler({ method: 'get', path: '/user', @@ -329,30 +329,53 @@ describe('REST server tests', () => { expect((r.body as any).data).toHaveLength(1); expect((r.body as any).data[0]).toMatchObject({ id: 'user1' }); - // filter to empty r = await handler({ method: 'get', path: '/user', - query: { ['filter[id]']: 'user3' }, + query: { ['filter[email$contains]']: '1@abc' }, + prisma, + }); + expect((r.body as any).data).toHaveLength(1); + expect((r.body as any).data[0]).toMatchObject({ id: 'user1' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$contains]']: '1@bc' }, prisma, }); expect((r.body as any).data).toHaveLength(0); - // to-many relation collection filter r = await handler({ method: 'get', path: '/user', - query: { ['filter[posts]']: '2' }, + query: { ['filter[email$startsWith]']: 'user1' }, prisma, }); expect((r.body as any).data).toHaveLength(1); - expect((r.body as any).data[0]).toMatchObject({ id: 'user2' }); + expect((r.body as any).data[0]).toMatchObject({ id: 'user1' }); - // multi filter r = await handler({ method: 'get', path: '/user', - query: { ['filter[id]']: 'user1', ['filter[posts]']: '2' }, + query: { ['filter[email$startsWith]']: 'ser1' }, + prisma, + }); + expect((r.body as any).data).toHaveLength(0); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$endsWith]']: '1@abc.com' }, + prisma, + }); + expect((r.body as any).data).toHaveLength(1); + expect((r.body as any).data[0]).toMatchObject({ id: 'user1' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$endsWith]']: '1@abc' }, prisma, }); expect((r.body as any).data).toHaveLength(0); @@ -367,6 +390,41 @@ describe('REST server tests', () => { expect((r.body as any).data).toHaveLength(1); expect((r.body as any).data[0]).toMatchObject({ id: 2 }); + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$gt]']: '0' }, + prisma, + }); + expect((r.body as any).data).toHaveLength(1); + expect((r.body as any).data[0]).toMatchObject({ id: 2 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$gte]']: '1' }, + prisma, + }); + expect((r.body as any).data).toHaveLength(1); + expect((r.body as any).data[0]).toMatchObject({ id: 2 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$lt]']: '0' }, + prisma, + }); + expect((r.body as any).data).toHaveLength(0); + + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$lte]']: '0' }, + prisma, + }); + expect((r.body as any).data).toHaveLength(1); + expect((r.body as any).data[0]).toMatchObject({ id: 1 }); + // Boolean filter r = await handler({ method: 'get', @@ -377,6 +435,42 @@ describe('REST server tests', () => { expect((r.body as any).data).toHaveLength(1); expect((r.body as any).data[0]).toMatchObject({ id: 2 }); + // filter to empty + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[id]']: 'user3' }, + prisma, + }); + expect((r.body as any).data).toHaveLength(0); + + // to-many relation collection filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[posts]']: '2' }, + prisma, + }); + expect((r.body as any).data).toHaveLength(1); + expect((r.body as any).data[0]).toMatchObject({ id: 'user2' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[posts]']: '1,2,3' }, + prisma, + }); + expect((r.body as any).data).toHaveLength(2); + + // multi filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[id]']: 'user1', ['filter[posts]']: '2' }, + prisma, + }); + expect((r.body as any).data).toHaveLength(0); + // to-one relation filter r = await handler({ method: 'get', @@ -420,6 +514,23 @@ describe('REST server tests', () => { }, ], }); + + // invalid filter operation + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$foo]']: '1' }, + prisma, + }); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-filter', + title: 'Invalid filter', + }, + ], + }); }); it('related data filtering', async () => {