からめもぶろぐ。

俺たちは雰囲気で OAuth をやっている

SharePoint REST API を使ってリスト アイテムの画像列を更新する

SharePoint Online には新しい列の種類として 2020 年より画像列というものが追加されています。これまでカスタム リストで画像を扱う場合は添付ファイルに設定することが多かったのですが、画像を列として表現できるようになったため、利便性が高くなっていると思います。ところでやはり気になるのはプログラムからどうやって設定するの?というところです。CSOM ではまだ対応していないようですが、REST API を使ってできそうなのでやってみました。

前提

画像列は列のデータとして画像が埋め込まれているわけではなく、画像は別のドキュメント ライブラリにあって、そこへの情報を JSON 形式で持っているという形になります。通常 UI から画像をアップロードしたときは「サイトのリソース ファイル」に格納されます。なので手順としては、

  • サイトのリソース ファイルに画像をアップロード
  • リスト アイテムの列に JSON 情報を入れて更新

という流れになります。

手順

画像のアップロード

UI からアップロードしたときは「/SiteAssets/Lists/{{listid}}」のフォルダーにファイルが格納されます。自分でフォルダーを作成するのは大変なので UploadImage という専用の REST API を使うことにします。URL は以下のような形で本文にファイルのコンテンツを含めます。

POST https://example.sharepoint.com/sites/TestSite1/_api/web/uploadimage(listtitle=@v1,imagename=@v2,listid=@v3,itemid=@v4)?@v1='Test List 1'&@v2='image.png'&@v3='1c36d431-f9a0-43b2-8284-cae8ccf5ca3b'&@v4=1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJ0eX...

結果として以下のような JSON が返却されます。

{
  "odata.metadata": "https://example.sharepoint.com/sites/TestSite1/_api/$metadata#SP.SPImageItem",
  "Name": "image.png",
  "ServerRelativeUrl": "/sites/TestSite1/SiteAssets/Lists/1c36d431-f9a0-43b2-8284-cae8ccf5ca3b/image.png",
  "UniqueId": "2e8f34a1-4237-4bb6-8941-61af7e985204"
}

リスト アイテムの更新

画像列は内部的には複数行テキスト列になっています。なので通常と同様に JSON 文字列を設定してあげれば大丈夫です。JSON を渡すのではなく JSON 文字列を渡すので注意してください。基本的には画像のアップロードの結果で取得した情報をもとに作成できるはずです。

POST https://example.sharepoint.com/sites/TestSite1/_api/web/lists('1c36d431-f9a0-43b2-8284-cae8ccf5ca3b')/items(1)
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJ0eX...
If-Match: *
X-HTTP-Method: MERGE

{
  "ImageField1": "{\"type\":\"thumbnail\",\"fileName\":\"image.png\",\"fieldName\":\"ImageField1\",\"serverUrl\":\"https://example.sharepoint.com\",\"serverRelativeUrl\":\"/sites/TestSite1/SiteAssets/Lists/1c36d431-f9a0-43b2-8284-cae8ccf5ca3b/image.png\",\"id\":\"2c5377bf-ec2f-4b44-9fa4-9867881d4bf5\"}"
}

JSON のみを展開すると以下のような感じになります。

{
  "type": "thumbnail",
  "fileName": "image.png",
  "fieldName": "ImageField1",
  "serverUrl": "https://example.sharepoint.com",
  "serverRelativeUrl": "/sites/TestSite1/SiteAssets/Lists/1c36d431-f9a0-43b2-8284-cae8ccf5ca3b/image.png",
  "id": "2c5377bf-ec2f-4b44-9fa4-9867881d4bf5"
}

ちなみに JSON の最小構成としては

{
  "type": "thumbnail",
  "serverRelativeUrl": "/sites/TestSite1/SiteAssets/Lists/1c36d431-f9a0-43b2-8284-cae8ccf5ca3b/image.png",
}

のようにいくつかのプロパティを省略しても動作するのですが、結果の表示のされ方が異なります。すべてのプロパティを設定した場合は Microsoft Graph の /drives/{{driveid}}/items/{{itemid}}/thumbnails を呼び出します。設定しない場合は serverRelativeUrl が呼び出されます。サムネイルを使うことでパフォーマンスが向上するため設定したほうがいいでしょう。

