GROWIのAPI使い方まとめ

# はじめに
最近になってGROWIというオープンソースのwikiツールを使い始めてAPI周りで躓くことがあったので調べて見た知見を共有させていただきます。

# GROWIとは
簡単に言うとチームやプロジェクトでの情報共有にめっちゃ便利なOSS（オープンソースソフトウェア）のWikiツールでMarkdown対応しているのでそれなりに整ったページがサクッと作れます！

情報整理とか、ドキュメントの管理が必要なときに役立つ感じ。
環境構築もDockerで簡単に作れるので導入ハードルも低めな印象です。

公式HPでデモ版が公開されているため、気になった方はまずはお試しで触ってみてもらえればと思います。

https://growi.org/ja/

https://demo.growi.org/

# GROWIのAPIの使い方
APIについてのドキュメントは公式が出してくれているのでそれを参照して動作確認していきます。
今回の環境は下記の通りです。

- 動作環境
  GROWI: 7.1.5
  GROWI REST API v3 (7.1.5-RC.0)
  Python: 3.12.0

https://docs.growi.org/en/api/rest-v3.html

ただ、下記の記事にもある通りドキュメントと実装に一部乖離があるようでした。
また、ドキュメント通りにAPIを叩いてもエラーが発生しました。（内容は後ほど触れます。）

https://zuglab.hateblo.jp/entry/2019/09/22/012450

今回APIで確認した機能は下記の4つの処理です。
・投稿されている記事のリストを取得：ドキュメントに記載はないが実装されているもの
・１つの記事の詳細情報取得：ドキュメント通りで動作確認OK
・新規記事の投稿：ドキュメント通りで動作確認OK
・記事の更新：一部パラメータの誤りを確認

# サンプルコード
今回、サンプルのコードはpythonで実装しました。
url、tokenはそれぞれの環境に合わせて読み替えてください。
(tokenはすでに使用していない環境のためそのまま掲載してます。)
「記事の更新」については公式のドキュメントの下記の点を修正して作成してます。
growi_api.py、api-test.py
`revision_id -> revisionId`

### ディレクトリ構造
```
./
├── api_test.py # メインプログラム
└── growi_api.py # GROWi_API処理
```

```python:api-test.py
import growi_api

# GrowiのURLとAPIトークン
url = 'http://192.168.0.204:3002'
access_token = 'W7Et17Q3KYcJ2iwVgDkCvQD+dhj4kLer6QbuPeF9BI0='
path = '/api-test' # 取得する記事のパス

# 全記事の取得
print('全記事の取得')
res = growi_api.get_page_list(url, access_token, path)
for data in res['pages']:
    print(data['_id'], data['path'])
print('\n---')

# 記事の作成
print('記事の作成')
path = '/api-test/python'
body = '''# Created by python-script'''
res = growi_api.create_page(url, access_token, path, body)
print(res)
print('\n---')

# 全記事の取得
print('作成した記事のID取得')
res = growi_api.get_page_list(url, access_token, path)
for data in res['pages']:
    if data['path'] == path:
        pageId = data['_id']
        print(data['_id'], data['path'])
print('\n---')

# 記事の詳細情報取得
print('記事の詳細情報取得')
res = growi_api.get_page_info(url, access_token, pageId)
print(res)
revisionId = res['page']['revision']['_id']
print(f'revisionId: {revisionId}')
print(res['page']['revision']['body'])
print('\n---')

# 記事の更新
print('記事の更新')
body = '''# Created by python-script\n# Updated by python-script'''
res = growi_api.update_page(url, access_token, pageId, revisionId, body)
print(res)
print('\n---')

# 記事の詳細情報取得(更新後の確認)
print('記事の詳細情報取得(更新後の確認)')
res = growi_api.get_page_info(url, access_token, pageId)
revisionId = res['page']['revision']['_id']
print(f'revisionId: {revisionId}')
print('---')
print(res['page']['revision']['body'])
print('\n---')
```

