📚

[Notion API x Slack API in Rails] 即席お問い合わせ管理システム構築

2023/01/29に公開

バヅクリの合原です。
とある新規開発PJにて、リリース1週間前でしたが、
「問い合わせ機能必要ではないか?」となり、、、

かねてから、使ってみたかったNotionを使えないか?と。実際に使ってみたら、
当初の想定以上に、いいものができたので、まとめたいと思います。

前提

前提として、今回の環境は、下記のフロントエンドとバックエンドの分離構成であります。

  • Backend - Rails
  • Frontend - Nextjs -- こちらの実装については、割愛。

何をやったか

  • Nextjsにてモーダル表示ー>POST送信
  • Rails側でNotionApiリクエストし、問い合わせ内容をPageとして保存
  • 上記でできた NotioページのURLをSlackAPIへリクエストして、Slack通知

処理フローの設計

前述の分離構成から、フロントエンドでは、表示・描画に専念ということで、
RailsからNotion APIへリクエストをする方針としました。

また、

Notionには標準でslack連携機能があるのですが、
こちらは、些細な更新でも、設定したslackチャンネルに通知が来てしまうため(=過剰)、
別で、Slack Apiを用いて、問い合わせたあった場合のみ通知を実行すること、としました。

👇シーケンス(っぽい)図

Notionデータベースの基本

さて、Notion Api自体初めてでしたので、まずは公式ドキュメントを拝見し、
Notionの(データの)構成を確認します。
今回触れた箇所にスコープして、簡単にまとめたものは以下、引用の通り。

demo
Notion APIのデータ構造を実際にAPIを叩きながら理解する

データ構造イメージ

Database > Page > Block(Page内に追加するコンテンツ総称)

https://www.notion.so/help/intro-to-databases

[下準備]database id を控えておく

これに従い、事前に、
下記の「問い合わせ一覧ページ」を新規作成しておきます
=>こちらが、問い合わせ内容の保存対象となるDatabseとなります。

下記の通り、新規作成ページURLからdatabase idを取得しておきます。

https://booknotion.site/setting-databaseid

※後ほど、Notion APIリクエストで使います。

今回は下記の
その上で、前述のAPI操作にて、今回の仕組みを実装したいと思います。

Notion APIの使い方(概要)

https://developers.notion.com/docs/getting-started#whats-a-notion-integration

の通り、

  1. NotionではIntegrationというものを作成し、
  2. こちらを特定のNotionのページやワークスペースにインストールして使います。

言い換えるなら、Integrationとは、
Notion上で使えるカスタムアプリケーション
といったところでしょうか。FacebookアプリやZoomアプリ等と似てますねw

Integration Typeとは?

Integrationには以下のような種類があります。

  • Public
  • Internal

※詳細な説明は公式Docsを参照

今回は、会社のアプリケーションの一部として使いますので、後者のInternalを使います。

https://developers.notion.com/docs/getting-started#integration-types

[下準備]Integration作成

https://developers.notion.com/docs/create-a-notion-integration

Visit https://www.notion.com/my-integrations in your browser.

上記URLよりIntegrationの作成を行います。

[下準備]各種機能の設定をする(後から変更も可能)

一言で言うと、
Integrationのpermissionの設定ですかね。

[下準備]作成したIntegrationのシークレットトークンを控えておく

👇こちらを後ほど、使うので控えておきます。

[下準備]作成したIntegrationを追加する

Step 2: Share a database with your integration
You must share specific pages with an integration in order for the API to access those pages

作成したIntegrationを追加します。これによって、
Integrationが該当ページの編集権限を持つ状態になります。
つまり、Notion APIを使って、ページの編集が可能となります。

👇APIで操作したいNotionページの右上の3点リーダーから

👇「コネクトの追加」にて、作成したIntegration名で検索すると、Integrtationが見つかるので「追加」をする

ここまでで、 Notion側の準備は完了です。

Notion API ≒ RESTful API

The Notion API follows RESTful conventions when possible, with most operations performed via GET, POST, PATCH, and DELETE requests on page and database resources. Request and response bodies are encoded as JSON.

認証・認可方法

Requests use the HTTP Authorization header to both authenticate and authorize operations. The Notion API accepts bearer tokens in this header. Bearer tokens are provided to you when you create an integration.

https://developers.notion.com/reference/authentication

ポイント

  • HTTP Authorization headerにBearer tokensを付与してリクエストすること
  • Bearer tokens=Itengrationのシークレットトークン

