1
2

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.

microsoftgraph/msgraph-sdk-ruby を読む

Last updated at Posted at 2020-02-21

動機と概要

Microsoft Graph を利用する機会があり、公式rubyライブラリ microsoftgraph/msgraph-sdk-ruby を使う上で生じた疑問をソースコードを読んでスッキリした際の備忘録。

  • Gemは microsoft_graph
  • リーディング時のコミットID e5408c4096a826ba96a802aef25ee91bf7e11894

TL;DR

  • Q1. なぜ?ソース内で定義されていない機能が使えるの?(例: graph.me )

    • A1. メタプログラミングのテクニックを用いて metadataファイルを元に動的に定義しているから。
  • Q2. graph.me の他にオブジェクト経由でアクセスできる機能はどこを見ればわかるの?

    • A2. metadataファイル内の NavigationProperty を見るか、:navigation_properties メソッドで確認可能。
  • Q3. オブジェクト経由でAPI呼び出しする際にクエリオプション($filter,$top ...等)って渡せる?

    • A3. サポートされていないようなので、自前でURL生成しリクエストする処理を実装する必要あり。

Microsoft Graph は ODataプロトコルを採用

Microsoft Graphは Open Data Protocol (OData) というWebAPIのプロトコルを採用しており、
これにより、多様なクライアントでも一貫した方法でのデータアクセスに寄与しています。

公開されているリソースの形式は、APIのルートURLに $metadata を付与したURLで確認可能です。
Microsoft Graph v1.0 であれば以下がメタデータのURLになります。(XML形式)

"user"型のデータ定義を抜粋。

.xml
<EntityType Name="user" BaseType="graph.directoryObject" OpenType="true">
  <Property Name="accountEnabled" Type="Edm.Boolean"/>
  <Property Name="ageGroup" Type="Edm.String"/>
  <Property Name="assignedLicenses" Type="Collection(graph.assignedLicense)"   Nullable="false"/>
  <Property Name="assignedPlans" Type="Collection(graph.assignedPlan)"   Nullable="false"/>
  <Property Name="businessPhones" Type="Collection(Edm.String)" Nullable="false"/>
  <Property Name="city" Type="Edm.String"/>
  <Property Name="companyName" Type="Edm.String"/>
  <Property Name="consentProvidedForMinor" Type="Edm.String"/>
  <Property Name="country" Type="Edm.String"/>
  <Property Name="creationType" Type="Edm.String"/>
  <Property Name="department" Type="Edm.String"/>
  <Property Name="displayName" Type="Edm.String"/>
  <Property Name="employeeId" Type="Edm.String"/>
  <Property Name="faxNumber" Type="Edm.String"/>
  <Property Name="givenName" Type="Edm.String"/>
  ...略...
</EntityType>

OData形式については以下記事が理解の助けになりました。

(本題) gem: microsoft_graph を利用する上での疑問

Microsoft Graph の公式Rubyライブラリ microsoftgraph/msgraph-sdk-ruby のREADMEより

.rb
callback = Proc.new { |r| r.headers["Authorization"] = "Bearer #{tokens.access_token}" }

graph = MicrosoftGraph.new(base_url: "https://graph.microsoft.com/v1.0",
                           cached_metadata_file: File.join(MicrosoftGraph::CACHED_METADATA_DIRECTORY, "metadata_v1.0.xml"),
                           api_version: '1.6', # Optional
                           &callback
)

me = graph.me # get the current user
puts "Hello, I am #{me.display_name}."

me.direct_reports.each do |person|
  puts "How's it going, #{person.display_name}?"
end

疑問1: graph.meは、直接定義されていないのになぜ動くのか?

graph.me で自身のプロフィール情報を取得できるが、
ソースコードをgrepしても MicrosoftGraphのインスタンスメソッドに me メソッドは直接的に定義されていなかった。
どのように動いているのか?ソースコードを追ってみた。

ライブラリの大元の MicrosoftGraph より

lib/microsoft_graph.rb
class MicrosoftGraph
  attr_reader :service
  BASE_URL = "https://graph.microsoft.com/v1.0/"

  def initialize(options = {}, &auth_callback)
    @service = OData::Service.new(
      api_version: options[:api_version],
      auth_callback: auth_callback,
      base_url: BASE_URL,
      metadata_file: options[:cached_metadata_file]
    )
    @association_collections = {}
    unless MicrosoftGraph::ClassBuilder.loaded?
      MicrosoftGraph::ClassBuilder.load!(service)
    end

  end

  # ... 略 ...

