📘 hkob-astro-notion-blog

これまではてなブログにて情報発信をしていましたが、令和5年3月22日より、こちらでの情報発信を始めました。2019年以前の古い記事は過去の Middleman 時代のものなので、情報が古いです。記録のためだけに残しています。

NotionRubyMapping v0.8.2 更新記録

1. はじめに

Notion API の ChangeLog が更新され、ブロックがページなどの途中にも設定できるようになりました。append_block_children に after オプションが付いたということです。また、public_url も取得できるようになったようなので、そちらも対応します。右側のツイートにも書きましたが、オプションを設定できるようにするだけでなく、append_after というメソッドも追加したいと思います。

単に実装しようかと思ったのですが、最近更新頻度が少なくて作業手順を忘れそうになっています。そんなわけでこんなツイートをしてみました。こうやって自分を追い込む形。

2. テストデータ作成

とにかくテストを書かないと実装できないので、まずはテストデータを作ります。テストデータ置き場にこんなコールアウトを作りました。この First block と Last block の間に Middle block を作るテストを書きましょう。

これらのブロックの ID を記録しておきます。

  • Callout block: 03f6460c26734af484b95de15082d84e
  • Numbered list item block (First): 263f125b179e4e4f996a1eff812d9d3d
  • Numbered list item block (Last): 28b314c5d2b54df8876095b67a673d69 (多分使わない)

まず、NotionRubyMapping を読み込んでインテグレーションキーをつなげておきます。

require "notion_ruby_mapping"
include NotionRubyMapping
NotionRubyMapping.configure { |c| c.token = ENV["NOTION_API_KEY"] }

Callout block を取得し、これまでの append_block_children で登録するスクリプトを作成してみます。

# callout block を取得
cblock = Block.find "03f6460c26734af484b95de15082d84e"
# 追加する Numbered list item を作成
add_block = NumberedListItemBlock.new "Middle block"
# append_block_children のスクリプトを表示
print cblock.append_block_children(add_block, dry_run: true)

結果は以下のようになりました(astro-notion-blog はコードの折り返しができないので、適当なところで改行しています)。

#!/bin/sh
curl -X PATCH 'https://api.notion.com/v1/blocks/03f6460c26734af484b95de15082d84e/children' \
  -H 'Notion-Version: 2022-06-28' \
  -H 'Authorization: Bearer '"$NOTION_API_KEY"'' \
  -H 'Content-Type: application/json' \
  --data '{"children":[{"type":"numbered_list_item","object":"block",
"numbered_list_item":{"rich_text":[{"type":"text",
"text":{"content":"Middle block","link":null},"plain_text":"Middle block",
"href":null}],"color":"default"}}]}'=> nil

spec/fixtures のフォルダに append_block_children_append_after.sh というスクリプトを作成します。

cd spec/fixtures
touch append_block_children_append_after.sh

このファイルの中身は以下のようになります。今回アップデートされた append_after オプションを追加しただけです。ChangeLog のテキストが間違えているので注意です。 "after" にも引用記号が必要でしたね。

#!/bin/sh
curl -X PATCH 'https://api.notion.com/v1/blocks/03f6460c26734af484b95de15082d84e/children' \
  -H 'Notion-Version: 2022-06-28' \
  -H 'Authorization: Bearer '"$NOTION_API_KEY"'' \
  -H 'Content-Type: application/json' \
  --data '{"children":[{"type":"numbered_list_item","object":"block",
"numbered_list_item":{"rich_text":[{"type":"text",
"text":{"content":"Middle block","link":null},"plain_text":"Middle block",
"href":null}],"color":"default"}}], 
"after": "263f125b179e4e4f996a1eff812d9d3d"}'

このフォルダには次のような Makefile を用意しています。フォルダ内の更新された shell script があったら自動的に実行して、API からの返り値を JSON ファイルとして保存するものです。

TARGETS=$(patsubst %.sh,%.json,$(SOURCES))
SOURCES=$(wildcard *.sh)

