Skip to content
Open
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
6 changes: 4 additions & 2 deletions apps/api-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,17 @@
"pmx": "^1.6.7",
"postcss": "^8.4.38",
"postcss-prefix-selector": "^2.1.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"redis": "^4.6.13",
"sanitize-html": "^2.7.0",
"sequelize": "^6.37.8",
"swagger-jsdoc": "^6.3.0",
"umzug": "^3.2.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"ws": "^7.5.7"
},
"devDependencies": {
"@apidevtools/swagger-parser": "^12.1.0",
"@types/cron": "^2.4.0",
"eslint": "^7.32.0",
"eslint-config-airbnb": "^18.2.1",
Expand Down
13 changes: 13 additions & 0 deletions apps/api-server/src/lib/reporting/api-version.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use strict';

/**
* The reporting API's major.minor.patch version (NLgov API Design Rules:
* every response must carry this in an API-Version header, not just the
* URI's /v1 segment). Shared between routes/api/reports/index.js (sets it on
* every response reached via that router) and api-token-scope-guard.js
* (globally mounted BEFORE that router, so its own 403s need the same header
* set independently — see Server.js's middleware order).
*/
const API_VERSION = '1.0.0';

module.exports = { API_VERSION };
100 changes: 71 additions & 29 deletions apps/api-server/src/lib/reporting/filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ const { Op } = require('sequelize');
// getProjectScope/getFieldTypes here does not load the database layer — unit
// tests that inject opts.fieldTypes still never need a real DB connection.
const { getProjectScope } = require('./component-registry');
const {
fromFilterError,
fromFilterErrors,
sendProblem,
} = require('./problem-json');

// Query params the reporting endpoints understand. Everything else is ignored
// (no error) — e.g. a stray projectId query param (projectId comes from the path).
Expand All @@ -31,6 +36,20 @@ class ReportingFilterError extends Error {
}
}

/**
* Wraps 2+ independent ReportingFilterErrors found on the same request (e.g.
* an invalid dateFrom AND an unsupported status param). NLgov API Design
* Rules require every validation error to be reported together in one
* response, not one-at-a-time across repeated requests.
*/
class ReportingValidationErrors extends Error {
constructor(errors) {
super('Multiple validation errors');
this.name = 'ReportingValidationErrors';
this.errors = errors;
}
}

const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;

/**
Expand Down Expand Up @@ -110,24 +129,43 @@ function buildReportingWhere(req, componentKey, opts = {}) {
);
}

// Date range on createdAt — half-open [from, to)
// Collected across every independent check below so a single response can
// report ALL validation errors at once (NLgov API Design Rules), rather
// than the caller discovering them one request at a time.
const errors = [];

// Date range on createdAt — half-open [from, to). dateFrom/dateTo are
// parsed independently so an invalid dateFrom doesn't hide an invalid
// dateTo; the ordering check only runs once both individually parsed OK.
const createdAt = {};
let dateFromValue;
let dateToValue;
if (q.dateFrom !== undefined && q.dateFrom !== '') {
createdAt[Op.gte] = parseDateParam(q.dateFrom, 'dateFrom');
try {
dateFromValue = parseDateParam(q.dateFrom, 'dateFrom');
} catch (err) {
if (!(err instanceof ReportingFilterError)) throw err;
errors.push(err);
}
}
if (q.dateTo !== undefined && q.dateTo !== '') {
createdAt[Op.lt] = parseDateParam(q.dateTo, 'dateTo');
try {
dateToValue = parseDateParam(q.dateTo, 'dateTo');
} catch (err) {
if (!(err instanceof ReportingFilterError)) throw err;
errors.push(err);
}
}
if (
createdAt[Op.gte] &&
createdAt[Op.lt] &&
createdAt[Op.gte] >= createdAt[Op.lt]
) {
throw new ReportingFilterError(
'invalid_date_range',
'dateFrom must be strictly before dateTo',
'dateFrom',
'The range is half-open [dateFrom, dateTo); ensure dateFrom < dateTo'
if (dateFromValue) createdAt[Op.gte] = dateFromValue;
if (dateToValue) createdAt[Op.lt] = dateToValue;
if (dateFromValue && dateToValue && dateFromValue >= dateToValue) {
errors.push(
new ReportingFilterError(
'invalid_date_range',
'dateFrom must be strictly before dateTo',
'dateFrom',
'The range is half-open [dateFrom, dateTo); ensure dateFrom < dateTo'
)
);
}
if (Object.getOwnPropertySymbols(createdAt).length > 0) {
Expand All @@ -140,39 +178,43 @@ function buildReportingWhere(req, componentKey, opts = {}) {
opts.fieldTypes ||
require('./component-registry').getFieldTypes(componentKey);
if (!Object.prototype.hasOwnProperty.call(fieldTypes, 'status')) {
throw new ReportingFilterError(
'unsupported_status_filter',
`The '${componentKey}' report has no status field to filter on`,
'status',
'Remove the status parameter for this endpoint'
errors.push(
new ReportingFilterError(
'unsupported_status_filter',
`The '${componentKey}' report has no status field to filter on`,
'status',
'Remove the status parameter for this endpoint'
)
);
} else {
where.status = q.status;
}
where.status = q.status;
}

if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new ReportingValidationErrors(errors);

return { where, include };
}

/**
* Sends a ReportingFilterError as an HTTP 400 with the agreed body shape.
* Re-throws anything that is not a ReportingFilterError so the caller can 500.
* Sends a ReportingFilterError (or ReportingValidationErrors, for 2+ errors
* found at once) as an HTTP 400 in the RFC 9457 problem+json shape (NLgov API
* Design Rules). Re-throws anything else so the caller can 500.
*/
function respondFilterError(res, err) {
if (err instanceof ReportingValidationErrors) {
return sendProblem(res, 400, fromFilterErrors(err.errors));
}
if (!(err instanceof ReportingFilterError)) throw err;
return res.status(400).json({
error: {
code: err.code,
message: err.message,
param: err.param,
hint: err.hint,
},
});
return sendProblem(res, 400, fromFilterError(err));
}

