20191112のReactに関する記事は6件です。

Auth0のCustom Social Connectionを利用してYAHOO! Japanと接続する

はじめに

この記事はAuth0のCustom Social Connection Extensionを利用してYAHOO! JapanとAuth0を接続、YAHOO!IDでSingle Page Web Applicationにログインする手順をまとめています。YAHOO!IDの取得、およびAuth0の無料アカウントの取得とテナントの作成が完了していることが前提となっています。Auth0の無料アカウント取得がまだの方はこちらの記事を参照の上ご準備をお願いします。

検証環境

  • OS : macOS Catalina 10.15.1
  • node : 10.15.3
  • npm : 6.12.0
  • Git : 2.23.0

手順

YAHOO! Japan側

Auth0からYAHOO! JapanにOAuth2.0で接続する際に必要となるアプリケーションを作成します。Chromeでデベロッパーネットワークにアクセスして"新しいアプリケーションを開発"を押します。

"アプリケーションの種類"で"サーバサイド"を選択、"アプリケーション名"に任意の名前を入力、ガイドラインで同意をチェックしてその他は全てデフォルトで画面下の"確認"ボタンを押します。

作成したアプリケーションを選択し、"コールバックURLに" "https://<auth0テナントのドメイン名>/login/callback" を入力、画面下の"更新"を押します。YAHOO! Japan側でAuth0のコールバックエンドポイントへのアクセスを許可しています 画面上に記載されている"CLIENT ID"と"シークレットを"控えておきます。Auth0側の設定で必要になります

Auth0側

Custome Extensionの設定

Auth0のダッシュボードにログイン、左ペインの"Extensions"をクリックして左上の"Custom Social Connections"をクリックし"INSTALL"を押します。

"Installed Extensions"タブから"Custom Social Connections"をクリック、右上の"NEW CONNECTION"をクリックします。

各パラメータを設定して画面下の"SAVE"を押します。

function(accessToken, ctx, cb) {
  request.get('https://userinfo.yahooapis.jp/yconnect/v2/attribute', {
    headers: {
      'Authorization': 'Bearer ' + accessToken
    }
  }, function(e, r, b) {
    if (e) return cb(e);
    if (r.statusCode !== 200) return cb(new Error('StatusCode: ' + r.statusCode));
    var profile = JSON.parse(b);
    cb(null, {
      user_id: profile.uid,
      email: profile.email,
    });
  });
}

Single Page Web Applicationの登録

左ペインの"Applications"をクリックして右上の"CREATE APPLICATION"を押します。

"Name"に任意の名前を入力、"Choose an application type"で"Simgle Page Web Applications"を選択して”CREATE”を押します。

"Settings"タブをクリックして"Allowed Callback URLs", "Allowed Web Origins", "Allowed Logout URLs"に"http://localhost:3000"を入力して画面下の"SAVE CHANGES"を押します。

Single Page Web Applicationの配備

ApplicationのGitHubリポジトリをローカルPCにクローンします。

$ git clone https://github.com/auth0-samples/auth0-react-samples.git

auth0-react-samples/01-Login/srcに移動します。

$ cd auth0-react-samples/01-Login/src

auth_config.json.exampleをコピーしてauth_config.jsonを作成します。

$ cp auth_config.json.example auth_config.json

auth_copnfig.jsonを編集します。"clientID"は"Appications"->"作成したApplication"->"Settings"から確認できます。

{
  "domain": "kiriko.auth0.com",
  "clientId": "xxxx"
}

auth0-react-samples/01-Loginに移動します。

$ cd auth0-react-samples/01-Login

npm installを実行して必要なパッケージをインストールします。

$ npm install

npm startを実行してApplicationを起動します。

$ npm start

動作確認

Chromeで"http://localhost:3000"にアクセス、右上の"Login"を押します。

"LOG IN WITH <作成したCustom Connectionの名前>を押します。

YAHOO!IDでログインします。

Done!

おわりに

Auth0は業界標準のプロトコル(例/OAuth2.0, Open ID Connect)に幅広く対応しているため、YAHOO!JapanをはじめOAuth2.0対応のAuthorizationサービスと簡単に接続することが可能です。ダッシュボードの"Connections"->"Social"に該当のSocial Providerが無い場合はこちらの記事をご参照の上Custom Social Extensionを利用して接続設定をお願いします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Reactで画像の遅延ロードするためのコンポーネント

ReactでHooksとIntersection Observer API使って、プレースホルダー付きで画像の遅延読み込みするコンポーネントをだいぶ前に作った時のメモ。

使い方
<LazyImage src="https://your-image-url" />
import React, { useState, useRef, useEffect } from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'
require('intersection-observer') // intersection-observer api polyfill