```python:growi_api.py
import requests
import json


class GrowiAPIError(Exception):
    """GrowiAPIのエラーを表す例外クラス"""
    def __init__(self, description):
        super().__init__()
        self.description = description
    
    def __repr__(self):
        return str(self.description)

    def __str__(self):
        return str(self.description)


def get_page_list(url: str, access_token: str, path: str) -> dict:
    """複数記事の取得
    Args:
        url: GrowiのURL
        access_token: APIトークン
        path: 取得する記事のパス
    Returns:
        res (dict): 複数記事の情報
    """
    api_endpoint = f'{url}/_api/v3/pages/list'

    params = {
        'access_token': access_token,
        'path': path,
    }

    res = requests.get(api_endpoint, params=params)
    print(f'response_code: {res.status_code}')

    if res.status_code == 200:
        return json.loads(res.text)
    else:
        raise GrowiAPIError(res.text)

def get_page_info(url: str, access_token: str, pageId: str) -> dict:
    """記事の詳細情報取得
    Args:
        url: GrowiのURL
        access_token: APIトークン
        pageId: 取得する記事のID
    Returns:
        dict: 記事の詳細情報
    """
    api_endpoint = f'{url}/_api/v3/page'

    params = {
        'access_token': access_token,
        'pageId': pageId,
    }

    res = requests.get(api_endpoint, params=params)
    print(f'response_code: {res.status_code}')

    if res.status_code == 200:
        return json.loads(res.text)
    else:
        raise GrowiAPIError(res.text)
    
def create_page(url: str, access_token: str, path: str, body: str) -> None:
    api_endpoint = f'{url}/_api/v3/page'
    params = {
        'access_token': access_token,
    }
    data = {
        'path': path,
        'body': body,
    }

    res = requests.post(api_endpoint, data=data, params=params)
    print(f'response_code: {res.status_code}')

    if res.status_code == 201:
        return json.loads(res.text)
    else:
        raise GrowiAPIError(res.text)


def update_page(url: str, access_token: str, pageId: str, revisionId: str ,body: str) -> None:
    api_endpoint = f'{url}/_api/v3/page'
    params = {
        'access_token': access_token,
    }
    # 公式ドキュメントだとrevision_idだがコード上はrevisionIdのため修正
    data = {
        'pageId': pageId,
        'revisionId': revisionId,
        'body': body,
    }

    res = requests.put(api_endpoint, data=data, params=params)
    print(f'response_code: {res.status_code}')

    if res.status_code == 201:
        return json.loads(res.text)
    else:
        raise GrowiAPIError(res.text)

```

### エラー結果(revision_idとした場合)
`message":"Posted param \"revisionId\" is outdated.`とあり、リビジョンIDが古いとのこと。
ただ、よく見るとrevisionIdとなってますね。
```
記事の更新
response_code: 409
rowi_api.GrowiAPIError: {"errors":[{"code":"conflict","args":{"returnLatestRevision":{"revisionId":"676d19529550ffd68eb7e651","revisionBody":"# Created by python-script","createdAt":"2024-12-26T08:52:34.509Z","user":{"_id":"676d13f99550ffd68eb7dbc9","isGravatarEnabled":false,"isEmailPublished":false,"lang":"ja_JP","status":2,"admin":false,"readOnly":false,"isInvitationEmailSended":false,"isQuestionnaireEnabled":true,"name":"growi-bot","username":"growi-bot","createdAt":"2024-12-26T08:29:45.721Z","updatedAt":"2024-12-26T08:37:33.202Z","__v":0,"lastLoginAt":"2024-12-26T08:29:45.739Z","imageUrlCached":"/images/icons/user.svg"}}},"message":"Posted param \"revisionId\" is outdated."}]}
```

