Skip to content

Commit

Permalink
feat(validator): add support for JSON array path validation (honojs#563)
Browse files Browse the repository at this point in the history
* refactor(JSONPath): improve typing of JSONPath

* chore(vscode-settings): add deno.enable=false

* fix(validator): add null to type Type def

* feat(JSON-Path): add support for array JSONPath-Plus syntax

* fix(validator): update isRequired to pass valid bool types

* test: update tests for isRequired validator

* feat(validator): add support for JSON array path validation

* chore(deno): denoify array support changes

* fix(validator): type check all vals in array

* chore(deno): denoify changes

* test(validator): add tests for array type checking

* fix(validator): change JSONPrimative to JSONPrimitive

* refactor(json): More compatible with https://jsonpath.com/.

* implementation of `asArray`

* fix(validator): update JSONPath implementation and add isArray check in validation

* fix(validator): fix typing errors on SchemaToProp

* Revert "fix(validator): fix typing errors on SchemaToProp"

This reverts commit b8ddef8.

* fix(validator): fix SchemaToProp error for VTypeArrays

* chore(deno): denoify

Co-authored-by: Taku Amano <[email protected]>
Co-authored-by: Yusuke Wada <[email protected]>
  • Loading branch information
3 people authored Sep 30, 2022
1 parent 13ce2ee commit 9a0389e
Show file tree
Hide file tree
Showing 10 changed files with 626 additions and 87 deletions.
10 changes: 3 additions & 7 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
{
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"deno.enable": false,
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}
}
29 changes: 26 additions & 3 deletions deno_dist/middleware/validator/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,37 @@
import type { Context } from '../../context.ts'
import type { Environment, Next, ValidatedData } from '../../hono.ts'
import { VBase, Validator } from './validator.ts'
import type { VString, VNumber, VBoolean, VObject, ValidateResult } from './validator.ts'
import type {
VString,
VNumber,
VBoolean,
VObject,
VNumberArray,
VStringArray,
VBooleanArray,
ValidateResult,
} from './validator.ts'

type Schema = {
[key: string]: VString | VNumber | VBoolean | VObject | Schema
[key: string]:
| VString
| VNumber
| VBoolean
| VObject
| VStringArray
| VNumberArray
| VBooleanArray
| Schema
}

