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

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

タグ:github

自分で開発したウェブサービスの公開先として、最近は dokku で作った公開プライベート(=自分専用)クラウド環境が重宝しています。デプロイはソースコードを git clone(pull) して git push するだけ、という非常に簡略化されていることに加え、やはり「自分専用(=使いたいサブドメインを誰かに先に取られている心配がない)」という気持ちの余裕も以外と大きい気がしています。dokku の導入については過去の記事を参照してください:
dokku でプライベート PaaS 環境を構築する(1)
dokku でプライベート PaaS 環境を構築する(2)


今回は dokku のいわゆる CI/CD(Continuous Integration / Continuous Delivery) についてブログエントリを書きます。dokku が非常に便利であるが故に、ソースコードが更新されるたびに手作業で push するのは(大した手間ではないのですが)やはり面倒です。というわけで Git リポジトリへのプッシュをフックとする dokku のデプロイ自動化を実現する方法を調べてみました。特に今回紹介するのは GitHub Actions を使った方法で、ソースコードを GitHub リポジトリで管理している人であればちょっとした設定を追加するだけで CI が実現できます。なお、今回 CI/CD を設定する dokku サーバーは "yellowmix.net" というカスタムドメインを有効に設定してあって、"XXXXX.yellowmix.net" というホスト名でアプリケーションをデプロイできるように設定済みである、という前提で紹介します(手順3の中にこの設定に関わる部分があります)。


以下、その手順紹介です。


【手順1 パスフレーズなしの秘密鍵/公開鍵を作成・登録】
GitHub Actions 内でデプロイを自動実行するため、パスフレーズ無しで作られた秘密鍵が必要です。というわけで、パスフレーズ有りの秘密鍵を使っている場合は作成および登録の再実行が必要になります。

まずは秘密鍵と公開鍵を(再)作成します。dokku サーバーのシェルにログインして、以下のコマンドを実行します:
$ ssh-keygen

この後、秘密鍵を作成するパスを入力するよう促されます。デフォルトでは "~/.ssh/id_rsa" が指定されているはずです。変更する場合は正しいファイルパスを入力、デフォルト設定のままで問題なければそのまま Enter を入力します。

次にファイルのパスフレーズを入力するよう促されますが、今回はパスフレーズのない秘密鍵および公開鍵のペアを作りたいので、空のまま Enter を押して先に進めます。パスフレーズの指定が終わると指定されたパスで秘密鍵および公開鍵が作成されます。特に秘密鍵の内容は手順2でも必要になるので、 cat コマンドで一度中身を確認しておきます("-----BEGIN RSA PRIVATE KEY-----" で始まって "-----END RSA PRIVATE KEY-----" で終わる内容が表示されます):
$ cat ~/.ssh/id_rsa

秘密鍵が作れたら dokku に登録します。上述のセットアップを既に別の(パスフレーズ付きの)秘密鍵で実施済みで、別の秘密鍵が登録済みの場合は、以下のコマンドで一度削除しておきます:
$ sudo dokku ssh-keys:remove admin

改めて(再度)秘密鍵をファイルパスを指定して登録します:
$ cat ~/.ssh/id_rsa | sudo dokku ssh-keys:add admin

これで新しい(パスフレーズのない)秘密鍵が作成され、 dokku に登録されました。続いて GitHub のリポジトリ側で GitHub Actions 側の設定を行います。


【手順2 GitHub の対象アプリケーションに GitHub Actions 用の秘密鍵を設定】
GitHub リポジトリにコミットされたタイミングでデプロイを実行できるよう、対象アプリケーションの GitHub リポジトリに GitHub Actions を設定します。まずは GitHub の対象アプリケーションのページを開き、Settings を選択します(ユーザーの Settings ではなく、アプリケーションの Settings を開きます):
2022060901


CI ワークフローを定義する前に、ワークフロー内で使う秘密鍵の情報を先に登録しておきます。Settings ページの左ペインから Secrets - Actions を選択します:
2022060902


新規に秘密鍵を登録したいので、画面右上の "New repository secret" ボタンをクリックします:
2022060903


秘密鍵を登録する画面が表示されるので、NAME 欄に SSH_PRIVATE_KEY と入力し、Value 欄には手順1で作成した秘密鍵ファイルの内容("-----BEGIN RSA PRIVATE KEY-----" から "-----END RSA PRIVATE KEY-----" まで)をそのままコピー&ペーストして入力し、最後に "Add secret" ボタンをクリックします:
2022060904


以下のように表示されればリポジトリへの秘密鍵の登録は完了です:
2022060905



【手順3 対象アプリケーションに GitHub Actions を設定】
ここまでの準備ができていれば、後はコミット時(プッシュ時)の GitHub Actions を登録することで dokku への自動デプロイを行うことができるようになります。ソースコードに .github/workflows/ というフォルダを追加し、このフォルダ内に以下の内容の deploy.yml ファイルを追加します:
---
name: 'deploy'