MicrosoftGraph::ClassBuilder.load!(service)

結論としては、このload!メソッドの処理でRubyのオブジェクト定義をしていた。

このメソッドでは、metadata(XML)のEntityType, ComplexType, EntitySet, Action, Functionタグを順にメタプログラミングのテクニックでクラス化し、
メソッドの最後に Singletonタグで定義されている内容 (me 等)を、MicrosoftGraphのインスタンスメソッドとして定義しているからgraph.me で呼び出し可能になっていた。

ClassBuilder.load! より

lib/microsoft_graph/class_builder.rb
class MicrosoftGraph
  class ClassBuilder
    @@loaded = false

    def self.load!(service)
      if !@@loaded
        @service_namespace = service.namespace
        service.entity_types.each do |entity_type|
          create_class! entity_type
        end

        service.complex_types.each do |complex_type|
          create_class! complex_type
        end

        service.entity_sets.each do |entity_set|
          add_graph_association! entity_set
        end

        service.actions.each do |action|
          add_action_method! action
        end

        service.functions.each do |function|
          add_function_method! function
        end

        service.singletons.each do |singleton|
          class_name = classify(singleton.type_name)
          MicrosoftGraph.instance_eval do
            resource_name = singleton.name
            define_method(OData.convert_to_snake_case(resource_name)) do
              MicrosoftGraph
                .const_get(class_name)
                .new(
                  graph:         self,
                  resource_name: resource_name,
                  parent:        self
                ).tap(&:fetch)
            end
          end
        end

        MicrosoftGraph.instance_eval do
          define_method(:navigation_properties) do
            service.entity_sets
              .concat(service.singletons)
              .map { |navigation_property|
                [navigation_property.name.to_sym, navigation_property]
              }.to_h
          end
        end

        @@loaded = true
      end
    end

metadataの具体例と合わせて、class_builder.rb の処理内容を追ってみる
以下はmetadataの EntityType のUser型の抜粋。

metadata_v1.0.xml
<EntityType Name='user' BaseType='microsoft.graph.directoryObject' OpenType='true'>
  <Property Name='companyName' Type='Edm.String' Unicode='false'/>
  <Property Name='displayName' Type='Edm.String' Unicode='false'/>
  ...  略 ...
  <NavigationProperty Name='calendar' Type='microsoft.graph.calendar' ContainsTarget='true'/>
  <NavigationProperty Name='calendars' Type='Collection(microsoft.graph.calendar)' ContainsTarget='true'/>
  <NavigationProperty Name='events' Type='Collection(microsoft.graph.event)' ContainsTarget='true'/>
  ...  略 ...
</EntityType>

メソッド内でクラス定義している create_class! メソッドにて
指定のクラス名(User)とベースクラス(BaseEntity)を指定して、MicrosoftGraph::<クラス名> でクラス化。

lib/microsoft_graph/class_builder.rb
    def self.create_class!(type)
      superklass = get_superklass(type)
      klass = MicrosoftGraph.const_set(classify(type.name), Class.new(superklass))
      klass.const_set("ODATA_TYPE", type)
      klass.instance_eval do
        def self.odata_type
          const_get("ODATA_TYPE")
        end
      end
      create_properties(klass, type)
      create_navigation_properties(klass, type) if type.respond_to? :navigation_properties
    end

    def self.get_superklass(type)
      if type.base_type.nil?
        (type.class == OData::ComplexType) ?
          MicrosoftGraph::Base :
          MicrosoftGraph::BaseEntity
      else
        Object.const_get("MicrosoftGraph::" + classify(type.base_type))
      end
    end

その後の create_properties で、
metadata内のProperty(companyName,displayName等)分、ゲッターセッターを動的に定義し、
graph.me.display_name のように、プロパティにアクセスできるようになっていた。
また、:properties メソッドで自身のプロパティ一覧が参照できる。