type SchemaToProp<T> = {
[K in keyof T]: T[K] extends VNumber
[K in keyof T]: T[K] extends VNumberArray
? number[]
: T[K] extends VBooleanArray
? boolean[]
: T[K] extends VStringArray
? string[]
: T[K] extends VNumber
? number
: T[K] extends VBoolean
? boolean
Expand Down
118 changes: 95 additions & 23 deletions deno_dist/middleware/validator/validator.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { JSONPath } from '../../utils/json.ts'
import type { JSONObject, JSONPrimitive, JSONArray } from '../../utils/json.ts'
import { rule } from './rule.ts'
import { sanitizer } from './sanitizer.ts'

type Target = 'query' | 'header' | 'body' | 'json'
type Type = string | number | boolean | object | undefined
type Type = JSONPrimitive | JSONObject | JSONArray | File
type Rule = (value: Type) => boolean
type Sanitizer = (value: Type) => Type

Expand All @@ -26,6 +27,7 @@ type VOptions = {
target: Target
key: string
type?: 'string' | 'number' | 'boolean' | 'object'
isArray?: boolean
}

export abstract class VBase {
Expand All @@ -34,15 +36,16 @@ export abstract class VBase {
key: string
rules: Rule[]
sanitizers: Sanitizer[]
isArray: boolean
private _message: string | undefined
private _optional: boolean

constructor(options: VOptions) {
this.target = options.target
this.key = options.key
this.type = options.type || 'string'
this.rules = []
this.sanitizers = []
this.isArray = options.isArray || false
this._optional = false
}

Expand All @@ -58,7 +61,7 @@ export abstract class VBase {

isRequired = () => {
return this.addRule((value: unknown) => {
if (value) return true
if (value !== undefined && value !== null && value !== '') return true
return false
})
}
Expand Down Expand Up @@ -115,29 +118,36 @@ export abstract class VBase {
value = body[this.key]
}
if (this.target === 'json') {
const obj = await req.json()
const obj = (await req.json()) as JSONObject
value = JSONPath(obj, this.key)
}

result.value = value
result.isValid = this.validateValue(value)

if (result.isValid == false) {
if (result.isValid === false) {
if (this._message) {
result.message = this._message
} else {
const valToStr = Array.isArray(value)
? `[${value
.map((val) =>
val === undefined ? 'undefined' : typeof val === 'string' ? `"${val}"` : val
)
.join(', ')}]`
: value
switch (this.target) {
case 'query':
result.message = `Invalid Value: the query parameter "${this.key}" is invalid - ${value}`
result.message = `Invalid Value: the query parameter "${this.key}" is invalid - ${valToStr}`
break
case 'header':
result.message = `Invalid Value: the request header "${this.key}" is invalid - ${value}`
result.message = `Invalid Value: the request header "${this.key}" is invalid - ${valToStr}`
break
case 'body':
result.message = `Invalid Value: the request body "${this.key}" is invalid - ${value}`
result.message = `Invalid Value: the request body "${this.key}" is invalid - ${valToStr}`
break
case 'json':
result.message = `Invalid Value: the JSON body "${this.key}" is invalid - ${value}`
result.message = `Invalid Value: the JSON body "${this.key}" is invalid - ${valToStr}`
break
}
}
Expand All @@ -147,26 +157,54 @@ export abstract class VBase {

private validateValue = (value: Type): boolean => {
// Check type
if (typeof value !== this.type) {
if (this._optional && typeof value === 'undefined') {
// Do nothing.
// The value is allowed to be `undefined` if it is `optional`
} else {
if (this.isArray) {
if (!Array.isArray(value)) {
return false
}
}

// Sanitize
for (const sanitizer of this.sanitizers) {
value = sanitizer(value)
}
for (const val of value) {
if (typeof val !== this.type) {
// Value is of wrong type here
// If not optional, or optional and not undefined, return false
if (!this._optional || typeof val !== 'undefined') return false
}
}

for (const rule of this.rules) {
if (!rule(value)) {
return false
// Sanitize
for (const sanitizer of this.sanitizers) {
value = value.map((innerVal) => sanitizer(innerVal)) as JSONArray
}

for (const rule of this.rules) {
for (const val of value) {
if (!rule(val)) {
return false
}
}
}
return true
} else {
if (typeof value !== this.type) {
if (this._optional && typeof value === 'undefined') {
// Do nothing.
// The value is allowed to be `undefined` if it is `optional`
} else {
return false
}
}

// Sanitize
for (const sanitizer of this.sanitizers) {
value = sanitizer(value)
}

for (const rule of this.rules) {
if (!rule(value)) {
return false
}
}
return true
}
return true
}
}

Expand All @@ -176,6 +214,10 @@ export class VString extends VBase {
this.type = 'string'
}

asArray = () => {
return new VStringArray(this)
}

isEmpty = (
options: {
ignore_whitespace: boolean
Expand Down Expand Up @@ -225,6 +267,10 @@ export class VNumber extends VBase {
this.type = 'number'
}

asArray = () => {
return new VNumberArray(this)
}

isGte = (min: number) => {
return this.addRule((value) => rule.isGte(value as number, min))
}
Expand All @@ -240,6 +286,10 @@ export class VBoolean extends VBase {
this.type = 'boolean'
}

asArray = () => {
return new VBooleanArray(this)
}

isTrue = () => {
return this.addRule((value) => rule.isTrue(value as boolean))
}
Expand All @@ -255,3 +305,25 @@ export class VObject extends VBase {
this.type = 'object'
}
}

export class VNumberArray extends VNumber {
isArray: true
constructor(options: VOptions) {
super(options)
this.isArray = true
}
}
export class VStringArray extends VString {
isArray: true
constructor(options: VOptions) {
super(options)
this.isArray = true
}
}
export class VBooleanArray extends VBoolean {
isArray: true
constructor(options: VOptions) {
super(options)
this.isArray = true
}
}
42 changes: 30 additions & 12 deletions deno_dist/utils/json.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
export const JSONPath = (data: object, path: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let val: any = data
const parts = path.split('.')
export type JSONPrimitive = string | boolean | number | null | undefined
export type JSONArray = (JSONPrimitive | JSONObject | JSONArray)[]
export type JSONObject = { [key: string]: JSONPrimitive | JSONArray | JSONObject }
export type JSONValue = JSONObject | JSONArray | JSONPrimitive

const JSONPathInternal = (data: JSONValue, parts: string[]): JSONValue => {
const length = parts.length
for (let i = 0; i < length && val !== undefined; i++) {
for (let i = 0; i < length && data !== undefined; i++) {
const p = parts[i]
if (p !== '') {
if (typeof val === 'object') {
val = val[p]
} else {
val = undefined
}
if (p === '') {
continue
}

if (typeof data !== 'object' || data === null) {
return undefined
}

if (p === '*') {
const restParts = parts.slice(i + 1)
const values = Object.values(data).map((v) => JSONPathInternal(v, restParts))
return restParts.indexOf('*') === -1 ? values : values.flat()
} else {
data = (data as JSONObject)[p] // `data` may be an array, but accessing it as an object yields the same result.
}
}
return data
}

export const JSONPath = (data: JSONObject, path: string) => {
try {
return JSONPathInternal(data, path.replace(/\[(.*?)\]/g, '.$1').split(/\./))
} catch (e) {
return undefined
}
return val
}
41 changes: 38 additions & 3 deletions src/middleware/validator/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,12 +387,12 @@ describe('Structured data', () => {
post: {
title: v.json('post.title').isAlpha(),
content: v.json('post.content'),
}
},
})),
(c) => {
const header = c.req.valid().header
const post = c.req.valid().post
return c.json({header, post})
return c.json({ header, post })
}
)

Expand All @@ -413,7 +413,7 @@ describe('Structured data', () => {
})
const res = await app.request(req)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({header: headers, post: json.post})
expect(await res.json()).toEqual({ header: headers, post: json.post })
})

it('Should return 400 response - missing a required header', async () => {
Expand All @@ -431,3 +431,38 @@ describe('Structured data', () => {
expect(res.status).toBe(400)
})
})

describe('Array values', () => {
const app = new Hono()
app.post(
'/post',
validator((v) => ({
post: {
title: v.json('post.title').isAlpha(),
tags: v.json('post.tags').asArray().isRequired(),
ids: v.json('post.ids').asNumber().asArray(),
},
})),
(c) => {
const res = c.req.valid()
return c.json({ tag1: res.post.tags[0] })
}
)

it('Should return 200 response', async () => {
const json = {
post: {
title: 'foo',
tags: ['Workers', 'Deno', 'Bun'],
ids: [1, 3, 5],
},
}
const req = new Request('http://localhost/post', {
method: 'POST',
body: JSON.stringify(json),
})
const res = await app.request(req)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ tag1: 'Workers' })
})
})
Loading

0 comments on commit 9a0389e

Please sign in to comment.