1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Headless CMSのContentfulをRailsで使ってみる~実装編1~

Last updated at Posted at 2021-06-08

本記事の趣旨

Contentfulについて別記事に書きましたが、
なかなか使用するにはハードルを感じたので、私が作成した共通処理を一例として公開したいと思います。
まだまだ荒いところもあるかと思うので、あくまで参考まで。

前の記事
https://qiita.com/polar_bear_tech/items/fac1fbc345662c396e5b

Contentful Gem(読み込み用)の難しいところ

以下のポイントを解決するようモデルと共通処理を作っていきます。

  • Contentful::Entryモデルを継承してinitializeでフィールドを設定する必要がある
  • エントリー取得時のクエリが長く、感覚的に使いにくい
  • モデル間のリレーションがあると深いJSONになり扱いにくい

基本的な実装

Contentfulで作成したモデルには"contentful_readable.rb"をincludeすることにしています。
ここにclientを実装する時、entry_mappingにContentfulGemで扱うモデルをマッピングします。

app/models/concerns/contentful_readable.rb
require 'contentful'

module ContentfulReadable
  extend ActiveSupport::Concern

  included do
    class << self
      def cda_client
        @cda_client ||= Contentful::Client.new(
          space: ENV['CONTENTFUL_SPACE_ID'],
          access_token: ENV['CONTENTFUL_DELIVERY_ACCESS_TOKEN'],
          environment: ENV['CONTENTFUL_ENVIRONMENT'] || 'master',
          # ↓ここの数値が多いとリレーション項目を持ってきてしまってクエリが重くなる
          max_include_resolution_depth: 1, 
          timeout_read: 30,
          timeout_write: 30,
          timeout_connect: 10,
          # Contentfulのモデルをマッピングする。左辺がContentModelのID、右辺がモデルクラス
          entry_mapping: {
            'user' => User
          }
        )
      end
    end
  end
end

ContentfulGemで扱われるモデルには、Contentful::Entryを継承させます。
忘れず先程の共通処理をincludeさせます。

id,first_name,usersとタイムスタンプのフィールドを持つモデルとして、アクセサを定義した時、
initializeではそれぞれに初期化時に値を入れるよう処理を記載する必要があります。

app/models/user.rb
class User < Contentful::Entry
  include ContentfulReadable

  attr_accessor :id,
                :first_name,
                :user_ids, # ユーザー同士の多対多リレーション項目usersのIDを配列で持つ
                :created_at,
                :updated_at

  def initialize(
        item = {},
        _configuration = {},
        localized = false,
        includes = [],
        entries = {},
        depth = 0,
        errors = []
    )

    # Contentfulがデフォルトで持っている項目はsysから
    self.id = item.dig('sys', 'id')
    self.created_at = item.dig('sys', 'createdAt')
    self.updated_at = item.dig('sys', 'updatedAt')
    # Content Model定義時に設定したフィールドはfieldsから
    self.first_name = item.dig('fields', 'firstName')
    # リレーション項目はさらにネストされたsysから
    self.user_ids = item.dig('fields', 'users').map { |hash| dig('sys', 'id') }
    # 注) 上記は必ず値が入っている場合の例
    
    # Contentful::Entryのinitializeも動くようにsuperを実行
    super
  end
end

このモデルのクエリを発行するとき、以下のようになります。

User.cda_client.entries(
  content_type: 'user',
  'fields.firstName[eq]' => 'Tarou',
  'fields.users.sys.contentType.sys.id' => 'user',
  'fields.users.fields.firstName[eq]' => 'Jun'
)

[eq]というのは一致条件で、他にも[ne][in][lte][gte]などがあります。(詳しくはCDAのリファレンスを参照)
条件が多かったり、リレーション項目で条件をつける場合、例のように長くなります。

共通処理を充実させて簡単にしてみる

結論から、共通処理の最終形です。

app/models/concerns/contentful_readable.rb
# frozen_string_literal: true

require 'contentful'