docs.microsoft.com

Power Automate + Office スクリプトを共有して実行できるようにする

非常に強力な Power Automate と Office スクリプトの組み合わせですが、そのままではほかの人に共有して実行できないという欠点があります。普通に共有した場合、以下のようにエラーが出てしまいます。

f:id:karamem0:20210818135245p:plain

スクリプトが見つかりません。共有が解除されたか、削除された可能性があります。

こちらの記事で解決策は提示されているものの、作成者のユーザーの接続を使って実行させるため、ちょっと気持ち悪いところがあります。

qiita.com

できれば実行者のコンテキストで実行させたいところです。そこでなんとかできないものかと頑張ってみました。

前提

Power Automate の実行結果を見てみると、Office スクリプトを「ms-officescript://onedrive_business_itemlink/{{fileid}}」の形式で指定していることがわかります。この fileid は Microsoft Graph の Drive API で取得できるファイルの ID です。この ID が作成者のユーザーの OneDrive for Business のファイルを見ているため実行できないということがわかります。この ID を実行時に変更してあげれば、自分の OneDrive for Business にある任意のスクリプトを実行できそうです。

手順

Office スクリプトを作成する

Excel on the web で Office スクリプトを作成します。value を受け取ってセルに書き込むだけの簡単なスクリプトです。

function main(workbook: ExcelScript.Workbook, value: string)
{
  const worksheet = workbook.getActiveWorksheet();
  worksheet.getCell(0, 0).setValue(value);
}

保存するとスクリプトは Documents/Office Scripts フォルダーに格納されます。

Office スクリプトを公開された場所に格納する

共有されたユーザーがアクセスできる場所であればどこでもいいのですが、今回は SharePoint のドキュメント ライブラリにアップロードします。ついでにスクリプトを実行する Excel ファイルのテンプレートもアップロードします。Power Automate を実行したときに、ここのファイルを自分の OneDrive for Business にコピーして実行するようにします。

f:id:karamem0:20210818140110p:plain

フローを作成する

全体像はこうなりました。Excel ファイルは任意の場所に、Office スクリプト ファイルは Documents/Office Scripts フォルダーにコピーするようにします。

f:id:karamem0:20210818140329p:plain

最後にこれらのファイルを指定してスクリプトを実行するようにします。

f:id:karamem0:20210818140527p:plain

ファイルを作成したときに出力として ID が取得できます。これは「{{driveid}}.{{fileid}}」の形式になっているのでピリオドで分割して設定してあげます。

  • ドキュメント ライブラリ
split(outputs('テンプレート_ファイルを作成します')?['body/Id'],'.')[0]
  • ファイル
split(outputs('テンプレート_ファイルを作成します')?['body/Id'],'.')[1]
  • スクリプト
concat('ms-officescript%3A%2F%2Fonedrive_business_itemlink%2F',split(outputs('スクリプト_ファイルを作成を作成します')?['body/Id'],'.')[1])

ScriptParameters には Office スクリプトに渡すパラメーターを JSON 形式で指定します。今回は value パラメーターを受け取るので以下のようにしています。

{
  "value": "OfficeScripts1"
}

実行

共有されたユーザーで実行するとフローが正常に終了しているのがわかります。

f:id:karamem0:20210818142044p:plain

ちゃんと結果も書き込まれていますね。

f:id:karamem0:20210818142154p:plain

まとめ

今回は手動で実行するフローでしたが Power Apps からの呼び出しでも同様のテクニックが使えるのではないかと思います。

Microsoft Teams アプリから Microsoft Graph を使用するためのベスト プラクティス

Microsoft Teams アプリを開発する上で Microsoft Graph と連携することは欠かせません。Microsoft Graph は Microsoft 365 の統一されたエンドポイントであり、社内のさまざまなリソースにアクセスすることができます。例えば以下のようなシナリオが考えられます。

  • チームの同僚のプロフィール画像を表示したい。
  • アプリに表示されている内容を自分のタスクとして Planner に追加したい。
  • 指定したユーザーと来週のオンライン ミーティングを設定したい。

