まだプログラマーですが何か?

プログラマーネタとアスリートネタ中心。たまに作成したウェブサービス関連の話も http://twitter.com/dotnsf

タグ:github

2022 年最初のブログエントリとなります。遅ればせながら本年もよろしくお願いいたします。

今年最初のエントリは React や Angular といったフロントエンドフレームワークにまつわるネタです。最近流行りのこれらのフロントエンドフレームワークを使うことで、比較的簡単に SPA(Single Page Application) を作ることができます。SPA 化のメリット/デメリットを理解した上で作る必要があるとは思いますが、SPA 化することによる大きなメリットの1つに「Amazon S3 などのオブジェクトストレージや Github ページといった、安価かつ滅多にサービスダウンしない環境でフロントエンドのウェブホスティングができる」ことがあると思っています。

この点を少し補足しておきます。「自分が作って管理しているサービスやアプリケーションを安定運用したい」というのは誰でも思うことだと思っています。ただ現実にはこれは簡単なことではありません。サービスやアプリケーションは利用者が直接参照するフロントエンド部分に加えて、データベースなどのバックエンド部分、そしてこれらを繋ぐ API サーバーなどから成り立っていて、これら全てを安定運用するのは簡単なことではありません。特にフロントエンドや API サーバーについては最近よく耳にするコンテナオーケストレーションなどによって比較的安価に安定運用することもできるようになりました。ただフロントエンド部がアプリケーションサーバーを持たないシンプルな静的ウェブコンテンツであれば、上述したような Amazon S3 のウェブコンテンツ機能を使ったり、Github ページ機能を使うことで、滅多にサービスダウンしないウェブページとして公開するという方法もあります。現実問題として、この方法だとフロントエンドページの公開は簡単ですが、API サーバーなどのバックエンドとの通信時に CORS を考慮する必要が生じたりして、これはこれで面倒な設定も必要になるのですが、一方で「ほぼ無料」レベルの安価で利用できるウェブサーバーにしてはかなり安定しているのも事実で、用途によっては(「面倒な設定」の面倒レベルがあまり高くならないケースであれば)運用方法の考慮に含めてもいいのではないかと思っています。補足は以上。


一方、最近のウェブアプリケーション開発において IDaaS の利用ケースもよくあります(個人的にも業務で多く使っている印象です)。ログインや認証、ユーザー管理といった ID 周りの機能がマネージドサービスとして提供されていて、自分で面倒な開発をしなくても API や SDK から利用可能になる、というものです。具体的なサービスとしては AWS CognitoAuth0 などが有名どころでしょうか。IBM Cloud からも AppID という IDaaS が提供されています。

そしてフロントエンドアプリケーションと IDaaS の組み合わせというのが実は相性が悪かったりします(苦笑)。多くの IDaaS では OAuth と呼ばれるプロトコルでの認証/認可が主流となっているのですが、アプリケーションサーバーを持たない静的なフロントエンドアプリケーションでは OAuth のコールバックの仕組みとの相性が悪く、実装が非常に難しいという事情があります。その結果として、各 IDaaS ベンダーから認証専用の JavaScript SDK が提供され、それらを使って認証機能を実装することになります。IBM Cloud の AppID も専用の JavaScript SDK が用意され、React などで作ったフロントエンドに組み込むことで AppID へのログインや認証を実現できます(この SDK では PKCE と呼ばれる方法を使ってログインを実現しています)。

で、ここまでのアプリ開発方法に関する内容についてはこちらのページでも紹介されているのですが、問題はこの先にありました。この方法で作った React のフロントエンドアプリを Github ページにホスティングして運用しようとすると・・・結論としては少し特殊なリダイレクト URI の設定が必要でした。これを知らないと Github ページでの運用時にトラブルが起こりそうだと思ったので、将来の自分への備忘録の意味も含めてブログに設定内容を記載しておくことにしました。

前段部分の長いブログですが、といった背景の中で「IBM AppID の JavaScript SDK を使って作った SPA アプリを Github ページで動かす」ためのアプリ開発手順と設定内容について、順に紹介します。


【React で SPA アプリを作成する】
この部分は上述ページでも紹介されている内容ではありますが、一般的な内容でもあるので、特にコメントに色をつけずに紹介します。以下のコマンドで react-appid というフォルダに React のサンプルアプリを作成します:
$ npx create-react-app react-appid

$ cd react-appid

ここまでは普通の React アプリと同じです。ここから下で AppID の認証に対応したり、Github ページで運用する場合特有の設定を加えていきます。


【IBM AppID サービスインスタンスの準備を行う】
ここは上述ページでも触れられてはいるのですが、あまり詳しくは紹介されていないので、ここで改めて手順を紹介します。

IBM Cloud にログインして AppID サービスを作成後にインスタンスを開き、「アプリケーション」タブから「アプリケーションの追加」ボタンで対象アプリケーションを追加します。その際にアプリケーションのタイプを「単一ページ・アプリケーション」(SPA) として追加するようにしてください:
2022011301


追加したアプリケーションを選択して、内容を確認します。type の値が "singlepageapp" となっていることを確認してください。確認できたらこの中の "clientId" 値と "discoveryEndpoint" 値を後で使うことになるのでメモしておきます:
2022011303


まだ AppID にユーザーを登録していない場合はここでユーザーも登録しておきましょう。メニューの「クラウド・ディレクトリー」から「ユーザー」を選択し、「ユーザーの追加」からログイン可能なユーザー(の ID とパスワード)を登録しておきます:
2022011302


またリダイレクト URL を登録しておきましょう。「認証の管理」から「認証設定」を選択して、リダイレクト URL に http://localhost:3000/ を追加しておきます:
2022011303



IBM AppID 側で準備する内容は以上です。取得した情報を使ってアプリ開発を続けます。


【React アプリに IBM AppID モジュールを追加して、AppID のログイン機能を追加する】
ここは上述ページでも詳しく記載されている内容です。まずは IBM AppID を利用するためのライブラリを追加します:
$ npm install ibmcloud-appid-js

そしてソースコードの src/App.js をテキストエディタで開き、以下の内容に書き換えます(詳しい内容は上述ページを参照してください)。この時に太字で示している clientId の値と discoveryEndpoint の値には先程 AppID の画面で確認した clientId 値と discoveryEndpoint 値をコピペして指定してください:
import React from 'react';
import logo from './logo.svg';
import './App.css';
import AppID from 'ibmcloud-appid-js';
function App() {
  const appID = React.useMemo(() => {
    return new AppID()
  }, []);
  const [errorState, setErrorState] = React.useState(false);
  const [errorMessage, setErrorMessage] = React.useState('');
  (async () => {
    try {
      await appID.init({
        clientId: 'AppID の clientID の値',
        discoveryEndpoint: 'AppID の discoveryEndpoint の値'
      });
    } catch (e) {
      setErrorState(true);
      setErrorMessage(e.message);
    }
  })();
  const [welcomeDisplayState, setWelcomeDisplayState] = React.useState(false);
  const [loginButtonDisplayState, setLoginButtonDisplayState] = React.useState(true);
  const [userName, setUserName] = React.useState('');
  const loginAction = async () => {
    try {
      const tokens = await appID.signin();
      setErrorState(false);
      setLoginButtonDisplayState(false);
      setWelcomeDisplayState(true);
      setUserName(tokens.idTokenPayload.name);
    } catch (e) {
      setErrorState(true);
      setErrorMessage(e.message);
    }
  };
  return (
    <div  classname="App">
    <header  classname="App-header">
      <img  alt="logo" classname="App-logo" src="{logo}">
        {welcomeDisplayState && <div> Welcome {userName}! You are now authenticated.</div>}
        {loginButtonDisplayState && <button  onclick="{loginAction}" id="login" style="{{fontSize:">Login</button>}
        {errorState && <div  style="{{color:">{errorMessage}</div>}
    </header>
    </div>
  );
}
export default App;

この状態で一度動作確認します:
$ npm start

ウェブブラウザが起動して http://localhost:3000/ が開き、以下のような画面が表示されます:
2022011301


"Login" と書かれた箇所をクリックするとログイン画面がポップアップ表示されます。ここで AppID に登録済みのユーザー ID とパスワードを指定してサインインします:
2022011304


正しくログインできると先程の画面に戻り、登録したユーザーの氏名が表示とともに "You are now authenticated." と表示され、ログインが成功したことが明示されます:
2022011305


