2023年3月10日 星期五

AdonisJs第七天validate

 參數驗證算是後端功能中非常重要的一環,可以過濾前端算計來的參數,也可以轉換成適當的類型。

AdonisJs中有內建validate,使用方式先依照規範把validator建立起來,寫出要接收的schema以及相對應的訊息,之後加以驗證即可,範例如下

export default class BasicValidator {
constructor(protected ctx: HttpContextContract) {}

public schema = schema.create({
name: schema.string(),
})

public messages = {
"name.required": "name is required",
"name.string": "name must be string",
}
}

// 使用方式
export default class BasicController {
async create({ request }: HttpContextContract) {
const { name } = await request.validate(BasicValidator)
return name
}
}

這邊值得一題,驗證完會回傳驗證結果,並轉成相對應的物件,例如string, number, DateTime, File......因此不會接收前端傳回來多餘的內容。如果設計得宜,是非常好的保障。

ect. 如果messages沒有相對應的定義訊息,系統就會使用預設訊息拋出

schema的規範相當多元,也可以上訂傳進來是物件,陣列,或是把時間格式轉換成DateTime,又或者限制輸入的內容金桔現在陣列之中的enum

public schema = schema.create({
date: schema.date({ format: 'yyyy-MM-dd'}), date格式必須為yyyy-MM-dd否則報錯
gender: schema.enum([1, 2]) // gender只能為1或2
object: schema.object().members({ a: schema.string(), b: schema.number() }),
array: schema.array().members(schema.number()),
})

validate也可以搭配rules使用,rules也預設有非常多的規則,甚至可以自定義驗證規則

public schema = schema.create({
id: schema.number([rules.exists({ table: "users", column: "id" })]), //id需存在於users table
})

更多細節都可以上官網查詢,這邊就不一一贅述。

接下來要介紹一個套件,如果你使用過nest js那你一定對他撰寫驗證的檔案驗證新穎,我同樣抱持著adonis會不會也這麼剛好有類似的東西開始搜索,(先說adonis提供的所有工具已足夠應付幾乎90%專案會遇上的問題,但小編我們身為攻城屍要足夠貪婪,能好就絕對不要濫,能省時間能更方便就絕對要去追求!!),終於讓我找到adonis-class-validateor,不過這套有點小bug,在File的轉換上會報錯,經小編修正後為@lu7766lu7766/adonis-class-validateor,安裝後下指令node ace invoke @lu7766lu7766/adonis-class-validateor設置好相關配置,即可開始使用。一樣先上範例

import { schema, validate } from "@ioc:Adonis/ClassValidator"
import { DateTime } from "luxon"