Microsoft Teams アプリはチームのコラボレーションや生産性を高める目的で作成されることが多いです。そのためユーザビリティを損なうことなくどのようにして Microsoft Graph にアクセスするかは大きな課題になります。そこでこの記事では Microsoft Teams アプリから Microsoft Graph を使用するためのベスト プラクティス (およびいくつかのテクニック) について考えてみたいと思います。

429 (Too Many Requests) HTTP ステータスに対応する

Microsoft Graph に限らず、API サービスはリクエストに対してスロットリングを設けることで、サービスが高負荷になることを防止しています。リクエストがしきい値を超える場合、リクエストは処理されず、429 HTTP ステータスが返されます。
Microsoft Graph におけるスロットリングのしきい値は以下の docs に公開されています。

docs.microsoft.com

docs にもある通り、429 HTTP ステータスに対しては「エラーに対応する」「エラーを回避する」という 2 つの方法が必要です。これらはどちらか片方だけでいいというものではなく、どちらも実施する必要があります。

エラーに対応する

429 HTTP ステータスが発生する場合はレスポンスに Retry-After ヘッダーが含まれるので、ヘッダーで指定された秒数だけ待機して、リトライをするように設計する必要があります。とはいえリトライを自前で実装するのは大変なので、Microsoft Graph SDK を使うことを検討してください。Microsoft Graph SDK には RetryHandler というミドルウェアが存在し、429 HTTP ステータスが発生したときのリトライを代わりに行ってくれます。

エラーを回避する

バッチ リクエストについて

バッチ リクエストは Microsoft Graph へのリクエストそのものを減らしてパフォーマンスを向上させるテクニックですが、それぞれのリクエストが個別に評価されるため、429 HTTP ステータスの回避には役立ちません。また、リクエストの一部が 429 HTTP ステータスになったとしても、全体としては 200 HTTP ステータスを返すことがあるため、Microsoft Graph SDK はリトライを行いません。

レスポンスをキャッシュする

Microsoft Teams アプリはタブが表示されるために新しいページが読み込みなおされます。ユーザーがタブを切り替えることは頻繁にあるため、そのたびに Microsoft Graph リクエストを送っていてはすぐにスロットリングのしきい値に達してしまいます。取得したリクエストは一定期間キャッシュすることで余計なリクエストを減らすほかパフォーマンスの向上にもつながります。キャッシュの場所としては IndexedDB を使うのがいいでしょう。また Microsoft Graph Toolkit を使用すると自動的にキャッシュも行ってくれます。

トークンの有効期間に対応する

Microsoft Graph へのリクエストには OAuth によるアクセス トークンが必要になります。アクセス トークンの有効期間はアプリケーションの種類や組織のポリシーによって期間が異なり、docs には明示されていないものの、通常は 1 時間から数時間くらいです。*1

docs.microsoft.com

Microsoft Teams は仕事をしている間はずっと起動しているため、Microsoft Teams アプリも長時間起動し続けている可能性があります。アプリの使用中によってアクセス トークンの有効期間が切れるシナリオは想定しておく必要があります。

アクセス トークンの期間切れを検知する

Microsoft Graph SDK や Microsoft Graph Toolkit を使用している場合、アクセス トークンを渡すロジックはコールバックになっています。よってアクセス トークンが要求されるタイミングは開発者が制御することができます。このタイミングでアクセス トークンの有効期間が切れているかどうかをチェックする必要があります。Azure AD のアクセス トークンは JWT なのでデコードして中身を検証することができます。exp には UNIX 時間 (1970/01/01 からの経過秒数) が含まれるため、これが過去の時間を示すときには適切なハンドリングを行う必要があります。

アクセス トークンの期間切れに対応する

手動で再表示するようにユーザーに促す

あまりスマートな方法でありませんが、一時的な回避方法として、エラー画面を表示して再表示させるというのが簡単です。アクセス トークンはアプリの初期化時に取得することが多いので、再表示をさせることで問題を解決できます。

