Rustで学術論文からテキストを抽出する #21 応用編- arXiv論文収集システム構築⑨ Notion APIラッパーの実装 (1)

:::note
## Summary
- NotionのAPIラッパーの実装方針を検討し，一部のAPIを実装しました
    - NotionはAPIが充実しており，ラッパーの実装も重厚になるため，arXivのチェインパターンとSemantic Scholarのエンドポイントパターンを組み合わせた実装方針を採用しました
    - GitHub Copilotがないと，もうコーディングできない身体になってしまった

| crate | GitHub |
|:-----:|:------:|
|[rsrpp](https://crates.io/crates/rsrpp)|[rsrpp](https://github.com/akitenkrad/rsrpp)
|[rsrpp-cli](https://crates.io/crates/rsrpp-cli)|[rsrpp](https://github.com/akitenkrad/rsrpp)
|[arxiv-tools](https://crates.io/crates/arxiv-tools)|[rs-arxiv-tools](https://github.com/akitenkrad/rs-arxiv-tools)|
|[ss-tools](https://crates.io/crates/ss-tools)|[rs-ss-tools](https://github.com/akitenkrad/rs-ss-tools)|
|[keyword-tools](https://crates.io/crates/keyword-tools)|[keywords](https://github.com/akitenkrad/keywords)|
|[openai-tools](https://crates.io/crates/openai-tools)|[rs-openai-tools](https://github.com/akitenkrad/rs-openai-tools)|

:::

## 前回までのあらすじ

[前回：Rustで学術論文からテキストを抽出する #20](https://qiita.com/akitenkrad/items/59051807e2781166e763)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933655/b3da3727-9a61-2b67-bea8-ba75c7bb4fac.png)

⑤論文要約まで一区切りついたので，今回から最後のパーツであるNotion周りを実装していきます．

## 今回実装する機能

今回から⑥のNotionへのアップロードを実装していきます．
NotionはAPIが充実していて，その分実装が重たいので，2回に分けて必要な機能を実装していこうと思います．

今回のシステムに必要な機能は，NotionのデータベースのCreate，Read，Update，Delete (CRUD) です．
また，Notionではデータベースの1レコードにページが紐づいており，さらにページは複数のブロックから成っているので，一部PagesやBlocksのCRUDも実装します．

Notionでは，arXivやSemantic Scholarと比較すると機能ごとのエンドポイントの数が多く，またやりとりするデータの構造も複雑です．

NotionのAPI Referenceは[こちら](https://developers.notion.com/reference/intro)．
エンドポイントと今回の実装対象はこちら．

| 機能  | Method| Endpoint | 実装要否 |
|-|:-:|-|:-:|
|Create a token |Post|/oauth/token| - |
|Append block children |Patch|/blocks/{block_id}/children|○|
|Retrieve a block |Get|/blocks/{block_id}|○|
|Retrieve block children |Get|/blocks/{block_id}/children|○|
|Update a block |Patch|/blocks/{block_id}|○|
|Delete a block |Delete|blocks/{block_id}|○|
|Create a page |Post|/pages|○|
|Retrieve a page |Get|/pages/{page_id}|○|
|Retrieve a page property item |Get|/pages/{page_id}/properties/{property_id}|○|
|Update page properties |Patch|/pages/{page_id}|○|
|Create a database |Post|/databases|-|
|Query a database|Post|/databases/{database_id}/query|○|
|Retrieve a database|Get|/databases/{database_id}|○|
|Update a database |Patch|/databases/{database_id}|○|
|List all users |Get|/users|-|
|Retrieve a user |Get|/users/{user_id}|-|
|Retrieve your token's bot user|Get|/users/me|-|
|Create comment |Post|/comments|-|
|Retrieve comments |Get|/comments|-|
|Search by title |Post|/search|-|

やはり，Blocks，Pages，Database周りは大体実装することになりそうです．
実装する機能が多いので，今回は大まかな方針の検討とDatabaseのQueryを例にとって，設計がうまく回りそうかどうか確認します．
次回で残りを実装し，動作確認を行います．

## 実装方針の検討

Notionでは，ページやブロックなどを構成する要素 (テキストやURL，チェックボックスなど) をAPIで割と細かく制御できるようになっています．
そのため，arXivのときに実装したようなシンプルなチェイン方式では鎖が長くなりすぎてしまい，一方Semantic Scholarのようにエンドポイントごとに実装をまとめようとすると，これまたクエリのための設定が複雑になりすぎてしまう可能性が高いです．

```rust: NGパターン① チェインタイプ
// 鎖が長くなりすぎて実装しづらい
let notion = Notion::new();
notion
    .checkbox()
    .equals(XXX)
    .and()
    .rich_text(XXXX)
    .is_empty()
    .or()
    ...
    ...
```

```rust: NGパターン② エンドポイントタイプ
// エンドポイントに全て突っ込むので，ネストが深くなりすぎで困る
let notion = Notion::new();
notion.query_database(
    DATABASE_ID,
    DatabaseFilter::new(
        CheckboxFilter::new(
            
        ),
        RichtextFilter::new(
            Text::new(
                ...
                ...
            )
        )
    )
)
```

そこで，今回はNotion APIラッパーの実装にあたっては，エンドポイントの数が多いことからチェインは向かないので，基本的にエンドポイント型を採用しつつ，PagesやBlocksなどNotionのパーツを切り分けることでモジュールを分割します．
よって，Queryの場合は検索条件になるFilterを別モジュールに分割し，コードの見通しをよくする戦略でいこうと思います．

完成系のイメージは以下のような感じ．

```rust: Notion APIラッパーのイメージ
// 検索条件を構築
let mut filter = DatabaseFilter::new();
filter.or(vec![
    CheckboxFilter::new(),
    DateFilter::new(),
    and(vec![
        SelectFilter::new(),
        RichTextFilter::new(),
    ])
]);
let filter = filter.build();

// 検索を実行
let notion = Notion::new();
let response = notion.query_database(
    DATABASE_ID,
    filter
);
```

理想的...とは言えませんが，多少マシになったかなと思っています．

## Query a databaseの実装

では，試しにDatabaseをクエリするAPIを実装してみます．

[API Reference](https://developers.notion.com/reference/post-database-query)によれば，curlの場合は以下のようになります．

```bash
curl -X POST 'https://api.notion.com/v1/databases/897e5a76ae524b489fdfe71f5945d1af/query' \
  -H 'Authorization: Bearer '"$NOTION_API_KEY"'' \
  -H 'Notion-Version: 2022-06-28' \
  -H "Content-Type: application/json" \
--data '{
    "filter": {
        "or": [
            {
                "property": "In stock",
                "checkbox": {
                    "equals": true
                }
            },
            {
                "property": "Cost of next trip",
                "number": {
                    "greater_than_or_equal_to": 2
                }
            }
        ]
    },
    "sorts": [
        {
            "property": "Last ordered",
            "direction": "ascending"
        }
    ]
}'
```

URLやヘッダ部分に関しては特に問題になりそうなところはないので，bodyのJSON構築に集中すれば良さそうです．

まずは，クエリ本体．

```rust
pub struct Notion {
    pub api_key: String,
    pub database_id: String,
}

impl Notion {
    pub fn new() -> Self {
        dotenv().ok();
        let api_key = std::env::var("NOTION_API_KEY").expect("NOTION_API_KEY must be set");
        let database_id = std::env::var("NOTION_DATABASE_ID").unwrap_or("".to_string());

        Notion {
            api_key,
            database_id,
        }
    }

    pub async fn query_database(&self, filter: DatabaseFilter) -> Result<Vec<Record>> {
        let url = format!("https://api.notion.com/v1/databases/{}/query", self.database_id);
        let body = serde_json::to_string(&filter).unwrap()
        let client = request::Client::new();
        let content = client
            .post(&url)
            .header("Content-Type", "application/json")
            .header("Authorization", format!("Bearer {}", self.api_key))
            .header("Notion-Version", "2022-06-28")
            .body(body)
            .send()
            .await?
            .text()
            .await?;
        let response = serde_json::from_str::<Response>(&content).unwrap()
        return Ok(response.records);
    }
}
```

Filterの構築を外部に切り出しているので，ここはシンプルにまとまります．

さて，問題のフィルター部分の実装ですが，予想通り割と長いコードになりました．と言ってもCopilotのおかげでだいぶコーディング量は少なくて済みましたが．

Checkboxのサンプルだけ載せておくと，以下のようなコードになりました．

```rust
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CheckboxFilterItem {
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub equals: Option<bool>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub does_not_equal: Option<bool>,
}

impl CheckboxFilterItem {
    pub fn equals(&mut self) -> CheckboxFilterItem {
        self.equals = Some(true);
        self.clone()
    }

    pub fn does_not_equal(&mut self) -> CheckboxFilterItem {
        self.does_not_equal = Some(true);
        self.clone()
    }
}
```

次のように使う想定です．

```rust
// Checkboxのフィルター条件
let checkbox = CheckboxFilterItem::default().equals()
```

これを，フィルタのタイプ数分，14種類実装します．全体で700行くらいのコードになりました．GitHub Copilot様様です．

- checkbox
- date
- files
- formula
- multi_select
- number
- people
- phone_number
- relation
- rich_text
- select
- status
- timestamp
- ID

最後に，検索条件をまとめて扱うための構造体を定義して，Filterの実装は完了です．
検索条件は指定したものだけをJSONに落としたいので，基本的に`Option<>`つきで定義します．
最後に `build()`することで構築した検索条件をJSONのテキストに変換できる仕組みとしました．

```rust
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DatabaseFilterItems {
    #[serde(default = "String::new", skip_serializing_if = "String::is_empty")]
    pub property: String,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub and: Option<Vec<DatabaseFilterItems>>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub or: Option<Vec<DatabaseFilterItems>>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub checkbox: Option<CheckboxFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub date: Option<DateFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub files: Option<FilesFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub formula: Option<FormulaFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub multi_select: Option<MultiSelectFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub number: Option<NumberFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub people: Option<PeopleFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub relation: Option<RelationFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub rich_text: Option<RichTextFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub select: Option<SelectFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub status: Option<StatusFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub timestamp: Option<TimestampFilterItem>,
    #[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
    pub id: Option<IdFilterItem>,
}

impl DatabaseFilterItems {
    pub fn and(items: Vec<DatabaseFilterItems>) -> Self {
        DatabaseFilterItems {
            and: Some(items),
            ..Default::default()
        }
    }

    pub fn or(items: Vec<DatabaseFilterItems>) -> Self {
        DatabaseFilterItems {
            or: Some(items),
            ..Default::default()
        }
    }

    pub fn checkbox(property: &str, item: CheckboxFilterItem) -> Self {
        DatabaseFilterItems {
            property: property.to_string(),
            checkbox: Some(item),
            ..Default::default()
        }
    }

    pub fn date(property: &str, item: DateFilterItem) -> Self {
        DatabaseFilterItems {
            property: property.to_string(),
            date: Some(item),
            ..Default::default()
        }
    }
    ...
    ...
}

pub struct DatabaseFilter {
    pub args: DatabaseFilterItems,
}

impl DatabaseFilter {
    pub fn new() -> Self {
        DatabaseFilter {
            args: DatabaseFilterItems::default(),
        }
    }

    pub fn and(&mut self, items: Vec<DatabaseFilterItems>) {
        self.args.and = Some(items);
    }

    pub fn or(&mut self, items: Vec<DatabaseFilterItems>) {
        self.args.or = Some(items);
    }

    pub fn build(&self) -> String {
        serde_json::to_string(&self.args).unwrap()
    }
}
```

APIが充実している分，なかなか重厚な作りになってしまいましたが，今後RustでNotionを操作するときには役に立つクレートになりそうな予感がします．

ということで，実装方針はこちらでなんとかなりそうなので，次回まとめて残りのAPIを実装し，動作確認を行います．

## 次回

Notion APIのラッパーを完成させます．

[次回：Rustで学術論文からテキストを抽出する #22](https://qiita.com/akitenkrad/items/8af7bb934ca9b75400d2)