Skip to content

Commit 2293dcf

Browse files
authored
refactor: migrate validation from Joi to Zod (#484)
1 parent 678cfe5 commit 2293dcf

20 files changed

Lines changed: 197 additions & 230 deletions

package-lock.json

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

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,15 +132,15 @@
132132
"accepts": "^1.3.8",
133133
"express": "4.22.1",
134134
"helmet": "6.0.1",
135-
"joi": "17.7.0",
136135
"js-yaml": "4.1.1",
137136
"knex": "2.4.2",
138137
"pg": "8.9.0",
139138
"pg-query-stream": "4.3.0",
140139
"ramda": "0.28.0",
141140
"redis": "4.5.1",
142141
"tor-control-ts": "^1.0.0",
143-
"ws": "^8.18.0"
142+
"ws": "^8.18.0",
143+
"zod": "^3.22.4"
144144
},
145145
"config": {
146146
"commitizen": {

src/adapters/web-socket-adapter.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import cluster from 'cluster'
22
import { EventEmitter } from 'stream'
33
import { IncomingMessage as IncomingHttpMessage } from 'http'
44
import { WebSocket } from 'ws'
5+
import { ZodError } from 'zod'
56

67
import { ContextMetadata, Factory } from '../@types/base'
78
import { createNoticeMessage, createOutgoingEventMessage } from '../utils/messages'
@@ -179,13 +180,12 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
179180
if (error instanceof Error) {
180181
if (error.name === 'AbortError') {
181182
console.error(`web-socket-adapter: abort from client ${this.clientId} (${this.getClientAddress()})`)
182-
} else if (error.name === 'SyntaxError' || error.name === 'ValidationError') {
183-
if (typeof (error as any).annotate === 'function') {
184-
debug('invalid message client %s (%s): %o', this.clientId, this.getClientAddress(), (error as any).annotate())
185-
} else {
186-
console.error(`web-socket-adapter: malformed message from client ${this.clientId} (${this.getClientAddress()}):`, error.message)
187-
}
188-
this.sendMessage(createNoticeMessage(`invalid: ${error.message}`))
183+
} else if (error.name === 'SyntaxError' || error instanceof ZodError) {
184+
debug('invalid message client %s (%s): %s', this.clientId, this.getClientAddress(), error.message)
185+
const notice = error instanceof ZodError
186+
? `invalid: ${error.issues[0]?.message ?? error.message}`
187+
: `invalid: ${error.message}`
188+
this.sendMessage(createNoticeMessage(notice))
189189
} else {
190190
console.error('web-socket-adapter: unable to handle message:', error)
191191
}

src/schemas/base-schema.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
1-
import Schema from 'joi'
1+
import { z } from 'zod'
22

3-
export const prefixSchema = Schema.string().case('lower').hex().min(4).max(64).label('prefix')
3+
const lowerHexRegex = /^[0-9a-f]+$/
44

5-
export const idSchema = Schema.string().case('lower').hex().length(64).label('id')
5+
export const prefixSchema = z.string().regex(lowerHexRegex).min(4).max(64)
66

7-
export const pubkeySchema = Schema.string().case('lower').hex().length(64).label('pubkey')
7+
export const idSchema = z.string().regex(lowerHexRegex).length(64)
88

9-
export const kindSchema = Schema.number().min(0).multiple(1).label('kind')
9+
export const pubkeySchema = z.string().regex(lowerHexRegex).length(64)
1010

11-
export const signatureSchema = Schema.string().case('lower').hex().length(128).label('sig')
11+
export const kindSchema = z.number().int().min(0)
1212

13-
export const subscriptionSchema = Schema.string().min(1).label('subscriptionId')
13+
export const signatureSchema = z.string().regex(lowerHexRegex).length(128)
1414

15-
const seconds = (value: any, helpers: any) => (Number.isSafeInteger(value) && Math.log10(value) < 10) ? value : helpers.error('any.invalid')
15+
export const subscriptionSchema = z.string().min(1)
1616

17-
export const createdAtSchema = Schema.number().min(0).multiple(1).custom(seconds)
17+
export const createdAtSchema = z.number().int().min(0).refine(
18+
(value) => Number.isSafeInteger(value) && Math.log10(value) < 10,
19+
{ message: 'Invalid timestamp' }
20+
)
1821

1922
// [<string>, <string> 0..*]
20-
export const tagSchema = Schema.array()
21-
.ordered(Schema.string().required().label('identifier'))
22-
.items(Schema.string().allow('').label('value'))
23-
.label('tag')
23+
export const tagSchema = z.tuple([z.string().min(1)]).rest(z.string())

src/schemas/event-schema.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import Schema from 'joi'
1+
import { z } from 'zod'
22

33
import {
44
createdAtSchema,
@@ -25,15 +25,13 @@ import {
2525
* "sig": <64-bytes signature of the sha256 hash of the serialized event data, which is the same as the "id" field>,
2626
* }
2727
*/
28-
export const eventSchema = Schema.object({
28+
export const eventSchema = z.object({
2929
// NIP-01
30-
id: idSchema.required(),
31-
pubkey: pubkeySchema.required(),
32-
created_at: createdAtSchema.required(),
33-
kind: kindSchema.required(),
34-
tags: Schema.array().items(tagSchema).required(),
35-
content: Schema.string()
36-
.allow('')
37-
.required(),
38-
sig: signatureSchema.required(),
39-
}).unknown(false)
30+
id: idSchema,
31+
pubkey: pubkeySchema,
32+
created_at: createdAtSchema,
33+
kind: kindSchema,
34+
tags: z.array(tagSchema),
35+
content: z.string(),
36+
sig: signatureSchema,
37+
}).strict()

src/schemas/filter-schema.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,24 @@
1-
import Schema from 'joi'
1+
import { z } from 'zod'
22

33
import { createdAtSchema, kindSchema, prefixSchema } from './base-schema'
44

5-
export const filterSchema = Schema.object({
6-
ids: Schema.array().items(prefixSchema.label('prefixOrId')),
7-
authors: Schema.array().items(prefixSchema.label('prefixOrAuthor')),
8-
kinds: Schema.array().items(kindSchema),
9-
since: createdAtSchema,
10-
until: createdAtSchema,
11-
limit: Schema.number().min(0).multiple(1),
12-
}).pattern(/^#[a-z]$/, Schema.array().items(Schema.string().max(1024)))
5+
const knownFilterKeys = new Set(['ids', 'authors', 'kinds', 'since', 'until', 'limit'])
6+
7+
export const filterSchema = z.object({
8+
ids: z.array(prefixSchema).optional(),
9+
authors: z.array(prefixSchema).optional(),
10+
kinds: z.array(kindSchema).optional(),
11+
since: createdAtSchema.optional(),
12+
until: createdAtSchema.optional(),
13+
limit: z.number().int().min(0).optional(),
14+
}).catchall(z.array(z.string().min(1).max(1024))).superRefine((data, ctx) => {
15+
for (const key of Object.keys(data)) {
16+
if (!knownFilterKeys.has(key) && !/^#[a-z]$/.test(key)) {
17+
ctx.addIssue({
18+
code: z.ZodIssueCode.custom,
19+
message: `Unknown key: ${key}`,
20+
path: [key],
21+
})
22+
}
23+
}
24+
})
Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { idSchema } from './base-schema'
2-
import Schema from 'joi'
2+
import { z } from 'zod'
33

4-
export const lnbitsCallbackQuerySchema = Schema.object({
5-
hmac: Schema.string().pattern(/^[0-9]{1,20}:[0-9a-f]{64}$/).required(),
6-
}).unknown(false)
4+
export const lnbitsCallbackQuerySchema = z.object({
5+
hmac: z.string().regex(/^[0-9]{1,20}:[0-9a-f]{64}$/),
6+
}).strict()
77

8-
export const lnbitsCallbackBodySchema = Schema.object({
9-
payment_hash: idSchema.label('payment_hash').required(),
10-
}).unknown(false)
8+
export const lnbitsCallbackBodySchema = z.object({
9+
payment_hash: idSchema,
10+
}).strict()

src/schemas/message-schema.ts

Lines changed: 36 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,45 @@
1-
import Schema from 'joi'
1+
import { z } from 'zod'
22

33
import { eventSchema } from './event-schema'
44
import { filterSchema } from './filter-schema'
55
import { MessageType } from '../@types/messages'
66
import { subscriptionSchema } from './base-schema'
77

8-
export const eventMessageSchema = Schema.array().ordered(
9-
Schema.string().valid('EVENT').required(),
10-
eventSchema.required(),
11-
)
12-
.label('EVENT message')
8+
export const eventMessageSchema = z.tuple([
9+
z.literal(MessageType.EVENT),
10+
eventSchema,
11+
])
1312

14-
export const reqMessageSchema = Schema.array()
15-
.ordered(Schema.string().valid('REQ').required(), Schema.string().max(256).required().label('subscriptionId'))
16-
.items(filterSchema.required().label('filter')).max(12)
17-
.label('REQ message')
13+
export const reqMessageSchema = z.tuple([
14+
z.literal(MessageType.REQ),
15+
z.string().max(256).min(1),
16+
]).rest(filterSchema).superRefine((val, ctx) => {
17+
if (val.length < 3) {
18+
ctx.addIssue({
19+
code: z.ZodIssueCode.too_small,
20+
minimum: 3,
21+
type: 'array',
22+
inclusive: true,
23+
message: 'REQ message must contain at least one filter',
24+
})
25+
} else if (val.length > 12) {
26+
ctx.addIssue({
27+
code: z.ZodIssueCode.too_big,
28+
maximum: 12,
29+
type: 'array',
30+
inclusive: true,
31+
message: 'REQ message must contain at most 12 elements',
32+
})
33+
}
34+
})
1835

19-
export const closeMessageSchema = Schema.array().ordered(
20-
Schema.string().valid('CLOSE').required(),
21-
subscriptionSchema.required().label('subscriptionId'),
22-
).label('CLOSE message')
36+
export const closeMessageSchema = z.tuple([
37+
z.literal(MessageType.CLOSE),
38+
subscriptionSchema,
39+
])
2340

24-
export const messageSchema = Schema.alternatives()
25-
.conditional(Schema.ref('.'), {
26-
switch: [
27-
{
28-
is: Schema.array().ordered(Schema.string().equal(MessageType.EVENT)).items(Schema.any()),
29-
then: eventMessageSchema,
30-
},
31-
{
32-
is: Schema.array().ordered(Schema.string().equal(MessageType.REQ)).items(Schema.any()),
33-
then: reqMessageSchema,
34-
},
35-
{
36-
is: Schema.array().ordered(Schema.string().equal(MessageType.CLOSE)).items(Schema.any()),
37-
then: closeMessageSchema,
38-
},
39-
],
40-
})
41+
export const messageSchema = z.union([
42+
eventMessageSchema,
43+
reqMessageSchema,
44+
closeMessageSchema,
45+
])
Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import { pubkeySchema } from './base-schema'
2-
import Schema from 'joi'
2+
import { z } from 'zod'
33

4-
export const nodelessCallbackBodySchema = Schema.object({
5-
id: Schema.string(),
6-
uuid: Schema.string().required(),
7-
status: Schema.string().required(),
8-
amount: Schema.number().required(),
9-
metadata: Schema.object({
10-
requestId: pubkeySchema.label('metadata.requestId').required(),
11-
description: Schema.string().optional(),
12-
unit: Schema.string().optional(),
13-
createdAt: Schema.alternatives().try(Schema.string(), Schema.date()).optional(),
14-
}).unknown(true).required(),
15-
}).unknown(false)
4+
export const nodelessCallbackBodySchema = z.object({
5+
id: z.string().optional(),
6+
uuid: z.string(),
7+
status: z.string(),
8+
amount: z.number(),
9+
metadata: z.object({
10+
requestId: pubkeySchema,
11+
description: z.string().optional(),
12+
unit: z.string().optional(),
13+
createdAt: z.union([z.string(), z.date()]).optional(),
14+
}).passthrough(),
15+
}).strict()

0 commit comments

Comments
 (0)