自動的にアクセス トークンを再取得する

可能な限りこの方法を採用します。アクセス トークンが期限切れになったことを検知したとき、microsoftTeams.authentication.getAuthToken メソッドを呼び出して新しいクライアント トークンを取得し、サーバー トークンに変換します。getAuthToken メソッドはコールバックを受け取るのですが取り回しが悪いので Promise に変換するようにヘルパーを作ることをお勧めします。

function getAuthToken() {
  return new Promise((resolve, reject) => {
    microsoftTeams.authentication.getAuthToken({
      successCallback: (token) => resolve(token),
      failureCallback: (error) => reject(error)
    });
  });
}

github.com

パフォーマンスを向上させる

Google Chrome では同時に接続できるリクエストの数が 6 に制限されています。Electron で開発されている Microsoft Teams も同様の制限を受けると考えていいでしょう。*2 よって大量のリクエストを発生させる可能性がある場合はリクエスト数を減らすことでパフォーマンスを向上させることができます。すでに述べた 429 HTTP ステータスへの対応と重複する点もあります。

OData クエリを活用する

例えば、元となるデータとそれに関連するデータを取得するときに、$expand パラメーターを使用することで、リクエストの回数を削減することができます。また、$select パラメーターを使用することで、レスポンス データのサイズを削減し、パフォーマンスを向上させることができます。

バッチ リクエストを使用する

API の種類によっては $expand ができないものもあります。例えば、/teams/{id} と /teams/{id}/members はアクセス許可が異なるため、$expand が使用できません。このような場合に、バッチ リクエストを使用することにより、リクエストを減らすことができます。注意点として、バッチ リクエストでは依存関係のあるリクエストの順序を指定することはできますが、実行した結果を別の API のリクエストに含めることはできません。

レスポンスをキャッシュする

データの種類ごとにキャッシュ戦略を立てる必要があります。プロフィール画像のような更新頻度の低いデータと、プレゼンス情報のような更新頻度の高いデータでは、当然キャッシュの有無や有効期間も異なります。

見た目を整える

リクエスト中はユーザーにそうであることを表現する必要があります。Fluent UI には Loader や Skeleton のようなロード中であることを表現するコンポーネントが用意されています。最近は Loader よりも Skeleton が好まれる傾向にあります。いずれにしても、ユーザー体験を損なうことのないように考慮するべきです。

まとめ

Microsoft Teams アプリに限定して書きましたが、だいたいのことは Microsoft Teams アプリでなくても当てはまりますし、さらにいえば Microsoft Graph に関係なく API を呼び出すアプリケーション全般に当てはまることもあるかと思います。

  • リトライを実装する
  • リクエストの回数を減らす
  • データをキャッシュする
  • ユーザー体験を損なわない

このあたりを留意していただければ価値のある Microsoft Teams アプリを作ることができるのではないかと思います。

*1:Azure AD が発行するトークンの有効期間について | Japan Azure Identity Support Blog では既定では 1 時間という記述もありますが情報が古いこともあり正しくないです。

*2:ごめんなさい、公式なドキュメントを見つけられませんでした。

ドクターイエロー運行予測 2.0.0 を公開しました

ドクターイエロー運行予測というサイトを公開していたのですが jQuery + Knockout.js で作っていたものを React で作り直しました。

preddy.karamem0.dev

基本的にはあまり変わっていないのですがベースの UI ライブラリに Fluent UI を使ったのでどことなく MIcrosoft Teams っぽいです。あとやっと React が書き慣れてきた感じがします。

Capreze 1.7.0 を公開しました

Capreze 1.7.0 を公開しました。
Capreze は画面キャプチャ用のウィンドウ リサイズ ツールです。ウィンドウを正確なサイズにリサイズしてくれます。

github.com

複数ディスプレイ環境における解像度の違いに対応しました。これまでは起動時に表示されるディスプレイの解像度にしたがってオフセットを計算していたのですが、ウィンドウを別のディスプレイに移動したときにオフセットが再計算されるようになりました。複数ディスプレイ環境でも正確にオフセットを考慮してウィンドウをリサイズしてくれます。