diff --git a/README.md b/README.md index 7308eb3..c2c98a6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # express-crud-router -[![codecov](https://codecov.io/gh/lalalilo/express-crud-router/branch/master/graph/badge.svg)](https://codecov.io/gh/lalalilo/express-crud-router) [![CircleCI](https://circleci.com/gh/lalalilo/express-crud-router.svg?style=svg)](https://circleci.com/gh/lalalilo/express-crud-router) +[![codecov](https://codecov.io/gh/nicgirault/express-crud-router/branch/master/graph/badge.svg)](https://codecov.io/gh/nicgirault/express-crud-router) [![CircleCI](https://circleci.com/gh/nicgirault/express-crud-router.svg?style=svg)](https://circleci.com/gh/nicgirault/express-crud-router) Expose resource CRUD (Create Read Update Delete) routes in your Express app. Compatible with [React Admin Simple Rest Data Provider](https://github.com/marmelab/react-admin/tree/master/packages/ra-data-simple-rest). The lib is ORM agnostic. [List of existing ORM connectors](https://www.npmjs.com/search?q=keywords:express-crud-router-connector). @@ -9,9 +9,8 @@ import crud from 'express-crud-router' app.use( crud('/admin/users', { - getList: ({ filter, limit, offset, order }) => + get: ({ filter, limit, offset, order }) => User.findAndCountAll({ limit, offset, order, where: filter }), - getOne: id => User.findByPk(id), create: body => User.create(body), update: (id, body) => User.update(body, { where: { id } }), destroy: id => User.destroy({ where: { id } }), @@ -78,13 +77,64 @@ app.use( crud('/admin/users', sequelizeCrud(User), { filters: { email: value => ({ - [Op.iLike]: value, + email: { + [Op.iLike]: value, + }, }), }, }) ) ``` +Custom filter handlers can be asynchronous. It makes it possible to filter based on properties of a related record. For example if we consider a blog database schema where posts are related to categories, one can filter posts by category name thanks to the following filter: + +```ts +crud('/admin/posts', actions, { + filters: { + categoryName: async value => { + const category = await Category.findOne({ name: value }).orFail() + + return { + categoryId: category.id, + } + }, + }, +}) +``` + +Notes: + +- the filter key (here categoryName) won't be passed to the underlying action handler. +- there is no support of conflicting attributes. In the following code, one filter will override the effect of the other filter. There is no garantee on which filter will be prefered. + +```ts +crud('/admin/posts', actions, { + filters: { + key1: async value => ({ + conflictingKey: 'hello', + }), + key2: async value => ({ + conflictingKey: 'world', + }), + }, +}) +``` + +### Additional attributes + +Additional attributes can be populated in the read views. For example one can add a count of related records like this: + +```ts +crud('/admin/categories', actions, { + additionalAttributes: async category => { + return { + postsCount: await Post.count({ categoryId: category.id }) + } + }, + additionalAttributesConcurrency: 10 // 10 queries Post.count will be perform at the same time +}) +``` + ### Custom behavior & other ORMs ```ts @@ -95,9 +145,8 @@ import { User } from './models' const app = new express() app.use( crud('/admin/users', { - getList: ({ filter, limit, offset, order }, { req, res }) => + get: ({ filter, limit, offset, order }, { req, res }) => User.findAndCountAll({ limit, offset, order, where: filter }), - getOne: (id, { req, res }) => User.findByPk(id), create: (body, { req, res }) => User.create(body), update: (id, body, { req, res }) => User.update(body, { where: { id } }), destroy: (id, { req, res }) => User.destroy({ where: { id } }), @@ -109,16 +158,15 @@ An ORM connector is a lib exposing an object of following shape: ```typescript interface Actions { - getOne: (identifier: string) => Promise - create: (body: R) => Promise - destroy: (id: string) => Promise - update: (id: string, data: R) => Promise - getList: GetList = (conf: { + get: GetList = (conf: { filter: Record limit: number offset: number order: Array<[string, string]> }) => Promise<{ rows: R[]; count: number }> + create: (body: R) => Promise + destroy: (id: string) => Promise + update: (id: string, data: R) => Promise } ``` @@ -130,20 +178,15 @@ When using react-admin autocomplete reference field, a request is done to the AP ```ts app.use( - crud('/admin/users', { - search: async (q, limit) => { - const { rows, count } = await User.findAndCountAll({ - limit, - where: { + crud('/admin/users', , sequelizeCrud(User), { + filters: { + q: q => ({ [Op.or]: [ { address: { [Op.iLike]: `${q}%` } }, { zipCode: { [Op.iLike]: `${q}%` } }, { city: { [Op.iLike]: `${q}%` } }, ], - }, - }) - - return { rows, count } + }), }, }) ) @@ -151,6 +194,28 @@ app.use( express-crud-router ORM connectors might expose some search behaviors. +### Recipies + +#### Generic filter on related record attributes + +```ts +crud('/admin/posts', actions, { + filters: { + category: async categoryFilters => { + const categories = await Category.find(categoryFilters) + + return { + categoryId: categories.map(category => category.id), + } + }, + }, +}) +``` + +This code allows to perform queries such as: + +`/admin/posts?filter={"category": {"name": "recipies"}}` + ## Contribute This lib uses [semantic-release](https://github.com/semantic-release/semantic-release). You need to write your commits following this nomenclature: @@ -171,3 +236,7 @@ feat: my commit BREAKING CHANGE: detail here ``` + +## Thanks + +Thank you to [Lalilo](https://www.welcometothejungle.com/fr/companies/lalilo) who made this library live. diff --git a/package.json b/package.json index 4723390..a70944c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "types": "./lib/index.d.ts", "repository": { "type": "git", - "url": "git+https://github.com/lalalilo/express-crud-router.git" + "url": "git+https://github.com/nicgirault/express-crud-router.git" }, "author": "nicgirault ", "keywords": [ @@ -20,9 +20,9 @@ "express" ], "bugs": { - "url": "https://github.com/lalalilo/express-crud-router/issues" + "url": "https://github.com/nicgirault/express-crud-router/issues" }, - "homepage": "https://github.com/lalalilo/express-crud-router#readme", + "homepage": "https://github.com/nicgirault/express-crud-router#readme", "license": "MIT", "scripts": { "build": "rimraf lib && babel src -d lib --extensions '.ts' && tsc", @@ -62,8 +62,7 @@ ] }, "dependencies": { - "body-parser": "^1.19.0", - "lodash": "^4.17.15" + "body-parser": "^1.19.0" }, "release": { "branches": [ diff --git a/src/getList/index.ts b/src/getList/index.ts index f8360ec..5b39ad1 100644 --- a/src/getList/index.ts +++ b/src/getList/index.ts @@ -1,9 +1,9 @@ import { RequestHandler, Request, Response } from 'express' -import mapValues from 'lodash/mapValues' +import pLimit from 'p-limit'; import { setGetListHeaders } from './headers' -export type GetList = (conf: { +export type Get = (conf: { filter: Record limit: number offset: number @@ -13,49 +13,43 @@ export type GetList = (conf: { res: Response }) => Promise<{ rows: R[]; count: number }> -export type Search = ( - q: string, - limit: number, - filter: Record, - opts?: { req: Request, res: Response } -) => Promise<{ rows: R[]; count: number }> +export interface GetListOptions { + filters: FiltersOption + additionalAttributes: (record: R) => object | Promise + additionalAttributesConcurrency: number +} + +type FiltersOption = Record any> export const getMany = ( - doGetFilteredList: GetList, - doGetSearchList?: Search, - filtersOption?: FiltersOption + doGetFilteredList: Get, + options?: Partial> ): RequestHandler => async (req, res, next) => { try { - const { q, limit, offset, filter, order } = parseQuery( + const { limit, offset, filter, order } = await parseQuery( req.query, - filtersOption + options?.filters ?? {} + ) + + const { rows, count } = await doGetFilteredList({ + filter, + limit, + offset, + order, + }, { req, res }) + setGetListHeaders(res, offset, count, rows.length) + res.json( + options?.additionalAttributes + ? await computeAdditionalAttributes(options.additionalAttributes, options.additionalAttributesConcurrency ?? 1)(rows) + : rows ) - if (!q) { - const { rows, count } = await doGetFilteredList({ - filter, - limit, - offset, - order, - }, {req, res}) - setGetListHeaders(res, offset, count, rows.length) - res.json(rows) - } else { - if (!doGetSearchList) { - return res.status(400).json({ - error: 'Search has not been implemented yet for this resource', - }) - } - const { rows, count } = await doGetSearchList(q, limit, filter, {req, res}) - setGetListHeaders(res, offset, count, rows.length) - res.json(rows) - } } catch (error) { next(error) } } -export const parseQuery = (query: any, filtersOption?: FiltersOption) => { +export const parseQuery = async (query: any, filtersOption: FiltersOption) => { const { range, sort, filter } = query const [from, to] = range ? JSON.parse(range) : [0, 10000] @@ -65,21 +59,35 @@ export const parseQuery = (query: any, filtersOption?: FiltersOption) => { return { offset: from, limit: to - from + 1, - filter: getFilter(filters, filtersOption), + filter: await getFilter(filters, filtersOption), order: [sort ? JSON.parse(sort) : ['id', 'ASC']] as [[string, string]], q, } } -const getFilter = ( +const getFilter = async ( filter: Record, - filtersOption?: FiltersOption -) => - mapValues(filter, (value, key) => { + filtersOption: FiltersOption +) => { + const result: Record = {} + + for (const [key, value] of Object.entries(filter)) { if (filtersOption && filtersOption[key]) { - return filtersOption[key](value) + Object.assign(result, await filtersOption[key]!(value)) + } else { + result[key] = value } - return value - }) + } + + return result +} -export type FiltersOption = Record any> + +const computeAdditionalAttributes = + (additionalAttributes: GetListOptions["additionalAttributes"], concurrency: number) => { + const limit = pLimit(concurrency) + + return (records: R[]) => Promise.all(records.map(record => + limit(async () => ({ ...record, ...await additionalAttributes(record) })) + )) + } diff --git a/src/getOne.ts b/src/getOne.ts index 929958c..628c150 100644 --- a/src/getOne.ts +++ b/src/getOne.ts @@ -1,19 +1,24 @@ import { RequestHandler, Request, Response } from 'express' +import { Get } from './getList' -export type GetOne = (identifier: string, opts?: { req: Request, res: Response }) => Promise - -export const getOne = (doGetOne: GetOne): RequestHandler => async ( +export const getOne = (doGetList: Get): RequestHandler => async ( req, res, next ) => { try { - const record = await doGetOne(req.params.id, { req, res }) - - if (!record) { + const { rows } = await doGetList({ + filter: { + id: req.params.id + }, + limit: 1, + offset: 0, + order: [] + }, { req, res }) + if (rows.length === 0) { return res.status(404).json({ error: 'Record not found' }) } - res.json(record) + res.json(rows[0]) } catch (error) { next(error) } diff --git a/src/index.ts b/src/index.ts index d10c38a..fe19a5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,46 +1,39 @@ import { Router } from 'express' import bodyParser from 'body-parser' -import { getMany, GetList, Search, FiltersOption } from './getList' -import { getOne, GetOne } from './getOne' +import { getMany, Get, GetListOptions } from './getList' +import { getOne } from './getOne' import { create, Create } from './create' import { update, Update } from './update' import { destroy, Destroy } from './delete' export interface Actions { - getOne: GetOne | null + get: Get | null create: Create | null destroy: Destroy | null update: Update | null - getList: GetList | null - search: Search | null } -interface CrudOptions { - filters: FiltersOption -} -export { GetOne, Create, Destroy, Update, GetList, Search } + +export { Create, Destroy, Update, Get } export const crud = ( path: string, actions: Partial>, - options?: Partial + options?: Partial> ) => { const router = Router() router.use(bodyParser.json()) - if (actions.getList) + if (actions.get) { router.get( path, getMany( - actions.getList, - actions.search || undefined, - options && options.filters + actions.get, + options ) ) - - if (actions.getOne) { - router.get(`${path}/:id`, getOne(actions.getOne)) + router.get(`${path}/:id`, getOne(actions.get)) } if (actions.create) { @@ -48,10 +41,10 @@ export const crud = ( } if (actions.update) { - if (!actions.getOne) { + if (!actions.get) { throw new Error('You cannot define update without defining getOne') } - router.put(`${path}/:id`, update(actions.update, actions.getOne)) + router.put(`${path}/:id`, update(actions.update, actions.get)) } if (actions.destroy) { diff --git a/src/update.ts b/src/update.ts index 7d037df..a89a2c2 100644 --- a/src/update.ts +++ b/src/update.ts @@ -1,14 +1,19 @@ import { RequestHandler, Request, Response } from 'express' -import { GetOne } from './getOne' +import { Get } from './getList' -export type Update = (id: string, data: R, opts?: { req: Request,res: Response }) => Promise +export type Update = (id: string, data: R, opts?: { req: Request, res: Response }) => Promise export const update = ( doUpdate: Update, - doGetOne: GetOne + doGetList: Get ): RequestHandler => async (req, res, next) => { try { - const record = await doGetOne(req.params.id, { req, res }) + const record = await doGetList({ + filter: { id: req.params.id }, + limit: 1, + offset: 0, + order: [] + }, { req, res }) if (!record) { return res.status(404).json({ error: 'Record not found' }) diff --git a/tests/index.spec.ts b/tests/index.spec.ts index 20e423c..d000899 100644 --- a/tests/index.spec.ts +++ b/tests/index.spec.ts @@ -1,9 +1,9 @@ +import { Server } from 'http' import { crud } from '../src' import { setupApp } from './app' -import { User } from './User' describe('crud', () => { - const ctx = { + const ctx: { server: Server | null } = { server: null, } @@ -12,7 +12,7 @@ describe('crud', () => { }) afterEach(() => { - ctx.server.close() + ctx.server?.close() }) const expectReqRes = expect.objectContaining({ @@ -22,12 +22,12 @@ describe('crud', () => { describe('actions', () => { describe('GET_LIST', () => { - it('calls getList with expected params when no "q" filter is provided', async () => { - const getList = jest.fn() + it('calls get with expected params', async () => { + const get = jest.fn() const dataProvider = await setupApp( crud('/users', { - getList, + get, }), ctx ) @@ -35,9 +35,9 @@ describe('crud', () => { const rows = new Array(5).fill(1) const totalCount = 300 - getList.mockResolvedValue({ + get.mockResolvedValue({ count: totalCount, - rows: rows as User[], + rows, }) const response = await dataProvider.getList('users', { @@ -48,7 +48,7 @@ describe('crud', () => { expect(response.data).toEqual(rows) expect(response.total).toEqual(totalCount) - expect(getList).toHaveBeenCalledWith({ + expect(get).toHaveBeenCalledWith({ offset: 10, limit: 5, filter: {}, @@ -56,35 +56,25 @@ describe('crud', () => { }, expectReqRes) }) - it('calls search with expected params when a "q" filter is provided', async () => { - const search = jest.fn() + it('populates additional fields when provided', async () => { const dataProvider = await setupApp( - crud('/users', { - getList: jest.fn(), - search, + crud('/users', { + get: jest.fn().mockResolvedValue({ rows: [{ id: 1 }], count: 1 }), + }, { + additionalAttributes: async (record) => { + return { additionalProperty: await new Promise(resolve => resolve(`value ${record.id}`)) } + } }), ctx ) - const rows = new Array(5).fill(1) - const totalCount = 300 - - search.mockResolvedValue({ - count: totalCount, - rows: rows as User[], - }) - const response = await dataProvider.getList('users', { pagination: { page: 0, perPage: 25 }, sort: { field: 'id', order: 'DESC' }, - filter: { q: 'some search', language: 'en' }, + filter: {}, }) - expect(response.data).toEqual(rows) - expect(response.total).toEqual(totalCount) - expect(search).toHaveBeenCalledWith('some search', 25, { - language: 'en', - }, expectReqRes) + expect(response.data[0]).toEqual({ id: 1, additionalProperty: 'value 1' }) }) }) @@ -109,12 +99,12 @@ describe('crud', () => { describe('UPDATE', () => { it('calls update with expected params', async () => { - const getOne = jest.fn().mockResolvedValue({ id: 1, name: 'Éloi' }) + const get = jest.fn().mockResolvedValue({ id: 1, name: 'Éloi' }) const update = jest.fn().mockResolvedValue({ id: 1, name: 'Éloi' }) const dataProvider = await setupApp( crud('/users', { - getOne, + get, update, }), ctx @@ -152,11 +142,11 @@ describe('crud', () => { expect.assertions(1) const update = jest.fn() - const getOne = jest.fn().mockResolvedValue(null) + const get = jest.fn().mockResolvedValue(null) const dataProvider = await setupApp( crud('/users', { - getOne, + get, update, }), ctx @@ -197,11 +187,11 @@ describe('crud', () => { }) describe('GET_ONE', () => { - it('calls getOne with expected params', async () => { - const getOne = jest.fn().mockResolvedValue({ id: 1, name: 'Éloi' }) + it('calls get with expected params', async () => { + const get = jest.fn().mockResolvedValue({ rows: [{ id: 1, name: 'Éloi' }], count: 1 }) const dataProvider = await setupApp( crud('/users', { - getOne, + get, }), ctx ) @@ -211,16 +201,16 @@ describe('crud', () => { }) expect(response.data).toEqual({ id: 1, name: 'Éloi' }) - expect(getOne).toHaveBeenCalledWith('1', expectReqRes) + expect(get).toHaveBeenCalledWith({ filter: { id: '1' }, limit: 1, offset: 0, order: [] }, expectReqRes) }) it('throws a 404 when record is not found', async () => { expect.assertions(1) - const getOne = jest.fn().mockResolvedValue(null) + const get = jest.fn().mockResolvedValue({ rows: [], count: 0 }) const dataProvider = await setupApp( crud('/users', { - getOne, + get, }), ctx ) diff --git a/tests/simpleRestProvider.ts b/tests/simpleRestProvider.ts index 4bf45e1..50932cc 100644 --- a/tests/simpleRestProvider.ts +++ b/tests/simpleRestProvider.ts @@ -21,11 +21,11 @@ export default ( const options = countHeader === 'Content-Range' ? { - // Chrome doesn't return `Content-Range` header if no `Range` is provided in the request. - headers: new Headers({ - Range: `${resource}=${rangeStart}-${rangeEnd}`, - }), - } + // Chrome doesn't return `Content-Range` header if no `Range` is provided in the request. + headers: new Headers({ + Range: `${resource}=${rangeStart}-${rangeEnd}`, + }), + } : {} return httpClient(url, options).then(({ headers, json }) => { diff --git a/yarn.lock b/yarn.lock index ff727b5..00f5ac6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5304,6 +5304,13 @@ p-is-promise@^3.0.0: resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-3.0.0.tgz#58e78c7dfe2e163cf2a04ff869e7c1dba64a5971" integrity sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ== +p-limit@3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + p-limit@^1.1.0: version "1.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" @@ -6925,3 +6932,8 @@ yargs@^16.2.0: string-width "^4.2.0" y18n "^5.0.5" yargs-parser "^20.2.2" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==