【React アプリをビルドして、SPA アプリでもログインできることを確認する】
今作成した React アプリをビルドして index.html にまとめた SPA アプリにしたあとでも AppID でログインできることを確認します。nginx などの HTTP サーバーがあればそれを使ってもいいのですが、ここでは Node.js のシンプルなウェブサーバーを使って動作確認します。以下のような内容で app.js を作成し、react-appid フォルダ直下(README.md などと同じ階層)に保存します:
var express = require( 'express' ),
    app = express();

app.use( express.static( __dirname + '/build' ) );

var port = process.env.PORT || 3000;
app.listen( port );
console.log( "server starting on " + port + " ..." );

↑ build/ フォルダをコンテキストルートにして 3000 番ポートで HTTP リクエストを待ち受けるだけのシンプルなコードです。

app.js が準備できたら、まずは一度 React コードをビルドして SPA アプリ化します:
$ npm run build

ビルドが完了すると React アプリが SPA 化されて圧縮されて build/ フォルダにまとめられますので、これを app.js を使って起動します:
$ node app

改めてウェブブラウザで http://localhost:3000/ にアクセスして同じアプリが起動していることを確認し、Login から AppID アカウントでログインできることを確認します。アプリケーション・サーバーを持たない SPA アプリでも IBM AppID を使って認証できることが確認できました:
2022011305


【React アプリをビルドした SPA アプリを Github ページでも動くように調整する】
ここまでできれば後は build/ フォルダを Github ページで公開するだけで動くんじゃないか? と思うかもしれませんが、実は期待通りに動くようになるまではいくつかの落とし穴があります。1つずつ解いていきましょう。

(1)絶対パス→相対パスへの書き換え

React の SPA アプリをただビルドしただけだと、コンテキストルート直下で動く想定のアプリになってしまいます。どういうことかというと、例えば http://localhost:3000/ とか https://xxx.xxxxxxx.com/ といったサブディレクトリを持たない GET / というリクエストに対して動くアプリになります(要はビルドで作成される index.html 内で参照される外部 CSS や JavaScript は "/" ではじまる絶対パスになっています)。一方、Github ページで動かす際は https://dotnsf.github.io/react-appid/ のようなサブディレクトリ上で動くことになります。ここがコンフリクトになってしまい、絶対パスで指定された CSS や JavaScript のロードエラーが発生してしまうのでした。これを回避するために index.html 内の該当箇所を絶対パス指定から相対パス指定に変更する必要があるのでした。

具体的にはこの下の(3)までの作業後に改めて $ npm run build を実行してから、build/index.html の以下の <head> 部分で頭に . を付けて、絶対パス指定から相対パス指定に書き換えてください:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<link rel="icon" href="./favicon.ico"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<meta name="theme-color" content="#000000"/>
<meta name="description" content="Web site created using create-react-app"/>
<link rel="apple-touch-icon" href="./logo192.png"/>
<link rel="manifest" href="./manifest.json"/>
<title>React App</title>
<script defer="defer" src="./static/js/main.85d3d620.js"></script>
<link href="./static/css/main.073c9b0a.css" rel="stylesheet">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>



(2)回転する画像の URL を絶対指定

(1)と似た内容ですが、SPA アプリ内で表示される画像も絶対パスのままだと正しく表示されません。ただこちらは JavaScript 内で {logo} と指定されている画像なので単純に絶対パスを相対パスにすればよいという対処が使えないのでした。 まあアプリケーションとしては必須ではないので単純に画像ごと削除してしまってもいいのですが、残したい場合は Git リポジトリ上の画像 URL を直接指定する、などの方法で対処する必要があります。よくわからない人は以下の例でそのまま書き換えてください:
    :
  return (
    <div className='App'>
    <header className='App-header'>
      <img src="https://raw.githubusercontent.com/dotnsf/react-appid/main/src/logo.svg" className='App-logo' alt='logo' />
        {welcomeDisplayState && <div> Welcome {userName}! You are now authenticated.</div>}
        {loginButtonDisplayState && <button style={{fontSize: '24px', backgroundColor: 'skyblue', 
          border: 'none', cursor: 'pointer'}} id='login' onClick={loginAction}>Login</button>}
        {errorState && <div style={{color: 'red'}}>{errorMessage}</div>}
    </header>
    </div>
  );
    :


(3)デフォルトの .gitignore を変更

$ npx create-react-app コマンドで作成した React プロジェクトにははじめから .gitignore ファイルが含まれています。が、この .gitignore では /build フォルダを除外するよう記述されています。今回は build/ フォルダを Github ページで運用することが目的なので、/build フォルダが除外されてしまっては意味がありません。.gitignore ファイルを編集して、/build フォルダを含めるよう(コメントアウトするなど)してください:
  :
  :
#/build
  :
  :

(1)でも紹介しましたが、ここまでの変更を行ったら再度 SPA アプリケーションをビルドし、その後に build/index.html ファイルに対して(1)の変更を行ってください。


(4)AppID のリダイレクト URL の設定が特殊

これまではアプリケーションを http://localhost:3000/ という開発時専用の URL で実行していたので、AppID のリダイレクト URL も http://localhost:3000 だけを登録して動作確認できました。では実際に Github ページでアプリケーションを動かす際にはどのようなリダイレクト URL を指定すればいいのでしょうか? 実はここがくせ者でした。

例えば私(Github ID は dotnsf )が react-appid という github リポジトリを作って、このリポジトリを Github ページとして公開して運用しようとすると、アプリケーションの URL は以下のようになります:
 https://dotnsf.github.io/react-appid

2022011306


ということは AppID に設定するリダイレクト URL にもこの値を指定するべき・・・だと思っていたのですが、なんとリポジトリ名部分が不要で、 https://dotnsf.github.io/ を指定するのが正しい設定内容のようでした:
2022011308


この URL がリダイレクト URL として設定されていれば AppID SDK が正しく動作して、認証も正しく実行できるようになりました(自分は実はここで結構つまずきました):
2022011307


(5)build フォルダを Github ページとして公開する方法
最後に作成したプロジェクトを Github に登録して、build フォルダを Github ページで公開する方法についてです。root フォルダや docs フォルダを Github ページで公開する場合は選択肢から選ぶだけなんですが、任意のフォルダを Github ページで公開するのは少しコツが必要です。

例えば react-appid というリポジトリを使う場合は、まず Github 上でこのリポジトリを公開設定で作成します。そして普通に main リポジトリに登録します:
$ git init

$ git add .

$ git commit -m 'first commit'

$ git branch -M main

$ git remote add origin https://github.com/dotnsf/react-appid.git

$ git push -u origin main

その後、build フォルダを Github ページで公開するには以下のコマンドを実行します:
$ git subtree push --prefix build origin gh-pages

これで該当フォルダが Github ページとして公開されます。実際に以下のページはこの設定を使って公開しています:

https://dotnsf.github.io/react-appid/

2022011306


ID: username1@test.com, パスワード: password1 でログインできるので、よければ試してみてください:
2022011307


React で作成した SPA アプリケーションの認証をどうするか、という問題を IBM AppID (と SDK)で解決する方法と、ビルドした SPA アプリを Github ページで運用する場合の特殊な設定やコマンドについて紹介しました。




QR コード生成サービスを作ってソースコードごと公開してみました:
https://dotnsf.github.io/qrgenerator/

2021091203



同じようなコンセプトのサービスは世に多くあることを知っていますが、自分が作ったものは以下のような特徴を持ってます。便利に感じた方はぜひ使ってみてください。
  • (静的コンテンツだけで構成される)Github ページで公開している。バックエンドサービスが使えない形で公開しているので、実は裏で URL を集めている、といった心配は無用
  • 単に情報を QR コード化するだけのサービスではなく、考えつく限りの以下のようなカスタマイズを可能にしている

カスタマイズできる内容は以下の通りです:
・内容(テキスト、URLなど ここはカスタマイズできて当然)
誤り訂正レベル(デフォルトは M(約15%))
・生成する画像のサイズ(デフォルトは 512x512)
・前景色(デフォルト値は黒)
・背景色(デフォルト値は白)
・画面中央にロゴのように含める画像(デフォルトなし 誤り訂正レベルに応じて自動拡大縮小)


たとえばこんな感じにマンホールマップの URL と画像を設定して「生成」をクリックすると、
2021091201


指定された通りの QR コードがこんな感じで作成されて、
2021091202


「画像ダウンロード」をクリックすると、生成された QR コードを画像としてダウンロードできます:
1631428093716

