- 投稿日:2019-11-12T16:30:06+09:00
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"を押します。
- Name : 任意
- Client ID : YAHOO! Japan側で作成したアプリケーションのClient ID
- Client Secret : YAHOO! Japan側で作成したアプリケーションのSecret
- Authorization URL : https://auth.login.yahoo.co.jp/yconnect/v2/authorization
- Token URL : https://auth.login.yahoo.co.jp/yconnect/v2/token
- Scope : openid profile email
- Custom Headers : ブランクのまま
- Fetch User Profile Script : 以下のコード
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/srcauth_config.json.exampleをコピーしてauth_config.jsonを作成します。
$ cp auth_config.json.example auth_config.jsonauth_copnfig.jsonを編集します。"clientID"は"Appications"->"作成したApplication"->"Settings"から確認できます。
{ "domain": "kiriko.auth0.com", "clientId": "xxxx" }auth0-react-samples/01-Loginに移動します。
$ cd auth0-react-samples/01-Loginnpm installを実行して必要なパッケージをインストールします。
$ npm installnpm startを実行してApplicationを起動します。
$ npm start
動作確認
Chromeで"
http://localhost:3000
"にアクセス、右上の"Login"を押します。"LOG IN WITH <作成したCustom Connectionの名前>を押します。
YAHOO!IDでログインします。
おわりに
Auth0は業界標準のプロトコル(例/OAuth2.0, Open ID Connect)に幅広く対応しているため、YAHOO!JapanをはじめOAuth2.0対応のAuthorizationサービスと簡単に接続することが可能です。ダッシュボードの"Connections"->"Social"に該当のSocial Providerが無い場合はこちらの記事をご参照の上Custom Social Extensionを利用して接続設定をお願いします。
- 投稿日:2019-11-12T14:36:12+09:00
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
- 投稿日:2019-11-12T12:37:31+09:00
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のアップデートを待ったほうがいいと思う
はじめに
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.tsximport 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を実行可能なコードに変換するスタイルは、いくつかあります。
tsc
コマンドで.ts(x)ファイルを.jsファイルにコンパイルする(ts-nodeもこれのはず)@babel/preset-typescript
で「TypeScript独自文法」を剥がしてECMAScript化してから、通常のBabelコンパイルを行う- 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からの派生で選べる選択肢というのはさほど多くはなく、
- ejectする
- react-app-rewiredを使う
のどちらかになります。今回は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.jsmodule.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
はこういう時の脱出ハッチとしてめちゃくちゃ便利なので、使い方を覚えておくと便利です。濫用すると死ぬけど、折を見て使ってみてくださいね。
- 投稿日:2019-11-12T11:38:37+09:00
【React】 Firebaseを使用して認証機能の実装
概要
ReactでFirebaseを使用した簡単な認証の実装方法に関してまとめました。
あくまでも、実装の流れをメインとしており、各機能の詳しい説明等は省いております。Firebaseのセットアップ
新しいプロジェクトの作成
まずはFirebaseのConsoleで新しいProjectの作成を行いましょう。
Authenticationの使用
Authentication
の画面にて、SET UP SIGN-IN METHOD
をクリックしましょう。そして、何を使用して認証を行いたいのか選択しましょう。
本投稿でははEmailを使用して行うので、Email/Password
を許可します。credentialsの取得
左端にあるセッティングボタンから、
project settings
を選択し、アプリのcredentials
を取得しましょう。
(project settings
の下の方にIOSかAndroidかWebのボタンがあるのでWebのボタンをクリックします。)
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-domReactアプリを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
を開き、ルーティングを設定します。
この場合、Home
をPrivateRoute
に設定しているので、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
andSignup
コンポーネントの作成.各コンポーネントを下記のように作成していきます。
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 startHome画面アクセスに認証が必要である事の確認
http://localhost:3000
にアクセスしようとすると下記のようにLoginページに飛ばされる事を確認します。ユーザーが作成できる事の確認
http://localhost:3000/signup
でpassword
を入力し、Sing Up
をクリックします。
自身のFirebaseコンソールで作成したユーザーが登録されている事を確認します。
作成したユーザーでログインできる事の確認
http://localhost:3000/login
で先ほど作成したユーザーの情報でログインし、Home
画面にアクセスできる事を確認します。参考
- 投稿日:2019-11-12T10:13:39+09:00
Amazon S3でSPAをサクッと公開する
はじめに
ストレージサービスとして有名&優秀なAmazon S3ですが、実は「静的ウェブサイトホスティング」という機能を使うことで、Vue.jsやReactで作ったSPAを簡単に公開することができます。また、AWS CLI を使用することでコマンド一発でサクッとデプロイすることができます。
herokuやfirebaseなどの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等)が揃っている状態にしましょう。
ちなみにVue.jsでのプロジェクト開始方法およびビルド方法はこちらの記事(Vue CLI スタートガイド)にまとめてありますので、フロントフレームワークを全く触ったことないという方はこちらを参考にしてみてください。
S3用のIAMユーザ作成
AWSルートアカウントの作成
AWSを初めて使うという方はAWSルートアカウントを作成しましょう。
こちらを参考にすると良いと思います。
AWS アカウント作成の流れ | AWSS3用のIAMユーザの作成
ルートアカウントは全権限アカウントのため全てのAWSリソースへのアクセスができてしまいます。このルートアカウントで作業を続けることはセキュリティ上よろしくないので、S3のみ使用可能なIAMユーザを別途作成し、今後の作業はこのS3用IAMユーザで行います。
まずルートアカウントでログインし、マネジメントコンソールからIAMへ移動します(検索バーで「iam」と打てば出てきます)。
「ユーザー」メニューを選択すると、作成したIAMユーザの一覧が表示されます。
今回は新規でユーザを作成するので、青色ボタンの「ユーザーを追加」をクリックしましょう。
IAMユーザを作成するための設定画面が表示されます。
- ユーザー名は任意の名前で構いません(公開しようとしているSPA用のIAMユーザであることが分かるネーミングだと良いです)
それ以外はデフォルトの設定で問題ありません。
IAMユーザの作成が完了すると一覧に表示されるので、問題なく作成されているか確認しましょう。
IAMユーザの作成が完了したら、早速ログインしてみましょう。
右上にIAMユーザ名 @ アカウント名
と表示されていれば問題なくログインできています。
S3以外のサービスへのアクセスがブロックされているか確認するために、試しにIAMを開いてみましょう。
先ほどIAMユーザを作成した画面に移動しても「アクセス権限が必要です」と表示され、ユーザを新規作成できないようになっているはずです。
このように、利用用途ごとにIAMユーザを作成してAWSサービスへの権限を切り分け、意図しない操作が実行されないようにしましょう。
S3でSPAを公開
先ほどログインしたS3用IAMユーザで作業を進めます。
バケットの作成
S3の画面へ移動し、青色の「+バケットを作成する」ボタンをクリックしましょう。
バケット作成に際しての設定画面が表示されるので、情報を入力しましょう。
- バケット名:任意の文字で構いません。ここで設定したバケット名が後ほど静的ホスティングする際のURLの一部として使われるので、それっぽい名前を付けましょう。
- リージョン:これも任意で構いませんが、「アジアパシフィック(東京)」を選択するのが無難です。
![]()
それ以外の設定は一旦デフォルトのままで問題ないです。
バケットが作成されるとバケット一覧に表示されるようになります。
コンテンツのアップロード
バケット名をクリックするとバケットの詳細画面に遷移することができます。
青色の「アップロード」ボタンをクリックし、公開したいSPAの各ファイル(index.htmlとcss/js/imgフォルダ等)をローカルからアップロードしましょう。
初回アップロード時にバケットの設定について色々と聞かれますが、全てデフォルトで問題ないです。
無事アップロードが完了するとこのような画面になると思います。
静的ウェブサイトホスティング機能の設定
「プロパティ」タブへ移動し、「Static website hosting」の設定を行います。
上記の設定が完了したら「保存」ボタンをクリックしましょう。
なお、この画面で表示されている「エンドポイント」のURLがウェブサイトへのアクセス用URLになります。が、今の状態でこのURLにアクセスしようとしても403エラーが返ってきてしまいます。URLでのアクセスを許可するために、次の章で説明するバケットポリシーを設定しましょう。
バケットポリシーの設定
S3に格納したオブジェクトが不用意にネットに晒されないよう、デフォルトでは外部からS3バケットへのアクセスは全て拒否するようになっています。先ほど403エラーが返ってきたのもそのためです。正しくWebページを表示させるためにはURLによる外部からのリクエストを明示的に許可する必要があります。
アクセス制御に関することは「アクセス制御」タブで行います。
まず、「ブロックパブリックアクセス」を開きます。「編集」をクリックし、以下の設定を行います。
- 「パブリックアクセスをすべてブロック」のチェックを外します
- 下から2つ目、「新しいパブリックバケットポリシーを介して...」のチェックを外します
- 一番下、「任意のパブリックバケットポリシーを介して...」のチェックを外します
![]()
これでバケットポリシーによるアクセス許可設定が有効になります。ここの設定を行わないと、いくらバケットポリシーで許可の設定を行ってもブロックされてしまうので注意しましょう。
次に「バケットポリシー」を開き、エディタ欄に以下の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ページが表示されるようになっているはずです。
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 awscli3: ルートアカウントでS3用IAMユーザのアクセスキーとシークレットアクセスキーを生成・取得します。IAMメニューで先ほど作成したS3用IAMユーザを選択し、「認証情報」タブの「アクセスキーの作成」ボタンをクリックします。アクセスキーとシークレットアクセスキーが表示されるので、手元に控えておきましょう。
4:ターミナルに
aws configure
と入力しS3を操作するための設定を行います。Access Key ID
とSecret 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/ --recursive1行目はシェルスクリプトを走らせるためのおまじないです。詳しく知りたい方はこちらの記事(#!/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.json
のscripts
ブロックで設定できます。
"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にアクセスするとしっかり変更が反映されていますね。
おわりに
これでローカルで作成していた静的ウェブサイトを公開できるようになりました。
デプロイもコマンド一発で簡単にできるようになったので、開発速度もかなり向上したんじゃないでしょうか。個人的な今後としては、LambdaやDynamoDBを利用したサーバーレスAPIとの通信にチャレンジしてみたいと思います。
- 投稿日:2019-11-12T08:53:25+09:00
[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で認証用プロジェクトの作成
https://console.firebase.google.com/ でプロジェクトを一つ作成
Authentication -> ログイン方法 -> GitHubを有効にする
認証コールバックURLを取得(ClientIDとClientSecretは後で記入)GitHubにアプリケーションを登録
https://github.com/settings/developers でアプリケーションを作成
アプリケーション名とHomepageURL(仮アドレスでOK)と認証コールバックURLを設定
発行されたClientIDとClientSecretをFirebase側に設定アプリケーション上で必要となるデータ
発行したキーの中から、プログラム上では以下の情報が必要となります
Firebase -> APIキー、AuthDomain
GitHub -> clientId認証部分のプログラムを作成
OAuth認証にはFirebaseを利用しているので、アプリケーション登録のための初期設定さえ完了していれば、プログラム的には単純に書くことが出来ます。
まずは発行済みのキーを用意します。
config.tsexport 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.tsimport { 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.tsimport { 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.tsximport 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.tsximport 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.tsximport 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.tsximport { 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の面倒な手続きは一切書く必要がありません。自分で作っておいてなんですがヤバいです。