.SUFFIXES: .sh .json

.sh.json:
	sh $? > $@
	sleep 1

exec: $(TARGETS)

実行するとこんな感じになります。

touch *.json; make
sh append_block_children_append_after.sh > append_block_children_append_after.json
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  1794    0  1531  100   263   1855    318 --:--:-- --:--:-- --:--:--  2182
sleep 1

取得した JSON は以下のようになりました。追加した Middle block だけでなく、その後ろの Last block も返却されるようです。

{
  "object": "list",
  "results": [
    {
      "object": "block",
      "id": "67c0e9bd-bbe4-456c-8731-27763d7fa580",
      "parent": {
        "type": "block_id",
        "block_id": "03f6460c-2673-4af4-84b9-5de15082d84e"
      },
      "created_time": "2023-07-12T12:20:00.000Z",
      "last_edited_time": "2023-07-12T12:20:00.000Z",
      "created_by": {
        "object": "user",
        "id": "40673a87-d8ed-41e0-aa55-7f0e8ace24cd"
      },
      "last_edited_by": {
        "object": "user",
        "id": "40673a87-d8ed-41e0-aa55-7f0e8ace24cd"
      },
      "has_children": false,
      "archived": false,
      "type": "numbered_list_item",
      "numbered_list_item": {
        "rich_text": [
          {
            "type": "text",
            "text": {
              "content": "Middle block",
              "link": null
            },
            "annotations": {
              "bold": false,
              "italic": false,
              "strikethrough": false,
              "underline": false,
              "code": false,
              "color": "default"
            },
            "plain_text": "Middle block",
            "href": null
          }
        ],
        "color": "default"
      }
    },
    {
      "object": "block",
      "id": "28b314c5-d2b5-4df8-8760-95b67a673d69",
      "parent": {
        "type": "block_id",
        "block_id": "03f6460c-2673-4af4-84b9-5de15082d84e"
      },
      "created_time": "2023-07-12T09:44:00.000Z",
      "last_edited_time": "2023-07-12T09:44:00.000Z",
      "created_by": {
        "object": "user",
        "id": "2200a911-6a96-44bb-bd38-6bfb1e01b9f6"
      },
      "last_edited_by": {
        "object": "user",
        "id": "2200a911-6a96-44bb-bd38-6bfb1e01b9f6"
      },
      "has_children": false,
      "archived": false,
      "type": "numbered_list_item",
      "numbered_list_item": {
        "rich_text": [
          {
            "type": "text",
            "text": {
              "content": "Last block",
              "link": null
            },
            "annotations": {
              "bold": false,
              "italic": false,
              "strikethrough": false,
              "underline": false,
              "code": false,
              "color": "default"
            },
            "plain_text": "Last block",
            "href": null
          }
        ],
        "color": "default"
      }
    }
  ],
  "next_cursor": null,
  "has_more": false,
  "type": "block",
  "block": {}
}

テストページを見るとちゃんと追加されていました。

3. Notion API 代替 stub の作成

spec_helper.rb 内の TestConnection クラスにブロックIDの定数を定義します。上で記録した Callout block と First の Numbered list item block の ID です。以下のような名前にしました。

    APPEND_AFTER_PARENT_ID = "03f6460c26734af484b95de15082d84e"
    APPEND_AFTER_PREVIOUS_ID = "263f125b179e4e4f996a1eff812d9d3d"

次に generate_stubs メソッドに append_after メソッドを追加します。

    def generate_stubs
      WebMock.enable!
      retrieve_page
      retrieve_database
      retrieve_block
      query_database
      update_page
      create_page
      create_database
      update_database
      retrieve_block_children
      append_block_children_page
      append_block_children_block
      destroy_block
      update_block
      retrieve_property
      retrieve_comments
      append_comment
      retrieve_user
      retrieve_users
      search
      append_after
    end

