【Spring Boot】第11課-驗證 request body 的請求資料

在網頁上填寫資料時,前端會對欄位進行資料檢查,避免送出不合理的值。但若有人繞過這項檢查,直接對 API 發出請求會怎樣呢?假設後端不跟著做檢查,就可能把不合理的資料存進資料庫。

本文會將 JDK 的標記應用於 Spring Boot,藉此實現後端的資料驗證。接著撰寫相關測試案例。最後將請求主體與資料庫文件的 Java 類別區隔開來。

本文的練習用專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch10

一、資料驗證概述

對開發過前端的人來說,使用者填寫完資料送出前,先欄位中的資料進行檢查是很正常的事。例如電子郵件地址不允許輸入中文,或在價格不允許負數。

但有心人士只要知道 API 路徑和接受的資料格式,就能透過其他途徑來發出請求,像 Postman 就是一項工具。那麼 API 是如何得知的呢?比方說讀者可透過瀏覽器的開發者工具,查看發出的請求。下面以 Chrome 瀏覽器在網路論壇「Dcard」發表文章為例。

在 Chrome 按下 F12 可開啟開發者工具。點選最上方的 Network 頁籤,便能觀看該分頁發出的請求。圖中的「Request URL」代表API路徑。再往下則是請求與回應標頭。而最後的「Request Payload」則是請求主體,此處為發表文章的相關資料。

回到目前的練習用專案,由於尚未進行資料驗證,因此用 Postman 發出請求時,提供不合理的產品名稱或價格,是能存進資料庫的。然而這種狀況理應是不允許的,因此後端也需要具備阻擋的機制。


二、定義驗證規則

本文要驗證的對象是 Product 的請求主體。在 API 接收到資料時,針對它的 nameprice 欄位做檢查。為了進行驗證,請讀者在 pom.xml 匯入以下的函式庫。

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

匯入後,請開啟 Product 類別,在欄位加上標記以定義驗證規則。產品名稱我們規定為「非空值」,價格規定為「大於等於零」。

public class Product {
private String id;

@NotEmpty
private String name;

@Min(0)
private int price;

// 略過 get 與 set 方法
}

這些標記是來自 javax.validation.constraints 套件,它們可以規定各個欄位的值應該要符合什麼條件。上面的 @NotEmpty 標記規定字串欄位不能是 null 或空字串,也可用在集合欄位上(如List)。而 @Min 標記則規定數值欄位的最小值。

加上標記後,我們還需決定在什麼地方才要對 Product 物件做驗證。為了避免不合法的資料進入 API,因此請在 ProductController 的方法參數加上 @Valid 標記。

@RestController
@RequestMapping(value = "/products", produces = MediaType.APPLICATION_JSON_VALUE)
public class ProductController {

@PostMapping
public ResponseEntity<Product> createProduct(@Valid @RequestBody Product request) {
// 其餘略過
}

@PutMapping(value = "/{id}")
public ResponseEntity<Product> replaceProduct(@PathVariable("id") String id,
@Valid @RequestBody Product request) {
// 其餘略過
}

// 略過其餘 API
}

加上 @Valid 標記,當 Product 參數傳入時,便會對欄位進行檢查。若有不符合的地方,Spring Boot 會拋出例外,回傳 HTTP 400(Bad Request)。其他種類的驗證標記,讀者只要在程式碼最上方 import 套件那邊,對著套件路徑上的「constraint」按下 Ctrl + 左鍵,在 IntelliJ 視窗的左邊就能看見可使用的標記了。

三、發送不合法的資料

添加驗證規則後,可使用 Postman 發送不合法資料,並觀察結果。筆者發送的請求主體如下。

{
"name": "",
"price": -1
}

若有任何欄位沒通過驗證,會收到如下圖的回應。

「status」與「error」欄位已經表明 HTTP 的回應狀態。「Bad Request」的意思是,發出的請求內容已經是不合理的了。

而「errors」陣列紀錄著未通過驗證的欄位。其中「defaultMessage」是錯誤訊息,代表該欄位未通過的原因。這個訊息可以自訂,只要在驗證規則的標記中傳入 message 的參數即可。

public class Product {
private String id;

@NotEmpty(message = "Product name is undefined.")
private String name;

@Min(value = 0, message = "Price should be greater or equal to 0.")
private int price;

// 略過 get 與 set 方法
}


四、撰寫測試案例

上一節使用 Postman 驗證結果,接著我們來撰寫測試案例。在專案中,會接收 Product 請求的 API 只有 POST 和 PUT 方法,因此筆者針對這兩項。以下提供兩個測試案例,而讀者可自行練習其他情形。

下面的範例測試是以 POST 方法發送沒有名稱的產品請求。 