細かい仕様としては、生成された QR コード部分をクリックすると情報テキストがクリップボードにコピーされます。上の例だと "https://manholemap.juge.me/" がクリップボードにコピーされるので、ブラウザのアドレス欄にペーストするだけでマンホールマップに遷移できるようになる、というわけです。


カスタマイズできる内容のうち、誤り訂正レベルについては自分も知らなかったので解説を加えておきます。

QR コードは紙やビニールといった「クシャクシャになったりして、そもそも印刷内容が平らに見えない可能性がある媒体に印刷して使う」可能性があることを考慮した仕様になっています。それが誤り訂正レベルです。QR コード自体を冗長化する形で作成して、画面の一部が読み取り不可な状態になっていても含まれている情報を認識できるようにするためのものです。

これが誤り訂正レベルで、QR コードでは4段階(H(約30%), Q(約25%), M(約15%), L(約7%))用意されています。H だと全体の30%が読み取り不可になっていても大丈夫、という意味ですが、QR コード自体が他よりも細かなものになります。ちなみに以下はどちらも "https://manholemap.juge.me/" の QR コードですが、左は誤り訂正レベル H 、右は Q です。白黒の細かさの違いがわかると思います:
Q


そのため最終的な印字サイズが小さいと読み取り自体が難しくなったりします。どの誤り訂正レベルを選ぶかは(真ん中にロゴ画像を含めるかどうかにも関わりますが)試行錯誤しながら読み取れそうなものを作りながら選ぶ必要があると思っています。

ちなみにソースコードはこちらです。裏で URL を保存してたりしない証拠としてこちらも公開しておきます:
https://github.com/dotnsf/qrgenerator


よかったら使ってください。


このブログエントリは以下の続きです:
Github API を使う(1)
Github API を使う(2)

過去2つのエントリで Github API の基本的な内容として OAuth による認証を行ってアクセストークンを取得し、そのアクセストークンを使って(ログインしたユーザーの権限で)「自分自身の情報を取得する」、「特定リポジトリの特定ブランチに含まれるファイル一覧を取得する」という2つの Github API の実装内容をサンプルソースコードと合わせて紹介しました。

シリーズ最終回の今回はこれまでの内容に加えて以下の機能を Github API で実装して、このシリーズ当初の目的であった「Github ベースのファイルサーバー」っぽいものを作ってみます:
・ブランチを作成する/マージする
・ブランチにファイルをコミットする


実際に作ってみたものを先に紹介しておきます。ソースコードはこちらから参照してください:
https://github.com/dotnsf/githubapi

2021051901


これまでのサンプルと同様に git clone などでソースコード取得後、 settings.js に OAuth アプリケーションの client_id と client_secret 、コールバック URL、API で操作する対象のリポジトリを指定します(この時に作って登録した OAuth アプリケーションのものを流用しても構いません)。またこれも同様ですが、アプリケーション登録時に使ったユーザーとは別のユーザーでアクセスする場合は collaborators に追加しておく必要があります。

加えて、このアプリケーションでは各ユーザー毎にブランチを(ログイン時に自動的に)作成してもらい、そのブランチで作業した内容を特定の別ブランチにマージできるような機能があり、そのマージ先のブランチを target_branch_name 変数に指定する必要があります(デフォルト状態では "__all__")。この初期設定を最初に済ませておいてください。

ここで早速動作確認、、、する前に対象となるリポジトリがアプリケーションによってどのように変化していくかを確認しておきます。今回 settings.js の repo_name で指定されたリポジトリはこちらで、main ブランチの中に README.md ファイルが1つだけ格納されている状態です:
2021051905


また main 以外にはブランチは存在していません:
2021051906


ではこのリポジトリがどのように変わっていくのかを確認するため、改めてアプリケーションを実行します:
$ npm install

$ node app

起動したらウェブブラウザで http://localhost:8080/ にアクセスして、前回までと同様に画面右上の login ボタンから Gihub ユーザー ID でログインします:
2021051902


OAuth ログインに成功すると以下のような画面になります:
2021051903


画面右上にログインしたユーザーのアバター画像、画面内には files, merge, push という3つのボタンと、ファイル選択フィールドが表示されているはずです。各ボタンやフィールドの説明をする前に、この(ログイン直後の)時点でリポジトリ側に変化が加えられているので説明しておきます。

対象リポジトリをリロードして、改めてブランチを確認します。すると直前までは main 1つだけだったブランチに2つのブランチ("3183150" と "__all__")が追加されていることがわかります:
2021051907


このアプリケーションではユーザーがログインすると同時に Gihub ユーザー ID と同じ名前のブランチと、settings.js の target_branch_name 変数で指定したブランチの2つが指定リポジトリ内に作成されます(作成済みの場合は失敗するだけなので変化ありません)。この時点で指定リポジトリにはもともと存在していた main ブランチに加えて、ログインしたユーザーのユーザー ID 名と同じブランチ(以降、「個人ブランチ」と呼びます)と、(今回は "__all__" が指定されているので)__all__ ブランチが作成されています。つまり "318350" とはログインした私の Github ユーザー ID ということです。ちなみにユーザー ID を確認するにはログイン後にアバター画像の上をマウスでホバーした時に表示されます:
2021051904
(↑この例だと 318350)


別の(collaborators として権限を与えられた)ユーザーがログインすると、そのユーザーの個人ブランチも作成されます。なお新たに作成される個人ブランチは「その時点での main ブランチの内容をコピー」した状態で作成されます。

改めてボタン類の説明をします。このうち files ボタンは前回紹介したものと同様ですが、main ブランチではなく個人ブランチ内のファイル一覧が表示されます。ログイン直後は main ブランチをコピーした状態なので、もとの main ブランチに含まれていたファイルが一覧(この例では README.md)表示されます。ファイルの(個人ブランチからの)削除もここから行うことができます:
2021051908


まずこの状態に変化を加えるため、ファイルを1つ追加してみます。「ファイルを選択」と書かれた部分をクリックしてローカルシステムからファイルを1つ選択し、push ボタンをクリックします:
2021052001


すると選択したファイルがアップロードされ、個人ブランチに追加されます。アップロード後に files ボタンをクリックすると、アップロードしたファイルが追加されて一覧表示されることがわかります:
2021052002


今回のサンプルアプリでは DELETE ボタンでブランチから削除、またファイル名部分のクリックでファイルダウンロードまでが実装されています:
2021052003


またこの時点でリモートリポジトリの該当ブランチを確認すると、アップロードしたファイルが含まれていることを確認できます:
2021052004


ではこの個人ブランチに加えた変更(ファイル追加)を(settings.js で指定した)別ブランチにマージします。マージするには画面内の merge ボタンをクリックするだけです(画面に { "result": true } と表示されれば成功です):
2021052005


改めてリモートリポジトリの __all__ ブランチ(settings.js の target_branch_name 変数で指定したブランチ)を確認すると、このアクションによって個人ブランチでの変更がマージされているはずです。つまりマージ処理も Github API で実装できていることが確認できます:
2021052006


可能であれば(リポジトリに collaborators として招待した)別の Github ユーザーで同じ手順を実行してみてください。まずはログインして、別のファイルを push します:
2021052007


この時点で(正確には最初のログイン直後の時点で)このユーザーの個人ブランチが作られ、同ブランチにファイルが push された状態になっています:
2021052008

2021052009


ここでこのユーザーも merge を行うと、__all__ ブランチには元のユーザーが追加したファイルと、このユーザーが追加したファイル両方が含まれる状態になります(マージ元である個人ブランチには変化はありません):
2021052010


というわけで、ログインユーザー毎にブランチを作ってファイルを追加/削除し、特定ブランチにマージする機能が Github API で実装できました。当初「Github API を使ってファイルサーバーを作る」ことを目的としていたのですが、ここまで実現できたことで技術的に最低限必要な機能は実装できそうだと思っています。


【Github API による実装内容】
次にこのサンプルアプリケーションのソースコードを解説して、このアプリケーションの機能を Github API でどのように実装しているのかを紹介します。改めてソースコードはこちらです:
https://github.com/dotnsf/githubapi

また、以下の説明の大半は api/github.js ファイル内で実装している内容です。実際のソースコードを参照する場合はこのファイルを見て確認してください。

すべてのコードを説明するのはさすがにちと大変そうなので、「ブランチを作成」する処理と「ファイルをブランチに追加」するという2つの処理内容をソースコード含めて紹介します。