### 修正後の実行結果
```
❯ python3 api_test.py
全記事の取得
response_code: 200

---
記事の作成
response_code: 201
{'page': {'parent': '676d16179550ffd68eb7e2ca', 'descendantCount': 0, 'isEmpty': False, 'status': 'published', 'grant': 1, 'grantedUsers': [], 'liker': [], 'seenUsers': [], 'commentCount': 0, '_id': '676d16179550ffd68eb7e2c5', 'grantedGroups': [], 'updatedAt': '2024-12-26T08:38:47.225Z', 'path': '/api-test/python', 'creator': {'_id': '676d13f99550ffd68eb7dbc9', 'isGravatarEnabled': False, 'isEmailPublished': False, 'lang': 'ja_JP', 'status': 2, 'admin': False, 'name': 'growi-bot', 'username': 'growi-bot', 'createdAt': '2024-12-26T08:29:45.721Z', 'lastLoginAt': '2024-12-26T08:29:45.739Z', 'imageUrlCached': '/images/icons/user.svg'}, 'lastUpdateUser': {'_id': '676d13f99550ffd68eb7dbc9', 'isGravatarEnabled': False, 'isEmailPublished': False, 'lang': 'ja_JP', 'status': 2, 'admin': False, 'name': 'growi-bot', 'username': 'growi-bot', 'createdAt': '2024-12-26T08:29:45.721Z', 'lastLoginAt': '2024-12-26T08:29:45.739Z', 'imageUrlCached': '/images/icons/user.svg'}, 'createdAt': '2024-12-26T08:38:47.216Z', '__v': 0, 'revision': '676d16179550ffd68eb7e2d2', 'latestRevisionBodyLength': 26, 'id': '676d16179550ffd68eb7e2c5'}, 'tags': [], 'revision': {'_id': '676d16179550ffd68eb7e2d2', 'format': 'markdown', 'pageId': '676d16179550ffd68eb7e2c5', 'body': '# Created by python-script', 'author': {'imageUrlCached': '/images/icons/user.svg', 'isGravatarEnabled': False, 'isEmailPublished': False, 'name': 'growi-bot', 'username': 'growi-bot', 'lang': 'ja_JP', 'status': 2, 'lastLoginAt': '2024-12-26T08:29:45.739Z', 'admin': False, 'readOnly': False, 'isInvitationEmailSended': False, 'isQuestionnaireEnabled': True, '_id': '676d13f99550ffd68eb7dbc9', 'createdAt': '2024-12-26T08:29:45.721Z'}, 'createdAt': '2024-12-26T08:38:47.223Z', '__v': 0}}

---
作成した記事のID取得
response_code: 200
676d16179550ffd68eb7e2c5 /api-test/python

---
記事の詳細情報取得
response_code: 200
{'page': {'_id': '676d16179550ffd68eb7e2c5', 'parent': '676d16179550ffd68eb7e2ca', 'descendantCount': 0, 'isEmpty': False, 'status': 'published', 'grant': 1, 'grantedUsers': [], 'liker': [], 'seenUsers': [], 'commentCount': 0, 'grantedGroups': [], 'updatedAt': '2024-12-26T08:38:47.225Z', 'path': '/api-test/python', 'creator': {'_id': '676d13f99550ffd68eb7dbc9', 'isGravatarEnabled': False, 'isEmailPublished': False, 'lang': 'ja_JP', 'status': 2, 'admin': False, 'name': 'growi-bot', 'username': 'growi-bot', 'email': 'growi-bot@aaa.com', 'createdAt': '2024-12-26T08:29:45.721Z', 'lastLoginAt': '2024-12-26T08:29:45.739Z', 'imageUrlCached': '/images/icons/user.svg'}, 'lastUpdateUser': {'_id': '676d13f99550ffd68eb7dbc9', 'isGravatarEnabled': False, 'isEmailPublished': False, 'lang': 'ja_JP', 'status': 2, 'admin': False, 'name': 'growi-bot', 'username': 'growi-bot', 'email': 'growi-bot@aaa.com', 'createdAt': '2024-12-26T08:29:45.721Z', 'lastLoginAt': '2024-12-26T08:29:45.739Z', 'imageUrlCached': '/images/icons/user.svg'}, 'createdAt': '2024-12-26T08:38:47.216Z', '__v': 0, 'latestRevisionBodyLength': 26, 'revision': {'_id': '676d16179550ffd68eb7e2d2', 'format': 'markdown', 'pageId': '676d16179550ffd68eb7e2c5', 'body': '# Created by python-script', 'author': {'_id': '676d13f99550ffd68eb7dbc9', 'isGravatarEnabled': False, 'isEmailPublished': False, 'lang': 'ja_JP', 'status': 2, 'admin': False, 'name': 'growi-bot', 'username': 'growi-bot', 'email': 'growi-bot@aaa.com', 'createdAt': '2024-12-26T08:29:45.721Z', 'lastLoginAt': '2024-12-26T08:29:45.739Z', 'imageUrlCached': '/images/icons/user.svg'}, 'createdAt': '2024-12-26T08:38:47.223Z', '__v': 0}, 'id': '676d16179550ffd68eb7e2c5'}}
revisionId: 676d16179550ffd68eb7e2d2
# Created by python-script

---
記事の更新
response_code: 201
{'page': {'_id': '676d16179550ffd68eb7e2c5', 'parent': '676d16179550ffd68eb7e2ca', 'descendantCount': 0, 'isEmpty': False, 'status': 'published', 'grant': 1, 'grantedUsers': [], 'liker': [], 'seenUsers': [], 'commentCount': 0, 'grantedGroups': [], 'updatedAt': '2024-12-26T08:38:47.499Z', 'path': '/api-test/python', 'creator': {'_id': '676d13f99550ffd68eb7dbc9', 'isGravatarEnabled': False, 'isEmailPublished': False, 'lang': 'ja_JP', 'status': 2, 'admin': False, 'name': 'growi-bot', 'username': 'growi-bot', 'createdAt': '2024-12-26T08:29:45.721Z', 'lastLoginAt': '2024-12-26T08:29:45.739Z', 'imageUrlCached': '/images/icons/user.svg'}, 'lastUpdateUser': {'_id': '676d13f99550ffd68eb7dbc9', 'isGravatarEnabled': False, 'isEmailPublished': False, 'lang': 'ja_JP', 'status': 2, 'admin': False, 'name': 'growi-bot', 'username': 'growi-bot', 'createdAt': '2024-12-26T08:29:45.721Z', 'lastLoginAt': '2024-12-26T08:29:45.739Z', 'imageUrlCached': '/images/icons/user.svg'}, 'createdAt': '2024-12-26T08:38:47.216Z', '__v': 0, 'latestRevisionBodyLength': 53, 'revision': '676d16179550ffd68eb7e317', 'id': '676d16179550ffd68eb7e2c5'}, 'revision': {'_id': '676d16179550ffd68eb7e317', 'format': 'markdown', 'pageId': '676d16179550ffd68eb7e2c5', 'body': '# Created by python-script\n# Updated by python-script', 'author': {'imageUrlCached': '/images/icons/user.svg', 'isGravatarEnabled': False, 'isEmailPublished': False, 'name': 'growi-bot', 'username': 'growi-bot', 'lang': 'ja_JP', 'status': 2, 'lastLoginAt': '2024-12-26T08:29:45.739Z', 'admin': False, 'readOnly': False, 'isInvitationEmailSended': False, 'isQuestionnaireEnabled': True, '_id': '676d13f99550ffd68eb7dbc9', 'createdAt': '2024-12-26T08:29:45.721Z'}, 'hasDiffToPrev': True, 'createdAt': '2024-12-26T08:38:47.496Z', '__v': 0}}

---
記事の詳細情報取得(更新後の確認)
response_code: 200
revisionId: 676d16179550ffd68eb7e317
---
# Created by python-script
# Updated by python-script

```

