├── .gitignore ├── .npmignore ├── .travis.yml ├── .jshintrc ├── package.json ├── README.md ├── src └── paginate-anything.js └── spec └── paginate-anything.spec.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.11" 4 | - "0.10" 5 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node" : true, 3 | "jasmine" : true, 4 | "unused" : true, 5 | "expr" : true, 6 | "camelcase" : true 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-paginate-anything", 3 | "description": "Node server side for the angular-paginate-anything directive. Modify the http response for pagination, return 2 properties to use in a query", 4 | "version": "1.0.0", 5 | "main": "src/paginate-anything.js", 6 | "private": false, 7 | "scripts": { 8 | "test": "node_modules/jasmine-node/bin/jasmine-node spec/ && node_modules/jshint/bin/jshint src/" 9 | }, 10 | "repository" : { 11 | "type": "git", 12 | "url": "https://github.com/polo2ro/node-paginate-anything.git" 13 | }, 14 | "dependencies": { 15 | }, 16 | "devDependencies": { 17 | "jasmine-node": "~1.14.3", 18 | "jshint": "latest" 19 | }, 20 | "engine": { 21 | "node": ">=0.6" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | node-paginate-anything 2 | ====================== 3 | 4 | [![Build Status](https://travis-ci.org/polo2ro/node-paginate-anything.svg?branch=master)](https://travis-ci.org/polo2ro/node-paginate-anything) 5 | [![Dependency Status](https://david-dm.org/polo2ro/node-paginate-anything.svg)](https://david-dm.org/polo2ro/node-paginate-anything) 6 | [![devDependency Status](https://david-dm.org/polo2ro/node-paginate-anything/dev-status.svg)](https://david-dm.org/polo2ro/node-paginate-anything#info=devDependencies) 7 | 8 | nodejs server side module for [angular-paginate-anything](https://github.com/begriffs/angular-paginate-anything) 9 | 10 | This nodejs module add the required headers in the http response to paginate the items. This is a rewrite of [clean_pagination](https://github.com/begriffs/clean_pagination) 11 | 12 | 13 | ### Install 14 | ```Bash 15 | npm install node-paginate-anything 16 | ``` 17 | 18 | ### Usage 19 | 20 | ```JavaScript 21 | var paginate = require('node-paginate-anything'); 22 | 23 | var queryParameters = paginate(ClientRequest, ServerResponse, totalItems, maxRangeSize); 24 | 25 | mongooseQuery.limit(queryParameters.limit); 26 | mongooseQuery.skip(queryParameters.skip); 27 | ``` 28 | 29 | 30 | 31 | parameter | Description 32 | ---------------|--------------- 33 | ClientRequest | [clientRequest](http://nodejs.org/api/http.html#http_class_http_clientrequest) object from the native http module or from an express app. 34 | ServerResponse | [ServerResponse](http://nodejs.org/api/http.html#http_class_http_serverresponse) object to modify before sending the http response. 35 | totalItems | total number of items in the result set. 36 | maxRangeSize | angular-paginate-anything send is own requested range in the request, this parameter specify the maximum value. 37 | 38 | 39 | ### Benefits 40 | 41 | * **HTTP Content-Type agnoticism.** Information about total items, 42 | selected ranges, and next- previous-links are sent through headers. 43 | It works without modifying your API payload in any way. 44 | * **Graceful degredation.** Both client and server specify the maximum 45 | page size they accept and communication gracefully degrades to 46 | accomodate the lesser. 47 | * **Expressive retrieval.** This approach, unlike the use of `per_page` and 48 | `page` parameters, allows the client to request any (possibly unbounded) 49 | interval of items. 50 | * **Semantic HTTP.** Built in strict conformance to RFCs 2616 and 5988. 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /src/paginate-anything.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Modify the http response for pagination, return 2 properties to use in a query 5 | * 6 | * @url https://github.com/begriffs/clean_pagination 7 | * @url http://nodejs.org/api/http.html#http_class_http_clientrequest 8 | * @url http://nodejs.org/api/http.html#http_class_http_serverresponse 9 | * 10 | * 11 | * @param {http.ClientRequest} req http request to get headers from 12 | * @param {http.ServerResponse} res http response to complete 13 | * @param {int} totalItems total number of items available, can be Infinity 14 | * @param {int} maxRangeSize 15 | * 16 | * @return {Object} 17 | * .limit Number of items to return 18 | * .skip Zero based position for the first item to return 19 | */ 20 | exports = module.exports = function(req, res, totalItems, maxRangeSize) 21 | { 22 | 23 | /** 24 | * Parse requested range 25 | */ 26 | function parseRange(hdr) { 27 | var m = hdr && hdr.match(/^(\d+)-(\d*)$/); 28 | if(!m) { 29 | return null; 30 | } 31 | return { 32 | from: parseInt(m[1]), 33 | to: m[2] ? parseInt(m[2]) : Infinity 34 | }; 35 | } 36 | 37 | res.setHeader('Accept-Ranges', 'items'); 38 | res.setHeader('Range-Unit', 'items'); 39 | res.setHeader('Access-Control-Expose-Headers', 'Content-Range, Accept-Ranges, Range-Unit'); 40 | 41 | maxRangeSize = parseInt(maxRangeSize); 42 | 43 | var range = { 44 | from: 0, 45 | to: (totalItems -1) 46 | }; 47 | 48 | if ('items' === req.headers['range-unit']) 49 | { 50 | var parsedRange = parseRange(req.headers.range); 51 | if (parsedRange) 52 | { 53 | range = parsedRange; 54 | } 55 | } 56 | 57 | if ((null !== range.to && range.from > range.to) || (range.from > 0 && range.from >= totalItems)) 58 | { 59 | if (totalItems > 0 || range.from !== 0) { 60 | res.statusCode = 416; // Requested range unsatisfiable 61 | } else { 62 | res.statusCode = 204; // No content 63 | } 64 | res.setHeader('Content-Range', '*/'+totalItems); 65 | return; 66 | } 67 | 68 | var availableTo; 69 | var reportTotal; 70 | 71 | if (totalItems < Infinity) 72 | { 73 | availableTo = Math.min( 74 | range.to, 75 | totalItems -1, 76 | range.from + maxRangeSize -1 77 | ); 78 | 79 | reportTotal = totalItems; 80 | 81 | } else { 82 | availableTo = Math.min( 83 | range.to, 84 | range.from + maxRangeSize -1 85 | ); 86 | 87 | reportTotal = '*'; 88 | } 89 | 90 | res.setHeader('Content-Range', range.from+'-'+availableTo+'/'+reportTotal); 91 | 92 | var availableLimit = availableTo - range.from + 1; 93 | 94 | if ( 0 === availableLimit) 95 | { 96 | res.statusCode = 204; // no content 97 | res.setHeader('Content-Range', '*/0'); 98 | return; 99 | } 100 | 101 | if (availableLimit < totalItems) 102 | { 103 | res.statusCode = 206; // Partial contents 104 | } else { 105 | res.statusCode = 200; // OK (all items) 106 | } 107 | 108 | // Links 109 | function buildLink(rel, itemsFrom, itemsTo) 110 | { 111 | var to = itemsTo < Infinity ? itemsTo : ''; 112 | return '<'+req.url+'>; rel="'+rel+'"; items="'+itemsFrom+'-'+to+'"'; 113 | } 114 | 115 | var requestedLimit = range.to - range.from + 1; 116 | var links = []; 117 | 118 | if (availableTo < totalItems -1) 119 | { 120 | links.push(buildLink('next', 121 | availableTo + 1, 122 | availableTo + requestedLimit 123 | )); 124 | 125 | if (totalItems < Infinity) 126 | { 127 | var lastStart = Math.floor((totalItems-1) / availableLimit) * availableLimit; 128 | 129 | links.push(buildLink('last', 130 | lastStart, 131 | lastStart + requestedLimit - 1 132 | )); 133 | } 134 | } 135 | 136 | if (range.from > 0) 137 | { 138 | var previousFrom = Math.max(0, range.from - Math.min(requestedLimit, maxRangeSize)); 139 | links.push(buildLink('prev', 140 | previousFrom, 141 | previousFrom + requestedLimit - 1 142 | )); 143 | 144 | links.push(buildLink('first', 145 | 0, 146 | requestedLimit-1 147 | )); 148 | } 149 | 150 | res.setHeader('Link', links.join(', ')); 151 | 152 | // return values named from mongoose methods 153 | return { 154 | limit: availableLimit, 155 | skip: range.from 156 | }; 157 | }; 158 | -------------------------------------------------------------------------------- /spec/paginate-anything.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var http = require('http'); 3 | var paginate = require('../src/paginate-anything'); 4 | 5 | var server = http.createServer(function (req, res) { 6 | var url = require('url').parse(req.url, true); 7 | var params = url.query; 8 | 9 | var totalItems = '*' === params.totalItems ? Infinity : params.totalItems; 10 | var maxRangeSize = params.maxRangeSize; 11 | 12 | paginate(req, res, totalItems, maxRangeSize); 13 | 14 | var input = 'Item '; 15 | var body = ''; 16 | var multiplier = totalItems; 17 | 18 | while (true) { 19 | if (multiplier & 1) { 20 | body += input; 21 | } 22 | multiplier >>= 1; 23 | if (multiplier) { 24 | input += input; 25 | } else { 26 | break; 27 | } 28 | } 29 | 30 | res.end(body); 31 | req.connection.destroy(); 32 | }); 33 | server.listen(3000); 34 | 35 | 36 | 37 | /** 38 | * @param int totalItems Total number of items on server (can be Infinity) 39 | * @param int maxRangeSize The maxRangeSize value used on server 40 | * @param string range range used by the client query 41 | */ 42 | function paginatedRequest(totalItems, maxRangeSize, range, callback) 43 | { 44 | if (totalItems >= Infinity) 45 | { 46 | totalItems = '*'; 47 | } 48 | var headers; 49 | if (range !== null) { 50 | headers = { 51 | 'range-unit': 'items', 52 | 'range': range 53 | }; 54 | } else { 55 | headers = {}; 56 | } 57 | 58 | var options = { 59 | hostname: 'localhost', 60 | port: 3000, 61 | path: '/?totalItems='+totalItems+'&maxRangeSize='+maxRangeSize, 62 | method: 'GET', 63 | headers: headers 64 | }; 65 | 66 | var req = http.request(options, callback); 67 | 68 | 69 | req.on('error', function(e) { 70 | console.log('problem with request: ' + e.message); 71 | }); 72 | 73 | req.end(); 74 | } 75 | 76 | 77 | /** 78 | * Get the ranges for each link in the link header 79 | * @param http.ServerResponse response 80 | */ 81 | function linkHeader(response) 82 | { 83 | var link = {}; 84 | 85 | if (response.headers.link === undefined) 86 | { 87 | return link; 88 | } 89 | 90 | var arr = response.headers.link.split(','); 91 | for(var i=0; i= total', function (done){ 137 | http.get('http://localhost:3000?totalItems=100&maxRangeSize=100', function(response){ 138 | expect(response.headers['accept-ranges']).toBe('items'); 139 | expect(response.headers['content-range']).toBe('0-99/100'); 140 | expect(response.statusCode).toBe(200); 141 | done(); 142 | }); 143 | }); 144 | 145 | 146 | it('truncates response on rangeless request if max_range < total', function (done){ 147 | http.get('http://localhost:3000?totalItems=101&maxRangeSize=100', function(response){ 148 | expect(response.headers['accept-ranges']).toBe('items'); 149 | expect(response.headers['content-range']).toBe('0-99/101'); 150 | expect(response.statusCode).toBe(206); 151 | done(); 152 | }); 153 | }); 154 | 155 | 156 | it('truncate oversized range', function (done){ 157 | paginatedRequest(101, 100, '0-100', function(response){ 158 | expect(response.headers['accept-ranges']).toBe('items'); 159 | expect(response.headers['content-range']).toBe('0-99/101'); 160 | expect(response.statusCode).toBe(206); 161 | done(); 162 | }); 163 | }); 164 | 165 | it('server should respond with partial content and links', function (done){ 166 | paginatedRequest(100, 1000, '10-19', function(response){ 167 | var links = linkHeader(response); 168 | expect(links.prev).toBe('0-9'); 169 | expect(links.first).toBe('0-9'); 170 | expect(links.next).toBe('20-29'); 171 | expect(links.last).toBe('90-99'); 172 | expect(response.headers['content-range']).toBe('10-19/100'); 173 | expect(response.statusCode).toBe(206); 174 | done(); 175 | }); 176 | }); 177 | 178 | it('server should respond with a requested range unsatisfiable response', function (done){ 179 | paginatedRequest(5, 1000, '20-25', function(response){ 180 | expect(response.headers['content-range']).toBe('*/5'); 181 | expect(response.statusCode).toBe(416); 182 | done(); 183 | }); 184 | 185 | paginatedRequest(80, 1000, '25-15', function(response){ 186 | expect(response.headers['content-range']).toBe('*/80'); 187 | expect(response.statusCode).toBe(416); 188 | done(); 189 | }); 190 | }); 191 | 192 | 193 | it('returns empty body when there are zero total items', function (done){ 194 | paginatedRequest(0, 1000, null, function(response){ 195 | expect(response.statusCode).toBe(204); 196 | 197 | response.setEncoding('utf8'); 198 | response.on('data', function (chunk) { 199 | expect(chunk).toBe(''); 200 | }); 201 | 202 | response.on('end', function() { 203 | done(); 204 | }); 205 | }); 206 | }); 207 | 208 | 209 | it('accepts a range starting from 0 when there are no items', function (done){ 210 | paginatedRequest(0, 1000, '0-9', function(response){ 211 | expect(response.headers['content-range']).toBe('*/0'); 212 | expect(response.statusCode).toBe(204); 213 | done(); 214 | }); 215 | }); 216 | 217 | 218 | it('refuses a range with nonzero start when there are no items', function (done){ 219 | paginatedRequest(0, 1000, '1-10', function(response){ 220 | expect(response.headers['content-range']).toBe('*/0'); 221 | expect(response.statusCode).toBe(416); 222 | done(); 223 | }); 224 | }); 225 | 226 | 227 | it('refuses range start past end', function (done){ 228 | paginatedRequest(101, 100, '101-', function(response){ 229 | expect(response.headers['content-range']).toBe('*/101'); 230 | expect(response.statusCode).toBe(416); 231 | done(); 232 | }); 233 | }); 234 | 235 | 236 | it('allows one-item requests', function (done){ 237 | paginatedRequest(101, 100, '0-0', function(response){ 238 | expect(response.headers['content-range']).toBe('0-0/101'); 239 | expect(response.statusCode).toBe(206); 240 | done(); 241 | }); 242 | }); 243 | 244 | 245 | it('handles ranges beyond collection length via truncation', function (done){ 246 | paginatedRequest(101, 100, '50-200', function(response){ 247 | expect(response.headers['content-range']).toBe('50-100/101'); 248 | expect(response.statusCode).toBe(206); 249 | done(); 250 | }); 251 | }); 252 | 253 | it('respond partial content and correct range for infinity total items', function (done){ 254 | paginatedRequest(Infinity, 1000, '50-55', function(response){ 255 | var links = linkHeader(response); 256 | expect(links.prev).toBe('44-49'); 257 | expect(links.first).toBe('0-5'); 258 | 259 | expect(response.headers['content-range']).toBe('50-55/*'); 260 | expect(response.statusCode).toBe(206); 261 | done(); 262 | }); 263 | }); 264 | 265 | it('next page range can extend beyond last item', function (done){ 266 | paginatedRequest(100, 100, '50-89', function(response){ 267 | var links = linkHeader(response); 268 | expect(links.next).toBe('90-129'); 269 | done(); 270 | }); 271 | }); 272 | 273 | 274 | it('previous page range cannot go negative', function (done){ 275 | paginatedRequest(100, 100, '10-99', function(response){ 276 | var links = linkHeader(response); 277 | expect(links.prev).toBe('0-89'); 278 | done(); 279 | }); 280 | }); 281 | 282 | it('first page range always starts at zero', function (done){ 283 | paginatedRequest(100, 100, '63-72', function(response){ 284 | var links = linkHeader(response); 285 | expect(links.first).toBe('0-9'); 286 | done(); 287 | }); 288 | }); 289 | 290 | it('last page range can extend beyond the last item', function (done){ 291 | paginatedRequest(100, 100, '0-6', function(response){ 292 | var links = linkHeader(response); 293 | expect(links.last).toBe('98-104'); 294 | done(); 295 | }); 296 | }); 297 | 298 | it('infinite collections have no last page', function (done){ 299 | paginatedRequest(Infinity, 100, '0-9', function(response){ 300 | var links = linkHeader(response); 301 | expect(links.last).toBe(undefined); 302 | done(); 303 | }); 304 | }); 305 | 306 | // TODO: omitting the end number asks for everything 307 | it('omitting the end number omits in first link too', function (done){ 308 | paginatedRequest(Infinity, 1000000, '50-', function(response){ 309 | var links = linkHeader(response); 310 | expect(links.first).toBe('0-'); 311 | done(); 312 | }); 313 | }); 314 | 315 | it('next link with omitted end number shifts by max page', function (done){ 316 | paginatedRequest(Infinity, 1000000, '50-', function(response){ 317 | var links = linkHeader(response); 318 | expect(links.next).toBe('1000050-'); 319 | done(); 320 | }); 321 | }); 322 | 323 | it('prev link with omitted end number shifts by max page', function (done){ 324 | paginatedRequest(Infinity, 25, '50-', function(response){ 325 | var links = linkHeader(response); 326 | expect(links.prev).toBe('25-'); 327 | done(); 328 | }); 329 | }); 330 | 331 | it('shifts penultimate page to beginning, preserving length', function (done){ 332 | paginatedRequest(100, 101, '10-49', function(response){ 333 | var links = linkHeader(response); 334 | expect(links.prev).toBe('0-39'); 335 | expect(links.first).toBe('0-39'); 336 | done(); 337 | }); 338 | }); 339 | 340 | // TODO: prev is the left inverse of next 341 | 342 | // TODO: for from > to-from, next is the right inverse of prev 343 | 344 | it('omits prev and first links at start', function (done){ 345 | paginatedRequest(100, 101, '0-9', function(response){ 346 | var links = linkHeader(response); 347 | expect(links.prev).toBe(undefined); 348 | expect(links.first).toBe(undefined); 349 | done(); 350 | }); 351 | }); 352 | 353 | it('omits next and last links at end', function (done){ 354 | paginatedRequest(100, 101, '90-99', function(response){ 355 | var links = linkHeader(response); 356 | expect(links.last).toBe(undefined); 357 | expect(links.next).toBe(undefined); 358 | done(); 359 | }); 360 | }); 361 | 362 | 363 | // TODO: preserves query parameters in link headers 364 | 365 | it('Test server should close', function (done){ 366 | server.close(done); 367 | }); 368 | }); 369 | 370 | 371 | --------------------------------------------------------------------------------