Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/controllers/customercontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
74 changes: 74 additions & 0 deletions app/middleware/pagination.js
Original file line number Diff line number Diff line change
@@ -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: <https://api.example.com/v1/customer/bycompany/1?limit=100&offset=100>; rel="next",
* <https://api.example.com/v1/customer/bycompany/1?limit=100&offset=0>; rel="prev",
* <https://api.example.com/v1/customer/bycompany/1?limit=100&offset=0>; rel="first",
* <https://api.example.com/v1/customer/bycompany/1?limit=100&offset=200>; 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 };
76 changes: 76 additions & 0 deletions tests/unit/pagination.test.js
Original file line number Diff line number Diff line change
@@ -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
});
});
Loading