lib/microsoft_graph/class_builder.rb
    def self.create_properties(klass, type)
      property_map = type.properties.map { |property|
        define_getter_and_setter(klass, property)
        [
          OData.convert_to_snake_case(property.name).to_sym,
          property
        ]
      }.to_h

      klass.class_eval do
        define_method(:properties) do
          super().merge(property_map)
        end
      end
    end

    def self.define_getter_and_setter(klass, property)
      klass.class_eval do
        property_name = OData.convert_to_snake_case(property.name)
        define_method(property_name.to_sym) do
          get(property_name.to_sym)
        end
        define_method("#{property_name}=".to_sym) do |value|
          set(property_name.to_sym, value)
        end
      end
    end

その後の create_navigation_properties で、
metadata内のNavigationProperty(calendar,events等)分、クラスのインスタンスメソッドとしてを動的に定義し、
graph.me.calendarのように、オブジェクトをチェインする形で容易に関連リソースにアクセスできるようになっていた。
また、:navigation_properties メソッドで自身のナビゲーションプロパティ一覧が参照できる。

lib/microsoft_graph/class_builder.rb
    def self.create_navigation_properties(klass, type)
      klass.class_eval do
        type.navigation_properties.each do |navigation_property|
          navigation_property_name = OData.convert_to_snake_case(navigation_property.name).to_sym
          define_method(navigation_property_name.to_sym) do
            get_navigation_property(navigation_property_name.to_sym)
          end
          unless navigation_property.collection?
            define_method("#{navigation_property_name}=".to_sym) do |value|
              set_navigation_property(navigation_property_name.to_sym, value)
            end
          end
        end

        define_method(:navigation_properties) do
          type.navigation_properties.map { |navigation_property|
            [
              OData.convert_to_snake_case(navigation_property.name).to_sym,
              navigation_property
            ]
          }.to_h
        end
      end
    end

load! メソッドの後方のSingleton定義処理をSingletonタグ(Name='me')の具体例と共に追ってみる。

lib/microsoft_graph/class_builder.rb
service.singletons.each do |singleton|
  class_name = classify(singleton.type_name)
  MicrosoftGraph.instance_eval do
    resource_name = singleton.name
    define_method(OData.convert_to_snake_case(resource_name)) do
      MicrosoftGraph
        .const_get(class_name)
        .new(
          graph:         self,
          resource_name: resource_name,
          parent:        self
        ).tap(&:fetch)
    end
  end
end
metadata_v1.0.xml
 <Singleton Name='me' Type='microsoft.graph.user'>
   <NavigationPropertyBinding Path='ownedDevices' Target='directoryObjects'/>
   <NavigationPropertyBinding Path='registeredDevices' Target='directoryObjects'/>
   <NavigationPropertyBinding Path='manager' Target='directoryObjects'/>
   <NavigationPropertyBinding Path='directReports' Target='directoryObjects'/>
   <NavigationPropertyBinding Path='memberOf' Target='directoryObjects'/>
   <NavigationPropertyBinding Path='createdObjects' Target='directoryObjects'/>
   <NavigationPropertyBinding Path='ownedObjects' Target='directoryObjects'/>
 </Singleton>

ここでは、すべてのSingletonタグのName(me等)を MicrosoftGraph クラスのインスタンスメソッドとして
指定クラス(meであれば、MicrosoftGraph::User)のインスタンスを初期化する処理を定義している。
メソッドの最後の tap(&:fetch) 処理で該当リソースの情報をAPIで取得する処理を行っていた。
これにより me メソッドが利用できるようになっていた。

APIアクセスしている処理は以下

lib/base_entity.rb
    def fetch
      @persisted = true
      initialize_serialized_properties(graph.service.get(path)[:attributes])
    end
lib/odata/service.rb
    def get(path, *select_properties)
      camel_case_select_properties = select_properties.map do |prop|
        OData.convert_to_camel_case(prop)
      end

      if ! camel_case_select_properties.empty?
        encoded_select_properties = URI.encode_www_form(
          '$select' => camel_case_select_properties.join(',')
        )
        path = "#{path}?#{encoded_select_properties}"
      end

      response = request(
        method: :get,
        uri: "#{base_url}#{path}"
      )
      {type: get_type_for_odata_response(response), attributes: response}
    end


    def request(options = {})
      uri = options[:uri]

      if @api_version then
        parsed_uri = URI(uri)
        params = URI.decode_www_form(parsed_uri.query || '')
                    .concat(@api_version.to_a)
        parsed_uri.query = URI.encode_www_form params
        uri = parsed_uri.to_s
      end

      req = Request.new(options[:method], uri, options[:data])
      @auth_callback.call(req) if @auth_callback
      req.perform
    end

