からめもぶろぐ。

SharePoint が得意なフレンズなんだね!すごーい!

SharePoint Framework (SPFx) から Office 365 API を叩いてみる

SharePoint Framework (SPFx) の Web パーツでは新しいエクスペリエンスが採用され、これまでの SharePoint アドインで使用されていた iframe が廃止されました。SharePoint アドインでは OAuth2 の暗黙的な許可フローでうまいこと iframe 内でリダイレクトしてできたのですが、SPFx ではそれができなくなりました。さて困ったということで頑張ってなんとかしてみました。

念のため公式の対応方法を紹介しておきます。

dev.office.com

adal.js が Web パーツに対応していないから AuthorizationContext の中身を書き換えろとかそれライブラリで対応する話じゃないんですかね。無理して adal.js を使うこともないので今回は使わずに実装します。

サンプル コード

github.com

アプリケーションの登録

Azure ActiveDirectory にアプリケーションを登録してアプリケーション ID を控えておきます。アクセス許可に Exchange Online の「Read user and shared mail」を選択します。

プロジェクトの作成

SharePoint Framework を使うまでの流れは以下を参考にしてください。

dev.office.com

yeoman を使って適当な名前 (今回は SharePointAddIn1) でプロジェクトを作成します。現時点では SharePointAddIn1WebPart.manifest.json にバグがあって "$schema" のパスが通っていません。そこでファイル名を "clientSideComponentManifestSchema.json" から "client-side-component-manifest.schema.json" に変更します。

Web パーツ プロパティの作成

別に定数でもいいのですがせっかくなのでプロパティを作成します。

ISharePointAddIn1WebPartProps.ts
export interface ISharePointAddIn1WebPartProps {
    appId: string;
    authUrl: string;
    resourceUrl: string;
}
SharePointAddIn1WebPart.manifest.json
{
    "preconfiguredEntries": [
        {

            (snip)

            "properties": {
                "appId": "<appid>",
                "authUrl": "https://login.microsoftonline.com/common/oauth2/authorize",
                "resourceUrl": "https://outlook.office.com"
            }
        }
    ]
}

ユーザー プロパティの作成

一度取得した Access Token を sessionStorage に保存しておくようにします。コンストラクターで Web パーツのインスタンス ID を受け取ることで、複数の Web パーツがあった場合でも Access Token が競合しないようにします。

ISharePointAddIn1UserProps.ts
export class SharePointAddIn1UserProps {

    private instanceId: string;

    constructor(instanceId: string) {
        this.instanceId = instanceId;
    }

    (snip)

    public get accessToken(): string {
        return this._getValue("accessToken");
    }

    public set accessToken(value: string) {
        this._setValue("accessToken", value);
    }

    (snip)

    private _getValue(key: string): string {
        var stringValue = sessionStorage.getItem(this.instanceId);
        if (stringValue == null) {
            return null;
        }
        var jsonValue = JSON.parse(stringValue);
        return jsonValue[key];
    }

    private _setValue(key: string, value: string): void {
        var stringValue = sessionStorage.getItem(this.instanceId);
        if (stringValue == null) {
            stringValue = "{}";
        }
        var jsonValue = JSON.parse(stringValue);
        jsonValue[key] = value;
        stringValue = JSON.stringify(jsonValue);
        sessionStorage.setItem(this.instanceId, stringValue);
    }

}

OAuth フローの作成

上記にもある通り iframe によるリダイレクトができないので window.open() で新しいウィンドウを立ち上げるようにします。呼び出し元では setInterval() でウィンドウの状態を監視し、フローが終わったら URL から Access Token を取得します。取得が終わったら Outlook Mail REST API を叩いて未読件数を取得します。

SharePointAddIn1WebPart.ts
public render(): void {
    this.userProperties = new SharePointAddIn1UserProps(this.context.instanceId);
    if (window.location.hash == "") {
        var loginName = this.context.pageContext.user.loginName;
        if (this.userProperties.loginName != loginName) {
            this.userProperties.clear();
            this.userProperties.loginName = loginName;
        }
        if (this.userProperties.accessToken == null) {
            var redirectUrl = window.location.href.split("?")[0];
            var requestUrl = this.properties.authUrl + "?" +
                "response_type=token" + "&" +
                "client_id=" + encodeURI(this.properties.appId) + "&" +
                "resource=" + encodeURI(this.properties.resourceUrl) + "&" +
                "redirect_uri=" + encodeURI(redirectUrl);
            var popupWindow = window.open(requestUrl, this.context.instanceId, "width=600px,height=400px");
            var handle = setInterval((self) => {
                if (popupWindow == null || popupWindow.closed == null || popupWindow.closed == true) {
                    clearInterval(handle);
                }
                try {
                    if (popupWindow.location.href.indexOf(redirectUrl) != -1) {
                        var hash = popupWindow.location.hash;
                        clearInterval(handle);
                        popupWindow.close();
                        var query = {};
                        var elements = hash.slice(1).split("&");
                        for (var index = 0; index < elements.length; index++) {
                            var pair = elements[index].split("=");
                            var key = decodeURIComponent(pair[0]);
                            var value = decodeURIComponent(pair[1]);
                            query[key] = value;
                        }
                        self.userProperties.accessToken = query["access_token"];
                        self.userProperties.expiresIn = query["expires_in"];
                        self._renderBody();
                        self._renderContent();
                    }
                } catch (e) { }
            }, 1, this);
        } else {
            this._renderBody();
            this._renderContent();
        }
    }
}

