DBへのREST APIを自動で生やすrailsプラグイン

::: message info このエントリーは[フィヨルドブートキャンプアドベントカレンダー2023 Part.1](https://adventar.org/calendars/9142)の25日目の記事です。 昨日は :@siroemk: @siroemkさんの[応用情報技術者試験に合格した時の勉強法](https://siroemk.hatenablog.com/entry/2023/12/24/180500)でした。 - [フィヨルドブートキャンプ Part 1 Advent Calendar 2023](https://adventar.org/calendars/9142) - [フィヨルドブートキャンプ Part 2 Advent Calendar 2023](https://adventar.org/calendars/9309) ::: 去年のアドベントカレンダーでこういうエントリーを書きました。 [Web APIを手作りする時代は終わった?](https://bootcamp.fjord.jp/articles/61) これが既存のrailsプロジェクトにマウントできて、複雑なAPIは手書きして、単純なCRUDは自動で作られたAPIを使うというやり方ができたら便利じゃないかということで、プラグインとして動いてDBのテーブルにREST APIを提供する[crazy\_train](https://github.com/komagata/crazy_train)というgemを[PostgREST](https://postgrest.org/en/stable/)を<s>パクって</s>オマージュして作りました。 [komagata/crazy\_train: Provides a RESTful API for database tables for your rails apps\.](https://github.com/komagata/crazy_train) ## 使い方 既存のrailsプロジェクトのGemfileに下記を追加して`$ bundler install`してください。 ``` gem 'crazy_train` ``` 下記を実行してください。 ```console $ rails generate crazy_train:install ``` 設定ファイルが生成されます。 ```ruby # config/initializers/crazy_train.rb: CrazyTrain.setup do |config| config.secret = 'xxxxxxxxxxxxxxxx' config.unauthorized_role = 'anonymous' config.authorized_role = 'authenticated' end ``` 同時にroutesに下記が追加されます。 ```ruby # routes.rb: Rails.application.routes.draw do mount CrazyTrain::Engine, at: '/api' end ``` crazy_trainはアクセス制御に[RLS](https://www.postgresql.org/docs/16/ddl-rowsecurity.html)を使います。 認証無しでアクセスするとデフォルトでは`anonymous`ROLEでDBにアクセスします。認証済みの場合は`authenticated`ROLEでアクセスします。 未認証(`anonymous`)の場合にも`posts`がSELECTできるように権限とRLSのPOLICYを設定しましょう。(既存のrailsプロジェクトに`posts`テーブルがあると想定) ```sql GRANT SELECT ON posts TO anonymous; ALTER TABLE posts ENABLE ROW LEVEL SECURITY; CREATE POLICY posts_policy ON posts TO anonymous USING (TRUE); ``` そうすると下記のようにREST APIを使うことができます。 ```console $ curl http://localhost:3000/api/posts [ { "id": 1, "content": "crazy" }, { "id": 2, "content": "train" } ] ``` 他のテーブルや他のROLE用の権限・POLICY設定もすれば同じようにAPIを使うことができます。(通常のrailsでのDBへのアクセスは`default`ROLEで行われるので影響はありません) 複雑なものは通常通り手動でWeb APIを作り、単純なCRUDで済むものはこれを使いましょう。 ## 認証 crazy_trainはJWTでの認証の仕組みを提供します。 `crazy_train:generate_token`タスクを使ってtokenを生成することができます。(secretはCrazyTrain.config.secretの値を使用します) ```ruby $ rails crazy_train:generate_token aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ``` tokenを使えば認証済みの状態(デフォルトでは`authenticated`ROLE)でAPIにアクセスできます。 ```console $ curl curl http://localhost:3000/api/posts/1234 \ -H "Authorization: Bearer aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" { "id": 1234, "content": "crazy" } ``` ### JWTベースのROLEなりすまし 他のROLE(例えば`admin`ROLE)を作って認証したい場合はどうすればいいでしょうか。 crazy_trainではJWTのpayloadに`role`という項目があればその中身をROLE名として使います。 admin ROLEで認証するためのtokenを作るには下記のようにします。 ```console $ rails crazy_train:generate_token PAYLOAD='{"role": "admin"}' bbbbbbbbbbbbbbbbbbbbbbbbbbb ``` このtokenを使ってAPIにアクセスした場合には`admin`ROLEとしてDBにアクセスします。 ```console $ curl curl http://localhost:3000/api/posts/1234 \ -H "Authorization: Bearer bbbbbbbbbbbbbbbbbbbbbbbbbbb" { "id": 1234, "content": "crazy" } ``` ### 個別のユーザーの識別 「自分の投稿したpostsだけは閲覧できる」といったようなPOLICYの作成に個別のユーザーの識別が必要な場合はどうすればいいでしょうか。 crazy_trainではJWTのpayloadに含めたものはpostgresqlのユーザー定義設定パラメータを通じて取得できます。 下記をpayloadに指定した場合、 ``` { "user_id": 1234 } ``` DBの中では`current_setting`を使って`request.jwt.claims`から取得することができます。 ``` SELECT current_setting('request.jwt.claims', true)::json->>'user_id'; # => 1234 ``` 「自分の投稿したpostsだけは閲覧できる」というPOLICYは下記のように書くことができます。 ``` CREATE POLICY posts_policy ON posts USING (user_id = current_setting('request.jwt.claims', true)::json->>'user_id'); ``` ### 独自のユーザー認証 一般的なWebアプリを想定した場合、ユーザー認証には様々なパターンがあるため、JWTのtokeを生成するためのAPIは自前で用意しましょう。 #### emailとpasswordで認証する場合の例 下記のようにpayloadに含まれる値も自前で好きなものを用意しましょう。 (説明のために簡略化して書いています。下記のコードをそのまま使わないでください) ```ruby # app/controllers/taken_controller.rb: class TokenController < ApplicationController def create user = User.find_by( email: params[:email], password: params[:password] ) if user token = CrazyTrain::JWT.encode( 'role' => user.role, 'user_id' => user.id ) render json: { token: token } else head :unauthorized end end end ``` ## API 下記のようにDBに存在するテーブルに対してCRUDが一通りできるようになります。 | HTTP Verb | URL | Description | | --------- | --- | ----------- | | GET | `/api/posts` | List | | GET | `/api/posts/1234` | Read | | POST | `/api/posts` | Create | | PATCH | `/api/posts/1234` | Update | | DELETE | `/api/posts/1234` | Delete | ### ソート `order`パラメータを使ってソートが行えます。カラム名とソート順序は`.`で区切られ、複数のルールは`,`で区切られます。 ```console $ curl http://localhost:3000/api/posts?order=content.asc,id.desc [ { "id": 2, "content": "train" }, { "id": 1, "content": "crazy" } ] ``` ## これから まだ作り始めたばかりで機能が全然足りないです。作ろうとしてる機能をIssueに登録しつつ作っていこうと思いますので、使ってみた感想やPRをいただけたらとても嬉しいです😄