January 2018

Volume 33 Number 1

Office - Microsoft Graph と Azure Functions を使用して自社向けに API をビルドする

Mike Ammerlaan | January 2018

自社の組織を API として考えると、どうなるでしょう。 

まずは組織の中心になる人々について考えます。その人々が果たす役割や職務の種類について考えます。多くの場合、タスクやプロジェクトを遂行する仮想チームを定義して、こうした人々をグループ化します。続いてグループのメンバーが作業に使用する環境やツールなどのリソースを階層化します。次に、プロセスと作業アクティビティを追加します。おそらく、これらが API のメソッドになります。"widgetMarketingTeam.runCampaign()" は恐らくシンプルすぎるかもしれません。それでも、自社の組織向けの API があれば、自社が機能するしくみについて優れた洞察が得られます。さらに、効率の高いプロセスとツールをビルドすることで生産性を変革できる可能性があります。

重要なのは、すべてのリソースを一貫して利用できるようにし、論理的に相互に結び付けることです。そうすることで、個人やチームの作業方法に合った包括的なプロセスを作成できます。多くの API をまとめて結び付けることができれば、ビルドする最終的な製品セットは単なるパーツの寄せ集めではなく、はるかに有用なものになります。

そのため、マイクロソフトは Microsoft Graph を用意しています。Microsoft Graph は、自社組織の主要データ セットにまたがって機能する API です。これを使用すると、作業の実行方法を変えるのに必要なすべてを結び付けることができます。さらに、Microsoft Graph は OneDrive や Mail (Outlook.com) などのコンシューマー サービスと連携します。そのため、個人の生産性の変革にも役に立ちます。

API が無秩序に広がる問題を解決する

組織全体を考えると、使用しているソフトウェア システムのセットは大きく異なることがあります。開発者の立場からは、各システムに独特の構造が存在します。通常は、個別の API セット、認証要件、相互作用のスタイルがそれぞれ異なります。多くの場合、ソフトウェア プロジェクトの大きな課題となるのは、こうしたさまざまなシステムを単純に橋渡しし、高いレベルの洞察を提供することです。また、異なる API を抽象化し、個々の認証方式を習得することも課題になる可能性があります。

過去を見ても、さまざまな製品チーム (ここでは Microsoft の製品チーム) が提供するそれぞれの API は動作が異なり、製品間での統合が必要になります。5 年前でさえ、ユーザーの完全なプロファイルと写真を取得するプロセスでは、Exchange API (個人についての情報の取得) と SharePoint API (管理されたユーザー プロファイルからの写真の取得) の両方への呼び出しが必要でした。それぞれに独自の認証方式、API 手法など異なる要件がありました。もしも個人の上司に関する情報を取得するとしたらどうなるでしょう。この場合、3 つ目のシステムに照会して、組織階層を取得することが必要になります。これらの操作をすべて結び付けることは可能でしたが、必要以上に複雑です。

こうした問題を解決したいという願望から誕生したのが Microsoft Graph です。データと認証を統一し、システムに一貫性を持たせることによって、最終的な API セットの使用がはるかに簡単かつ実用的になります。Microsoft Graph は組織全体の多種多様なシステムを結び付け、社内の主要なファセットや機能を表現します。2 年前のリリース以降、Microsoft Graph は機能と能力の両面で幅広く拡張され続けており、組織の基本 API として実際に機能するところまできています。

Microsoft Graph の核となるのは、ユーザーのセットです。ユーザーのセットとは、通常、組織内にアカウントを持つすべての従業員を指します。Microsoft Graph では新たな概念として、簡略化し、一元管理するグループが導入されています。最初のグループは、一般に、ユーザーとその他のセキュリティ グループの一覧を含みます。グループにはリソースのセットを関連付けて含めることができます。Microsoft Teams のチャットベースのワークスペース、Planner タスク ボード、ドキュメント ライブラリとファイルを含む SharePoint サイトなどがこうしたリソースの例です。そこから、ユーザーとグループ向けの作業用のさまざまなツールを表現します。Drive API のファイル、Planner API のタスク、ユーザーとグループ両方の着信メール、連絡先、予定表などがツールの例です (図 1 参照)。