# yamllint disable-line rule:truthy
on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Cloning repo
        uses: actions/checkout@v2
        with:
          fetch-depth: 0

      - name: Push to dokku
        uses: dokku/github-action@master
        with:
          # specify the `main` branch as the remote branch to push to
          branch: 'main'
          git_remote_url: 'ssh://dokku@yellowmix.net:22/hostname'
          ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}

このうち、青字部分(main と書かれた部分)は「どのブランチが更新された時に、どのブランチを対象にデプロイするか」を指定しています。今回は「 main ブランチ更新時に main ブランチの内容をデプロイ」したいので、このような設定にしています。master ブランチを使いたい場合はここを master に変更してください。 また赤字部分はデプロイ先のリポジトリ URL です。この例では "yellowmix.net" というドメインホストを使って https://hostname.yellowmix.net/ という URL でアプリケーションを公開するための設定にしています。この部分は皆さんの dokku 環境に合わせて変更する必要があるのでご注意ください。 加えて、最終行に手順2で設定した秘密鍵を使う指定がされています。秘密鍵は外部に漏れてはまずい情報なので、ハードコートや公開変数などは使わずに、このような形で登録済みのシークレット情報を参照するよう指定しています。

なお、この deploy.yml ファイルの中で dokku/github-action というリポジトリが指定されている箇所がありますが、これは dokku から提供されている GitHub Actions 連携用のコンテナイメージです。このコンテナイメージを使ってデプロイを行う、という作業内容になっています。同コンテナイメージのソースコードはこちらで提供されています:


ここまで用意できたら、対象のプロジェクトを GitHub にコミット&プッシュします(以下の例は main ブランチに直接コミットしている想定です):
$ git add .

$ git commit -m 'node v14'

$ git push origin main

プッシュが成功すると GitHub リポジトリで GitHub Actions が起動するはずです。起動の様子はリポジトリ内の "Actions" メニューから確認できます(画面は Actions が実行中の様子):
2022060906


実行が(成功か失敗かで)完了すると、その結果が表示されます(この例では緑マークが付いているので成功しています。失敗は赤):
2022060907


成功・失敗に関係なく、Actions 名部分をクリックすると、Actions の実行ログを見ることができます。特に失敗した場合などはこのログが原因を調べるヒントになっている可能性が高いので、失敗に終わった場合はまずログを参照することになると思います:
2022060908


GitHub Actions が成功していた場合、更新されたアプリケーションが指定 URL で稼働できているはずです。実際にアクセスして動作確認してみましょう:
2022060900


以上、GitHub Actions を使って dokku にアプリケーションを自動デプロイするための設定を紹介しました。dokku は専用のリポジトリに git push するだけでもデプロイが出来て便利ですが、一度 CI/CD の便利さを知ってしまうと「わざわざデプロイする手間が面倒」に感じられてしまうほど便利なので、今後 dokku を使って動かすアプリについては全てこの設定を有効にしてもいいかな、と感じています。


 

自作のマークダウン CMS を一週間ほど自分だけで使ってみて、まあまあな感じだったので一般公開します。

"My info" を略して "Mynfo" と名付けています。ソースコードは GitHub に公開してます。自分でも使ってみたい場合はフォークして使ってください(フォーク先は public でも private でも構いませんが、public の場合は GitHub 上のコンテンツが公開される点に注意してください):
https://github.com/dotnsf/mynfo


また、サンプルとして(皆さんには編集権限はありませんが)この公開ソースコードで作ったコンテンツサービスを以下で公開しています:
https://mynfo.herokuapp.com/


実際にみなさんもこの仕組みを使ってコンテンツサービスを公開する場合、必須ではありませんが heroku のアカウントを所有している場合は自分の GitHub リポジトリにコンテンツをコミットすることで CI/CD で最新コンテンツに更新できる仕組みがあります(しかもこの方法だとコンテンツの更新履歴が Git 側に残る、という大きなメリットがあります)。この機能を使う想定で設定方法を README.md 内で解説しているので、heroku アカウントと併せて使うことを推奨します(試しにローカルホストで動かしてみる、程度であれば不要です)。


【ローカルホストで動かしてみる】
試しにローカルホストで動かすには Node.js 環境が必要です。事前にセットアップしておきましょう。

フォークしたコードを git clone して、npm install してから node app で実行します:
$ git clone https://github.com/dotnsf/mynfo.git ("dotnsf" 部分はあなたの Github アカウント名に読み替えてください)

$ cd mynfo

$ npm install

$ node app

デフォルトでは 8080 番ポートで HTTP リクエストを待ち受けます。ウェブブラウザで localhost:8080 にアクセスします。以下のような画面が表示されれば起動成功です(初期状態では README.md にシンボリックリンクした md/index.md の内容が(マークダウンが HTML に変換されて)表示されています。英語で一通りの使い方が紹介されています):
2022021901

