-
Notifications
You must be signed in to change notification settings - Fork 229
migrate Joi schemas and validation utils to Zod #465
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,23 +1,23 @@ | ||
| import Schema from 'joi' | ||
| import { z } from 'zod' | ||
|
|
||
| export const prefixSchema = Schema.string().case('lower').hex().min(4).max(64).label('prefix') | ||
| const lowerHexRegex = /^[0-9a-f]+$/ | ||
|
|
||
| export const idSchema = Schema.string().case('lower').hex().length(64).label('id') | ||
| export const prefixSchema = z.string().regex(lowerHexRegex).min(4).max(64) | ||
|
|
||
| export const pubkeySchema = Schema.string().case('lower').hex().length(64).label('pubkey') | ||
| export const idSchema = z.string().regex(lowerHexRegex).length(64) | ||
|
|
||
| export const kindSchema = Schema.number().min(0).multiple(1).label('kind') | ||
| export const pubkeySchema = z.string().regex(lowerHexRegex).length(64) | ||
|
|
||
| export const signatureSchema = Schema.string().case('lower').hex().length(128).label('sig') | ||
| export const kindSchema = z.number().int().min(0) | ||
|
|
||
| export const subscriptionSchema = Schema.string().min(1).label('subscriptionId') | ||
| export const signatureSchema = z.string().regex(lowerHexRegex).length(128) | ||
|
|
||
| const seconds = (value: any, helpers: any) => (Number.isSafeInteger(value) && Math.log10(value) < 10) ? value : helpers.error('any.invalid') | ||
| export const subscriptionSchema = z.string().min(1) | ||
|
|
||
| export const createdAtSchema = Schema.number().min(0).multiple(1).custom(seconds) | ||
| export const createdAtSchema = z.number().int().min(0).refine( | ||
| (value) => Number.isSafeInteger(value) && Math.log10(value) < 10, | ||
| { message: 'Invalid timestamp' } | ||
| ) | ||
|
|
||
| // [<string>, <string> 0..*] | ||
| export const tagSchema = Schema.array() | ||
| .ordered(Schema.string().required().label('identifier')) | ||
| .items(Schema.string().allow('').label('value')) | ||
| .label('tag') | ||
| export const tagSchema = z.tuple([z.string().min(1)]).rest(z.string()) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,24 @@ | ||
| import Schema from 'joi' | ||
| import { z } from 'zod' | ||
|
|
||
| import { createdAtSchema, kindSchema, prefixSchema } from './base-schema' | ||
|
|
||
| export const filterSchema = Schema.object({ | ||
| ids: Schema.array().items(prefixSchema.label('prefixOrId')), | ||
| authors: Schema.array().items(prefixSchema.label('prefixOrAuthor')), | ||
| kinds: Schema.array().items(kindSchema), | ||
| since: createdAtSchema, | ||
| until: createdAtSchema, | ||
| limit: Schema.number().min(0).multiple(1), | ||
| }).pattern(/^#[a-z]$/, Schema.array().items(Schema.string().max(1024))) | ||
| const knownFilterKeys = new Set(['ids', 'authors', 'kinds', 'since', 'until', 'limit']) | ||
|
|
||
| export const filterSchema = z.object({ | ||
| ids: z.array(prefixSchema).optional(), | ||
| authors: z.array(prefixSchema).optional(), | ||
| kinds: z.array(kindSchema).optional(), | ||
| since: createdAtSchema.optional(), | ||
| until: createdAtSchema.optional(), | ||
| limit: z.number().int().min(0).optional(), | ||
| }).catchall(z.array(z.string().min(1).max(1024))).superRefine((data, ctx) => { | ||
| for (const key of Object.keys(data)) { | ||
| if (!knownFilterKeys.has(key) && !/^#[a-z]$/.test(key)) { | ||
| ctx.addIssue({ | ||
| code: z.ZodIssueCode.custom, | ||
| message: `Unknown key: ${key}`, | ||
| path: [key], | ||
| }) | ||
| } | ||
| } | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,40 +1,45 @@ | ||
| import Schema from 'joi' | ||
| import { z } from 'zod' | ||
|
|
||
| import { eventSchema } from './event-schema' | ||
| import { filterSchema } from './filter-schema' | ||
| import { MessageType } from '../@types/messages' | ||
| import { subscriptionSchema } from './base-schema' | ||
|
|
||
| export const eventMessageSchema = Schema.array().ordered( | ||
| Schema.string().valid('EVENT').required(), | ||
| eventSchema.required(), | ||
| ) | ||
| .label('EVENT message') | ||
| export const eventMessageSchema = z.tuple([ | ||
| z.literal(MessageType.EVENT), | ||
| eventSchema, | ||
| ]) | ||
|
|
||
| export const reqMessageSchema = Schema.array() | ||
| .ordered(Schema.string().valid('REQ').required(), Schema.string().max(256).required().label('subscriptionId')) | ||
| .items(filterSchema.required().label('filter')).max(12) | ||
| .label('REQ message') | ||
| export const reqMessageSchema = z.tuple([ | ||
| z.literal(MessageType.REQ), | ||
| z.string().max(256).min(1), | ||
| ]).rest(filterSchema).superRefine((val, ctx) => { | ||
| if (val.length < 3) { | ||
| ctx.addIssue({ | ||
| code: z.ZodIssueCode.too_small, | ||
| minimum: 3, | ||
| type: 'array', | ||
| inclusive: true, | ||
| message: 'REQ message must contain at least one filter', | ||
| }) | ||
| } else if (val.length > 12) { | ||
| ctx.addIssue({ | ||
| code: z.ZodIssueCode.too_big, | ||
| maximum: 12, | ||
| type: 'array', | ||
| inclusive: true, | ||
| message: 'REQ message must contain at most 12 elements', | ||
| }) | ||
| } | ||
| }) | ||
|
|
||
| export const closeMessageSchema = Schema.array().ordered( | ||
| Schema.string().valid('CLOSE').required(), | ||
| subscriptionSchema.required().label('subscriptionId'), | ||
| ).label('CLOSE message') | ||
| export const closeMessageSchema = z.tuple([ | ||
| z.literal(MessageType.CLOSE), | ||
| subscriptionSchema, | ||
| ]) | ||
|
|
||
| export const messageSchema = Schema.alternatives() | ||
| .conditional(Schema.ref('.'), { | ||
| switch: [ | ||
| { | ||
| is: Schema.array().ordered(Schema.string().equal(MessageType.EVENT)).items(Schema.any()), | ||
| then: eventMessageSchema, | ||
| }, | ||
| { | ||
| is: Schema.array().ordered(Schema.string().equal(MessageType.REQ)).items(Schema.any()), | ||
| then: reqMessageSchema, | ||
| }, | ||
| { | ||
| is: Schema.array().ordered(Schema.string().equal(MessageType.CLOSE)).items(Schema.any()), | ||
| then: closeMessageSchema, | ||
| }, | ||
| ], | ||
| }) | ||
| export const messageSchema = z.union([ | ||
| eventMessageSchema, | ||
| reqMessageSchema, | ||
| closeMessageSchema, | ||
| ]) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,15 @@ | ||
| import { pubkeySchema } from './base-schema' | ||
| import Schema from 'joi' | ||
| import { z } from 'zod' | ||
|
|
||
| export const nodelessCallbackBodySchema = Schema.object({ | ||
| id: Schema.string(), | ||
| uuid: Schema.string().required(), | ||
| status: Schema.string().required(), | ||
| amount: Schema.number().required(), | ||
| metadata: Schema.object({ | ||
| requestId: pubkeySchema.label('metadata.requestId').required(), | ||
| description: Schema.string().optional(), | ||
| unit: Schema.string().optional(), | ||
| createdAt: Schema.alternatives().try(Schema.string(), Schema.date()).optional(), | ||
| }).unknown(true).required(), | ||
| }).unknown(false) | ||
| export const nodelessCallbackBodySchema = z.object({ | ||
| id: z.string().optional(), | ||
| uuid: z.string(), | ||
| status: z.string(), | ||
| amount: z.number(), | ||
| metadata: z.object({ | ||
| requestId: pubkeySchema, | ||
| description: z.string().optional(), | ||
| unit: z.string().optional(), | ||
| createdAt: z.union([z.string(), z.date()]).optional(), | ||
| }).passthrough(), | ||
| }).strict() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,11 @@ | ||
| import Joi from 'joi' | ||
| import { z } from 'zod' | ||
|
|
||
| const getValidationConfig = () => ({ | ||
| abortEarly: true, | ||
| stripUnknown: false, | ||
| convert: false, | ||
| }) | ||
| export const validateSchema = (schema: z.ZodTypeAny) => (input: unknown) => { | ||
| const result = schema.safeParse(input) | ||
| if (!result.success) { | ||
| return { value: undefined, error: (result as z.SafeParseError<unknown>).error } | ||
| } | ||
| return { value: result.data, error: undefined } | ||
| } | ||
|
|
||
| export const validateSchema = (schema: Joi.Schema) => (input: any) => schema.validate(input, getValidationConfig()) | ||
|
|
||
| export const attemptValidation = (schema: Joi.Schema) => | ||
| (input: any) => Joi.attempt(input, schema, getValidationConfig()) | ||
| export const attemptValidation = (schema: z.ZodTypeAny) => (input: unknown) => schema.parse(input) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,7 +61,7 @@ describe('NIP-01', () => { | |
| it('returns error if unknown key is provided', () => { | ||
| Object.assign(event, { unknown: 1 }) | ||
|
|
||
| expect(validateSchema(eventSchema)(event)).to.have.nested.property('error.message', '"unknown" is not allowed') | ||
| expect(validateSchema(eventSchema)(event)).to.have.property('error').that.is.not.undefined | ||
| }) | ||
|
Comment on lines
61
to
65
|
||
|
|
||
|
|
||
|
|
@@ -131,7 +131,7 @@ describe('NIP-01', () => { | |
| cases[prop].forEach(({ transform, message }) => { | ||
| it(`${prop} ${message}`, () => expect( | ||
| validateSchema(eventSchema)(transform(event)) | ||
| ).to.have.nested.property('error.message', `"${prop}" ${message}`)) | ||
| ).to.have.property('error').that.is.not.undefined) | ||
| }) | ||
| }) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -99,7 +99,7 @@ describe('NIP-01', () => { | |
| cases[prop].forEach(({ transform, message }) => { | ||
| it(`${prop} ${message}`, () => expect( | ||
| validateSchema(filterSchema)(transform(filter)) | ||
| ).to.have.nested.property('error.message', `"${prop}" ${message}`)) | ||
| ).to.have.property('error').that.is.not.undefined) | ||
| }) | ||
|
Comment on lines
99
to
103
|
||
| }) | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
createNoticeMessage(invalid: ${error.message})will send the rawZodError.messageto clients, which is typically a stringified list of issues and can be verbose and hard to read. Consider formatting this down to a stable, single-line summary (e.g., first issue message + path) before sending, to keep notices small and client-friendly.