はじめに
本記事はCrystalアドベントカレンダー2023の12月18日分の記事です。
本当は記事の公開までに移行を完了させたかったのですが間に合わなかったので、途中経過のご報告となります...。
経緯
今回移行するプロジェクトは、私が2020年に趣味で開発したサービスのバックエンドAPIです。
下記の技術スタックで構成されています。
- 言語: Python
- フレームワーク: Django REST Framework
- DB: PostgreSQL(地理情報処理のための拡張機能であるPostGISを有効にしています)
- 認証: Auth0
当初の計画では地理情報処理に重点を置いていたため、この分野に優れるPythonとDjangoを選定したのですが、結果的には地理情報処理の出番は少なかったので、あまり好きではないPythonを使い続ける理由が減りました。
強制力の働かない趣味プロジェクトのメンテを継続するには好きな言語を使わないとツラいと思い始めていたこともあり、思い切って私が一番好きな言語であるCrystalに移行することに決めた次第です。
Luckyを選んだ理由
Crystal用のWebアプリケーションフレームワークは多数ありますが、中でもメジャーなのは下記の3つです。
Kemal
マイクロフレームワークです。RubyのSinatraに似ています。
Amber
フルスタックフレームワークです。Railsの影響が色濃いです。
Lucky
フルスタックフレームワークです。型安全と速度を重視しています。
今回Luckyを選んだ理由は下記の通りです。
- あまり時間を割けないため、レールの敷かれたフレームワークが良い(Kemalが候補から外れる)
- Amberはパフォーマンスが悪く、近年開発がやや不活発
- Luckyは高速な上に標準ORMのAvramで型安全なクエリを書くことができ、開発が活発で企業によるプロダクション投入例も複数ある
Djangoに強く影響されたMartenも候補に上がりましたが、まだ若いフレームワークでプロダクション投入例もおそらく無いため、今回は見送ることにしました。
2023年版のFortunesベンチマークの結果をフルスタックでORM付きの高機能なフレームワークに絞ると、Luckyの成績は3位となります。機能の豊富さと高速な実行を両立できていることが分かりますね。
本記事ではDjangoからの移行にあたって困ったことに話を絞りますので、Luckyの基本については公式ガイドをご参照ください。
基本方針
- LuckyはAPIモードで利用し、フロントエンドには干渉しない
- Djangoで作ったテーブルをそのまま利用する(モデル作成時にマイグレーションでテーブルを生成せず、既存テーブルとモデルをマッピングする)
困ったこと
PostGISを利用するためのライブラリが無い
LuckyでPostGISのデータ型を使えるようにするライブラリが見当たりませんでした。
Luckyの標準ORMであるAvramがPostgreSQLを操作するために内部で利用しているwill/crytal-pgには、PostGISのデータも扱えるようにするjgaskins/postgisという拡張があるものの、ここ2年ほどメンテされていないようで最新のcrystal-pgと組み合わせるとエラーが出ます。
暫定的な対処として、まずjgaskins/postgisからフォークしたfujikawahiroaki/postgisで最新のcrystal-pgでも動くように改修し、さらにLuckyのモデルおよびマイグレーションから使えるようにするやっつけ仕事なライブラリfujikawahiroaki/lucky_postgisを作りました。
lucky_postgisのソースの中核部分はこんな感じです。Luckyで使いたいDBデータ型を増やす時の参考になれば幸いです。
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
というモデルを作って利用していることにご注意ください。
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
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
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
# 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との比較検証ができていないので、現時点ではあまり語れることが無いですね...。
移行が完了したら続編を書こうと思います。