動機と概要
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
メソッドで確認可能。
- A2. metadataファイル内の
-
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"型のデータ定義を抜粋。
<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より
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
より
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!
より
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型の抜粋。
<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::<クラス名>
でクラス化。
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
メソッドで自身のプロパティ一覧が参照できる。
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
メソッドで自身のナビゲーションプロパティ一覧が参照できる。
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')の具体例と共に追ってみる。
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
<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アクセスしている処理は以下
def fetch
@persisted = true
initialize_serialized_properties(graph.service.get(path)[:attributes])
end
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
メソッドにて確認可能
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
メソッドで確認可能
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リクエストで取得するアイテム数を増やすことが可能なので、
リクエスト数を減らすチューニングが可能になるが、コードを見る限りクエリオプションは渡せないようだ。
ライブラリの思想として、ユーザが容易に利用できることに重きを入れているのを感じとれた。(個人の感想)
カレンダーイベントなどの複数リソースを取得する処理(ページネーション)は、ライブラリの中でよしなにラップしてくれている。
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プロトコルの思想に触れて、コードを読める範囲が広がった気がする。とてもエレガントなライブラリでした。