「ブランチ作成」
まずは「ブランチを作る」部分(正確には「main ブランチをコピーして新しいブランチを作る」部分)です。サンプルアプリケーションではログイン時に個人ブランチとマージ対象ブランチを作っています(いずれも作成成功するのは初回のみで、2回目以降は "already exists" というエラーになりますが無視します)。

OAuth 処理の一環で GET /api/callback を処理してログインが成功すると、InitMyBranch() 関数を実行します。この InitMyBranch() 関数は次のようになっています:
async function InitMyBranch( access_token ){
  return new Promise( async function( resolve, reject ){
    if( access_token ){
      var option = {
        url: 'https://api.github.com/user',
        headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
        method: 'GET'
      };
      request( option, async function( err, res0, body ){
        if( err ){
          console.log( { err } );
          resolve( false );
        }else{
          body = JSON.parse( body );
          //. body = { login: 'dotnsf', id: XXXXXX, avatar_url: 'xxx', name: 'きむらけい', email: 'xxx@xxx', created_at: 'XX', updated_at: 'XX', ... }
          loggedIns[access_token].user = body;

          //. https://qiita.com/nysalor/items/68d2463bcd0bb24cf69b

          //. main ブランチの SHA 取得
          var option1 = {
            url: 'https://api.github.com/repos/' + settings.repo_name + '/git/refs/heads/main',
            headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
            method: 'GET'
          };
          console.log( { option1 } );
          request( option1, async function( err1, res1, body1 ){
            if( err1 ){
              console.log( { err1 } );
              resolve( false );
            }else{
              body1 = JSON.parse( body1 );
              console.log( { body1 } );  //. body1 = { message: 'Git Repository is empty.', documentation_url 'xxx' }  ->  あらかじめリポジトリの main ブランチに README.md などを登録しておくことで回避
              var sha1 = body1.object.sha;
  
              //. 個人ブランチ作成
              var data2 = {
                ref: 'refs/heads/' + body.id,
                sha: sha1
              };
              var option2 = {
                url: 'https://api.github.com/repos/' + settings.repo_name + '/git/refs',
                headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi', 'Accept': 'application/vnd.github.v3+json' },
                json: data2,
                method: 'POST'
              };
              request( option2, async function( err2, res2, body2 ){
                if( err2 ){
                  console.log( { err2 } );
                  resolve( false );
                }else{
                  console.log( { body2 } );  //. { message: 'Reference already exists', .. }
                  //body2 = JSON.parse( body2 );
                  //console.log( { body2 } );

                  //. 作成したブランチの SHA 取得(?)
                  var option3 = {
                    url: 'https://api.github.com/repos/' + settings.repo_name + '/git/refs/heads/' + body.id,
                    headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
                    method: 'GET'
                  };
                  console.log( { option3 } );
                  request( option3, async function( err3, res3, body3 ){
                    var obj = false;
                    if( err3 ){
                      console.log( { err3 } );
                      //resolve( false );
                    }else{
                      body3 = JSON.parse( body3 );
                      console.log( { body3 } );  //. 権限がないユーザーだと { message: 'Not Found', documentation_url: '' }
                      if( body3.message ){
                        //resolve( false );
                      }else{
                        var sha3 = body3.object.sha;
                        //req.session.oauth.sha = sha3;
                        body.sha = sha3;

                        //. ターゲットブランチの生成結果に関係なく、この値を返す
                        obj = JSON.parse( JSON.stringify( body ) );
                        //resolve( body );
                      }
                    }

                    //. ターゲットブランチ作成
                    var data4 = {
                      ref: 'refs/heads/' + settings.target_branch_name,
                      sha: sha1
                    };
                    var option4 = {
                      url: 'https://api.github.com/repos/' + settings.repo_name + '/git/refs',
                      headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi', 'Accept': 'application/vnd.github.v3+json' },
                      json: data4,
                      method: 'POST'
                    };
                    request( option4, async function( err4, res4, body4 ){
                      if( err4 ){
                        console.log( { err4 } );
                      }else{
                        console.log( { body4 } );  //. { message: 'Reference already exists', .. }
                      }
                      resolve( obj );
                    });
                  });
                }
              });
            }
          });
        }
      });
    }else{
      resolve( false );
    }
  });
}

InitMyBranch 関数は OAuth 処理で取得したアクセストークンを引数に渡されて実行します。この関数内ではまず(1)個人ブランチを作成 して、次に(2)マージ対象ブランチを作成 します。処理内容は似ていますが、この順に説明します。

