Skip to content

Commit 8242998

Browse files
CryptoJonesAaron K. Clarkclaude
authored
feat(api): RFC 5988 Link-header pagination — helper + customer bycompany (#71)
Adds a small Link-header builder and applies it to GET /v1/customer/bycompany/:id as the proof-of-concept. Browser JS clients get the Link header exposed via Access-Control-Expose-Headers so it's reachable from fetch(). Header shape (example: limit=100, offset=100, total=300): Link: <https://api/v1/customer/bycompany/1?limit=100&offset=200>; rel="next", <https://api/v1/customer/bycompany/1?limit=100&offset=0>; rel="prev", <https://api/v1/customer/bycompany/1?limit=100&offset=0>; rel="first", <https://api/v1/customer/bycompany/1?limit=100&offset=200>; rel="last" Standard semantics — `next` omitted on the last page, `prev` omitted on the first, `first`/`last` only when pagination matters. Other query params are preserved across the substitution (so a filter+limit+from query keeps the filter when the client follows the next link). The body envelope still includes count/limit/offset — clients can keep using either approach. Adding the header is purely additive. This PR only wires it into customer/bycompany. Follow-up PRs will roll it through the rest of the paginated list endpoints (timeentry, worker, billingtype, inventoryitem, job/invoice/customerpayment by customer, PO header/line by parent, inventory tx by company). Tests: 269 / 269 + 4 integration skipped (was 261 / 261). 8 new cases for buildLinkHeader covering the page-boundary states, query-param preservation, absolute-URL building, and invalid input. Co-authored-by: Aaron K. Clark <akclark@thenetwerk.net> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1fe15be commit 8242998

3 files changed

Lines changed: 155 additions & 0 deletions

File tree

app/controllers/customercontroller.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const { sequelize } = require('../config/db.config.js');
66
const db = require('../config/db.config.js');
77
const log = require('../config/logger.js');
88
const auth = require('../middleware/auth.js');
9+
const { buildLinkHeader } = require('../middleware/pagination.js');
910
const Customer = db.Customer;
1011

1112
// IsMaster / GetCompanyId previously lived inline in this file and
@@ -238,6 +239,10 @@ exports.getAllByCompanyId = async (req, res) => {
238239
offset,
239240
order: [['custId', 'ASC']],
240241
});
242+
const link = buildLinkHeader({ req, limit, offset, count });
243+
if (link) res.setHeader('Link', link);
244+
// Expose Link header to browser JS clients via CORS.
245+
res.setHeader('Access-Control-Expose-Headers', 'Link');
241246
return res.status(200).json({
242247
message: "Successfully retrieved customers with CompanyId " + companyId,
243248
count,

app/middleware/pagination.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 Aaron K. Clark
3+
"use strict";
4+
5+
/**
6+
* Build an RFC 5988 Link header value for a paginated response.
7+
*
8+
* Link: <https://api.example.com/v1/customer/bycompany/1?limit=100&offset=100>; rel="next",
9+
* <https://api.example.com/v1/customer/bycompany/1?limit=100&offset=0>; rel="prev",
10+
* <https://api.example.com/v1/customer/bycompany/1?limit=100&offset=0>; rel="first",
11+
* <https://api.example.com/v1/customer/bycompany/1?limit=100&offset=200>; rel="last"
12+
*
13+
* Returns null when there's no pagination (count <= limit + offset == 0).
14+
* Callers set the header conditionally:
15+
*
16+
* const link = buildLinkHeader({ req, limit, offset, count });
17+
* if (link) res.setHeader('Link', link);
18+
*
19+
* Inputs:
20+
* - req: the express request (read req.originalUrl + req.protocol + req.get('host'))
21+
* - limit: the page size (the same value the controller returned in body.limit)
22+
* - offset: the current page's offset
23+
* - count: total row count across all pages
24+
*/
25+
26+
function buildLinkHeader({ req, limit, offset, count }) {
27+
const lim = Number(limit);
28+
const off = Number(offset);
29+
const total = Number(count);
30+
if (!Number.isFinite(lim) || lim <= 0) return null;
31+
if (!Number.isFinite(off) || off < 0) return null;
32+
if (!Number.isFinite(total) || total < 0) return null;
33+
34+
// Resolve the URL minus the query string. req.originalUrl is "/path?qs";
35+
// strip the qs portion deterministically.
36+
const proto = (req.protocol || 'http');
37+
const host = (req.get && req.get('host')) || 'localhost';
38+
const url = req.originalUrl || '/';
39+
const qIdx = url.indexOf('?');
40+
const basePath = qIdx === -1 ? url : url.slice(0, qIdx);
41+
const existingQs = qIdx === -1 ? '' : url.slice(qIdx + 1);
42+
43+
const buildLink = (newOffset) => {
44+
const params = new URLSearchParams(existingQs);
45+
params.set('limit', String(lim));
46+
params.set('offset', String(newOffset));
47+
return `${proto}://${host}${basePath}?${params.toString()}`;
48+
};
49+
50+
const links = [];
51+
52+
// next: only if there's a next page
53+
if (off + lim < total) {
54+
links.push(`<${buildLink(off + lim)}>; rel="next"`);
55+
}
56+
// prev: only if not on the first page
57+
if (off > 0) {
58+
const prevOffset = Math.max(0, off - lim);
59+
links.push(`<${buildLink(prevOffset)}>; rel="prev"`);
60+
}
61+
// first: always include if pagination applies (offset > 0 OR there's a next)
62+
if (off > 0 || off + lim < total) {
63+
links.push(`<${buildLink(0)}>; rel="first"`);
64+
}
65+
// last: only if there's data and pagination matters
66+
if (total > 0 && (off > 0 || off + lim < total)) {
67+
const lastOffset = Math.floor(Math.max(0, total - 1) / lim) * lim;
68+
links.push(`<${buildLink(lastOffset)}>; rel="last"`);
69+
}
70+
71+
return links.length === 0 ? null : links.join(', ');
72+
}
73+
74+
module.exports = { buildLinkHeader };

tests/unit/pagination.test.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 Aaron K. Clark
3+
//
4+
// Unit tests for the RFC 5988 Link header builder.
5+
6+
import { describe, test, expect } from 'vitest';
7+
import { buildLinkHeader } from '../../app/middleware/pagination.js';
8+
9+
function fakeReq({ originalUrl = '/v1/customer/bycompany/1', host = 'api.example.com', protocol = 'https' } = {}) {
10+
return {
11+
originalUrl,
12+
protocol,
13+
get: (h) => (h.toLowerCase() === 'host' ? host : undefined),
14+
};
15+
}
16+
17+
describe('buildLinkHeader', () => {
18+
test('returns null when no pagination is needed (offset=0, count <= limit)', () => {
19+
const link = buildLinkHeader({ req: fakeReq(), limit: 100, offset: 0, count: 50 });
20+
expect(link).toBeNull();
21+
});
22+
23+
test('emits next + first + last when on the first page of multi-page results', () => {
24+
const link = buildLinkHeader({ req: fakeReq(), limit: 100, offset: 0, count: 250 });
25+
expect(link).toContain('rel="next"');
26+
expect(link).toContain('offset=100');
27+
expect(link).toContain('rel="first"');
28+
expect(link).toContain('rel="last"');
29+
expect(link).toContain('offset=200'); // last page = floor((250-1)/100)*100 = 200
30+
expect(link).not.toContain('rel="prev"'); // we're on page 0
31+
});
32+
33+
test('emits prev + next + first + last on a middle page', () => {
34+
const link = buildLinkHeader({ req: fakeReq(), limit: 100, offset: 100, count: 300 });
35+
expect(link).toContain('rel="prev"');
36+
expect(link).toContain('rel="next"');
37+
expect(link).toContain('rel="first"');
38+
expect(link).toContain('rel="last"');
39+
});
40+
41+
test('drops next on the last page', () => {
42+
const link = buildLinkHeader({ req: fakeReq(), limit: 100, offset: 200, count: 250 });
43+
expect(link).not.toContain('rel="next"');
44+
expect(link).toContain('rel="prev"');
45+
expect(link).toContain('rel="first"');
46+
expect(link).toContain('rel="last"');
47+
});
48+
49+
test('preserves other query params (e.g. filter args)', () => {
50+
const req = fakeReq({ originalUrl: '/v1/timeentry/bycompany/1?customerId=42&from=2026-01-01T00:00:00Z' });
51+
const link = buildLinkHeader({ req, limit: 100, offset: 0, count: 300 });
52+
expect(link).toContain('customerId=42');
53+
expect(link).toContain('from=2026-01-01');
54+
});
55+
56+
test('builds absolute URLs (proto + host)', () => {
57+
const link = buildLinkHeader({
58+
req: fakeReq({ host: 'node.timetrackerapi.com', protocol: 'https' }),
59+
limit: 10, offset: 0, count: 50,
60+
});
61+
expect(link).toContain('https://node.timetrackerapi.com/v1/customer/bycompany/1');
62+
});
63+
64+
test('returns null on invalid inputs', () => {
65+
expect(buildLinkHeader({ req: fakeReq(), limit: 0, offset: 0, count: 10 })).toBeNull();
66+
expect(buildLinkHeader({ req: fakeReq(), limit: 10, offset: -1, count: 10 })).toBeNull();
67+
expect(buildLinkHeader({ req: fakeReq(), limit: 10, offset: 0, count: -1 })).toBeNull();
68+
expect(buildLinkHeader({ req: fakeReq(), limit: 'abc', offset: 0, count: 10 })).toBeNull();
69+
});
70+
71+
test('last page offset is correctly aligned to limit boundary', () => {
72+
// count=100, limit=30: pages are at offsets 0, 30, 60, 90. Last page = 90.
73+
const link = buildLinkHeader({ req: fakeReq(), limit: 30, offset: 0, count: 100 });
74+
expect(link).toContain('offset=90'); // last page anchor
75+
});
76+
});

0 commit comments

Comments
 (0)