iT邦幫忙

2023 iThome 鐵人賽

DAY 10
0

好容易終於撐到第十天了,在中文裡有全或滿的意思,比如十全十美十全大補十分可愛,希望大家不會滿到吸收不了 XDD。

API route 路由,先來GET一下

直接新增一個tic_tac_toe.rs檔案:

// web/src/main.rs
mod tic_tac_toe;

然後先看一下warp的todo範例來寫api,這次我們先從get著手:

/// The 4 TODOs filters combined.
pub fn todos(
    db: Db,
) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
    todos_list(db.clone())
        .or(todos_create(db.clone()))
        .or(todos_update(db.clone()))
        .or(todos_delete(db))
}
/// GET /todos?offset=3&limit=5
pub fn todos_list(
    db: Db,
) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
    warp::path!("todos")
        .and(warp::get())
        .and(warp::query::<ListOptions>())
        .and(with_db(db))
        .and_then(handlers::list_todos)
}

看起來它的DB是使用參數注入,把每個api chain在一起,fn回傳的impl Filter應該是要讓fn可以一直chain下去(就像鐵軌可以一直往下接),再對照一下db的初始化過程:

// main
let db = models::blank_db();    // 初始化db物件
let api = filters::todos(db);   // 注入db至API使用

// mod models
pub fn blank_db() -> Db {
    Arc::new(Mutex::new(Vec::new())) // 有沒有似曾相似的感覺
}

不過以我們目前的案例而言,db(in-memory)是放在service裡,所以我們把它的注入的對象db置換成我們的service,仿著著寫出主體框架,看看編譯器又要教我們什麼:

// web/src/tic_tac_toe.rs
use std::convert::Infallible;
use warp::Filter;
use service::tic_tac_toe::TicTacToeService;

pub fn router_games(
    service: impl TicTacToeService          // 把範例db改成我們的service
) -> impl Filter<Extract=(impl warp::Reply, ), Error=warp::Rejection> + Clone {
    games_get(service.clone())
    // .or(games_create(db.clone()))        // 先註解,保留等等擴充
    // .or(games_update(db.clone()))
    // .or(games_delete(db))
}

/// GET /tic_tac_toe/:id
pub fn games_get(
    service: impl TicTacToeService
) -> impl Filter<Extract=(impl warp::Reply, ), Error=warp::Rejection> + Clone {
    // 這邊想用path傳id,是參考todo範例的delete怎麼傳path參數
    warp::path!("tic_tac_toe" / usize)                 // 在路由裡把 id 放path
        .and(warp::get())                              // 設為 get 方法
        .and(warp::any().map(move || service.clone())) // 仿範例with_db
        .and_then(handle_games_get)
}

pub async fn handle_games_get(
    id: usize,
    service: impl TicTacToeService,
) -> Result<impl warp::Reply, Infallible> {
    let game = service.get(id).unwrap();      // 先用unwrap讓程式通過
    Ok(warp::reply::json(&game))              // 等等再來處理error
}
  • 參數傳 impl trait 是指可傳遞有實作該trait的具體類別

初步執行結果,提醒需加Clone trait

第一個問題是我們的Service需要實現Clone,一種是加到參數的限制條件,一種是加到我們原先的trait裡。以這個情境我把Clone補在原先的trait裡,這樣之後需要引用到時,就不用再參數逐項列示了:

