Skip to content

Commit 262d742

Browse files
chore: add slonik validation and transformation interceptors (#64)
* chore: add slonik validation and transformation interceptors * chore: add response types for validation * chore(services): adjust types and return objects for validation * chore(routes): use response type in drafts, proposals, and votes * chore(database): change 'email' column name for validation * chore: adjust types in test helpers for validation * chore: add countSchema to count queries for validation * fix: remove type assertion from helper function queries * fix(database): use custom type parser for dates * chore: use param objects for proposals, drafts, and votes services * fix(drafts): remove 'optional' from types
1 parent 4438b51 commit 262d742

17 files changed

Lines changed: 256 additions & 92 deletions

database/db_init.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ CREATE TABLE if NOT EXISTS drafts (
5757
id SERIAL PRIMARY KEY,
5858
created TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL,
5959
updated TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL,
60-
email TEXT NOT NULL
60+
voter_email TEXT NOT NULL
6161
);
6262

6363
CREATE OR REPLACE TRIGGER set_updated

database/index.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,35 @@
11
import { DatabasePool, sql } from 'slonik';
22
import {
3-
createPool,
3+
createPool,
4+
type Interceptor,
5+
type QueryResultRow,
6+
SchemaValidationError
47
} from 'slonik';
8+
import {
9+
createFieldNameTransformationInterceptor
10+
} from 'slonik-interceptor-field-name-transformation';
511

612
const DB_URL = process.env.DB_URL || 'postgres://postgres:postgres@db:5432/postgres';
713
const CA_CERT = process.env.CA_CERT
814
let pool: DatabasePool;
915

16+
const customTypeParsers = [
17+
{
18+
name: 'timestamptz',
19+
parse: (value: string) => {
20+
return value;
21+
}
22+
}
23+
];
24+
1025
const baseConfig = {
11-
typeParsers:[]
26+
interceptors: [
27+
createFieldNameTransformationInterceptor({
28+
format: 'CAMEL_CASE'
29+
}),
30+
createResultParserInterceptor()
31+
],
32+
typeParsers: customTypeParsers
1233
}
1334

1435
const envConfigs: {[env:string]:{}} = {
@@ -48,3 +69,33 @@ export async function resetDatabase(){
4869
async (connection) => await connection.query(sql.unsafe`TRUNCATE votes CASCADE`)
4970
)
5071
}
72+
73+
function createResultParserInterceptor(): Interceptor {
74+
return {
75+
// If you are not going to transform results using Zod, then you should use `afterQueryExecution` instead.
76+
// Future versions of Zod will provide a more efficient parser when parsing without transformations.
77+
// You can even combine the two – use `afterQueryExecution` to validate results, and (conditionally)
78+
// transform results as needed in `transformRow`.
79+
transformRow: async (executionContext, actualQuery, row) => {
80+
const { log, resultParser } = executionContext;
81+
82+
if (!resultParser) {
83+
return row;
84+
}
85+
86+
// It is recommended (but not required) to parse async to avoid blocking the event loop during validation
87+
const validationResult = await resultParser.safeParseAsync(row);
88+
89+
if (!validationResult.success) {
90+
console.log('Validation failed:', validationResult.error.issues);
91+
throw new SchemaValidationError(
92+
actualQuery,
93+
row,
94+
validationResult.error.issues
95+
);
96+
}
97+
98+
return validationResult.data as QueryResultRow;
99+
},
100+
};
101+
}

helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { SchemaValidationError } from "slonik";
2-
import { AnyZodObject, ZodError } from "zod";
2+
import { AnyZodObject, ZodError, z } from "zod";
33
import { Request, Response, NextFunction } from "express";
44

55
export function formatQueryErrorResponse(e: SchemaValidationError) {

package-lock.json

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"jsonwebtoken": "9.0.2",
2020
"slonik": "^40.2.1",
2121
"slonik-utilities": "^2.0.2",
22+
"slonik-interceptor-field-name-transformation": "40.2.4",
2223
"zod": "^3.22.4"
2324
},
2425
"devDependencies": {

routes/drafts.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import express from "express";
22
import DraftsService from '../services/drafts';
33
import { SchemaValidationError } from 'slonik';
44
import {filterNullValues, formatQueryErrorResponse, validateRequest} from '../helpers';
5-
import {PendingDraft, DraftUpdate, Draft} from '../types/draft';
5+
import {PendingDraft, DraftUpdate, DraftResponse} from '../types/draft';
66
import { z } from "zod";
77

88
const router = express.Router();
@@ -56,10 +56,15 @@ router.get("/", validateRequest(IndexRequest), async (req, res) => {
5656
try {
5757
if (recordId) {
5858
let draft = await DraftsService.show(recordId);
59-
draft = filterNullValues(draft) as Draft
59+
draft = filterNullValues(draft) as DraftResponse
6060
return res.status(200).json(draft);
6161
} else {
62-
let drafts = await DraftsService.index(req.user.userEmail, type, cursor, limit);
62+
let drafts = await DraftsService.index({
63+
email: req.user.userEmail,
64+
type,
65+
cursor,
66+
limit
67+
});
6368
if (drafts.length === 0) {
6469
return res.status(404).json({ message: 'No drafts found' });
6570
}
@@ -68,7 +73,7 @@ router.get("/", validateRequest(IndexRequest), async (req, res) => {
6873
drafts = drafts.slice(0, limit);
6974
nextCursor = (cursor ?? 0) + limit;
7075
}
71-
const filteredDrafts = drafts.map(filterNullValues) as Draft[]
76+
const filteredDrafts = drafts.map(filterNullValues) as DraftResponse[]
7277
const response = {
7378
limit: limit || drafts.length,
7479
drafts: filteredDrafts,
@@ -91,8 +96,11 @@ router.post("/", validateRequest(PostRequest), async (req, res) => {
9196
const validationResult = req.validated.body as PendingDraft
9297

9398
try{
94-
let draft = await DraftsService.store(validationResult, req.user.userEmail);
95-
draft = filterNullValues(draft) as Draft
99+
let draft = await DraftsService.store({
100+
data: validationResult,
101+
email: req.user.userEmail
102+
});
103+
draft = filterNullValues(draft) as DraftResponse
96104

97105
return res.status(201).json(draft);
98106
}catch(e){
@@ -105,7 +113,7 @@ router.put("/", validateRequest(PutRequest), async (req, res) => {
105113
const { recordId } = req.validated.query as RecordQuery;
106114
const validationResult = req.validated.body as PendingDraft;
107115
try {
108-
const draft = await DraftsService.update(recordId, validationResult);
116+
const draft = await DraftsService.update({ id: recordId, data: validationResult });
109117
return res.status(200).json(draft);
110118
} catch (e) {
111119
if (e instanceof Error && e.message.includes('Draft not found')) {
@@ -120,7 +128,7 @@ router.patch("/", validateRequest(PatchRequest), async (req, res) => {
120128
const { recordId } = req.validated.query as RecordQuery;
121129
const validationResult = req.validated.body as DraftUpdate;
122130
try {
123-
const draft = await DraftsService.update(recordId, validationResult);
131+
const draft = await DraftsService.update({ id: recordId, data: validationResult });
124132
return res.status(200).json(draft);
125133
} catch (e) {
126134
if (e instanceof Error && e.message.includes('Draft not found')) {

routes/proposals.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import express from 'express';
22
import ProposalsService from '../services/proposals';
33
import { SchemaValidationError } from 'slonik';
44
import { NotFoundError, filterNullValues, formatQueryErrorResponse, validateRequest } from '../helpers';
5-
import { PendingProposal, Proposal } from '../types/proposal';
5+
import { PendingProposal, ProposalResponse } from '../types/proposal';
66
import VotesRouter from "./votes";
77
import { z } from "zod";
88

@@ -42,10 +42,16 @@ router.get("/", validateRequest(IndexRequest), async (req, res) => {
4242
try {
4343
if (recordId) {
4444
let proposal = await ProposalsService.show(recordId);
45-
proposal = filterNullValues(proposal) as Proposal
45+
proposal = filterNullValues(proposal) as ProposalResponse
4646
return res.status(200).json(proposal);
4747
} else {
48-
let proposals = await ProposalsService.index(type, status, cursor, limit, req.user.userEmail);
48+
let proposals = await ProposalsService.index({
49+
type: type,
50+
status: status,
51+
cursor: cursor,
52+
limit: limit,
53+
userEmail: req.user.userEmail
54+
});
4955
if (proposals.length === 0) {
5056
return res.status(404).json({ message: 'No proposals found' });
5157
}
@@ -54,7 +60,7 @@ router.get("/", validateRequest(IndexRequest), async (req, res) => {
5460
proposals = proposals.slice(0, limit); // Remove the extra draft
5561
nextCursor = (cursor ?? 0) + limit;
5662
}
57-
proposals = proposals.map(filterNullValues) as Proposal[]
63+
proposals = proposals.map(filterNullValues) as ProposalResponse[]
5864
const response = {
5965
limit: limit || proposals.length,
6066
proposals: proposals,
@@ -79,8 +85,12 @@ router.post("/", validateRequest(PostRequest), async (req, res) => {
7985
const validationResult = req.validated.body as PendingProposal
8086

8187
try {
82-
let proposal = await ProposalsService.store(validationResult, req.user.userFullName, req.user.userEmail);
83-
proposal = filterNullValues(proposal) as Proposal
88+
let proposal = await ProposalsService.store({
89+
data: validationResult,
90+
author: req.user.userFullName,
91+
email: req.user.userEmail
92+
});
93+
proposal = filterNullValues(proposal) as ProposalResponse
8494
return res.status(201).json(proposal);
8595
} catch (e) {
8696
console.log(e)

routes/votes.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import express from "express";
22
import VotesService from '../services/votes';
33
import {filterNullValues, validateRequest} from '../helpers';
4-
import {PendingVote, Vote} from '../types/vote';
4+
import {PendingVote, VoteResponse} from '../types/vote';
55
import { z } from "zod";
66

77
const router = express.Router();
@@ -28,9 +28,14 @@ router.post("/", validateRequest(PostRequest), async (req, res) => {
2828
const { recordId } = req.validated.query as RecordQuery
2929
const validationResult = req.validated.body as PendingVote
3030

31+
console.log('Received data for storing vote:', { recordId, validationResult, userEmail: req.user.userEmail });
32+
3133
try{
32-
let vote = await VotesService.store(validationResult, recordId, req.user.userEmail);
33-
vote = filterNullValues(vote) as Vote;
34+
let vote = await VotesService.store({
35+
data: validationResult,
36+
recordId,
37+
email: req.user.userEmail});
38+
vote = filterNullValues(vote) as VoteResponse;
3439
return res.status(201).json(vote);
3540
}catch(e){
3641
console.log(e)
@@ -41,7 +46,7 @@ router.post("/", validateRequest(PostRequest), async (req, res) => {
4146
router.delete("/", validateRequest(DeleteRequest), async (req, res) => {
4247
const { recordId } = req.validated.query as RecordQuery
4348
try {
44-
const rowCount = await VotesService.destroy(recordId, req.user.userEmail);
49+
const rowCount = await VotesService.destroy({ recordId, email: req.user.userEmail });
4550
if (rowCount === 0) {
4651
return res.status(404).json({message: 'Vote not found'})
4752
}

services/drafts.ts

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,35 @@
11
import { getPool } from '../database';
22
import { sql} from 'slonik';
33
import {update as slonikUpdate} from 'slonik-utilities';
4-
import {Draft, DraftBody, DraftUpdate, PendingDraft} from '../types/draft';
4+
import {Draft, DraftBody, DraftResponse, DraftUpdate, PendingDraft} from '../types/draft';
55
import {filterNullValues} from "../helpers";
6-
import {number} from "zod";
76
import { faker } from "@faker-js/faker";
87

9-
async function index(email: string, type?: string, cursor?: number, limit?: number): Promise<readonly Draft[]> {
8+
interface IndexParams {
9+
email: string;
10+
type?: string;
11+
cursor?: number;
12+
limit?: number;
13+
}
14+
15+
interface StoreParams {
16+
data: DraftUpdate;
17+
email: string;
18+
}
19+
20+
interface UpdateParams {
21+
id: number;
22+
data: DraftBody;
23+
}
24+
25+
async function index({ email, type, cursor, limit }: IndexParams): Promise<readonly DraftResponse[]> {
1026
const pool = await getPool();
1127
return await pool.connect(async (connection) => {
1228
const adjustedLimit = limit ? limit + 1 : null;
13-
const rows = await connection.any(sql.type(Draft)`
29+
const rows = await connection.any(sql.type(DraftResponse)`
1430
SELECT id, created, updated, title, summary, description, type
1531
FROM drafts
16-
WHERE email = ${email}
32+
WHERE voter_email = ${email}
1733
${type ? sql.fragment`AND type = ${type}` : sql.fragment``}
1834
ORDER BY id
1935
OFFSET ${cursor ?? null}
@@ -23,22 +39,23 @@ async function index(email: string, type?: string, cursor?: number, limit?: numb
2339
});
2440
}
2541

26-
async function store(data: DraftUpdate, email: string): Promise<Draft> {
42+
async function store({ data, email }: StoreParams): Promise<DraftResponse> {
2743
const pool = await getPool();
2844
return await pool.connect(async (connection) => {
2945
const draft = await connection.one(sql.type(Draft)`
30-
INSERT INTO drafts (title, summary, description, type, email)
46+
INSERT INTO drafts (title, summary, description, type, voter_email)
3147
VALUES (${data.title ?? null}, ${data.summary ?? null}, ${data.description ?? null}, ${data.type ?? null}, ${email})
32-
RETURNING id, created, updated, title, summary, description, type;`)
48+
RETURNING *;`)
3349

34-
return draft;
50+
const { voterEmail, ...rest } = draft
51+
return rest;
3552
});
3653
}
3754

38-
async function show(id: number): Promise<Draft> {
55+
async function show(id: number): Promise<DraftResponse> {
3956
const pool = await getPool();
4057
return await pool.connect(async (connection) => {
41-
const draft = await connection.maybeOne(sql.type(Draft)`
58+
const draft = await connection.maybeOne(sql.type(DraftResponse)`
4259
SELECT id, created, updated, title, summary, description, type
4360
FROM drafts
4461
WHERE id = ${id};`)
@@ -48,7 +65,7 @@ async function show(id: number): Promise<Draft> {
4865
});
4966
}
5067

51-
async function update(id: number, data: DraftBody): Promise<Draft> {
68+
async function update({ id, data }: UpdateParams): Promise<Draft> {
5269
const pool = await getPool();
5370
return await pool.connect(async (connection) => {
5471
const result = await slonikUpdate(
@@ -81,7 +98,7 @@ async function destroy(id: number) {
8198
async function count(): Promise<number> {
8299
const pool = await getPool();
83100
return await pool.connect(async (connection) => {
84-
const result = await connection.oneFirst(sql.type(number())`
101+
const result = await connection.oneFirst(sql.unsafe`
85102
SELECT COUNT(*) FROM drafts;`);
86103
return Number(result);
87104
});

0 commit comments

Comments
 (0)