日常

【チュートリアル】React で Microsoft Graph API を利用する

経緯

僕は大学の頃から、日々のメモなどを取るノートアプリとして、MicrosoftのOneNoteを使用しています。
家庭をもつようになって、いろいろ管理する情報も増えていき、活用していきたいのですが、OneNoteは昔から同じフォント、サイズでも行間が異なっていく等の微妙なレイアウト崩れが発生しており、気持ち悪くて悩んでました。
今でも改善はされないので、Microsoft Graph APIを利用してオリジナルのOnenoteクライアントアプリを作成しようと思います。
ひとまずは、APIを理解するために、公式チュートリアルを実践します。

Reactプロジェクトの作成

下記コマンドで、Reactプロジェクトを作成します。
Node.jsがインストールされていることが前提です。

node -v  # v12.16.2
npx create-react-app graph-tutorial

プロジェクトが作成できたら、ディレクトリを移動してローカルWebサーバーを起動します。

cd graph-tutorial
yarn start

さらに、追加のパッケージをインストールします。

yarn add react-router-dom bootstrap reactstrap @fortawesome/fontawesome-free
yarn add moment msal @microsoft/microsoft-graph-client

エディターの設定を行う

僕はWebStormで開発を行っています。
インデントやコードフォーマットを統一するための設定を追加していきます。

.editorconfigの作成

下記の通り、.editorconfigファイルを生成して、インデント等の設定を行います。

root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
charset = utf-8

Prettierの設定をする

次にシングルクォート、セミコロンの使用無し、配列の最後の要素のカンマは複数行の記法時のみ許容、という設定をするために、Prettierを使用します。

yarn add prettier --dev --exact
{
  "singleQuote": true,
  "semi": false,
  "trailingComma": "all"
}

アプリケーションの設計をする

まずはじめにアプリのナビゲーションバーを作っていきます。
./srcディレクトリ配下にNavBar.jsという名前でファイルを作成します。

import React, { Component } from 'react'
import { NavLink as RouterNavLink } from 'react-router-dom'
import {
  Collapse,
  Container,
  Navbar,
  NavbarToggler,
  NavbarBrand,
  Nav,
  NavItem,
  NavLink,
  UncontrolledDropdown,
  DropdownToggle,
  DropdownMenu,
  DropdownItem
} from 'reactstrap'
import '@fortawesome/fontawesome-free/css/all.css'
function UserAvatar(props) {
  // ユーザーアバターを利用できる場合、イメージタグを返す
  if (props.user.avatar) {
    return (
      <img
        src={props.user.avatar}
        alt="user"
        className="rounded-circle align-self-center mr-2"
        style={{ width: '32px' }}
      />
    )
  }
  // ユーザーアバターが利用できない場合、デフォルトアイコンを返す
  return (
    <i
      className="far fa-user-circle fa-lg rounded-circle align-self-center mr-2"
      style={{ width: '32px' }}
    />
  )
}
function AuthNavItem(props) {
  if (props.isAuthenticated) {
    return (
      <UncontrolledDropdown>
        <DropdownToggle nav caret>
          <UserAvatar user={props.user} />
        </DropdownToggle>
        <DropdownMenu right>
          <h5 className="dropdown-item-text mb-0">{props.user.displayName}</h5>
          <p className="dropdown-item-text text-muted mb-0">{props.user.email}</p>
          <DropdownItem divider />
          <DropdownItem onClick={props.authButtonMethod}>Sign Out</DropdownItem>
        </DropdownMenu>
      </UncontrolledDropdown>
    )
  }
  // 認証していない場合、サインインリンクを返す
  return (
    <NavItem>
      <NavLink onClick={props.authButtonMethod}>Sign In</NavLink>
    </NavItem>
  )
}
export default class NavBar extends Component {
  constructor(props) {
    super(props)
    this.toggle = this.toggle.bind(this)
    this.state = {
      isOpen: false
    }
  }
  toggle() {
    this.setState({
      isOpen: !this.state.isOpen
    })
  }
  render() {
    let calenderLink = null
    if (this.props.isAuthenticated) {
      calenderLink = (
        <NavItem>
          <RouterNavLink to="/calendar" className="nav-link" exact>
            Calender
          </RouterNavLink>
        </NavItem>
      )
    }
    return (
      <div>
        <Navbar color="dark" dark expand="md" fixed="top">
          <Container>
            <NavbarBrand href="/">React Graph Tutorial</NavbarBrand>
            <NavbarToggler onClick={this.toggle} />
            <Collapse isOpen={this.state.isOpen} navbar>
              <Nav className="mr-auto" navbar>
                <NavItem>
                  <RouterNavLink to="/" className="nav-link" exact>
                    Home
                  </RouterNavLink>
                </NavItem>
                {calenderLink}
              </Nav>
              <Nav className="justify-content-end" navbar>
                <NavItem>
                  <NavLink
                    href="https://developer.microsoft.com/graph/docs/concepts/overview"
                    target="_blank"
                  >
                    <i className="fas fa-external-link-alt mr-1" />
                    Docs
                  </NavLink>
                </NavItem>
                <AuthNavItem
                  isAuthenticated={this.props.isAuthenticated}
                  authButtonMethod={this.props.authButtonMethod}
                  user={this.props.user}
                />
              </Nav>
            </Collapse>
          </Container>
        </Navbar>
      </div>
    )
  }
}