@Test
public void get400WhenCreateProductWithEmptyName() throws Exception {
JSONObject request = new JSONObject()
.put("name", "")
.put("price", 350);

mockMvc.perform(post("/products")
.headers(httpHeaders)
.content(request.toString()))
.andExpect(status().isBadRequest());
}

下面的範例測試是以 PUT 方法發送價格為負的產品請求。

@Test
public void get400WhenReplaceProductWithNegativePrice() throws Exception {
Product product = createProduct("Computer Science", 350);
productRepository.insert(product);

JSONObject request = new JSONObject()
.put("name", "Computer Science")
.put("price", -100);

mockMvc.perform(put("/products/" + product.getId())
.headers(httpHeaders)
.content(request.toString()))
.andExpect(status().isBadRequest());
}

假設在產品請求的 JSON 資料中,沒有提供價格,讀者會發現測試通過了!原因是 Controller 收到請求,將資料賦予給 Product 物件後,才會開始驗證。由於 price 的類型是 int,預設值為0,所以符合 Min(0) 的規則。

解法是將 price 的資料型態改為 Integer,並加上 @NotNull 標記。這樣在欄位未接收值的情況下,預設值會是 null,便能偵測出錯誤了。


五、區隔請求類別與資料庫文件

在上一節有提到修改資料型態的做法,但筆者不建議為了這個考量而去改變資料庫文件的欄位設計。例如把型態改為 Integer,就潛藏著資料庫的產品價格有 null 的可能性。

較好的做法是分別建立請求主體與資料庫文件的類別。因此我們建立一個產品請求類別 ProductRequest,只包含名稱與價格的欄位,並加上驗證標記。而原先的 Product 類別則恢復原狀。

public class ProductRequest {
@NotEmpty(message = "Product name is undefined.")
private String name;

@NotNull
  @Min(value = 0, message = "Price should be greater or equal to 0.")
private Integer price;

// 略過 get 與 set 方法
}

這個 ProductRequest 物件,會代替 Controller 及相關 Service 方法的參數。而為了將產品請求轉換為資料庫文件,筆者建立了 ProductConverter 類別來做這件事。

public class ProductConverter {

private ProductConverter() {

}

public static Product toProduct(ProductRequest request) {
Product product = new Product();
product.setName(request.getName());
product.setPrice(request.getPrice());

return product;
}
}

完成後,我們用 ProductRequest 物件來修改 Controller 的程式碼。

@RestController
@RequestMapping(value = "/products", produces = MediaType.APPLICATION_JSON_VALUE)
public class ProductController {

@PostMapping
public ResponseEntity<Product> createProduct(@Valid @RequestBody ProductRequest request) {
// 其餘略過
}

@PutMapping(value = "/{id}")
public ResponseEntity<Product> replaceProduct(@PathVariable("id") String id,
@Valid @RequestBody ProductRequest request) {
// 其餘略過
}
// 略過其餘 API
}

以下是修改 Service 程式碼的部份,原先的轉換過程可交由 ProductConverter 來處理。

@Service
public class ProductService {

public Product createProduct(ProductRequest request) {
Product product = ProductConverter.toProduct(request);
return repository.insert(product);
}

public Product replaceProduct(String id, ProductRequest request) {
Product oldProduct = getProduct(id);
Product newProduct = ProductConverter.toProduct(request);
newProduct.setId(oldProduct.getId());

return repository.save(newProduct);
}
// 其餘略過
}

如此一來,前後端要串接時能共同訂定請求主體的欄位,而後端仍可保有資料庫文件的設計。

本文的完成範例:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch11

上一篇:【Spring Boot】第10課-MockMvc 整合測試(二)

下一篇:【Spring Boot】第12課-使用 Jackson 控制回傳的 JSON 欄位

留言

  1. 作者您好,你下一篇 : 你下一篇 : 【Spring Boot】第12課-使用 Jackson 標記調整 JSON 欄位,的連結上,少打了 .html ,導致無法順利連過去 第12課,再麻煩您修改一下。
    您寫得很用心,謝謝您的分享

    回覆刪除
  2. 你好,对于 PathVariable 来说,如果我希望它为一个 int,是应该直接用 Integer 来声明,还是使用 String 再加上 @Digits。好像 Spring Boot 没有办法在解析传入的数据时自动依照声明的变量类型来处理。

    回覆刪除
  3. 三、發送不合法的資料的部分
    回應沒有詳細的error資料,而且也沒法顯示自訂訊息,是什麼原因呢?謝謝

    回覆刪除
    回覆
    1. 對於比較新版本的 Spring Boot,要在 application.properties 檔案中加入「server.error.include-message=always」這個設定值,才會看到喔!

      刪除

張貼留言