curlでAPIリクエストしてみる

実装に入る前にまずは、サクッとcurlで確認です。
必要なのは、下記。

  • 作成したItengrationシークレットトークン
  • 事前に控えていたdatabase id
curl -X POST https://api.notion.com/v1/pages \
  -H "Authorization: Bearer [ここにシークレットトークン]" \
  -H "Content-Type: application/json" \
  -H "Notion-Version: 2021-08-16" \
  --data "{
    \"parent\": { \"database_id\": \ [ここにdatabase id]\" },
    \"properties\": {
      \"title\": {
        \"title\": [
          {
            \"text\": {
              \"content\": \"問い合わせ内容本文サンプル\"
            }
          }
        ]
      }
    }
  }"

とすると、期待通り、データが保存されます。

APIリクエストの方法がわかったところで、あとは、これを
Railsにて実装するのみです。

Notion APIリクエスト実装 in Rails

Notion APIは、下記の通り、ドキュメントが非常にわかりやすく、整備されています。
https://developers.notion.com/reference/intro

※zoom APIにそっくりw

https://qiita.com/kieaiaarh/items/dfd6dd1257adbcee91ad

設計

今回は、Nextjsから問い合わせリクエストをRailsで受け付けた上で、
Notion API、Slack APIへリクエストするため、
form objectにて、この責務を担うこととします。

..今回に限らず、API経由でのリソースの操作が統一的にできる作りになっているので、
下記のようにNotion APIリクエストをするmoduleを作成し、名前空間も統一することとしました。

こうしておけば、追加で必要になったタイミングで機能の拡張もしやすいため。

app/models/notions/
├── base.rb
├── databases
├── pages
│   └── post.rb
└── pages.rb

以下、本論に関わるところ以外は、一部改変しております。悪しからずです。

APIリクエストするbase class作成

class Notions::Base
  include ActiveModel::Model
  class ApiError < StandardError; end

  validates :secret_token, presence: true

  private

    def api_request
      raise NotImplementedError, "Implement #{self.class.name}#token_request"
    end

    def headers
      {
        "Content-Type" => "application/json",
        # https://developers.notion.com/reference/versioning
        "Notion-Version" => versioning_date
      }.merge(token_request_headers)
    end

    def versioning_date
      raise NotImplementedError, "Implement #{self.class.name}#versioning_date"
    end

    def token_request_headers
      {
        "Authorization" => "Bearer #{secret_token}"
      }
    end

    def secret_token
      Rails.application.credentials.notion[Rails.env.to_sym][:api_key]
    end

    def base_url
      "https://api.notion.com/v1/"
    end
end

上記を継承する形で、各リソースへのAPIリクエストをするclassを作成

今回は、問い合わせがあるたびに、
databaseにpageを作成するため、

app/models/notions/[リソース名]/[httpリクエスト名].rb
としました。

実装については、
https://developers.notion.com/reference/post-page

に従い、
request_bodyにて、自由に必要なパラメータを受け取れるようにしておきました。

class Notions::Pages::Post < Notions::Base
  class ApiError < StandardError; end

  REQUIRED_ATTRS = %i[
    parent
    properties
  ].freeze
  OPTIONAL_ATTRS = %i[
    children
    icon
    cover
  ].freeze
  (REQUIRED_ATTRS + OPTIONAL_ATTRS).each do |attr|
    attr_reader attr
    attr_accessor attr if OPTIONAL_ATTRS.include? attr
    validates attr, presence: true if REQUIRED_ATTRS.include? attr
  end

  def initialize(parent, properties, attributes = {})
    @parent = parent
    @properties = properties
    super(attributes)
  end

  def create!
    return raise ArgumentError, errors.full_messages.join(", ") if invalid?

    raise ApiError, "#{response_body['code']}, #{response_body['message']}" unless api_response.code == "200"
    response_body
  end

  private

    def response_body
      @response_body ||= JSON.parse(api_response.body)
    end

    def api_response
      uri = URI.parse(api_request_url)
      http = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl = true

      @api_response ||= http.post(uri.path, request_body.to_json, headers)
    end

    def api_request_url
      base_url + Notions::Pages::RESOURCES
    end

    def versioning_date
      "2021-08-16"
    end

    # https://developers.notion.com/reference/post-page
    # https://developers.notion.com/reference/parent-object
    def request_body
      request_body = {
                        parent: parent,
                        properties: properties
                      }.merge(children)
      request_body.merge(children) if children.present?

    end