今回の API 呼び出しの stub を作るメソッド append_after は以下のようになります。実際には、すでに作成ずみの stub 作成サブ関数を呼んでいるだけです。このメソッドを呼び出すと、NotionCache インスタンスの append_block_children_block_path メソッドが APPEND_AFTER_PARENT_ID という引数と {} 以下の Payload でアクセスされたときの API 呼び出しを横取りする stub を作成します。この時、Status 200 を返し、かつ append_block_childrenappend_after を連結した append_block_children_append_after.json のファイルの中身を返すようになっています。これによってテストの際に余計な Notion API へのアクセスをしないようにしています。

    def append_after
      generate_stubs_sub :patch, :append_block_children, :append_block_children_block_path, {
        append_after: [
          APPEND_AFTER_PARENT_ID, 200,
          {
            "children" => [
              {
                "type" => "numbered_list_item",
                "object" => "block",
                "numbered_list_item" => {
                  "rich_text" => [
                    {
                      "type" =>"text",
                      "text" => {
                        "content" => "Middle block",
                        "link" => nil
                      },
                      "plain_text" => "Middle block",
                      "href" => nil
                    }
                  ],
                  "color" => "default"
                }
              }
            ],
            "after" => "263f125b179e4e4f996a1eff812d9d3d"
          }
        ]
      }
    end

4. テストの記述と実装 (after オプション, dry_run)

block_spec.rb に今回のテストを追加します。最初は after オプションを追加した場合のテストです

様々なブロックに対する page_id, block_id への append_block_children のテストは shared_example 化して簡略化していますが、今回は単発テストなので、shared_example の中身を取り出して改変しました。一気にテストを書くとエラーだらけになり気持ちがめげるので、最初に dry_run で出力される shell スクリプトが正しいかどうかのテストだけを実施してみます。

  describe "append block children with after" do
      parent_id = TestConnection::APPEND_AFTER_PARENT_ID
      previous_id = TestConnection::APPEND_AFTER_PREVIOUS_ID
      append_block = NumberedListItemBlock.new "Middle block"
      let(:parent_block) { Block.find parent_id }
      let(:previous_block) { Block.find previous_id }

      context "after option" do
        context "dry_run" do
          let(:dry_run) { parent_block.append_block_children append_block, after: previous_id, dry_run: true }
          it_behaves_like :dry_run, :patch, :append_block_children_page_path, id: parent_id,
                          json: {
                            "children" => [append_block.block_json],
                            "after" => previous_id,
                          }
        end
        # context "create" do
        #   let(:block) { parent_block.append_block_children append_block, after: previous_id }
        #   it { expect(block.id).to eq TestConnection::APPEND_AFTER_ADDED_ID }
        # end
      end

保存すると Guard によりテストが自動実行します。想定外のところでエラーになりました。 Block.find parent_id のところで Notion API 呼び出しがかかってしまっていました。stub を作っていないので当たり前でした。その下の previous_block の読み込みと同時に stubs を作っておきます。

WebMock::NetConnectNotAllowedError:
       Real HTTP connections are disabled. Unregistered request: GET https://api.notion.com/v1/blocks/03f6460c26734af484b95de15082d84e with headers {'Accept'=>'*/*', 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Authorization'=>'Bearer secret_J08RBf9SlofiMhuIeJZc6AnI5qHXwDOaq4RolJFaaZ0', 'Notion-Version'=>'2022-06-28', 'User-Agent'=>'Faraday v1.10.3'}

stubs 用の JSON 取得用の二つのスクリプトを作成しておきます。

  1. retrieve_block_append_after_parent.sh
    curl 'https://api.notion.com/v1/blocks/03f6460c26734af484b95de15082d84e' \
      -H 'Authorization: Bearer '"$NOTION_API_KEY"'' \
      -H 'Notion-Version: 2022-06-28'
  2. retrieve_block_append_after_previous.sh
    curl 'https://api.notion.com/v1/blocks/263f125b179e4e4f996a1eff812d9d3d' \
      -H 'Authorization: Bearer '"$NOTION_API_KEY"'' \
      -H 'Notion-Version: 2022-06-28'