@@ service/src/tic_tac_toe.rs @@
+pub trait TicTacToeService : Clone {
-pub trait TicTacToeService {
    fn new_game(&self) -> Result<(usize, Game), Error>;
    fn get(&self, id: usize) -> Result<Game, Error>;

這裡雖然看起來很像TicTacToeService繼承了Clone的trait,但其實不是。因為rust裡沒有繼承。這裡定義的CloneSupertrait,只是加了一個限制,定義我們的Service trait如果要被實作,那麼實作的類別也要一起實作Clone,直接看實例:

編譯器提醒要幫InMemoryTicTacToeService補上Clone的實作

編譯器提醒我們,因為我們在trait TicTacToeService加上Clone這個supertrait,而我們先前只有實作CRUD,沒有實作Clone,所以要補上這個限制條件。可以自己寫也可以用內建的derive clone巨集幫我們實作:

@@ service/src/tic_tac_toe.rs @@
+#[derive(Clone)]
 struct InMemoryTicTacToeService {
     games: Arc<Mutex<HashMap<usize, Game>>>,

加完後又是其他error:

警告沒有實作 Serialize

這裡又說我們的game沒有實作 Serialize,所以不能轉json,在rust裡序列化及反序列化基本上使用的是serde這個套件,取Serialize及Deserialize字首為命名,立馬加入package:

如果是效能的追求者,也可以選擇rkyv來進行。

@@ Cargo.toml @@
 rand = { version = "0.8" }
+serde = { version = "1.0", features = ["derive"] }
 thiserror = { version = "1.0" }

@@ core/Cargo.toml @@
 [dependencies]
 rand = { worksapce = true }
+serde = { workspace = true }
 thiserror = { worksapce = true }

@@ core/src/tic_tac_toe.rs @@
+use serde::Serialize;
...
 /// 井字遊戲的符號。
+#[derive(Copy, Clone, Debug, PartialEq, Serialize)]
-#[derive(Copy, Clone, Debug, PartialEq)]
 pub enum Symbol {
    O,
...
 /// 井字遊戲棋局
+#[derive(Debug, Clone, Serialize)]
-#[derive(Debug, Clone)]
 pub struct Game {

接下來出現的問題有點小棘手,我這裡先放結論,把過程的放在補充資料:

提醒move元件需要Send

這裡說因為我們用到了move,所以被move的東西要是可Send的,通常Send又會搭配Sync一起,在「可延展的並行與 Sync 及 Send 特徵」有介紹。我們直接回到Serivce的trait加上:

@@ service/src/tic_tac_toe.rs @@
+pub trait TicTacToeService: Clone + Send + Sync {
-pub trait TicTacToeService: Clone {
    fn new_game(&self) -> Result<(usize, Game), Error>;

改完上面的東西後,編譯是通過了,不過我們的功能還沒加到路由裡,到main裡面補上:

// web/src/main.rs
use service::tic_tac_toe::TicTacToeService;
// 略
let game_service = service::tic_tac_toe::InMemoryTicTacToeService::new();
game_service.new_game().unwrap();     // 裡面是空的,先造一筆資料方便測試
let api_games = tic_tac_toe::router_games(game_service);

let routes = hello
    .or(api_games)

記得要把service裡的相關東西改pub:

@@ service/src/tic_tac_toe.rs @@
 #[derive(Clone)]
+pub struct InMemoryTicTacToeService {
-struct InMemoryTicTacToeService {
     games: Arc<Mutex<HashMap<usize, Game>>>,
 }

impl InMemoryTicTacToeService {
+    pub fn new() -> Self {
-    fn new() -> Self {
         Self {
             games: Arc::new(Mutex::new(HashMap::new())),

好容易我們把rust的抱怨都解完了,接著開啟網頁http://localhost:3030/tic_tac_toe/1試一下api內容:

在browser中進行get api 測試

錯誤處理 Error Handling

調整一下剛剛打API的參數,把 id 改成 2,看看會發生什麼事情:

在瀏覽器在試打不正確的api參數

看一下後端的log:

對照後端的訊息輸出

因為我們剛剛沒處理錯誤,所以在log裡可以看到出現了panic恐慌。rust裡的恐慌panic一般是指發生了無法復原的錯誤,這裡的panic是unwrap發出來的,我們知道unwrap是去幫我們解開Ok的資料結果,但這裡的結果是Err,所以無法解開Ok,故而直接報錯並中斷。我們先著手進行錯誤處理的修正吧,看一下warp裡是使用Rejection來回傳錯類別給API呼叫者:

// warp/examples/rejections.rs
let routes = div_with_header.or(div_with_body)
    .recover(handle_rejection);

fn div_by() -> impl Filter<Extract = (NonZeroU16,), Error = Rejection> + Copy {
    warp::header::<u16>("div-by").and_then(|n: u16| async move {
        if let Some(denom) = NonZeroU16::new(n) {
            Ok(denom)
        } else {
            Err(reject::custom(DivideByZero))
        }
    })
}

#[derive(Serialize)]
struct ErrorMessage {
    code: u16,
    message: String,
}

async fn handle_rejection(err: Rejection) -> Result<impl Reply, Infallible> {
    // 略
    let json = warp::reply::json(&ErrorMessage {
        code: code.as_u16(),
        message: message.into(),
    });
    Ok(warp::reply::with_status(json, code))
}

以上截取部分內容,看起來是在handle function裡丟出Error,這個Error是Rejection認得的(Error = Rejection),我們仿照這個方式先寫個自己的錯誤訊息type:

// web/src/main.rs
mod error;
// web/src/error.rs
use std::convert::Infallible;
use std::error::Error;
use warp::{Rejection, Reply};
use warp::http::StatusCode;

pub enum AppError {            // 我們在這定義web專案可能會遇到的錯誤
    UserFriendly(String, String),     // 回傳訊息給予前端User使用
    BadRequest(String),               // 錯誤的要求
    NotFound(String),                 // 找不到資源
    Unauthorized,                     // 未經授權的操作
    InternalServerError,              // 其他未歸類錯誤
}

struct AppErrorMessage {              // 非 2XX 回應的Body
    message: String,                  // 錯誤的訊息內容
    details: Option<String>,          // 有關錯誤的細節資料(如果有的話)
}

pub async fn handle_rejection(err: Rejection) -> Result<impl Reply, Infallible> {
    let code;                		  // http 回應代碼
    let message;             		  // http 回應訊息
    let mut details = None;  		  // http 回應細息

    if let Some(AppError::UserFriendly(msg, detail)) = err.find() {
        code = StatusCode::BAD_REQUEST;        // HTTP回應代碼
        message = msg.as_str();                // 所有message賦值都同類型,
        details = Some(detail.to_string());    // 所以我們統一轉成 &str
    // 略... 各種mapping,詳見原始碼
    } else {
        tracing::error!("unhandled rejection: {:?}", err); // 輸出至log
        code = StatusCode::INTERNAL_SERVER_ERROR;
        message = "UNHANDLED_REJECTION";      // <= 字串值本身也是&str類別
    }

    let json = warp::reply::json(&AppErrorMessage {
        message: message.into(),
        details: details.into(),
    });

    Ok(warp::reply::with_status(json, code))
}

大略照著寫一下,然後就差不多要開始解任務了:

AppErrorMessage未實作Serialize

這個我們知道,AppErrorMessage要序列化成Json要加Serializederive,記得Cargo也要加引用:

@@ web/Cargo.toml @@
 dotenvy = { workspace = true }
+serde = { workspace = true }
 tokio = { workspace = true }

@@ web/src/error.rs @@
+#[derive(serde::Serialize)]
 struct AppErrorMessage {

編譯過了,繼續小步向前:

@@ web/src/main.rs @@
 let routes = hello
     .or(api_games)
+    .recover(error::handle_rejection)
     .with(warp::trace::request())

沒有問題,繼續試著把handle裡的unwrap改為拋出error,如下:

@@ web/src/tic_tac_toe.rs @@
 pub async fn handle_games_get(id: usize, service: impl TicTacToeService) -> Result<impl warp::Reply, Infallible> {
+    let game = service.get(id)?;
-    let game = service.get(id).unwrap();
     Ok(warp::reply::json(&game))

未實作From trait for Infalible

rust說,這個方法不接受我們回傳的錯誤,比對一下剛剛的範例和games_get的Error type使用的是warp::Rejection,我們把這個方法的錯誤類別改Rejection試試。

+use warp::{Filter, Rejection};
-use warp::Filter;
...
 pub async fn handle_games_get(
     id: usize,
     service: impl TicTacToeService,
+) -> Result<impl warp::Reply, Rejection> {
-) -> Result<impl warp::Reply, Infallible> {
     let game = service.get(id)?;
     Ok(warp::reply::json(&game))

service裡的Error未實作Reject的警告訊息

第一個warning提示我們有未使用的use,把它移掉就好。再來是要實作(impl) Rejct這個trait給service裡的Error(這裡拋出來的)。記得套用trait的規則嗎,至少其中一個要是在這個crate內定義的,在這裡這兩個都是外部crate引入的,所以我們無法在這裡實作,我們就先把service裡的Error轉換為剛剛設定的AppError把,還記得From trait嗎,我們在error裡寫From,然後這次透過map_err轉換錯誤:

@@ web/src/tic_tac_toe.rs @@
-use std::convert::Infallible;
 use warp::Filter;
 use service::tic_tac_toe::TicTacToeService;
+use crate::error::AppError;
...
+let game = service.get(id).map_err(AppError::from)?;
-let game = service.get(id)?;

@@ web/src/error.rs @@
+impl From<service::tic_tac_toe::Error> for AppError {
+    fn from(value: service::tic_tac_toe::Error) -> Self {
+        match value {
+            service::tic_tac_toe::Error::GameRules(message)
+                 => AppError::UserFriendly("違反遊戲規則".into(), message),
+            service::tic_tac_toe::Error::GameOver 
+                => AppError::BadRequest("遊戲已結束".into()),
+            service::tic_tac_toe::Error::NotFound
+                => AppError::NotFound("遊戲不存在".into()),
+            service::tic_tac_toe::Error::Unknown
+                => AppError::InternalServerError,
+        }
+    }
+}

map_err裡面也可以傳遞fn,轉換完的Error就可以往外拋了(喂!不是那個外拋啦)

提示AppError需要實作Reject trait

這裡又要實作一個trait,好在AppError是我們在這寫的,可以實作:

@@ web/src/error.rs @@
+impl warp::reject::Reject for AppError {}

AppError需要實作Debug trait

依說明補上derive:

+#[derive(Debug)]
pub enum AppError {
    UserFriendly(String, String),

執行成功終端畫面

好了我們來試一下API,這裡我用VScode的Thunder client演示API的測試:

在VSCode裡測試get api

root的路由我們沒設定,所以出現了剛剛handle_rejection裡最後一項未處理的錯誤:500 UNHANDLED_REJECTION,接著試一下我們的API:

rest api 測試get棋局

打一個正確的,如上圖正確顯示200 OK,及正確的json body。

rest api 測試404的訊息

再打一個錯誤的id,果然出現了我們剛剛包的錯誤訊息,如果我們想顯示更詳細的資訊的話,可以客製Error enum的Variant,加上想要的資料進行傳遞與顯示,

初見 type

還記得剛剛在實作AppError的from時,因為Error會撞名所以我們要拉很長的namespace路徑嗎?這時候可以用type簡化寫法:

// web/src/error.rs
type GameSrvError = service::tic_tac_toe::Error; // 定義給下面用的type。

impl From<GameSrvError> for AppError {
    fn from(value: GameSrvError) -> Self {
        match value {
            GameSrvError::GameRules(message)
                => AppError::UserFriendly("違反遊戲規則".into(), message),
            GameSrvError::GameOver
                => AppError::BadRequest("遊戲已結束".into()),
            GameSrvError::NotFound => AppError::NotFound("遊戲不存在".into()),
            GameSrvError::Unknown => AppError::InternalServerError,
        }
    }

剩下的API

Create方法,我在測試的時候無意間Copilot幫忙寫了另一種寫法,把獨立的方法改放至閉包裡,我在這邊列出來給大家參考:

// web/src/tic_tac_toe.rs
/// POST /tic_tac_toe/
pub fn games_create(
    service: impl TicTacToeService
) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
    warp::path!("tic_tac_toe" )
        .and(warp::post())
        .and_then(move || {
            let service = service.clone();
            async move {
                let games = service.new_game().map_err(AppError::from)?;
                Ok::<_, warp::Rejection>(warp::reply::json(&games))
            }
        })
}
@@ web/src/tic_tac_toe.rs @@
 games_get(service.clone())
+    .or(games_create(service.clone()))
-    // .or(games_create(db.clone()))        

測試post api-1
測試post api-2

上面連續POST兩筆,序號有正確遞增(id: 2 -> 3),用GET看看是否有正確讀出:
測試get剛剛post的資料

寫法OK,繼續補完剩下的API:

pub fn router_games(
    service: impl TicTacToeService
) -> impl Filter<Extract = (impl warp::Reply,), Error = Rejection> + Clone {
    games_get(service.clone())
        .or(games_create(service.clone()))
        .or(games_play(service.clone()))
        .or(games_delete(service))
}

/// PUT /tic_tac_toe/:id/:num
pub fn games_play(
    service: impl TicTacToeService
) -> impl Filter<Extract=(impl warp::Reply, ), Error=Rejection> + Clone {
    warp::path!("tic_tac_toe" / usize / usize)
        .and(warp::put())
        .and_then(move |id, num| {
            let service = service.clone();
            async move {
                let games = service.play(id, num).map_err(AppError::from)?;
                Ok::<_, Rejection>(warp::reply::json(&games))
            }
        })
}

/// DELETE /tic_tac_toe/:id
pub fn games_delete(
    service: impl TicTacToeService
) -> impl Filter<Extract=(impl warp::Reply, ), Error=Rejection> + Clone {
    warp::path!("tic_tac_toe" / usize)
        .and(warp::delete())
        .and_then(move |id| {
            let service = service.clone();
            async move {
                let games = service.delete(id).map_err(AppError::from)?;
                Ok::<_, Rejection>(warp::reply::json(&games))
            }
        })
}

測試play

測一下,看起來都正常作動了,但其間我還有測到一個問題,就是我們輸入遊戲的步數並沒有檢核:

put api未正確回傳error訊息

錯誤是在core裡發生panic了,所以沒有依我們回傳error給予前端的路徑走:
put api error在後端輸出的訊息

我們調整一下,到核心層(domain layer)調整play方法,順便加上一個新的錯誤型態:

@@ core/src/tic_tac_toe.rs @@
 /// 井字遊戲的錯誤類型。
 #[derive(Debug, thiserror::Error)]
 pub enum Error {
+    #[error("錯誤的格號,請填1至9間的數字!")]
+    WrongStep,
...
 pub fn play(&mut self, num: usize) -> Result<(), Error> {
+    if num < 1 || num > 9 {
+        return Err(Error::WrongStep);
+    }   

這裡加好就可以了,看一下結果是正確的:

測試api會正確提示錯誤訊息

see clippy again

不過剛剛的num <1 || num > 9好像有先前遇過,可以用range的寫法,不太記得了,直接請clippy幫我們回憶一下:

clippy 提示使用range

既然都請出clippy了,我們順便修一下其他的部分:

軟體的修改成本越早越小,可以看一下測試左移的概念。

clippy建議要實作Default trait

這邊提到我們應該考慮對InMemoryTicTacToeService實作Default trait,咦可是我們不是已經有給new了嗎,先不要自以為是覺得很多餘跳過它,看一下這邊的help說什麼:

clippy對於new_without_default的說明

這裡有說為什麼不好,因為user(應該是指其他引用到我們程式的開發人員)會預期有一個無參數的建構式,因為new是我們自己建立的,到時候可能會改成有參數的版本,所以clippy會建議提供一個Default trait的,給定一個無參數的預設版本,因為rust並沒有支援function多載,也就是說你不能像其他語言一樣:

public void Hello();
public void Hello(string name);
public void Hello(int id);

那我們在這依照建議加一下Default trait的實作吧:

@@ service/src/tic_tac_toe.rs @@
+impl Default for InMemoryTicTacToeService {
+    fn default() -> Self {
+        Self {
+            games: Arc::new(Mutex::new(HashMap::new())),
+        }
+    }
+}

接下來又說 這裡的let game取得的是(unit value),請我們拿掉:

warring about let-binding has unit value

Why is this bad?

A unit value cannot usefully be used anywhere. So binding one is kind of pointless.

unit value就是指()這個0元素的tuple(?),因為?會把error外拋,所以在這邊的games只會拿到(),這樣指派好像沒什麼意義。我們直接拿掉就好了。

然後又發現了一個小bug (?),剛剛在delete的api太開心地把play copy下來改,忘了delete應該沒有回傳值,所以我們改掉:

+service.delete(id).map_err(AppError::from)?;
-let games = service.delete(id).map_err(AppError::from)?;
+Ok::<_, Rejection>(warp::reply())
-Ok::<_, Rejection>(warp::reply::json(&games))

delete api測試結果

測試結果回傳200 OK,好像哪裡怪怪的,沒內容應該要是204 No Content,再回去看一下warp的範例可以直接回傳如下:

@@ web/src/tic_tac_toe.rs @@
+use warp::http::StatusCode;
...
+Ok::<_, Rejection>(StatusCode::NO_CONTENT)
-Ok::<_, Rejection>(warp::reply())

最後我們回傳錯誤訊息的地方details並沒有型別轉換,不需要用到into()

複習一下,into是from的同義函數,我們實作 A From B 可以自動得到 B into A。
rust建議拿掉 .into

拿掉後:
rust建議使用物件shorthand

這邊又說同名可以省略,記得之前第3篇提到rust的物件shorthand嗎,這邊rust可以使用這個寫法改寫如下:

@@ web/src/error.rs @@
 let json = warp::reply::json(&AppErrorMessage {
     message: message.into(),
+    details,
-    details: details.into(),
 });

單元測試 Test

最後我們補一下測試完成REST API的章節吧,打了前兩行,副駕幫我生成後續的案例:

記得自己補run.shrun.ps1,或是喜歡自己打命令也可以 XDD

// web/src/tic_tac_toe.rs
#[cfg(test)]
mod tests {
    use super::*;
    use warp::http::StatusCode;
    use warp::test::request;
    use service::tic_tac_toe::InMemoryTicTacToeService;

    #[tokio::test]
    async fn test_games_get() {
        let service = InMemoryTicTacToeService::new();
        let (id, _) = service.new_game().unwrap();
        let api = games_get(service);

        let res = request()
            .method("GET")
            .path(&format!("/tic_tac_toe/{}", id))
            .reply(&api)
            .await;

        assert_eq!(res.status(), StatusCode::OK);
    }

    #[tokio::test]
    async fn test_games_create() {
        let service = InMemoryTicTacToeService::new();
        let api = games_create(service);

        let res = request()
            .method("POST")
            .path("/tic_tac_toe")
            .reply(&api)
            .await;

        assert_eq!(res.status(), StatusCode::OK);
    }

    #[tokio::test]
    async fn test_games_play() {
        let service = InMemoryTicTacToeService::new();
        let (id, _) = service.new_game().unwrap();
        let api = games_play(service);

        let res = request()
            .method("PUT")
            .path(&format!("/tic_tac_toe/{}/{}", id, 1))
            .reply(&api)
            .await;

        assert_eq!(res.status(), StatusCode::OK);
    }

    #[tokio::test]
    async fn test_games_delete() {
        let service = InMemoryTicTacToeService::new();
        let (id, _) = service.new_game().unwrap();
        let api = games_delete(service);

        let res = request()
            .method("DELETE")
            .path(&format!("/tic_tac_toe/{}", id))
            .reply(&api)
            .await;

        assert_eq!(res.status(), StatusCode::NO_CONTENT);
    }
}

但是卻出現了很奇怪的問題:
警示core裡找不到prelude

這裡的test要使用tokio是因為我們的測試fn是async的,所以請tokio來協助我們跑,就像之前的#[tokio::main]一樣,不過我們tokio引用的feature已經是full了,應該不會再缺東西了,他說無法在core裡找到prelude,prelude在rust裡有預載入模組的意思,這裡找不到,花了一點時間也google不到相關的錯誤訊息,啊,忽然想到,該不會是我們一開始命名的專案core跟它打架了吧!囧。

rust裡要怎麼處理同名crate,可以看Cargo Book的說明:

@@ web/Cargo.toml @@
+my-core = { path = "../core", package = "core" }
-core = { path = "../core" }

我們把前面rename成my-core,(在web裡要引入的命名空間),在補上我們在"../core"這裡要引入的package名字是core,如此即可,只是在web裡如果要使用到我們的core,namespace的路徑就都要改成my_core

let game = my_core::tic_tac_toe::Game::default();

記得把-_

單元測試結果

上面幾項happy path都通過了,我們試一下不happy的路徑會怎樣:再提供往下測試的一些方式:

// web/src/tic_tac_toe.rs
mod tests {
    use crate::error::handle_rejection;
    // ... 略
    #[tokio::test]
    async fn test_games_delete_not_found() {
        let service = InMemoryTicTacToeService::new();
        let api = games_delete(service)
            .recover(handle_rejection);   // 記得加上error handling

        let res = request()               // 沒加handle_rejection的話,
            .method("DELETE")             // 這裡的回應會走預設的500
            .path(&format!("/tic_tac_toe/{}", 12))
            .reply(&api)
            .await;

        assert_eq!(res.status(), StatusCode::NOT_FOUND); 
    }
    
    #[tokio::test]
    async fn test_games_play_non_empty() {
        let service = InMemoryTicTacToeService::new();
        let (id, _) = service.new_game().unwrap();
        let api = games_play(service.clone()).recover(handle_rejection);
        service.play(id, 1).unwrap();     // 模擬已下第一格

        let res = request()
            .method("PUT")
            .path(&format!("/tic_tac_toe/{}/{}", id, 1)) // 格號非空應報錯
            .reply(&api)
            .await;

        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
    }
}

如果想要測試response的body內容,則需要反序列化(deserialize)json檔,我們需要serde_json來幫我們處理:

@@ Cargo.toml @@
 serde = { version = "1.0", features = ["derive"] }
+serde_json = { version = "1.0"}
 thiserror = { version = "1.0" }

@@ web/Cargo.toml @@
 serde = { workspace = true }
+serde_json = { workspace = true }
 tokio = { workspace = true }

@@ core/src/tic_tac_toe.rs @@
+use serde::{Deserialize, Serialize};
-use serde::Serialize;
...
+#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
-#[derive(Copy, Clone, Debug, PartialEq, Serialize)]
 pub enum Symbol {
...
+#[derive(Debug, Clone, Serialize, Deserialize)]
-#[derive(Debug, Clone, Serialize)]
 pub struct Game {
// web/src/tic_tac_toe.rs 
mod test {
    /// ...略
    use my_core::tic_tac_toe::Game;

    async fn test_games_get() {
        // ...略 
        let body = res.into_body();    // 取得body的Bytes
        // 使用serde_json把字串反序列化為物件
        let game: Game = serde_json::from_slice(&body).unwrap();
        assert_eq!(game.is_over, false);
        assert_eq!(game.winner, None);
        let is_empty = game.cells.iter().all(|x| *x == None);
        assert_eq!(is_empty, true);
    }
}

測試本身也是一門藝術,我們先點到這邊,本篇我們完成了REST API,以及測試,下一篇可以開始進入前端了。

補充資料

Clippy設定

雖然clippy對我們的的代碼品質很有幫助,不過有時候有些情境可能要先空著,比如dead_code,或有人很堅持要不想改成建議的方式,是可以單獨設定關掉提示:在clippy的訊息會說是什麼原因造成,照著找那個項目加上allow的屬性就可以,比如剛剛的數字range,我們看原提示:

clippy 訊息

最下面有個 #[warn(clippy::manual_range_contains)] 表示這個規則檔被掃出來,我們在play方法上加以下:

+#[allow(clippy::manual_range_contains)]
 pub fn play(&mut self, num: usize) -> Result<(), Error> {

就可以讓clippy安靜了,另外如果想要做整個專案的設定,可以參考Clippy的設定檔文件

解決錯誤的補充

我覺得在學習中,常常會遇到不同的錯誤訊息,接著花很多時間處理,這裡順這個機會示範一下如何去找錯誤的方式,提供大家不同的思路。大家不要害怕程式噴出的錯誤訊息,往往我們的經驗就是從解bug中獲得的(?)。

我們看rust跑出來的結果很奇怪,而且很嚇人(?)

rust中warp無法套用and,因為未實作tarit限制

我們先看紅字的部分,And<And<And<...隱約可以猜出因為我們呼叫了很多次and,所以泛型trait的type也一直train在一起,在這邊至少我們可以推知我們傳的handle_games_get需要符合參數傳遞的要求。

再看下面一堆白字第一段最後面有寫到第21:30 是第21行第30個字,對照我們的程式碼是在:

.and(warp::any().map(move || service.clone())) // <= 第30字是 move開始

但知道這樣好像沒什麼幫助(?),再回到畫面中間藍字說不符合 Filter或FilterBase要求,上面有提到warp原始檔的位置,就再去看一下warp的原始檔說了什麼:

#[derive(Clone, Copy, Debug)]
pub struct And<T, U> {
    pub(super) first: T,
    pub(super) second: U,
}

impl<T, U> FilterBase for And<T, U>
where
    T: Filter,
    T::Extract: Send,
    U: Filter + Clone + Send,
    <T::Extract as Tuple>::HList: Combine<<U::Extract as Tuple>::HList> + Send,
    CombinedTuples<T::Extract, U::Extract>: Send,
    U::Error: CombineRejection<T::Error>,
{
    type Extract = CombinedTuples<T::Extract, U::Extract>;
    type Error = <U::Error as CombineRejection<T::Error>>::One;
    type Future = AndFuture<T, U>;

    fn filter(&self, _: Internal) -> Self::Future {
        AndFuture {
            state: State::First(self.first.filter(Internal), self.second.clone()),
        }
    }
}

可以看出 And<T, U> 的U類別,需要套用 Filter + Clone + Send,而我們參數裡的service只有clone並沒有send,所以把我們要傳遞的service加上 Send + Sync 試試

// service/src/tic_tac_toe.rs
pub trait TicTacToeService: Clone + Send + Sync {

或者是一開始把回到warp的todo範例,不要自作聰明想節省行數,就亂結合fn (?):

pub fn games_get(
    service: impl TicTacToeService
) -> impl Filter<Extract=(impl warp::Reply, ), Error=warp::Rejection> + Clone {
    warp::path!("tic_tac_toe" / usize)                 
        .and(warp::get())                              
        .with_service(service)     // <= 把move改這行
        .and_then(handle_games_get)
}

// 把範例的with_db改為with_service
fn with_service(service: impl TicTacToeService) -> impl Filter<Extract=(impl TicTacToeService, ), Error=std::convert::Infallible> + Clone {
    warp::any().map(move || service.clone())
}

這時候編譯器的提示就會清楚很多了:

需要實作Send trait

本系列專案源始碼放置於 https://github.com/kenstt/demo-app


上一篇
09 我的rust環境我決定 Example, Logger, Env
下一篇
11 使用 Svelte 復刻 井字遊戲 UI
系列文
前端? 後端? 摻在一起做成全端就好了30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言