const Img = styled.img`
  transition: all 1s ease-in-out;

  @keyframes placeHolder {
    0% {
      background-position: -468px 0;
    }
    100% {
      background-position: 468px 0;
    }
  }

  ${props => props.imageLoaded
    || props.noPlaceholder
    || `
    animation-name: placeHolder;
    animation-duration: 1.5s;
    animation-fill-mode: forwards;
    animation-iteration-count: infinite;
    animation-timing-function: linear;
    background: #f6f7f8;
    background: linear-gradient(to right, #eeeeee 8%, #dfdfdf 20%, #eeeeee 33%);
    background-size: 800px 104px;
    filter: blur(5px);
    position: relative;
  `}
`

function LazyImage({ src, ...props }) {
  const imageRef = useRef()
  const [imageLoaded, setImageLoaded] = useState(false)

  let observer

  useEffect(() => {
    observer = new IntersectionObserver(startLoading, { rootMargin: '50px' })
    if (!imageLoaded) {
      observer.observe(imageRef.current)
    }
    return () => observer.unobserve(imageRef.current)
  }, [])

  function startLoading(entries, object) {
    entries.forEach((entry) => {
      if (!entry.isIntersecting) return

      if (entry.intersectionRatio > 0) {
        observer.unobserve(entry.target)
      }

      if (!imageLoaded) {
        const downloadingImage = new Image()
        downloadingImage.onload = () => {
          if (imageRef.current) {
            imageRef.current.setAttribute('src', downloadingImage.src)
            setImageLoaded(true)
          }
        }

        downloadingImage.src = src
        object.unobserve(entry.target)
      }

      object.unobserve(entry.target)
    })
  }

  return (
    <Img
      {...props}
      alt="GOOD BOYE"
      ref={imageRef}
      imageLoaded={imageLoaded}
    />
  )
}

LazyImage.propTypes = {
  src: PropTypes.string,
  noPlaceholder: PropTypes.bool,
}

LazyImage.defaultProps = {
  src: '',
  noPlaceholder: false,
}

export default LazyImage
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Create React AppでTypeScript 3.7の新文法を使う(とハマるので頑張って回避する)

要約

  • 「tscを静的型チェッカーで運用しててコンパイルはBabel」という運用の(Create React Appのような)プロジェクトはTS3.7の新文法を使うときにハマる
  • TS側がOptional Chaining等に対応していても、Babel側に入ってない(presetに取り込まれていない場合が多い)
  • @babel/plugin-proposal-optional-chaining 等を入れておく必要がある
  • ところでCreate React Appは babel.config.js が使えないのでそのままだと詰む
  • react-app-rewired使ったりして何とか頑張ろうな
  • 素直にreact-scriptsのアップデートを待ったほうがいいと思う

はじめに

TypeScript 3.7がリリースされました

Optional Chainging(hoge?.fuga)やNullish Coalescing(hoge ?? fuga)といった、便利な文法が取り込まれ、多くの人に祝福されたリリースとなりました。TypeScript 3.7のマイルストーンが付いたことでOptional Chaining導入検討Issueがお祭りムードになったのは記憶に新しいところです。

react-scripts 3.2.0時点でのCRAでは新文法が使えない

さて、上げて落とす形になって申し訳ないのですが、残念ながら本日現在、Reactエンジニアが愛するCreate React AppでTS3.7の新文法を利用することはできません。

2019年11月12日現在、$ create-react-app [プロジェクト名] --typescript のコマンドで作ったプロジェクトのdependenciesは、次のようになります。

package.json
"dependencies": {
  "@types/jest": "24.0.22",
  "@types/node": "12.12.7",
  "@types/react": "16.9.11",
  "@types/react-dom": "16.9.4",
  "react": "^16.11.0",
  "react-dom": "^16.11.0",
  "react-scripts": "3.2.0",
  "typescript": "3.7.2"
}

まあまあ最新の構成になっていて、TypeScriptもv3.7.2が入っていますね。

やったー! これで僕らも快適生活の仲間入りだ! というわけで、次のようなコードを書いてみます。

App.tsx
import React from 'react';
import logo from './logo.svg';
import './App.css';

const App: React.FC = () => {
  // 中身がオブジェクトかnullかわからないhoge変数
  const hoge: { fuga: string } | null = (() => {
    switch(Math.floor(Math.random() * 10) % 2) { // 0 or 1
      case 0:
        return { fuga: "piyo" };
      default:
        return null;
    }
  })();

  return (
    <div className="App">
      {/*略*/}
        <p>
          {hoge?.fuga /* <= TS3.7 Optional Chaining */}
        </p>
        <p>
          {hoge ?? "none" /* <= TS3.7 Nullish Coalescing */}
        </p>
      {/*略*/}
    </div>
  );
}

export default App;

TypeScript的には何も問題ないコードなので、エディタでの表示や、tscコマンドでの静的型チェックの結果は、特に問題が起こりません。

問題が起こるのは、ビルドが絡んだときです。

$ yarn build
yarn run v1.19.0
$ react-scripts build
Creating an optimized production build...
Failed to compile.

./src/App.tsx
SyntaxError: /my/project/path/src/App.tsx: Support for the experimental syntax 'optionalChaining' isn't currently enabled (23:16):

  21 |         </p>
  22 |         <p>
> 23 |           {hoge?.fuga}
     |                ^
  24 |         </p>
  25 |         <p>
  26 |           {hoge ?? "none"}

Add @babel/plugin-proposal-optional-chaining (https://git.io/vb4Sk) to the 'plugins' section of your Babel config to enable transformation.


error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

Optional Chainingは有効になっていないよ、と怒られてしまいます。ちなみに上から順にエラーを出していくだけなので、順序を変えればNullish Coalescingでも同じことを言われます。

$ yarn build
yarn run v1.19.0
$ react-scripts build
Creating an optimized production build...
Failed to compile.

./src/App.tsx
SyntaxError: /my/project/path/src/localhost/ts37app/src/App.tsx: Support for the experimental syntax 'nullishCoalescingOperator' isn't currently enabled (23:17):

  21 |         </p>
  22 |         <p>
> 23 |           {hoge ?? "none"}
     |                 ^
  24 |         </p>
  25 |         <p>
  26 |           {hoge?.fuga}

Add @babel/plugin-proposal-nullish-coalescing-operator (https://git.io/vb4Se) to the 'plugins' section of your Babel config to enable transformation.


error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

これは困りました。

CRAでの文法解決はBabelの責務

以前の記事でも言及したことがあったのですが、Create React AppのTypeScriptサポートは、Babelの仕組みに乗っかったものです。

TypeScriptを実行可能なコードに変換するスタイルは、いくつかあります。

  1. tsc コマンドで.ts(x)ファイルを.jsファイルにコンパイルする(ts-nodeもこれのはず)
  2. @babel/preset-typescript で「TypeScript独自文法」を剥がしてECMAScript化してから、通常のBabelコンパイルを行う
  3. Static TypeScriptのように直接バイナリにコンパイルする

これらのうち、Create React Appが採用しているのは、2番めの方式です。noEmitオプションがついたtscコマンドを実行することで、静的型チェックを行うことはありますが、あくまでもチェックのみで、コンパイルやトランスパイルといったことはtscの責務に含まれません。

この方式を取っている場合、文法を最終的に実行可能な形式に落とし込むのは、Babel側の責務です。Babelが処理できない文法は扱うことができないのです。今回のケースでは、Babel側が全く知らないというわけではなく、「薄々知ってはいるけど、まだStage 3でexperimental扱いなので、デフォルトでは有効ではない」ということをエラーメッセージで知らせてくれている形でした。

素のCRAでは使えない

エラーメッセージにもあるとおり、 @babel/plugin-proposal-optional-chaining@babel/plugin-proposal-nullish-coalescing-operator のプラグインをインストールし、babel.config.js等の設定ファイルへ適切に適用すれば、新しい文法が有効になります。

さて、ここで残念なお知らせがあるのですが、Create React Appには babel.config.js.babelrc を置くことができません。また、 react-scripts のv3.2.0にはこれらのプラグインが含まれていません。

というわけで、詰みました。 react-scripts のアップデートをお待ちください。幸い、既にプルリクエストがマージされています。じきにリリースされるでしょう。

新しい文法さえ使わなければビルドはできるので、もう少し新文法を使うのは我慢しましょう。

react-app-rewiredでがんばる

ここで終わってしまうとタイトルが回収できないので、「いやだいいやだい! 僕はすぐに新文法を使いたいんだい!!」という皆さん(主に私)はどうすればいいのかという話をします。

まあCRAからの派生で選べる選択肢というのはさほど多くはなく、

のどちらかになります。今回はrewiredを使いましょう。

普通のセットアップ方法(config-overrides.jsを作るところまで)の解説は公式READMEに譲るとして、その先の話をします。逆に、セットアップができない人がこの先の操作をするのは危険なので、この記事を閉じたほうがよさそうです。

まずは必要なパッケージをインストールしておきましょう(npmの人はいい感じに読み替えてください)。

$ yarn add babel-loader @babel/plugin-proposal-optional-chaining @babel/plugin-proposal-nullish-coalescing-operator

次に、config-overrides.jsには次のような設定を行います。

config-overrides.js
module.exports = function override(config, env) {
  config.module.rules.push({
    test: /\.tsx?$/,
    use: {
      loader: 'babel-loader',
      options: {
        // 設定ファイルは使わない
        babelrc: false,
        configFile: false,

        // 元の動きを再現する(つもり)
        presets: [["react-app", { flow: false, typescript: true }]],
        // 今回追加するプラグイン
        plugins: [
          '@babel/plugin-proposal-optional-chaining',
          '@babel/plugin-proposal-nullish-coalescing-operator',
        ],
      },
    },
  });

  return config;
};

なけなしのwebpack筋で書いたので、間違っているところもありそうという気持ちはあるのですが、ひとまず私はこれで急場を凌いでいます。

それではビルドしてみましょう。

$ yarn build
yarn run v1.19.0
$ react-app-rewired build
Creating an optimized production build...
Compiled successfully.

File sizes after gzip:

  39.84 KB  build/static/js/2.7713c0fe.chunk.js
  773 B     build/static/js/runtime-main.42fdd2c2.js
  709 B     build/static/js/main.bce5e502.chunk.js
  418 B     build/static/css/main.dfca195d.chunk.css

The project was built assuming it is hosted at the server root.
You can control this with the homepage field in your package.json.
For example, add this to build it for GitHub Pages:

  "homepage" : "http://myname.github.io/myapp",

The build folder is ready to be deployed.
You may serve it with a static server:

  yarn global add serve
  serve -s build

Find out more about deployment here:

  https://bit.ly/CRA-deploy

✨  Done in 12.92s.

うまくいったようです。

まとめ

過渡期ということで、かなりお手軽にハマれてしまう案件が発生していたため、記事にしてみました。

react-scripts がアップデートされたら陳腐化する記事なので、早く陳腐化してほしいなあ。

それはそれとして、webpack筋がある程度ある人に取っては react-app-rewired はこういう時の脱出ハッチとしてめちゃくちゃ便利なので、使い方を覚えておくと便利です。濫用すると死ぬけど、折を見て使ってみてくださいね。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【React】 Firebaseを使用して認証機能の実装

概要

ReactでFirebaseを使用した簡単な認証の実装方法に関してまとめました。
あくまでも、実装の流れをメインとしており、各機能の詳しい説明等は省いております。

Firebaseのセットアップ

新しいプロジェクトの作成

まずはFirebaseのConsoleで新しいProjectの作成を行いましょう。

image

Authenticationの使用

Authenticationの画面にて、 SET UP SIGN-IN METHODをクリックしましょう。

image

そして、何を使用して認証を行いたいのか選択しましょう。
本投稿でははEmailを使用して行うので、Email/Passwordを許可します。

image

credentialsの取得

左端にあるセッティングボタンから、project settingsを選択し、アプリのcredentialsを取得しましょう。
(project settingsの下の方にIOSかAndroidかWebのボタンがあるのでWebのボタンをクリックします。)

image

credentialsは下記のような形式で取得可能です。

apiKey: "your_key",
authDomain: "your_app_id.firebaseapp.com",
databaseURL: "https://your_app_id.firebaseio.com",
projectId: "your_app_id",
storageBucket: "your_storage_bucket",
messagingSenderId: "sender_id",
appId: "your_app_id"

Reactアプリケーションのセットアップ

create-react-appを使用して、アプリケーションを作成します。

$ create-react-app your-app-name
$ cd your-app-name

今回の実装で必要となる2つのパッケージをインストールします。

$ yarn add firebase react-router react-router-dom

ReactアプリをFirebaseと繋ぐ

先ほど取得したfirebase credentialsを下記の形式で.envに貼り付けましょう。

// .env
REACT_APP_FIREBASE_KEY="your_key"
REACT_APP_FIREBASE_DOMAIN="your_app_id.firebaseapp.com"
REACT_APP_FIREBASE_DATABASE="https://your_app_id.firebaseio.com"
REACT_APP_FIREBASE_PROJECT_ID="your_app_id"
REACT_APP_FIREBASE_STORAGE_BUCKET="your_storage_bucket"
REACT_APP_FIREBASE_SENDER_ID="sender_id"

新しくsrc/base.jsを作成し、下記のコードのようにすることで、与えられたcredentialsでfirebaseのインスタンスを作成します。

import * as firebase from "firebase/app";
import "firebase/auth";

export const app = firebase.initializeApp({
  apiKey: process.env.REACT_APP_FIREBASE_KEY,
  authDomain: process.env.REACT_APP_FIREBASE_DOMAIN,
  databaseURL: process.env.REACT_APP_FIREBASE_DATABASE,
  projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
  storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.REACT_APP_FIREBASE_SENDER_ID
});

Contextの設定

src/auth/AuthProviderを作成し、認証に必要なロジックをここに集約させます。
そして、集約した認証に必要な情報をコンポーネントツリー全体に渡すためにReactのContextを使用します。

  • ReactのContexに関してはこちらで詳しい説明がされています。
  • 下記コード内のsignInWithEmailAndPassword等はfirebaseから提供されている関数で公式ドキュメントで細かい動作等を確認頂けます。
import React, { useEffect, useState } from "react";
import { app } from "../base.js";

// contextの作成
export const AuthContext = React.createContext();

export const AuthProvider = ({ children }) => {
  const [currentUser, setCurrentUser] = useState(null);

  // ユーザーをログインさせる関数
  const login = async (email, password, history) => {
    try {
      await app.auth().signInWithEmailAndPassword(email, password);
      history.push("/");
    } catch (error) {
      alert(error);
    }
  };

  // 新しいユーザーを作成しログインさせる関数
  const signup = async (email, password, history) => {
    try {
      await app.auth().createUserWithEmailAndPassword(email, password);
      history.push("/");
    } catch (error) {
      alert(error);
    }
  };

  useEffect(() => {
    app.auth().onAuthStateChanged(setCurrentUser);
  }, []);

  return (
    // Contextを使用して認証に必要な情報をコンポーネントツリーに流し込む。
    <AuthContext.Provider
      value={{
        login: login,
        signup: signup,
        currentUser
      }}
    >
      {children}
    </AuthContext.Provider>
  );
};

Private Routeの作成

認証を許可されたユーザーのみがアクセスできるPrivate Routeを作成します。
新しくsrc/auth/PrivateRouteを作成し、下記のコードのように設定します。
AuthContextから渡されたcurrentUserがセットされていればアクセスを許可し、セットされていない(null)の場合はLoginページに移動されます。

import React, { useContext } from "react";
import { Route } from "react-router-dom";
import { AuthContext } from "./AuthProvider";
import Login from "./Login";

const PrivateRoute = ({ component: RouteComponent, ...options }) => {
  const { currentUser } = useContext(AuthContext);
  const Component = currentUser ? RouteComponent : Login;

  return <Route {...options} component={Component} />;
};

export default PrivateRoute;

ルーティングの設定

src/App.jsを開き、ルーティングを設定します。
この場合、HomePrivateRouteに設定しているので、Homeにアクセスするのにユーザーの認証を必要としています。

Home,Login,SignUpコンポーネントはこれから作成します。

import React from "react";
import { BrowserRouter as Router, Route } from "react-router-dom";
import PrivateRoute from "./auth/PrivateRoute";
import { AuthProvider } from "./auth/AuthProvider";
import Home from "./components/Home";
import Login from "./auth/Login";
import SignUp from "./auth/SignUp";

const App = () => {
  return (
    <AuthProvider>
      <Router>
        <div>
          <PrivateRoute exact path="/" component={Home} />
          <Route exact path="/login" component={Login} />
          <Route exact path="/signup" component={SignUp} />
        </div>
      </Router>
    </AuthProvider>
  );
};

export default App;

Home, Login and Signupコンポーネントの作成.

各コンポーネントを下記のように作成していきます。

src/components/Home.jsx

import React from "react";
import { app } from "../base";

function Home(props) {
  return (
    <div>
      <h2>Home Page</h2>
      // ユーザーをログアウトさせる
      <button onClick={() => app.auth().signOut()}>Sign out</button>
    </div>
  );
}

export default Home;

src/auth/Login.jsx

import React, { useContext } from "react";
import { withRouter } from "react-router";
import { AuthContext } from "./AuthProvider";

const Login = ({ history }) => {
  const { login } = useContext(AuthContext);

  // AuthContextからlogin関数を受け取る
  const handleSubmit = event => {
    event.preventDefault();
    const { email, password } = event.target.elements;
    login(email.value, password.value, history);
  };

  return (
    <div>
      <h1>Log in</h1>
      <form onSubmit={handleSubmit}>
        <label>
          Email
          <input name="email" type="email" placeholder="Email" />
        </label>
        <label>
          Password
          <input name="password" type="password" placeholder="Password" />
        </label>
        <button type="submit">Log in</button>
      </form>
    </div>
  );
};

export default withRouter(Login);

src/auth/Signup.jsx

import React, { useContext } from "react";
import { withRouter } from "react-router";
import { AuthContext } from "./AuthProvider";

const SignUp = ({ history }) => {
  const { signup } = useContext(AuthContext);
  // AuthContextからsignup関数を受け取る
  const handleSubmit = event => {
    event.preventDefault();
    const { email, password } = event.target.elements;
    signup(email.value, password.value, history);
  };

  return (
    <div>
      <h1>Sign up</h1>
      <form onSubmit={handleSubmit}>
        <label>
          Email
          <input name="email" type="email" placeholder="Email" />
        </label>
        <label>
          Password
          <input name="password" type="password" placeholder="Password" />
        </label>
        <button type="submit">Sign Up</button>
      </form>
    </div>
  );
};

export default withRouter(SignUp);

アプリケーションの動作確認

アプリケーションを立ち上げます。

$ yarn start

Home画面アクセスに認証が必要である事の確認

http://localhost:3000にアクセスしようとすると下記のようにLoginページに飛ばされる事を確認します。

image.png

ユーザーが作成できる事の確認

http://localhost:3000/signupemailpasswordを入力し、Sing Upをクリックします。
image.png

自身のFirebaseコンソールで作成したユーザーが登録されている事を確認します。

image.png

作成したユーザーでログインできる事の確認

http://localhost:3000/loginで先ほど作成したユーザーの情報でログインし、Home画面にアクセスできる事を確認します。

image.png

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Amazon S3でSPAをサクッと公開する

はじめに

ストレージサービスとして有名&優秀なAmazon S3ですが、実は「静的ウェブサイトホスティング」という機能を使うことで、Vue.jsやReactで作ったSPAを簡単に公開することができます。また、AWS CLI を使用することでコマンド一発でサクッとデプロイすることができます。herokufirebaseなどのPaaSが充実している昨今、あまりS3でやるメリットない気もしますが。今回はその手順についてまとめてみました。

(ちなみに私がVue.jsをよく使うのでちょくちょくVue.jsが登場しますが、Reactでも同様の操作ができるはずなので、適宜読み替えていただければと思います。)

お値段

まず一番大事なお金の話から。
S3の料金は「利用したストレージの容量」「リクエスト件数」「リクエストに対するデータ送信量」の3軸で計算されます。

ストレージ料金
最初の 50 TB/月 0.025USD/GB
リクエスト料金
PUT、COPY、POST、または LIST リクエスト リクエスト 1,000 件あたり 0.0047USD
GET、SELECT および他のすべてのリクエスト リクエスト 1,000 件あたり 0.00037USD
データ転送料金
1 GB まで/月 0.00USD/GB
次の 9.999 TB/月 0.114USD/GB

料金 - Amazon S3 | AWSより一部抜粋

例えば、配信するコンテンツのデータサイズが1MB、リクエスト件数が100件/日の場合、
ストレージ料金:0.000025USD
リクエスト料金:0.00111USD
データ転送料金:0.228USD
で、合計約 0.229USD/月 (日本円で約 25.19円/月)です。ばちくそ安いですね。

アカウント開設から1年以内の無料枠を利用すればほぼ0円に抑えられると思います。
※参考 - AWS クラウド無料利用枠

SPAの準備

各フレームワークでのビルドを実行し、
index.htmlと各フォルダ(css/js/img等)が揃っている状態にしましょう。
スクリーンショット 2019-11-12 9.28.31.png

ちなみにVue.jsでのプロジェクト開始方法およびビルド方法はこちらの記事(Vue CLI スタートガイド)にまとめてありますので、フロントフレームワークを全く触ったことないという方はこちらを参考にしてみてください。

S3用のIAMユーザ作成

AWSルートアカウントの作成

AWSを初めて使うという方はAWSルートアカウントを作成しましょう。
こちらを参考にすると良いと思います。
AWS アカウント作成の流れ | AWS

S3用のIAMユーザの作成

ルートアカウントは全権限アカウントのため全てのAWSリソースへのアクセスができてしまいます。このルートアカウントで作業を続けることはセキュリティ上よろしくないので、S3のみ使用可能なIAMユーザを別途作成し、今後の作業はこのS3用IAMユーザで行います。

まずルートアカウントでログインし、マネジメントコンソールからIAMへ移動します(検索バーで「iam」と打てば出てきます)。
スクリーンショット 2019-11-10 22.48.28.png

「ユーザー」メニューを選択すると、作成したIAMユーザの一覧が表示されます。
今回は新規でユーザを作成するので、青色ボタンの「ユーザーを追加」をクリックしましょう。
スクリーンショット 2019-11-10 22.52.02.png

IAMユーザを作成するための設定画面が表示されます。

  • ユーザー名は任意の名前で構いません(公開しようとしているSPA用のIAMユーザであることが分かるネーミングだと良いです)
  • 「AWS マネジメントコンソールへのアクセス」にチェックを入れ、ログインパスワードを設定しましょう
    スクリーンショット 2019-11-10 23.05.31.png

  • 「既存のポリシーを直接アタッチ」から、AmazonS3FullAccessを選択し、チェックを入れましょう
    スクリーンショット 2019-11-10 23.10.52.png

それ以外はデフォルトの設定で問題ありません。

IAMユーザの作成が完了すると一覧に表示されるので、問題なく作成されているか確認しましょう。
スクリーンショット 2019-11-10 23.19.27.png

IAMユーザの作成が完了したら、早速ログインしてみましょう。
右上にIAMユーザ名 @ アカウント名と表示されていれば問題なくログインできています。
スクリーンショット 2019-11-11 9.57.50.png

S3以外のサービスへのアクセスがブロックされているか確認するために、試しにIAMを開いてみましょう。
先ほどIAMユーザを作成した画面に移動しても「アクセス権限が必要です」と表示され、ユーザを新規作成できないようになっているはずです。
スクリーンショット 2019-11-11 10.01.22.png

このように、利用用途ごとにIAMユーザを作成してAWSサービスへの権限を切り分け、意図しない操作が実行されないようにしましょう。

S3でSPAを公開

先ほどログインしたS3用IAMユーザで作業を進めます。

バケットの作成

S3の画面へ移動し、青色の「+バケットを作成する」ボタンをクリックしましょう。
バケット作成に際しての設定画面が表示されるので、情報を入力しましょう。

  • バケット名:任意の文字で構いません。ここで設定したバケット名が後ほど静的ホスティングする際のURLの一部として使われるので、それっぽい名前を付けましょう。
  • リージョン:これも任意で構いませんが、「アジアパシフィック(東京)」を選択するのが無難です。 スクリーンショット 2019-11-11 10.23.26.png

それ以外の設定は一旦デフォルトのままで問題ないです。

バケットが作成されるとバケット一覧に表示されるようになります。
スクリーンショット 2019-11-11 10.31.27.png

コンテンツのアップロード

バケット名をクリックするとバケットの詳細画面に遷移することができます。
青色の「アップロード」ボタンをクリックし、公開したいSPAの各ファイル(index.htmlとcss/js/imgフォルダ等)をローカルからアップロードしましょう。
初回アップロード時にバケットの設定について色々と聞かれますが、全てデフォルトで問題ないです。
無事アップロードが完了するとこのような画面になると思います。
スクリーンショット 2019-11-11 10.49.53.png

静的ウェブサイトホスティング機能の設定

「プロパティ」タブへ移動し、「Static website hosting」の設定を行います。

  • 「このバケットを使用してウェブサイトをホストする」にチェックを入れます
  • 「インデックスドキュメント」にindex.htmlを指定します スクリーンショット 2019-11-11 10.52.54.png

上記の設定が完了したら「保存」ボタンをクリックしましょう。

なお、この画面で表示されている「エンドポイント」のURLがウェブサイトへのアクセス用URLになります。が、今の状態でこのURLにアクセスしようとしても403エラーが返ってきてしまいます。URLでのアクセスを許可するために、次の章で説明するバケットポリシーを設定しましょう。
スクリーンショット 2019-11-11 11.00.11.png

バケットポリシーの設定

S3に格納したオブジェクトが不用意にネットに晒されないよう、デフォルトでは外部からS3バケットへのアクセスは全て拒否するようになっています。先ほど403エラーが返ってきたのもそのためです。正しくWebページを表示させるためにはURLによる外部からのリクエストを明示的に許可する必要があります。

アクセス制御に関することは「アクセス制御」タブで行います。

まず、「ブロックパブリックアクセス」を開きます。「編集」をクリックし、以下の設定を行います。

  • 「パブリックアクセスをすべてブロック」のチェックを外します
  • 下から2つ目、「新しいパブリックバケットポリシーを介して...」のチェックを外します
  • 一番下、「任意のパブリックバケットポリシーを介して...」のチェックを外します スクリーンショット 2019-11-11 22.28.31.png

これでバケットポリシーによるアクセス許可設定が有効になります。ここの設定を行わないと、いくらバケットポリシーで許可の設定を行ってもブロックされてしまうので注意しましょう。

次に「バケットポリシー」を開き、エディタ欄に以下のJSONをバケット名を置き換えて貼り付けましょう。
このJSONはsample-hosting-kiyokiyoバケットへのGetリクエストを許可するバケットポリシーです。AWS公式チュートリアルのものをそのまま抜粋しました。バケット名の部分のみ、自分が作成したバケット名に置き換えるのを忘れないようにしましょう。

{
   "Version":"2012-10-17",
   "Statement":[{
    "Sid":"PublicReadForGetBucketObjects",
         "Effect":"Allow",
      "Principal": "*",
       "Action":["s3:GetObject"],
       "Resource":["arn:aws:s3:::sample-hosting-kiyokiyo/*"
       ]
     }
   ]
 }

これで外部からS3に格納したファイルを取得できるようになりました。
先ほど403エラーが返ってきたURLでアクセスし直すと、今度は正しくWebページが表示されるようになっているはずです。
スクリーンショット 2019-11-11 22.45.49.png

S3へのデプロイコマンドを作る

上記の手順でWebページの公開はできるようになりましたが、Webページを更新するたびにIAMユーザでログインし、S3バケットに格納してあるファイルを削除して、ローカルにある新しいファイルをアップロードし直す、というのはかなり面倒です。Vue.jsでは
npm run serveでローカルサーバーを起動し、
npm run buildでビルドを行うことができます。
それと同じノリで、
npm run deployで、S3へのデプロイができるよう設定を組みましょう。

AWS CLI を使用する

AWS CLI を使うと、ターミナル等のコマンドラインツールからAWSサービスを操作できるようになります。これを利用して、S3上の対象バケットにあるファイルを削除し、ローカルにある新規ファイルをアップロードするスクリプトを組みます。

こちらの記事でAWS CLI を使うための手順がまとめられているので参考にしてみてください。
【初心者向け】MacユーザがAWS CLIを最速で試す方法 | Developers.IO

かいつまんで説明しますと、

1:まずpipをインストールします(Python3.4以降であればPythonのインストールと同時に使えるそうです)。pip -Vと打ってバージョンが表示されれば問題ないです。

$ pip -V
pip 19.3.1 from /Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/pip (python 3.7)

2:次にAWS CLI をインストールします

$ pip install awscli

3: ルートアカウントでS3用IAMユーザのアクセスキーとシークレットアクセスキーを生成・取得します。IAMメニューで先ほど作成したS3用IAMユーザを選択し、「認証情報」タブの「アクセスキーの作成」ボタンをクリックします。アクセスキーとシークレットアクセスキーが表示されるので、手元に控えておきましょう。
スクリーンショット 2019-11-11 23.24.42.png

4:ターミナルにaws configureと入力しS3を操作するための設定を行います。Access Key IDSecret Access Keyに先ほど取得した情報を入力しましょう。Default regionはS3バケットで指定したリージョン(アジアパシフィック(東京)の場合はap-northeast-1)を入力しましょう。Default output formatはとりあえずtextで問題ありません。

$ aws configure
AWS Access Key ID [None]: XXXX
AWS Secret Access Key [None]: XXXXXXXX
Default region name [None]: ap-northeast-1
Default output format [None]: text

動作確認として、aws s3 lsでS3に登録しているバケットが一覧表示されればOKです。

$ aws s3 ls
2019-11-11 22:45:22 sample-hosting-kiyokiyo

デプロイ用のシェルスクリプトを組む

index.html等が格納されているディレクトリをdistとします。
distと同じ階層にデプロイ用のスクリプトを記載したdeploy-s3.shを配置します。
ディレクトリ構造のイメージはこんな感じです。

(any directory)
 ├dist/
 │ ├css/
 │ ├img/
 │ ├js/
 │ └index.html
 └deploy-s3.sh

deploy-s3.shの中身はこんな感じで書きます。

deploy-s3.sh
#!/bin/sh

aws s3 rm s3://sample-hosting-kiyokiyo/ --recursive
aws s3 cp dist s3://sample-hosting-kiyokiyo/ --recursive

1行目はシェルスクリプトを走らせるためのおまじないです。詳しく知りたい方はこちらの記事(#!/bin/sh は ただのコメントじゃないよ! Shebangだよ!)とかが参考になると思います。
2行目ではsample-hosting-kiyokiyoバケットの中身を再帰的(--recursive)に削除(rm)しています。
3行目ではdistディレクトリの中身をsample-hosting-kiyokiyoバケットにコピー(cp)しています。

AWS CLI でできることはこちらのAWS CLI Command Referenceにまとまっているので、他のスクリプトを走らせたい方は調べてみてください。

デプロイ用コマンドを作る

最後に、npm run deployと入力したら先ほど作成したdeploy-s3.shが呼び出されるようにします。
npm runコマンドはpackage.jsonscriptsブロックで設定できます。
"deploy": "bash deploy-s3.sh"をscriptsブロック内に追加しましょう。

package.json
{
  (省略)
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "deploy": "bash deploy-s3.sh"
  },
  (省略)
}

これで作業としては完了です。
試しにローカルでの変更をS3にデプロイできるか確かめてみましょう。

まず、ローカルで適当に変更を行います。
サンプルとして今回はとりあえずApp.vueのHelloWorldタグを以下のように変えてみます。

(変更前)
<HelloWorld msg="Welcome to Your Vue.js App"/>
(変更後)
<HelloWorld msg="Hi! My name is Kiyokiyo! Nice to meet you!"/>

ビルドコマンドを走らせます。

$ npm run build

これでローカルのdistディレクトリ以下に必要なファイルが揃いました。

最後にデプロイコマンドを走らせます。

$ npm run deploy

S3上のファイルがdeleteされ、ローカルのファイルがS3にアップロードされたことが、ターミナルの出力からも分かると思います。

WebページのURLにアクセスするとしっかり変更が反映されていますね。
スクリーンショット 2019-11-12 9.17.36.png

おわりに

これでローカルで作成していた静的ウェブサイトを公開できるようになりました。
デプロイもコマンド一発で簡単にできるようになったので、開発速度もかなり向上したんじゃないでしょうか。

個人的な今後としては、LambdaやDynamoDBを利用したサーバーレスAPIとの通信にチャレンジしてみたいと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[TypeScript]GitHubにGraphQLでアクセスするServerlessプログラムを作る

[TypeScript]GitHubにGraphQLでアクセスするServerlesssプログラムを作る

※同じ記事をこちらにも書いています

動作画面・ソースコード

.
├── README.md
├── dist [出力ディレクトリ]
│   ├── index.html
│   └── js
│       ├── bundle.js
│       └── bundle.js.map
├── front
│   ├── public
│   │   └── index.html [トップページ定義]
│   ├── src
│   │   ├── App.tsx [Application初期設定]
│   │   ├── GitHub
│   │   │   ├── FirebaseGitAuthModule.ts  [FirebaseGit認証用モジュール]
│   │   │   ├── GitHubModule.ts           [Githubアクセス用モジュール]
│   │   │   └── GraphQLgetRepositories.ts [GraphQLクエリ]
│   │   ├── Parts [サブパーツコンポーネント]
│   │   │   ├── CircleButton [ボタンコンポーネント]
│   │   │   │   └── index.tsx
│   │   │   ├── FlexParent.tsx [配置スタイル定義用]
│   │   │   └── LodingImage [ローディングアニメーション]
│   │   │       ├── index.tsx
│   │   │       └── loading.svg
│   │   ├── RepositorieList [リポジトリリスト表示用]
│   │   │   └── RepositorieList.tsx
│   │   ├── TopArea [トップエリア]
│   │   │   ├── TopArea.tsx
│   │   │   └── Window [ログイン/ログアウトウインドウ]
│   │   │       ├── LoginWindow.tsx
│   │   │       ├── LogoutWindow.tsx
│   │   │       ├── WindowModule.tsx
│   │   │       └── WindowStyle.tsx
│   │   ├── config.ts [GitHub/FirebaseのAPIキー]
│   │   ├── index.tsx  [Store設定等]
│   │   ├── resource.d.ts [画像リソース定義用]
│   │   └── tsconfig.json
│   └── webpack.config.js
└── package.json

前提条件

 サーバレスのフロントエンドアプリケーションとしてGitHubのリポジトリデータを表示するアプリケーションを作成します

 GitHubのGraphQL APIを使用するには、アプリケーションキーを発行する必要がありますが、フロントエンドにシークレットキーを埋め込むわけにはいかないので、OAuthを行う方式をとります。

 GitHubのOAuthはバックエンドのリダイレクトを必要とするため、認証にはFirebaseを使用します。

使用パッケージ

 フロントエンドにはReactを使用します。@jswf系は自作ライブラリをnpmに登録したものなのでだれも知らないと思います。

項目 内容
TypeScript 使用言語
React フロントエンド構築用
firebase OAuth2認証用
redux-persist Storeデータをブラウザに保存
@jswf/react React用仮想ウインドウライブラリ
@jswf/redux-module Redux簡略化ライブラリ
@jswf/adapter 通信用ライブラリ

アプリケーションキーの発行

FirebaseとGitHubにそれぞれ設定が必要となります
この作業をすることによって、GitHubのAPIを呼び出すのに必要なtokenが受け取れるようになります

Firebaseで認証用プロジェクトの作成

image.png

https://console.firebase.google.com/ でプロジェクトを一つ作成
Authentication -> ログイン方法 -> GitHubを有効にする
認証コールバックURLを取得(ClientIDとClientSecretは後で記入)

GitHubにアプリケーションを登録

image.png

https://github.com/settings/developers でアプリケーションを作成
アプリケーション名とHomepageURL(仮アドレスでOK)と認証コールバックURLを設定
発行されたClientIDとClientSecretをFirebase側に設定

アプリケーション上で必要となるデータ

発行したキーの中から、プログラム上では以下の情報が必要となります

Firebase -> APIキー、AuthDomain
GitHub -> clientId

認証部分のプログラムを作成

 OAuth認証にはFirebaseを利用しているので、アプリケーション登録のための初期設定さえ完了していれば、プログラム的には単純に書くことが出来ます。

 まずは発行済みのキーを用意します。

config.ts
export const firebaseConfig = {
  apiKey: "",
  authDomain: ""
};
export const githubConfig = {
  clientId: ""
};

Firebaseから送られてきたcredentialの中からaccessTokenを取り出します。
これがGitHubのAPIを呼び出すときに必要となるキーになります。

また、スコープはログイン表示処理の方から送られてくるのですが、
  無指定
  "repo" プライベートリポジトリの情報を取得
  "read:org" 組織データを取得する
を選択するようになっています。

 データ入出力は@jswf/redux-moduleを使って、this.setStateとthis.getStateでStoreデータを書き換えています。コンポーネント間のやり取りは全部この方式が用いられているので、表示用のコンポーネントの中でpropsやstateは出てこない形となります。

FirebaseGitAuthModule.ts
import { ReduxModule } from "@jswf/redux-module";
import * as firebase from "firebase/app";
import "firebase/auth";
import { firebaseConfig } from "../config";

/**
 *保存ステータス
 *
 * @interface State
 * name GitHubユーザ名
 * token GitHubAPIアクセストークン
 */
interface State {
  name: string | null;
  token: string | null;
}

/**
 *Firebase用GitHub認証モジュール
 *
 * @export
 * @class FirebaseGitAuthModule
 * @extends {ReduxModule<State>}
 */
export class FirebaseGitAuthModule extends ReduxModule<State> {
  static defaultState: State = { name: null, token: null };
  static app?: firebase.app.App;
  /**
   *GitHubApiログイン処理
   *
   * @memberof FBGitAuthModule
   */
  public login(scopes: string[]) {
    //Firebaseの初期化
    if (!FirebaseGitAuthModule.app)
      FirebaseGitAuthModule.app = firebase.initializeApp(firebaseConfig);
    //認証スコープの定義
    const provider = new firebase.auth.GithubAuthProvider();
    scopes.forEach(scope => provider.addScope(scope));
    //Firebase経由のGitHubへの認証
    firebase
      .auth()
      .signInWithPopup(provider)
      .then(({ credential, additionalUserInfo }) => {
        if (additionalUserInfo && credential) {
          const name = additionalUserInfo.username;
          const token = (credential as firebase.auth.AuthCredential & {
            accessToken: string;
          }).accessToken;
          if (name && token) {
            if (
              this.getState("name") !== name &&
              this.getState("token") !== token
            )
              this.setState({ name, token });
          }
        }
      });
  }
  /**
   *GitHubAPIログアウト処理
   *
   * @memberof FBGitAuthModule
   */
  public logout() {
    this.setState({ name: null, token: null });
    if (FirebaseGitAuthModule.app) firebase.auth().signOut();
  }
  /**
   *GitHubアクセス用トークンの取得
   *
   * @returns
   * @memberof FirebaseGitAuthModule
   */
  public getToken() {
    return this.getState("token");
  }
  /**
   *GitHubユーザ名の取得
   *
   * @returns
   * @memberof FirebaseGitAuthModule
   */
  public getUserName() {
    return this.getState("name");
  }
}

GitHubへのアクセス

 organizationsの組織データを含むものと、そうで無いものに分けてGraphQLクエリーを作成しています。ちなみにデータが100件を超えた場合などは想定していません。

GraphQLgetRepositories.ts
/// GraphQLアクセス用クエリーデータ
export const getRepositories = `
{
  viewer {
    name: login
    repositories(last: 100) {
      ...rep
    }
}

fragment rep on RepositoryConnection {
  nodes {
    id
    url
    name
    owner {
      login
    }
    branches: refs(last: 10, refPrefix: "refs/heads/") {
      totalCount
      nodes {
        name
        target {
          ... on Commit {
            committedDate
            message
          }
        }
      }
    }
    stargazers {
      totalCount
    }
    watchers {
      totalCount
    }
    isPrivate
    createdAt
    updatedAt
    description
  }
}
`;
export const getRepositoriesOrg = `
{
  viewer {
    name: login
    repositories(last: 100) {
      ...rep
    }
    organizations(last: 100) {
      nodes {
        name
        repositories(last: 100) {
          ...rep
        }
      }
    }
  }
}

fragment rep on RepositoryConnection {
  nodes {
    id
    url
    name
    owner {
      login
    }
    branches: refs(last: 10, refPrefix: "refs/heads/") {
      totalCount
      nodes {
        name
        target {
          ... on Commit {
            committedDate
            message
          }
        }
      }
    }
    stargazers {
      totalCount
    }
    watchers {
      totalCount
    }
    isPrivate
    createdAt
    updatedAt
    description
  }
}
`;
/// クエリー結果の構造
export type QLRepositories = {
  nodes: {
    id: string;
    name: string;
    owner: { login: string };
    url: string;
    isPrivate: boolean;
    branches?: {
      totalCount: number;
      nodes: {
        name: string;
        target: { committedDate: string; message: string };
      }[];
    };
    watchers: { totalCount: number };
    stargazers: { totalCount: number };
    createdAt: string;
    updatedAt: string;
    description: string;
  }[];
};
export type QLRepositoryResult = {
  data: {
    viewer: {
      name: string;
      organizations?: {
        nodes: ({ name: string; repositories: QLRepositories } | null)[];
      };
      repositories: QLRepositories;
    };
  };
};

GraphQLクエリーで取得したデータを扱いやすい形に変換し、Storeに保存します。

GitHubModule.ts
import { ReduxModule } from "@jswf/redux-module";
import { Adapter } from "@jswf/adapter";
import { hasProperty } from "hasproperty-ts";
import {
  getRepositories,
  QLRepositoryResult,
  QLRepositories,
  getRepositoriesOrg
} from "./GraphQLgetRepositories";
import { FirebaseGitAuthModule } from "./FirebaseGitAuthModule";

//リポジトリ情報の構造
export type GitRepositories = {
  id: string;
  name: string;
  url: string;
  owner: string;
  stars: number;
  watchers: number;
  private: boolean;
  branche: {
    count: number;
    name: string;
    message: string;
    update: string;
  };
  createdAt: string;
  updatedAt: string;
  description: string;
}[];

/**
 *Reduxのストア保存ステータス
 *
 * @interface State
 */
interface State {
  repositories?: GitRepositories;
  loading: boolean;
  scopes: string[];
}

/**
 *GitHubアクセス用Reduxモジュール
 *
 * @export
 * @class GitHubModule
 * @extends {ReduxModule<State>}
 */
export class GitHubModule extends ReduxModule<State> {
  static includes = [FirebaseGitAuthModule];
  //Storeの初期状態
  static defaultState: State = { loading: false, scopes: [] };

  /**
   *ユーザ名の取得
   *
   * @returns
   * @memberof GitHubModule
   */
  public getLoginName() {
    const firebaseModule = this.getModule(FirebaseGitAuthModule);
    return firebaseModule.getUserName();
  }
  public setScopes(scopes: string[]) {
    this.setState({ scopes });
  }
  public getScopes() {
    return this.getState("scopes")!;
  }
  public isScope(scope: string) {
    return this.getState("scopes")!.indexOf(scope) >= 0;
  }
  /**
   *GitHubApiログイン処理
   *
   * @memberof GitHubModule
   */
  public login() {
    const firebaseModule = this.getModule(FirebaseGitAuthModule);
    firebaseModule.login(this.getState("scopes")!);
  }
  /**
   *GitHubAPIログアウト処理
   *
   * @memberof GitHubModule
   */
  public logout() {
    const firebaseModule = this.getModule(FirebaseGitAuthModule);
    firebaseModule.logout();
    this.setState({ repositories: [] });
  }
  /**
   *情報取得状況を返す
   *
   * @returns
   * @memberof GitHubModule
   */
  public isLoading() {
    return this.getState("loading")!;
  }
  /**
   *リポジトリの情報を返す
   *
   * @returns
   * @memberof GitHubModule
   */
  public getRepositories() {
    this.setState({ loading: true });
    return this.sendGitHub(
      this.isScope("read:org") ? getRepositoriesOrg : getRepositories
    )
      .then(e => {
        if (hasProperty<QLRepositoryResult["data"]>(e, "data")) {
          const repositories: { [key: string]: GitRepositories[0] } = {};

          const repPush = (_name: string, node: QLRepositories["nodes"][0]) => {
            const branche = node.branches
              ? node.branches.nodes.sort(
                  (a, b) =>
                    new Date(b.target.committedDate).getTime() -
                    new Date(a.target.committedDate).getTime()
                )[0]
              : undefined;
            repositories[node.id] = {
              id: node.id,
              name: node.name,
              url: node.url,
              private: node.isPrivate,
              branche: {
                count: node.branches ? node.branches.totalCount : 0,
                name: branche ? branche.name : "",
                message: branche ? branche.target.message : "",
                update: branche ? branche.target.committedDate : ""
              },
              stars: node.stargazers.totalCount || 0,
              watchers: node.watchers.totalCount || 0,
              owner: node.owner.login,
              createdAt: node.createdAt,
              updatedAt: node.updatedAt,
              description: node.description
            };
          };
          e.data.viewer.repositories.nodes.forEach(node =>
            repPush(e.data.viewer.name, node)
          );
          e.data.viewer.organizations &&
            e.data.viewer.organizations.nodes.forEach(org => {
              org &&
                org.repositories.nodes.forEach(node => repPush(org.name, node));
            });
          const rep = Object.values(repositories).sort((a, b) => {
            const av = a.branche.update
              ? new Date(a.branche.update).getTime()
              : 0;
            const bv = b.branche.update
              ? new Date(b.branche.update).getTime()
              : 0;
            return bv - av;
          });
          this.setState(rep, "repositories");
        } else this.setState({ repositories: [] });
      })
      .finally(() => {
        this.setState({ loading: false });
      });
  }

  /**
   *GitHubAPIに情報を要求する
   *
   * @param {(string | object)} params
   * @returns
   * @memberof GitHubModule
   */
  public async sendGitHub(params: string | object) {
    const firebaseModule = this.getModule(FirebaseGitAuthModule);
    const token = firebaseModule.getToken();
    if (token) {
      return Adapter.sendJsonAsync(
        "https://api.github.com/graphql",
        {
          query:
            typeof params === "object"
              ? (params as { loc: { source: { body: string } } }).loc.source
                  .body
              : params
        },
        { Authorization: `bearer ${token}` }
      ).catch(({ status }) => {
        if (status === 401) this.logout();
      });
    }
    return null;
  }
}

トップエリア表示用コンポーネント

 上部の表示やログイン処理の部分です
 表示はReactで書いていますが、このアプリで書いたコンポーネントはpropsや標準stateは一切使っていません
 コンポーネント間のやりとりは@jswf/redux-moduleを使って、ReduxのStore経由になっています

 以下はタイトルやログインボタンの表示処理です

TopArea.tsx
import React from "react";
import styled from "styled-components";
import { LogoutWindow } from "./Window/LogoutWindow";
import { WindowModule } from "./Window/WindowModule";
import { CircleButton } from "../Parts/CircleButton";
import { GitHubModule } from "../GitHub/GitHubModule";
import { useModule } from "@jswf/redux-module";
import { WindowState } from "@jswf/react";
import { LoginWindow } from "./Window/LoginWindow";

const Root = styled.div`
  z-index: 100;
  display: flex;
  padding: 0.5em;
  background-color: #aaffdd;
  > #title {
    flex: 1;
    font-size: 250%;
    font-family: "Trebuchet MS", "Lucida Sans Unicode", "Lucida Grande",
      "Lucida Sans", Arial, sans-serif;
    font-weight: bolder;
    color: white;
    -webkit-text-stroke: 1px rgba(30, 80, 60, 0.6);
  }
  > #buttons {
    padding: 0.2em;
    > * {
      margin-left: 0.5em;
    }
  }
`;

export function TopArea() {
  const gitHubModule = useModule(GitHubModule);
  const loginWindowModule = useModule(WindowModule, "Login", true);
  const logoutWindowModule = useModule(WindowModule, "Logout", true);
  const loginName = gitHubModule.getLoginName();
  const title = "GitHub Manager";
  return (
    <Root>
      <div id="title">{title}</div>
      <div id="buttons">
        <CircleButton onClick={() => gitHubModule.getRepositories()}>
          更新
        </CircleButton>
        <CircleButton
          onClick={() => {
            loginName
              ? logoutWindowModule.setWindowState(WindowState.NORMAL)
              : loginWindowModule.setWindowState(WindowState.NORMAL);
          }}
        >
          {loginName || "未ログイン"}
        </CircleButton>
      </div>
      <LoginWindow />
      <LogoutWindow />
    </Root>
  );
}

仮想ウインドウライブラリを使ったポップアップ式のウインドウを表示します

LoginWindow.tsx
import React from "react";

import { useModule } from "@jswf/redux-module";
import { CircleButton } from "../../Parts/CircleButton";
import { GitHubModule } from "../../GitHub/GitHubModule";
import { JSWindow, WindowState } from "@jswf/react";
import { githubConfig } from "../../config";
import { WindowStyle } from "./WindowStyle";
import { WindowModule } from "./WindowModule";

export function LoginWindow() {
  const gitHubModule = useModule(GitHubModule);
  const windowModule = useModule(WindowModule, "Login");
  const windowState = windowModule.getWindowState();
  const scopes = new Set(gitHubModule.getScopes());

  return (
    <>
      <JSWindow
        width={400}
        windowState={windowState}
        title="ログイン"
        clientStyle={{ backgroundColor: "#aaeeff" }}
        onUpdate={e =>
          windowState !== e.windowState &&
          windowModule.setWindowState(e.windowState)
        }
      >
        <WindowStyle>
          <div id="link">
            <a
              target="_blank"
              href={`https://github.com/settings/connections/applications/${githubConfig.clientId}`}
            >
              GitHubの権限設定
            </a>
          </div>
          <div id="link">
            <a target="_blank" href="https://github.com/logout">
              GitHubのログアウト
            </a>
          </div>
          <div id="message">
            <div>ログインしますか?</div>
            <div id="option">
              <div>
                <label>
                  <input
                    type="checkbox"
                    checked={scopes.has("repo")}
                    onChange={e => {
                      e.target.checked
                        ? scopes.add("repo")
                        : scopes.delete("repo");
                      console.log(scopes);
                      gitHubModule.setScopes(Array.from(scopes));
                    }}
                  />
                  プライベートリポジトリにアクセス
                </label>
              </div>
              <div>
                <label>
                  <input
                    type="checkbox"
                    checked={scopes.has("read:org")}
                    onChange={e => {
                      e.target.checked
                        ? scopes.add("read:org")
                        : scopes.delete("read:org");
                      gitHubModule.setScopes(Array.from(scopes));
                    }}
                  />
                  組織のポジトリにアクセス
                </label>
              </div>
            </div>
          </div>

          <CircleButton
            onClick={() => {
              windowModule.setWindowState(WindowState.HIDE);
              gitHubModule.login();
            }}
          >
            OK
          </CircleButton>
          <CircleButton
            onClick={() => windowModule.setWindowState(WindowState.HIDE)}
          >
            Cancel
          </CircleButton>
        </WindowStyle>
      </JSWindow>
    </>
  );
}

以下はログアウト用ウインドウです

LogoutWindow.tsx
import React from "react";
import { useModule } from "@jswf/redux-module";
import { CircleButton } from "../../Parts/CircleButton";
import { GitHubModule } from "../../GitHub/GitHubModule";
import { JSWindow, WindowState } from "@jswf/react";
import { githubConfig } from "../../config";
import { WindowStyle } from "./WindowStyle";
import { WindowModule } from "./WindowModule";

export function LogoutWindow() {
  const gitHubModule = useModule(GitHubModule);
  const windowModule = useModule(WindowModule, "Logout");
  const windowState = windowModule.getWindowState();
  return (
    <>
      <JSWindow
        windowState={windowState}
        title="ログアウト"
        clientStyle={{ backgroundColor: "#aaeeff" }}
        onUpdate={e =>
          windowState !== e.windowState &&
          windowModule.setWindowState(e.windowState)
        }
      >
        <WindowStyle>
          <div id="link">
            <a
              target="_blank"
              href={`https://github.com/settings/connections/applications/${githubConfig.clientId}`}
            >
              GitHubの権限設定
            </a>
          </div>
          <div id="link">
            <a target="_blank" href="https://github.com/logout">
              GitHubのログアウト
            </a>
          </div>
          <div id="message">
            <div>ログアウトしますか?</div>
          </div>

          <CircleButton
            onClick={() => {
              windowModule.setWindowState(WindowState.HIDE);
              gitHubModule.logout();
            }}
          >
            OK
          </CircleButton>
          <CircleButton
            onClick={() => windowModule.setWindowState(WindowState.HIDE)}
          >
            Cancel
          </CircleButton>
        </WindowStyle>
      </JSWindow>
    </>
  );
}

リポジトリ表示部分

データはGitHubModuleから送られてくるので、あとはそれを表示するだけです
表示には@jswf/reactのListView機能を使っています
このListViewはWindowsライクなヘッダサイズ可変機能がついています

RepositorieList.tsx
import {
  ListView,
  ListHeaders,
  ListHeader,
  ListRow,
  ListItem
} from "@jswf/react";
import React, { useEffect, useRef } from "react";
import { GitHubModule, GitRepositories } from "../GitHub/GitHubModule";
import { useModule } from "@jswf/redux-module";
import { LoadingImage } from "../Parts/LodingImage";
import dateFormat from "dateformat";
import styled from "styled-components";

const Root = styled.div`
  flex: 1;
  overflow: hidden;
  position: relative;
  #name {
    > div:nth-child(2) {
      font-size: 60%;
    }
  }
  #org {
    font-size: 80%;
    white-space: normal;
  }
  #message {
    white-space: normal;
  }
`;

export function RepositorieList() {
  const gitHubModule = useModule(GitHubModule);
  const repositories = gitHubModule.getState("repositories");
  const loginName = gitHubModule.getLoginName();
  const listView = useRef<ListView>(null);
  const loading = gitHubModule.isLoading();
  const firstUpdate = useRef(true);
  useEffect(() => {
    //初回は無視
    if (firstUpdate.current) {
      firstUpdate.current = false;
      return;
    }
    //リポジトリデータが無ければ要求
    gitHubModule.getRepositories();
  }, [loginName]);

  return (
    <Root>
      {loading && <LoadingImage />}
      <ListView
        ref={listView}
        onItemDoubleClick={index =>
          listView.current &&
          window.open(
            (listView.current!.getItemValue(index) as GitRepositories[0]).url,
            "_blank"
          )
        }
      >
        <ListHeaders>
          <ListHeader width={250}>Name</ListHeader>
          <ListHeader width={100}>Owner</ListHeader>
          <ListHeader>Private</ListHeader>
          <ListHeader type="number">Branches</ListHeader>
          <ListHeader type="number">Stars</ListHeader>
          <ListHeader type="number">Watchers</ListHeader>
          <ListHeader width={180}>Date</ListHeader>
          <ListHeader width={180}>Last Branch</ListHeader>
          <ListHeader>Commit Message</ListHeader>
        </ListHeaders>
        {repositories &&
          repositories.map(e => (
            <ListRow key={e.id} value={e}>
              <ListItem value={e.name}>
                <div id="name">
                  <div>{e.name}</div>
                  <div>{e.description}</div>
                </div>
              </ListItem>
              <ListItem value={e.owner}>
                <div id="org">{e.owner}</div>
              </ListItem>
              <ListItem>{e.private && "*"}</ListItem>
              <ListItem>{(e.branche && e.branche.count) || 0}</ListItem>
              <ListItem>{e.stars}</ListItem>
              <ListItem>{e.watchers}</ListItem>
              <ListItem value={new Date(e.updatedAt).getTime()}>
                <div>
                  <div>
                    U:{dateFormat(new Date(e.updatedAt), "yyyy/mm/dd HH:MM")}
                  </div>
                  <div>
                    C:{dateFormat(new Date(e.createdAt), "yyyy/mm/dd HH:MM")}
                  </div>
                </div>
              </ListItem>
              <ListItem
                value={
                  e.branche.update ? new Date(e.branche.update).getTime() : 0
                }
              >
                <div>
                  {e.branche.update &&
                    dateFormat(new Date(e.branche.update), "yyyy/mm/dd HH:MM")}
                </div>
                <div>{e.branche.name}</div>
              </ListItem>
              <ListItem>
                <div id="message">{e.branche && e.branche.message}</div>
              </ListItem>
            </ListRow>
          ))}
      </ListView>
    </Root>
  );
}

まとめ

 今回のプログラムを作ってみて、GraphQLのクエリーを初めて触ってみたのですが、慣れていないせいもあって、欲しいデータにたどり着くまでかなり時間がかかりました。それとGitHubのAPIはRestだと取得できるのにGraphQLでは存在しないデータ(Insights系)があって不便でした。この辺りは今後追加されることを期待しています。

 GraphQLへのアクセスは、自作ライブラリで固めてしまったのですが、その中でも@jswf/redux-moduleはかなり扱いやすいです。これを使うことによって必要な非同期のデータの入出力は、専用のモジュールで集中して作ることができます。表示側のコンポーネントは、作成したモジュールを使用することによって、自動的にデータ更新の副作用が起こるようになります。ということで細かいことを気にしなくてもどんどんプログラムが作っていけます。しかもReduxの面倒な手続きは一切書く必要がありません。自分で作っておいてなんですがヤバいです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む