module ContentfulReadable
  extend ActiveSupport::Concern

  included do
    class << self
      def relation_field(field)
        @relations = [] if @relations.nil?
        @relations.push(field)
      end

      attr_reader :relations

      # すべてのユーザーを取得する
      def all
        cnt_array = cda_client.entries(content_type: name.camelize(:lower))
        items = cnt_array.items.dup

        Enumerator::Lazy.new(0...cnt_array.total) do |yielder, _index|
          yielder << items.shift

          if items.empty?
            cnt_array = cnt_array.next_page(cda_client)
            items = cnt_array.items.dup
          end
        end
      end

      def total
        cda_client.entries(content_type: name.camelize(:lower), limit: 0).total
      end

      alias_method :count, :total

      # Hash型式で条件を渡すと一致するエントリを配列で返す
      # conditions<Hash>:
      #                   keyに検索対象のフィールド名と演算子名をsnakeケースで設定し、検索値をvalueに設定する。
      #                   演算子名はcontentfulのAPIドキュメントに準拠する
      #                   https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/search-parameters
      #                   例: { field_name_eq: value, field_name_gte: value }
      # order<Hash>:
      #              keyに順序の指定項目を、valueにasc/descを指定する。
      #              複数条件を指定した場合、先の要素が優先される。(例の場合はupdated_atの降順の後にidで昇順となる)
      #              例: { updated_at: "desc", id: "asc" }
      # include_level<Integer>: 複数のエントリーを取得する場合に、関係を持つエントリーを併せて取得する
      # limit<Integer>: 取得エントリー数の上限(default: 100)
      # skip<Integer>: クエリ総数がlimitを超える場合に、スキップするページ数を指定する
      def where(conditions, order: { updated_at: 'desc', id: 'asc' }, include_level: 0, limit: 100, skip: 0)
        query_params = {
          content_type: name.camelize(:lower),
          include: include_level,
          order: order_format(order),
          limit: limit,
          skip: skip
        }
        conditions_format(conditions).each { |k, v| query_params.store(k, v) }
        cda_client.entries(query_params).to_a
      end

      def find_by(conditions, include_level: 0, order: { updated_at: 'desc', id: 'asc' })
        query_params = {
          content_type: name.camelize(:lower),
          include: include_level,
          order: order_format(order),
          limit: 1
        }
        conditions_format(conditions).each { |k, v| query_params.store(k, v) }
        cda_client.entries(query_params).first
      end

      # ID指定で1件取得して返す
      def find(id)
        cda_client.entry(id)
      end

      def cda_client
        @cda_client ||= Contentful::Client.new(
          space: ENV['CONTENTFUL_SPACE_ID'],
          access_token: ENV['CONTENTFUL_DELIVERY_ACCESS_TOKEN'],
          environment: ENV['CONTENTFUL_ENVIRONMENT'] || 'master',
          max_include_resolution_depth: 1,
          timeout_read: 30,
          timeout_write: 30,
          timeout_connect: 10,
          entry_mapping: {
            'user' => User
          }
        )
      end

      private

      # 順序指定条件のHashをContebntfulに渡せる形式に変換する
      # in: { updated_at: desc, id: asc }
      # out: "-sys.updatedAt,sys.id"
      def order_format(order_hash)
        formatted_orders = []
        order_hash.each do |field, order|
          camelized_field = field.to_s.camelize(:lower)
          prefix = %w[id createdAt updatedAt].include?(camelized_field) ? 'sys' : 'fields'
          order_symbol = order.casecmp('desc').zero? ? '-' : ''
          formatted_orders.push("#{order_symbol}#{prefix}.#{camelized_field}")
        end
        formatted_orders.join(',')
      end

      # 検索条件のHashをContebntfulに渡せる形式に変換する
      def conditions_format(conditions)
        formatted_conditions = {}
        conditions.each do |field_cnd, value|
          if value.is_a?(Hash)
            conditions_format(value).each do |link_condition, link_value|
              camelized_field = field_cnd.to_s.camelize(:lower)
              formatted_conditions.store("fields.#{camelized_field}.sys.contentType.sys.id", camelized_field)
              formatted_conditions.store("fields.#{camelized_field}.#{link_condition}", link_value)
            end
          elsif field_cnd.to_s == 'query'
            formatted_conditions.store(:query, value)
          else
            field_cnd_array = field_cnd.to_s.split('_')
            cnd = field_cnd_array.pop
            cnd = cnd == 'eq' ? '' : "[#{cnd}]"
            if cnd == '' && value.nil?
              cnd = '[exists]'
              value = false
            end
            field = field_cnd_array.join('_')
            camelized_field = field.to_s.camelize(:lower)
            prefix = %w[id createdAt updatedAt].include?(camelized_field) ? 'sys' : 'fields'
            formatted_conditions.store("#{prefix}.#{camelized_field}#{cnd}", value)
          end
        end
        formatted_conditions
      end
    end
  end

  private

  def initialize_field_set(item)
    return if item.blank?

    self.id = item.dig('sys', 'id')
    self.created_at = item.dig('sys', 'createdAt')
    self.updated_at = item.dig('sys', 'updatedAt')

    item['fields'].each do |key, value|
      if self.class.relations.present? && self.class.relations.include?(key.underscore)
        case value
        when Hash
          send("#{key.underscore}_id=", value.dig('sys', 'id')) if self.class.method_defined?("#{key.underscore}_id=")
        when Array
          if self.class.method_defined?("#{key.underscore.singularize}_ids=")
            send("#{key.underscore.singularize}_ids=", value.map { |hash| hash.dig('sys', 'id') })
          end
        end
      elsif self.class.method_defined?("#{key.underscore}=")
        send("#{key.underscore}=", value)
      end
    end
  end
