Marginalia

NotionヘッドレスCMS化記録 (1) Notion APIとTypeScript

このブログ https://blog.lacolaco.netHugo で生成しており、これまではMarkdownファイルをローカルで手書きして記事を書いていた。そしてより気軽に記事を書ける環境を求めて、 Notion をヘッドレスCMSとして使ってみることにした。ちなみにこの記事もNotionで書いている。

この試みは特に新しいものではないため、それほど苦労せず実現できるだろうと思っていたが、実際に開発してみると思っていたよりも苦労した。そこでこの記事から何回かに分けて、NotionをヘッドレスCMSとして使うにあたっての困難とそれを乗り越えるための工夫について書き残す。

今回の内容はNotionの開発者向けAPIとSDKをTypeScriptで利用するにあたって苦労した点だ。

Notion JavaScript Client

公式ドキュメント に書かれているように、Notion APIは公式のJavaScript向けクライアントライブラリ(以下 @notionhq/client) が提供されている。

https://github.com/makenotion/notion-sdk-js

ライブラリのパッケージはnpmで配布されているため、誰でも簡単にNotion APIを使ったアプリケーションを開発できる。また、 @notionhq/client のコードベースはTypeScriptで書かれているため、TypeScriptプロジェクトで利用すれば静的型の支援を受けながら開発できる…と思っていたが、ここに少し落とし穴があった。

データモデル単体の型定義が公開されていない

Notion APIで取得するページやブロックなどのデータは、各APIエンドポイントのレスポンス型に内包される形でしかアクセスできない。つまり、@notionhq/client から PageObjectBlockObject などの独立した型定義がインポートできない。ソースコードを見れば内部的に定義されているものは見つけられるが、パブリックAPIとしては提供されていない。

API呼び出しからレスポンスデータの処理をまとめて書いてしまうならそれほど困らないが、ソースコードを構造化し、モジュールごとに責務を分割したいと思ったら、個々のデータモデルを引数として受け取る関数が記述できないのは困りものだった。

結局この問題はAPI呼び出しメソッドの ReturnType 型から内部のモデル部分の型を取り出すことにした。Promiseを返すメソッドであるため Awaited 型も併用し、さらに配列の要素の型を取り出すために自作の ElementType<T> 型も用意した。

// utils-types.d.ts
declare type ElementType<T> = T extends (infer U)[] ? U : never;

// notion/types.ts
import { Client } from '@notionhq/client';

export type PageObject =
	ElementType<Awaited<ReturnType<Client['databases']['query']>>['results']>;

export type BlockObject =
  ElementType<Awaited<ReturnType<Client['blocks']['children']['list']>>['results']>;

これでページオブジェクトを引数に取る関数が記述できるようになったと思ったが、まだこれだけでは実用的ではなかった。

データモデルのUnion型が親切じゃない

ページオブジェクトにはページのプロパティ情報を格納した properties フィールドがあるが、上述の型定義で取り出した PageObject 型にはそれが存在しない。正しくは、 properties フィールドをもたない型とのUnion型になっているため、Type Guardを通さないとアクセスできない。

// Client.databases.query.resultsの型定義
    results: Array<{
        ...
        properties: Record<string, ...> | null;
        object: "page";
        id: string;
        ...
    } | {
        object: "page";
        id: string;
    }>;

今回のユースケースでは properties を持たないページはイレギュラーでしかないため、この型定義のまま扱うとType Guardを何度も書くことになる。そこで PageObject 型が常に properties フィールドを持つように、独自のユーティリティ型として MatchType<T, U> を作成し、次のようにして properties フィールドの存在を保証した。また同様に、 BlockObjecttype フィールドの存在を保証するように定義した。

// util-types.d.ts
declare type MatchType<T, U, V = never> = T extends U ? T : V;

// notion/types.ts
export type PageObject = MatchType<
  ElementType<Awaited<ReturnType<Client['databases']['query']>>['results']>,
  {
    properties: unknown;
  }
>;

export type BlockObject = MatchType<
  ElementType<Awaited<ReturnType<Client['blocks']['children']['list']>>['results']>,
  { 
    type: unknown;
  }
>;

これで型の問題は解決したと思ったが、もうひとつ重大な問題が残っていた。

ネストしたブロックがレスポンスに含まれていない

Notionにおいてページオブジェクトはブロックオブジェクトでもあり、ページのコンテンツはページ(ブロック)を親とする子ブロックのリストとして表現される。そして、あるブロックの子ブロックを取得するAPIは、ネストした孫レベルのブロックをレスポンスに含まない

https://developers.notion.com/reference/get-block-children

Returns only the first level of children for the specified block. See block objects
 for more detail on determining if that block has nested children.

孫レベルのブロックそのものはレスポンスに含まれていないが、各ブロックオブジェクトは has_children フィールドを持っており、これが真であればそのブロックを親とする孫ブロックがあることを示す。

つまり、ページに含まれるコンテンツをすべて取得したいと思ったら、ページ直下の子ブロックだけでなく、その子ブロックのうち has_children が真であるブロックの子ブロックをさらに取得する必要がある。この問題を解決するため、独自に depth という再帰呼び出しの深さを保持するフィールドを用意し、末端まですべてのブロックを取得できるようにした。また、 depthchildrenBlockObject 型に加え、型定義が本当に完成した。

// notion/types.ts
import { Client } from '@notionhq/client';

export type PageObject = MatchType<
  ElementType<Awaited<ReturnType<Client['databases']['query']>>['results']>,
  {
    properties: unknown;
  }
>;

export type BlockObject = MatchType<
  ElementType<Awaited<ReturnType<Client['blocks']['children']['list']>>['results']>,
  { type: unknown }
> & {
  depth: number;
  children?: BlockObject[];
};

// notion/api.ts
async fetchChildBlocks(parentId: string, depth = 0): Promise<BlockObject[]> {
  const blocks: BlockObject[] = [];
  let cursor = null;
  do {
    const { results, next_cursor, has_more } = await this.client.blocks.children.list({
      block_id: parentId,
    });
    for (const block of results) {
      if ('type' in block) {
        if (block.has_children) {
          const children = await this.fetchChildBlocks(block.id, depth + 1);
          blocks.push({ ...block, depth, children });
        } else {
          blocks.push({ ...block, depth });
        }
      }
    }
    cursor = has_more ? next_cursor : null;
  } while (cursor !== null);
  return blocks;
}

まとめ・次回予告

NotionをブログのヘッドレスCMSとして利用するシステムの開発にあたって、Notion APIとTypeScriptに関するいくつかの困難を非公開APIや any に頼らずどうにか乗り越えられた。

ここからは、APIから取得したデータをもとにMarkdownファイルを生成し、ブログのデプロイフローへ組み込んでいくが、ここにもいろいろと苦労した点があったのでそれらはまた次回に。