Microsoft Graph の生産性 API
図 1 Microsoft Graph の生産性 API

Microsoft Graph の API には、長い時間をかけて新しい機能を追加してきています。Microsoft Graph でカスタム メタデータと項目を一緒に保持する新しい機能は、それらの項目を細かくカスタマイズする機能を提供します。グループは単なるグループではなくなりました。講義内容、講師、時間割を説明する追加メタデータを提供すると、グループは教育機関の授業を表現できます。その後、このメタデータを使用してクエリを実行します。たとえば、科学の授業を表現するすべてのグループを検索できます。または、Microsoft Graph 内の関連エンティティにシステムの ID を 追加して、システムを Microsoft Graph に結び付けてもかまいません。

Microsoft Graph は核となる オブジェクトの作成、読み取り、更新、削除 (CRUD) 用の API を提供するだけではありません。大きな特徴は、ユーザーの作業に応じて背後で生み出される洞察の層にあります。たとえば、Graph には組織全体の階層とグループのコレクションが含まれていますが、それらはチームの作業方法にとって最適な表現とは限りません。作業を分析することで、最も密接に関係するユーザー (仮想チーム) やファイルの一覧を取得して、ユーザーを結び付けることができます。さらに、あるユーザーの一団が会議に出席できる時間などの共通ユーティリティをメソッドとして利用できるようになります。

Azure Functions