まず(1)個人ブランチの作成です。個人ブランチはユーザー個人の Github ユーザー ID 名のブランチを作ることになるので、まずは実行ユーザーの Github ユーザー ID を取得する必要があります。そのため以前のブログエントリでも説明した個人ユーザー情報を取得する API を実行します:
            :
            :
      var option = {
        url: 'https://api.github.com/user',
        headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
        method: 'GET'
      };
      request( option, async function( err, res0, body ){
        if( err ){
          console.log( { err } );
          resolve( false );
        }else{
          body = JSON.parse( body );
            :
            :

これで Github API を取得すれば、作成する個人ブランチの名称が決まったことになります。次にその名称のブランチを作成するのですが、git のブランチは作成元ブランチを指定する必要があります(CLI でも同様ですが、ブランチ作成元になるブランチを指定する必要がある、という意味です)。今回は main ブランチを元に個人ブランチを作るので、まずは main ブランチの SHA 情報を取得しておく必要があります。というわけで、これも以前のブログエントリで説明した方法で main ブランチの情報を取得する API を実行します:
            :
            :
          //. main ブランチの SHA 取得
          var option1 = {
            url: 'https://api.github.com/repos/' + settings.repo_name + '/git/refs/heads/main',
            headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
            method: 'GET'
          };
          console.log( { option1 } );
          request( option1, async function( err1, res1, body1 ){
            if( err1 ){
              console.log( { err1 } );
              resolve( false );
            }else{
              body1 = JSON.parse( body1 );
              console.log( { body1 } );
              var sha1 = body1.object.sha;

            :
            :

これで個人ブランチを作成するために必要な情報が揃いました。あらためて main ブランチの SHA を指定して作成元を明示した上で、新しいブランチを Github ユーザー ID 名で作成します:
            :
            :
              //. 個人ブランチ作成
              var data2 = {
                ref: 'refs/heads/' + body.id,
                sha: sha1
              };
              var option2 = {
                url: 'https://api.github.com/repos/' + settings.repo_name + '/git/refs',
                headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi', 'Accept': 'application/vnd.github.v3+json' },
                json: data2,
                method: 'POST'
              };
              request( option2, async function( err2, res2, body2 ){
                if( err2 ){
                  console.log( { err2 } );
                  resolve( false );
                }else{
                  console.log( { body2 } );  //. { message: 'Reference already exists', .. }

            :
            :

余談ですが、既に個人ブランチが作成済みであった場合、この API 実行は失敗するのですが、REST API の HTTP ステータスとしては 200 が、作成メッセージとして "Reference already exists" が返ります(上のコードの赤字部分)。一般的にはこういう場合は 400 番代のエラーステータスコードが返ってくることが多いと思っていますが、この点は注意が必要です(処理的にはエラー扱いしなくてよい、こちらの方が楽ですけど・・)。

これで個人ブランチが作成された状態になりました。続いて同様にマージ対象ブランチを作成します。これも main ブランチを元に新しいブランチを作る形になるため、main ブランチの SHA を指定して、settings.js 内の target_branch_name 変数で定義した新しいブランチを作成します:
            :
            :
                    //. ターゲットブランチ作成
                    var data4 = {
                      ref: 'refs/heads/' + settings.target_branch_name,
                      sha: sha1
                    };
                    var option4 = {
                      url: 'https://api.github.com/repos/' + settings.repo_name + '/git/refs',
                      headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi', 'Accept': 'application/vnd.github.v3+json' },
                      json: data4,
                      method: 'POST'
                    };
                    request( option4, async function( err4, res4, body4 ){
                      if( err4 ){
                        console.log( { err4 } );
                      }else{
                        console.log( { body4 } );  //. { message: 'Reference already exists', .. }
                      }
                      resolve( obj );
                    });
            :
            :


「ファイルを追加」
次は「ブランチにファイルを追加する」部分です。処理としてはブラウザからファイルをアップロードして、そのアップロードしたファイルを API でブランチ(の tree)に追加する、という流れになります。tree については前回のブログエントリを参照してください。

まずはファイルアップロード処理の部分から見てみます。ブラウザ画面からファイルを選択して push ボタンを押すと、POST /api/file という API が実行されます。その内容がこちらで、アップロードされたファイル(のパス)を取り出し、アクセストークンなどと一緒に PushToMyBranch() 関数を実行しています:
router.post( '/file', async function( req, res ){
  res.contentType( 'application/json; charset=utf-8' );

  if( req.session && req.session.oauth && req.session.oauth.token && req.session.oauth.id, req.session.oauth.sha ){
    //. https://qiita.com/ngs/items/34e51186a485c705ffdb
    var filepath = req.file.path;
    var filetype = req.file.mimetype;
    //var filesize = req.file.size;
    var ext = filetype.split( "/" )[1];
    var filename = req.file.filename;
    var originalfilename = req.file.originalname;

    var r = await PushToMyBranch( req.session.oauth.token, req.session.oauth.id, req.session.oauth.sha, filepath, filetype, originalfilename );
    console.log( { r } );
    if( r ){
      //. 追加したブランチの最新 SHA を取得
      r = await InitMyBranch( req.session.oauth.token );
      console.log( { r } );
      if( r ){
        req.session.oauth.sha = r.sha;
      }
    }

    //res.write( JSON.stringify( { result: r }, null, 2 ) );
    //res.end();
    res.redirect( '/' );
  }else{
    //res.status( 400 );
    //res.write( JSON.stringify( { error: 'no access_token' }, null, 2 ) );
    //res.end();
    res.redirect( '/' );
  }
});

PushToMyBranch() 関数内の処理がこちらです。
async function PushToMyBranch( access_token, id, sha, filepath, filetype, originalfilename ){
  return new Promise( async function( resolve, reject ){
    if( access_token && id && sha ){
      var data1 = {};
      if( filetype.startsWith( 'text' ) ){
        //. text
        var text = fs.readFileSync( filepath, 'utf8' );
        data1 = {
          content: text,
          encoding: 'utf-8'
        };
      }else{
        //. binary
        var bin = fs.readFileSync( filepath );
        data1 = {
          content: new Buffer( bin ).toString( 'base64' ),
          encoding: 'base64'
        };
      }
      console.log( { data1 } );
  
      //. BLOB 作成
      var option1 = {
        url: 'https://api.github.com/repos/' + settings.repo_name + '/git/blobs',
        headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
        json: data1,
        method: 'POST'
      };
      request( option1, async function( err1, res1, body1 ){
        if( err1 ){
          console.log( { err1 } );
          fs.unlink( filepath, function( e ){} );
          resolve( false );
        }else{
          //body1 = JSON.parse( body1 );
          //. body1 = { url: 'XXXXX', sha: 'XXXXXX' }
          console.log( { body1 } );
          var sha1 = body1.sha;

          //. ここで Tree を新規に作成するのではなく、既存の最新 Tree を取得して追加する
          //. 最後に InitMyBranch() を実行するなりして、セッション内 sha の更新が必要?
          //. インスペクト
          var option2 = {
            url: 'https://api.github.com/repos/' + settings.repo_name + '/commits/' + sha,
            headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
            method: 'GET'
          };
          request( option2, async function( err2, res2, body2 ){
            if( err2 ){
              console.log( { err2 } );
              resolve( false );
            }else{
              body2 = JSON.parse( body2 );  //. body2 = { commit: {}, url: '', author: {}, files: [], .. }
              console.log( { body2 } );

              //. tree
              var option3 = {
                url: 'https://api.github.com/repos/' + settings.repo_name + '/git/trees/' + body2.sha,
                headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
                method: 'GET'
              };
              request( option3, async function( err3, res3, body3 ){
                if( err3 ){
                  console.log( { err3 } );
                  resolve( false );
                }else{
                  body3 = JSON.parse( body3 );
                  console.log( { body3 } ); //. body3.tree = [ { path: "README.md", size: 130, url: "", .. }, .. ]
    
                  //. Tree 追加
                  var data4 = { tree: [] };
                  data4.tree = JSON.parse( JSON.stringify( body3.tree ) );
                  data4.tree.push( { path: originalfilename, mode: '100644', type: 'blob', sha: sha1 } );

                  var option4 = {
                    url: 'https://api.github.com/repos/' + settings.repo_name + '/git/trees',
                    headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
                    json: data4,
                    method: 'POST'
                  };
                  request( option4, async function( err4, res4, body4 ){
                    if( err4 ){
                      console.log( { err4 } );
                      fs.unlink( filepath, function( e ){} );
                      resolve( false );
                    }else{
                      //body4 = JSON.parse( body4 );
                      console.log( { body4 } );
                      var sha4 = body4.sha;

                      //. 現在の Commit の SHA を取得
                      var option5 = {
                        url: 'https://api.github.com/repos/' + settings.repo_name + '/branches/' + id,
                        headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
                        method: 'GET'
                      };
                      request( option5, async function( err5, res5, body5 ){
                        if( err5 ){
                          console.log( { err5 } );
                          fs.unlink( filepath, function( e ){} );
                          resolve( false );
                        }else{
                          body5 = JSON.parse( body5 );
                          console.log( { body5 } );
                          var sha5 = body5.commit.sha;

                          //. Commit を作成
                          var ts = ( new Date() ).getTime();
                          var data6 = {
                            message: '' + ts,
                            tree: sha4,
                            parents: [ sha5 ]
                          };
                          var option6 = {
                            url: 'https://api.github.com/repos/' + settings.repo_name + '/git/commits',
                            headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
                            json: data6,
                            method: 'POST'
                          };
                          request( option6, async function( err6, res6, body6 ){
                            if( err6 ){
                              console.log( { err6 } );
                              fs.unlink( filepath, function( e ){} );
                              resolve( false );
                            }else{
                              //body6 = JSON.parse( body6 );
                              console.log( { body6 } );
                              var sha6 = body6.sha;
          
                              //. リファレンスを更新
                              var data7 = {
                                force: false,
                                sha: sha6
                              };
                              var option7 = {
                                url: 'https://api.github.com/repos/' + settings.repo_name + '/git/refs/heads/' + id,
                                headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
                                json: data7,
                                method: 'PATCH'
                              };
                              request( option7, async function( err7, res7, body7 ){
                                if( err7 ){
                                  console.log( { err7 } );
                                  fs.unlink( filepath, function( e ){} );
                                  resolve( false );
                                }else{
                                  //body7 = JSON.parse( body7 );
                                  console.log( { body7 } );
                                  fs.unlink( filepath, function( e ){} );

                                  var sha7 = body7.object.sha;

                                  resolve( { sha: sha7 } );
                                }
                              });
                            }
                          });
                        }
                      });
                    }
                  });
                }
              });
            }
          });
        }
      });
    }else{
      if( filepath ){
        fs.unlink( filepath, function( e ){} );
      }
      resolve( false );
    }
  });
}

では PushToMyBranch() 関数内の処理を順に説明します。この関数内ではまず(1)ファイルの blob を作成 した後に(2)ブランチ内の現在のファイルツリーを取得 します。そして(3)ファイルの blob をツリーに追加 してから(4)ファイルをコミット し、最後に(5)リファレンス SHA を更新 する、という一連の流れを処理しています。では上述のコードを少しずつ見ながら、この流れを説明します。

まず(1)ファイルの blob を作成する部分です。ここはアップロードされたファイルのタイプを参照して、テキストファイルだったら UTF-8 エンコードで、それ以外だったら Base64 エンコードして中身を取り出した上で blob オブジェクトを作成し、その SHA を取り出します:
            :
            :
      var data1 = {};
      if( filetype.startsWith( 'text' ) ){
        //. text
        var text = fs.readFileSync( filepath, 'utf8' );
        data1 = {
          content: text,
          encoding: 'utf-8'
        };
      }else{
        //. binary
        var bin = fs.readFileSync( filepath );
        data1 = {
          content: new Buffer( bin ).toString( 'base64' ),
          encoding: 'base64'
        };
      }
      console.log( { data1 } );
  
      //. BLOB 作成
      var option1 = {
        url: 'https://api.github.com/repos/' + settings.repo_name + '/git/blobs',
        headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
        json: data1,
        method: 'POST'
      };
      request( option1, async function( err1, res1, body1 ){
        if( err1 ){
          console.log( { err1 } );
          fs.unlink( filepath, function( e ){} );
          resolve( false );
        }else{
          //body1 = JSON.parse( body1 );
          //. body1 = { url: 'XXXXX', sha: 'XXXXXX' }
          console.log( { body1 } );
          var sha1 = body1.sha;
            :
            :

(2)この blob をブランチに追加するのですが、そのためにブランチの現在のファイルツリーを取得しておきます:
            :
            :
          //. インスペクト
          var option2 = {
            url: 'https://api.github.com/repos/' + settings.repo_name + '/commits/' + sha,
            headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
            method: 'GET'
          };
          request( option2, async function( err2, res2, body2 ){
            if( err2 ){
              console.log( { err2 } );
              resolve( false );
            }else{
              body2 = JSON.parse( body2 );  //. body2 = { commit: {}, url: '', author: {}, files: [], .. }
              console.log( { body2 } );

              //. tree
              var option3 = {
                url: 'https://api.github.com/repos/' + settings.repo_name + '/git/trees/' + body2.sha,
                headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
                method: 'GET'
              };
              request( option3, async function( err3, res3, body3 ){
                if( err3 ){
                  console.log( { err3 } );
                  resolve( false );
                }else{
                  body3 = JSON.parse( body3 );
                  console.log( { body3 } ); //. body3.tree = [ { path: "README.md", size: 130, url: "", .. }, .. ]
            :
            :

そして(3)ファイルの blob をツリーに追加します:
            :
            :
                  //. Tree 追加
                  var data4 = { tree: [] };
                  data4.tree = JSON.parse( JSON.stringify( body3.tree ) );
                  data4.tree.push( { path: originalfilename, mode: '100644', type: 'blob', sha: sha1 } );

                  var option4 = {
                    url: 'https://api.github.com/repos/' + settings.repo_name + '/git/trees',
                    headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
                    json: data4,
                    method: 'POST'
                  };
                  request( option4, async function( err4, res4, body4 ){
                    if( err4 ){
                      console.log( { err4 } );
                      fs.unlink( filepath, function( e ){} );
                      resolve( false );
                    }else{
                      //body4 = JSON.parse( body4 );
                      console.log( { body4 } );
                      var sha4 = body4.sha;

            :
            :

そして(4)この状態をコミットします:
            :
            :
                      //. 現在の Commit の SHA を取得
                      var option5 = {
                        url: 'https://api.github.com/repos/' + settings.repo_name + '/branches/' + id,
                        headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
                        method: 'GET'
                      };
                      request( option5, async function( err5, res5, body5 ){
                        if( err5 ){
                          console.log( { err5 } );
                          fs.unlink( filepath, function( e ){} );
                          resolve( false );
                        }else{
                          body5 = JSON.parse( body5 );
                          console.log( { body5 } );
                          var sha5 = body5.commit.sha;

                          //. Commit を作成
                          var ts = ( new Date() ).getTime();
                          var data6 = {
                            message: '' + ts,
                            tree: sha4,
                            parents: [ sha5 ]
                          };
                          var option6 = {
                            url: 'https://api.github.com/repos/' + settings.repo_name + '/git/commits',
                            headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
                            json: data6,
                            method: 'POST'
                          };
                          request( option6, async function( err6, res6, body6 ){
                            if( err6 ){
                              console.log( { err6 } );
                              fs.unlink( filepath, function( e ){} );
                              resolve( false );
                            }else{
                              //body6 = JSON.parse( body6 );
                              console.log( { body6 } );
                              var sha6 = body6.sha;

            :
            :

最後にこの後の処理に備えて(5)リファレンス SHA を更新することで一連のアップロード処理は完了します:
            :
            :
                              //. リファレンスを更新
                              var data7 = {
                                force: false,
                                sha: sha6
                              };
                              var option7 = {
                                url: 'https://api.github.com/repos/' + settings.repo_name + '/git/refs/heads/' + id,
                                headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
                                json: data7,
                                method: 'PATCH'
                              };
                              request( option7, async function( err7, res7, body7 ){
                                if( err7 ){
                                  console.log( { err7 } );
                                  fs.unlink( filepath, function( e ){} );
                                  resolve( false );
                                }else{
                                  //body7 = JSON.parse( body7 );
                                  console.log( { body7 } );
                                  fs.unlink( filepath, function( e ){} );

                                  var sha7 = body7.object.sha;

                                  resolve( { sha: sha7 } );
                                }
                              });

            :
            :

これ以外にもブランチからファイルを削除するなど、他に実装している内容もありますが、なんとなく API を実行する際の共通の流れ(変更する場合は直前の状態の SHA を取得し、その値を指定して変更の POST を実行)がわかるのではないかと思います。

また上のソースコードの最後の Github API 実行部分をよく見ると、新しい状態の SHA を取得するための REST API のメソッドが珍しい PATCH になっていることがわかります。メソッドの種類として存在は知っていましたが、実際に使ったのは初めてのような気がします。それくらい珍しい REST API でした。



このブログエントリの続きです:
Github API を使う(1)


前回は Github API を使って外部アプリケーションから OAuth 認証を行ってアクセストークンと取得するところまでをサンプルコードと一緒に紹介しました。今回は取得したアクセストークンを使って、実際に API を実行する様子を紹介します。内容としては前回の続きとなるので、まずは前回の内容を理解しておいてください。

また今回もこのサンプルアプリケーションを使って説明します。ソースコードなどはこちらを参照してください:
https://github.com/dotnsf/github_oauth_sample


今回紹介するのは2つの API です:
(1)ログインしたユーザー自身の情報を取り出す
(2)ログインしたユーザーの権限で対象リポジトリ内の main ブランチのファイル一覧を取り出す


まずは(1)の実装を説明します。(1)は機能としては前回説明した内容にも含まれていました。前回の最後にサンプルアプリケーションにログインすると、ログインしたユーザーのアバター画像(URL)や Github ID を取得して表示していたのですが、実はここで既に(1)に相当する API が実行されていました:
2021051602


これはソースコードでは api/github.js 内の(前回紹介した)アクセストークンを取得した直後に GetMyInfo() 関数を実行して取得していたものです。この GetMyInfo() 関数は以下のようになっています:
async function GetMyInfo( access_token ){
  return new Promise( async function( resolve, reject ){
    if( access_token ){
      var option = {
        url: 'https://api.github.com/user',
        headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
        method: 'GET'
      };
      request( option, async function( err, res0, body ){
        if( err ){
          console.log( { err } );
          resolve( false );
        }else{
          body = JSON.parse( body );
          //. body = { login: 'dotnsf', id: XXXXXX, avatar_url: 'xxx', name: 'きむらけい', email: 'xxx@xxx', created_at: 'XX', updated_at: 'XX', ... }

          resolve( body );
        }
      });
    }else{
      resolve( false );
    }
  });
}

取得したアクセストークン(access_token)をヘッダに入れて GET https://api.gihub.com/user を実行しています。これで OAuth 認証したユーザーの情報を取得することができ、その結果(body)をそのまま返す、という関数です。

ログイン後のサンプルアプリケーションではこの結果を使ってアバターの URL (avatar_url)から Github アバター画像を表示したり、ホバー時に Github ID を表示するようにしていました:
2021051602


次に(2)です。これはログイン後の画面に表示される files ボタンをクリックすると画面下部に表示される、dotnsf/my_githubapi_test リポジトリ内 main ブランチのファイル一覧を取得する API です:
2021051604


ソースコードでは少し長めの関数(ListFiles())になっています。複数の Github API を連続で実行しているので、1つずつ説明します:
//. 現在のファイル一覧
async function ListFiles( access_token ){
  return new Promise( async function( resolve, reject ){
    if( access_token ){
      //. main ブランチの SHA 取得
      var option1 = {
        url: 'https://api.github.com/repos/' + settings.repo_name + '/git/refs/heads/main',
        headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
        method: 'GET'
      };
      console.log( { option1 } );
      request( option1, async function( err1, res1, body1 ){
        if( err1 ){
          console.log( { err1 } );
          resolve( false );
        }else{
          body1 = JSON.parse( body1 );
          console.log( { body1 } );  //. body1 = { message: 'Git Repository is empty.', documentation_url 'xxx' }  ->  あらかじめリポジトリの main ブランチに README.md などを登録しておくことで回避
          var sha1 = body1.object.sha;

          //. インスペクト
          var option2 = {
            url: 'https://api.github.com/repos/' + settings.repo_name + '/commits/' + sha1,
            headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
            method: 'GET'
          };
          request( option2, async function( err2, res2, body2 ){
            if( err2 ){
              console.log( { err2 } );
              resolve( false );
            }else{
              body2 = JSON.parse( body2 );  //. body2 = { commit: {}, url: '', author: {}, files: [], .. }
              console.log( { body2 } );

              //. tree
              var option3 = {
                url: 'https://api.github.com/repos/' + settings.repo_name + '/git/trees/' + body2.sha,
                headers: { 'Authorization': 'token ' + access_token, 'User-Agent': 'githubapi' },
                method: 'GET'
              };
              request( option3, async function( err3, res3, body3 ){
                if( err3 ){
                  console.log( { err3 } );
                  resolve( false );
                }else{
                  body3 = JSON.parse( body3 );
                  console.log( { body3 } ); //. body3.tree = [ { path: "README.md", size: 130, url: "", .. }, .. ]
    
                  resolve( body3.tree );
                }
              });
            }
          });
        }
      });
    }else{
      resolve( false );
    }
  });
}

この ListFiles() 関数では Github API を3回実行しています。特定ブランチのファイル一覧を取得するだけなんですが、一発で取得するような便利な方法は用意されておらず、ある程度 Git の内部構造を理解する必要もあります。まあ、いい勉強にもなるので、もう少しお付き合いください。

ListFiles() 関数内では以下3つの Github API を順次実行しています:

(2-1)対象リポジトリの、対象ブランチの SHA 値を取得する

Github API では多くのタイミングで SHA(Secure Hash Algorithm) 値を取得したり、取得した値を使って実行したりします。これは1つのリポジトリ内に多くのブランチが存在していたり、ブランチ内にも多くのコミット情報が記録されているので、「リポジトリ内のある瞬間の状態」が複数管理されている状態にあるわけです。そのそれぞれの状態が SHA によるハッシュ値を使って管理されています。今回はファイル一覧の情報を取得することが目的なのですが、「どのファイル一覧」かを指定するための準備段階から実装する必要があります。

ソースコード(api/github.js)内の ListFiles() 関数では、まず(settings.js 内の repo_name 変数に記載された)対象リポジトリの main ブランチを対象としてファイル一覧を取得しています。その第一段階として現在の main ブランチの SHA を取得する必要があり、具体的には GET https://api.github.com/repos/(対象リポジトリ)/(対象ブランチ)/git/refs/heads/main を実行して、その実行結果の object.sha 値を取り出しています。


(2-2)対象ブランチの最新コミットの SHA 値を取得する

取得した main ブランチの SHA を使って、同ブランチの最新コミットの SHA 値を取得(インスペクト)します。ここは具体的には GET https://api.github.com/(対象リポジトリ)/(対象ブランチ)/commits/(SHA値) を実行し、その実行結果の sha 値を取り出しています。


(2-3)対象ブランチの最新コミットのファイルツリーを取得する

ここまでの準備作業を経て、目的のファイル一覧を取得できるようになります。ファイル一覧はツリーと呼ばれる構造に格納されているので、直前に取得したコミットの SHA 値を使って、GET https://api.github.com/(対象リポジトリ)/(対象ブランチ)/git/trees/(SHA 値) を実行し、その実行結果の tree 値を取り出して、関数の実行結果としています。


フロントエンド側ではこの結果を使ってファイル一覧を表示します:
2021051604



とりあえず2回に渡って Github API を使った OAuth ログインおよびアクセストークンの取得と、取得したアクセストークンを使っての(ログインしたユーザー権限での)Github API をいくつか実行する様子をサンプルを交えて紹介しました。基本的にはこの応用でもっといろいろな API も実行できるし、前回の冒頭で紹介した「Github リポジトリを使ったファイルサーバーっぽいもの」の開発もできると思っています。

予定としては次回のエントリで、実際に Github API で作成したファイルサーバーを紹介するつもりです。お楽しみに。


(2021/05/21 追記 続きはこちら

Github API を使って簡易アプリケーションを作ってみました。その成果物を紹介すると同時に、Github API そのものについても少しずつ紹介する内容を書きたいと思ってブログエントリを書き始めています。アプリケーションの紹介までは長くなりそうなのでテーマを分割して、今回はとりあえず準備段階を含めた OAuth ログインを紹介します。


【もともとやりたかったこと】
もともとは「ファイルサーバー的なものを作りたい」と思っていました。ファイルサーバー自体はアプリケーション開発テーマとしては(わざわざ新たに作る必要があるものとも思わず、Box とかを使えばいいので)既に枯れたテーマだと思っていますが、ファイルのバージョン管理機能など細かな使い勝手を意識して実装しようとすると既存のものだけでは難しそうだと判断しました。その技術検討の中で(本来の目的とは違う使い方であることを理解した上で)Git のリポジトリをファイルサーバーとみなすとバージョン管理ははじめからついているし、個人ごとのフォルダも個人ごとにブランチを作ればいけそうだし、管理者は管理者用ブランチにマージすればまとめて見れるし、自分の希望に近いことを実現できるのではないか? と思いついたのでした。

要するにファイルサーバーのバックエンドとして Git を使い、そのバックエンド部分を API で読み書きするようなフロントエンドをアプリケーションとして実装すればよい、ということになります。実現するための Git の API として上述の Github API の存在を知ったことでなんかできそうな目処がたったので、実際に作って検証してみた、という経緯です。


【Github API を使う準備】
これから作るアプリケーションでは Github API を使うため、まずは Github に API を利用するアプリケーションを登録して、OAuth 認証用の各種 ID を取得したり、コールバック URL を設定しておく、という準備段階を済ませておく必要があります。以下、その手順を紹介します。

ウェブブラウザで Github にログインし、右上のアイコンメニューから Settings を選択します:
2021051601


Settings 画面の左で Developer settings を選択します:
2021051602


今回作るアプリケーションは Github に外部ログインするウェブアプリケーションです。この外部ログインを実現するため OAuth App として登録する必要があります。左メニューで OAuth Apps を選択して、"New OAuth App" ボタン(初めて登録する場合は "Register a new application" ボタン)をクリックします:
2021051603


アプリケーション名(適当)とアプリケーション URL (適当、http://localhost:8080/ など)を指定後、コールバック URL を指定します。この値は OAuth 認証後にリダイレクトする先の URL となり、アプリケーション側ではこの URL にアクセスされた際のパラメータを見て(API 実行時に必要な)アクセストークンを取得・保管する処理が必要になります。なので、アプリケーション毎にこの処理を行う URL を定義する必要がありますが、後述のサンプルアプリケーションでは "http://localhost:8080/api/callback" に GET リクエストがあった時にこの処理を行う想定で作っています。そのためアプリケーション URL には http://localhost:8080/api/callback と入力してください。他の値はオプションなので適当に入力し、最後に "Register Application" ボタンをクリックします:
2021051604


※注意点として、Github API ではこのコールバック URL は OAuth アプリケーションごとに1つだけしか登録できないようです。今回は localhost 環境で動かす想定でコールバック URL を http://localhost:8080/api/callback と登録しましたが、実際に公開するサービスとして利用する場合は、その本番サーバーのホスト名やポート番号を使って指定する必要があります。本番利用時にはこの値を書き換えるか、あるいは本番サーバー用の OAuth アプリケーションを新たに登録し、そこで取得した値(後述)を使ってアプリケーションを動かしてください。


アプリケーションの登録が完了した直後の画面に、外部アプリケーションからの認証時に指定する必要のある各種情報が表示されます(client_secret は "Generate a new client secret" ボタンをクリックすることで表示されます)。このうちの client_idclient_secret の値を(後で使うので)メモしておきます。なお、client_id の値は忘れてしまった場合でも改めて OAuth Apps 一覧から選択することで再び参照することができますが、client_secret の値はこの画面を閉じてしまうと2度と参照することができません(client_id ごとリセットして取得し直す必要があります)。間違えないように正しく記録を残しておくように気をつけてください:
2021051605


これで Github API を使うための、OAuth App を登録する手順が完了しました。次はこの OAuth App を実際に作って動かす段階となりますが、その前に Github API を使って操作する Github リポジトリを用意しておきます。


【Github API で操作するリポジトリを用意する】
後述のサンプルアプリケーションでは「指定した特定の Github リポジトリ内の main ブランチに属しているファイルの一覧を取得する」ことが可能な実装をしています。そのための「特定の Github リポジトリ」を用意します。

単にファイル一覧を読み取るだけなので(変更を加えるわけではないので)、既存の Github リポジトリがあればそれを使ってもいいし、新規に Github リポジトリを作成した上で指定しても構いません。今回は dotnsf/my_githubapi_test という動作確認用リポジトリを作り、この中の main ブランチのファイル一覧を取得するようなアプリケーションとして以下の説明を続けます(実際にはみなさんも独自のリポジトリを作って、README.md 他を main ブランチに入れておいてください):
2021051606


ただ一点注意が必要です。上述の client_id / client_secret を取得した時に使った Github ユーザー ID と同じ ID でログインする場合はこのままでいいのですが、別の Github ユーザー ID で使いたい場合や、友人など別の Github ユーザー ID からも同アプリケーションを使わせたい場合など、(client_id / client_secret を取得した時とは)別の Github ユーザーにもこのアプリケーションを使わせたい場合、そのユーザーが対象のリポジトリを読み取るための Collaborator 権限設定が必要になります。 その場合は同リポジトリのオーナーでブラウザログインし、対象リポジトリを開いてから Settings メニューを選択します:
2021051607


そして画面左から Manage Access メニューを選択し(パスワードを聞かれます)、画面右の Manage Access 内の "Invite a collaborator" ボタンをクリックします:
2021051608


そして後述のアプリケーションを使わせたい Github ユーザーを指定して "Add **** to this repository" ボタンをクリックします:
2021051609


すると指定されたユーザーに招待メールが送信され、メール内の "View invitations" リンクから遷移して accept することで該当リポジトリに対する Collaborator 権限が付与され、リポジトリのオーナー以外のユーザーでも操作できるようになります:
2021051601



ここまでの作業で外部アプリケーションからログインするための設定と、操作対象リポジトリの準備ができました。ではサンプルのアプリケーションを使って、実際に Github API が動作する様子を体験してみます。


【Github の OAuth を使って外部アプリケーションから Github にログインする】
Node.js を使って Github API を実際に動かすサンプルアプリケーションを用意しました。Node.js 導入環境を使って、こちらから git clone するかダウンロード&展開してください:
https://github.com/dotnsf/github_oauth_sample


ソースコード内の settings.js ファイルを編集して、上述の準備段階で集めた情報を指定します。git clone 後かダウンロード後、ローカルシステムにある同ファイルをテキストエディタで開き、以下のように値を入力します(青字はコメント):
//. settings.js
exports.client_id = 'xxxxxxx'; OAuth App 作成時に取得した client_id の値
exports.client_secret = 'xxxxxxx'; OAuth App 作成時に取得した client_secret の値
exports.callback_url = 'http://localhost:8080/api/callback'; OAuth App 作成時に指定したコールバック URL の値
exports.repo_name = 'dotnsf/my_githubapi_test';  操作対象リポジトリ名

この状態で npm install を指定して依存ライブラリを導入してから node app でアプリケーションを起動します:
$ npm install

$ node app

起動に成功すると、このサンプルアプリケーションは 8080 番ポートで HTTP リクエストを待ち受けます。ウェブブラウザで http://localhost:8080/ にアクセスします:
2021051601


最初はログイン前なので "login" ボタンが表示されています。このボタンをクリックすると Github API を使った OAuth ログインの処理がスタートします。一度もログインしたことがない場合は以下のような同意画面が表示されるので、ユーザー名を確認後に "Authorize ***" をクリックしてください:
E1ewH_vVkAAwHHE


すると再度 http://localhost:8080/ に転送されますが、今度はログイン後なのでユーザー情報を取得することができ、ログインした Github ユーザーのアバターアイコンやユーザー ID が画面右上に表示されます。このアイコンが自分の Github ユーザーアイコンであることを確認してください。このアイコンをクリックしてログアウトすることもでき、ログアウトすると再度 login ボタンが表示されます:
2021051602


とりあえず、Github API を使ったログイン処理を実装することができました。以下、アプリケーションのログイン部分の仕組みを解説します。


【Github の OAuth ログインの仕組み】
詳しくはサンプルアプリケーションのソースコード内 api/github.js を見ていただきたいのですが、正確には「ログイン認証の仕組み」というよりは「ログイン認証してアクセストークンを取得する仕組み」です。

まずログイン認証の仕組みは OAuth を使っています。アプリケーションで "login" ボタンをクリックすると、https://github.com/login/oauth/authorize にリダイレクトしています。その際に URL パラメータに付与する形で settings.js 内に記載した client_id の値と callback_url の値を指定しています:
router.get( '/login', function( req, res ){
  //. GitHub API V3
  //. https://docs.github.com/en/developers/apps/authorizing-oauth-apps
  res.redirect( 'https://github.com/login/oauth/authorize?client_id=' + settings.client_id + '&redirect_uri=' + settings.callback_url + '&scope=repo' );
});


このリダイレクト先で上述の Github の認証を行います:
E1ewH_vVkAAwHHE


ここで Authorize するとコールバック URL に転送されます。その際に一時的なアクセストークンが code パラメータに指定される形で送付されてくるので、これを取り出した上で client_id や client_secrent などの値と一緒に https://github.com/login/oauth/access_token に POST リクエストを発行します。その実行結果(form encoded形式)から access_token 変数としてアクセストークンを取り出すことができるので、(本サンプルアプリケーションではセッション内に記録する形で)保存して、この後の Github API 実行時に指定できるようにしています。その上で改めて GetMyInfo() 関数を実行してログイン情報を取得し(詳しくは次回に)トップページにリダイレクトしています:
router.get( '/callback', function( req, res ){
  res.contentType( 'application/json; charset=utf-8' );
  var code = req.query.code;
  var option = {
    url: 'https://github.com/login/oauth/access_token',
    form: { client_id: settings.client_id, client_secret: settings.client_secret, code: code, redirect_uri: settings.callback_url },
    method: 'POST'
  };
  request( option, async function( err, res0, body ){
    if( err ){
      console.log( { err } );
    }else{
      //. body = 'access_token=XXXXX&scope=YYYY&token_type=ZZZZ';
      var tmp1 = body.split( '&' );
      for( var i = 0; i < tmp1.length; i ++ ){
        var tmp2 = tmp1[i].split( '=' );
        if( tmp2.length == 2 && tmp2[0] == 'access_token' ){
          var access_token = tmp2[1];

          req.session.oauth = {};
          req.session.oauth.token = access_token;

          var r = await GetMyInfo( access_token );
          if( r ){
            req.session.oauth.id = r.id;
            req.session.oauth.login = r.login;
            req.session.oauth.name = r.name;
            req.session.oauth.email = r.email;
            req.session.oauth.avatar_url = r.avatar_url;
          }
        }
      }
    }
    //console.log( 'redirecting...' );
    res.redirect( '/' );
  });
});

トップページでは画面ロード直後に GET /api/isLoggedIn という API が実行されます。この API はセッション内にアクセストークンがあるかどうかを調べ、含まれていた場合はその情報(ログイン時に GetMyInfo() 関数によって取り出したユーザー情報)をレスポンスの値として返します。つまりログインしていない場合は false 値が、ログインしている場合はユーザー情報を返すという関数です。これによって、未ログイン時のトップページでは login ボタンを、ログイン時のトップページでは logout ボタンをそれぞれ表示するようにしています:
router.get( '/isLoggedIn', function( req, res ){
  res.contentType( 'application/json; charset=utf-8' );
  var status = false;
  if( req.session && req.session.oauth && req.session.oauth.token ){
    status = JSON.parse( JSON.stringify( ( req.session.oauth ) ) );
  }

  if( !status ){
    res.status( 400 );
    res.write( JSON.stringify( { status: false }, null, 2 ) );
  }else{
    res.write( JSON.stringify( { status: true, user: status }, null, 2 ) );
  }
  res.end();
});

なお、logout ボタンが押された場合はセッションの中身を空にする、という処理が実行されます。これによって取得したアクセストークンは無効になり、(再ログインによって)再びアクセストークンを取得するまで Github API は実行できなくなります:
router.post( '/logout', function( req, res ){
  if( req.session.oauth ){
    req.session.oauth = {};
  }
  res.contentType( 'application/json; charset=utf-8' );
  res.write( JSON.stringify( { status: true }, null, 2 ) );
  res.end();
});

と、Github API を使ったログイン時の処理内容を中心に紹介しました。次回は GetMyInfo() 関数でユーザー情報を取り出す仕組みと、ログイン後に main ブランチ内のファイル一覧を取り出す仕組みを紹介する予定です。


(2021/05/18 追記 続きはこちらこちら

このページのトップヘ