コードを読む際にメタプログラミングの理解に以下記事が参考になった。

疑問2: graph.meの他にオブジェクト呼び出しができるメソッドはどこを見ればわかるの?

metadataの<NavigationProperty>を参考にするか、
該当オブジェクトで、:navigation_properties メソッドにて確認可能

.rb
graph.me.navigation_properties.keys
=> [:owned_devices, :registered_devices, :manager, :direct_reports, :member_of, :created_objects, :owned_objects, :messages, :mail_folders, :calendar, :calendars, :calendar_groups, :calendar_view, :events, :contacts, :contact_folders, :photo, :drive]

なお、参照できるプロパティ一覧は :properties メソッドで確認可能

.rb
me.properties.keys
=> [:id, :account_enabled, :assigned_licenses, :assigned_plans, :business_phones, :city, :company_name, :country, :department, :display_name, :given_name, :job_title, :mail, :mail_nickname, :mobile_phone, :on_premises_immutable_id, :on_premises_last_sync_date_time, :on_premises_security_identifier, :on_premises_sync_enabled, :password_policies, :password_profile, :office_location, :postal_code, :preferred_language, :provisioned_plans, :proxy_addresses, :state, :street_address, :surname, :usage_location, :user_principal_name, :user_type, :about_me, :birthday, :hire_date, :interests, :my_site, :past_projects, :preferred_name, :responsibilities, :schools, :skills]

疑問3: クエリオプションってオブジェクト呼び出し時に渡せるの?

ODataプロトコルでは、$filter,$count,$orderby,$skip,$top,$expand,$select
などの クエリオプション がサポートされているが、オブジェクト呼び出し時には指定できるの?

例えば、大量のカレンダーイベントを取得するときに $top を使えば、1リクエストで取得するアイテム数を増やすことが可能なので、
リクエスト数を減らすチューニングが可能になるが、コードを見る限りクエリオプションは渡せないようだ。

ライブラリの思想として、ユーザが容易に利用できることに重きを入れているのを感じとれた。(個人の感想)
カレンダーイベントなどの複数リソースを取得する処理(ページネーション)は、ライブラリの中でよしなにラップしてくれている。

lib/microsoft_graph/collection_association.rb
    def fetch_next_page
      @next_link ||= query_path

      result =
        begin
          @graph.service.get(@next_link)
        rescue OData::ClientError => e
          if matches = /Unsupported sort property '([^']*)'/.match(e.message)
            raise MicrosoftGraph::TypeError.new("Cannot sort by #{matches[1]}")
          elsif /OrderBy not supported/.match(e.message)
            if @order_by.length == 1
              raise MicrosoftGraph::TypeError.new("Cannot sort by #{@order_by.first}")
            else
              raise MicrosoftGraph::TypeError.new("Cannot sort by at least one field requested")
            end
          else
            raise e
          end
        end

      @next_link = result[:attributes]['@odata.next_link']
      @next_link.sub!(MicrosoftGraph::BASE_URL, "") if @next_link

      result[:attributes]['value'].each do |entity_hash|
        klass =
          if member_type = specified_member_type(entity_hash)
            ClassBuilder.get_namespaced_class(member_type.name)
          else
            default_member_class
          end
        @internal_values.push klass.new(attributes: entity_hash, parent: self, persisted: true)
      end
      @loaded = @next_link.nil?
    end

クエリオプションを使いたい場合、自前でクエリオプションを連結したURLを生成してHTTPリクエスト処理を実装する必要があるようだ。
URL生成の際には、path メソッドは役に立ちそう。

me.path
=> "users/xxx-xxx-xxx-xxx"

>> me.calendar.path
=> "users/xxx-xxx-xxx-xxx/calendars/yyyy_yyyy"

>> me.calendars.path
=> "users/xxx-xxx-xxx-xxx/calendars"

まとめ

microsoft_graph のソースコードを読んで、Rubyのメタプログラミングの技法や、ODataプロトコルの思想に触れて、コードを読める範囲が広がった気がする。とてもエレガントなライブラリでした。

1
2
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
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?