make すると JSON が作成されました。

make
sh retrieve_block_append_after_previous.sh > retrieve_block_append_after_previous.json
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   719  100   719    0     0    516      0  0:00:01  0:00:01 --:--:--   516
sleep 1
sh retrieve_block_append_after_parent.sh > retrieve_block_append_after_parent.json
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   769  100   769    0     0   3680      0 --:--:-- --:--:-- --:--:--  3733

retrive_block の stubs 設定ハッシュにこれらの関連を追記しておきます。これで二つのブロックのNotion API 読み込みが stub 化されました。

BLOCK_ID_HASH = {
      append_after_parent: APPEND_AFTER_PARENT_ID,
      append_after_previous: APPEND_AFTER_PREVIOUS_ID,
      bookmark: "aa1c25bcb1724a36898d3ce5e1f572b7",
      breadcrumb: "6d2ed6f38f744e838766747b6d7995f6",

Notion API 呼び出しは回避したものの、別のところで以下のようなエラーが出ました。先にこちらを解決します。BLOCK_ID_HASH に二つのブロックを入れてしまったので、一括テストのところで have children のチェックに失敗してしまっているようです。Numbered list block は子供を持てるのでそこでエラーになっているようです。

1) NotionRubyMapping::Block For append_after_parent block can append_after_parent have children? = false
     Failure/Error: expect(target.can_have_children).to eq can_have_children
     
       expected: false
            got: true
     
       (compared using ==)
     
       Diff:
       @@ -1 +1 @@
       -false
       +true
       
     # ./spec/notion_ruby_mapping/blocks/block_spec.rb:21:in `block (4 levels) in <module:NotionRubyMapping>'

  2) NotionRubyMapping::Block For append_after_previous block can append_after_previous have children? = false
     Failure/Error: expect(target.can_have_children).to eq can_have_children
     
       expected: false
            got: true
     
       (compared using ==)
     
       Diff:
       @@ -1 +1 @@
       -false
       +true
       
     # ./spec/notion_ruby_mapping/blocks/block_spec.rb:21:in `block (4 levels) in <module:NotionRubyMapping>'

該当するテストはこちらでした。can_have_children の正解に append_after_parent と append_after_previous も入れておきます。

TestConnection::BLOCK_ID_HASH.each do |key, id|
      describe "For #{key} block" do
        let(:target) { Block.find id }
        it "receive id" do
          expect(target.id).to eq nc.hex_id(id)
        end

        can_have_children = %i[bulleted_list_item paragraph inline_contents numbered_list_item synced_block template
                               toggle toggle_heading_1 toggle_heading_2 toggle_heading_3 quote table to_do
                               synced_block_original callout column_list column append_after_parent
                               append_after_previous].include? key
        it "can #{key} have children? = #{can_have_children}" do
          expect(target.can_have_children).to eq can_have_children
        end
      end
    end

残るは今回の本題になります。今回追加した after というキーワードは知らないと言われました。うまくテストできているようです。早速実装しましょう。

1) NotionRubyMapping::Block append block children with after after option dry_run behaves like dry_run 
     Failure/Error:
           def append_block_children(*blocks, dry_run: false)
             raise StandardError, "This block can have no children." unless page? || (block? && can_have_children)
       
             only_one = blocks.length == 1
             json = {
               "children" => Array(blocks).map do |block|
                 assert_parent_children_pair block
                 block.block_json
               end,
             }
     
     ArgumentError:
       unknown keyword: :after

append_block_children の部分で Cmd-b とすると block.rb の親クラスである base.rb の append_block_children が開きました。append_block_children は page.rb でも動作するので、親クラスである base.rb に定義されていたのでした。とりあえずキーワードだけ定義してみます。

def append_block_children(*blocks, after: nil, dry_run: false)

