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_children
と append_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 取得用の二つのスクリプトを作成しておきます。
-
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'
-
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行の変更で済むという話なのですが。とりあえずリリースできてよかったです。