### 画面キャプチャ
WEBでも記事が作成されたことを確認できました！
![スクリーンショット 2024-12-26 17.47.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187797/b0dd05ff-eb2b-88f0-0e95-2b1cce523eda.png)


# 実装の確認
updatePageのAPIはGROWIのコードのどのファイルに実装されているかについてはこのへん見ればわかるかなと思います。

https://github.com/weseek/growi/blob/master/apps/app/src/server/routes/apiv3/page/update-page.ts

validationチェックはこのへん
```typescript:update-page.ts
  // define validators for req.body
  const validator: ValidationChain[] = [
    body('pageId').exists().not().isEmpty({ ignore_whitespace: true })
      .withMessage("'pageId' must be specified"),
    body('revisionId').optional().exists().not()
      .isEmpty({ ignore_whitespace: true })
      .withMessage("'revisionId' must be specified"),
```

検知したエラーメッセージはこのへん
```typescript:update-page.ts
      if (currentPage != null && !await currentPage.isUpdatable(sanitizeRevisionId, origin)) {
        const latestRevision = await Revision.findById(currentPage.revision).populate('author');
        const returnLatestRevision = {
          revisionId: latestRevision?._id.toString(),
          revisionBody: latestRevision?.body,
          createdAt: latestRevision?.createdAt,
          user: serializeUserSecurely(latestRevision?.author),
        };
        return res.apiv3Err(new ErrorV3('Posted param "revisionId" is outdated.', PageUpdateErrorCode.CONFLICT, undefined, { returnLatestRevision }), 409);
      }
```

投稿されている記事のリスト取得はこちら

https://github.com/weseek/growi/blob/master/apps/app/src/server/routes/apiv3/pages/index.js#L426

```typescript:index.js
  router.get('/list', accessTokenParser, loginRequired, validator.displayList, apiV3FormValidator, async(req, res) => {

    const { path } = req.query;
    const limit = parseInt(req.query.limit) || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationS') || 10;
    const page = req.query.page || 1;
    const offset = (page - 1) * limit;

```