次にアプリのホームページを作成します。
./srcディレクトリ配下にWelcome.jsファイルを作成します。

import React from 'react'
import { Button, Jumbotron } from 'reactstrap'
function WelcomeContent(props) {
  if (props.isAuthenticated) {
    return (
      <div>
        <h4>Welcome {props.user.displayName}!</h4>
        <p>Use the navigation bar at the top of the page to get started.</p>
      </div>
    )
  }
  return (
    <Button color="primary" onClick={props.authButtonMethod}>
      Click here to sign in
    </Button>
  )
}
export default class Welcome extends Component {
  render() {
    return (
      <Jumbotron>
        <h1>React Graph Tutorial</h1>
        <p className="lead">
          This sample app shows how to use the Microsoft Graph API to access
          Outlook and OneDrive data from React
        </p>
        <WelcomeContent
          isAuthenticated={this.props.isAuthenticated}
          user={this.props.user}
          authButtonMethod={this.props.authButtonMethod}
        />
      </Jumbotron>
    )
  }
}

次にエラーメッセージ表示用コンポーネントを作成します。
./srcディレクトリ配下にErrorMessage.jsファイルを作ります。

import React, { Component } from 'react'
import { Alert } from 'reactstrap'
export default class ErrorMessage extends Component {
  render() {
    let debug = null
    if (this.props.debug) {
      debug = (
        <pre className="alert-pre border bg-light p-2">
          <code>{this.props.debug}</code>
        </pre>
      )
    }
    return (
      <Alert color="danger">
        <p className="mb-3">{this.props.message}</p>
        {debug}
      </Alert>
    )
  }
}

スタイルを変更します。

body {
  padding-top: 4.5rem;
}
.alert-pre {
  word-wrap: break-word;
  word-break: break-all;
  white-space: pre-wrap;
}

App.jsを更新します。

import React, { Component } from 'react'
import { BrowserRouter as Router, Route } from 'react-router-dom'
import { Container } from 'reactstrap'
import NavBar from './NavBar'
import ErrorMessage from './ErrorMessage'
import Welcome from './Welcome'
import 'bootstrap/dist/css/bootstrap.css'
class App extends Component {
  constructor(props) {
    super(props)
    this.state = {
      isAuthenticated: false,
      user: {},
      error: null
    }
  }
  render() {
    let error = null
    if (this.state.error) {
      error = (
        <ErrorMessage
          message={this.state.error.message}
          debug={this.state.error.debug}
        />
      )
    }
    return (
      <Router>
        <div>
          <NavBar
            isAuthenticated={this.state.isAuthenticated}
            authButtonMethod={null}
            user={this.state.user}
          />
          <Container>
            {error}
            <Route
              exact
              path="/"
              render={(props) => (
                <Welcome
                  {...props}
                  isAuthenticated={this.state.isAuthenticated}
                  user={this.state.user}
                  authButtonMethod={null}
                />
              )}
            />
          </Container>
        </div>
      </Router>
    )
  }
  setErrorMessage(message, debug) {
    this.setState({
      error: { message: message, debug: debug }
    })
  }
}
export default App