キーワードのエラーがなくなったので、結果の比較ができています。after キーワードがついていることを期待していますが、当然ついていないので比較に失敗します。

       expected: "#!/bin/sh\ncurl (中略) \"color\":\"default\"}}],\"after\":\"263f125b179e4e4f996a1eff812d9d3d\"}'"
            got: "#!/bin/sh\ncurl (中略)\"color\":\"default\"}}]}'"
     
       (compared using ==)

これを通すために json 作成部に最後の一行だけ追記します。実装の変更は実質2行だけですね。

      json = {
        "children" => Array(blocks).map do |block|
          assert_parent_children_pair block
          block.block_json
        end,
      }
      json["after"] = after if after

この変更により無事にテストが通過しました。

append block children with after
    after option
      dry_run
        behaves like dry_run
          is expected to eq "#!/bin/sh\ncurl (中略)"

先ほどコメントアウトしていた Notion API 呼び出しが含む実際の作成処理もテストしましょう。

        context "create" do
          let(:block) { parent_block.append_block_children append_block, after: previous_id }
          it { expect(block.id).to eq TestConnection::APPEND_AFTER_ADDED_ID }
        end

こちらは JSON が変更されているので問題なくテストが通過します。

append block children with after
    after option
      dry_run
        behaves like dry_run
          is expected to eq "#!/bin/sh\ncurl (中略)"
      create
        is expected to eq "67c0e9bdbbe4456c873127763d7fa580"

5. テストの記述と実装 (append_after メソッド)

Notion API の仕様であればこれで終了なのですが、Ruby 使いであれば previous_block に対して append_after というメソッドを作りたいところです。検証する項目は全く同じですので、DRY 原則に従って、shared_example にしてしまいましょう。先ほどのメソッドの dry_run と block の let が抜けただけのものです。

      shared_examples "append_block_children_append_after" do
        context "dry_run" do
          it_behaves_like :dry_run, :patch, :append_block_children_page_path, id: parent_id,
                          json: {
                            "children" => [append_block.block_json],
                            "after" => previous_id,
                          }
        end

        context "create" do
          it { expect(block.id).to eq TestConnection::APPEND_AFTER_ADDED_ID }
        end
      end

shared_example に抜き出してしまったので、実際のテストはこれだけになりました。抜き出した let を設定して、shared_example を呼び出すだけです。この変更を行なってもテストは無事に成功していました。

      context "after option" do
        let(:dry_run) { parent_block.append_block_children append_block, after: previous_id, dry_run: true }
        let(:block) { parent_block.append_block_children append_block, after: previous_id }
        it_behaves_like "append_block_children_append_after"
      end

ここまでできればあとは簡単です。above_block の append_after メソッドを呼び出しましょう。parent_block を準備しなくていいのが楽ですね。

      context "append_after method" do
        let(:dry_run) { above_block.append_after append_block, dry_run: true }
        let(:block) { above_block.append_after append_block }
        it_behaves_like "append_block_children_append_after"
      end

当然ながら undefined method のエラーになります。

