KtorでAtProtocolのRefreshSessionをしたい

はじめに

Blueskyのデスクトップアプリ for MacCompose for Desktop でちまちまと作っている。

通信は Ktor + Ktorfit というKMPのデファクトなライブラリを使っている。

Blueskyのライムライン取得や投稿などはHTTPリクエストを送ることになるが、それら通信の仕様やBlueskyが実装している分散型SNSの仕様は AT Protocolとして定義されている。

atproto.com

通信の流れとしてはよくある感じで、

  1. createSessionでセッションを生成する
  2. 以降はここで得られたトークンをHeaderに入れて通信する(Bearer Authentication
  3. セッションの有効期限が切れたら refreshSession でリフレッシュ

となる。

Ktorでは各リクエストにトークンを渡す設定は簡単に行える。

install(Auth) {
    bearer {
        loadTokens {
            val accessToken = dataSource.getAccessToken()
            val refreshToken = dataSource.getRefreshToken()
            BearerTokens(accessToken, refreshToken)
        }
    }
}

こんな実装をしておくとヘッダーにAuthorization: Bearer ${your_access_token}が付与される。

ktor.io

また、リフレッシュトークンの仕組みも備わっており、こちらもかなり簡単に実装ができるようになっている。

install(Auth) {
    bearer {
        refreshTokens {
            // トークンをrefreshするようなエンドポイントを叩くなりして新しいトークンを得る
            BearerTokens(newAccessToken, newRefresshToken)
        }
    }
}

ktor.io

Ktorのリフレッシュトークン機構の発動条件

このリフレッシュトークンの仕組みが発動するのは、

  • refreshTokensトークン更新の設定をしており、
  • リクエストの結果がstatusCode=401のとき

に限られる。

https://github.com/ktorio/ktor/blob/6c391d04548626d77a9a6e9523f9cdeffa6aa5ef/ktor-client/ktor-client-plugins/ktor-client-auth/common/src/io/ktor/client/plugins/auth/Auth.kt#L138-L151

多くのサービスでは問題ないと思うが、トークン有効期限切れのときに401を返さないサービスも存在する。

Blueskyもそうだった。

Blueskyの場合、有効期限が切れているときには次のようなエラーレスポンスが返ってくる。

400 Bad Request
{"error":"ExpiredToken","message":"Token has expired"}

これだとKtorのリフレッシュトークンの仕組みは発動しないため、代替案を考える必要があった。

案1 発動条件を変更する(没)

Ktorは柔軟に設計されている印象を受けていたので、リフレッシュトークンに関してもその発動条件を変更することができるだろうと思った。

ドキュメントと実装をみたが、どうやらできそうになかった。

できても良いような気がしているので、もしできるなら方法を知りたい......。

案2 エラー情報を改変して返す(没)

カスタムプラグイン(後述)を使って「status=400かつerror=ExpiredTokenの場合には401エラーが返ってきた」ことにするよう、偽装できないかと考えた。

結果、こちらも無理そうだった。カスタムプラグインでは返ってきたレスポンスの内容を編集するような操作はできなかった。

案3 カスタムプラグインで実装する(採用)

Ktorにはカスタムプラグインという仕様が存在する。OkHttpInterceptorのように、リクエストやレスポンス(またはその両方)の前後に任意の処理を挟むことができる。

ktor.io

  1. 通信がstatusCode=400で、かつエラーレスポンスのerrorExpiredTokenだった場合、
  2. refreshSessionを実行し、
  3. 新たに取得したトークンで400エラーとなった最初の通信をリトライする

というような実装をする。

例としてはこんな感じ。

class RefreshTokenPluginFactory {

    fun create() = createClientPlugin("RefreshTokenPlugin") {
        on(Send) { request ->
            // 目的のリクエストを実行
            val originalCall = proceed(request)

            // 400エラーで失敗し 
            if (originalCall.response.status == HttpStatusCode.BadRequest) {
                val errorResponse = originalCall.response.body<ErrorResponse>()
                
                // エラーが`ExpiredToken`だったとき
                if (errorResponse.error == "ExpiredToken") {
                    
                    // refreshSessionをリクエスト
                    val newSession = originalCall.client.request {
                        method = HttpMethod.Post
                        
                        // see https://github.com/bluesky-social/atproto/blob/main/lexicons/com/atproto/server/refreshSession.json
                        url("https://bsky.social/xrpc/com.atproto.server.refreshSession")
                        
                        // ここはrefreshTokenを使う
                        // see https://atproto.com/specs/xrpc#authentication
                        header("Authorization", "Bearer ${refreshToken}")
                    }.body<Session>
                    
                    // 新しく取得したトークンをもとのリクエストヘッダーに追加
                    request.headers["Authorization"] = "Bearer ${newSession.accessToken}"

                    // 再度リクエスト
                    proceed(request)
                } else {
                    originalCall
                }
            } else {
                originalCall
            }
        }
    }
}

これをinstallすれば良い。

install(refreshTokenPluginFactory.create())

ただ、このままだと色々と問題がある。

RefreshSessionがループしないようにする

上記のサンプル実装ではPOST /refreshSessionをリクエストする場合にもプラグイン処理が呼ばれる。

そのため、POST /refreshSessionExpiredTokenエラーになるとPOST /refreshSessionをリクエストして...と無限ループに陥る可能性がある。

従ってガードが必要。

if (request.url.toString().contains("refreshSession")) {
    return@on process(request)
}

RefreshTokenが利用されない

Blueskyの場合(というか他のサービスでもそうかも知れないが)、POST /refreshSessionを行う場合にはrefreshTokenを利用する必要がある。

しかし最初に例示したloadTokens{}を使っている場合、上記のようにプラグイン内でrefreshTokenを設定していたとしても、accessTokenで上書きされてしまう。

トークン設定はプラグインの中で行う実装をし、loadTokensは使わないことでこの問題を回避できる。

if (request.url.toString().contains("refreshSession")) {
    return@on process(request)
}

request.headers["Authorization"] = accessToken
val originalCall = process(request)

これは削除する。

install(Auth) {
    bearer {
        loadTokens {
            val accessToken = dataSource.getAccessToken()
            val refreshToken = dataSource.getRefreshToken()
            BearerTokens(accessToken, refreshToken)
        }
    }
}

全体的に実装はもうちょっと整理したほうがいい気がする。

例えば「ヘッダーに情報を詰める処理」と「リフレッシュトークンの処理」はそれぞれ別のプラグインとして実装するのもアリかもしれない。

そもそも、もっと良い方法がありそうな気もするのだけれど。

以上。

Goでつくったもの 2023年

はじめに

『Goでつくるインタプリタ』を写経した、という記事を半年ほど前に書いた。

n-seki.hatenablog.com

この記事の最後で、

この本を読み進めながら、Slack APIを叩くCLIか何か作ろうかな。

と書いており、アンサー記事ということで、Goでつくったものをつらつらと紹介していく。

コードの公開・非公開問わず、ツールっぽいものを4つ作ったよ。

slack2md

github.com

過去24時間で投稿されたSlackのメッセージをMarkdown形式のファイルにしてくれるツール。 個人Slackを日記代わりにしたくて作った。

オプションとして、Markdownに含めるチャンネルやユーザーを指定できる。

実装はあまりがんばっておらず、たとえばページングには対応していない。なので過去24時間で1チャンネルあたり100件以上の投稿があった場合には取りこぼしが発生する。直したいとは思っている(が、唯一のユーザーである自分が困っていないので......)。

Slackメッセージの取得には https://github.com/slack-go/slack を使っているが、一部RichTextが未対応なものがあり、自分で実装する必要があった。

自分はこのツールをMacAutomatorで定時実行することで日記(というかバックアップに近い)を自動生成している。

emv-go

EMVで定義されるTLVデータ(文字列)をデコードして、ヒューマンリーダブルな形式で標準出力してくれるツール。これはコードは公開していないのだけれど、特別な理由があるわけではなく、単に大量のタグを定義するのに飽きてしまい開発が止まっているため。

EMVやTLVについてはこの記事がとても分かりやすいのでおすすめ。

qiita.com

このツールを実装しているときに、EMVでTLVがどのように定義されているか確認したのだけれど、L = Length 部について誤解していたことがあった。

Length部は固定長だと思っていたのだけど、なんと可変長だった。Length部の最初の2バイトを見たときに、先頭ビット(0x10000000)が立っている場合には下位7ビットが Length 部の長さを表す。

たとえば Length に相当するデータが次のような2進数で表現できる値であった場合、

1000 0010 00011 1001

最初の2バイトの先頭ビットが立っているので、0x01111111とマスクすることで得られる 0x0010 がLength部の長さになる。 0x0010は10進数では2なので、以降の2バイトが長さを表している。この例だと、0x000111001 がData部の長さになる。

なるほどなと思った。これによってデータ部が長くても対応できる。

あとは、LTVのV = Value部はさらに別のLTVを含んでいる場合があるために再帰的にパースを行ったりと面白いトピックがあって、実装するのは楽しかった。

slack2bsky

Slackでスタンプを付けた投稿をBlueskyに投稿するツール。 Google Cloud Functionsで動かしている。

SlackのWebhookでリアクションイベントでClound Functionsがリクエストされるようにして、Function側ではリクエストの検証などを経て投稿内容をBlueskyに投稿する。Blueskyとの通信では indigo を使っている。

github.com

当初はライブラリを使わずにAT ProtocolのAPIを直接叩く実装をしていた。AT Protocolの仕様に触れられてよかった。

atproto.com

indigoで完全に書き換えたらコード公開したい。

repo-pr-stat

github.com

GitHub RepositoryのPull Requestに関する以下の情報を、指定された期間で算出するツール。

  • PRの数
  • PRが作成されてからマージされるまでの平均時間
  • PRがオープンになってからマージされるまでの平均時間
  • コントリビューター毎のPR作成数

これらをJSON形式で標準出力に出力する。

似たようなツールはいろいろあるのだが、「PRがオープンになってからマージされるまでの平均時間」を出してくれるものがなかったので作った、という感じ(あまり頑張っては探してないので、そういうツールはあるかもしれない)。

実は、GitHub APIで得られるPRを表すJSONには「オープンされた時刻」という情報は含まれていない。

ではどうしているのかというと、issueのイベントを取得するエンドポイントを利用している。

docs.github.com

Pull Requestなのに issue のエンドポイント?と疑問に思ったかもしれない。自分も思った。

しかしこのエンドポイントでは、パラメータ issue_number にPRのnumberを渡すと、ちゃんとPRのイベントを返してくれる。 (参考 https://docs.github.com/ja/rest/issues/events?apiVersion=2022-11-28#about-events

で、イベントを取得したら ready_for_review のイベントを探す。あったらそのイベント発生時刻がPRがオープンされた時刻だ。

実装はあまり頑張らず、100件固定でイベントを取得している(ページングなし)。この100件にready_for_review がなかったら Draft の期間なしでPR作成時にOpenされた、とみなしている。

イベントにはコミットやコメントなどが含まれるので、1つのPRで100件を超えることは普通にありそうだが、ready_for_reviewが101件目以降に現れるというのは稀なケースだろうと思って妥協している。

Google CloudのCloud FuctionsのSlack チュートリアルをやってみた

はじめに

タイトルの通り、Google CloudのClound Funtionsのチュートリアルをやってみた。 Slackのスラッシュコマンドを作るというもの。

cloud.google.com

自由に使えるSlackワークスペースが必要なので多少敷居は高いかもしれないけど、自分みたいな初学者にとっては内容、ボリュームともにちょうどいいチュートリアルだった。

はまりどころ

このチュートリアルに限らず、Google Cloudのドキュメントはかなり丁寧に書かれているので迷うことがあまりない。

が、チュートリアルを進め、いざSlackスラッシュコマンド /kg を実行すると dispatch_failed というメッセージを吐いてコマンドが終了する。

Cloud Functionsのログを確認すると次のようなエラーが出力されていた。

makeSearchRequest failed: googleapi: Error 403: Knowledge Graph Search API has not been used in project (project id) before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/kgsearch.googleapis.com/overview?project=(project id) then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry."

ここにあるように Knowledge Graph Search API を有効化してあげるとエラーが解消してスラッシュコマンドが動くようなる。

チュートリアルに明記されていたほうがいいなと思ったのでフィードバックを送信してみた。記述が追加されるといいな。

さいごに

Google Cloudを有効化したところ多くのクレジットが付与されて嬉しかったのだが、有効期限があと3ヶ月弱......無駄にしたくないけど現時点での請求金額 1円 なので、たぶん無理。

Go言語でつくるインタプリタを写経した

『Go言語でつくるインタプリタ』の第4章までの写経が終わったので感じたことなどをメモする。

www.oreilly.co.jp

モチベーション

2023年に入って何か新しい言語を勉強しようと思い立ち、RustとGoで悩んでGoを選択した。 CLIやツールを作ろうとしたときに第一候補になるような言語を身に着けたかった、というのが大きい。

......昨年まではHaskellを勉強していて、そのハードっぷりに自分にはまだ早かったという諦観があり、(比較的)かんたんそうなGoに救いを求めた、とも言える。

まずは文法を覚える、ということで『初めてのGo言語』を買って写経しながら読んだ。

www.oreilly.co.jp

これはとてもいい本だった。Goの文法が少ないという点も相まって、1冊にGoの仕様がギュッと詰まっている。読んでいて『Kotlin in Action』を思い出すなどした.....第2版出るのかな。

Why 『Go言語でつくるインタプリタ』?

『初めてのGo言語』を一周したあとで何をすべきか考えた。自分は「作りながら覚える」ということにストレスを感じるタイプなので、あと1,2冊は本を読んでおきたいが、何を読もう。

『Go言語でつくるインタプリタ』は(たぶん)2020年ころに買って、とりあえず読むだけ読んで満足して本棚に安置していた。(この本のすごいところは読むだけでも楽しいというところ)

そういえばこの本はGoだな、ということで2月末から写経を開始した。

感想とか

読むだけで面白い本なので、当然ながら写経しながら読むとより面白い。 この本はGo言語を使って「Monkey」言語のインタプリタを実装していく、という内容になっている。 第4章まで進めるとMonkeyは配列やハッシュ(JavaでいうMap)、高階関数までをも扱えるプログラミング言語になる。

写経することによって、字句解析をして、抽象構文木(AST)を作って、評価する、という一連の流れを「体験」することができる。

全体としては高度な内容を扱っていると思うのだが、ステップ・バイ・ステップでかなり詳細に解説してくれるので分かった気になれるし、実際に写経で手を動かすことで理解も深まる。

字句解析の時点では正直あまり楽しいものではないのだが、ASTの構築や評価で面白さが跳ね上がる。とくに評価については、たった数行を追加するだけで期待通りの挙動が手に入ることが多く、不思議さもあった(言い換えると実装を理解するのが少し大変だった)。

得たもの

まずはGoの基礎的な文法に対する「慣れ」を得ることができた。『初めてのGo言語』の直後では考え考えコードを書く感じだったが、いまではstructを作るとか、関数・メソッドを書くことに対するコストがかなり下がったと感じている。

関数・メソッドのキーワード、KotlinではfunGoではfuncだけれど、そこの混乱もなくなった。

あとはテストの書き方もなんとなく理解できた。 GoではテスティングフレームワークもGoに含まれているので、依存を追加することなくテストが書ける。

例に漏れずテストでもGoらしく(ライブラリを使わなければ)オブジェクトの比較なんかは自力で実装する必要があるので、テストの写経は大変だった。『Go言語でつくるインタプリタ』ではテストをかなり手厚く書きながら開発を進めていく。これ自体は良いことだとは思うけれど。

上述した字句解析、抽象構文木、評価という知識・概念についても学べたが、100%理解できたとは言えず、かつ完全に覚えているわけではないので、かなり穴が空いた状態だと思う。

が、こういうのはたぶん「経験した」というのが大事だと思うので、これはこれで良いのだと考えている。

次にやること

やることというかやっているのは『Go言語プログラミングエッセンス』の読み進め。

gihyo.jp

まだ3章までしか読んでないけれど、とにかく読みやすいと感じる。Goらしい文法に対して「どうしてそうなっているか」のコメントが個人的にはとてもありがたい。

この本を読み進めながら、Slack APIを叩くCLIか何か作ろうかな。

BottomSheetメモ

AndroidのBottomSheetを触る機会があったのでメモ。

基本的な実装はmaterial.ioを見ればだいたい全部分かる。

material.io

Standard Bottom Sheetはメイン画面と同時に表示・操作することができる Modal Buttom Sheetはシート表示中はメイン画面は操作できない。灰色オーバーレイがかかる。これをカスタムすることはできない(はず)。

前者は CoordinatorLayoutを使ったレイアウトxmlで、後者はBottomSheetDialogFragmentで実装を行う。 シートの挙動制御はいずれも BottomSheetBehaviorを介して行う。

material.ioと併せてBehaviorのドキュメントには目を通したい。

developer.android.com

アプリ内で統一した初期値を与える必要がある場合には、Standard Bottom Sheetだったらstyleを定義してあげる。 BottomSheetDialogFragmentの方はレイアウトxmlで初期値を与えることができるので、共通化できそうではある。

シートの上部を丸角にしたくなるが、素直に実装するとハマる。 何かというと、Expandedな状態のときに丸角は許可されていないため、自動的にround = 0dp になってしまう。

github.com

materialのバージョンによっても挙動が違うかもしれない。 対処方法は調べるといろいろと出てくる。

全体的に直感的に使えるインターフェースという印象だった。

startActivityForResultを待ち合わせる

これは

遷移先のActivityから処理結果を得るには、これまでonActivityResultをオーバーライドし、requestCodeで分岐するような実装が必要でした。

androidx.activity:1.2.0、で代替手段となるコールバック方式のAPI registerForActivityResultが提供されています。

developer.android.com

これをRxJavaやKotlin Coroutinesと一緒に使うことで、処理結果であるActivityResultを待ち合わせる実装をしてみます。

Normalな実装

registerForActivityResultの基本的な使い方は上記の公式ガイドを見ていただくとして、後述する実装と統一感をもたせるためregisterForActivityResultをラップした実装を載せておきます。

class NormalStartActivityForResultLauncher(
    activity: ComponentActivity,
    action: (ActivityResult) -> Unit,
) {

    private val startActivityForResult = activity.registerForActivityResult(
        StartActivityForResult()
    ) { activityResult -> action(activityResult) }

    fun launch(intent: Intent) {
        startActivityForResult.launch(intent)
    }
}

これはこう使います。

private val normalLauncher = NormalStartActivityForResultLauncher(this) { activityResult ->
        // なにか処理
}

val intent = Intent(this, TargetActivity::class.java)
normalLauncher.launch(intent)

TargetActivityの結果をコールバックで得ることができます。

RxJavaな実装とKotlin Coroutinesな実装

RxJava

Singleを使ってこのような実装をしてみました。RxJava3を利用しています。

class RxStartActivityForResult(
    activity: ComponentActivity
) {

    private var emitter: SingleEmitter<ActivityResult>? = null
    private val startActivityForResultLauncher = activity.registerForActivityResult(
        StartActivityForResult()
    ) {
        emitter?.onSuccess(it)
    }

    fun launch(intent: Intent): Single<ActivityResult> {
        startActivityForResultLauncher.launch(intent)
        return Single.create { emitter = it }
    }
}

コールバックで取得したActivityResultをemitter::onSuccessで渡しています。 これはこんな感じに使えます。

private val rxLauncher = RxStartActivityForResult(this)

val intent = Intent(this, TargetActivity::class.java)
rxLauncher.launch(intent)
    .subscribeOn(Schedulers.single())
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe { activityResult ->
        // なにか処理
    }

Kotlin Coroutines

同じような要領で実装します。CompletableDeferredを使ってみました。

class DeferredStartActivityForResultLauncher(
    activity: ComponentActivity
) {

    private var deferred: CompletableDeferred<ActivityResult>? = null
    private val startActivityForResultLauncher = activity.registerForActivityResult(
        StartActivityForResult()
    ) {
        deferred?.complete(it)
    }

    fun launch(intent: Intent): CompletableDeferred<ActivityResult> {
        startActivityForResultLauncher.launch(intent)
        deferred = CompletableDeferred()
        return deferred!!
    }
}

利用側はこんな感じです。

private val deferredLauncher = DeferredStartActivityForResultLauncher(this)

lifecycleScope.launch {
    val intent = Intent(this, TargetActivity::class.java)
    val activityResult = deferredLauncher.launch(intent).await()
    // なにか処理
}

さいごに

コールバックではなくRxJavaやKotlin Coroutinesを使う必要があるユースケースはあまり多くないように思いますが、registerForActivityResultの登場でこういう実装もしやすくなったと感じます。

挙げているコードはこちらに置いています。

github.com

Haskell(GHCi)のAtcoder用の実行環境を整えたメモ

はじめに

Atcoderの問題を解くときには、Haskellでコードを書いてstack ghciをして :l でファイルをロードしてサンプルケースを試す、というやり方をしています。

Atcoderでは利用できるプログラミング言語と、各言語で利用可能なライブラリが一覧されているので、ここに記載されているGHCのバージョン、パッケージがGHCiで利用できるよう設定をするのがゴールです。

ルール - C++入門 AtCoder Programming Guide for beginners (APG4b)

やったこと

通常のHaskellプロジェクト同様、次の設定ファイルを作成します。

通常は実行ファイルやテストの設定も書きますが、今回はGHCや各パッケージのバージョンが指定できればOKです。最低限必要そうな設定だけを残した結果、最終的に各ファイルはこのような内容になりました。

stack.yaml

resolver: ghc-8.8.3 

packages:
- .

extra-deps:
- QuickCheck-2.13.2 
- array-0.5.4.0
- attoparsec-0.13.2.3 
- bytestring-0.10.10.0
- containers-0.6.2.1 
- deepseq-1.4.4.0
- extra-1.7.1
- fgl-5.7.0.2
- hashable-1.3.0.0
- heaps-0.3.6.1
- integer-logarithms-1.0.3
- primitive-0.7.0.1
- psqueues-0.2.7.2
- random-1.1
- reflection-2.1.5
- template-haskell-2.15.0.0 
- text-1.2.4.0
- unboxing-vector-0.1.1.0
- vector-0.12.1.2 
- vector-algorithms-0.8.0.3
- vector-th-unbox-0.2.1.7

package.yaml(プロジェクト名などの情報は省略しています)

dependencies:
- base >= 4.7 && < 5
- array
- containers
- bytestring
- hashable
- psqueues
- vector

library:
  source-dirs:
  - .

とりあえずすぐに使いそうなものだけを package.yaml に書いています。

パッケージの設定についてはこの公式記事がわかりやすかったです。

stack.yaml vs cabal package files - The Haskell Tool Stack

ここで stack ghci をすると version 8.8.3 で動いていそうです。

Configuring GHCi with the following packages: 
GHCi, version 8.8.3: https://www.haskell.org/ghc/  :? for help

:show packages でロードされているパッケージを確認してみます。

> :show packages
active package flags:
  -package-id vector-0.12.1.2-AWRYcz9jfa25Avs2q9Jg9V
  -package-id psqueues-0.2.7.2-FrNNxh57zmjqe5jR5GXac
  -package-id hashable-1.3.0.0-CzctMgLWbfP4pv5F2JiKgj
  -package-id containers-0.6.2.1
  -package-id bytestring-0.10.10.0
  -package-id base-4.13.0.0
  -package-id array-0.5.4.0

期待通りstack.yaml で指定しているバージョンがロードされています。

References