end

問い合わせを受け付ける Form Objectにて、リクエストを実行


class Inquiry
  class SendgridAPIError < StandardError; end
  include ActiveModel::Model
  include SendgridMailable

  MAIL_FROM = "noreply@hoge.com".freeze
  MAIL_TEMPLATE_ID = "hogehoge-id".freeze

  define_model_callbacks :save, only: :after
  after_save :client_thanks_mail!

  ATTRS = %i[
    category_name
    description
    name
    email
  ].freeze

  ATTRS.each do |a|
    attr_accessor a
    validates a, presence: true
  end

  def save
    return false if invalid?

    run_callbacks :save do
      res = notion_post.create!
      slack_post!(res)
      true
    rescue StandardError, Notions::Pages::Post::ApiError => e
      errors.add :base, e.message
      false
    end
  end

  private

    def client_thanks_mail!
      # 問い合わせ完了時のメール送信処理
    end

    def api_request_response
      @api_request_response ||= send_sendgrid_mail(from: MAIL_FROM, template_id: MAIL_TEMPLATE_ID) do
        [[email, template_data]]
      end
    end

    def template_data
      { category_name: category_name, description: description }
    end

    def slack_post!(notion_response)
      # slack apiリクエスト実行処理
    end

    def api_request_success?
      SendgridResult::SUCCESSFUL_RESPONSE_CODE_RANGE.cover? api_request_response.status_code.to_i
    end

    #------------ notion -----------

    def notion_post
      @notion_post ||= Notions::Pages::Post.new(parent, properties, children: children)
    end

    def parent
      { database_id: database_id }
    end

    def database_id
      Rails.application.credentials.notion[Rails.env.to_sym][:databases][:survey][:inquiry]
    end

        # リクエストbodyに含める問い合わせ内容を組み立てる
    def properties_mappings
      {
        "内容": description.truncate(30),
        "カテゴリ": category_name.to_s,
        "企業名": "#{name}",
        "ユーザー": name,
        "メールアドレス": email,
      }
    end

    def properties
      properties_mappings.each_with_object({}) do |(k, v), h|
        h[k.to_s] = if k.to_s == "内容"
                      {
                        title: [
                          {
                            text: {
                              content: v,
                            }
                          }
                        ]
                      }
                    else
                      {
                        rich_text: [
                          {
                            text: {
                              content: v
                            }
                          }
                        ]
                      }
                    end
      end
    end

    # https://developers.notion.com/reference/block#block-type-object
    def children
      {
        children: [
          {
            object: "block",
            type: "heading_1",
            heading_1: {
              rich_text: [{ type: "text", text: { content: "設問カテゴリ" } }]
            }
          },
          {
            object: "block",
            type: "heading_2",
            heading_2: {
              rich_text: [{ "type": "text", "text": { content: category_name.to_s } }]
            }
          },
          {
            object: "block",
            type: "paragraph",
            paragraph: {
              rich_text: [
                {
                  type: "text",
                  text: {
                    content: description,
                  }
                }
              ]
            }
          }
        ]
      }
    end

end


全てを掲載はできないため、一部、割愛しているところもありますが、
大体はこういった流れで、実装完了。

Nextjsからのリクエストに応じて、このFormObjectをコントローラから実行する状態にして、完了。

Slack API

こちらの実装については、今回は割愛。
また、別で披露する機会ありましたら!

まとめ

ドキュメントもわかりやすく、簡単に扱えるNotion APIを今回初めて使ってみました。
実際、リリース1週間前という時間がない中での導入でしたが、狙い通り、
Notion APIを使うことで、Rails側の実装も最小限に抑え、
ローコード開発ができた良い事例となりました。

また、バヅクリでは、通常業務においてもNotion自体利用しているため、今後より多くの場面でAPIを使った自動化やローコード開発を取り入れていきたいと思えた経験になりました。
その際はまた、改めて披露させていただきますw

現在Railsエンジニアを募集しております!

さてさて、
そんなバヅクリ開発チーム=DXチームでは、現在Railsエンジニアを募集しております!

気になる方はぜひ👇より!

https://herp.careers/v1/buzzkuri/wCQFGVkQS3Xa

https://buzzkuri.co.jp/recruit

こちらにコメントでもいいので、カジュアルにご連絡いただけたらな、と思います!

バヅクリテックブログ

Discussion