ページを確認します。

AzureActiveDirectory管理センターでアプリを登録する

Azure Active Directory admin center

上記サイトをブラウザで開き、Microsoftアカウントでログインします。
左側のナビゲーションから、[Azure Active Directory] → [管理] → [アプリの登録]を選択します。

[新規登録]を選択します。

アプリーケーションの登録画面で下記のように入力します。
名前:「React Graph Tutorial」
サポートされているアカウントの種類:「任意の組織ディレクトリ内のアカウントと個人のMicrosoftアカウント」
リダイレクトURI:「http://localhost:3000」

その後の画面で表示されるアプリケーション(クライアント)IDを使用します。

ナビゲーションの[認証] → [暗黙の付与]で「アクセストークン」と「IDトークン」にチェックを付けます。

Azure AD認証を追加する

AzureADでの認証を有効にするために、アプリケーションを拡張していきます。

./srcディレクトリ配下にConfig.jsというファイルを作ります。

module.exports = {
  appId: 'YOUR_APPLICATION_ID',
  redirectUri: 'http://localhost:3000',
  scopes: ['user.read', 'calendars.read']
}

次に.src/App.jsに以下のimport文を追記します。

import config from './Config';
import { UserAgentApplication } from 'msal';

サインインの実装

App.jsconstructorに以下のように修正します。

constructor(props) {
  super(props)
  this.userAgentApplication = new UserAgentApplication({
    auth: {
      clientId: config.appId,
      redirectUri: config.redirectUri
    },
    cache: {
      cacheLocation: 'localStorage',
      storeAuthStateInCookie: true
    }
  })
  const user = this.userAgentApplication.getAccount()
  this.state = {
    isAuthenticated: user !== null,
    user: {},
    error: null
  }
  if (user) {
    this.getUserProfile()
  }
}

次のログイン用の関数をApp.jsに追加します。

async login() {
  try {
    await this.userAgentApplication.loginPopup({
      scopes: config.scopes,
      prompt: 'select_account'
    })
    await this.getUserProfile()
  } catch (err) {
    let error = {}
    if (typeof err === 'string') {
      var errParts = err.split('|')
      error =
        errParts.length > 1
          ? { message: errParts[1], debug: errParts[0] }
          : { message: err }
    } else {
      error = {
        message: err.message,
        debug: JSON.stringify(err)
      }
    }
    this.setState({
      isAuthenticated: false,
      user: {},
      error: error
    })
  }
}

ログアウト用関数をApp.jsに追記します。

logout() {
  this.userAgentApplication.logout()
}

NavBarWelcomeコンポーネントの動作を変更します。

<NavBar
  isAuthenticated={this.state.isAuthenticated}
  authButtonMethod={this.state.isAuthenticated ? this.logout.bind(this) : this.login.bind(this)}
  user={this.state.user}
/>
<Welcome
  {...props}
  isAuthenticated={this.state.isAuthenticated}
  user={this.state.user}
  authButtonMethod={this.login.bind(this)}
/>

getUserProfile()関数を追加します。

async getUserProfile() {
  try {
    const accessToken = await this.userAgentApplication.acquireTokenSilent({
      scopes: config.scopes
    })
    if (accessToken) {
      this.setState({
        isAuthenticated: true,
        error: { message: "Access token: ", debug: accessToken.accessToken }
      })
    }
  } catch (err) {
    var errParts = err.split('|')
    this.setState({
      isAuthenticated: false,
      user: {},
      error: { message: errParts[1], debug: errParts[0] }
    })
  }
}

この状態で実行すると、サインイン時にポップアップが表示されます。
サインインが完了するとアクセストークンが画面に表示されます。

