ใช้ Typescript type ในการ validate request
Tags: node, typescript
Published: 2020-04-02 04:56:44

ปกติในการทำ api ที่ดี เราจะต้องมีการ validate input ก่อนนำไปใช้งานทุกครั้ง โดยปกติ ถ้าเป็นการเช็ค input เพียงไม่กี่ตัว เราอาจจะใช้ if..then..else ดักๆเอาได้ แต่ในกรณีที่ input มีความซับซ้อน เช่น เป็นที่มี field เยอะ แถมแต่ละ field ก็มี type ไม่เหมือนกันแถมบางอันยังเป็น optional อีก code ในการ validate request ก็จะมีความยุ่งเหยิงขึ้น ในกรณีแบบนี้เองที่เรามักจะใช้ json schema มาเป็นตัวช่วย


## Validate Input โดยใช้ JSON Schema

JSON Schema ชื่อก็บอกอยู่แล้วว่าเป็นมาตรฐานในการ ประกาศ schema ให้กับ JSON ดูรายละเอียดเพิ่มเติมได้ที่ [json-schema.org](https://json-schema.org/)

ทีนี้เรามาดูกันดีกว่าว่ามันหน้าตาเป็นยังไง ก่อนอื่นสร้าง project ที่มี structure ดังนี้
```
schemas
  person-schema.js
src
  index.js
package.json

```


สมมติเราต้องการเขียน api `POST /persons` ที่รับ object ที่มี `firstName` และ `lastName` เป็น required field โดยที่ทั้งคู่มี type เป็น string จะเขียน JSON schema ได้ดังนี้

```javascript
// schemas/person-schema.js

const schema = {
  type: 'object',
  properties: {
    firstName: { type: 'string' },
    lastName: { type: 'string' }
  },
  required: ['firstName', 'lastName'],
  $schema: 'http://json-schema.org/draft-07/schema#',
}
```

เมื่อมี JSON Schema เราสามารถใช้ JSON Schema validator ในการ validate javascript object ได้วันนี้เราจะมาลลองใช้ [ajv](https://github.com/epoberezkin/ajv) ซึ่งเป็น JSON Schema validator ตัวนึงที่เป็นที่นิยม วิธีการใช้งานก็ง่ายดาย

ก่อนอื่นก็ install ajv ลงมาก่อน

```
yarn add ajv
```

จากนั้นเขียน code เพื่อใช้งานดังนี้

```javascript
// src/index.js

import PersonSchema from '../schemas/person-schema'

// Setup express and middlewares
// ...

const ajv = new Ajv()
const personValidator = ajv.compile(PersonSchema)

app.post('/persons', (req: Request, res: Response) => {
  if (!personValidator(req.body)) {
    res.status(400).send({ code: 400, errors: personValidator.errors })
  } else {
    res.send('Correct format')
  }
})
```

ง่ายๆ เพียงเท่านี้เราก็จะได้ validator ที่คอย validate request.body ให้มี schema ตรงตามที่เราต้องการเสมอแล้ว


## สร้าง JSON Schema จาก Typescript Type

ปัญหาเกิดขึ้นเมื่อเราเขียน typescript เราจำเป็นต้องเขียน type ในอีกรูปแบบหนึ่งที่แน่นอนว่าไม่ใช่ JSON Schema ทำให้เมื่อมีการแก้ไข schema เราต้องทำทั้ง 2 ที่ซึ่งเสี่ยงต่อการผิดพลาดได้สูง เราจะมาลองสร้าง JSON Schema จาก Typescript Type ด้วย [typescript-json-schema](https://github.com/YousefED/typescript-json-schema) กัน ก่อนอื่นสร้าง project typescript ดังนี้

```
schemas
src
  index.ts
  types
    person-type.ts
build-schema.js
tsconfig.json
package.json
```
จากนั้น install [typescript-json-schema](https://github.com/YousefED/typescript-json-schema)

```
yarn add --dev typescript-json-schema
```

เช่น ประกาศ type Person ซึ่งมี field เช่นเดียวกันกับ JSON Schema ในตอนที่แล้ว

```javascript
// types/person-type.ts

export interface Person {
  firstName: "string",
  lastName: "string",
}

```

จากนั้นเราจะมาเขียน script เพื่อสร้าง JSON Schema กัน
```javascript
// build-schema.js

const fs = require('fs')
const { resolve } = require('path')
const tjs = require('typescript-json-schema')

// อ่าน รายชื่อของ files ใน directory src/types
fs.readdir('src/types', (err, files) => {
  const _files = files.map(file => resolve(`src/types/${file}`))
  
  // Setting ของ tsj ถ้า field ในไม่ใช่ optional field ให้ list ใน required fields 
  // ของ JSON Schema ด้วย
  const settings = {
    required: true,
  }

  // typescript compiler option
  const compilerOptions = {
    strictNullChecks: true,
  }

  // สร้าง JSON Schema จาก type ที่ถูกประกาศใน directory src/types ทั้งหมด
  const program = tjs.getProgramFromFiles(_files, compilerOptions)
  const generator = tjs.buildGenerator(program, settings)

  // วนลูปไล่สร้าง schema ทีละ file
  const interfaces = ['Person'] // List ของ interfaces ที่ต้องการสร้าง schema
  interfaces.forEach(interface => {
    const symbol = generator.getSchemaForSymbol(interface)
    const exportStatement = `
      const schema = ${JSON.stringify(symbol)}
      module.exports = schema
    `
    fs.writeFileSync(`schemas/${interface.toLowerCase()}-schema.js`, exportStatement)
  })
})

```

สร้าง script สำหรับเรียก build-schema
```
// package.json
{
  ...
  "scripts": {
    ...
    "build:schema": "node build-schema.js",
  }
}
```

จากนั้นลองสั่งสร้าง schema ดู จะเห็น file person-schema.js ถูกสร้างขึ้นใน directory schemas
```
yarn build:schema
```

## ทดลองใช้ JSON Schema ที่สร้างขึ้น

เรามาลองใช้ type และ JSON Schema ที่สร้างขึ้นกัน
```javascript
// src/index.ts

import bodyParser from 'body-parser'
import { Person } from './types/person-type'
import PersonSchema from '../schemas/person-schema.js'

const app = express()
app.use(bodyParser.json())

function getFullName(person: Person): string {
  return `${person.firstName} ${person.lastName}`
}

const ajv = new Ajv()
const personValidator = ajv.compile(PersonSchema)

app.post('/persons', (req: Request, res: Response) => {
  if (!personValidator(req.body)) {
    res.status(400).send({ code: 400, errors: personValidator.errors })
  } else {
    res.send(`Hello, ${getFullName(req.body)}`)
  }
})
app.listen(3000, () => {
  console.log('Start server listening at port 3000')
})
```

ทดสอบโดย `curl`
```
curl -H "content-Type: application/json" -d '{"firstName":"arnupharp", "lastName":"viratanapanu"}' http://localhost:3000/persons
```
จะได้ response
```
Hello, arnupharp viratanapanu
```

แต่ถ้าเราไม่ใส่ `firstName` หรือ `lastName`
```
curl -H "content-Type: application/json" -d '{"firstNameIsMissing":"arnupharp", "lastName":"viratanapanu"}' http://localhost:3000/persons
```
จะได้ error กลับมา
```
{
   "code":400,
   "errors":[
      {
         "keyword":"required",
         "dataPath":"",
         "schemaPath":"#/required",
         "params":{
            "missingProperty":"firstName"
         },
         "message":"should have required property 'firstName'"
      }
   ]
}"⏎"
```

ดู source code เต็มๆได้ที่ {% github topscores/ts-to-schema %}