[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を利用しているので、アプリケーション登録のための初期設定さえ完了していれば、プログラム的には単純に書くことが出来ます。
まずは発行済みのキーを用意します。
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は出てこない形となります。
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件を超えた場合などは想定していません。
/// 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に保存します。
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経由になっています
以下はタイトルやログインボタンの表示処理です
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>
);
}
仮想ウインドウライブラリを使ったポップアップ式のウインドウを表示します
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>
</>
);
}
以下はログアウト用ウインドウです
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ライクなヘッダサイズ可変機能がついています
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の面倒な手続きは一切書く必要がありません。自分で作っておいてなんですがヤバいです。