Skip to content

Commit 5db7d66

Browse files
author
CryptoJones
committed
feat: zod-backed input validation (#12)
1 parent 469150d commit 5db7d66

7 files changed

Lines changed: 402 additions & 10 deletions

File tree

app/middleware/validate.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 Aaron K. Clark
3+
"use strict";
4+
5+
const { ZodError } = require('zod');
6+
7+
/**
8+
* Express middleware factory: validate.body(schema) / validate.query(schema)
9+
* / validate.params(schema). On parse failure, responds 400 with a
10+
* structured error listing every invalid field. On success, replaces
11+
* req.body / req.query / req.params with the parsed (coerced + stripped)
12+
* value so controllers see only whitelisted fields.
13+
*
14+
* Why this lives at the middleware boundary rather than inside each
15+
* controller:
16+
* - Centralizes the body-whitelist defense (controllers had
17+
* hand-rolled ALLOWED_FIELDS arrays — easy to drift).
18+
* - Surfaces parse errors as 400 with field-level detail before
19+
* they reach Sequelize, which would otherwise emit confusing
20+
* "value too long for type character varying(...)"-style 500s.
21+
* - Coerces "1" → 1 for path/query int params consistently.
22+
*/
23+
24+
function fmt(err) {
25+
if (err instanceof ZodError) {
26+
// Zod 4 exposes `.issues`; older v3 exposed `.errors`. Accept
27+
// both so the formatter works across zod major bumps.
28+
const list = err.issues || err.errors || [];
29+
return {
30+
message: 'Validation failed.',
31+
issues: list.map((e) => ({
32+
path: Array.isArray(e.path) ? e.path.join('.') : String(e.path || ''),
33+
code: e.code,
34+
message: e.message,
35+
})),
36+
};
37+
}
38+
return { message: 'Validation failed.', error: String(err) };
39+
}
40+
41+
function validateOn(key) {
42+
return (schema) => (req, res, next) => {
43+
const result = schema.safeParse(req[key]);
44+
if (!result.success) {
45+
return res.status(400).json(fmt(result.error));
46+
}
47+
req[key] = result.data;
48+
return next();
49+
};
50+
}
51+
52+
module.exports = {
53+
body: validateOn('body'),
54+
query: validateOn('query'),
55+
params: validateOn('params'),
56+
};

app/routers/router.js

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ const customer = require('../controllers/customercontroller.js');
99
const health = require('../controllers/healthcontroller.js');
1010
const timeEntry = require('../controllers/timeentrycontroller.js');
1111
const openapiSpec = require('../config/openapi.js');
12+
const v = require('../middleware/validate.js');
13+
const customerSchemas = require('../schemas/customer.schema.js');
14+
const timeEntrySchemas = require('../schemas/timeentry.schema.js');
1215

1316
// Health / readiness probe. No auth required — only exposes liveness
1417
// of the API process and reachability of the database.
@@ -24,15 +27,50 @@ router.use('/docs', swaggerUi.serve, swaggerUi.setup(openapiSpec, {
2427
}));
2528

2629
// v1 customer routes.
27-
router.get('/v1/customer/:id', customer.getCustomerById);
28-
router.get('/v1/customer/bycompany/:id', customer.getAllByCompanyId);
29-
router.post('/v1/customer', customer.createCustomer);
30+
router.get(
31+
'/v1/customer/:id',
32+
v.params(customerSchemas.intIdParam),
33+
customer.getCustomerById,
34+
);
35+
router.get(
36+
'/v1/customer/bycompany/:id',
37+
v.params(customerSchemas.intIdParam),
38+
v.query(customerSchemas.listByCompanyQuery),
39+
customer.getAllByCompanyId,
40+
);
41+
router.post(
42+
'/v1/customer',
43+
v.body(customerSchemas.createCustomerBody),
44+
customer.createCustomer,
45+
);
3046

3147
// v1 time-entry routes.
32-
router.post('/v1/timeentry', timeEntry.create);
33-
router.get('/v1/timeentry/bycompany/:id', timeEntry.listByCompany);
34-
router.get('/v1/timeentry/:id', timeEntry.getById);
35-
router.patch('/v1/timeentry/:id', timeEntry.update);
36-
router.delete('/v1/timeentry/:id', timeEntry.remove);
48+
router.post(
49+
'/v1/timeentry',
50+
v.body(timeEntrySchemas.createTimeEntryBody),
51+
timeEntry.create,
52+
);
53+
router.get(
54+
'/v1/timeentry/bycompany/:id',
55+
v.params(timeEntrySchemas.intIdParam),
56+
v.query(timeEntrySchemas.listByCompanyQuery),
57+
timeEntry.listByCompany,
58+
);
59+
router.get(
60+
'/v1/timeentry/:id',
61+
v.params(timeEntrySchemas.intIdParam),
62+
timeEntry.getById,
63+
);
64+
router.patch(
65+
'/v1/timeentry/:id',
66+
v.params(timeEntrySchemas.intIdParam),
67+
v.body(timeEntrySchemas.updateTimeEntryBody),
68+
timeEntry.update,
69+
);
70+
router.delete(
71+
'/v1/timeentry/:id',
72+
v.params(timeEntrySchemas.intIdParam),
73+
timeEntry.remove,
74+
);
3775

3876
module.exports = router;

app/schemas/customer.schema.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 Aaron K. Clark
3+
"use strict";
4+
5+
const { z } = require('zod');
6+
7+
const intIdParam = z.object({
8+
id: z.coerce.number().int().positive(),
9+
});
10+
11+
/**
12+
* Body schema for POST /v1/customer.
13+
*
14+
* Whitelist semantics: any field not listed here is stripped via
15+
* .strip() (zod's default). Server-managed fields (custId, custArch)
16+
* are NOT accepted from the body at all.
17+
*
18+
* teCompId is optional here because the controller may default it
19+
* to the authKey's owning company for non-master keys. Master keys
20+
* must supply it; the controller enforces that separately so it
21+
* can emit a 400 with the contextual message rather than a generic
22+
* zod issue.
23+
*/
24+
const createCustomerBody = z.object({
25+
custCompanyName: z.string().max(255).optional(),
26+
custFName: z.string().max(255).optional(),
27+
custLName: z.string().max(255).optional(),
28+
custAddress1: z.string().max(255).optional(),
29+
custAddress2: z.string().max(255).optional(),
30+
custCity: z.string().max(255).optional(),
31+
custState: z.string().max(255).optional(),
32+
custZip: z.string().max(32).optional(),
33+
custPhone: z.string().max(64).optional(),
34+
custEmail: z.string().email().max(255).optional(),
35+
custCompId: z.coerce.number().int().positive().optional(),
36+
}).strict({
37+
message: 'Unexpected field in body. Whitelist: custCompanyName, custFName, custLName, custAddress1, custAddress2, custCity, custState, custZip, custPhone, custEmail, custCompId.',
38+
});
39+
40+
const listByCompanyQuery = z.object({
41+
limit: z.coerce.number().int().positive().max(500).optional(),
42+
offset: z.coerce.number().int().nonnegative().optional(),
43+
}).strict({
44+
message: 'Unexpected query parameter. Allowed: limit, offset.',
45+
});
46+
47+
module.exports = {
48+
intIdParam,
49+
createCustomerBody,
50+
listByCompanyQuery,
51+
};

app/schemas/timeentry.schema.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 Aaron K. Clark
3+
"use strict";
4+
5+
const { z } = require('zod');
6+
7+
const intIdParam = z.object({
8+
id: z.coerce.number().int().positive(),
9+
});
10+
11+
const isoDatetime = z.string().datetime({
12+
offset: true,
13+
message: 'Must be an ISO 8601 datetime (e.g. 2026-05-16T09:00:00Z).',
14+
});
15+
16+
/**
17+
* POST /v1/timeentry body. teCompId is optional for non-master keys
18+
* (defaults to authKey's company) and required for master keys
19+
* (controller enforces). teCustId + teStartedAt are required;
20+
* teEndedAt is optional (in-flight entries allowed).
21+
*
22+
* Server-managed fields (teId, teMinutes, teArch) are not accepted
23+
* from the body.
24+
*/
25+
const createTimeEntryBody = z.object({
26+
teCustId: z.coerce.number().int().positive(),
27+
teCompId: z.coerce.number().int().positive().optional(),
28+
teDescription: z.string().max(10000).optional(),
29+
teStartedAt: isoDatetime,
30+
teEndedAt: isoDatetime.optional(),
31+
teBillable: z.boolean().optional(),
32+
}).strict({
33+
message: 'Unexpected field in body. Whitelist: teCustId, teCompId, teDescription, teStartedAt, teEndedAt, teBillable.',
34+
});
35+
36+
/**
37+
* PATCH /v1/timeentry/:id body. None of the fields are required —
38+
* a PATCH is a partial update — but at least one must be present.
39+
* The controller already handles the "no updatable fields" case
40+
* with a 400; the schema just enforces shape.
41+
*/
42+
const updateTimeEntryBody = z.object({
43+
teDescription: z.string().max(10000).optional(),
44+
teStartedAt: isoDatetime.optional(),
45+
teEndedAt: isoDatetime.nullable().optional(),
46+
teBillable: z.boolean().optional(),
47+
}).strict({
48+
message: 'Unexpected field in body. Whitelist: teDescription, teStartedAt, teEndedAt, teBillable.',
49+
});
50+
51+
const listByCompanyQuery = z.object({
52+
customerId: z.coerce.number().int().positive().optional(),
53+
from: isoDatetime.optional(),
54+
to: isoDatetime.optional(),
55+
limit: z.coerce.number().int().positive().max(500).optional(),
56+
offset: z.coerce.number().int().nonnegative().optional(),
57+
}).strict({
58+
message: 'Unexpected query parameter. Allowed: customerId, from, to, limit, offset.',
59+
});
60+
61+
module.exports = {
62+
intIdParam,
63+
createTimeEntryBody,
64+
updateTimeEntryBody,
65+
listByCompanyQuery,
66+
};

package-lock.json

Lines changed: 11 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
"pino-http": "^11.0.0",
3232
"sequelize": "^6.6.5",
3333
"sequelize-cli": "^6.2.0",
34-
"swagger-ui-express": "^5.0.1"
34+
"swagger-ui-express": "^5.0.1",
35+
"zod": "^4.4.3"
3536
},
3637
"devDependencies": {
3738
"supertest": "^7.2.2",

0 commit comments

Comments
 (0)