Django REST FrameworkからCrystal言語のLuckyフレームワークに移行してみる

# はじめに
本記事はCrystalアドベントカレンダー2023の12月18日分の記事です。
本当は記事の公開までに移行を完了させたかったのですが間に合わなかったので、途中経過のご報告となります...。

# 経緯
今回移行するプロジェクトは、私が2020年に趣味で開発したサービスのバックエンドAPIです。
下記の技術スタックで構成されています。

* 言語: Python
* フレームワーク: Django REST Framework
* DB: PostgreSQL（地理情報処理のための拡張機能であるPostGISを有効にしています）
* 認証: Auth0

当初の計画では地理情報処理に重点を置いていたため、この分野に優れるPythonとDjangoを選定したのですが、結果的には地理情報処理の出番は少なかったので、あまり好きではないPythonを使い続ける理由が減りました。

強制力の働かない趣味プロジェクトのメンテを継続するには好きな言語を使わないとツラいと思い始めていたこともあり、思い切って私が一番好きな言語であるCrystalに移行することに決めた次第です。

# Luckyを選んだ理由
Crystal用のWebアプリケーションフレームワークは多数ありますが、中でもメジャーなのは下記の3つです。

[**Kemal**](https://kemalcr.com/)
マイクロフレームワークです。RubyのSinatraに似ています。

[**Amber**](https://amberframework.org/)
フルスタックフレームワークです。Railsの影響が色濃いです。

[**Lucky**](https://luckyframework.org/)
フルスタックフレームワークです。型安全と速度を重視しています。

今回Luckyを選んだ理由は下記の通りです。
* あまり時間を割けないため、レールの敷かれたフレームワークが良い（Kemalが候補から外れる）
* Amberはパフォーマンスが悪く、近年開発がやや不活発
* Luckyは高速な上に標準ORMのAvramで型安全なクエリを書くことができ、開発が活発で企業によるプロダクション投入例も複数ある

Djangoに強く影響された[Marten](https://martenframework.com/)も候補に上がりましたが、まだ若いフレームワークでプロダクション投入例もおそらく無いため、今回は見送ることにしました。

[2023年版のFortunesベンチマークの結果をフルスタックでORM付きの高機能なフレームワークに絞る](https://www.techempower.com/benchmarks/#section=data-r22&hw=ph&test=fortune&c=e&o=e)と、Luckyの成績は3位となります。機能の豊富さと高速な実行を両立できていることが分かりますね。

本記事ではDjangoからの移行にあたって困ったことに話を絞りますので、Luckyの基本については[公式ガイド](https://luckyframework.org/guides/getting-started/why-lucky)をご参照ください。

# 基本方針
* LuckyはAPIモードで利用し、フロントエンドには干渉しない
* Djangoで作ったテーブルをそのまま利用する（モデル作成時にマイグレーションでテーブルを生成せず、既存テーブルとモデルをマッピングする）

# 困ったこと
### PostGISを利用するためのライブラリが無い
LuckyでPostGISのデータ型を使えるようにするライブラリが見当たりませんでした。

Luckyの標準ORMであるAvramがPostgreSQLを操作するために内部で利用している[will/crytal-pg](https://github.com/will/crystal-pg)には、PostGISのデータも扱えるようにする[jgaskins/postgis](https://github.com/jgaskins/postgis)という拡張があるものの、ここ2年ほどメンテされていないようで最新のcrystal-pgと組み合わせるとエラーが出ます。

暫定的な対処として、まずjgaskins/postgisからフォークした[fujikawahiroaki/postgis](https://github.com/fujikawahiroaki/postgis)で最新のcrystal-pgでも動くように改修し、さらにLuckyのモデルおよびマイグレーションから使えるようにする**やっつけ仕事な**ライブラリ[fujikawahiroaki/lucky_postgis](https://github.com/fujikawahiroaki/lucky_postgis)を作りました。

lucky_postgisのソースの中核部分はこんな感じです。Luckyで使いたいDBデータ型を増やす時の参考になれば幸いです。
```crystal:src/point2d_extensions.cr
require "avram"
require "postgis"

struct PostGIS::Point2D
  include JSON::Serializable
  @[JSON::Field(key: "x")]
  property x : Float64

  @[JSON::Field(key: "y")]
  property y : Float64

  @[JSON::Field(key: "srid")]
  property srid : UInt32

  def self.adapter
    Lucky
  end

  module Lucky
    alias ColumnType = PostGIS::Point2D
    include Avram::Type

    def self.criteria(query : T, column) forall T
      Criteria(T, Point2D).new(query, column)
    end

    def from_db!(value : PostGIS::Point2D)
      value
    end

    def parse(value : PostGIS::Point2D)
      SuccessfulCast(PostGIS::Point2D).new(value)
    end

    def parse(value : String)
      begin
        point_data = Hash(String, (UInt32 | Float64)).from_json(value)
        x = point_data["x"].as(Float64)
        y = point_data["y"].as(Float64)
        srid = point_data["srid"].as(UInt32)
        point = PostGIS::Point2D.new(x, y, srid)
        SuccessfulCast(PostGIS::Point2D).new(point)
      rescue
        FailedCast.new
      end
    end

    def to_db(value : PostGIS::Point2D)
      "POINT (#{value.x} #{value.y})"
    end

    class Criteria(T, V) < Avram::Criteria(T, V)
      include Avram::IncludesCriteria(T, V)
    end
  end
end
```

もっと詳しい方がいつか本格的なPostGIS用ライブラリを作ってくれたら、そちらに移行しようと思います...。

### LuckyでAuth0を利用するためのライブラリが無い
LuckyのAPIモードとAuth0を組み合わせるためのライブラリが見当たらなかったので、Luckyが標準で用意しているJWT認証機構の一部を書き換えて対処することにしました。

* 標準の`src/actions/mixins/api/auth/helpers.cr`を参考に`src/actions/mixins/api/auth/auth0_helpers.cr`を作る
* 標準の`src/actions/mixins/api/auth/require_auth_token.cr`を参考に`src/actions/mixins/api/auth/require_auth0_token.cr`を作る
* 標準の`src/actions/mixins/api/auth/skip_require_auth_token.cr` を参考に`src/actions/mixins/api/auth/skip_require_auth0_token.cr`を作る
* `src/actions/api_action.cr`でincludeする各モジュールを上記のAuth0対応版に差し替える

※Lucky標準の`User`モデルではなく、Djangoで生成した`auth_user`テーブルに対応する`AuthUser`というモデルを作って利用していることにご注意ください。

```crystal:src/actions/mixins/api/auth/auth0_helpers.cr
require "http"
require "http/headers"
require "json"
require "jwks"

def make_pub_keys(jwks)
  pub_keys = Hash(String, String).new
  unless jwks.values.nil?
    jwks.values.not_nil!.each do |key|
      pub_keys[key.kid] = key.to_pem
    end
  end
  pub_keys
end

module Api::Auth::Auth0Helpers
  alias Auth0JWKS = NamedTuple(kty: String, kid: String, use: String, n: String, e: String, x5c: String)

  class Auth0TokenAuthError < Exception
  end

  AUTH0_DOMAIN_URL    = "https://#{ENV["AUTH0_DOMAIN"]}"
  AUTH0_USER_INFO_URL = "#{AUTH0_DOMAIN_URL}/userinfo"

  @@jwks = JW::Public::KeySets.new("#{AUTH0_DOMAIN_URL}/.well-known/jwks.json")
  @@pub_keys : Hash(String, String) = make_pub_keys(@@jwks)

  def make_pub_keys
    pub_keys = Hash(String, String).new
    unless @@jwks.values.nil?
      @@jwks.values.not_nil!.each do |key|
        pub_keys[key.kid] = key.to_pem
      end
    end
    pub_keys
  end

  # The 'memoize' macro makes sure only one query is issued to find the user
  memoize def current_user? : AuthUser?
    auth_token.try do |value|
      user_from_auth_token(value)
    end
  end

  private def auth_token : String?
    bearer_token || token_param
  end

  private def bearer_token : String?
    context.request.headers["Authorization"]?
      .try(&.gsub("Bearer", ""))
      .try(&.strip)
  end

  private def token_param : String?
    params.get?(:auth_token)
  end

  # validate Auth0 access token.
  private def validate_token(token : String)
    begin
      header = JWT.decode(token: token, verify: false, validate: false)[1]
      raise Auth0TokenAuthError.new unless header["typ"].as_s == "JWT"
      algo = JWT::Algorithm.parse(header["alg"].as_s)
      raise Auth0TokenAuthError.new unless algo.as?(JWT::Algorithm::RS256)
      jwt_public_key_pem = @@pub_keys[header["kid"]]
      decoded_token = JWT.decode(token: token, key: jwt_public_key_pem, algorithm: algo, verify: true, validate: true)
      decoded_token
    rescue ex : Auth0TokenAuthError
      nil
    rescue ex : JWT::DecodeError
      @@jwks = JW::Public::KeySets.new("#{AUTH0_DOMAIN_URL}/.well-known/jwks.json")
      @@pub_keys = make_pub_keys(@@jwks)
      nil
    end
  end

  private def user_from_auth_token(token : String) : AuthUser?
    begin
      decoded_token = self.validate_token(token)
      return nil if decoded_token.nil?
      username = decoded_token[0]["sub"].as_s.sub("|", ".")
      user = AuthUserQuery.new.username(username).first?
      if user.nil?
        SaveAuthUser.create!(username: username)
      else
        return user
      end
    rescue e
      nil
    end
  end
end
```

```crystal:src/actions/mixins/api/auth/require_auth0_token.cr
module Api::Auth::RequireAuth0Token
  macro included
    before require_auth_token
  end

  private def require_auth_token
    if current_user?
      continue
    else
      json auth_error_json, 401
    end
  end

  private def auth_error_json
    ErrorSerializer.new(
      message: "Not authenticated.",
      details: auth_error_details
    )
  end

  private def auth_error_details : String
    if auth_token
      "The provided authentication token was incorrect."
    else
      "An authentication token is required. Please include a token in an 'auth_token' param or 'Authorization' header."
    end
  end

  # Tells the compiler that the current_user is not nil since we have checked
  # that the user is signed in
  private def current_user : AuthUser
    current_user?.as(AuthUser)
  end
end
```

```crystal:src/actions/mixins/api/auth/skip_require_auth0_token.cr
module Api::Auth::SkipRequireAuth0Token
  macro included
    skip require_auth_token
  end

  # Since sign in is not required, current_user might be nil
  def current_user : AuthUser?
    current_user?
  end
end
```

```crystal:src/actions/api_action.cr
# Include modules and add methods that are for all API requests
abstract class ApiAction < Lucky::Action
  # APIs typically do not need to send cookie/session data.
  # Remove this line if you want to send cookies in the response header.
  disable_cookies
  accepted_formats [:json]

  include Api::Auth::Auth0Helpers
  include Api::Auth::RequireAuth0Token

  # By default all actions are required to use underscores to separate words.
  # Add 'include Lucky::SkipRouteStyleCheck' to your actions if you wish to ignore this check for specific routes.
  # include Lucky::EnforceUnderscoredRoute
  include Lucky::SkipRouteStyleCheck
end
```

今回は取り急ぎ私のプロジェクト構成に依存するコードを書きましたが、余裕ができたら使い回せる部分を切り出してLucky用のAuth0ライブラリにしてみたいと思っています。

# まとめ
まだ移行作業が完了しておらず本番環境でのDjangoとの比較検証ができていないので、現時点ではあまり語れることが無いですね...。

移行が完了したら続編を書こうと思います。