::: 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をいただけたらとても嬉しいです😄
ブログ