private _renderBody(): void {
    this.domElement.innerHTML = `
        <div id="${this.context.manifest.id}" class="${styles.container}">
        </div>`;
}

private _renderContent(): void {
    var container = this.domElement.querySelector(`#${this.context.manifest.id}`);
    var requestUrl = this.properties.resourceUrl + "/api/v2.0/me/mailfolders";
    fetch(requestUrl, {
        method: "GET",
        headers: new Headers({
            "Accept": "application/json",
            "Authorization": `Bearer ${this.userProperties.accessToken}`
        })
    })
        .then(response => response.json())
        .then(data => {
            var items = data.value;
            for (var index = 0; index < items.length; index++) {
                var displayName = items[index].DisplayName;
                var unreadItemCount = items[index].UnreadItemCount;
                container.innerHTML += `<div>${displayName}: ${unreadItemCount}</div>`;
            }
        });
}

実行結果

初回のみ新しいウィンドウが立ち上がります。次回以降は保存された Access Token が使われるためウィンドウは表示されません。

f:id:karamem0:20170808155931p:plain

もしかしたら動的に iframe を作ってフローを処理する方法もあるかも。

SPClient 0.13 をリリースしました

www.powershellgallery.com

コンテンツ タイプに列の追加と削除ができるようになりました。コンテンツ タイプの場合、追加と削除は Field ではなく FieldLink というクラスを使うのですが、使いづらい感じなので隠蔽しています。
また、Debug-SPClientObject という関数を追加しました。これは、ClientObject で読み込まれたプロパティのみを Dictionary にして返すもので、CSOM を PowerShell で使うときに出る「The collection has not been initialized.」のエラーを解決してくれます。

PS> Get-SPClientWeb -Default -Retrieval "Title" | Debug-SPClientObject

Key   Value
---   -----
Title チーム サイト

さらに ConvertTo-Json と組み合わせていい感じにできます。

PS> Get-SPClientWeb -Default -Retrieval "Title" | Debug-SPClientObject | ConvertTo-Json

{
    "Title":  "チーム サイト"
}

CSOM の WebTemplate には Path が入ってこない件

どういうことかというと、以下のコードを実行します。

public static class Program {

    private static void Main(string[] args) {
        var siteUrl = "<サイトの URL>";
        var userName = "<ユーザー名>";
        var rawPassword = "<パスワード>";
        var securePassword = new SecureString();
        foreach (var c in rawPassword) {
            securePassword.AppendChar(c);
        }
        var credentials = new SharePointOnlineCredentials(userName, securePassword);
        var clientContext = new ClientContext(siteUrl) {
            Credentials = credentials
        };
        var webTemplates = clientContext.Web.GetAvailableWebTemplates(1041, true);
        clientContext.Load(webTemplates);
        clientContext.ExecuteQuery();
        Console.ReadLine();
    }

}

f:id:karamem0:20170712095938p:plain

はい、そうですね。Path プロパティの値が null になります。

どういうことだってばよ

CSOM では自らのオブジェクトの場所を特定するために、オブジェクトが Load された後、識別情報を Path プロパティに保持します。*1 たとえばリスト アイテムだと以下のような値が入っています。

5e66049e-800e-4000-b805-a573a858fe86|740c6a0b-85e2-48a0-a494-e0f1759d4aa7:site:217cc400-7454-40e2-8f0d-546625dfb9fb:web:564072f0-9feb-48d6-b7bc-f77e17623c87:list:00077cba-a4e8-460f-a2b3-3a96a2da523f:item:1,1

「site:<GUID>」とか「web:<GUID>」となっているのがそれぞれの親オブジェクトの ID です。Load メソッドを呼び出すときは常にこの値を見るため、WebTemplate を Load しようとすると常に例外が発生します。

f:id:karamem0:20170712095942p:plain

WebTemplate も ClientObject の派生クラスなので同じ動きしてくれないと困るのですが。

*1:Load メソッドの呼び出し前は ObjectPathPropertyObjectPathMethod のような参照情報が入っています。

SPClient 0.12 をリリースしました

www.powershellgallery.com

また 0.11 のリリース告知を忘れてましたので、0.11 と 0.12 の変更点をまとめます。

新たに追加された関数はないのですが、パラメーターの名前が変わったり、既定の動作が変更されたりしています。例えば NoEnumerate パラメーターの追加により、パイプラインでの処理に柔軟性を持たせることができるようになりました。ちなみに内部的には NoEnumerate の値はそのまま Write-Output に渡されています。

SPClientContentType でリスト コンテンツ、SPClientField でサイト列がそれぞれサポートされました。パラメーターに Web を渡すか List を渡すかの違いだけです。