end

併せてモデルの最終形。

app/models/user.rb
class User < Contentful::Entry
  include ContentfulReadable

  attr_accessor :id,
                :first_name,
                :user_ids, # ユーザー同士の多対多リレーション項目usersのIDを配列で持つ
                :created_at,
                :updated_at

  relation_field 'users'

  def initialize(
        item = {},
        _configuration = {},
        localized = false,
        includes = [],
        entries = {},
        depth = 0,
        errors = []
    )

    # 共通処理
    initialize_field_set(item)
    
    # Contentful::Entryのinitializeも動くようにsuperを実行
    super
  end

  # usersでリレーションを持つ、Userモデルを取得する
  def friends
    return [] if user_ids.blank?

    @users ||= User.where(
      { id_in: user_ids },
      order: { updated_at: 'desc'}
    )
  end
end

上記までの最終形では、
Contentfulのフィールド名とRails上のフィールド名に一定のルールを設け、
それに則った状態で使用できるようにしています。たとえば下記のような感じ。

項目 Contentful Rails
非リレーション項目 firstName first_name
複数リレーション項目 friendUsers friend_user_ids
単数リレーション項目 friendUser friend_user_id

軽くそれぞれのポイントを触れていきます。

リレーション項目の表現

共通処理から抜粋。
ここでリレーション項目をモデルに定義できるようにしています。
定義されたリレーション項目はinitialize、クエリの共通処理で使用しています。

  def relation_field(field)
    @relations = [] if @relations.nil?
    @relations.push(field)
  end

  attr_reader :relations

initializeの簡略化

共通処理から抜粋。
コメントの通り、命名ルールに則って初期値を入れていきます。

  def initialize_field_set(item)
    return if item.blank?

    # Contentfulのデフォルト項目はそのまま入れる
    self.id = item.dig('sys', 'id')
    self.created_at = item.dig('sys', 'createdAt')
    self.updated_at = item.dig('sys', 'updatedAt')

    # ContentModelに定義した項目は・・・
    item['fields'].each do |key, value|
      # リレーション項目なら
      if self.class.relations.present? && self.class.relations.include?(key.underscore)
        case value
        # 単数リレーションなら
        when Hash
          # IDを所定の名前の項目に入れる
          send("#{key.underscore}_id=", value.dig('sys', 'id')) if self.class.method_defined?("#{key.underscore}_id=")
        # 複数リレーションなら
        when Array
          if self.class.method_defined?("#{key.underscore.singularize}_ids=")
            # IDの配列を所定の名前の項目に入れる
            send("#{key.underscore.singularize}_ids=", value.map { |hash| hash.dig('sys', 'id') })
          end
        end
      # 非リレーション項目なら
      elsif self.class.method_defined?("#{key.underscore}=")
        # 値を所定の名前の項目に入れる
        send("#{key.underscore}=", value)
      end
    end
  end

クエリの簡略化