(↑皆さんが使うときには md/index.md と README.md とのシンボリックリンクは切っちゃって構いません)


画面右上のハンバーガーメニューをクリックすると、リポジトリ内の md/ フォルダ以下を対象フォルダとするファイル選択画面が表示されます(デフォルト状態では md/ フォルダ以下に about_markdown.md, index.md, sample.md の3つのマークダウンファイルが存在しており、それらが(.md 拡張子が省略された形で)一覧表示されています:
2022021902


試しに一番上の about_markdown を選択すると md/about_markdown.md ファイルの内容が HTML に変換されて表示されます:
2022021901


また初期画面で表示されている README.md の最初にも記述していますが、実行時の環境変数や settings.js ファイル内で値を指定することで挙動を変えることができます。例えばサービスを不特定多数の人に公開するのではなく、一部の人向けにクローズドな形で公開したい場合は環境変数 BASIC_USERNAME と BASIC_PASSWORD を指定して実行することでベーシック認証が有効になり、正しい ユーザー ID とパスワードを入力しないとコンテンツは見ることができません。

見栄えに関しては環境変数 CONTENTS_TITLE でサイトの題名(デフォルト値は "Mynfo")を、BOOTSTRAP_THEME で Bootstrap のテーマ(デフォルト値は "warning")を指定できます。例えば前者を ".NSF Mynfo"、後者を "info" に指定するとこのような見栄えになります:
2022021904


基本機能としてはこのような感じです。プロジェクトフォルダ内の md/ フォルダ内にあるマークダウン(.md)ファイルを参照することができる、というものです。ただこのサービス自体にはマークダウンの編集機能はありません。このままサーバー上で(普通に)公開する場合は、別途 md/ フォルダ内のマークダウンファイルを編集する仕組み(サーバー上で直接編集するなど)が必要です。

動くか動かないかでは動くのですが、個人的にはこのような使い方はあまり想定していません。あくまで後述のような、GitHub リポジトリと連携する使い方を想定して開発しています。


【heroku のデプロイパイプラインを使う】
サーバー上の .md ファイルを直接編集するような使い方は本来想定したものではありません。この Mynfo の最大の特徴は以下のような使い方ができることです:

・ローカルホストで(好きなマークダウンエディタで).md ファイルを編集して、
・Github にコミット&プッシュすると、
・デプロイパイプラインによって、サービス側のコンテンツも更新される

この流れを heroku を使って実現する方法も README.md に(英語で)記載していますが、このブログでは同じ内容を日本語で紹介します。

まず heroku のアカウントを作成します。heroku には複数種類のアカウントが存在していますが、ただ動かすだけであれば(容量や可用性などを考慮せずに、小規模1インスタンスだけで動かす前提であれば)無料プランで構いません。

※なお heroku の無料プランで作成するアプリは 30 分間アクセスがないと止まってしまい、次にアクセスした時に再起動してからアプリが稼働します(この場合、アプリ画面が表示されるまで再起動のぶん少し時間がかかります)。24 時間ずっと稼働状態をキープしたい場合は有料プランで運用することを推奨します。

そして heroku にログインし、画面右上から New - Create new app を選択します:



次にアプリケーション名(下図では "mynfo")を入力し、サービスを作成する地域(下図では "United States")を指定します。そして次に(Create app ではなく) Add to pipeline を選択します:



Choose a pipeline と書かれた箇所をクリックし、Create new pipeline を選択します:



そして作成するパイプラインの名称(下図では "mynfo-pipeline")を適当に入力し、デプロイ先のステージを指定します。例えば「一度ステージング環境にデプロイし、動作確認ができたらプロダクション環境へデプロイ」するようなケースも想定できますが、今回はそのままプロダクション環境へデプロイするシンプルな例を紹介します。というわけでここではパイプラインのデプロイ先として production を直接指定します。最後に Create app をクリックします:



これでパイプラインそのものは作成されましたが、まだ GitHub リポジトリとの連携ができていません。このまま作業を続けます。

デプロイ方法として GitHub 連携をしたいので Deployment Method に GitHub を選択します:



そして対象となる GitHub リポジトリ(クローンしたリポジトリ)を指定します。Connect to GitHub 欄で自分の GitHub アイコンを選択し、対象リポジトリ名(そのままであれば "mynfo")を入力して Search します:



すると該当の GitHub リポジトリが見つかるので、横にある Connect ボタンをクリックします:



これで GitHub との接続ができました。最後に自動デプロイのための対象ブランチを指定するので、このまま続けます:



Automatic Deploy 欄でパイプラインが参照する GitHub リポジトリのブランチを指定します。特にブランチを作っていない場合は main だけが選択できるので、この main が選択された状態で Enable Automatic Deploy ボタンをクリックします(別途ブランチを作成して、main 以外のブランチを指定することも可能です):



これでフォークした GitHub リポジトリの main ブランチに変更がコミットされると、パイプラインが動いて最新コードが heroku 上のアプリケーションとして https://(アプリ名).herokuapp.com/ という URL で動き出すようになります:



また、この時点で作成したパイプラインの状態を参照すると、GitHub リポジトリの指定したブランチを接続されて、更新時の自動デプロイが有効になっている旨を確認できます:



この状態で試しに1ファイルを追加して動作確認しています。ローカルホスト上で1ファイル追加して git コマンドでコミットしてもいいのですが、今回は GitHub のウェブ画面を使って追加コミットする例を紹介します。

まずはウェブブラウザで該当 GitHub リポジトリのページ(フォークしたリポジトリのページ)を開き、md フォルダを選択します:



ここからファイルを指定して編集することもできますが、今回は新しいファイルを1つ追加することにします。画面右の Add file ボタンをクリックして、表示されるメニューから Create new file を指定します:



GitHub リポジトリに直接ファイルを追加する編集画面が表示されるので、試しにファイル名を test.md 、ファイルの中身には "# テスト" と見出し行だけ入力します:



画面を下にスクロールすると、GitHub に直接コミットする情報を指定する箇所が現れます。ここでコミットコメント(下図では "md/test.md added.")、を指定し、"Commit directly to the main branch" が選択された状態のまま Commit new file ボタンをクリックします:



これで GitHub リポジトリにマークダウンファイルが1つ追加でコミットされました(ローカルホストでも編集する場合はこの状態を git pull して、ローカルホスト側も更新しておいてください)。

そして指定ブランチがコミットされたことで、heroku に作成したパイプラインが自動実行されます。パイプライン実行そのものも数秒で終わってしまいますが、下図はビルド中(デプロイ完了前)のパイプライン画面です。アプリケーションのビルド中である旨が表示されています:



デプロイが完了するとこのような画面になり、ここから最新の(追加コードが反映された状態の)アプリケーションを開くこともできます:



確認のため、アプリケーションを開きます(Open app と書かれたボタンで開きます)。そして画面右上のハンバーガーメニューをクリックします:



すると存在していなかった test.md というファイルが確認できます。試しにこのファイルを選択します:



作成した通りの内容( "# テスト")であることが確認できます:



このように GitHub リポジトリと連携して、ローカルホストや GitHub 画面でマークダウンファイルを追加・更新・削除してサーバーにコミットすると、自動で公開アプリにも反映される仕組みができました。こうしておくことでローカルホストでは VSCode など好きなマークダウンエディタで編集することができるようになって、後は編集後にコミット&プッシュすればアプリにも反映される、という仕組みになりました。

冒頭でも書きましたが、このコンテンツサービスそのものはベーシック認証を有効にすることで一部の人だけに公開することもできます。ただそれとは別に GitHub リポジトリを公開するか/しないかを考慮する点があることに注意してください。基本的にベーシック認証をかけるサービスであれば、フォーク先の GitHub リポジトリも private 扱いにするべきだと思っています。

また環境変数 GITHUB_REPO_URL (リポジトリURL)と GITHUB_BRANCH (ブランチ名)を指定して実行すると、コンテンツの各画面内に GitHub のコンテンツメニューが追加され、各ページやファイル選択画面内から GitHub の対象ページ画面に移動したり、直接編集ページに移動したり、フォルダに新しいファイルを追加することも可能です(編集は編集権限を持っているユーザーのみ使えます):
2022021906

2022021905


この GitHub と連携した使い方はちょっとクセがあるので、普段 Git を使っていない人からすると(編集画面も無いし)むしろ不便に感じるかもしれません。が、Git やマークダウンに慣れたエンジニアであれば、むしろこの使い方で(慣れたマークダウンエディタとも連携して)コンテンツを更新情報まで含めて管理ができることになって便利だと思っていて、このようなサービスを作ってみました。

ちなみに、このアプリでブログ的な使い方をする場合は、コンテンツのファイル名を日付(例えば 20220219.md)にして、環境変数 REVERSE_FILES の値を 1 にして実行することで(新しい日付のファイルがリストの上にくるように並ぶので)使いやすいのでは、と思っています。



Git やマークダウンをバッチリ使っている自分からはなかなか便利に使えているサービスなので、同様の属性を持った人に使ってみていただきたいです。

なお現時点では GitHub と heroku のパイプラインを使う想定で README.md を記述していますが、GitLab など GitHub 以外の Git システムや、GitHub Actions など heroku パイプライン以外の自動デプロイ化の仕組みを使って運用することもできると考えています。ぜひ多くのパターンで挑戦していただきたいです。

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 でした。



このページのトップヘ