Microsoft Graph は広範なシステムやプロセスで使用し、カスタマイズすることを目的とします。Microsoft Graph は、シンプルな REST API として、また、さまざまな SDK と組み合わせて、簡単に連携できるように設計されています。Microsoft Graph でのプロセスのビルドや統合に適した選択肢が Azure Functions (https://azure.microsoft.com/ja-jp/services/functions/) です。Azure Functions では、コードの ブロックを必要なところにピンポイントで追加でき、コードを使用した分だけに従量制で課金されます。Azure Functions では、C# や Node.js など、言語をまたがる開発をサポートします。

最近リリースされた Azure Functions との統合の新しいセットを使用すると、簡単に Microsoft Graph に結び付けることができます。Azure Functions バインド拡張機能が Azure Functions 2.0 ランタイムのプレビューで利用できるようになります。これにより、Microsoft Graph と連携する共通タスクの一部が自動化されます。このような共通タスクには、認証や、webhook のメカニズムを操作することなどがあります。

ここからは、Microsoft Graph での作業に着手する例を見ていきます。

Azure Functions によるタスクの作成

たとえば、チームのメンバーが行った作業を上司が確認して承認するとします。ユーザー タスクは、操作を行うようユーザーに求める方法の 1 つです。このタスクを使って、人間の操作を変換して追跡します。今回は、ユーザーの上司に割り当てるタスクを作成するシンプルな Web サービスを実装します。 

通常、Microsoft Graph プロジェクトで最初に使用するのは Graph エクスプローラーです。Graph エクスプローラーは、Microsoft Graph の呼び出しをすばやくモデル化し、その結果を調べ、実行する可能性があるすべてを想定できるようにするアプリケーション Web サイトです。Graph エクスプローラーは、developer.microsoft.com/graph から入手できます。Graph エクスプローラーは、読み取り専用のデモ テナントを使用するか、独自のテナントにサインインできるようにします。組織のアカウントを使用してサインインすると、組織独自のデータに直接アクセスできます。Office Developer Program (dev.office.com/devprogram、英語) から取得できる開発者テナントを使用するのがお勧めです。これにより、自身の開発を自由にテストできる個別のテナントが提供されます。

今回は、2 つのシンプルな URL を入力して、このサンプルで行う呼び出しの種類を確認します。まず、「ユーザーの上司を取得」する方法を確認します。これは、Graph エクスプローラーで [GET my manager] (上司の取得) サンプルを選択すると確認できます (図 2 参照)。対応する URL が、[Run Query] (クエリを実行) フィールドに表示されます。

[GET my manager] を選択した結果
図 2 [GET my manager] を選択した結果

操作の 2 つ目のパーツは、Planner タスクの作成です。Graph エクスプローラーで、一連のサンプルを展開して Planner タスクのサンプルを追加します。このサンプル セット内で、Planner タスクを作成する操作を確認できます (https://graph.microsoft.com/v1.0/planner/tasks への POST)。

これで、必要な Web サービス要求がわかったので、Azure Functions を使用して関数を作成します。

まず、新しい Azure Functions アプリケーションを作成します。通常は /ja-jp/azure/azure-functions/functions-bindings-microsoft-graph の指示に従ってこの作業を完了します。手短に言うと、新しい Azure Functions バインド拡張機能はプレビュー段階にあるため、Azure Functions アプリケーションを 2.0 プレビュー ("ベータ") ランタイムに切り替える必要があります。また、Microsoft Graph 拡張機能をインストールし、App Service 認証を構成することも必要です。

Microsoft Graph のアプリケーション登録を構成する場合、このサンプルでは、上司の情報と次に示すタスクを読み取れるように、さらにいくつかのアクセス許可が必要になります。

  • ユーザー タスクとプロジェクトの作成、読み取り、更新および削除 (Tasks.ReadWrite)
  • ユーザーの基本プロファイルの表示 (profile)
  • すべてのグループの読み取りと書き込み (Group.ReadWrite.All)
  • すべてのユーザーの基本プロファイルの読み取り (User.ReadBasic.All)

Microsoft Graph 用の Azure Functions バインド拡張機能を利用して認証を処理し、Microsoft Graph API へのアクセスに使用する認証済みアクセス トークンを取得します。これを行うには、標準 HTTP C# トリガーを作成します。[Integrate] (統合) で [Advanced Editor] (詳細エディター) を選択し、図 3 に示す bindings を使用します。これを使用するには、ユーザーはアプリケーションにサインインし、アプリケーションの認証と承認を受ける必要があります。

図 3 認証を処理する HTTP トリガーの作成

{
  "bindings": [
    {
      "name": "req",
      "type": "httpTrigger",
      "direction": "in"
    },
    {
      "type": "token",
      "direction": "in",
      "name": "accessToken",
      "resource": "https://graph.microsoft.com",
      "identity": "userFromRequest"
    },
    {
      "name": "$return",
      "type": "http",
      "direction": "out"
    }
  ],
  "disabled": false
}

この関数のコードを 図 4 に示します。関数アプリケーションの環境変数 PlanId を構成する必要があります。この変数は、タスクに使用する Planner の計画 ID を含みます。この構成を行うには、関数アプリケーションの [Application Settings] (アプリケーションの設定) を使用します。

図 4 Azure Functions ソースであるユーザーの上司に割り当てられたタスクのポスト

#r "Newtonsoft.Json"
using System.Net;
using System.Threading.Tasks;
using System.Configuration;
using System.Net.Mail;
using System.IO;
using System.Web;
using System.Text;
using Newtonsoft.Json.Linq;
public static HttpResponseMessage Run(HttpRequestMessage req, string accessToken, TraceWriter log)
{
  log.Info("Processing incoming task creation requests.");
  // Retrieve data from query string
  // Expected format is taskTitle=task text&taskBucket=bucket
  // title&taskPriority=alert
  var values = HttpUtility.ParseQueryString(req.RequestUri.Query);
  string taskTitle = values["taskTitle"];
  string taskBucket = values["taskBucket"];
  string taskPriority = values["taskPriority"];
  if (String.IsNullOrEmpty(taskTitle))
  {
    log.Info("Incomplete request received - no title.");
    return new HttpResponseMessage(HttpStatusCode.BadRequest);
  }
  string planId = System.Environment.GetEnvironmentVariable("PlanId");
  // Retrieve the incoming users' managers ID
  string managerJson = GetJson(
    "https://graph.microsoft.com/v1.0/me/manager/", accessToken, log);
    dynamic manager = JObject.Parse(managerJson);
  string managerId = manager.id;
  string appliedCategories = "{}";
  if (taskPriority == "alert" || taskPriority == "1")
  {
    appliedCategories = "{ \"category1\": true }";
  }
  else
  {
    appliedCategories = "{ \"category2\": true }";
  }
  string now =  DateTime.UtcNow.ToString("yyyy-MM-ddTHH\\:mm\\:ss.fffffffzzz");
  string due =  DateTime.UtcNow.AddDays(5).ToString(
    "yyyy-MM-ddTHH\\:mm\\:ss.fffffffzzz");
  string bucketId = "";
  // If the incoming request wants to place a task in a bucket,
  // find the bucket ID to add it to
  if (!String.IsNullOrEmpty(taskBucket))
  {
    // Retrieve a list of planner buckets so that you can match
    // the task to a bucket, where possible
    string bucketsJson = GetJson(
      "https://graph.microsoft.com/v1.0/planner/plans/" + planId +
      "/buckets", accessToken, log);
    if (!String.IsNullOrEmpty(bucketsJson))
    {
      dynamic existingBuckets = JObject.Parse(bucketsJson);
      taskBucket = taskBucket.ToLower();
      foreach (var bucket in existingBuckets.value)
      {
        var existingBucketTitle = bucket.name.ToString().ToLower();
        if (taskBucket.IndexOf(existingBucketTitle) >= 0)
        {
          bucketId = ", \"bucketId\": \"" + bucket.id.ToString() + "\"";
        }
      }
    }
  }
  string jsonOutput = String.Format(" {{ \"planId\": \"{0}\", \"title\": \"{1}\", \"orderHint\": \" !\", \"startDateTime\": \"{2}\", \"dueDateTime\": \"{6}\", \"appliedCategories\": {3}, \"assignments\": {{ \"{4}\": {{ \"@odata.type\": \"#microsoft.graph.plannerAssignment\",  \"orderHint\": \" !\"  }} }}{5} }}",
    planId, taskTitle, now, appliedCategories, managerId, bucketId, due);
  log.Info("Creating new task: " + jsonOutput);
  PostJson("https://graph.microsoft.com/v1.0/planner/tasks",
    jsonOutput, accessToken, log);
  return new HttpResponseMessage(HttpStatusCode.OK);
}
private static string GetJson(string url, string token, TraceWriter log)
{
  HttpWebRequest hwr = (HttpWebRequest)WebRequest.CreateHttp(url);
  log.Info("Getting Json from endpoint '" + url + "'");
  hwr.Headers.Add("Authorization", "Bearer " + token);
  hwr.ContentType = "application/json";
  WebResponse response = null;
  try
  {
    response = hwr.GetResponse();
    using (Stream stream = response.GetResponseStream())
    {
      using (StreamReader sr = new StreamReader(stream))
      {
        return sr.ReadToEnd();
      }
     }
  }
  catch (Exception e)
  {
    log.Info("Error: " + e.Message);
  }
  return null;
}
private static string PostJson(string url, string body, string token, TraceWriter log)
{
  HttpWebRequest hwr = (HttpWebRequest)WebRequest.CreateHttp(url);
  log.Info("Posting to endpoint " + url);
  hwr.Method = "POST";
  hwr.Headers.Add("Authorization", "Bearer " + token);
  hwr.ContentType = "application/json";
  var postData = Encoding.UTF8.GetBytes(body.ToString());
  using (var stream = hwr.GetRequestStream())
  {
  stream.Write(postData, 0, postData.Length);
  }
  WebResponse response = null;
  try
  {
    response = hwr.GetResponse();
    using (Stream stream = response.GetResponseStream())
    {
      using (StreamReader sr = new StreamReader(stream))
      {
        return sr.ReadToEnd();
      }
    }
  }
  catch (Exception e)
  {
    log.Info("Error: " + e.Message);
  }
  return null;
}

このサンプルは、1 つの認証トークンを指定する 1 つのコード ピースに、データのさまざまなセット (この例の場合は、ユーザーの上司と Planner タスク) をまとめる方法を示しています。タスクの作成と割り当ては、複数のチームにまたがるアクティビティを駆動する一般的な方法です。そのため、実行時にタスクを作成して既存の Planner エクスペリエンスを利用できるのは非常に便利です。"widgetMarketingTeam.launchCampaign()" では不十分かもしれません。ですが、少なくとも、チームに焦点を絞った体系的なスタートを切ることができるタスクのスターター セットを作成する方法はおわかりいただけるでしょう。

OneDrive でのファイルの処理

ユーザーの OneDrive に存在するファイルを処理するタスクも実行できます。この例では、Microsoft Graph 用の Azure Functions バインド拡張機能を利用して、使用するファイルを準備する作業を行います。その後、準備したファイルを Cognitive Services API に渡して、音声認識を行います。これはデータ処理の一例で、OneDrive と SharePoint 間でファイルからより多くの価値を得るのに有効です。

まず、関数アプリケーションのセットアップや Azure Active Directory 登録など、前の例と同じ手順をいくつか実行します。このサンプルで使用する Azure Active Directory のアプリケーション登録では、「ユーザーがアクセスできるすべてのファイルの読み取り」 (Files.Read.All) アクセス許可が必要になります。また、Cognitive Services Speech API キーも必要です。このキーは、https://azure.microsoft.com/ja-jp/try/cognitive-services/?api=speech-api から入手できます。

既に述べたように、Azure Functions バインド拡張機能から開始して、新しい HTTP C# トリガーを設定します。関数の [Integrate] (統合) タブで、図 5 に示すバインド マークアップを使用して、関数をバインド拡張機能に結び付けます。この例のバインド拡張機能は Azure 関数の myOneDriveFile パラメーターを onedrive バインド拡張機能に結び付けます。

図 5 OneDrive 上のファイルを取得する新しいトリガーの設定

{
  "bindings": [
    {
      "name": "req",
      "type": "httpTrigger",
      "direction": "in"
    },
    {
      "name": "myOneDriveFile",
      "type": "onedrive",
      "direction": "in",
      "path": "{query.filename}",
      "identity": "userFromRequest",
    },
    {
      "name": "$return",
      "type": "http",
      "direction": "out"
    }
  ],
  "disabled": false
}

ここで、図 6 に示すコードが必要になります。

図 6 One Drive からのオーディオ ファイルの書き起こし

#r "Newtonsoft.Json"
using System.Net;
using System.Text;
using System.Configuration;
using Newtonsoft.Json.Linq;
public static  async Task<HttpResponseMessage> Run(HttpRequestMessage req,
  Stream myOneDriveFile, TraceWriter log)
{
  // Download the contents of the audio file
  log.Info("Downloading audio file contents...");
  byte[] audioBytes;
  audioBytes = StreamToBytes(myOneDriveFile);
  // Transcribe the file using cognitive services APIs
  log.Info($"Retrieving the cognitive services access token...");
  var accessToken =
    System.Environment.GetEnvironmentVariable("SpeechApiKey");
  var bingAuthToken = await FetchCognitiveAccessTokenAsync(accessToken);
  log.Info($"Transcribing the file...");
  var transcriptionValue = await RequestTranscriptionAsync(
    audioBytes, "en-us", bingAuthToken, log);
  HttpResponseMessage hrm = new HttpResponseMessage(HttpStatusCode.OK);
  if (null != transcriptionValue)
  {
    hrm.Content = new StringContent(transcriptionValue, Encoding.UTF8, "text/html");
  }
  else
  {
    hrm.Content = new StringContent("Content could not be transcribed.");
  }
  return hrm;
}
private static async Task<string> RequestTranscriptionAsync(byte[] audioBytes,
  string languageCode, string authToken, TraceWriter log)
{
  string conversation_url = $"https://speech.platform.bing.com/speech/recognition/conversation/cognitiveservices/v1?language={languageCode}";
  string dictation_url = $"https://speech.platform.bing.com/speech/recognition/dictation/cognitiveservices/v1?language={languageCode}";
  HttpResponseMessage response = null;
  string responseJson = "default";
  try
  {
    response = await PostAudioRequestAsync(conversation_url, audioBytes, authToken);
    responseJson = await response.Content.ReadAsStringAsync();
    JObject data = JObject.Parse(responseJson);
    return data["DisplayText"].ToString();
  }
  catch (Exception ex)
  {
    log.Error($"Unexpected response from transcription service A: {ex.Message} |" +
      responseJson + "|" + response.StatusCode  + "|" +
      response.Headers.ToString() +"|");
    return null;
  }
}
private static async Task<HttpResponseMessage> PostAudioRequestAsync(
  string url, byte[] bodyContents, string authToken)
{
  var payload = new ByteArrayContent(bodyContents);
  HttpResponseMessage response;
  using (var client = new HttpClient())
  {
    client.DefaultRequestHeaders.Add("Authorization", "Bearer " + authToken);
    payload.Headers.TryAddWithoutValidation("content-type", "audio/wav");
    response = await client.PostAsync(url, payload);
  }
  return response;
}
private static byte[] StreamToBytes(Stream stream)
{
  using (MemoryStream ms = new MemoryStream())
  {
    stream.CopyTo(ms);
    return ms.ToArray();
  }
}
private static async Task<string> FetchCognitiveAccessTokenAsync(
  string subscriptionKey)
{
  string fetchUri = "https://api.cognitive.microsoft.com/sts/v1.0";
  using (var client = new HttpClient())
  {
    client.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key", subscriptionKey);
    UriBuilder uriBuilder = new UriBuilder(fetchUri);
    uriBuilder.Path += "/issueToken";
    var response = await client.PostAsync(uriBuilder.Uri.AbsoluteUri, null);
    return await response.Content.ReadAsStringAsync();
  }
}

この関数を適切に使用すれば、ユーザーは Azure 関数にサインインした後で filename パラメーターを指定できます。ファイル名に .WAV が含まれていて、ファイルに英語のコンテンツが含まれている場合、そのコンテンツが英語のテキストとして書き起こされます。これは Azure Functions を使って実装しているため、通常、関数の呼び出しに応じてのみ料金が発生します。Azure Functions は、Microsoft Graph で保持されているデータを拡張する柔軟な方法を提供します。

Azure Functions と Microsoft Graph

ここで紹介した 2 つのサンプルは、Microsoft Graph 内でデータの上位に人的プロセスと技術プロセスの両方を築き上げる方法を示しています。Microsoft Graph と複数のワークロードにまたがる機能 (たとえば、今回のタスク サンプルの例で示した組織の階層とタスク) を幅広く組み合わせると、組織全体の価値を築き、高めることができます。Microsoft Graph と Azure Functions を組み合わせると、自社組織の完全な API を組み立てて、全員の生産性を変革することができます。developer.microsoft.com/graph にアクセスし、https://azure.microsoft.com/ja-jp/services/functions/ の Azure Functions を使用して、組織のソリューションをすぐにビルドしましょう。


Mike Ammerlaan は Microsoft Office Ecosystem チームで製品マーケティングのディレクターを務めており、ユーザーが Office 365 を使用して魅力的なソリューションをビルドするのを支援しています。以前は、18 年間 Microsoft でプログラム マネージャーとして働き、SharePoint、Excel、Yammer、Bing Maps、Combat Flight Simulator などの製品を開発してきました。

この記事のレビューに協力してくれたマイクロソフト技術スタッフの   Ryan Gregg、Matthew Henderson、および Dan Silver に心より感謝いたします。


この記事について MSDN マガジン フォーラムで議論する