microsoftgraph/msgraph-sdk-ruby を読む


## 動機と概要

[Microsoft Graph](https://developer.microsoft.com/ja-jp/graph) を利用する機会があり、公式rubyライブラリ [microsoftgraph/msgraph-sdk-ruby](https://github.com/microsoftgraph/msgraph-sdk-ruby) を使う上で生じた疑問をソースコードを読んでスッキリした際の備忘録。

- Gemは [microsoft_graph](https://rubygems.org/gems/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形式)

https://graph.microsoft.com/v1.0/$metadata

"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形式については以下記事が理解の助けになりました。

- 参考
  - [Microsoft データ プロトコル (OData) を開く](https://docs.microsoft.com/ja-jp/dynamics365/fin-ops-core/dev-itpro/data-entities/odata)
  - [Microsoft Graph 内を移動してデータとメソッドにアクセスする](https://docs.microsoft.com/ja-jp/graph/traverse-the-graph)
  - [【SAPUI5】OData(1) ODataとは](https://qiita.com/tami/items/411a226d1ea6bb25b5f1)
  - [ODataにさわってみよう](https://www.cresco.co.jp/blog/entry/3135/)

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

Microsoft Graph の公式Rubyライブラリ [microsoftgraph/msgraph-sdk-ruby](https://github.com/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
```

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

- 参考
  - [Rubyでメタプログラミングことはじめ](https://qiita.com/iguchi1124/items/a2a64f27e6dabbac1301)


### 疑問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`
などの [クエリオプション](https://docs.microsoft.com/ja-jp/graph/query-parameters) がサポートされているが、オブジェクト呼び出し時には指定できるの？


例えば、大量のカレンダーイベントを取得するときに `$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](https://rubygems.org/gems/microsoft_graph/)　のソースコードを読んで、Rubyのメタプログラミングの技法や、ODataプロトコルの思想に触れて、コードを読める範囲が広がった気がする。とてもエレガントなライブラリでした。