RubyのOpenStrictクラスの使い方をGemのコードで理解を試みる

こんにちは!kossyです!




今回は、RubyのOpenStrictクラスの使い方を saml-idp という Gem のコード内での使い方を見て、理解を深めてみたいと思います。




ドキュメントを見る

公式のOpenStrictクラスのドキュメントを見てみます。

docs.ruby-lang.org

要素を動的に追加・削除できる手軽な構造体を提供するクラスです。
OpenStruct のインスタンスに対して未定義なメソッド x= を呼ぶと、 OpenStruct クラスの BasicObject#method_missing で捕捉され、そのインスタンスインスタンスメソッド x, x= が定義されます。
この挙動によって要素を動的に変更できる構造体として働きます。

出典: https://docs.ruby-lang.org/ja/latest/class/OpenStruct.html

「動的にメソッドを生やしたり削除したりできる」ところを抑えればよさそうですね。

かなり実装の自由度が高いように感じますが、saml-idp Gem 内でどのように使われているかを見てみます。


SamlIdp::Configurator

github.com

# encoding: utf-8
require 'ostruct'
require 'securerandom'

module SamlIdp
  class Configurator
    attr_accessor :x509_certificate
    attr_accessor :secret_key
    attr_accessor :password
    attr_accessor :algorithm
    attr_accessor :organization_name
    attr_accessor :organization_url
    attr_accessor :base_saml_location
    attr_accessor :entity_id
    attr_accessor :reference_id_generator
    attr_accessor :attribute_service_location
    attr_accessor :single_service_post_location
    attr_accessor :single_service_redirect_location
    attr_accessor :single_logout_service_post_location
    attr_accessor :single_logout_service_redirect_location
    attr_accessor :attributes
    attr_accessor :service_provider
    attr_accessor :assertion_consumer_service_hosts
    attr_accessor :session_expiry

    def initialize
      self.x509_certificate = Default::X509_CERTIFICATE
      self.secret_key = Default::SECRET_KEY
      self.algorithm = :sha1
      self.reference_id_generator = ->() { SecureRandom.uuid }
      self.service_provider = OpenStruct.new
      self.service_provider.finder = ->(_) { Default::SERVICE_PROVIDER }
      self.service_provider.metadata_persister = ->(id, settings) {  }
      self.service_provider.persisted_metadata_getter = ->(id, service_provider) {  }
      self.session_expiry = 0
      self.attributes = {}
    end

    # formats
    # getter
    def name_id
      @name_id ||= OpenStruct.new
    end

    def technical_contact
      @technical_contact ||= TechnicalContact.new
    end

    class TechnicalContact < OpenStruct
      def mail_to_string
        "mailto:#{email_address}" if email_address.to_s.length > 0
      end
    end
  end
end

initializeメソッド内で service_provider 属性に対して、OpenStrictクラスのインスタンスが代入されています。

service_providerに対して未定義のメソッドを呼び出す箇所は、ReadMeのConfigration Sectionに記載されていました。

github.com

  # `identifier` is the entity_id or issuer of the Service Provider,
  # settings is an IncomingMetadata object which has a to_h method that needs to be persisted
  config.service_provider.metadata_persister = ->(identifier, settings) {
    fname = identifier.to_s.gsub(/\/|:/,"_")
    FileUtils.mkdir_p(Rails.root.join('cache', 'saml', 'metadata').to_s)
    File.open Rails.root.join("cache/saml/metadata/#{fname}"), "r+b" do |f|
      Marshal.dump settings.to_h, f
    end
  }

  # `identifier` is the entity_id or issuer of the Service Provider,
  # `service_provider` is a ServiceProvider object. Based on the `identifier` or the
  # `service_provider` you should return the settings.to_h from above
  config.service_provider.persisted_metadata_getter = ->(identifier, service_provider){
    fname = identifier.to_s.gsub(/\/|:/,"_")
    FileUtils.mkdir_p(Rails.root.join('cache', 'saml', 'metadata').to_s)
    full_filename = Rails.root.join("cache/saml/metadata/#{fname}")
    if File.file?(full_filename)
      File.open full_filename, "rb" do |f|
        Marshal.load f
      end
    end
  }

  # Find ServiceProvider metadata_url and fingerprint based on our settings
  config.service_provider.finder = ->(issuer_or_entity_id) do
    service_providers[issuer_or_entity_id]
  end

metadata_persister と persisted_metadata_getter と finder メソッドが動的に定義されていました。

name_idメソッドも実体はOpenStrictインスタンスなので、使用例を見てみましょう。

  # Principal (e.g. User) is passed in when you `encode_response`
  #
  # config.name_id.formats =
  #   {                         # All 2.0
  #     email_address: -> (principal) { principal.email_address },
  #     transient: -> (principal) { principal.id },
  #     persistent: -> (p) { p.id },
  #   }
  #   OR
  #
  #   {
  #     "1.1" => {
  #       email_address: -> (principal) { principal.email_address },
  #     },
  #     "2.0" => {
  #       transient: -> (principal) { principal.email_address },
  #       persistent: -> (p) { p.id },
  #     },
  #   }

TechnicalContactクラスもOpenStrictクラスを継承したクラスとして定義されていて、こちらも動的に定義している箇所がありました。

  ## EXAMPLE ##
  # config.attributes = {
  #   GivenName: {
  #     getter: :first_name,
  #   },
  #   SurName: {
  #     getter: :last_name,
  #   },
  # }
  ## EXAMPLE ##

  # config.technical_contact.company = "Example"
  # config.technical_contact.given_name = "Jonny"
  # config.technical_contact.sur_name = "Support"
  # config.technical_contact.telephone = "55555555555"
  # config.technical_contact.email_address = "example@example.com"

こちらはユーザーが自由に定義できるユーザー情報を入れることを期待しているようですね。


service_provider属性にOpenStrictクラスを使う理由

これは個人的な推測ですが、 SAML認証のServiceProviderとして設定できる値は十数以上あり、

「その全てをServiceProviderクラスとして定義してしまうよりは、ユーザー側に必要な値だけ定義させれば良い」

という考えでservice_provider属性にOpenStrictクラスを使ったのかと思われます。

リンク先はruby-saml Gem で service_providerに設定できる値です。

github.com


まとめ

OpenStrictクラスを使ったコードを読んでみましたが、

「クラスとしてガチガチに定義するほどでもない時」
「使う側にメソッドを動的に定義させたい時」
「具体的にクラスに持たせたい属性が決まってない時」

などで使えるのではないかと思いました。