共通処理から抜粋。
コメント記載のルールで条件を指定して、クエリを実行します。

      # Hash型式で条件を渡すと一致するエントリを配列で返す
      # conditions<Hash>:
      #                   keyに検索対象のフィールド名と演算子名をsnakeケースで設定し、検索値をvalueに設定する。
      #                   演算子名はcontentfulのAPIドキュメントに準拠する
      #                   https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/search-parameters
      #                   例: { field_name_eq: value, field_name_gte: value }
      # order<Hash>:
      #              keyに順序の指定項目を、valueにasc/descを指定する。
      #              複数条件を指定した場合、先の要素が優先される。(例の場合はupdated_atの降順の後にidで昇順となる)
      #              例: { updated_at: "desc", id: "asc" }
      # include_level<Integer>: 複数のエントリーを取得する場合に、関係を持つエントリーを併せて取得する
      # limit<Integer>: 取得エントリー数の上限(default: 100)
      # skip<Integer>: クエリ総数がlimitを超える場合に、スキップするページ数を指定する
  def where(conditions, order: { updated_at: 'desc', id: 'asc' }, include_level: 0, limit: 100, skip: 0)
    query_params = {
      content_type: name.camelize(:lower),
      include: include_level,
      order: order_format(order),
      limit: limit,
      skip: skip
    }

    # 条件指定をContentful形式に整えてパラメータにマージする
    conditions_format(conditions).each { |k, v| query_params.store(k, v) }
    # エントリーを取得
    cda_client.entries(query_params).to_a
  end

  # 順序指定条件のHashをContebntfulに渡せる形式に変換する
  # in: { updated_at: desc, id: asc }
  # out: "-sys.updatedAt,sys.id"
  def order_format(order_hash)
    formatted_orders = []
    order_hash.each do |field, order|
      # キャメルケースに変換(created_at => createdAt)
      camelized_field = field.to_s.camelize(:lower)
      # sysかfieldsを判断
      prefix = %w[id createdAt updatedAt].include?(camelized_field) ? 'sys' : 'fields'
      # desc指定の場合は-をつける
      order_symbol = order.casecmp('desc').zero? ? '-' : ''
      formatted_orders.push("#{order_symbol}#{prefix}.#{camelized_field}")
    end
    # 条件をカンマ区切り文字列にして返す
    formatted_orders.join(',')
  end

  # 検索条件のHashをContebntfulに渡せる形式に変換する
  def conditions_format(conditions)
    formatted_conditions = {}

    # 下記のクエリを発行した場合(ユーザー名Taroで、フレンドにJunがいるユーザー)
    # User.where({ 
    #   first_name_eq: 'Taro',
    #   users: { first_name_eq: 'Jun' }
    # })
    conditions.each do |field_cnd, value|
      # リレーション項目の条件指定の場合
      if value.is_a?(Hash)
        # 指定された{ first_name_eq: 'Jun' }を再起的に取得して
        # { 'fields.users.firstName[eq]' => 'Jun' }をEachで回す
        conditions_format(value).each do |link_condition, link_value|
          camelized_field = field_cnd.to_s.camelize(:lower)
          # リレーション項目のID指定条件を追加 { "fields.users.sys.contentType.sys.id" => 'user' }
          formatted_conditions.store("fields.#{camelized_field}.sys.contentType.sys.id", camelized_field)
          # リレーション項目のfirstName条件を追加 { "fields.users.fields.firstName[eq]" => 'Jun' }
          formatted_conditions.store("fields.#{camelized_field}.#{link_condition}", link_value)
        end
      # 'query'が指定されていた場合、条件をそのまま入れる
      elsif field_cnd.to_s == 'query'
        formatted_conditions.store(:query, value)
      # 非リレーション項目の条件指定の場合
      else
        # first_name_eq => firstName[eq]にする
        field_cnd_array = field_cnd.to_s.split('_')
        cnd = field_cnd_array.pop
        cnd = cnd == 'eq' ? '' : "[#{cnd}]"
        if cnd == '' && value.nil?
          cnd = '[exists]'
          value = false
        end
        field = field_cnd_array.join('_')
        camelized_field = field.to_s.camelize(:lower)
        # Contentfulのデフォルト項目はsys,それ以外はfieldsをつける
        prefix = %w[id createdAt updatedAt].include?(camelized_field) ? 'sys' : 'fields'
        # 条件を追加 { 'fields.firstName[eq]' => 'Taro' }
        formatted_conditions.store("#{prefix}.#{camelized_field}#{cnd}", value)
      end
    end
    # 条件のHashを返す
    # { 
    #   'fields.firstName[eq]' => 'Taro',
    #   'fields.users.sys.contentType.sys.id' => 'user',
    #   'fields.users.fields.firstName[eq]' => 'Jun'
    # }
    formatted_conditions
  end

例えば、ユーザーモデルから抜粋して、こんなかんじに。
CDAのクエリ用にeq,ne,gthとかの条件指定文字列の名残はありますが、
冗長だったfields.sys...うんぬんはリレーションを見て勝手に補完するようにしてすっきりしました。
ActiveRecordちっくに扱えるのでマシになったかと個人的に思っています。

  # usersのUserモデルを取得する
  def friends
    return [] if user_ids.blank?

    @users ||= User.where(
      { id_in: user_ids },
      order: { updated_at: 'desc'}
    )
  end

あとがき

サンプル実装がコピーしてそのまま動かなかったらすみません、元実装を編集したので・・・
大丈夫だと思いますが、あくまで参考にしてください。

あまり私が実装していた時点では参考記事が少なかったので、一助になればと思います。
共通処理をここまで充実させれば、割と不自由なくContentfulが扱えますが、
「OR条件検索ができない」とかそういう限界は乗り越えられないのでご注意くださいね。

書き込み用Gem ContentfulManagementについては、いずれ・・・いずれ書くかもしれません。
需要があればその旨コメントいただけるとモチベーションになります。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?