1) NotionRubyMapping::Block append block children with after append_after method behaves like append_block_children_append_after dry_run behaves like dry_run 
     Failure/Error: let(:dry_run) { above_block.append_after append_block, dry_run: true }
     
     NoMethodError:
       undefined method `append_after' for NotionRubyMapping::NumberedListItemBlock-263f125b179e4e4f996a1eff812d9d3d:NotionRubyMapping::NumberedListItemBlock

これを通すための実装を記述します。親の append_block_children に丸投げするだけなので簡単ですね。今回の実装変更は実質 5 行だけの変更でした。

    # @param [Boolean] dry_run true if dry_run
    # @return [NotionRubyMapping::Block, String]
    # @param [Array<Block>] blocks
    def append_after(*blocks, dry_run: false)
      parent.append_block_children *blocks, after: id, dry_run: dry_run
    end

6. public_url のテストと実装

ついでなので、page オブジェクトに public_url メソッドを追加しておこうと思います。そもそも page オブジェクトに url メソッドもなかったのでこちらも追加しておきます。すでに取得済みだったら top_page の JSON を再取得するために、その取得スクリプトの更新時刻だけを上げておきます。

touch *.json
touch retrieve_page_top.sh
make

make して JSON を更新します。

make
sh retrieve_page_top.sh > retrieve_page_top.json
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  1033    0  1033    0     0   1302      0 --:--:-- --:--:-- --:--:--  1309
sleep 1

これによって全てのテストが失敗になっていないことを確認しておきます。全てのテストがちゃんと通過していました。

Finished in 0.9498 seconds (files took 0.94732 seconds to load)
2333 examples, 0 failures

早速テストを追加しましょう。page のタイトルだけをテストしていた spec があったので、そこで一緒に url と public_url をテストします。

        it "receive title, url, public_url" do
          page = subject.call
          page_id = "notion_ruby_mapping_test_data-c01166c613ae45cbb96818b4ef2f5a77"
          expect(page.title).to eq "notion_ruby_mapping_test_data"
          expect(page.url).to eq "https://www.notion.so/#{page_id}"
          expect(page.public_url).to eq "https://hkob.notion.site/#{page_id}"
        end

当然ながら undefined method のエラーになります。

1) NotionRubyMapping::Page find For an existing top page receive title, url, public_url
     Failure/Error: expect(page.url).to eq "https://www.notion.so/#{page_id}"
     
     NoMethodError:
       undefined method `url' for NotionRubyMapping::Page-c01166c613ae45cbb96818b4ef2f5a77:NotionRubyMapping::Page

page.rb に実装を追加します。public_url はこちら。

    # @return [String] 公開URL
    def public_url
      @json["public_url"]
    end

url はこちら

    # @return [String] URL
    def url
      @json["url"]
    end

無事にテストは通過しました。

NotionRubyMapping::Page
  find
    For an existing top page
      receive id
      receive title, url, public_url

7. リリース手続き

全てのテストが通過したことを確認したので、リリース手続きを行います。まず、notion_ruby_mapping/version.rb のバージョンを一つ進めます

module NotionRubyMapping
  VERSION = "0.8.2"
  NOTION_VERSION = "2022-06-28"
end

また、README.md に ChangeLog を追加しました。

- 2023/7/13 [v0.8.2] add 'after' option to append_block_chidren, 'append_after' method to block, and 'public_url' method to page

とりあえず development でコミットして、GitHub にプッシュしました。これまでは手元で main にマージしていたのですが、今回から、main ブランチは GibHub でプルリクエストでマージしてみました。一人の開発だったら問題ないかと思っていたのですが、こちらの方が手間がなくて楽ですね。

GitHub 上でマージした main ブランチを pull して手元に展開できたので、いよいよ gem をリリーします。試しにまずパッケージのビルドができることを確認しておきます。

rake build
notion_ruby_mapping 0.8.2 built to pkg/notion_ruby_mapping-0.8.2.gem.

うまくビルドできているようです。それではリリースしてみます。OTP code を入れるとリリースが成功しました。

rake release
notion_ruby_mapping 0.8.2 built to pkg/notion_ruby_mapping-0.8.2.gem.
Tagged v0.8.2.
Pushed git commits and release tag.
Pushing gem to https://rubygems.org...
You have enabled multi-factor authentication. Please enter OTP code.
Code:   ******
Successfully registered gem: notion_ruby_mapping (0.8.2)
Pushed notion_ruby_mapping 0.8.2 to rubygems.org
hkob@hM1mini ~/D/r/notion_ruby_mapping (main)>

あとは、GitHub の方にリリース番号をつけておきます。

8. おわりに

実装の変更はたった 11 行なのですが、そのためのテストデータの作成がとてつもない量ですね。逆にいうとここまで作り込んできたから、たかだか11行の変更で済むという話なのですが。とりあえずリリースできてよかったです。