Azure Functions + Graph APIでSharepointの更新を受け取る

# はじめに

今回はとある案件でSharepointに置いてある特定のディレクトリ内のファイルが更新されるたび、更新されたファイルの情報を取得して別のAPIにリクエストを送る、という処理が必要になりました。

Webhook自体あまり触ったことがなく色々と調べながら進めましたが、特にレシーバーとしてAzure Functionsを利用し、通知の受け取りに付随して処理を行うパターンについてはあまり例がなかったので、今回書き残しておくことにしました。

# 全体の流れ

- FunctionsでWebhook通知を受け取るレシーバーを作ってデプロイ
- Sharepoint認証用のアプリを作り、リダイレクトURLをFunctionsのエンドポイントに指定する
- Graph APIでサブスクリプションを作成するリクエストを送信し、Functionsとの連携を確立する

# Graph APIのWebhookについて

Graph APIでは、Microsoftの各種サービスに対する変更を、Webhookを用いて特定のエンドポイントに通知するAPIが提供されています。

対応しているサービスとして、OutlookやTeams、Sharepointなどがあります。

https://qiita.com/mingchun_zhao/items/cebb64512a1ccbd42027

Sharepoint Webhookの仕組みやサブスクリプションの作成方法については既に上記の記事があり、実際に開発の上でも参考にさせていただきました。

上記の記事と異なる点は、

- レシーバーとしてAzure Funtionsを利用する。
- 変更内容を取得するため、通知を受け取ったら差分取得のクエリを送信する必要がある。
- さらに差分の内容（更新されたファイル名）を取得する必要がある。

# Functionsの準備

Functionsに必要な機能は以下の通りです。

- サブスクリプション作成通知を受け取ったとき、検証用トークンを返信する
- 更新通知を受け取ったとき、Sharepointの更新差分を取得する
- 差分の情報をデコードする（ここでは更新されたファイルのパスの取得までを扱う）

言語はPythonを利用しています。

## サブスクリプションの作成

Graph APIでWebhookのサブスクリプションを作成した時、パラメータで指定したエンドポイントに検証トークンが送信されるので、それをすぐに返す必要があります。

```python
def create_subscription(req: func.HttpRequest) -> func.HttpResponse:
    try:
        logging.info('Subscription creating request recieved.')
        q = urlparse(req.url).query
        
        token = parse_qs(q).get('validationToken')[0] if parse_qs(q).get('validationToken') else None
        if token:
            logging.info("Validation token: "+token)
            return func.HttpResponse(token, status_code=200, headers={"Content-Type": "text/plain"})
        else:
            logging.error("Invalid subscription request")
            return func.HttpResponse("Invalid request", status_code=400)
    except Exception as e:
        raise e
```

## 更新差分の取得

Graph APIの差分取得の仕組みは、レスポンス差分内容などと共に含まれるnextURLを用いて次回の問い合わせを行うというものなのですが、Functionsにおいてネックとなるのが、このURLをどうやって保持するのかという点です。正直ここでFunctionsを選んだのはミスだったと思いました。ファイルの作成などのイベントの通知を受け取るのであればFunctionsでも構わないと思います。

今回は外部のBLOBストレージに保存することで解決していますが、更新頻度が高い場合は処理の衝突などで問題になる可能性があるので何か対策は必要だと思います。

```python
def receive_changes(req: func.HttpRequest) -> func.HttpResponse:
    # SharePointの通知内容を取得
    
    logging.info('SharePoint update notification received.')
    try:
        logging.info(pprint.pformat(req.headers))
        req_body = req.get_json()
        
        logging.info(pprint.pformat(req_body["value"], indent=2))
        
    except ValueError as e:
        logging.error("Invalid notification message")
        raise e
    
    for v in req_body["value"]:
        if v["clientState"] != client_state:
            logging.error("Invalid client state: %s" % req.headers.get('clientState'))
            return func.HttpResponse("Invalid request", status_code=400)
        
        if v["changeType"] != "updated":
            logging.info("Changes was not update")
            return func.HttpResponse("Not updated", status_code=400)
    
    logging.info("Fetching Delta link")
    # 最新の差分取得用URLをストレージから取得する
    try:
		    with BlobClient.from_connection_string(storage_connection, container_name, token_file, logger=logger) as blob_client:
		        blobfile = blob_client.download_blob().readall()
		        blobdata = json.loads(blobfile)
		        delta_url = blobdata["deltaLink"]
    except Exception as e:
        logging.error(f"Failed to get Delta Link from Storage: {e}")
        return func.HttpResponse("Failed to get Delta Link from Storage", status_code=500)
    
    logging.info(f"Checking Sharepoint Delta")
    result = check_update(delta_url)
    
    # 取得結果を表示
    if len(result) > 0:
        logging.info(pprint.pprint(result, indent=2))
        return func.HttpResponse("Function triggered successfully", status_code=200)
    else:
        logging.info("Update file not found")
        return func.HttpResponse("Update file not found", status_code=500)
```

