diff --git a/decl/http.js b/decl/http.js index da00f1a2..f8484f24 100644 --- a/decl/http.js +++ b/decl/http.js @@ -16,12 +16,15 @@ type params = { relationships?: Object; }; + page: { + size: number; + number: number; + }; + fields: Object; filter: Object; id?: number | string | Buffer; include: Array | Object; - limit: number; - page: number; sort: string | [string, string]; }; diff --git a/src/packages/controller/index.js b/src/packages/controller/index.js index 184dcb5a..f45160b5 100644 --- a/src/packages/controller/index.js +++ b/src/packages/controller/index.js @@ -365,7 +365,6 @@ class Controller { params: { sort, page, - limit, filter, fields, include @@ -374,8 +373,8 @@ class Controller { return this.model.select(...fields) .include(include) - .limit(limit) - .page(page) + .limit(page.size) + .page(page.number) .where(filter) .order(...sort); } diff --git a/src/packages/route/middleware/sanitize-params.js b/src/packages/route/middleware/sanitize-params.js index 4aaeb795..95e74d32 100644 --- a/src/packages/route/middleware/sanitize-params.js +++ b/src/packages/route/middleware/sanitize-params.js @@ -21,6 +21,7 @@ export default function sanitizeParams( } }: { modelName: string, + model: { relationshipNames: Array } @@ -32,25 +33,12 @@ export default function sanitizeParams( let { page, - limit, sort, filter, include, fields } = params; - if (!page) { - page = 1; - } else if (typeof page === 'string') { - page = parseInt(page, 10); - } - - if (!limit) { - limit = 25; - } else if (typeof limit === 'string') { - limit = parseInt(limit, 10); - } - if (!sort) { sort = ['createdAt', 'ASC']; } else { @@ -118,14 +106,12 @@ export default function sanitizeParams( }, {}); req.params = { - id: params.id, - filter: pick(filter, ...this.filter), - page, - limit, sort, include, - fields + fields, + id: params.id, + filter: pick(filter, ...this.filter) }; if (/^(POST|PATCH)$/g.test(req.method)) { @@ -136,13 +122,15 @@ export default function sanitizeParams( } } = params; - Object.assign(req.params, { + req.params = { + ...req.params, + data: { id: params.data.id, type, attributes: pick(attributes, ...this.params) } - }); + }; } } diff --git a/src/packages/route/middleware/set-limit.js b/src/packages/route/middleware/set-limit.js deleted file mode 100644 index 1b675854..00000000 --- a/src/packages/route/middleware/set-limit.js +++ /dev/null @@ -1,24 +0,0 @@ -// @flow -import type { IncomingMessage, ServerResponse } from 'http'; - -/** - * @private - */ -export default function setLimit( - req: IncomingMessage, - res: ServerResponse -): void { - const { route } = req; - - if (route && route.action === 'index') { - let { - params: { - limit - } - } = req; - - if (!limit) { - req.params.limit = this.defaultPerPage; - } - } -} diff --git a/src/packages/route/middleware/set-page.js b/src/packages/route/middleware/set-page.js new file mode 100644 index 00000000..5683c951 --- /dev/null +++ b/src/packages/route/middleware/set-page.js @@ -0,0 +1,46 @@ +// @flow +import type { IncomingMessage, ServerResponse } from 'http'; + +/** + * @private + */ +export default function setPage( + req: IncomingMessage, + res: ServerResponse +): void { + const { route } = req; + + if (route && route.action === 'index') { + const { defaultPerPage } = this; + + let { + params: { + page: { + size, + number + } = { + size: defaultPerPage, + number: 1 + } + } + } = req; + + size = parseInt(size, 10); + number = parseInt(number, 10); + + if (!Number.isFinite(size)) { + size = defaultPerPage; + } else if (!Number.isFinite(number)) { + number = 1; + } + + req.params = { + ...req.params, + + page: { + size, + number + } + }; + } +} diff --git a/src/packages/route/utils/create-action.js b/src/packages/route/utils/create-action.js index 12088125..fb115cd7 100644 --- a/src/packages/route/utils/create-action.js +++ b/src/packages/route/utils/create-action.js @@ -4,7 +4,7 @@ import { Model, Query } from '../../database'; import sanitizeParams from '../middleware/sanitize-params'; import setInclude from '../middleware/set-include'; import setFields from '../middleware/set-fields'; -import setLimit from '../middleware/set-limit'; +import setPage from '../middleware/set-page'; import insert from '../../../utils/insert'; import createPageLinks from './create-page-links'; @@ -25,7 +25,7 @@ export default function createAction( sanitizeParams, setInclude, setFields, - setLimit + setPage ]; const handlers = new Array(builtIns.length + middleware.length + 1); @@ -52,7 +52,6 @@ export default function createAction( params: { page, - limit, include } } = req; @@ -76,7 +75,6 @@ export default function createAction( ...createPageLinks({ page, - limit, total, query, domain, diff --git a/src/packages/route/utils/create-page-links.js b/src/packages/route/utils/create-page-links.js index ce35e687..87ee933e 100644 --- a/src/packages/route/utils/create-page-links.js +++ b/src/packages/route/utils/create-page-links.js @@ -1,19 +1,18 @@ // @flow -import querystring from 'querystring'; - import omit from '../../../utils/omit'; +import createQueryString from '../../../utils/create-query-string'; + +import type { IncomingMessage } from 'http'; export default function createPageLinks({ page, - limit, total, query, domain, pathname, defaultPerPage }: { - page: number, - limit: number, + page: IncomingMessage.params.page, total: number, query: Object, domain: string, @@ -25,50 +24,62 @@ export default function createPageLinks({ prev: ?string, next: ?string } { - const params = omit(query, 'limit', 'page'); - const lastPageNum = total > 0 ? Math.ceil(total / limit) : 1; + const params = omit(query, 'page', 'page[size]', 'page[number]'); + const lastPageNum = total > 0 ? Math.ceil(total / page.size) : 1; let base = domain + pathname; let prev = null; let next = null; let last = null; let first = null; - if (limit !== defaultPerPage) { - params.limit = limit; - } + params.page = page.size !== defaultPerPage ? { + size: page.size + } : {}; - if (Object.keys(params).length) { + if (Object.keys(params).length > 1) { base += '?'; - first = base + querystring.stringify(params); + first = base + createQueryString(params); } else { first = base; base += '?'; } if (lastPageNum > 1) { - last = base + querystring.stringify({ + last = base + createQueryString({ ...params, - page: lastPageNum + + page: { + ...params.page, + number: lastPageNum + } }); } else { last = first; } - if (page > 1) { - if (page === 2) { + if (page.number > 1) { + if (page.number === 2) { prev = first; } else { - prev = base + querystring.stringify({ + prev = base + createQueryString({ ...params, - page: page - 1 + + page: { + ...params.page, + number: page.number - 1 + } }); } } - if (page < lastPageNum) { - next = base + querystring.stringify({ + if (page.number < lastPageNum) { + next = base + createQueryString({ ...params, - page: page + 1 + + page: { + ...params.page, + number: page.number + 1 + } }); } diff --git a/src/packages/serializer/index.js b/src/packages/serializer/index.js index 462f4f4f..eaadc42f 100644 --- a/src/packages/serializer/index.js +++ b/src/packages/serializer/index.js @@ -119,8 +119,8 @@ class Serializer { * ], * "links": { * "self": "http://localhost:4000/posts", - * "first": "http://localhost:4000/posts?page=1", - * "last": "http://localhost:4000/posts?page=1", + * "first": "http://localhost:4000/posts", + * "last": "http://localhost:4000/posts", * "prev": null, * "next": null * } @@ -198,8 +198,8 @@ class Serializer { * ], * "links": { * "self": "http://localhost:4000/posts", - * "first": "http://localhost:4000/posts?page=1", - * "last": "http://localhost:4000/posts?page=1", + * "first": "http://localhost:4000/posts", + * "last": "http://localhost:4000/posts", * "prev": null, * "next": null * } @@ -261,8 +261,8 @@ class Serializer { * ], * "links": { * "self": "http://localhost:4000/posts", - * "first": "http://localhost:4000/posts?page=1", - * "last": "http://localhost:4000/posts?page=1", + * "first": "http://localhost:4000/posts", + * "last": "http://localhost:4000/posts", * "prev": null, * "next": null * } diff --git a/src/packages/server/utils/format-params.js b/src/packages/server/utils/format-params.js index 1c6541aa..69d7d8ef 100644 --- a/src/packages/server/utils/format-params.js +++ b/src/packages/server/utils/format-params.js @@ -71,6 +71,7 @@ export default async function formatParams(req) { return { ...result, + [parentKey]: { ...parentValue, [childKey]: value @@ -85,7 +86,10 @@ export default async function formatParams(req) { }, {}); if (/(PATCH|POST)/g.test(method)) { - Object.assign(params, await bodyParser(req)); + params = { + ...params, + ...(await bodyParser(req)) + }; } return format(params, method); diff --git a/src/utils/create-query-string.js b/src/utils/create-query-string.js new file mode 100644 index 00000000..4b2c9d7a --- /dev/null +++ b/src/utils/create-query-string.js @@ -0,0 +1,47 @@ +// @flow +import entries from './entries'; + +/** + * A replacement for querystring.stringify that supports nested objects. + * + * @private + */ +export default function createQueryString( + source: Object, + property?: string +): string { + return entries(source).reduce((result, [key, value], index, arr) => { + if (index > 0) { + result += '&'; + } + + if (property) { + result += ( + property + + encodeURIComponent('[') + + key + + encodeURIComponent(']') + + '=' + ); + } else { + result += `${key}=`; + } + + if (value && typeof value === 'object') { + if (Array.isArray(value)) { + result += value.map(encodeURIComponent).join(); + } else { + result = ( + result.substr(0, result.length - (key.length + 1)) + + createQueryString(value, key) + ); + } + } else if (!value && typeof value !== 'number') { + result += 'null'; + } else { + result += encodeURIComponent(value); + } + + return result; + }, ''); +} diff --git a/test/integration/controller.js b/test/integration/controller.js index f10c1043..3f5990ff 100644 --- a/test/integration/controller.js +++ b/test/integration/controller.js @@ -52,7 +52,9 @@ describe('Integration: class Controller', () => { before(async () => { pagesWithLimit = await Promise.all([ ...[1, 2, 4, 5, 6].map(async (page) => { - page = await fetch(`${host}/posts?page=${page}&limit=10`); + page = await fetch( + `${host}/posts?page%5Bsize%5D=10&page%5Bnumber%5D=${page}` + ); return { subject: page, @@ -63,7 +65,7 @@ describe('Integration: class Controller', () => { pagesWithoutLimit = await Promise.all([ ...[1, 2, 3].map(async (page) => { - page = await fetch(`${host}/posts?page=${page}`); + page = await fetch(`${host}/posts?page%5Bnumber%5D=${page}`); return { subject: page, @@ -81,7 +83,7 @@ describe('Integration: class Controller', () => { ).to.be.false; }); - it('supports limit parameter', () => { + it('supports page[size] parameter', () => { let page; for (page of pagesWithLimit) { @@ -89,7 +91,7 @@ describe('Integration: class Controller', () => { } }); - it('has a default limit parameter of 25', () => { + it('has a default page[size] of 25', () => { let page; for (page of pagesWithoutLimit) { diff --git a/test/unit/utils/create-query-string.js b/test/unit/utils/create-query-string.js new file mode 100644 index 00000000..c0fcf179 --- /dev/null +++ b/test/unit/utils/create-query-string.js @@ -0,0 +1,40 @@ +import { expect } from 'chai'; + +import { line } from '../../../src/packages/logger'; + +import createQueryString from '../../../src/utils/create-query-string'; + +describe('Unit: util createQueryString', () => { + const expected = line` + sort=-created-at& + ids=1,2,3& + page%5Bsize%5D=25& + page%5Bnumber%5D=1& + filter%5Bname%5D=test + `.replace(/\s/g, ''); + + const subject = { + sort: '-created-at', + + ids: [ + 1, + 2, + 3 + ], + + page: { + size: 25, + number: 1 + }, + + filter: { + name: 'test' + } + }; + + it('creates a valid query string', () => { + const result = createQueryString(subject); + + expect(result).to.equal(expected); + }); +});