export default class GetValidator {
@validate(schema.date({ format: "yyyy-MM-dd" }), {
date: "date content error",
required: "date required",
format: "date format error",
})
public date: DateTime

@validate(schema.enum(["a", "b"])) // 使用預設message
public centre_code: string

@validate(schema.string.optional() // 可不送
public code?: string
}

// 使用方式
export default class BasicController {
// ...service 定義方式省略
async getList({ request }: HttpContextContract) {
const { date, centre_code, code } = await request.classValidate(GetValidator)
return this.service.getList({ date, centre_code, code })
}
}

使用class-validator有兩大好處。
第一個好處,邏輯較為集中,傳統設計分為schema,message兩大部分,一但參數多起來,在定義message時很常要滾輪往上看schema是如何設計,相當不便,使用class-validator可以一目瞭然。
第二個好處,因為小編習慣把所接收到的參數全部丟進service進行處理,在參數上類別用傳統的方式就必須一個一個重新定義,否則所有參數就會是any,無法發揮ts跟vscode的長處

getList({ date, centre_code, code }: {date: DateTime; centre_code: string; code?: string}) {
const start_day = date.startOf("day").toFormat("yyyy-MM-dd hh:mm:ss")
const end_day = date.endOf("day").toFormat("yyyy-MM-dd hh:mm:ss")
return return [start_day, end_day, centre_code, code]
}

如果使用class-validator就可以直接將參數類別定義為validator

getList({ date, centre_code, code }: GetValidator) {
const start_day = date.startOf("day").toFormat("yyyy-MM-dd hh:mm:ss")
const end_day = date.endOf("day").toFormat("yyyy-MM-dd hh:mm:ss")
return [start_day, end_day, centre_code, code]
}

簡單說就是善用ts的優勢,讓之後的操作上可以說會方便許多。

以上就是關於validate想要跟各位分享的內容。


3/25更新一下

@validate(schema.string())
public name: string

name會是必填沒錯,但卻沒辦法送出空字串,只是空字串應該也算是合法字串吧,小編認為這應該算是個bug,目前已在github上題出,看之後會不會得到作者回覆

3/26更新一下

由於我寄去的信被作者打槍了,他們一再強調這並非是bug而是有意為之,要我提出為什麼非得這麼做的“討論”。我哪有那美國時間跟他寫英文信在討論,難道要我告訴他,前端編輯文字,當把文字刪除還要特別處理為null這件事不會很不直覺嗎?這段話用中文講都不太好理解更何況要用英文,所以不得已之下先在前端把所有空字串改為null,再把後端設為nullable結束第一階段。

既然有第一階段當然會有第二階段,無奈下我把adonis/validation的原始檔下載回本地研究了一下,發現兩件事

// src/Schema/index.ts
// 可以看到schema.string的實作
function string(options?: { escape?: boolean; trim?: boolean } | Rule[], rules?: Rule[]) {
if (!rules && Array.isArray(options)) {
rules = options
options = {}
}
return getLiteralType('string', false, false, options, rules || []) as ReturnType<StringType>
}
string.optional = function optionalString(
options?: { escape?: boolean; trim?: boolean } | Rule[],
rules?: Rule[]
) {
if (!rules && Array.isArray(options)) {
rules = options
options = {}
}
return getLiteralType('string', true, false, options, rules || []) as ReturnType<
StringType['optional']
>
}
string.nullable = function nullableString(
options?: { escape?: boolean; trim?: boolean } | Rule[],
rules?: Rule[]
) {
if (!rules && Array.isArray(options)) {
rules = options
options = {}
}
return getLiteralType('string', false, true, options, rules || []) as ReturnType<
StringType['nullable']
>
}
string.nullableAndOptional = function nullableAndOptionalString(
options?: { escape?: boolean; trim?: boolean } | Rule[],
rules?: Rule[]
) {
if (!rules && Array.isArray(options)) {
rules = options
options = {}
}
return getLiteralType('string', true, true, options, rules || []) as ReturnType<
StringType['nullableAndOptional']
>
}
// src/utils.ts 看到實作的方法
export function getLiteralType(
subtype: string,
optional: boolean,
nullable: boolean,
ruleOptions: any,
rules: Rule[]
): { getTree(): SchemaLiteral } {
const subTypeRule = rules.find((rule) => rule.name === subtype)
const optionsTree = {}

return {
getTree() {
return {
type: 'literal' as const,
nullable,
optional,
subtype: subtype,
rules: ([] as Rule[])
.concat(optional ? [] : nullable ? [schemaRules.nullable()] : [schemaRules.required()])
.concat(subTypeRule ? [] : [schemaRules[subtype](ruleOptions)])
.concat(rules)
.map((rule) => compileRule('literal', subtype, rule, optionsTree)),
}
},
}
}

// ----------------------------------------
// 意思是
schema.string() // 其實等同於
schema.string.optional([rules.required()])

schema.string.nullable() // 其實等同於
schema.string.optional([rules.nullable()])

schema.string.optional() // 意指不加入任何條件,沒後綴跟nullable後綴差別只在有沒有幫你預加上rule而已

// 另外再科普一下
schema.string.nullableAndOptional() // 其實等於optional一點用也沒有,不知道為什麼要這樣設計


那既然是由rule決定是否需要輸入,那我當然可以透過自定rule來定義出必填,但又可以是空白這件事,這我們必須先來看一下required到底做了什麼判斷

// src/Compiler/Validators/existence/required.ts
export const required: SyncValidation = {
compile: wrapCompile(RULE_NAME, [], () => {
return {
allowUndefineds: true,
}
}),
validate(value, _, { errorReporter, pointer, arrayExpressionPointer }) {
if (!exists(value)) {
errorReporter.report(pointer, RULE_NAME, DEFAULT_MESSAGE, arrayExpressionPointer)
}
},
}
// src/Validator/helpers.ts
export function exists(value: any) {
return !!value || value === false || value === 0
}
就是把false成員抓出來而已,但這樣並不能說明為什麼不送參數會報錯,因為不送就不會被驗證了吧,那原因應該在其他地方,想必就是compile裡面的allowUndefineds這個參數吧!!
既然知道方法,那剩下就是看自定rule怎麼寫,只是我看了一下官網,不只寫法跟原始碼的寫法不一樣,官網甚至只有教怎麼寫validate,也沒有地方可以設allowUndefineds。這時就要大大讚嘆typescript了,我看了一下rule的類別說明,竟然還可以寫第三個參數compileFn,這怎麼有點眼熟,裡面要回傳一個物件,裡面正好有我要的參數
{
name: string;
async: boolean;
allowUndefineds: boolean;
compiledOptions: Options;
}
那就開始動工吧

import { validator } from "@ioc:Adonis/Core/Validator"

validator.rule(
"stringAllowEmpty",
(value, _, options) => {
if (typeof value !== "string") {
options.errorReporter.report(options.pointer, "stringAllowEmpty", "stringAllowEmpty validation failed", options.arrayExpressionPointer)
}
},
() => ({
allowUndefineds: true,
})
)

declare module "@ioc:Adonis/Core/Validator" {
interface Rules {
stringAllowEmpty(): Rule
}
}

// usage
schema.string.optional([rules.stringAllowEmpty()])
以上打完收工!!
事實證明,偶爾看點原始碼還是有必要的。

3/26更新一下

小編還是覺得要多寫一個rule很麻煩,看能否把原本schema.string跟schema.number直接改寫,一開始也規劃直接寫在@lu7766lu7766/adonis-class-validateor裡面,後來絕地還是單除拆出來變成一個單獨得套件好了,所以我又增加了一個repo @lu7766lu7766/adonis-schema-override,非常直白告訴你我要覆寫了。
// src/providers/SchemaOverrideProvider.ts

import { ApplicationContract } from "@ioc:Adonis/Core/Application"
import { Rule, SchemaLiteral } from "@ioc:Adonis/Core/Validator"

// 因為這個rule沒有要給別人使用,所以類別宣告直接寫在檔案裡,沒有放在adonis-typings
declare module "@ioc:Adonis/Core/Validator" {
interface Rules {
defined(): Rule
}
}

export default class ClassValidatorProvider {
constructor(protected app: ApplicationContract) {}

public async boot() {
this.addRules()
this.overrideStringSchema()
}

private addRules() {
const { validator } = this.app.container.use("Adonis/Core/Validator")
    // 因為string, number本身就會檢測內容類別,所以我只需判斷是否有非文字的類別即可
validator.rule(
"defined",
(value, _, options) => {
if (value === undefined || value === null) {
options.errorReporter.report(options.pointer, "required", "required validation failed", options.arrayExpressionPointer)
}
},
() => ({
allowUndefineds: true,
})
)
}

private overrideStringSchema() {
const { schema, rules } = this.app.container.use("Adonis/Core/Validator")
// 因為schema.string類型比較特殊,既是方法又有屬性,所以暫時只想到一次全部覆寫這種寫法,歡迎有更好建議
function myString(...args) {
let option = {}
let params: Rule[] = [rules.defined()]
if (args.length === 1) {
params = params.concat(args[0])
} else if (args.length === 2) {
option = args[0]
params = params.concat(args[1])
}
return schema.string.optional(option, params) as {
t: string
getTree: () => SchemaLiteral
}
}
myString.nullable = schema.string.nullable
myString.optional = schema.string.optional
myString.nullableAndOptional = schema.string.nullableAndOptional
schema.string = myString
}
// number的方法就參照上面再寫一次就好
}


以上就真的大功告成收工了

小記:因為schema.string() == schema.strion.optional([rules.required()]),因為rule.required()是大家共用的,所以不能改,不然會天下大亂,我想作者大概也誤會我的意思,我只是想個別做調整就像上面這樣,總之就是小編又一次拯救了世界

沒有留言:

張貼留言