## 処理の分岐

```python
app = func.FunctionApp()
@app.route(route="webhook")
def webhook(req: func.HttpRequest) -> func.HttpResponse:
    try:
        url = req.url
        logging.info("url: "+url)
        q = urlparse(url).query
        if not q:
            logging.error("Invalid query, Request URL: %s" % url)
            return func.HttpResponse("Invalid request", status_code=400)
        logging.info('Function triggered.')
        # validationTokenがURLにくっついていたらサブスク作成に分岐
        if parse_qs(q).get('validationToken'):
            return create_subscription(req)
        else:
            return receive_changes(req)
    except Exception as e:
        logging.error("Error: %s" % e)
        return func.HttpResponse("Internal server error", status_code=500)
```

サブスクリプション通知の場合、URLが「?validationToken=~」の形になっているので、その点で作成か通知かに分岐させるようにします。

# 認証用アプリの準備

EntraアプリにSharepointの編集権限を委任することで認証を行うことにしました。

https://learn.microsoft.com/ja-jp/entra/identity-platform/quickstart-register-app?tabs=certificate

リダイレクトURIにはFunctionsのエンドポイントを指定します。「アクセス許可」の項目では「Sites.Manage.All」のアクセス許可を付与してから、シークレットを発行します。

# サブスクリプションの作成

サブスクリプションを作成する際には、Graph APIのURL [`https://graph.microsoft.com/v1.0/subscriptions`](https://graph.microsoft.com/v1.0/subscriptions) に、以下の情報を含めてリクエストを送信する必要があります。

- changeType : 通知する変更の種類。今回は `updated`
- notificationUrl : 通知先のURL。Functionsのエンドポイント。
- resource : 対象となるSharepointのID。詳しくは後述。
- expirationDateTime : サブスクリプションの有効期限を指定する。現在時刻から30日以内を指定する必要がある。
- clientState : 任意の文字列。変更通知に含まれるので、整合性チェックやクライアント側で複数のサブスクリプションを区別するために使える。

SharepointのIDを確認するためには、 [`https://graph.microsoft.com/v1.0/sites`](https://graph.microsoft.com/v1.0/sites) をPOSTするとテナント内のサイトが一覧表示されるので、そこから目的のサイトを探す方法があります。 形式は `mytenant.sharepoint.com,~~~~~~,~~~~~/drives/b!_~~~~~~~~/root/` のようになっているはずです。

先にEntraアプリの認証情報でgraph APIにログインしアクセストークンを入手した後、そちらをヘッダーに含めて以下のようにリクエストします。

- トークン取得リクエスト

![トークン取得リクエスト](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3327019/3da74e71-df5c-f86e-7d55-2602110d6ba9.png)

- サブスクリプション作成リクエスト

![サブスクリプション作成リクエスト](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3327019/7e7466ad-4a33-c880-2fc8-9efcd3911081.png)

ちなみに、HTTPリクエストを行う際にはVSCodeのREST Client拡張を利用しています。特別にソフトを導入することなくAPIを叩けるので便利です。

https://qiita.com/toshi0607/items/c4440d3fbfa72eac840c

# おわりに

今回はWebhook+Azure FunctionsでSharepointの更新通知を受け取る仕組みについて解説しました。自前でサーバーを立てずともMicrosoftのサービスのみで確立することができるのでことがAzureを活用するメリットだったのではないかと思います。

TeamsやOutlookなどとの連携も可能なので、様々なアプリを開発するのに応用してみてください。