ユーザーの詳細を取得する

Graphのすべての呼び出しを保持する処理を追加します。
./srcディレクトリ配下にGraphService.jsを作ります。

const graph = require('@microsoft/microsoft-graph-client')
function getAuthenticatedClient(accessToken) {
  const client = graph.Client.init({
    authProvider: (done) => {
      done(null, accessToken.accessToken)
    }
  })
  return client
}
export async function getUserDetails(accessToken) {
  const client = getAuthenticatedClient(accessToken)
  const user = await client.api('/me').get()
  return user
}

getUserProfile()を修正します。

import { getUserDetails } from './GraphService'
async getUserProfile() {
  try {
    const accessToken = await this.userAgentApplication.acquireTokenSilent({
      scopes: config.scopes
    })
    if (accessToken) {
      const user = await getUserDetails(accessToken)
      this.setState({
        isAuthenticated: true,
        user: {
          displayName: user.displayName,
          email: user.email || user.userPrincipalName
        },
        error: null
      })
    }
  } catch (err) {
    let error = {}
    if (typeof err === 'string') {
      const errParts = err.split('|')
      error =
        errParts.length > 1
          ? { message: errParts[1], debug: errParts[0] }
          : { message: err }
    } else {
      error = {
        message: err.message,
        debug: JSON.stringify(err)
      }
    }
    this.setState({
      isAuthenticated: false,
      user: {},
      error: error
    })
  }
}

サインアウト後、サインインし直して表示を確認します。

予定表のデータを取得する

新しいメソッドをGraphService.jsに追加します。

export async function getEvents(accessToken) {
  const client = getAuthenticatedClient(accessToken)
  const events = await client
    .api('/me/events')
    .select('subject, organizer, start, end')
    .orderby('createdDateTime DESC')
    .get()
  return events
}

上記処理で取得したデータを表示するカレンダーコンポーネントを作成します。
./src/ディレクトリ配下にCalendar.jsを作ります。

import React, { Component } from 'react'
import { Table } from 'reactstrap'
import moment from 'moment'
import config from './Config'
import { getEvents } from './GraphService'
function formatDateTime(dateTime) {
  return moment.utc(dateTime).local().format('M/D/YY h:mm A')
}
export default class Calendar extends Component {
  constructor(props) {
    super(props)
    this.state = {
      events: []
    }
  }
  async componentDidMount() {
    try {
      const accessToken = await window.msal.acquireTokenSilent({
        scopes: config.scopes
      })
      let events = await getEvents(accessToken)
      this.setState({ events: events.value })
    } catch (err) {
      this.props.showError('ERROR', JSON.stringify(err))
    }
  }
  render() {
    return (
      <pre>
        <code>{JSON.stringify(this.state.events, null, 2)}</code>
      </pre>
    )
  }
}

JSONの配列をページにレンダリングするだけのコンポーネントです。
これを表示するために、App.jsを修正します。

import Calendar from './Calendar'
<Route
  exact
  path="/calendar"
  render={(props) => (
    <Calendar
      {...props}
      showError={this.setErrorMessage.bind(this)}
    />
  )}
/>

ブラウザで動作確認します。
ナビゲーションバーのCalendarをクリックします。

結果を表示する

カレンダーをきれいに表示するようにコンポーネントを修正します。

render() {
  return (
    <div>
      <h1>Calendar</h1>
      <Table>
        <thead>
          <tr>
            <th scope="col">Organizer</th>
            <th scope="col">Subject</th>
            <th scope="col">Start</th>
            <th scope="col">End</th>
          </tr>
        </thead>
        <tbody>
          {this.state.events.map(function (event) {
            return (
              <tr key={event.id}>
                <td>{event.organizer.emailAddress.name}</td>
                <td>{event.subject}</td>
                <td>{formatDateTime(event.start.dateTime)}</td>
                <td>{formatDateTime(event.end.dateTime)}</td>
              </tr>
            )
          })}
        </tbody>
      </Table>
    </div>
  )
}

タイトルとURLをコピーしました