diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..1535e139ea --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "deno.enable": true, + "deno.lint": true, + "deno.unstable": true +} \ No newline at end of file diff --git a/main/adapter-commons/LICENSE b/main/adapter-commons/LICENSE new file mode 100644 index 0000000000..3f395cc665 --- /dev/null +++ b/main/adapter-commons/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2021 Feathers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/main/adapter-commons/README.md b/main/adapter-commons/README.md new file mode 100644 index 0000000000..d4f31e7f63 --- /dev/null +++ b/main/adapter-commons/README.md @@ -0,0 +1,22 @@ +# Feathers Adapter Commons + +[![CI](https://github.com/feathersjs/feathers/workflows/Node.js%20CI/badge.svg)](https://github.com/feathersjs/feathers/actions?query=workflow%3A%22Node.js+CI%22) +[![Dependency Status](https://img.shields.io/david/feathersjs/feathers.svg?style=flat-square&path=packages/adapter-commons)](https://david-dm.org/feathersjs/feathers?path=packages/adapter-commons) +[![Download Status](https://img.shields.io/npm/dm/@feathersjs/adapter-commons.svg?style=flat-square)](https://www.npmjs.com/package/@feathersjs/adapter-commons) + +> Shared utility functions for Feathers adatabase adapters + +## About + +This is a repository for handling Feathers common database syntax. See the [API documentation](https://docs.feathersjs.com/api/databases/common.html) for more information. + + +## Authors + +[Feathers contributors](https://github.com/feathersjs/adapter-commons/graphs/contributors) + +## License + +Copyright (c) 2021 [Feathers contributors](https://github.com/feathersjs/feathers/graphs/contributors) + +Licensed under the [MIT license](LICENSE). diff --git a/main/adapter-commons/src/filter-query.ts b/main/adapter-commons/src/filter-query.ts new file mode 100644 index 0000000000..76d1067159 --- /dev/null +++ b/main/adapter-commons/src/filter-query.ts @@ -0,0 +1,116 @@ +import { _ } from '../../commons/src/index.ts'; +import { BadRequest } from '../../errors/src/index.ts'; + +function parse (number: any) { + if (typeof number !== 'undefined') { + return Math.abs(parseInt(number, 10)); + } + + return undefined; +} + +// Returns the pagination limit and will take into account the +// default and max pagination settings +function getLimit (limit: any, paginate: any) { + if (paginate && (paginate.default || paginate.max)) { + const base = paginate.default || 0; + const lower = typeof limit === 'number' && !isNaN(limit) ? limit : base; + const upper = typeof paginate.max === 'number' ? paginate.max : Number.MAX_VALUE; + + return Math.min(lower, upper); + } + + return limit; +} + +// Makes sure that $sort order is always converted to an actual number +function convertSort (sort: any) { + if (typeof sort !== 'object' || Array.isArray(sort)) { + return sort; + } + + return Object.keys(sort).reduce((result, key) => { + result[key] = typeof sort[key] === 'object' + ? sort[key] : parseInt(sort[key], 10); + + return result; + }, {} as { [key: string]: number }); +} + +function cleanQuery (query: any, operators: any, filters: any): any { + if (Array.isArray(query)) { + return query.map(value => cleanQuery(value, operators, filters)); + } else if (_.isObject(query) && query.constructor === {}.constructor) { + const result: { [key: string]: any } = {}; + + _.each(query, (value, key) => { + if (key[0] === '$') { + if (filters[key] !== undefined) { + return; + } + + if (!operators.includes(key)) { + throw new BadRequest(`Invalid query parameter ${key}`, query); + } + } + + result[key] = cleanQuery(value, operators, filters); + }); + + Object.getOwnPropertySymbols(query).forEach(symbol => { + // @ts-ignore + result[symbol] = query[symbol]; + }); + + return result; + } + + return query; +} + +function assignFilters (object: any, query: any, filters: any, options: any) { + if (Array.isArray(filters)) { + _.each(filters, (key) => { + if (query[key] !== undefined) { + object[key] = query[key]; + } + }); + } else { + _.each(filters, (converter, key) => { + const converted = converter(query[key], options); + + if (converted !== undefined) { + object[key] = converted; + } + }); + } + + return object; +} + +export const FILTERS = { + $sort: (value: any) => convertSort(value), + $limit: (value: any, options: any) => getLimit(parse(value), options.paginate), + $skip: (value: any) => parse(value), + $select: (value: any) => value +}; + +export const OPERATORS = ['$in', '$nin', '$lt', '$lte', '$gt', '$gte', '$ne', '$or']; + +// Converts Feathers special query parameters and pagination settings +// and returns them separately a `filters` and the rest of the query +// as `query` +export function filterQuery (query: any, options: any = {}) { + const { + filters: additionalFilters = {}, + operators: additionalOperators = [] + } = options; + const result: { [key: string]: any } = {}; + + result.filters = assignFilters({}, query, FILTERS, options); + result.filters = assignFilters(result.filters, query, additionalFilters, options); + + result.query = cleanQuery(query, OPERATORS.concat(additionalOperators), result.filters); + + return result; +} diff --git a/main/adapter-commons/src/index.ts b/main/adapter-commons/src/index.ts new file mode 100644 index 0000000000..1fae5a0ba3 --- /dev/null +++ b/main/adapter-commons/src/index.ts @@ -0,0 +1,33 @@ +import { _ } from '../../commons/src/index.ts'; + +export { AdapterService } from './service.ts'; +export type { InternalServiceMethods, ServiceOptions, AdapterParams } from './service.ts'; +export { filterQuery, FILTERS, OPERATORS } from './filter-query.ts'; +export * from './sort.ts'; + +// Return a function that filters a result object or array +// and picks only the fields passed as `params.query.$select` +// and additional `otherFields` +export function select (params: any, ...otherFields: any[]) { + const fields = params && params.query && params.query.$select; + + if (Array.isArray(fields) && otherFields.length) { + fields.push(...otherFields); + } + + const convert = (result: any) => { + if (!Array.isArray(fields)) { + return result; + } + + return _.pick(result, ...fields); + }; + + return (result: any) => { + if (Array.isArray(result)) { + return result.map(convert); + } + + return convert(result); + }; +} diff --git a/main/adapter-commons/src/service.ts b/main/adapter-commons/src/service.ts new file mode 100644 index 0000000000..53dd94e73d --- /dev/null +++ b/main/adapter-commons/src/service.ts @@ -0,0 +1,231 @@ +import { NotImplemented, BadRequest, MethodNotAllowed } from '../../errors/src/index.ts'; +import type { ServiceMethods, Params, Id, NullableId, Paginated } from '../../feathers/src/declarations.ts'; +import { filterQuery } from './filter-query.ts'; + +const callMethod = (self: any, name: any, ...args: any[]) => { + if (typeof self[name] !== 'function') { + return Promise.reject(new NotImplemented(`Method ${name} not available`)); + } + + return self[name](...args); +}; + +const alwaysMulti: { [key: string]: boolean } = { + find: true, + get: false, + update: false +}; + +export interface ServiceOptions { + events?: string[]; + multi: boolean|string[]; + id: string; + paginate: { + default?: number; + max?: number; + } + whitelist?: string[]; + allow: string[]; + filters: string[]; +} + +export interface AdapterOptions extends Pick { + Model?: M; +} + +export interface AdapterParams extends Params { + adapter?: Partial>; +} + +/** + * Hook-less (internal) service methods. Directly call database adapter service methods + * without running any service-level hooks. This can be useful if you need the raw data + * from the service and don't want to trigger any of its hooks. + * + * Important: These methods are only available internally on the server, not on the client + * side and only for the Feathers database adapters. + * + * These methods do not trigger events. + * + * @see {@link https://docs.feathersjs.com/guides/migrating.html#hook-less-service-methods} + */ +export interface InternalServiceMethods> { + + /** + * Retrieve all resources from this service, skipping any service-level hooks. + * + * @param params - Service call parameters {@link Params} + * @see {@link HookLessServiceMethods} + * @see {@link https://docs.feathersjs.com/api/services.html#find-params|Feathers API Documentation: .find(params)} + */ + _find (params?: AdapterParams): Promise>; + + /** + * Retrieve a single resource matching the given ID, skipping any service-level hooks. + * + * @param id - ID of the resource to locate + * @param params - Service call parameters {@link Params} + * @see {@link HookLessServiceMethods} + * @see {@link https://docs.feathersjs.com/api/services.html#get-id-params|Feathers API Documentation: .get(id, params)} + */ + _get (id: Id, params?: AdapterParams): Promise; + + /** + * Create a new resource for this service, skipping any service-level hooks. + * + * @param data - Data to insert into this service. + * @param params - Service call parameters {@link Params} + * @see {@link HookLessServiceMethods} + * @see {@link https://docs.feathersjs.com/api/services.html#create-data-params|Feathers API Documentation: .create(data, params)} + */ + _create (data: D | D[], params?: AdapterParams): Promise; + + /** + * Replace any resources matching the given ID with the given data, skipping any service-level hooks. + * + * @param id - ID of the resource to be updated + * @param data - Data to be put in place of the current resource. + * @param params - Service call parameters {@link Params} + * @see {@link HookLessServiceMethods} + * @see {@link https://docs.feathersjs.com/api/services.html#update-id-data-params|Feathers API Documentation: .update(id, data, params)} + */ + _update (id: Id, data: D, params?: AdapterParams): Promise; + + /** + * Merge any resources matching the given ID with the given data, skipping any service-level hooks. + * + * @param id - ID of the resource to be patched + * @param data - Data to merge with the current resource. + * @param params - Service call parameters {@link Params} + * @see {@link HookLessServiceMethods} + * @see {@link https://docs.feathersjs.com/api/services.html#patch-id-data-params|Feathers API Documentation: .patch(id, data, params)} + */ + _patch (id: NullableId, data: D, params?: AdapterParams): Promise; + + /** + * Remove resources matching the given ID from the this service, skipping any service-level hooks. + * + * @param id - ID of the resource to be removed + * @param params - Service call parameters {@link Params} + * @see {@link HookLessServiceMethods} + * @see {@link https://docs.feathersjs.com/api/services.html#remove-id-params|Feathers API Documentation: .remove(id, params)} + */ + _remove (id: NullableId, params?: AdapterParams): Promise; +} + +export class AdapterService< + T = any, + D = Partial, + O extends Partial = Partial +> implements ServiceMethods, D> { + options: ServiceOptions & O; + + constructor (options: O) { + this.options = Object.assign({ + id: 'id', + events: [], + paginate: {}, + multi: false, + filters: [], + allow: [] + }, options); + } + + get id () { + return this.options.id; + } + + get events () { + return this.options.events; + } + + filterQuery (params: AdapterParams = {}, opts: any = {}) { + const paginate = typeof params.paginate !== 'undefined' + ? params.paginate + : this.getOptions(params).paginate; + const { query = {} } = params; + const options = Object.assign({ + operators: this.options.whitelist || this.options.allow || [], + filters: this.options.filters, + paginate + }, opts); + const result = filterQuery(query, options); + + return Object.assign(result, { paginate }); + } + + allowsMulti (method: string, params: AdapterParams = {}) { + const always = alwaysMulti[method]; + + if (typeof always !== 'undefined') { + return always; + } + + const { multi: option } = this.getOptions(params); + + if (option === true || option === false) { + return option; + } + + return option.includes(method); + } + + getOptions (params: AdapterParams): ServiceOptions & { model?: any } { + return { + ...this.options, + ...params.adapter + } + } + + find (params?: AdapterParams): Promise> { + return callMethod(this, '_find', params); + } + + get (id: Id, params?: AdapterParams): Promise { + return callMethod(this, '_get', id, params); + } + + create (data: Partial, params?: AdapterParams): Promise; + create (data: Partial[], params?: AdapterParams): Promise; + create (data: Partial | Partial[], params?: AdapterParams): Promise { + if (Array.isArray(data) && !this.allowsMulti('create', params)) { + return Promise.reject(new MethodNotAllowed('Can not create multiple entries')); + } + + return callMethod(this, '_create', data, params); + } + + update (id: Id, data: D, params?: AdapterParams): Promise { + if (id === null || Array.isArray(data)) { + return Promise.reject(new BadRequest( + 'You can not replace multiple instances. Did you mean \'patch\'?' + )); + } + + return callMethod(this, '_update', id, data, params); + } + + patch (id: Id, data: Partial, params?: AdapterParams): Promise; + patch (id: null, data: Partial, params?: AdapterParams): Promise; + patch (id: NullableId, data: Partial, params?: AdapterParams): Promise; + patch (id: NullableId, data: Partial, params?: AdapterParams): Promise { + if (id === null && !this.allowsMulti('patch', params)) { + return Promise.reject(new MethodNotAllowed('Can not patch multiple entries')); + } + + return callMethod(this, '_patch', id, data, params); + } + + remove (id: Id, params?: AdapterParams): Promise; + remove (id: null, params?: AdapterParams): Promise; + remove (id: NullableId, params?: AdapterParams): Promise; + remove (id: NullableId, params?: AdapterParams): Promise { + if (id === null && !this.allowsMulti('remove', params)) { + return Promise.reject(new MethodNotAllowed('Can not remove multiple entries')); + } + + return callMethod(this, '_remove', id, params); + } + + async setup () {} +} diff --git a/main/adapter-commons/src/sort.ts b/main/adapter-commons/src/sort.ts new file mode 100644 index 0000000000..0781c61d8d --- /dev/null +++ b/main/adapter-commons/src/sort.ts @@ -0,0 +1,110 @@ +// Sorting algorithm taken from NeDB (https://github.com/louischatriot/nedb) +// See https://github.com/louischatriot/nedb/blob/e3f0078499aa1005a59d0c2372e425ab789145c1/lib/model.js#L189 + +function compareNSB (a: any, b: any): 0 | 1 | -1 { + if (a < b) { return -1; } + if (a > b) { return 1; } + return 0; +} + +function compareArrays (a: any, b: any) { + let i; + let comp; + + for (i = 0; i < Math.min(a.length, b.length); i += 1) { + comp = compare(a[i], b[i]); + + if (comp !== 0) { return comp; } + } + + // Common section was identical, longest one wins + return compareNSB(a.length, b.length); +} + +function compare (a: any, b: any, compareStrings: any = compareNSB): any { + // undefined + if (a === undefined) { return b === undefined ? 0 : -1; } + if (b === undefined) { return a === undefined ? 0 : 1; } + + // null + if (a === null) { return b === null ? 0 : -1; } + if (b === null) { return a === null ? 0 : 1; } + + // Numbers + if (typeof a === 'number') { return typeof b === 'number' ? compareNSB(a, b) : -1; } + if (typeof b === 'number') { return typeof a === 'number' ? compareNSB(a, b) : 1; } + + // Strings + if (typeof a === 'string') { return typeof b === 'string' ? compareStrings(a, b) : -1; } + if (typeof b === 'string') { return typeof a === 'string' ? compareStrings(a, b) : 1; } + + // Booleans + if (typeof a === 'boolean') { return typeof b === 'boolean' ? compareNSB(a, b) : -1; } + if (typeof b === 'boolean') { return typeof a === 'boolean' ? compareNSB(a, b) : 1; } + + // Dates + if (a instanceof Date) { return b instanceof Date ? compareNSB(a.getTime(), b.getTime()) : -1; } + if (b instanceof Date) { return a instanceof Date ? compareNSB(a.getTime(), b.getTime()) : 1; } + + // Arrays (first element is most significant and so on) + if (Array.isArray(a)) { return Array.isArray(b) ? compareArrays(a, b) : -1; } + if (Array.isArray(b)) { return Array.isArray(a) ? compareArrays(a, b) : 1; } + + // Objects + const aKeys = Object.keys(a).sort(); + const bKeys = Object.keys(b).sort(); + let comp = 0; + + for (let i = 0; i < Math.min(aKeys.length, bKeys.length); i += 1) { + comp = compare(a[aKeys[i]], b[bKeys[i]]); + + if (comp !== 0) { return comp; } + } + + return compareNSB(aKeys.length, bKeys.length); +} + +// An in-memory sorting function according to the +// $sort special query parameter +export function sorter ($sort: any) { + let sortLevels = 0; // > 0 if $sort has tags with '.' i.e. '{a: 1, b: -1, "c.x.z": 1}' + + const getVal = (a: any, sortKeys: any[]) => { + const keys = sortKeys.map(key => key); + let val = a; + do { + const key = keys.shift(); + val = val[key]; + } while (keys.length); + + return val; + }; + + const criteria = Object.keys($sort).map(key => { + const direction = $sort[key]; + const keys = key.split('.'); + sortLevels += (keys.length > 1) ? 1 : 0; + + return { keys, direction }; + }); + + return function (a: any, b: any) { + let compared; + + for (const criterion of criteria) { + if (sortLevels) { + compared = criterion.direction * compare(getVal(a, criterion.keys), getVal(b, criterion.keys)); + } else { + compared = criterion.direction * compare(a[criterion.keys[0]], b[criterion.keys[0]]); + } + + if (compared !== 0) { + return compared; + } + } + + return 0; + }; +} + +export { compareNSB, compareArrays, compare } \ No newline at end of file diff --git a/main/adapter-commons/test/commons.test.ts b/main/adapter-commons/test/commons.test.ts new file mode 100644 index 0000000000..5ecc9a6b7f --- /dev/null +++ b/main/adapter-commons/test/commons.test.ts @@ -0,0 +1,67 @@ +import { it, assertEquals } from '../../commons/src/index.ts' +import { select } from '../src/index.ts'; + +it('adapter-commons: select', () => { + const selector = select({ + query: { $select: ['name', 'age'] } + }); + + return Promise.resolve({ + name: 'David', + age: 3, + test: 'me' + }).then(selector).then(result => assertEquals(result, { + name: 'David', + age: 3 + })); +}); + +it('adapter-commons: select with arrays', () => { + const selector = select({ + query: { $select: ['name', 'age'] } + }); + + return Promise.resolve([{ + name: 'David', + age: 3, + test: 'me' + }, { + name: 'D', + age: 4, + test: 'you' + }]).then(selector).then(result => assertEquals(result, [{ + name: 'David', + age: 3 + }, { + name: 'D', + age: 4 + }])); +}); + +it('adapter-commons: select with no query', () => { + const selector = select({}); + const data = { + name: 'David' + }; + + return Promise.resolve(data).then(selector).then(result => + assertEquals(result, data) + ); +}); + +it('adapter-commons: select with other fields', () => { + const selector = select({ + query: { $select: [ 'name' ] } + }, 'id'); + const data = { + id: 'me', + name: 'David', + age: 10 + }; + + return Promise.resolve(data) + .then(selector) + .then(result => { + assertEquals(result, { id: 'me', name: 'David' }) + }); +}); diff --git a/main/adapter-commons/test/filter-query.test.ts b/main/adapter-commons/test/filter-query.test.ts new file mode 100644 index 0000000000..c9a971da21 --- /dev/null +++ b/main/adapter-commons/test/filter-query.test.ts @@ -0,0 +1,256 @@ +import { it, assert, assertEquals, assertStrictEquals, assertThrows } from '../../commons/src/testing.ts' +import { errors } from '../../errors/src/index.ts' +import { objectId } from 'https://deno.land/x/objectid@0.2.0/mod.ts'; + +import { filterQuery } from '../src/filter-query.ts'; + +// const { ObjectId } = Bson +const makeLimitQuery = () => ({ $limit: 1 }) +const makeSkipQuery = () => ({ $skip: 1 }) +const makeSelectQuery = () => ({ $select: 1 }) + +// describe('@feathersjs/adapter-commons/filterQuery', () => { +// describe('$sort', () => { +it('returns $sort when present in query', () => { + const originalQuery = { $sort: { name: 1 } }; + const { filters, query } = filterQuery(originalQuery); + + assertStrictEquals(filters.$sort.name, 1); + assertEquals(query, {}); + assertEquals(originalQuery, { + $sort: { name: 1 } + }, 'does not modify original query'); +}); + +it('returns $sort when present in query as an object', () => { + const { filters, query } = filterQuery({ $sort: { name: { something: 10 } } }); + + assertStrictEquals(filters.$sort.name.something, 10); + assertEquals(query, {}); +}); + +it('converts strings in $sort', () => { + const { filters, query } = filterQuery({ $sort: { test: '-1' } }); + + assertStrictEquals(filters.$sort.test, -1); + assertEquals(query, {}); +}); + +it('does not convert $sort arrays', () => { + const $sort = [ [ 'test', '-1' ], [ 'a', '1' ] ]; + const { filters, query } = filterQuery({ $sort }); + + assertEquals(filters.$sort, $sort); + assertEquals(query, {}); +}); + +it('throws an error when special parameter is not known', () => { + try { + const query = { $foo: 1 }; + filterQuery(query); + assert(false, 'Should never get here'); + } catch (error: any) { + assertStrictEquals(error.name, 'BadRequest'); + assertStrictEquals(error.message, 'Invalid query parameter $foo'); + } +}); + +it('returns undefined when not present in query', () => { + const query = { foo: 1 }; + const { filters } = filterQuery(query); + + assertStrictEquals(filters.$sort, undefined); +}); + +it('returns $limit when present in query', () => { + const limitQuery = makeLimitQuery() + const { filters, query } = filterQuery(limitQuery); + + assertStrictEquals(filters.$limit, 1); + assertEquals(query, {}); +}); + +it('returns undefined when not present in query', () => { + const query = { foo: 1 }; + const { filters } = filterQuery(query); + + assertStrictEquals(filters.$limit, undefined); +}); + +it('removes $limit from query when present', () => { + const limitQuery = makeLimitQuery() + assertEquals(filterQuery(limitQuery).query, {}); +}); + +it('parses $limit strings into integers (#4)', () => { + const { filters } = filterQuery({ $limit: '2' }); + + assertStrictEquals(filters.$limit, 2); +}); + +it('allows $limit 0', () => { + const { filters } = filterQuery({ $limit: 0 }, { default: 10 }); + + assertStrictEquals(filters.$limit, 0); +}); + +// describe('pagination', () => { +it('limits with default pagination', () => { + const { filters } = filterQuery({}, { paginate: { default: 10 } }); + + assertStrictEquals(filters.$limit, 10); +}); + +it('limits with max pagination', () => { + const { filters } = filterQuery({ $limit: 20 }, { paginate: { default: 5, max: 10 } }); + const { filters: filtersNeg } = filterQuery({ $limit: -20 }, { paginate: { default: 5, max: 10 } }); + + assertStrictEquals(filters.$limit, 10); + assertStrictEquals(filtersNeg.$limit, 10); +}); + +it('limits with default pagination when not a number', () => { + const { filters } = filterQuery({ $limit: 'something' }, { paginate: { default: 5, max: 10 } }); + + assertStrictEquals(filters.$limit, 5); +}); + +it('limits to 0 when no paginate.default and not a number', () => { + const { filters } = filterQuery({ $limit: 'something' }, { paginate: { max: 10 } }); + + assertStrictEquals(filters.$limit, 0); +}); + +it('still uses paginate.max when there is no paginate.default (#2104)', () => { + const { filters } = filterQuery({ $limit: 100 }, { paginate: { max: 10 } }); + + assertStrictEquals(filters.$limit, 10); +}); + +// describe('$skip', () => { +it('returns $skip when present in query', () => { + const skipQuery = makeSkipQuery() + const { filters } = filterQuery(skipQuery); + + assertStrictEquals(filters.$skip, 1); +}); + +it('removes $skip from query when present', () => { + const skipQuery = makeSkipQuery() + assertEquals(filterQuery(skipQuery).query, {}); +}); + +it('returns undefined when not present in query', () => { + const query = { foo: 1 }; + const { filters } = filterQuery(query); + + assertStrictEquals(filters.$skip, undefined); +}); + +it('parses $skip strings into integers (#4)', () => { + const { filters } = filterQuery({ $skip: '33' }); + + assertStrictEquals(filters.$skip, 33); +}); + +// describe('$select', () => { +it('returns $select when present in query', () => { + const selectQuery = makeSelectQuery() + const { filters } = filterQuery(selectQuery); + + assertStrictEquals(filters.$select, 1); +}); + +it('removes $select from query when present', () => { + const selectQuery = makeSelectQuery() + assertEquals(filterQuery(selectQuery).query, {}); +}); + +it('returns undefined when not present in query', () => { + const query = { foo: 1 }; + const { filters } = filterQuery(query); + + assertStrictEquals(filters.$select, undefined); +}); + +it('includes Symbols', () => { + const TEST = Symbol('testing'); + const original = { + [TEST]: 'message', + other: true, + sub: { [TEST]: 'othermessage' } + }; + + const { query } = filterQuery(original); + + assertEquals(query, { + [TEST]: 'message', + other: true, + sub: { [TEST]: 'othermessage' } + }); +}); + +it('only converts plain objects', () => { + const userId = objectId().toString(); + const original = { + userId + }; + + const { query } = filterQuery(original); + + assertEquals(query, original); +}); + +// describe('arrays', () => { +it('validates queries in arrays', () => { + assertThrows( + () => { + filterQuery({ + $or: [{ $exists: false }] + }); + }, + errors.BadRequest, + 'Invalid query parameter $exists' + ); +}); + +// describe('additional filters', () => { +it('throw error when not set as additionals', () => { + try { + filterQuery({ $select: 1, $known: 1 }); + assert(false, 'Should never get here'); + } catch (error: any) { + assertStrictEquals(error.message, 'Invalid query parameter $known'); + } +}); + +it('returns default and known additional filters (array)', () => { + const query = { $select: ['a', 'b'], $known: 1, $unknown: 1 }; + const { filters } = filterQuery(query, { filters: [ '$known', '$unknown' ] }); + + assertStrictEquals(filters.$unknown, 1); + assertStrictEquals(filters.$known, 1); + assertEquals(filters.$select, [ 'a', 'b' ]); +}); + +it('returns default and known additional filters (object)', () => { + const { filters } = filterQuery({ + $known: 1, + $select: 1 + }, { filters: { $known: (value: any) => value.toString() } }); + + assertStrictEquals(filters.$unknown, undefined); + assertStrictEquals(filters.$known, '1'); + assertStrictEquals(filters.$select, 1); +}); + +// describe('additional operators', () => { +it('returns query with default and known additional operators', () => { + const { query } = filterQuery({ + $ne: 1, $known: 1 + }, { operators: [ '$known' ] }); + + assertStrictEquals(query.$ne, 1); + assertStrictEquals(query.$known, 1); + assertStrictEquals(query.$unknown, undefined); +}); diff --git a/main/adapter-commons/test/service.test.ts b/main/adapter-commons/test/service.test.ts new file mode 100644 index 0000000000..4e3a106cd2 --- /dev/null +++ b/main/adapter-commons/test/service.test.ts @@ -0,0 +1,216 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { it, assert, assertEquals, assertStrictEquals } from '../../commons/src/index.ts' +import { NotImplemented } from '../../errors/src/index.ts'; +import { AdapterService, InternalServiceMethods } from '../src/index.ts'; +import { Params, Id, NullableId } from '../../feathers/src/declarations.ts'; + +const METHODS = [ 'find', 'get', 'create', 'update', 'patch', 'remove' ]; + +// describe('@feathersjs/adapter-commons/service', () => { +class CustomService extends AdapterService { +} + +// describe('errors when method does not exit', () => { +METHODS.forEach(method => { + it(`Undeclared Extended Methods: ${method}`, () => { + const service = new CustomService({}); + + // @ts-ignore suppress + return service[method]() + .then(() => { + throw new Error('Should never get here'); + }).catch((error: Error) => { + assert(error instanceof NotImplemented); + assertStrictEquals(error.message, `Method _${method} not available`); + }); + }); +}); + +// // describe('works when methods exist', () => { +class MethodService extends AdapterService implements InternalServiceMethods { + _find (_params?: Params) { + return Promise.resolve([]); + } + + _get (id: Id, _params?: Params) { + return Promise.resolve({ id }); + } + + _create (data: Partial | Partial[], _params?: Params) { + return Promise.resolve(data); + } + + _update (id: NullableId, _data: any, _params?: Params) { + return Promise.resolve({ id }); + } + + _patch (id: NullableId, _data: any, _params?: Params) { + return Promise.resolve({ id }); + } + + _remove (id: NullableId, _params?: Params) { + return Promise.resolve({ id }); + } +} + +METHODS.forEach(method => { + it(`Internal Methods: ${method}`, () => { + const service = new MethodService({}); + const args = []; + + if (method !== 'find') { + args.push('test'); + } + + if (method === 'update' || method === 'patch') { + args.push({}); + } + + // @ts-ignore suppress + return service[method](...args); + }); +}); + +it('does not allow multi patch', () => { + const service = new MethodService({}); + + return service.patch(null, {}) + .then(() => assert(false)) + .catch(error => { + assertStrictEquals(error.name, 'MethodNotAllowed'); + assertStrictEquals(error.message, 'Can not patch multiple entries'); + }); +}); + +it('does not allow multi remove', () => { + const service = new MethodService({}); + + return service.remove(null, {}) + .then(() => assert(false)) + .catch(error => { + assertStrictEquals(error.name, 'MethodNotAllowed'); + assertStrictEquals(error.message, 'Can not remove multiple entries'); + }); +}); + +it('does not allow multi create', () => { + const service = new MethodService({}); + + return service.create([]) + .then(() => assert(false)) + .catch(error => { + assertStrictEquals(error.name, 'MethodNotAllowed'); + assertStrictEquals(error.message, 'Can not create multiple entries'); + }); +}); + +it('multi can be set to true', () => { + const service = new MethodService({}); + + service.options.multi = true; + + return service.create([]) + .then(() => assert(true)); +}); + +it('filterQuery', () => { + const service = new CustomService({ + whitelist: [ '$something' ] + }); + const filtered = service.filterQuery({ + query: { $limit: 10, test: 'me' } + }); + + assertEquals(filtered, { + paginate: {}, + filters: { $limit: 10 }, + query: { test: 'me' } + }); + + const withWhitelisted = service.filterQuery({ + query: { $limit: 10, $something: 'else' } + }); + + assertEquals(withWhitelisted, { + paginate: {}, + filters: { $limit: 10 }, + query: { $something: 'else' } + }); +}); + +it('getOptions', () => { + const service = new AdapterService({ + multi: true + }); + const opts = service.getOptions({ + adapter: { + multi: [ 'create' ], + paginate: { + default: 10, + max: 100 + } + } + }); + + assertEquals(opts, { + id: 'id', + events: [], + paginate: { default: 10, max: 100 }, + multi: [ 'create' ], + filters: [], + allow: [] + }); +}); + +// 'allowsMulti with true' +const allowsMultiWithTrueService = new AdapterService({multi: true}); + +it('allowsMulti with true: returns true for multiple methods', () => { + assertStrictEquals(allowsMultiWithTrueService.allowsMulti('patch'), true); +}); + +it('allowsMulti with true: returns false for always non-multiple methods', () => { + assertStrictEquals(allowsMultiWithTrueService.allowsMulti('update'), false); +}); + +it('allowsMulti with true: returns true for unknown methods', () => { + assertStrictEquals(allowsMultiWithTrueService.allowsMulti('other'), true); +}); + +// 'allowsMulti with false' +const multiWithFalseService = new AdapterService({multi: false}); + +it('allowsMulti with false: returns false for multiple methods', () => { + assertStrictEquals(multiWithFalseService.allowsMulti('remove'), false); +}); + +it('allowsMulti with false: returns true for always multiple methods', () => { + assertStrictEquals(multiWithFalseService.allowsMulti('find'), true); +}); + +it('allowsMulti with false: returns false for unknown methods', () => { + assertStrictEquals(multiWithFalseService.allowsMulti('other'), false); +}); + +// 'allowsMulti with array' +const multiArrayService = new AdapterService({multi: ['create', 'get', 'other']}); + +it('allowsMulti with array: returns true for specified multiple methods', () => { + assertStrictEquals(multiArrayService.allowsMulti('create'), true); +}); + +it('allowsMulti with array: returns false for non-specified multiple methods', () => { + assertStrictEquals(multiArrayService.allowsMulti('patch'), false); +}); + +it('allowsMulti with array: returns false for specified always multiple methods', () => { + assertStrictEquals(multiArrayService.allowsMulti('get'), false); +}); + +it('allowsMulti with array: returns true for specified unknown methods', () => { + assertStrictEquals(multiArrayService.allowsMulti('other'), true); +}); + +it('allowsMulti with array: returns false for non-specified unknown methods', () => { + assertStrictEquals(multiArrayService.allowsMulti('another'), false); +}); diff --git a/main/adapter-commons/test/sort.test.ts b/main/adapter-commons/test/sort.test.ts new file mode 100644 index 0000000000..ad40afd6d9 --- /dev/null +++ b/main/adapter-commons/test/sort.test.ts @@ -0,0 +1,289 @@ +import { it, assertEquals, assert } from '../../commons/src/testing.ts' +import { sorter } from '../src/index.ts'; + +// describe('sorter', () => { +it('simple sorter', () => { + const array = [{ + name: 'David' + }, { + name: 'Eric' + }]; + + const sort = sorter({ + name: -1 + }); + + assertEquals(array.sort(sort), [{ + name: 'Eric' + }, { + name: 'David' + }]); +}); + +it('simple sorter with arrays', () => { + const array = [{ + names: [ 'a', 'b' ] + }, { + names: [ 'c', 'd' ] + }]; + + const sort = sorter({ + names: -1 + }); + + assertEquals(array.sort(sort), [{ + names: [ 'c', 'd' ] + }, { + names: [ 'a', 'b' ] + }]); +}); + +it('simple sorter with objects', () => { + const array = [{ + names: { + first: 'Dave', + last: 'L' + } + }, { + names: { + first: 'A', + last: 'B' + } + }]; + + const sort = sorter({ + names: 1 + }); + + assertEquals(array.sort(sort), [{ + names: { + first: 'A', + last: 'B' + } + }, { + names: { + first: 'Dave', + last: 'L' + } + }]); +}); + +it('two property sorter', () => { + const array = [{ + name: 'David', + counter: 0 + }, { + name: 'Eric', + counter: 1 + }, { + name: 'David', + counter: 1 + }, { + name: 'Eric', + counter: 0 + }]; + + const sort = sorter({ + name: -1, + counter: 1 + }); + + assertEquals(array.sort(sort), [ + { name: 'Eric', counter: 0 }, + { name: 'Eric', counter: 1 }, + { name: 'David', counter: 0 }, + { name: 'David', counter: 1 } + ]); +}); + +it('two property sorter with names', () => { + const array = [{ + name: 'David', + counter: 0 + }, { + name: 'Eric', + counter: 1 + }, { + name: 'Andrew', + counter: 1 + }, { + name: 'David', + counter: 1 + }, { + name: 'Andrew', + counter: 0 + }, { + name: 'Eric', + counter: 0 + }]; + + const sort = sorter({ + name: -1, + counter: 1 + }); + + assertEquals(array.sort(sort), [ + { name: 'Eric', counter: 0 }, + { name: 'Eric', counter: 1 }, + { name: 'David', counter: 0 }, + { name: 'David', counter: 1 }, + { name: 'Andrew', counter: 0 }, + { name: 'Andrew', counter: 1 } + ]); +}); + +it('three property sorter with names', () => { + const array = [{ + name: 'David', + counter: 0, + age: 2 + }, { + name: 'Eric', + counter: 1, + age: 2 + }, { + name: 'David', + counter: 1, + age: 1 + }, { + name: 'Eric', + counter: 0, + age: 1 + }, { + name: 'Andrew', + counter: 0, + age: 2 + }, { + name: 'Andrew', + counter: 0, + age: 1 + }]; + + const sort = sorter({ + name: -1, + counter: 1, + age: -1 + }); + + assertEquals(array.sort(sort), [ + { name: 'Eric', counter: 0, age: 1 }, + { name: 'Eric', counter: 1, age: 2 }, + { name: 'David', counter: 0, age: 2 }, + { name: 'David', counter: 1, age: 1 }, + { name: 'Andrew', counter: 0, age: 2 }, + { name: 'Andrew', counter: 0, age: 1 } + ]); +}); + +// describe('sorter mongoDB-like sorting on embedded objects', () => { +const makeData = () => ([ +{ _id: 1, item: { category: 'cake', type: 'chiffon' }, amount: 10 }, +{ _id: 2, item: { category: 'cookies', type: 'chocolate chip' }, amount: 50 }, +{ _id: 3, item: { category: 'cookies', type: 'chocolate chip' }, amount: 15 }, +{ _id: 4, item: { category: 'cake', type: 'lemon' }, amount: 30 }, +{ _id: 5, item: { category: 'cake', type: 'carrot' }, amount: 20 }, +{ _id: 6, item: { category: 'brownies', type: 'blondie' }, amount: 10 } +]) + +it('straight test', () => { + const data = makeData() + const sort = sorter({ + amount: -1 + }); + + assertEquals(data.sort(sort), [ + { _id: 2, item: { category: 'cookies', type: 'chocolate chip' }, amount: 50 }, + { _id: 4, item: { category: 'cake', type: 'lemon' }, amount: 30 }, + { _id: 5, item: { category: 'cake', type: 'carrot' }, amount: 20 }, + { _id: 3, item: { category: 'cookies', type: 'chocolate chip' }, amount: 15 }, + { _id: 1, item: { category: 'cake', type: 'chiffon' }, amount: 10 }, + { _id: 6, item: { category: 'brownies', type: 'blondie' }, amount: 10 } + ]); +}); + +it('embedded sort 1', () => { + const data = makeData() + const sort = sorter({ + 'item.category': 1, + 'item.type': 1 + }); + + assertEquals(data.sort(sort), [ + { _id: 6, item: { category: 'brownies', type: 'blondie' }, amount: 10 }, + { _id: 5, item: { category: 'cake', type: 'carrot' }, amount: 20 }, + { _id: 1, item: { category: 'cake', type: 'chiffon' }, amount: 10 }, + { _id: 4, item: { category: 'cake', type: 'lemon' }, amount: 30 }, + { _id: 2, item: { category: 'cookies', type: 'chocolate chip' }, amount: 50 }, + { _id: 3, item: { category: 'cookies', type: 'chocolate chip' }, amount: 15 } + ]); +}); + +it('embedded sort 2', () => { + const data = makeData() + const sort = sorter({ + 'item.category': 1, + 'item.type': 1, + amount: 1 + }); + + assertEquals(data.sort(sort), [ + { _id: 6, item: { category: 'brownies', type: 'blondie' }, amount: 10 }, + { _id: 5, item: { category: 'cake', type: 'carrot' }, amount: 20 }, + { _id: 1, item: { category: 'cake', type: 'chiffon' }, amount: 10 }, + { _id: 4, item: { category: 'cake', type: 'lemon' }, amount: 30 }, + { _id: 3, item: { category: 'cookies', type: 'chocolate chip' }, amount: 15 }, + { _id: 2, item: { category: 'cookies', type: 'chocolate chip' }, amount: 50 } + ]); +}); + +it('embedded sort 3', () => { + const data = makeData() + const sort = sorter({ + 'item.category': 1, + 'item.type': 1, + amount: -1 + }); + + assert.deepStrictEqual(data.sort(sort), [ + { _id: 6, item: { category: 'brownies', type: 'blondie' }, amount: 10 }, + { _id: 5, item: { category: 'cake', type: 'carrot' }, amount: 20 }, + { _id: 1, item: { category: 'cake', type: 'chiffon' }, amount: 10 }, + { _id: 4, item: { category: 'cake', type: 'lemon' }, amount: 30 }, + { _id: 2, item: { category: 'cookies', type: 'chocolate chip' }, amount: 50 }, + { _id: 3, item: { category: 'cookies', type: 'chocolate chip' }, amount: 15 } + ]); +}); + +it('embedded sort 4', () => { + const data = makeData() + const sort = sorter({ + amount: -1, + 'item.category': 1 + }); + + assert.deepStrictEqual(data.sort(sort), [ + { _id: 2, item: { category: 'cookies', type: 'chocolate chip' }, amount: 50 }, + { _id: 4, item: { category: 'cake', type: 'lemon' }, amount: 30 }, + { _id: 5, item: { category: 'cake', type: 'carrot' }, amount: 20 }, + { _id: 3, item: { category: 'cookies', type: 'chocolate chip' }, amount: 15 }, + { _id: 6, item: { category: 'brownies', type: 'blondie' }, amount: 10 }, + { _id: 1, item: { category: 'cake', type: 'chiffon' }, amount: 10 } + ]); +}); + +it('embedded sort 5', () => { + const data = makeData() + const sort = sorter({ + 'item.category': 1, + amount: 1 + }); + + assert.deepStrictEqual(data.sort(sort), [ + { _id: 6, item: { category: 'brownies', type: 'blondie' }, amount: 10 }, + { _id: 1, item: { category: 'cake', type: 'chiffon' }, amount: 10 }, + { _id: 5, item: { category: 'cake', type: 'carrot' }, amount: 20 }, + { _id: 4, item: { category: 'cake', type: 'lemon' }, amount: 30 }, + { _id: 3, item: { category: 'cookies', type: 'chocolate chip' }, amount: 15 }, + { _id: 2, item: { category: 'cookies', type: 'chocolate chip' }, amount: 50 } + ]); +}); diff --git a/main/adapter-commons/tsconfig.json b/main/adapter-commons/tsconfig.json new file mode 100644 index 0000000000..316fd41336 --- /dev/null +++ b/main/adapter-commons/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig", + "include": [ + "src/**/*.ts" + ], + "compilerOptions": { + "outDir": "lib" + } +} diff --git a/main/commons/LICENSE b/main/commons/LICENSE new file mode 100644 index 0000000000..3f395cc665 --- /dev/null +++ b/main/commons/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2021 Feathers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/main/commons/README.md b/main/commons/README.md new file mode 100644 index 0000000000..f5e068413d --- /dev/null +++ b/main/commons/README.md @@ -0,0 +1,17 @@ +# Feathers Commons + +[![CI](https://github.com/feathersjs/feathers/workflows/CI/badge.svg)](https://github.com/feathersjs/feathers/actions?query=workflow%3ACI) +[![Dependency Status](https://img.shields.io/david/feathersjs/feathers.svg?style=flat-square&path=packages/commons)](https://david-dm.org/feathersjs/feathers?path=packages/commons) +[![Download Status](https://img.shields.io/npm/dm/@feathersjs/commons.svg?style=flat-square)](https://www.npmjs.com/package/@feathersjs/commons) + +> Shared Feathers utility functions + +## About + +This is a repository for utility functionality that is shared between different Feathers plugin and used by the main repository. + +## License + +Copyright (c) 2021 [Feathers contributors](https://github.com/feathersjs/feathers/graphs/contributors) + +Licensed under the [MIT license](LICENSE). diff --git a/main/commons/src/debug.ts b/main/commons/src/debug.ts new file mode 100644 index 0000000000..48f5d23298 --- /dev/null +++ b/main/commons/src/debug.ts @@ -0,0 +1,26 @@ +export type DebugFunction = (...args: any[]) => void; +export type DebugInitializer = (name: string) => DebugFunction; + +const debuggers: { [key: string]: DebugFunction } = {}; + +export function noopDebug (): DebugFunction { + return () => {} +} + +let defaultInitializer: DebugInitializer = noopDebug; + +export function setDebug (debug: DebugInitializer) { + defaultInitializer = debug; + + Object.keys(debuggers).forEach(name => { + debuggers[name] = debug(name); + }); +} + +export function createDebug (name: string) { + if (!debuggers[name]) { + debuggers[name] = defaultInitializer(name); + } + + return (...args: any[]) => debuggers[name](...args); +} diff --git a/main/commons/src/events.ts b/main/commons/src/events.ts new file mode 100644 index 0000000000..96090a2b47 --- /dev/null +++ b/main/commons/src/events.ts @@ -0,0 +1,546 @@ +/* eslint-disable no-console */ +// deno-lint-ignore-file no-explicit-any +// deno-lint-ignore-file no-implicit-any + +// Adapted from node_modules/events/events.js for Deno + +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +const R = typeof Reflect === 'object' ? Reflect : null; +const ReflectApply = R && typeof R.apply === 'function' + ? R.apply + : function ReflectApply (target: any, receiver: any, args: any) { + return Function.prototype.apply.call(target, receiver, args); + }; + +let ReflectOwnKeys: any; +if (R && typeof R.ownKeys === 'function') { + ReflectOwnKeys = R.ownKeys; +} else if (Object.getOwnPropertySymbols) { + ReflectOwnKeys = function ReflectOwnKeys (target: any) { + return Object.getOwnPropertyNames(target) + // @ts-ignore suppress + .concat(Object.getOwnPropertySymbols(target)); + }; +} else { + ReflectOwnKeys = function ReflectOwnKeys (target: any) { + return Object.getOwnPropertyNames(target); + }; +} + +function ProcessEmitWarning (warning: any) { + if (console && console.warn) console.warn(warning); +} + +const NumberIsNaN = Number.isNaN || function NumberIsNaN (value) { + return value !== value; +}; + +function EventEmitter (this: any) { + EventEmitter.init.call(this); +} + +// Backwards-compat with node 0.10.x +EventEmitter.EventEmitter = EventEmitter; + +EventEmitter.prototype._events = undefined; +EventEmitter.prototype._eventsCount = 0; +EventEmitter.prototype._maxListeners = undefined; + +// By default EventEmitters will print a warning if more than 10 listeners are +// added to it. This is a useful default which helps finding memory leaks. +let defaultMaxListeners = 10; + +function checkListener (listener: any) { + if (typeof listener !== 'function') { + throw new TypeError( + 'The "listener" argument must be of type Function. Received type ' + + typeof listener + ); + } +} + +Object.defineProperty(EventEmitter, 'defaultMaxListeners', { + enumerable: true, + get () { + return defaultMaxListeners; + }, + set (arg) { + if (typeof arg !== 'number' || arg < 0 || NumberIsNaN(arg)) { + throw new RangeError( + 'The value of "defaultMaxListeners" is out of range. It must be a non-negative number. Received ' + + arg + '.' + ); + } + defaultMaxListeners = arg; + } +}); + +EventEmitter.init = function (this: { _events: any, _eventsCount: number, _maxListeners: number | undefined }) { + if ( + this._events === undefined || + this._events === Object.getPrototypeOf(this)._events + ) { + this._events = Object.create(null); + this._eventsCount = 0; + } + + this._maxListeners = this._maxListeners || undefined; +}; + +// Obviously not all Emitters should be limited to 10. This function allows +// that to be increased. Set to zero for unlimited. +EventEmitter.prototype.setMaxListeners = function setMaxListeners (n: number) { + if (typeof n !== 'number' || n < 0 || NumberIsNaN(n)) { + throw new RangeError( + 'The value of "n" is out of range. It must be a non-negative number. Received ' + + n + '.' + ); + } + this._maxListeners = n; + return this; +}; + +function _getMaxListeners (that: any) { + if (that._maxListeners === undefined) { + // @ts-ignore suppress + return EventEmitter.defaultMaxListeners; + } + return that._maxListeners; +} + +EventEmitter.prototype.getMaxListeners = function getMaxListeners () { + return _getMaxListeners(this); +}; + +EventEmitter.prototype.emit = function emit (type: any) { + const args = []; + for (let i = 1; i < arguments.length; i++) args.push(arguments[i]); + let doError = (type === 'error'); + + const events = this._events; + if (events !== undefined) { + doError = doError && events.error === undefined; + } else if (!doError) { + return false; + } + + // If there is no 'error' event listener then throw. + if (doError) { + let er; + if (args.length > 0) { + er = args[0]; + } + if (er instanceof Error) { + // Note: The comments on the `throw` lines are intentional, they show + // up in Node's output if this results in an unhandled exception. + throw er; // Unhandled 'error' event + } + // At least give some kind of context to the user + const err: any = new Error( + 'Unhandled error.' + (er ? ' (' + er.message + ')' : '') + ); + err.context = er; + throw err; // Unhandled 'error' event + } + + const handler = events[type]; + + if (handler === undefined) { + return false; + } + + if (typeof handler === 'function') { + ReflectApply(handler, this, args); + } else { + const len = handler.length; + const listeners = arrayClone(handler, len); + for (let i = 0; i < len; ++i) { + ReflectApply(listeners[i], this, args); + } + } + + return true; +}; + +function _addListener (target: any, type: any, listener: any, prepend: any) { + let m; + let events; + let existing; + + checkListener(listener); + + events = target._events; + if (events === undefined) { + events = target._events = Object.create(null); + target._eventsCount = 0; + } else { + // To avoid recursion in the case that type === "newListener"! Before + // adding it to the listeners, first emit "newListener". + if (events.newListener !== undefined) { + target.emit( + 'newListener', + type, + listener.listener ? listener.listener : listener + ); + + // Re-assign `events` because a newListener handler could have caused the + // this._events to be assigned to a new object + events = target._events; + } + existing = events[type]; + } + + if (existing === undefined) { + // Optimize the case of one listener. Don't need the extra array object. + existing = events[type] = listener; + ++target._eventsCount; + } else { + if (typeof existing === 'function') { + // Adding the second element, need to change to array. + existing = events[type] = prepend + ? [listener, existing] + : [existing, listener]; + // If we've already got an array, just append. + } else if (prepend) { + existing.unshift(listener); + } else { + existing.push(listener); + } + + // Check for listener leak + m = _getMaxListeners(target); + if (m > 0 && existing.length > m && !existing.warned) { + existing.warned = true; + // No error code for this since it is a Warning + // eslint-disable-next-line no-restricted-syntax + const w: any = new Error( + 'Possible EventEmitter memory leak detected. ' + + existing.length + ' ' + String(type) + ' listeners ' + + 'added. Use emitter.setMaxListeners() to ' + + 'increase limit' + ); + w.name = 'MaxListenersExceededWarning'; + w.emitter = target; + w.type = type; + w.count = existing.length; + ProcessEmitWarning(w); + } + } + + return target; +} + +EventEmitter.prototype.addListener = function addListener (type: any, listener: any) { + return _addListener(this, type, listener, false); +}; + +EventEmitter.prototype.on = EventEmitter.prototype.addListener; + +EventEmitter.prototype.prependListener = function prependListener (type: any, listener: any) { + return _addListener(this, type, listener, true); +}; + +function onceWrapper (this: any) { + if (!this.fired) { + this.target.removeListener(this.type, this.wrapFn); + this.fired = true; + if (arguments.length === 0) { + return this.listener.call(this.target); + } + return this.listener.apply(this.target, arguments); + } +} + +function _onceWrap (target: any, type: any, listener: any) { + const state = { + fired: false, + wrapFn: undefined, + target, + type, + listener + }; + const wrapped: any = onceWrapper.bind(state); + wrapped.listener = listener; + state.wrapFn = wrapped; + return wrapped; +} + +EventEmitter.prototype.once = function once (type: any, listener: any) { + checkListener(listener); + this.on(type, _onceWrap(this, type, listener)); + return this; +}; + +EventEmitter.prototype.prependOnceListener = function prependOnceListener (type: any, listener: any) { + checkListener(listener); + this.prependListener(type, _onceWrap(this, type, listener)); + return this; +}; + +// Emits a 'removeListener' event if and only if the listener was removed. +EventEmitter.prototype.removeListener = function removeListener (type: any, listener: any ) { + const events = this._events; + const list = events[type]; + let position; + let i; + let originalListener; + + checkListener(listener); + + if (events === undefined) { + return this; + } + + if (list === undefined) { + return this; + } + + if (list === listener || list.listener === listener) { + if (--this._eventsCount === 0) { + this._events = Object.create(null); + } else { + delete events[type]; + if (events.removeListener) { + this.emit('removeListener', type, list.listener || listener); + } + } + } else if (typeof list !== 'function') { + position = -1; + + for (i = list.length - 1; i >= 0; i--) { + if (list[i] === listener || list[i].listener === listener) { + originalListener = list[i].listener; + position = i; + break; + } + } + + if (position < 0) { + return this; + } + + if (position === 0) { + list.shift(); + } else { + spliceOne(list, position); + } + + if (list.length === 1) { + events[type] = list[0]; + } + + if (events.removeListener !== undefined) { + this.emit('removeListener', type, originalListener || listener); + } + } + + return this; +}; + +EventEmitter.prototype.off = EventEmitter.prototype.removeListener; + +EventEmitter.prototype.removeAllListeners = function removeAllListeners (type: any) { + const events = this._events; + const listeners = events[type]; + let i; + + if (events === undefined) { + return this; + } + + // not listening for removeListener, no need to emit + if (events.removeListener === undefined) { + if (arguments.length === 0) { + this._events = Object.create(null); + this._eventsCount = 0; + } else if (events[type] !== undefined) { + if (--this._eventsCount === 0) { + this._events = Object.create(null); + } else { + delete events[type]; + } + } + return this; + } + + // emit removeListener for all listeners on all events + if (arguments.length === 0) { + const keys = Object.keys(events); + for (i = 0; i < keys.length; ++i) { + const key = keys[i]; + if (key === 'removeListener') continue; + this.removeAllListeners(key); + } + this.removeAllListeners('removeListener'); + this._events = Object.create(null); + this._eventsCount = 0; + return this; + } + + if (typeof listeners === 'function') { + this.removeListener(type, listeners); + } else if (listeners !== undefined) { + // LIFO order + for (i = listeners.length - 1; i >= 0; i--) { + this.removeListener(type, listeners[i]); + } + } + + return this; +}; + +function _listeners (target: any, type: any, unwrap: any) { + const events = target._events; + + if (events === undefined) { + return []; + } + + const evlistener = events[type]; + if (evlistener === undefined) { + return []; + } + + if (typeof evlistener === 'function') { + return unwrap ? [evlistener.listener || evlistener] : [evlistener]; + } + + return unwrap + ? unwrapListeners(evlistener) + : arrayClone(evlistener, evlistener.length); +} + +EventEmitter.prototype.listeners = function listeners (type: any) { + return _listeners(this, type, true); +}; + +EventEmitter.prototype.rawListeners = function rawListeners (type: any) { + return _listeners(this, type, false); +}; + +EventEmitter.listenerCount = function (emitter: any, type: any) { + if (typeof emitter.listenerCount === 'function') { + return emitter.listenerCount(type); + } else { + return listenerCount.call(emitter, type); + } +}; + +EventEmitter.prototype.listenerCount = listenerCount; +function listenerCount (this: any, type: any) { + const events = this._events; + + if (events !== undefined) { + const evlistener = events[type]; + + if (typeof evlistener === 'function') { + return 1; + } else if (evlistener !== undefined) { + return evlistener.length; + } + } + + return 0; +} + +EventEmitter.prototype.eventNames = function eventNames () { + return this._eventsCount > 0 ? ReflectOwnKeys(this._events) : []; +}; + +function arrayClone (arr: any, n: any) { + const copy = new Array(n); + for (let i = 0; i < n; ++i) { + copy[i] = arr[i]; + } + return copy; +} + +function spliceOne (list: any, index: any) { + for (; index + 1 < list.length; index++) { + list[index] = list[index + 1]; + } + list.pop(); +} + +function unwrapListeners (arr: any) { + const ret = new Array(arr.length); + for (let i = 0; i < ret.length; ++i) { + ret[i] = arr[i].listener || arr[i]; + } + return ret; +} + +function once (emitter: any, name: any) { + return new Promise(function (resolve, reject) { + function errorListener (err: Error) { + emitter.removeListener(name, resolver); + reject(err); + } + + function resolver () { + if (typeof emitter.removeListener === 'function') { + emitter.removeListener('error', errorListener); + } + resolve([].slice.call(arguments)); + } + + eventTargetAgnosticAddListener(emitter, name, resolver, { once: true }); + if (name !== 'error') { + addErrorHandlerIfEventEmitter(emitter, errorListener, { once: true }); + } + }); +} + +function addErrorHandlerIfEventEmitter (emitter: any, handler: any, flags: any) { + if (typeof emitter.on === 'function') { + eventTargetAgnosticAddListener(emitter, 'error', handler, flags); + } +} + +function eventTargetAgnosticAddListener (emitter: any, name: any, listener: any, flags: any) { + if (typeof emitter.on === 'function') { + if (flags.once) { + emitter.once(name, listener); + } else { + emitter.on(name, listener); + } + } else if (typeof emitter.addEventListener === 'function') { + // EventTarget does not have `error` event semantics like Node + // EventEmitters, we do not listen for `error` events here. + emitter.addEventListener(name, function wrapListener (arg: any) { + // IE does not have builtin `{ once: true }` support so we + // have to do it manually. + if (flags.once) { + emitter.removeEventListener(name, wrapListener); + } + listener(arg); + }); + } else { + throw new TypeError( + 'The "emitter" argument must be of type EventEmitter. Received type ' + + typeof emitter + ); + } +} + +export { EventEmitter }; diff --git a/main/commons/src/index.ts b/main/commons/src/index.ts new file mode 100644 index 0000000000..c77f51ad0f --- /dev/null +++ b/main/commons/src/index.ts @@ -0,0 +1,105 @@ +export * from './testing.ts'; + +// Removes all leading and trailing slashes from a path +export function stripSlashes (name: string) { + return name.replace(/^(\/+)|(\/+)$/g, ''); +} + +export type KeyValueCallback = (value: any, key: string) => T; + +// A set of lodash-y utility functions that use ES6 +export const _ = { + each (obj: any, callback: KeyValueCallback) { + if (obj && typeof obj.forEach === 'function') { + obj.forEach(callback); + } else if (_.isObject(obj)) { + Object.keys(obj).forEach(key => callback(obj[key], key)); + } + }, + + some (value: any, callback: KeyValueCallback) { + return Object.keys(value) + .map(key => [ value[key], key ]) + .some(([val, key]) => callback(val, key)); + }, + + every (value: any, callback: KeyValueCallback) { + return Object.keys(value) + .map(key => [ value[key], key ]) + .every(([val, key]) => callback(val, key)); + }, + + keys (obj: any) { + return Object.keys(obj); + }, + + values (obj: any) { + return _.keys(obj).map(key => obj[key]); + }, + + isMatch (obj: any, item: any) { + return _.keys(item).every(key => obj[key] === item[key]); + }, + + isEmpty (obj: any) { + return _.keys(obj).length === 0; + }, + + isObject (item: any) { + return (typeof item === 'object' && !Array.isArray(item) && item !== null); + }, + + isObjectOrArray (value: any) { + return typeof value === 'object' && value !== null; + }, + + extend (first: any, ...rest: any[]) { + return Object.assign(first, ...rest); + }, + + omit (obj: any, ...keys: string[]) { + const result = _.extend({}, obj); + keys.forEach(key => delete result[key]); + return result; + }, + + pick (source: any, ...keys: string[]) { + return keys.reduce((result: { [key: string]: any }, key) => { + if (source[key] !== undefined) { + result[key] = source[key]; + } + + return result; + }, {}); + }, + + // Recursively merge the source object into the target object + merge (target: any, source: any) { + if (_.isObject(target) && _.isObject(source)) { + Object.keys(source).forEach(key => { + if (_.isObject(source[key])) { + if (!target[key]) { + Object.assign(target, { [key]: {} }); + } + + _.merge(target[key], source[key]); + } else { + Object.assign(target, { [key]: source[key] }); + } + }); + } + return target; + } +}; + +// Duck-checks if an object looks like a promise +export function isPromise (result: any) { + return _.isObject(result) && + typeof result.then === 'function'; +} + +export function createSymbol (name: string) { + return typeof Symbol !== 'undefined' ? Symbol(name) : name; +} + +export * from './debug.ts'; diff --git a/main/commons/src/testing.ts b/main/commons/src/testing.ts new file mode 100644 index 0000000000..a4b438e101 --- /dev/null +++ b/main/commons/src/testing.ts @@ -0,0 +1,20 @@ +// https://deno.land/manual/testing/assertions +import * as denoAssert from 'https://deno.land/std@0.114.0/testing/asserts.ts'; + +const { assertEquals } = denoAssert + +export * from 'https://deno.land/std@0.114.0/testing/asserts.ts'; + +export const it = (name: string, fn: () => any, only = false) => Deno.test({ + only, + name, + fn +}); + +it.only = (name: string, fn: () => any) => it(name, fn, true); + +export const assert = { + deepStrictEqual (actual: unknown, expected: unknown, msg?: string) { + return assertEquals(actual, expected, msg) + } +} diff --git a/main/commons/test/debug.test.ts b/main/commons/test/debug.test.ts new file mode 100644 index 0000000000..eac1f9f8c1 --- /dev/null +++ b/main/commons/test/debug.test.ts @@ -0,0 +1,28 @@ +import { it, assertEquals, assertStrictEquals } from '../src/testing.ts'; +import { createDebug, setDebug, noopDebug } from '../src/index.ts'; + +const myDebug = createDebug('hello test'); + +it('default debug does nothing', () => { + assertStrictEquals(myDebug('hi', 'there'), undefined); +}); + +it('can set custom debug later', () => { + let call; + + const customDebug = (name: string) => (...args: any[]) => { + call = [ name ].concat(args); + } + + setDebug(customDebug); + + assertStrictEquals(myDebug('hi', 'there'), undefined); + assertEquals(call, [ 'hello test', 'hi', 'there' ]); + + const newDebug = createDebug('other test'); + + assertStrictEquals(newDebug('other', 'there'), undefined); + assertEquals(call, [ 'other test', 'other', 'there' ]); + + setDebug(noopDebug); +}); diff --git a/main/commons/test/module.test.ts b/main/commons/test/module.test.ts new file mode 100644 index 0000000000..b64291d195 --- /dev/null +++ b/main/commons/test/module.test.ts @@ -0,0 +1,24 @@ +import { it, assertStrictEquals } from '../src/testing.ts'; +import { _ } from '../src/index.ts'; +import * as commons from '../src/index.ts' + +it('commons: is commonjs compatible', () => { + assertStrictEquals(typeof commons, 'object'); + assertStrictEquals(typeof commons.stripSlashes, 'function'); + assertStrictEquals(typeof commons._, 'object'); +}); + +it('commons: exposes lodash methods under _', () => { + assertStrictEquals(typeof _.each, 'function'); + assertStrictEquals(typeof _.some, 'function'); + assertStrictEquals(typeof _.every, 'function'); + assertStrictEquals(typeof _.keys, 'function'); + assertStrictEquals(typeof _.values, 'function'); + assertStrictEquals(typeof _.isMatch, 'function'); + assertStrictEquals(typeof _.isEmpty, 'function'); + assertStrictEquals(typeof _.isObject, 'function'); + assertStrictEquals(typeof _.extend, 'function'); + assertStrictEquals(typeof _.omit, 'function'); + assertStrictEquals(typeof _.pick, 'function'); + assertStrictEquals(typeof _.merge, 'function'); +}); diff --git a/main/commons/test/utils.test.ts b/main/commons/test/utils.test.ts new file mode 100644 index 0000000000..7eb07dca9b --- /dev/null +++ b/main/commons/test/utils.test.ts @@ -0,0 +1,161 @@ +/* tslint:disable:no-unused-expression */ +import { it, assert, assertEquals, assertStrictEquals } from '../src/testing.ts'; +import { _, stripSlashes, isPromise, createSymbol } from '../src/index.ts'; + +it('stripSlashes', () => { + assertStrictEquals(stripSlashes('some/thing'), 'some/thing'); + assertStrictEquals(stripSlashes('/some/thing'), 'some/thing'); + assertStrictEquals(stripSlashes('some/thing/'), 'some/thing'); + assertStrictEquals(stripSlashes('/some/thing/'), 'some/thing'); + assertStrictEquals(stripSlashes('//some/thing/'), 'some/thing'); + assertStrictEquals(stripSlashes('//some//thing////'), 'some//thing'); +}); + +it('isPromise', () => { + assertStrictEquals(isPromise(Promise.resolve()), true); + assert(isPromise({ + then () {} + })); + assertStrictEquals(isPromise(null), false); +}); + +it('createSymbol', () => { + assertStrictEquals(typeof createSymbol('a test'), 'symbol'); +}); + +it('isObject', () => { + assertStrictEquals(_.isObject({}), true); + assertStrictEquals(_.isObject([]), false); + assertStrictEquals(_.isObject(null), false); +}); + +it('isObjectOrArray', () => { + assertStrictEquals(_.isObjectOrArray({}), true); + assertStrictEquals(_.isObjectOrArray([]), true); + assertStrictEquals(_.isObjectOrArray(null), false); +}); + +it('each', () => { + _.each({ hi: 'there' }, (value, key) => { + assertStrictEquals(key, 'hi'); + assertStrictEquals(value, 'there'); + }); + + _.each([ 'hi' ], (value, key) => { + assertStrictEquals(key, 0); + assertStrictEquals(value, 'hi'); + }); + + _.each('moo', () => { + throw new Error('Should never get here') + }); +}); + +it('some', () => { + assert(_.some([ 'a', 'b' ], current => current === 'a')); + assert(!_.some([ 'a', 'b' ], current => current === 'c')); +}); + +it('every', () => { + assert(_.every([ 'a', 'a' ], current => current === 'a')); + assert(!_.every([ 'a', 'b' ], current => current === 'a')); +}); + +it('keys', () => { + const data = { hi: 'there', name: 'David' }; + assertEquals(_.keys(data), [ 'hi', 'name' ]); +}); + +it('values', () => { + const data = { hi: 'there', name: 'David' }; + assertEquals(_.values(data), [ 'there', 'David' ]); +}); + +it('isMatch', () => { + assert(_.isMatch({ + test: 'me', hi: 'you', more: true + }, { + test: 'me', hi: 'you' + })); + + assert(!_.isMatch({ + test: 'me', hi: 'you', more: true + }, { + test: 'me', hi: 'there' + })); +}); + +it('isEmpty', () => { + assert(_.isEmpty({})); + assert(!_.isEmpty({ name: 'David' })); +}); + +it('extend', () => { + assertEquals(_.extend({ hi: 'there' }, { name: 'david' }), { + hi: 'there', + name: 'david' + }); +}); + +it('omit', () => { + assertEquals(_.omit({ + name: 'David', + first: 1, + second: 2 + }, 'first', 'second'), { + name: 'David' + }); +}); + +it('pick', () => { + assertEquals(_.pick({ + name: 'David', + first: 1, + second: 2 + }, 'first', 'second'), { + first: 1, + second: 2 + }); + + assertEquals(_.pick({ + name: 'David', + first: 1 + }, 'first', 'second'), { + first: 1 + }); +}); + +it('merge', () => { + assertEquals(_.merge({ hi: 'there' }, { name: 'david' }), { + hi: 'there', + name: 'david' + }); + + assertEquals(_.merge({}, { + name: 'david', + nested: { obj: true } + }), { + name: 'david', + nested: { obj: true } + }); + + assertEquals(_.merge({ name: 'david' }, {}), { + name: 'david' + }); + + assertEquals(_.merge({ + hi: 'there', + my: { + name: { is: 'david' }, + number: { is: 1 } + } + }, { my: { name: { is: 'eric' } } }), { + hi: 'there', + my: { + number: { is: 1 }, + name: { is: 'eric' } + } + }); + + assertStrictEquals(_.merge('hello', {}), 'hello'); +}); diff --git a/main/commons/tsconfig.json b/main/commons/tsconfig.json new file mode 100644 index 0000000000..316fd41336 --- /dev/null +++ b/main/commons/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig", + "include": [ + "src/**/*.ts" + ], + "compilerOptions": { + "outDir": "lib" + } +} diff --git a/main/errors/LICENSE b/main/errors/LICENSE new file mode 100644 index 0000000000..3f395cc665 --- /dev/null +++ b/main/errors/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2021 Feathers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/main/errors/README.md b/main/errors/README.md new file mode 100644 index 0000000000..da59712dd5 --- /dev/null +++ b/main/errors/README.md @@ -0,0 +1,23 @@ +# @feathersjs/errors + +[![CI](https://github.com/feathersjs/feathers/workflows/CI/badge.svg)](https://github.com/feathersjs/feathers/actions?query=workflow%3ACI) +[![Dependency Status](https://img.shields.io/david/feathersjs/feathers.svg?style=flat-square&path=packages/errors)](https://david-dm.org/feathersjs/feathers?path=packages/errors) +[![Download Status](https://img.shields.io/npm/dm/@feathersjs/errors.svg?style=flat-square)](https://www.npmjs.com/package/@feathersjs/errors) + +> Common error types for feathers apps + +## Installation + +``` +npm install @feathersjs/errors --save +``` + +## Documentation + +Refer to the [Feathers errors API documentation](https://docs.feathersjs.com/api/errors.html) for more details. + +## License + +Copyright (c) 2021 [Feathers contributors](https://github.com/feathersjs/feathers/graphs/contributors) + +Licensed under the [MIT license](LICENSE). diff --git a/main/errors/src/index.ts b/main/errors/src/index.ts new file mode 100644 index 0000000000..8d7e155356 --- /dev/null +++ b/main/errors/src/index.ts @@ -0,0 +1,273 @@ +export interface FeathersErrorJSON { + name: string; + message: string; + code: number; + className: string; + data?: any; + errors?: any; +} + +export type DynamicError = Error & { [key: string]: any }; +export type ErrorMessage = string | DynamicError | { [key: string]: any } | any[]; + +interface ErrorProperties extends Omit { + type: string; +} + +export class FeathersError extends Error { + readonly type!: string; + readonly code!: number; + readonly className!: string; + readonly data: any; + readonly errors: any; + + constructor (err: ErrorMessage | undefined, name: string, code: number, className: string, _data: any) { + let msg = typeof err === 'string' ? err : 'Error'; + const properties: ErrorProperties = { + name, + code, + className, + type: 'FeathersError' + }; + + if (Array.isArray(_data)) { + properties.data = _data; + } else if (typeof err === 'object' || _data !== undefined) { + const { message, errors, ...rest } = typeof err === 'object' ? err : _data; + + msg = message || msg; + properties.errors = errors; + properties.data = rest; + } + + super(msg); + Object.assign(this, properties); + } + + toJSON () { + const result: FeathersErrorJSON = { + name: this.name, + message: this.message, + code: this.code, + className: this.className + }; + + if (this.data !== undefined) { + result.data = this.data; + } + + if (this.errors !== undefined) { + result.errors = this.errors; + } + + return result; + } +} + +export class BadRequest extends FeathersError { + constructor (message?: ErrorMessage, data?: any) { + super(message, 'BadRequest', 400, 'bad-request', data); + } +} + +// 401 - Not Authenticated +export class NotAuthenticated extends FeathersError{ + constructor (message?: ErrorMessage, data?: any) { + super(message, 'NotAuthenticated', 401, 'not-authenticated', data); + } +} + +// 402 - Payment Error +export class PaymentError extends FeathersError { + constructor (message?: ErrorMessage, data?: any) { + super(message, 'PaymentError', 402, 'payment-error', data); + } +} + +// 403 - Forbidden +export class Forbidden extends FeathersError { + constructor (message?: ErrorMessage, data?: any) { + super(message, 'Forbidden', 403, 'forbidden', data); + } +} + +// 404 - Not Found +export class NotFound extends FeathersError { + constructor (message?: ErrorMessage, data?: any) { + super(message, 'NotFound', 404, 'not-found', data); + } +} + +// 405 - Method Not Allowed +export class MethodNotAllowed extends FeathersError { + constructor (message?: ErrorMessage, data?: any) { + super(message, 'MethodNotAllowed', 405, 'method-not-allowed', data); + } +} + +// 406 - Not Acceptable +export class NotAcceptable extends FeathersError { + constructor (message?: ErrorMessage, data?: any) { + super(message, 'NotAcceptable', 406, 'not-acceptable', data); + } +} + +// 408 - Timeout +export class Timeout extends FeathersError { + constructor (message?: ErrorMessage, data?: any) { + super(message, 'Timeout', 408, 'timeout', data); + } +} + +// 409 - Conflict +export class Conflict extends FeathersError { + constructor (message?: ErrorMessage, data?: any) { + super(message, 'Conflict', 409, 'conflict', data); + } +} + +// 410 - Gone +export class Gone extends FeathersError { + constructor (message?: ErrorMessage, data?: any) { + super(message, 'Gone', 410, 'gone', data); + } +} + +// 411 - Length Required +export class LengthRequired extends FeathersError { + constructor (message?: ErrorMessage, data?: any) { + super(message, 'LengthRequired', 411, 'length-required', data); + } +} + +// 422 Unprocessable +export class Unprocessable extends FeathersError { + constructor (message?: ErrorMessage, data?: any) { + super(message, 'Unprocessable', 422, 'unprocessable', data); + } +} + +// 429 Too Many Requests +export class TooManyRequests extends FeathersError { + constructor (message?: ErrorMessage, data?: any) { + super(message, 'TooManyRequests', 429, 'too-many-requests', data); + } +} + +// 500 - General Error +export class GeneralError extends FeathersError { + constructor (message?: ErrorMessage, data?: any) { + super(message, 'GeneralError', 500, 'general-error', data); + } +} + +// 501 - Not Implemented +export class NotImplemented extends FeathersError { + constructor (message?: ErrorMessage, data?: any) { + super(message, 'NotImplemented', 501, 'not-implemented', data); + } +} + +// 502 - Bad Gateway +export class BadGateway extends FeathersError { + constructor (message?: ErrorMessage, data?: any) { + super(message, 'BadGateway', 502, 'bad-gateway', data); + } +} + +// 503 - Unavailable +export class Unavailable extends FeathersError { + constructor (message?: ErrorMessage, data?: any) { + super(message, 'Unavailable', 503, 'unavailable', data); + } +} + +export interface Errors { + FeathersError: FeathersError; + BadRequest: BadRequest; + NotAuthenticated: NotAuthenticated; + PaymentError: PaymentError; + Forbidden: Forbidden; + NotFound: NotFound; + MethodNotAllowed: MethodNotAllowed; + NotAcceptable: NotAcceptable; + Timeout: Timeout; + Conflict: Conflict; + LengthRequired: LengthRequired; + Unprocessable: Unprocessable; + TooManyRequests: TooManyRequests; + GeneralError: GeneralError; + NotImplemented: NotImplemented; + BadGateway: BadGateway; + Unavailable: Unavailable; + 400: BadRequest; + 401: NotAuthenticated; + 402: PaymentError; + 403: Forbidden; + 404: NotFound; + 405: MethodNotAllowed; + 406: NotAcceptable; + 408: Timeout; + 409: Conflict; + 411: LengthRequired; + 422: Unprocessable; + 429: TooManyRequests; + 500: GeneralError; + 501: NotImplemented; + 502: BadGateway; + 503: Unavailable; +} + +export const errors = { + FeathersError, + BadRequest, + NotAuthenticated, + PaymentError, + Forbidden, + NotFound, + MethodNotAllowed, + NotAcceptable, + Timeout, + Conflict, + LengthRequired, + Unprocessable, + TooManyRequests, + GeneralError, + NotImplemented, + BadGateway, + Unavailable, + 400: BadRequest, + 401: NotAuthenticated, + 402: PaymentError, + 403: Forbidden, + 404: NotFound, + 405: MethodNotAllowed, + 406: NotAcceptable, + 408: Timeout, + 409: Conflict, + 410: Gone, + 411: LengthRequired, + 422: Unprocessable, + 429: TooManyRequests, + 500: GeneralError, + 501: NotImplemented, + 502: BadGateway, + 503: Unavailable +} + +export function convert (error: any) { + if (!error) { + return error; + } + + const FeathersError = (errors as any)[error.name]; + const result = FeathersError + ? new FeathersError(error.message, error.data) + : new Error(error.message || error); + + if (typeof error === 'object') { + Object.assign(result, error); + } + + return result; +} diff --git a/main/errors/test/index.test.ts b/main/errors/test/index.test.ts new file mode 100644 index 0000000000..800c233fc0 --- /dev/null +++ b/main/errors/test/index.test.ts @@ -0,0 +1,359 @@ +import { it, assert, assertEquals, assertStrictEquals, assertNotStrictEquals } from '../../commons/src/testing.ts' +import * as errors from '../src/index.ts'; + +const { convert } = errors; + +// 'errors.convert' +it('converts objects to feathers errors', () => { + const error = convert({ + name: 'BadRequest', + message: 'Hi', + expando: 'Me' + }); + + assertStrictEquals(error.message, 'Hi'); + assertStrictEquals(error.expando, 'Me'); + assert(error instanceof errors.BadRequest); +}); + +it('converts other object to error', () => { + let error = convert({ + message: 'Something went wrong' + }); + + assert(error instanceof Error); + assertStrictEquals(error.message, 'Something went wrong'); + + error = convert('Something went wrong'); + + assert(error instanceof Error); + assertStrictEquals(error.message, 'Something went wrong'); +}); + +it('converts nothing', () => + assertEquals(convert(null), null) +); + +// 'error types' +it('Bad Request', () => { + assertNotStrictEquals(typeof errors.BadRequest, 'undefined', 'has BadRequest'); +}); + +it('Not Authenticated', () => { + assertNotStrictEquals(typeof errors.NotAuthenticated, 'undefined', 'has NotAuthenticated'); +}); + +it('Payment Error', () => { + assertNotStrictEquals(typeof errors.PaymentError, 'undefined', 'has PaymentError'); +}); + +it('Forbidden', () => { + assertNotStrictEquals(typeof errors.Forbidden, 'undefined', 'has Forbidden'); +}); + +it('Not Found', () => { + assertNotStrictEquals(typeof errors.NotFound, 'undefined', 'has NotFound'); +}); + +it('Method Not Allowed', () => { + assertNotStrictEquals(typeof errors.MethodNotAllowed, 'undefined', 'has MethodNotAllowed'); +}); + +it('Not Acceptable', () => { + assertNotStrictEquals(typeof errors.NotAcceptable, 'undefined', 'has NotAcceptable'); +}); + +it('Timeout', () => { + assertNotStrictEquals(typeof errors.Timeout, 'undefined', 'has Timeout'); +}); + +it('Conflict', () => { + assertNotStrictEquals(typeof errors.Conflict, 'undefined', 'has Conflict'); +}); + +it('Gone', () => { + assertNotStrictEquals(typeof errors.Gone, 'undefined', 'has Gone'); +}); + +it('Length Required', () => { + assertNotStrictEquals(typeof errors.LengthRequired, 'undefined', 'has LengthRequired'); +}); + +it('Unprocessable', () => { + assertNotStrictEquals(typeof errors.Unprocessable, 'undefined', 'has Unprocessable'); +}); + +it('Too Many Requests', () => { + assertNotStrictEquals(typeof errors.TooManyRequests, 'undefined', 'has TooManyRequests'); +}); + +it('General Error', () => { + assertNotStrictEquals(typeof errors.GeneralError, 'undefined', 'has GeneralError'); +}); + +it('Not Implemented', () => { + assertNotStrictEquals(typeof errors.NotImplemented, 'undefined', 'has NotImplemented'); +}); + +it('Bad Gateway', () => { + assertNotStrictEquals(typeof errors.BadGateway, 'undefined', 'has BadGateway'); +}); + +it('Unavailable', () => { + assertNotStrictEquals(typeof errors.Unavailable, 'undefined', 'has Unavailable'); +}); + +it('400', () => { + assertNotStrictEquals(typeof errors.errors[400], 'undefined', 'has BadRequest alias'); +}); + +it('401', () => { + assertNotStrictEquals(typeof errors.errors[401], 'undefined', 'has NotAuthenticated alias'); +}); + +it('402', () => { + assertNotStrictEquals(typeof errors.errors[402], 'undefined', 'has PaymentError alias'); +}); + +it('403', () => { + assertNotStrictEquals(typeof errors.errors[403], 'undefined', 'has Forbidden alias'); +}); + +it('404', () => { + assertNotStrictEquals(typeof errors.errors[404], 'undefined', 'has NotFound alias'); +}); + +it('405', () => { + assertNotStrictEquals(typeof errors.errors[405], 'undefined', 'has MethodNotAllowed alias'); +}); + +it('406', () => { + assertNotStrictEquals(typeof errors.errors[406], 'undefined', 'has NotAcceptable alias'); +}); + +it('408', () => { + assertNotStrictEquals(typeof errors.errors[408], 'undefined', 'has Timeout alias'); +}); + +it('409', () => { + assertNotStrictEquals(typeof errors.errors[409], 'undefined', 'has Conflict alias'); +}); + +it('410', () => { + assertNotStrictEquals(typeof errors.errors[410], 'undefined', 'has Gone alias'); +}); + +it('411', () => { + assertNotStrictEquals(typeof errors.errors[411], 'undefined', 'has LengthRequired alias'); +}); + +it('422', () => { + assertNotStrictEquals(typeof errors.errors[422], 'undefined', 'has Unprocessable alias'); +}); + +it('429', () => { + assertNotStrictEquals(typeof errors.errors[429], 'undefined', 'has TooManyRequests alias'); +}); + +it('500', () => { + assertNotStrictEquals(typeof errors.errors[500], 'undefined', 'has GeneralError alias'); +}); + +it('501', () => { + assertNotStrictEquals(typeof errors.errors[501], 'undefined', 'has NotImplemented alias'); +}); + +it('502', () => { + assertNotStrictEquals(typeof errors.errors[502], 'undefined', 'has BadGateway alias'); +}); + +it('503', () => { + assertNotStrictEquals(typeof errors.errors[503], 'undefined', 'has Unavailable alias'); +}); + +it('instantiates every error', () => { + const index: any = errors.errors; + + Object.keys(index).forEach(name => { + const E = index[name]; + + if (E) { + // tslint:disable-next-line + new E('Something went wrong'); + } + }); +}); + +// 'inheritance' +it('instanceof differentiates between error types', () => { + const error = new errors.MethodNotAllowed(); + assert(!(error instanceof errors.BadRequest)); +}); + +it('follows the prototypical inheritance chain', () => { + const error = new errors.MethodNotAllowed(); + assert(error instanceof Error); + assert(error instanceof errors.FeathersError); +}); + +it('has the correct constructors', () => { + const error = new errors.NotFound(); + assert(error.constructor === errors.NotFound); + assert(error.constructor.name === 'NotFound'); +}); + +// 'successful error creation' +// 'without custom message' +it('default error', () => { + const error = new errors.GeneralError(); + assertStrictEquals(error.code, 500); + assertStrictEquals(error.className, 'general-error'); + assertStrictEquals(error.message, 'Error'); + assertStrictEquals(error.data, undefined); + assertStrictEquals(error.errors, undefined); + assertNotStrictEquals(error.stack, undefined); + assertStrictEquals(error instanceof errors.GeneralError, true); + assertStrictEquals(error instanceof errors.FeathersError, true); +}); + +it('can wrap an existing error', () => { + const error = new errors.BadRequest(new Error()); + assertStrictEquals(error.code, 400); + assertStrictEquals(error.message, 'Error'); +}); + +it('with multiple errors', () => { + const data = { + errors: { + email: 'Email Taken', + password: 'Invalid Password' + }, + foo: 'bar' + }; + + const error = new errors.BadRequest(data); + assertStrictEquals(error.code, 400); + assertStrictEquals(error.message, 'Error'); + assertEquals(error.errors, { email: 'Email Taken', password: 'Invalid Password' }); + assertEquals(error.data, { foo: 'bar' }); +}); + +it('with data', () => { + const data = { + email: 'Email Taken', + password: 'Invalid Password' + }; + + const error = new errors.GeneralError(data); + assertStrictEquals(error.code, 500); + assertStrictEquals(error.message, 'Error'); + assertEquals(error.data, data); +}); + +// 'with custom message' +it('contains our message', () => { + const error = new errors.BadRequest('Invalid Password'); + assertStrictEquals(error.code, 400); + assertStrictEquals(error.message, 'Invalid Password'); +}); + +it('can wrap an existing error', () => { + const error = new errors.BadRequest(new Error('Invalid Password')); + assertStrictEquals(error.code, 400); + assertStrictEquals(error.message, 'Invalid Password'); +}); + +it('with data', () => { + const data = { + email: 'Email Taken', + password: 'Invalid Password' + }; + + const error = new errors.GeneralError('Custom Error', data); + assertStrictEquals(error.code, 500); + assertStrictEquals(error.message, 'Custom Error'); + assertEquals(error.data, data); +}); + +it('with multiple errors', () => { + const data = { + errors: { + email: 'Email Taken', + password: 'Invalid Password' + }, + foo: 'bar' + }; + + const error = new errors.BadRequest(data); + + assertStrictEquals(error.code, 400); + assertStrictEquals(error.message, 'Error'); + assertEquals(error.errors, { email: 'Email Taken', password: 'Invalid Password' }); + assertEquals(error.data, { foo: 'bar' }); +}); + +it('can return JSON', () => { + const data = { + errors: { + email: 'Email Taken', + password: 'Invalid Password' + }, + foo: 'bar' + }; + + const expected = '{"name":"GeneralError","message":"Custom Error","code":500,"className":"general-error","data":{"foo":"bar"},"errors":{"email":"Email Taken","password":"Invalid Password"}}'; + + const error = new errors.GeneralError('Custom Error', data); + assertStrictEquals(JSON.stringify(error), expected); +}); + +it('can handle immutable data', () => { + const data = { + errors: { + email: 'Email Taken', + password: 'Invalid Password' + }, + foo: 'bar' + }; + + const error = new errors.GeneralError('Custom Error', Object.freeze(data)); + assertStrictEquals(error.data.errors, undefined); + assertEquals(error.data, { foo: 'bar' }); +}); + +it('allows arrays as data', () => { + const data = [ + { + hello: 'world' + } + ]; + + const error = new errors.GeneralError('Custom Error', data); + assertStrictEquals(error.data.errors, undefined); + assert(Array.isArray(error.data)); + assertEquals(error.data, [{ hello: 'world' }]); +}); + +it('has proper stack trace (#78)', () => { + try { + throw new errors.NotFound('Not the error you are looking for'); + } catch (e: any) { + const text = 'NotFound: Not the error you are looking for'; + + assertStrictEquals(e.stack.indexOf(text), 0); + + assert(e.stack.indexOf('index.test.ts') !== -1); + + const oldCST = Error.captureStackTrace; + + // @ts-ignore suppress + delete Error.captureStackTrace; + + try { + throw new errors.NotFound('Not the error you are looking for'); + } catch (e: any) { + assert(e); + Error.captureStackTrace = oldCST; + } + } +}); diff --git a/main/errors/tsconfig.json b/main/errors/tsconfig.json new file mode 100644 index 0000000000..316fd41336 --- /dev/null +++ b/main/errors/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig", + "include": [ + "src/**/*.ts" + ], + "compilerOptions": { + "outDir": "lib" + } +}