diff --git a/app/controllers/customercontroller.js b/app/controllers/customercontroller.js index 5535320..42909fa 100644 --- a/app/controllers/customercontroller.js +++ b/app/controllers/customercontroller.js @@ -6,6 +6,7 @@ const { sequelize } = require('../config/db.config.js'); const db = require('../config/db.config.js'); const log = require('../config/logger.js'); const auth = require('../middleware/auth.js'); +const { buildLinkHeader } = require('../middleware/pagination.js'); const Customer = db.Customer; // IsMaster / GetCompanyId previously lived inline in this file and @@ -238,6 +239,10 @@ exports.getAllByCompanyId = async (req, res) => { offset, order: [['custId', 'ASC']], }); + const link = buildLinkHeader({ req, limit, offset, count }); + if (link) res.setHeader('Link', link); + // Expose Link header to browser JS clients via CORS. + res.setHeader('Access-Control-Expose-Headers', 'Link'); return res.status(200).json({ message: "Successfully retrieved customers with CompanyId " + companyId, count, diff --git a/app/middleware/pagination.js b/app/middleware/pagination.js new file mode 100644 index 0000000..3f37811 --- /dev/null +++ b/app/middleware/pagination.js @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * Build an RFC 5988 Link header value for a paginated response. + * + * Link: ; rel="next", + * ; rel="prev", + * ; rel="first", + * ; rel="last" + * + * Returns null when there's no pagination (count <= limit + offset == 0). + * Callers set the header conditionally: + * + * const link = buildLinkHeader({ req, limit, offset, count }); + * if (link) res.setHeader('Link', link); + * + * Inputs: + * - req: the express request (read req.originalUrl + req.protocol + req.get('host')) + * - limit: the page size (the same value the controller returned in body.limit) + * - offset: the current page's offset + * - count: total row count across all pages + */ + +function buildLinkHeader({ req, limit, offset, count }) { + const lim = Number(limit); + const off = Number(offset); + const total = Number(count); + if (!Number.isFinite(lim) || lim <= 0) return null; + if (!Number.isFinite(off) || off < 0) return null; + if (!Number.isFinite(total) || total < 0) return null; + + // Resolve the URL minus the query string. req.originalUrl is "/path?qs"; + // strip the qs portion deterministically. + const proto = (req.protocol || 'http'); + const host = (req.get && req.get('host')) || 'localhost'; + const url = req.originalUrl || '/'; + const qIdx = url.indexOf('?'); + const basePath = qIdx === -1 ? url : url.slice(0, qIdx); + const existingQs = qIdx === -1 ? '' : url.slice(qIdx + 1); + + const buildLink = (newOffset) => { + const params = new URLSearchParams(existingQs); + params.set('limit', String(lim)); + params.set('offset', String(newOffset)); + return `${proto}://${host}${basePath}?${params.toString()}`; + }; + + const links = []; + + // next: only if there's a next page + if (off + lim < total) { + links.push(`<${buildLink(off + lim)}>; rel="next"`); + } + // prev: only if not on the first page + if (off > 0) { + const prevOffset = Math.max(0, off - lim); + links.push(`<${buildLink(prevOffset)}>; rel="prev"`); + } + // first: always include if pagination applies (offset > 0 OR there's a next) + if (off > 0 || off + lim < total) { + links.push(`<${buildLink(0)}>; rel="first"`); + } + // last: only if there's data and pagination matters + if (total > 0 && (off > 0 || off + lim < total)) { + const lastOffset = Math.floor(Math.max(0, total - 1) / lim) * lim; + links.push(`<${buildLink(lastOffset)}>; rel="last"`); + } + + return links.length === 0 ? null : links.join(', '); +} + +module.exports = { buildLinkHeader }; diff --git a/tests/unit/pagination.test.js b/tests/unit/pagination.test.js new file mode 100644 index 0000000..697706f --- /dev/null +++ b/tests/unit/pagination.test.js @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +// +// Unit tests for the RFC 5988 Link header builder. + +import { describe, test, expect } from 'vitest'; +import { buildLinkHeader } from '../../app/middleware/pagination.js'; + +function fakeReq({ originalUrl = '/v1/customer/bycompany/1', host = 'api.example.com', protocol = 'https' } = {}) { + return { + originalUrl, + protocol, + get: (h) => (h.toLowerCase() === 'host' ? host : undefined), + }; +} + +describe('buildLinkHeader', () => { + test('returns null when no pagination is needed (offset=0, count <= limit)', () => { + const link = buildLinkHeader({ req: fakeReq(), limit: 100, offset: 0, count: 50 }); + expect(link).toBeNull(); + }); + + test('emits next + first + last when on the first page of multi-page results', () => { + const link = buildLinkHeader({ req: fakeReq(), limit: 100, offset: 0, count: 250 }); + expect(link).toContain('rel="next"'); + expect(link).toContain('offset=100'); + expect(link).toContain('rel="first"'); + expect(link).toContain('rel="last"'); + expect(link).toContain('offset=200'); // last page = floor((250-1)/100)*100 = 200 + expect(link).not.toContain('rel="prev"'); // we're on page 0 + }); + + test('emits prev + next + first + last on a middle page', () => { + const link = buildLinkHeader({ req: fakeReq(), limit: 100, offset: 100, count: 300 }); + expect(link).toContain('rel="prev"'); + expect(link).toContain('rel="next"'); + expect(link).toContain('rel="first"'); + expect(link).toContain('rel="last"'); + }); + + test('drops next on the last page', () => { + const link = buildLinkHeader({ req: fakeReq(), limit: 100, offset: 200, count: 250 }); + expect(link).not.toContain('rel="next"'); + expect(link).toContain('rel="prev"'); + expect(link).toContain('rel="first"'); + expect(link).toContain('rel="last"'); + }); + + test('preserves other query params (e.g. filter args)', () => { + const req = fakeReq({ originalUrl: '/v1/timeentry/bycompany/1?customerId=42&from=2026-01-01T00:00:00Z' }); + const link = buildLinkHeader({ req, limit: 100, offset: 0, count: 300 }); + expect(link).toContain('customerId=42'); + expect(link).toContain('from=2026-01-01'); + }); + + test('builds absolute URLs (proto + host)', () => { + const link = buildLinkHeader({ + req: fakeReq({ host: 'node.timetrackerapi.com', protocol: 'https' }), + limit: 10, offset: 0, count: 50, + }); + expect(link).toContain('https://node.timetrackerapi.com/v1/customer/bycompany/1'); + }); + + test('returns null on invalid inputs', () => { + expect(buildLinkHeader({ req: fakeReq(), limit: 0, offset: 0, count: 10 })).toBeNull(); + expect(buildLinkHeader({ req: fakeReq(), limit: 10, offset: -1, count: 10 })).toBeNull(); + expect(buildLinkHeader({ req: fakeReq(), limit: 10, offset: 0, count: -1 })).toBeNull(); + expect(buildLinkHeader({ req: fakeReq(), limit: 'abc', offset: 0, count: 10 })).toBeNull(); + }); + + test('last page offset is correctly aligned to limit boundary', () => { + // count=100, limit=30: pages are at offsets 0, 30, 60, 90. Last page = 90. + const link = buildLinkHeader({ req: fakeReq(), limit: 30, offset: 0, count: 100 }); + expect(link).toContain('offset=90'); // last page anchor + }); +});