module.exports = {
buildReportingWhere,
respondFilterError,
parseDateParam,
ReportingFilterError,
ReportingValidationErrors,
KNOWN_PARAMS,
};
113 changes: 106 additions & 7 deletions apps/api-server/src/lib/reporting/filters.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const {
buildReportingWhere,
respondFilterError,
ReportingFilterError,
ReportingValidationErrors,
} = require('./filters');

const TYPES_WITH_STATUS = { status: 'ENUM', createdAt: 'DATE' };
Expand Down Expand Up @@ -112,16 +113,70 @@ describe('buildReportingWhere', () => {
expect(err.code).toBe('unsupported_status_filter');
expect(err.param).toBe('status');
});

it('collects independent invalid dateFrom + dateTo into one ReportingValidationErrors (NLgov ADR: report all validation errors together)', () => {
let err;
try {
buildReportingWhere(
req({ dateFrom: 'nope', dateTo: 'also-nope' }),
'resources',
{ fieldTypes: TYPES_WITH_STATUS }
);
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(ReportingValidationErrors);
expect(err.errors).toHaveLength(2);
expect(err.errors.map((e) => e.param)).toEqual(['dateFrom', 'dateTo']);
});

it('collects an invalid dateFrom together with an unsupported status filter', () => {
let err;
try {
buildReportingWhere(req({ dateFrom: 'nope', status: 'x' }), 'votes', {
fieldTypes: TYPES_NO_STATUS,
});
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(ReportingValidationErrors);
expect(err.errors.map((e) => e.code)).toEqual([
'invalid_date',
'unsupported_status_filter',
]);
});

it('does not double-report an inverted range when one side is also individually invalid', () => {
// dateTo is unparseable — the range-order check must not ALSO fire,
// since it can't meaningfully compare an invalid value.
let err;
try {
buildReportingWhere(
req({ dateFrom: '2026-02-01', dateTo: 'nope' }),
'resources',
{ fieldTypes: TYPES_WITH_STATUS }
);
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(ReportingFilterError);
expect(err.code).toBe('invalid_date');
expect(err.param).toBe('dateTo');
});
});

describe('respondFilterError', () => {
it('sends HTTP 400 with { error: { code, message, param, hint } }', () => {
const captured = {};
it('sends HTTP 400 as an application/problem+json body (NLgov ADR)', () => {
const captured = { headers: {} };
const res = {
status(c) {
captured.status = c;
return res;
},
set(k, v) {
captured.headers[k] = v;
return res;
},
json(b) {
captured.body = b;
return res;
Expand All @@ -132,13 +187,57 @@ describe('respondFilterError', () => {
new ReportingFilterError('invalid_date', 'bad', 'dateFrom', 'use ISO')
);
expect(captured.status).toBe(400);
expect(captured.headers['Content-Type']).toBe('application/problem+json');
expect(captured.body).toEqual({
error: {
code: 'invalid_date',
message: 'bad',
param: 'dateFrom',
hint: 'use ISO',
type: 'https://developer.overheid.nl/api-design-rules/problem/400',
title: 'bad',
status: 400,
detail: 'use ISO',
code: 'invalid_date',
param: 'dateFrom',
});
});

it('sends multiple validation errors together as one problem+json body with an errors array', () => {
const captured = { headers: {} };
const res = {
status(c) {
captured.status = c;
return res;
},
set(k, v) {
captured.headers[k] = v;
return res;
},
json(b) {
captured.body = b;
return res;
},
};
respondFilterError(
res,
new ReportingValidationErrors([
new ReportingFilterError(
'invalid_date',
'bad dateFrom',
'dateFrom',
'hint1'
),
new ReportingFilterError(
'invalid_date',
'bad dateTo',
'dateTo',
'hint2'
),
])
);
expect(captured.status).toBe(400);
expect(captured.body.errors).toHaveLength(2);
expect(captured.body.errors[0]).toEqual({
code: 'invalid_date',
title: 'bad dateFrom',
param: 'dateFrom',
detail: 'hint1',
});
});

Expand Down
Loading