#pyconjp PyCon JP 2022に参加しました

10/14(金)・15(土)に、TOC有明コンベンションホールで開催された「PyCon JP 2022」に参加しました。
PyCon JP 2022

 
リアル参加は2019年以来でした。無事に参加できてよかったです。
#pyconjp PyCon JP 2019に参加しました & 発表しました - メモ的な思考的な

今年も動画はすでに公開されています。ありがとうございます。
PyCon JP 2022 - YouTube

ここでは参加したメモを残します。誤りがありましたらご指摘ください。

 
目次

 

参加したトーク

今年は両日ともお昼ごはんから参加しました。

トークの資料は PyCon JP 2022のタイムテーブル からリンクが貼られています。ただ、後で自分が見返しやすいよう、このブログでもリンクを貼っておきます。

 

1日目

Sphinxを通して考える、「拡張」の仕方

Sphinxを通して考える、「拡張」の仕方 / First approach for development sphinx extension - Speaker Deck
Kazuya Takei (attakei) 氏

 
Sphinxの拡張はどこまでできるのかが気になって参加しました。

トークでは、拡張できる箇所として

  • 入力
  • 出力
  • 内部

に加え、イベントも追加できるとの解説がありました。

かなり柔軟に拡張できるような印象を受けるとともに、Sphinxがその柔軟さをどのように実現しているのかが気になりました。そのため、どこかでソースコードを読んでみたくなりました。

 

イベント駆動アーキテクチャについて

Event-driven architecture - Speaker Deck
Masataka Arai 氏

 
「イベント駆動アーキテクチャ」についてきちんと理解したかったため参加しました。

トークでは、イベント駆動アーキテクチャとして

  • Callback
  • Subject
  • Topic

があるという基本的なところから、キューの監視やリトライなどの運用についても解説がありました。

また、今回のイベント駆動アーキテクチャの考え方は、Python以外の環境でも役立ちそうでした。そこで、再度発表資料を読んだり、参考資料にあった「エキスパートPythonプログラミング 改訂3版」を読もうと思いました。

 

Python3.11新機能asyncio.TaskGroup()と2022年asyncioの"Hello-ish world"

Python3.11新機能asyncio.TaskGroup()と2022年asyncioの"Hello-ish world" - Speaker Deck
Junya Fukuda 氏

 
asycio まわりは全然追っていないため、どんな機能が追加されるのか気になって参加しました。

トークは 3.10と3.11での書き方を比べながらの解説がとてもわかりやすかったです。 asyncio.TaskGroup は例外・キャンセルを扱う時の実装でお世話になりそうだなーと思いながら聞いていました。

 
あと、発表前の接続トラブルにも焦ることなく発表している姿が印象に残りました。すごい。

 

Fast API と学ぶ WebRTC

20221014_Fast API と学ぶ WebRTC - Google スライド
Takayuki Kawazoe 氏

 
WebRTC という単語は聞くものの、実際にどんなものなのかを知りたくて参加しました。

トークでは、「WebRTCは Web Real-Time Communication 用のAPI群」という説明から始まり、各技術要素の解説や、Fast APIを使ったデモまでありました。

また、Pythonで WebRTC を扱う場合は aiortc がほぼ一択と分かったこともありがたかったです。
aiortc/aiortc: WebRTC and ORTC implementation for Python using asyncio

 

AST(Abstract Syntax Tree)に入門する

PyCon JP 2022/ASTに入門する - Speaker Deck
安本雅啓 氏

 
以前 astモジュールを扱ったこともあり、AST について理解を深めようと思い参加しました。

トークでは

  • CSTとASTの違い
  • PythonのAST
  • astモジュール

のそれぞれについて解説があり、とてもありがたかったです。

また、

  • darglint
    • docstringと実装の間で違いがあるかを検知するlinter
  • LibCST
    • Instagramの開発チームが作った、CST + AST + αな構文木を構築するライブラリ

の紹介もあり、色々すごいものがあるんだなーと思いました。

 

LT

  • プレゼン用PCの電源が落ちるハプニングがあっても続けられるLT
  • 電車への情熱があふれるLT

など、リアルで開催されるカンファレンスならではのLTという感じがあって楽しかったです。

 

2日目

Security Best Practices for Django Applications

Gajendra Deshpande 氏

 
Djangoのセキュリティまわりが気になったため参加しました。

トークでは、セキュリティ面で気にする事項とDjangoではどう対応すればよいかの解説がありました。

セキュリティに関するパッケージなど、いろいろな情報がスライドに色々記載されていたため、あとからスライドを見直したいと思いました。

 

Python Social Authで学ぶ、OAuth2.0認可コードフローにおける異常系への対処

Python Social Authで学ぶ、OAuth2.0認可コードフローにおける異常系への対処 - Speaker Deck
Yuuki Takahashi 氏

 
OAuth2.0の異常系がPython Social Authでどのように実装されているのか気になったので参加しました。

トークでは

  • Python Social Authの異常系を多くの例外として表していること
  • 各例外がどの異常系なのか、それぞれ解説があったこと

があり、とてもわかりやすかったです。

また、異常系の中でも注意するものとそうでないものの解説もありました。実際に運用してみた上での解説だと思うので、もし異常系を扱うときには参考になりそうです。

 

続・絵を読む技術 Pythonで読むイラストの心理戦略

続・絵を読む技術 Pythonで読むイラストの心理戦略 / The Art of Reading Illustrations 2nd - Speaker Deck
ひろさじ / Hirosaji 氏

 
前職で絵師さんが近くにいたこともあり、絵師さんにはどんな心理戦略があるんだろうと気になったため参加しました。

トークではイラストにおける「なにを伝えるか」について、それぞれ論理的な解説がありました。これを行っている絵師さん、改めてすごい思ったのでした。

また、各戦略についてPythonでさくっと分析していたのも印象に残りました。

他に、資料の最後の方に記載されているreferenceも充実しており、イラストやデザインまわりで気になることがあれば参照しようと思いました。

 

情報システム部門の業務におけるPythonの活用

情報システム部門の業務におけるPythonの活用 - Keito FUKUSHIMA - PyCon JP 2022 - Google スライド
Keito FUKUSHIMA 氏

 
情報システム部門ではどのようにPythonを活用しているのか気になったため参加しました。

スライドの冒頭でLANケーブル敷設の写真があり、「昔やったなー」と懐かしくなりました。

トークでは、社内でお使いの

について、Pythonスクリプトを書いて色々効率化している姿が分かりました。

また、質問コーナーで「GASでなくPythonを使っている理由は?」という質問に対し、「Pythonだと社内の有識者からレビューを受けられる」との回答がありました。たしかに社内でPython使いが多いのであれば、情報システム部門で使うものもPythonで書いた方が保守しやすくなるので、良いなーと思いました。

 

会場で見たネットワーク機器

一日目で会場を散策していたところ、ネットワーク機器が置いてあるとともに、ネットワーク図も公開されていました。

「ほー」とながめていたところ、スタッフの方から解説いただけました。ラズパイに乗ったRHEL 9.0でトラフィックを監視しているようでした。すごい。

 
二日目もネットワーク機器の元へ行ってみたところ、トラフィック監視の結果が分かりやすくグラフ化されていました。あっという間に改良されていてすごいと思いました。

 
カンファレンスのネットワークを支えていただき、ありがとうございました。

 

全体の感想

長い期間お会いできていなかった方々と直接お話できました。近況報告などお互い元気にやっていることがわかったのでよかったです。

また、レビューへ参加させていただいた書籍「Python実践レシピ」の著者のみなさまと直接お会いしたりお話したり、サインをもらったりしてました。こちらも嬉しかったです。

他にもご紹介いただいた方々と直接お話できるなど、Pythonが好きな人たちと交流を深められるので、リアル開催は本当にありがたいと感じました。

 
最後になりましたが、PyCon JP 2022をリアル開催として運営してくださったみなさま、参加者のみなさま、ありがとうございました。

Pundit + Rolify を使って、Rails製APIアプリでロールによる認可制御を行ってみた

Rails + ロールでの認可制御を調べたところ、以下のページに認可制御のgemがまとまっていました。
Category: User Authorization - The Ruby Toolbox

まずは一番Githubのstarが多いgemからみてみようということで、 Pundit をさわってみることにしました。
varvet/pundit: Minimal authorization through OO design and pure Ruby classes

 
ただ、PunditのREADMEを読んでみたものの、ロールを使った認可制御は記載されていませんでした。

そのため、ロール機能だけを扱えるgemがないかを上記ページで見たところ、 Rolify がありました。
RolifyCommunity/rolify: Role management library with resource scoping

RolifyのREADMEを読むと

This library can be easily integrated with any authentication gem (devise, Authlogic, Clearance) and authorization gem* (CanCanCan, authority, Pundit)

https://github.com/rolifycommunity/rolify

とあり、Punditと組み合わせて使えそうでした。

 
そこで、Pundit + Rolify を使って、RailsAPIアプリでロールによる認可制御を行ってみた時のメモを残します。

なお、記事が長いので、あらかじめソースコードのありかを記載しておきます。
https://github.com/thinkAmi-sandbox/rails_with_pundit-sample

 
目次

 

環境

  • Rails 7.0.4
    • APIモードで作成
  • Pundit 2.2.0
  • Rolify 6.0.0
  • WSL2上のUbuntuに構築

なお、動作確認にはWSL2上のターミナルで curl を使って行います。

Windowsにも curl はありますが、 " や日本語の扱いに難があります。

 

アプリ(密書管理システム) を作ってPundit +Rolify を理解する

PunditやRolifyの使い方については、色々と記事があり参考になります。

 
また、企業ブログの中でも登場したりします。

 
ただ、上記資料を読んだだけでは理解しきれませんでした。

そこで、具体的な認可制御のイメージをうかべながら理解するために、ストーリーに基づき Pundit + Rolify アプリを作り進めていくことにしました。

 
今回は「edo時代のbo藩が密書を管理するシステムを作る」というストーリーでアプリを作っていきます。

edo時代のbo藩では藩内システムをRailsで内製していました。

 

そんな中、他藩で密書がもれるという事件が起こりました。この事件を重くみたbakufuは全藩に「密書を適切に管理していない藩は取り潰す」とのおふれを出しました。

 

そのおふれを受け取ったbo藩が、密書管理システムの構築を検討し始めるというところから物語は始まります。

 

やらないこと

認証機能をしっかり作ること

今回の目的は認可制御なので、認証機能についてはまともに作らないことにしました。

つまり

  • 認証機能にDeviseは使わない
  • 今回はAPIアプリでは、Basic認証にする
    • Authorizationヘッダにユーザーとパスワードを入れる
    • そのユーザーとパスワードの組み合わせで認証する
  • ユーザーとパスワードは users テーブルに生データとして入れる
    • データベースがクラックされたらパスワードが流出する

という超簡易的なものにしました。

本番運用するには完全に不適切ですが、今回は認可機能に集中するため、あえてこのような構成にしています。

 

認証機能がないRails APIアプリの作成

ストーリー

bakufuのおふれを受けて、bo藩の家老たちが会議室に集まりました。

 

bo藩では、密書管理システムをRailsでアプリを作ることにしました。今まで内製してきたRailsの資産を活かすためです。

 

また、密書なのでリッチな画面は必要とせず、APIエンドポイントを用意してデータを取り出せれば良いと考えました。

 

以上により、システムの概要が決まったため、まずは密書データを読み書きできる機能から作り始めることとしました。

   

アプリの作成

セットアップ

APIエンドポイントを用意するだけなので、今回はAPIモードでrails newします。

# APIモードで rails new する
$ bundle exec rails new . --api --skip-bundle

$ bundle install

 

実装

POSTとGETを追加します。

モデル SecretMessage (密書) を作ります。

$ bin/rails g model SecretMessage title:string description:text
      invoke  active_record
      create    db/migrate/20221001100112_create_secret_messages.rb
      create    app/models/secret_message.rb
      invoke    test_unit
      create      test/models/secret_message_test.rb
      create      test/fixtures/secret_messages.yml

 
マイグレーションを実行します。

$ bin/rails db:migrate
== 20221001100112 CreateSecretMessages: migrating =============================
-- create_table(:secret_messages)
   -> 0.0012s
== 20221001100112 CreateSecretMessages: migrated (0.0013s) ====================

 
コントローラを作ります。

今のところ、密書の登録と一覧表示ができればよいので、 indexcreate のみを用意します。

class Api::SecretMessagesController < ApplicationController
  def index
    render json: SecretMessage.all
  end

  def create
    SecretMessage.create(create_params)
  end

  private def create_params
    params.permit(:title, :description)
  end

 
ルーティングを追加します。

Rails.application.routes.draw do
  namespace :api do
    resources :secret_messages, only: [:index, :create]
  end
end

 

動作確認

WSL2上でRailsアプリを起動します。

次に、WSL2上のターミナルにて、curlで密書を作成します。

$ curl -v -X POST -H "Content-Type: application/json; charset=UTF-8" -d '{"title":"テスト","description":"テストの密書です"}' http://127.0.0.1:3000/api/secret_messages

 
続いて、作成したデータを取得します。

$ curl http://127.0.0.1:3000/api/secret_messages

[{"id":1,"title":"テスト","description":"テストの密書です","created_at":"2022-10-01T10:52:26.415Z","updated_at":"2022-10-01T10:52:26.415Z"}]

 
意図した通りに動作しました。第一歩としては成功です。

 

ここまでのコミット

https://github.com/thinkAmi-sandbox/rails_with_pundit-sample/pull/1/commits/55693150dce3f709878ea0a16ada50983f8d1f16

 

Basic認証を使い、認証されたユーザーのみデータを取得可能にする

ストーリー

密書の作成・閲覧機能ができたので、次は認証機能を組み込むことにしました。

 

edo時代では「Basic認証ができれば十分である。パスワードも生で保存すればよい」という雰囲気だったため、認証機能としてRailsBasic認証を使うことにしました。

※現実では、今回のようなシステムの認証にBasic認証を採用してはいけません
※現実では、パスワードを生で保存してはいけません

 

アプリの作成

認証用のUserモデルを作成する

ユーザー名とパスワードの情報を保存するためのUserモデルを作成します。

$ bin/rails g model User name:string password:text

      invoke  active_record
      create    db/migrate/20221001110542_create_users.rb
      create    app/models/user.rb
      invoke    test_unit
      create      test/models/user_test.rb
      create      test/fixtures/users.yml

 
生成されたマイグレーションファイルを適用します。

$ bin/rails db:migrate
== 20221001110542 CreateUsers: migrating ======================================
-- create_table(:users)
   -> 0.0010s
== 20221001110542 CreateUsers: migrated (0.0011s) =============================

 

Basic認証用の authenticate_or_request_with_http_basic をコントローラに組み込む

Railsには、Basic認証を行うためのメソッド authenticate_or_request_with_http_basic があります。

 
密書を管理するAPIエンドポイントにはすべてBasic認証を組み込みたいため、各コントローラの親コントローラを用意します。

また、今回はAPIモードでrails newしたため、 ActionController::HttpAuthentication::Basic::ControllerMethods をincludeしています。

class BasicAuthController < ApplicationController
  include ActionController::HttpAuthentication::Basic::ControllerMethods

  before_action :basic_auth

  def basic_auth
    authenticate_or_request_with_http_basic do |username, password|
      User.exists?(name: username, password: password)
    end
  end
end

 
APIエンドポイントのコントローラでは、親となるクラスを差し替えます。

class Api::SecretMessagesController < BasicAuthController
  # ...
end

 

curlで動作確認

動作確認用にUserモデルにデータを登録します。

name password
karo pass

 
Basic認証情報がない場合、エラーになります。

$ curl http://127.0.0.1:3000/api/secret_messages
HTTP Basic: Access denied.

 
一方、Basic認証情報を付与してあげると、データの取得ができます。

$ curl -u karo:pass http://127.0.0.1:3000/api/secret_messages
[{"id":1,"title":"テスト","description":"テストの密書です","created_at":"2022-10-01T10:52:26.415Z","updated_at":"2022-10-01T10:52:26.415Z"}]

 

ここまでのコミット

https://github.com/thinkAmi-sandbox/rails_with_pundit-sample/pull/1/commits/217ee2136724d0c0c2b4070fea8e77697b210682

 

Punditを使って、自分の作成した密書のみ更新可能にする

ストーリー

ここまでで、密書の作成と読み取りはできるようになりました。

 

しかし、今のままでは一度作成した密書は変更できなくて不便です。そこで変更機能を追加することにしました。

 

ただ、家老たちからは「自分が作った密書を他人に書き換えられると困る」という話が出ました。

 

そのため、「作成者本人のみ密書を更新できる」という認可制御を追加することにしました。

 

続いて、認可制御をどのように実現するかを話し合った結果、 Pundit gemを使うことにしました。
varvet/pundit: Minimal authorization through OO design and pure Ruby classes

 

また、今後モデルがちょっと複雑になるかもしれないと考え、Annotate gemでモデルの属性をコメント化するようにしました。
ctran/annotate_models: Annotate Rails classes with schema and routes info

 

アプリの作成

PunditとAnnotateをセットアップ

Gemfileに追加します。

gem 'pundit'

group :development do
  gem 'annotate'
end

 
bundle installと、各gemのinstallを行います。

$ bundle install

$ bin/rails g pundit:install
      create  app/policies/application_policy.rb

$ bin/rails g annotate:install
      create  lib/tasks/auto_annotate_models.rake

 

SecretMessageモデルに、作成者として user の別名で owner を追加

密書に対する 作成者本人 を特定するため、SecretMessageモデルに owner を追加します。

ownerは

  • 外部キーとして、Userの id を参照
  • NOT NULL制約を追加

とします。

NOT NULL制約を追加した場合、密書を作成した user を削除したくなったら困るかもしれません。ただ、今のところbo藩では削除は想定していないため、NOT NULL制約とします。

ここで、前回の動作確認で作成した密書にはowner情報がないため、NOT NULL制約は付与できません。

そのため、本来であれば

  1. nullableでownerを追加
  2. 移行用のrake taskを作成し、既存の密書にownerの値をセット
  3. ownerをNOT NULL にする

という手順が必要そうです。

ただ、まだ登録済の密書が少ないため、今回は全データを削除してマイグレーションを適用することにしました。

 
まず、SecretMessageモデルの全データを削除した後、「NOT NULL制約なownerを追加する」マイグレーションを作成します。

$ bin/rails g migration AddOwnerToSecretMessage
      invoke  active_record
      create    db/migrate/20221001234046_add_owner_to_secret_message.rb

 
ここで、Railsのデフォルトでは、外部キー名が参照先のモデル名 (今回の場合 user) になります。

ただ、 user という単語だと作成者かどうかが分かりづらいため、外部キー名を別名の owner にしたいです。

そこで、以下を参考に、外部キーに別名を設定するマイグレーションにします。
Railsで別名の外部キーを設定する方法 - Qiita

class AddOwnerToSecretMessage < ActiveRecord::Migration[7.0]
  def change
    add_reference :secret_messages, :owner, foreign_key: { to_table: :users }
  end
end

 
マイグレーションファイルができたので、適用します。

$ bin/rails db:migrate
== 20221001234046 AddOwnerToSecretMessage: migrating ==========================
-- add_reference(:secret_messages, :owner, {:foreign_key=>{:to_table=>:users}})
   -> 0.0085s
== 20221001234046 AddOwnerToSecretMessage: migrated (0.0086s) =================

 
外部キーが作成されたので、次はモデルでの関連付けを行います。

今のところ、 SecretMessage → User の方向でしか参照を行わないため、SecretMessageモデルにのみ関連付けを追加します。

また、 class_name オプションを使い、 owner という関連を使った場合は User モデルを見るよう設定します。
4.1.2.3 :counter_cache | Active Record の関連付け - Railsガイド

class SecretMessage < ApplicationRecord
  belongs_to :owner, class_name: 'User', foreign_key: :owner_id
end

 

ownerにデータをセット

SecretMessageモデルに owner を追加できたので、次は owner に値を設定できるようにします。

owner の値は、各HTTPリクエストのBasic認証で渡される username を元に、Userモデルから取得します。

そこで、Basic認証を行っている処理に機能を追加し、 current_user という attr_reader でUserモデルの情報を保持します。

class BasicAuthController < ApplicationController
  # ...
  attr_reader :current_user  # 追加
  # ...
  def basic_auth
    authenticate_or_request_with_http_basic do |username, password|
      @current_user = User.find_by(name: username, password: password) # 保持するよう変更

      return @current_user.present?
    end
  end
end

 
次に、 create メソッドにて、 current_user から取り出したUserをセットするようにします。

class Api::SecretMessagesController < BasicAuthController
  def index
    render json: SecretMessage.all
  end

  def create
    SecretMessage.create(create_params)
  end

  private def create_params
    params.permit(:title, :description).merge(owner: current_user)
  end
end

 
ownerに値が設定できるようになったため、curlで動作確認します。

まずは密書を作成します。

curl -X POST -H "Content-Type: application/json; charset=UTF-8" -u karo:pass http://127.0.0.1:3000/api/secret_messages -d '{"title":"テスト","description":"密書"}'

 
次に、作成した密書を読み込みます。 owner に値が設定されていることが確認できました。

$ curl -u karo:pass http://127.0.0.1:3000/api/secret_messages

[{"id":1,"title":"テスト","description":"密書","created_at":"2022-10-02T01:22:09.742Z","updated_at":"2022-10-02T01:22:09.742Z","owner_id":1}]

 

Punditを使い、密書の更新はownerのみ可能にする

Punditを使った認可は、 Policy を元に制御されます。

そこで、PunditのREADMEや以下の記事を参考に、PunditのPolicyを作っていきます。

 

Policyを作成

rails g pundit:install 時に app/policies/application_policy.rb として ApplicationPolicy が生成されています。このPolicyでは各アクションの認可は不可となっています。

そこで、 ApplicationPolicy を継承して、今回のPolicyを作成します。

 
まずは policies/api/secret_message_policy.rb ファイルを作成します。

このPolicyが動く時点では認証制御は終わっているため、認可制御だけ記載します。

今回は

  • indexとcreateは、認証されていれば誰でもOK
  • updateは、対象密書の owner が、HTTPリクエストしてきたユーザーと一致していればOK

とします。

なお、PunditのREADMEには

In your controller, Pundit will call the current_user method to retrieve what to send into this argument

とあります。

そのため、先ほど作成した current_user メソッドの戻り値が Api::SecretMessagePolicy インスタンスuser へと設定されています。

class Api::SecretMessagePolicy < ApplicationPolicy
  def index?
    true
  end

  def create?
    true
  end

  def update?
    record.owner == user
  end
end

 

認可制御もれを防ぐため、verify_authorizedを実装

作成したPolicyをコントローラで適用するには authorize メソッドを使うことになります。
https://github.com/varvet/pundit#policies

ただ、うっかり authorize を実行し忘れると大変なことになります。

そこで、作成したPolicyをコントローラで適用し忘れないような仕組みとして、Punditには verify_authorized があります。

Pundit has a handy feature which reminds you in case you forget. Pundit tracks whether you have called authorize anywhere in your controller action. Pundit also adds a method to your controllers called verify_authorized. This method will raise an exception if authorize has not yet been called. You should run this method in an after_action hook to ensure that you haven't forgotten to authorize the action.

https://github.com/varvet/pundit#ensuring-policies-and-scopes-are-used

ただ、

This verification mechanism only exists to aid you while developing your application, so you don't forget to call authorize. It is not some kind of failsafe mechanism or authorization mechanism. You should be able to remove these filters without affecting how your app works in any way.

とあるように開発時向けの機能であるため、今回はコントローラ本体に実装するのではなく、

  • app/controllers/concerns/pundit_authorizable.rb というConcernで作成
    • verify_authorized は includedとして、includeされた時に動作するように指定
  • コントローラ本体ではConcernをinclude

とします。

module PunditAuthorizable
  extend ActiveSupport::Concern

  include Pundit::Authorization

  included do
    after_action :verify_authorized
  end
end

 

Policy Namespacingを実装

今回のコントローラは app/controllers/api ディレクトリの中に入れるため、Railsのデフォルトからは一階層深い位置にコントローラが置かれています。

ただ、Punditのデフォルトでは

  • app/controllers/ の下にコントローラがある
  • app/policies/ の下にPolicyがある

という前提のため、今回のような構造ではPolicyを発見できません。

そこで、Punditの Policy Namespacing という機能を使います。

authorize([:admin, post]) # => will look for an Admin::PostPolicy

のように authorize メソッドの第一引数に配列を渡せば良いようです。

また、毎回配列を渡すのが手間なので、 authorize メソッドを使う場合は常に api ディレクトリの下を探すよう指定します。

これも認可本体の処理とは関係ないので、Concernに実装しておきます。

module PunditAuthorizable
  extend ActiveSupport::Concern

  include Pundit::Authorization

  # ...
  def authorize(record, query = nil)
    super([:api, record], query)
  end
end

 

BasicAuthControllerにPunditAuthorizable Concernを組み込む

PunditAuthorizableBasic認証をしている全コントローラで動作してほしいため、 BasicAuthController に組み込みます。

なお、Basic認証後にConcernの before_action が動作してほしいため、Concernをincludeするのは before_action :basic_auth の後とします。
Rails: includeされた時にクラスメソッドとインスタンスメソッドを同時に追加する頻出パターン | TECHSCORE BLOG

class BasicAuthController < ApplicationController
  attr_reader :current_user

  include Pundit::Authorization

  # APIモードで作ったのでincludeが必要
  include ActionController::HttpAuthentication::Basic::ControllerMethods

  before_action :basic_auth

  # basic_authの後に PunditAuthorizable 内の before/after が動いてほしいので
  # この位置でinclude
  include PunditAuthorizable

  # ...
end

 

コントローラで authorize または skip_authorization メソッドを追加する

ここまでで update メソッドを定義するための準備ができたため、最後にコントローラを実装します。

なお、Punditで認可制御するためには各メソッドで authorize メソッドを呼びます。

 
また、「認証制御は必要だけど、認可制御は不要」なメソッド、今回の場合だと indexcreate に対して authorize メソッドを定義しないと verify_authorized に引っかかってしまいます。

回避するには

のどちらかを実装します。

exceptonly だと範囲が広すぎる気がするので、今回は個々のコントローラメソッド ( indexcreate ) で skip_authorization を呼ぶこととします。

class Api::SecretMessagesController < BasicAuthController
  def index
    skip_authorization # 認証OKなら誰でも見れる
    render json: SecretMessage.all
  end

  def create
    skip_authorization # 認証OKなら誰でも作れる
    SecretMessage.create(create_params)

    render status: :created
  end

  def update
    record = SecretMessage.find_by(id: params[:id])
    authorize record

    SecretMessage.update(update_params)

    render status: :no_content
  end

  private def create_params
    params.permit(:title, :description)
    params.permit(:title, :description).merge(owner: current_user)
  end

  private def update_params
    params.permit(:id, :title, :description)
  end
end

 

Policyに基づく認可エラー時の例外送出に対処する

Punditでは、Policyに基づく認可エラーの場合、例外 Pundit::NotAuthorizedError を送出します。
https://github.com/varvet/pundit#rescuing-a-denied-authorization-in-rails

この例外を捕捉しない場合、RailsはHTTP500エラーをHTTPリクエスト元に返します。

 
HTTP500エラーではなく403などで返したい場合、

  • Railsの仕組み rescue_from を使って捕捉して対応する
  • Punditの一括設定で対応する

な実装となります。

今のところ、Basic認証のときだけPunditを使っていることから、Basic認証用コントローラで rescue_from して例外を捕捉し、403エラーへと変換します。

class BasicAuthController < ApplicationController
  attr_reader :current_user

  include Pundit::Authorization
  rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized  # 追加

  # ...

  # 追加
  private def user_not_authorized
    render status: :forbidden
  end
end

 

curlで動作確認

今回は owner のみ密書の更新ができることを確認します。

まずは、別のユーザーを追加します。

name password
another_karo pass

 
次に、ownerが更新できるかを確認します。

既存の密書は karo ユーザーであれば更新できるため、試してみます。

$ curl -X PUT -H "Content-Type: application/json; charset=UTF-8" -u karo:pass http://127.0.0.1:3000/api/secret_messages/1 -d '{"title":"本人","description":"更新"}'

とすると更新できました。

 
続いて、ownerでない another_karo ユーザーで確認します。

$ curl -X PUT -H "Content-Type: application/json; charset=UTF-8" -u another_karo:pass http://127.0.0.1:3000/api/secret_messages/1 -d '{"title":"他人","description":"どうなる"}'

とすると、403エラーになりました。

なお、indexメソッドについては認可制御をしていないため

$ curl -u another_karo:pass http://127.0.0.1:3000/api/secret_messages/

だとデータの取得ができます。

 

RSpecによるrequest specで動作確認する

そろそろ curl での動作確認がつらくなってきました。

そこで、RSpec + factory_bot_rails によるテストコードを書きます。

 
Punditでは policy に対するテストコード(Policy spec)を書くことができます。
Policy Specs | varvet/pundit: Minimal authorization through OO design and pure Ruby classes

ただ、今回はcurlでやっていることと同等のテストコードとしたいことから、request specでテストコードを書いていくことにします。

 

セットアップ

Gemfileに追加します。

なお、READMEより、Rails7系に正式対応している rspec-rails は6系になります。

rspec-rails 6.x for Rails 6.1 or 7.x.

https://github.com/rspec/rspec-rails

 
ただ、まだ rspec-rails の6系はRCであり、正式リリースがなされていません。
RSpec Rails 6.0 Release Plan · Issue #2560 · rspec/rspec-rails

そこで、rspec-railsはバージョンを固定してGemfileに記載します。

group :development, :test do
  # Rails7系のため、rspec-railsは6系にする
  gem 'rspec-rails', '6.0.0.rc1'
  gem 'factory_bot_rails'
end

 
記載後、 bundle install します。

bundle install後、RSpecのセットアップを行います。

$ bin/rails generate rspec:install
      create  .rspec
      create  spec
      create  spec/spec_helper.rb
      create  spec/rails_helper.rb

 

rspec-railsBasic認証付きのテストコードを書く方法について

今回、認証はBasic認証を使っています。

そのため、テストコードの中でもBasic認証用のAuthorizationヘッダを設定してあげる必要があります。

ただ、ヘッダの設定は手間がかかるため方法を探したところ、gistsに記載がありました。

fwilkens commented on 13 Sep 2018

Working with request specs with rspec-rails 3.x (3.7.2 in my case), you can pass the auth in the headers:

get '/path', headers: { 'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials(username, password) }

https://gist.github.com/mattconnolly/4158961?permalink_comment_id=2705188#gistcomment-2705188

 
今回はこのやり方でAuthorizationヘッダを設定します。

 

factory_botによるfactoryを作成

SecretMessage用とUser用のfactoryを作成します。

SecretMessage用

FactoryBot.define do
  factory :secret_message do
    title { 'タイトル' }
    description { '説明' }
  end
end

User用

FactoryBot.define do
  factory :user, aliases: [:owner] do
    name { 'foo' }
    password { 'password' }
  end
end

 

テストコードを書く

今まで作成した index/create/update の各機能のテストコードを書きます。

すべて載せると長いので、ここではupdate用のみ記載します。

require 'rails_helper'

RSpec.describe "Api::SecretMessages", type: :request do
  let(:karo) { create(:user, name: 'karo', password: 'ps') }
  let(:karo_message) { create(:secret_message, owner: karo) }

  let(:others) { create(:user, name: 'others', password: 'ps') }
  let(:others_message) { create(:secret_message, owner: others) }

  let(:request_body) do
    {
      title: '更新タイトル',
      description: '更新説明',
    }
  end

  let(:header) { {'Content-Type' => 'application/json', 'Accept' => 'application/json'} }
  let(:params) { request_body.to_json }

  describe "PUT /api/select_messages" do
    context '正しいAuthorizationヘッダあり' do
      let(:valid_basic_auth) { ActionController::HttpAuthentication::Basic.encode_credentials(karo.name, karo.password) }

      context '家老の密書' do
        subject do
          put api_secret_message_path(karo_message), params: params, headers: { HTTP_AUTHORIZATION: valid_basic_auth }.merge(header)
        end

        it '成功してDBが更新されている' do
          subject

          expect(response).to have_http_status(204)

          # reloadで再読み込み
          expect(karo_message.reload).to have_attributes(
                                           title: '更新タイトル',
                                           description: '更新説明',
                                           owner: karo
                                         )
        end
      end

      context '他人の密書' do
        subject do
          put api_secret_message_path(others_message), params: params, headers: { HTTP_AUTHORIZATION: valid_basic_auth }.merge(header)
        end

        it '失敗して、DBは更新されていない' do
          subject

          expect(response).to have_http_status(403)

          # reloadで再読み込み
          expect(others_message.reload).to have_attributes(
                                             title: 'タイトル',
                                             description: '説明'
                                           )
        end
      end
    end

    context '誤ったAuthorizationヘッダ' do
      let(:invalid_basic_auth) { ActionController::HttpAuthentication::Basic.encode_credentials('foo', 'bar') }

      it 'エラー' do
        get api_secret_messages_path, headers: { HTTP_AUTHORIZATION: invalid_basic_auth }

        expect(response).to have_http_status(401)
      end
    end

    context 'Authorizationヘッダなし' do
      it 'エラー' do
        get api_secret_messages_path

        expect(response).to have_http_status(401)
      end
    end
  end
end

 
テストコードを流すと、すべてのテストがパスします。

 

ここまでのコミット

https://github.com/thinkAmi-sandbox/rails_with_pundit-sample/pull/1/commits/600c249e54aead9a75a4e20ed6edc6d099a8692e

 

Rolifyを使い、家老ロールのみ密書の登録・更新・閲覧を可能にする

ストーリー

ここまでの機能で密書管理システムは完成したように見えました。

 

そんな中、ある家老が子供に家督を譲って引退しました。

 

ただ、密書管理システムの設定はそのままだったため、引退した家老が昔の密書を勝手に閲覧したり更新してしまいました。

 

このことがbo藩で問題となったため、現役の家老たちはシステムを修正することにしました。

 

修正において、やりたいことは以下の通りです。

  • ログインしたユーザーのうち、現役の家老のみ、密書の閲覧・作成・更新を可能にしたい
    • ただし、更新については、今まで通り密書の作成者のみ可能にしたい
  • 現役の家老は複数人で構成され、誰かに譲ることもできる
    • ユーザーに対して永久に紐づくわけではない

 

権限マトリックス

認可制御が複雑になってきたため、権限マトリックスを作成します。

認証 役職 閲覧 作成 更新
あり 家老 o o 作成した密書のみ
あり なし x x x
なし - x x x

 

アプリの作成

家老という役職をロールとデータ構造のどちらで表現するか

やりたいことから考えると、 家老 という役職での認可制御は RBAC(Role-Based Access Control) のロールとして考えると良さそうです。

 
ロールを扱うためには、独自のデータベース構造を導入し、ユーザーに紐づければよさそうです。

ただ、この記事の冒頭でふれたように、 Rolify gemがそのあたりをいい感じに扱ってくれそうです。

そこで、今回は Rolify にてロールによる認可制御を実装してみます。

 

Rolifyのセットアップ

RolifyのREADMEに従い、セットアップを行います。
RolifyCommunity/rolify: Role management library with resource scoping

Gemfileに追加し、 bundle install します。

gem 'rolify'

 
Rolifyで管理する 家老 という役職は User モデルに紐づきます。

そこで、RolifyのジェネレータでUserモデルに対するRoleモデルを生成します。

$ bin/rails g rolify Role User
      invoke  active_record
      create    app/models/role.rb
      invoke    rspec
      create      spec/models/role_spec.rb
      invoke      factory_bot
      create        spec/factories/roles.rb
      insert    app/models/role.rb
      create    db/migrate/20221002124719_rolify_create_roles.rb
      insert  app/models/user.rb
      create  config/initializers/rolify.rb
===============================================================================

An initializer file has been created here: config/initializers/rolify.rb, you 
can change rolify settings to match your needs. 
Defaults values are commented out.

A Role class has been created in app/models (with the name you gave as 
argument otherwise the default is role.rb), you can add your own business logic 
inside.

Inside your User class (or the name you gave as argument otherwise the default 
is user.rb), rolify method has been inserted to provide rolify methods.

 
マイグレーションファイルが自動生成されるので、適用します。

$ bin/rails db:migrate
== 20221002124719 RolifyCreateRoles: migrating ================================
-- create_table(:roles)
   -> 0.0016s
-- create_table(:users_roles, {:id=>false})
   -> 0.0011s
-- add_index(:roles, :name)
   -> 0.0004s
-- add_index(:roles, [:name, :resource_type, :resource_id])
   -> 0.0005s
-- add_index(:users_roles, [:user_id, :role_id])
   -> 0.0005s
== 20221002124719 RolifyCreateRoles: migrated (0.0043s) =======================

Annotated (6): app/models/role.rb, spec/models/role_spec.rb, spec/factories/roles.rb, app/models/secret_message.rb, test/models/secret_message_test.rb, test/fixtures/secret_messages.yml

 
また、このマイグレーションを適用することにより、Userモデルに rolify が追加されます。

class User < ApplicationRecord
  rolify  # 追加
end

 

Userにロールを割り当てる

今回はメンテナンス用の画面がないため、Rails Console を使って対象のユーザーへロールを割り当てます。  
なお、RolifyのWikiによるとリソースに対するロールも割り当てることができそうです。

To define a role scoped to a resource instance

user = User.find(2)
user.add_role :moderator, Forum.first

https://github.com/RolifyCommunity/rolify/wiki/Usage

 
ただ、今回はリソースに対する制御は不要なため、リソースは指定せずに割り当てます。

>> karo = User.find_by(name: 'karo')
>> karo.add_role :chief_retainer

>> another = User.find_by(name: 'another_karo')
>> another.add_role :chief_retainer

 

ロールを見て認可制御するようPolicyを修正

今までは認証できていれば、閲覧や作成は可能でした。

ただ、今回からは 家老 ロールのみ可能とすることから、 以下を満たせるようPolicyを修正します。

  • indexとcreateは家老ロールのみ許可
  • updateは家老ロールかつ密書の作成者のみ許可

なお、都度 user.has_role? :chief_retainer と書くのは手間なので、 chief_retainer? というメソッドを生やして定義しています。

class Api::SecretMessagePolicy < ApplicationPolicy
  def index?
    chief_retainer?
  end

  def create?
    chief_retainer?
  end

  def update?
    chief_retainer? && record.owner == user
  end

  private def chief_retainer?
    user.has_role? :chief_retainer
  end
end

 

コントローラを修正

Policyの index?create? に修正を加えたため、コントローラの authorize メソッドにも修正を加えます。

indexcreate ではモデルのインスタンスは使用しません。そのため、PunditのREADMEの

If you don't have an instance for the first argument to authorize, then you can pass the class

https://github.com/varvet/pundit#policies

に従い、モデルクラス SecretMessage を渡すよう修正します。

class Api::SecretMessagesController < BasicAuthController
  def index
    authorize SecretMessage
# ...

  def create
    authorize SecretMessage
# ...
end

 

テストコードの修正

家老ロールを生成するfactoryを追加

テストコードの中で毎回家老ロールを割り当てるのは手間なので、factoryにて生成する時点でロールを割り当てます。

以下のstackoverflowに従い、 after(:create) にて家老ロールを割り当てます。
ruby on rails - Setting roles through rolify in FactoryBot definition - Stack Overflow

FactoryBot.define do
  factory :user, aliases: [:owner] do
    name { 'foo' }
    password { 'password' }

    # 追加
    factory :chief_retainer do
      after(:create) {|user| user.add_role(:chief_retainer)}
    end
  end
end

 
追加したfactoryは、テストコードで以下のようにして使えます。

context '家老ロール' do
  # 家老ロールを持ったUserを生成
  let(:karo) { create(:chief_retainer, name: 'karo', password: 'ps') }

  it '成功' do
    get api_secret_messages_path, headers: { HTTP_AUTHORIZATION: valid_basic_auth }

    expect(response).to have_http_status(200)
  end
end

 
あとは権限マトリックス通りにテストコードを書き、テストがパスすればOKです。

 

ここまでのコミット

https://github.com/thinkAmi-sandbox/rails_with_pundit-sample/pull/1/commits/ac365b75105d735965a2344ac08f30044fdf92dd

 

密書の作成者グループを追加し、作成者グループのメンバーのみ密書を更新可能にする

ストーリー

ここまでの修正にて、現役の家老たちのみ密書を取り扱えるようになりました。

 

そんな中、家老が過去の密書を見返していたところ、修正が必要そうな密書を見つけました。

しかし、その密書はすでに引退している家老だったため、修正することができません。

 

さすがにそれでは困るので、作成者本人でなくても修正できるよう、作成者グループという概念を導入・実装することにしました。

 

権限マトリックス

更新処理が行える権限について、「作成者」から「作成者グループ」へと変更します。

また、作成者グループは密書ごとに異なります。つまり、密書Aと密書Bの作成者グループに含まれる家老は異なります。

一方、それ以外の処理については今まで通りにします。

認証 役職 作成者グループ 閲覧 作成 更新
あり 家老 含まれる o o 作成者グループの密書のみ
あり 家老 含まれない o o x
あり なし - x x x
なし - - x x x

 

アプリの作成

作成者グループをロールとデータ構造のどちらで表現するか

今回の「作成者グループ」は、密書ごとに異なる必要があります。

そのため、ロールとして作成者グループを定義した場合、密書ごとにロールが必要になってしまいます。

そこで、作成者グループはテーブル構造で表現することとします。

 

作成者グループモデルの導入

密書・作成者グループ・ユーザーの関係を考えたところ、以下となりました。

  • 密書 : 作成者グループ = 1 : 0..n
    • 密書ごとに作成者グループが必要
    • 密書に対する作成者グループは後から追加可能とする
  • 作成者グループ : ユーザー = 1 : 0..n
    • 作成者グループには複数のユーザーが含まれる
    • 作成者グループに対するユーザーは後から追加可能とする

 
上記の関係に従い、マイグレーションを作成します。

$ bin/rails g model Author user:references secret_message:references
      invoke  active_record
      create    db/migrate/20221003110255_create_authors.rb
      create    app/models/author.rb
      invoke    rspec
      create      spec/models/author_spec.rb
      invoke      factory_bot
      create        spec/factories/authors.rb

 
生成されたマイグレーションは外部キーが nullable になっていません。そこで、手動でマイグレーションを修正し、nullableにします。

class CreateAuthors < ActiveRecord::Migration[7.0]
  def change
    create_table :authors do |t|
      t.references :user, null: true, foreign_key: true
      t.references :secret_message, null: false, foreign_key: true

      t.timestamps
    end
  end
end

 
マイグレーションを適用します。

$ bin/rails db:migrate
== 20221003110255 CreateAuthors: migrating ====================================
-- create_table(:authors)
   -> 0.0024s
== 20221003110255 CreateAuthors: migrated (0.0024s) ===========================

 

各モデルに関連付け(belongs_to/has_many)を追加

外部キーを生成したので、モデルにも関連付けを定義します。

SecretMessage

class SecretMessage < ApplicationRecord
  belongs_to :owner, class_name: 'User', foreign_key: :owner_id
  has_many :authors  # 追加
end

 
User

class User < ApplicationRecord
  rolify

  has_many :authors
end

 
Author

class Author < ApplicationRecord
  belongs_to :user
  belongs_to :secret_message
end

 

Policyを変更する

更新権限を 家老、かつ、作成者グループに含まれること へと変更します。

class Api::SecretMessagePolicy < ApplicationPolicy
# ...
  # 追加
  def update?
    author?
  end

# ...

  # 追加
  private def author?
    chief_retainer? && record.authors.exists?(user: user)
  end

 

テストコードの修正

作成者グループのfactoryを追加

外部キーのデフォルト値は無しとする、作成者グループのfactoryを定義します。

FactoryBot.define do
  factory :author do
    user { nil }
    secret_message { nil }
  end
end

 

テストコードの修正

更新系のテストコードを修正します。

 

ここまでのコミット

https://github.com/thinkAmi-sandbox/rails_with_pundit-sample/pull/1/commits/d56fccece0cdaae5f77b0235c44e8dd5011cf88a

 

奉行ロールを追加し、作成者グループにいる奉行は作成した密書の更新と閲覧を可能にする

ストーリー

一通りの認可制御ができ、家老たちは密書管理システムを運用していました。

 

そんな中、ある家老から「密書を奉行に見せたりしているんだが、都度システムの前に呼んだり密書をプリントアウトしたりしている。手間がかかるし、プリントアウトした密書を落としたら大変なことになってしまう。」と聞きました。

 

また、別の家老からは「内容によっては奉行に直接修正してもらって構わないものもあるんだが、今は奉行から手書きで修正依頼を上げてもらっている。手書きなので修正を間違えることがあり、そのうちオオゴトになってしまいそうだ。」とも聞きました。

 

それらの話の内容からすると奉行にもシステムを使わせたほうが良さそうでした。

 

そこで、家老と同じような、 奉行 ロールを導入して認可制御することにします。

 

権限マトリックス

奉行の認可制御としては、作成者グループに含まれている密書のみ閲覧・更新ができれば良さそうです。

認証 役職 作成者グループ 閲覧 作成 更新
あり 家老 含まれる o o 作成グループの密書のみ
あり 家老 含まれない o o x
あり 奉行 含まれる 作成グループの密書のみ o 作成グループの密書のみ
あり 奉行 含まれない x x x
あり なし - x x x
なし - - x x x

アプリの作成

Policyの修正

奉行ロールは Rolify では :magistrate で表すことにします。

 

更新権限の変更

作成者グループ、かつ、家老・奉行であれば更新できるようにします。

def update?
  author? && (chief_retainer? || magistrate?)
end

private def magistrate?
  user.has_role? :magistrate
end

 

閲覧権限の変更

奉行の閲覧権限は「奉行は自分が作成者グループに含まれる密書のみ閲覧できる」となります。

ただ、 index? メソッドはbooleanを返す必要があるため、「自分が作成者グループに含まれる密書のみ取得する」を index? メソッドには実装できません。

その代わり、DBから取得する際に絞り込む方法として Policy Scope という機能があります。

Often, you will want to have some kind of view listing records which a particular user has access to. When using Pundit, you are expected to define a class called a policy scope.

https://github.com/varvet/pundit#scopes

 
そこで、 index? メソッドとPolicy Scopeを使った

  • index? では家老・奉行だけを許可する
  • データの取得についてはPolicy Scopeを使い、奉行の場合は「自分が作成者グループに含まれる密書」のみ取得する
    • コントローラでは policy_scope メソッドを使い、必要なデータを取得する

という形で実装していきます。

 
まずは index? を実装します。

def index?
  chief_retainer? || magistrate?
end

 
次に、Policy Scopeを使うため、Policyの内部クラスとしてScopeを定義します。

class Api::SecretMessagePolicy < ApplicationPolicy
  class Scope < Scope
    def resolve
      # 外側の Api::SecretMessagePolicy の chief_retainer? は参照できない
      if user.has_role? :chief_retainer
        scope.all
      else
        scope.joins(:authors).where(authors: {user: user})
      end
    end
  end
# ...

 

コントローラの修正

コントローラでは以下の流れで実装します。

  1. authorize メソッドで認可制御する
  2. policy_scope メソッドで必要なデータのみ取得する
def index
  # まずは authorize メソッドで認可制御する
  authorize SecretMessage

  # Policy Scopeを使ってデータを絞り込む
  records = policy_scope(SecretMessage, policy_scope_class: Api::SecretMessagePolicy::Scope)

  render json: records
end

 

モデルの修正

以前の実装で、マイグレーションの外部キーをnullableとしました。

ただ、実際に外部キーへNULLを入れると ActiveRecord::RecordInvalid: Validation failed: User must exist とのエラーになります。

これはRails側のバリデーションに引っかかってしまっているようです。

そのため、以下の記事を参考に、モデルの belongs_tooptional を設定します。

class Author < ApplicationRecord
  belongs_to :user, optional: true  # optionalを追加
  belongs_to :secret_message
end

 

テストの修正

User factoryに奉行を追加

家老のfactory同様、奉行のfactoryを追加します。

FactoryBot.define do
  factory :user, aliases: [:owner] do
    name { 'foo' }
    password { 'password' }

    # ...
    factory :magistrate do
      after(:create) {|user| user.add_role(:magistrate)}
    end
  end
end

 

テストコードの追加

奉行ロールの場合に権限マトリックスに従っているかを確認します。

context '奉行ロール' do
  let!(:samurai) { create(:magistrate, name: 'samurai', password: 'ps') }

  context 'authorに含まれる' do
    before do
      create(:author, user: samurai, secret_message: secret_message)
    end

    it '成功' do
      get api_secret_messages_path, headers: { HTTP_AUTHORIZATION: valid_basic_auth }

      expect(response).to have_http_status(200)

      expect(parsed_response_body.length).to eq(1)

      titles = parsed_response_body.map {|msg| msg['title']}
      expect(titles).to eq(%w[密書1])
    end
  end

  context 'authorに含まれない' do
    it '失敗' do
      get api_secret_messages_path, headers: { HTTP_AUTHORIZATION: valid_basic_auth }

      expect(response).to have_http_status(200)
      expect(parsed_response_body.length).to eq(0)
    end
  end
end

 

ここまでのコミット

https://github.com/thinkAmi-sandbox/rails_with_pundit-sample/pull/1/commits/52832a3c00dc47d0899afd9b7bf27756b865f057

 

派閥モデルを追加し、家老と同じ派閥のユーザーは家老が作成した密書を閲覧可能にする

ストーリー

奉行も密書管理システムを使えるようになって「便利になったわー」と言われる日々を過ごしていました。

 

そんな中、建物の中にプリントアウトした密書が落ちていた事件が発生しました。

 

家老たちに話を聞くと、「派閥内の情報共有のために、自分の書いた密書は同じ派閥のメンバーにもプリントアウトして共有している」とのことでした。

 

プリントアウトされた密書が落ちていたとしても落とし主を追跡できないため、派閥のメンバーにも密書管理システムを使ってもらうことになりました。

 

さらに、bo藩での派閥について家老に聞いたところ、以下の組織体のようでした。

  • 家臣は1つの派閥に所属できる
    • ただし、一匹狼で過ごしたい場合は、派閥に属さないこともできる
  • 派閥に所属していない家老や奉行もいる

 

そこで、密書管理システムに「派閥のメンバーは、家老が作成者グループに所属している密書を閲覧できる」という認可制御を追加することにしました。

 

権限マトリックス

認証 役職 作成者グループ 家老の派閥 閲覧 作成 更新
あり 家老 含まれる - o o 作成グループの密書のみ
あり 家老 含まれない - o o x
あり 奉行 含まれる 所属 作成グループもしくは派閥の密書のみ o 作成グループの密書のみ
あり 奉行 含まれない 無所属 x x x
あり なし - 所属 派閥の密書のみ x x
なし - - 無所属 x x x

 

アプリの作成

派閥をロールとデータ構造のどちらで表現するか

派閥 という概念をロールとテーブル構造のどちらで表現するか考えましたが、ロールだと

  • A派閥
  • B派閥
  • ...

のように派閥が増えるごとにロールも増えていきそうでした。

また、ロールで表現してしまうと「ユーザーは1つの派閥にだけ所属できる」というバリデーションが必要になりそうでした。

そこで、今回はテーブル構造にて派閥を表現することにしました。

テーブル間の多重度ですが、ユーザーは一つの派閥にだけ所属できることから、 ユーザー : 派閥 = 1 : 0..1 とすれば良さそうです。

 
まずはマイグレーションを追加します。

$ bin/rails g model Faction name:string
      invoke  active_record
      create    db/migrate/20221004232102_create_factions.rb
      create    app/models/faction.rb
      invoke    rspec
      create      spec/models/faction_spec.rb
      invoke      factory_bot
      create        spec/factories/factions.rb

 
次に、Userモデルに派閥(Faction)を外部キーとして持てるようなマイグレーションを追加します。

$ bin/rails g migration AddFactionToUsers faction:references
      invoke  active_record
      create    db/migrate/20221004232303_add_faction_to_users.rb

 

なお、派閥は無所属でもよいため、マイグレーションを編集してnullableにします。

class AddFactionToUsers < ActiveRecord::Migration[7.0]
  def change
    add_reference :users, :faction, null: true, foreign_key: true
  end
end

 
DBに外部キーを追加したため、モデルにも関連を設定します。

「Userはどの派閥に属するか」を確認できればよいため、今回はUserモデルにのみ関連を追加します。

また、派閥に属さなくても良いため、 optional: true も追加しておきます。

class User < ApplicationRecord
  rolify

  has_many :authors
  belongs_to :faction, optional: true  # 追加
end

 

Policyの修正

Policyでは「派閥に所属するユーザー」は「家老が作成者グループの密書を取得」して閲覧できることを表現します。

そのため、Policyを以下とします。

  • index? に「派閥に所属するユーザー」の制御を追加
  • Policy Scopeに「家老が作成者グループの密書を取得」を追加

 

index? の修正

ユーザーが派閥に属しているかをOR条件に追加します。

無所属の家老や奉行もいるため、他の条件は今まで通りにします。

def index?
  chief_retainer? || magistrate? || belonging_to_faction?
end

private def belonging_to_faction?
  user.faction.present?
end

 

Policy Scopeの修正

権限マトリックスに従い、Policy Scopeで取得できるデータを制御します。

下記の①~③が実装できれば良さそうです。

  • 家老
    • ①全件
  • それ以外
    • ②自分が作成者グループの密書
    • ③作成者グループに同じ派閥グループの家老が含まれる密書

 
①~③の条件をActiveRecordで書いていきます。

①は全件取得になります。

# scopeにはSecretMessageモデルが入る
scope.all

 
②は、joinsでつなげてwhereで絞り込めば良さそうです。

scope.joins(authors: [user: :faction])
      .where(authors: {users: user})

 
③は少々手間です。

.or(scope.joins(authors: [user: :faction]).where(
  authors: {
    users: {
      id: User.with_role(:chief_retainer).select('users.id'),
      factions: {
        id: user.faction.id
      }
    }
  }
))

 
念のため、②と③を組み合わせた時に発行されるSQLを確認します。

今回はActiveRecordto_sql メソッドでSQLを表示し、とVS Code拡張の SQL Formatter Mod を使って整形して確認します。

確認したSQLは以下です。想定通りできていそうです。

SELECT
    DISTINCT "secret_messages".*
FROM
    "secret_messages"
    INNER JOIN "authors" ON "authors"."secret_message_id" = "secret_messages"."id"
    INNER JOIN "users" ON "users"."id" = "authors"."user_id"
    INNER JOIN "factions" ON "factions"."id" = "users"."faction_id"
WHERE
    (
        "authors"."user_id" = 1
        OR "users"."id" IN (
            SELECT
                "users"."id"
            FROM
                "users"
                INNER JOIN "users_roles" ON "users_roles"."user_id" = "users"."id"
                INNER JOIN "roles" ON "roles"."id" = "users_roles"."role_id"
            WHERE
                (
                    (
                        (roles.name = 'chief_retainer')
                        AND (roles.resource_type IS NULL)
                        AND (roles.resource_id IS NULL)
                    )
                )
        )
        AND "factions"."id" = 1
    )

 
上記①~③を組み込んだPolicyは以下となります。プロダクションコードはこれで完成です。

class Api::SecretMessagePolicy < ApplicationPolicy
  class Scope < Scope
    def resolve
      if user.has_role? :chief_retainer
        scope.all
      else
        scope.joins(:authors).where(authors: {user: user})
        return scope.all
      end

      # 上段: 自分がauthor
      # 下段: 同じ派閥の家老がauthor
      scope.joins(authors: [user: :faction])
           .where(authors: {users: user})
           .or(scope.joins(authors: [user: :faction]).where(
             authors: {
              users: {
                id: User.with_role(:chief_retainer).select('users.id'),
                factions: {
                  id: user.faction.id
                }
              }
            }
           ))
           .distinct # 1つのsecret_messagesに対しfactionが等しいauthorが複数いる時のためdistinctする
# ...

 

ActiveRecordの使い方メモ

 
上記のPolicyを書く時に参考にした、ActiveRecordの書き方についてもメモを残しておきます。

 

joinsで複数テーブルを連結する場合は、ハッシュと配列を使う

13.1.3 複数の関連付けを結合する | Active Record クエリインターフェイス - Railsガイド

結合したテーブルのwhereでは、文字列またはハッシュで条件を記述する

13.1.4 結合テーブルで条件を指定する | Active Record クエリインターフェイス - Railsガイド

今回はハッシュで記述しました。

なお、ハッシュのキーにはテーブル名もしくはエイリアスが使えるようです。
Rails 6.1: whereの関連付けでjoinedテーブルのエイリアス名を参照可能になった(翻訳)|TechRacho by BPS株式会社

 

ActiveRecordでの副問合せの書き方について

Rails4のActiveRecordは、IN演算子のサブクエリを使える - USO800

 

テストコードの修正

権限マトリックスに従ってテストコードを修正します。

権限マトリックスが複雑なためテストコードは掲載しませんが、以下のようなパターンをテストしています。

$ bundle exec rspec --format d
...
Api::SecretMessages
  GET /api/select_messages
    正しいAuthorizationヘッダあり
      家老ロール
        authorに含まれる
          behaves like すべて閲覧できる
        authorに含まれない
          behaves like すべて閲覧できる
      奉行ロール
        authorに含まれる
          同じ派閥の家老
            同じauthorにいる
              behaves like authorに同じ派閥の家老がいる密書(samurai_message)のみ閲覧できる
            別のauthorにいる
              behaves like すべて閲覧できる
            authorにいない
              behaves like 自分がauthorの密書(samurai_message)のみ閲覧できる
          同じ派閥の奉行
            同じauthorにいる
              behaves like 自分がauthorの密書(samurai_message)のみ閲覧できる
            別のauthorにいる
              behaves like 自分がauthorの密書(samurai_message)のみ閲覧できる
            いない
              behaves like 自分がauthorの密書(samurai_message)のみ閲覧できる
          同じ派閥の一般
            いる
              behaves like 自分がauthorの密書(samurai_message)のみ閲覧できる
            いない
              behaves like 自分がauthorの密書(samurai_message)のみ閲覧できる
        authorに含まれない
          同じ派閥の家老
            authorにいる
              behaves like authorに同じ派閥の家老がいる密書(samurai_message)のみ閲覧できる
            authorにいない
              behaves like 閲覧可能なデータがない
          同じ派閥の奉行
            authorにいる
              behaves like 閲覧可能なデータがない
            authorにいない
              behaves like 閲覧可能なデータがない
          同じ派閥の一般
            いる
              behaves like 閲覧可能なデータがない
            いない
              behaves like 閲覧可能なデータがない
      ロールなし(一般)
        派閥に所属
          同じ派閥の家老
            authorに含まれる
              behaves like authorに同じ派閥の家老がいる密書(samurai_message)のみ閲覧できる
            authorに含まれない
              behaves like 閲覧可能なデータがない
          別の派閥の家老
            authorに含まれる
              behaves like 閲覧可能なデータがない
          同じ派閥の奉行
            authorに含まれる
              behaves like 閲覧可能なデータがない
            authorに含まれない
              behaves like 閲覧可能なデータがない
          別の派閥の奉行
            authorに含まれる
              behaves like 閲覧可能なデータがない
        派閥に所属していない
          別の派閥の家老がauthorにいる
            behaves like 閲覧できない
          別の派閥の奉行がauthorにいる
            behaves like 閲覧できない
          別の派閥の役職者がauthorにいない
            behaves like 閲覧できない
    誤ったAuthorizationヘッダ
      エラー
    Authorizationヘッダなし
      エラー

 

ここまでのコミット

https://github.com/thinkAmi-sandbox/rails_with_pundit-sample/pull/1/commits/0e724a8da458b1a33e89cf78676421b7684db06b

 

まとめ

ストーリー

ここまでの実装にて、bo藩は密書管理システムを構築できました。

 

bakufuからのおふれにも従えて満足できるシステムになりました。めでたしめでたし。

 

実装してみての感想

今回、Pundit + Rolify を使って認可制御をしました。

100行未満のPolicyファイルで期待する認可制御が実現できたため、あとから見直しても容易に読み解けそうと感じました。

また、

  • コントローラのメソッドを認可するかどうか
    • index? など
  • どんなデータを取得するか
    • Policy Scope

が分離できているのも分かりやすく感じました。

 
一方、権限マトリックス通りに実装できているかについては、プロダクションコードをパッと見ても分かりづらく感じました。

ただ、そこはテストコードを見ればよいと割り切れば良さそうです。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_with_pundit-sample

今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_with_pundit-sample/pull/1

Django + google-authで、Google Cloud SchedulerからのHTTPリクエストのみ受け付けるAPIエンドポイントを作成する

Djangoアプリでのジョブをスケジューリングしたいと思い、Google Cloud Schedulerのドキュメントを読んだところ

Cloud Scheduler では、作業単位のスケジュールを設定して、定義した回数または一定の間隔で実行できます。これらの作業単位は、一般的に cron ジョブと呼ばれています。代表的な使い方としては、レポートメールを毎日送信する、10 分間隔でキャッシュ データを更新する、1 時間に 1 回要約情報を更新する、などがあります。

Cloud Scheduler を使用して作成された各 cron ジョブは、指定のスケジュールに従ってターゲットに送信されます。ターゲットはタスクが処理される場所です。ターゲットは、次のいずれかのタイプでなければなりません。

  • 一般公開されている HTTP/S エンドポイント

Cloud Scheduler について  |  Cloud Scheduler のドキュメント  |  Google Cloud

との記載がありました。

これにより、Djangoアプリ側でGoogle Cloud Scheduler から呼び出されたことが分かれば、ジョブのスケジューリングができそうでした。

 
次にどのような特徴を持つリクエストが飛んでくるかを調べてみたところ

HTTP リクエストの Authorization ヘッダに Service Account の情報を含んだ OpenID Connect の ID Token が渡ってくるので、その ID Token を検証すれば ok という形です。

GCP からの HTTP リクエストをセキュアに認証する. はじめに | by Yuki Furuyama | google-cloud-jp | Medium

とありました。

検証方法は

ターゲットが Google Cloud の外部にある場合、受信サービスはトークンを手動で確認する必要があります。

HTTP ターゲットで認証を使用する  |  Cloud Scheduler のドキュメント  |  Google Cloud

とありました。

ここには 手動で確認 とありますが、別のドキュメントには

Using one of the Google API Client Libraries (e.g. Java, Node.js, PHP, Python) is the recommended way to validate Google ID tokens in a production environment.

Using a Google API Client Library | バックエンドサーバーで認証する  |  Google Sign-In for Websites  |  Google Developers

とあり、Pythonクライアントライブラリを使えばそこまで苦労せずに確認できそうでした。

 
そこで今回、Django + google-authで、Google Cloud SchedulerからのHTTPリクエストのみ受け付けるAPIエンドポイントを作成してみましたので、メモを残します。

 
目次

 

環境

  • Google Cloud Scheduler
  • Google Cloud SchedulerからのHTTPリクエストのみ受け付けるAPIエンドポイント
    • Python 3.10.7
    • Django 4.1.1
    • google-auth 2.12.0
      • なお、IDトークン検証時に id_token.verify_oauth2_token(received_id_token, requests.Request(), CLIENT_ID) というエラーになるため、 requests も必要
      • requests 2.28.1
    • PyJWT 2.5.0
      • IDトークンをデコードするために使用
      • 今回、動作確認のためインストールしているだけなので、実運用上は無しで良い
    • WSL2上のUbuntuに構築

 

IDトークンの中身を確認できるDjangoアプリを作成

まずは、Cloud Schedulerからどんな値を持つIDトークンが送られてくるかを確認するためのDjangoアプリを作成します。

 

DjangoAPIエンドポイントを作る

HTTPリクエストを受け取れれば良いので、ふつうのDjangoアプリをWSL2上に構築します。

実装の詳細は記事の末尾にあるソースコードに譲り、ここでは概要だけ記載します。

 
プロジェクトの urls.py は

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('api.urls'))
]

として、アプリのurls.pyは

from django.urls import path

from api.views import CommandView, CommandWithAuthView

app_name = 'api'

urlpatterns = [
    path('health-check', HealthCheckView.as_view(), name='health-check'),
    path('command', CommandView.as_view(), name='command'),
]

とします。

 
次に、ViewではHTTPリクエストヘッダから Authorization ヘッダを取り出し、どんなIDトークンなのか見て見る感じにします。

ここで、IDトークンはエンコードされているため、 PyJWT を使ってヘッダとペイロードをデコードします。

なお、この時点ではIDトークンのデコードができれば良いことにして、中身の正しさは検証しません。

class CommandView(View):
    def get(self, request, *args, **kwargs):
        authz_header = request.headers.get('Authorization')  # => Bearer ***
        received_id_token = authz_header.replace('Bearer', '').lstrip()  # => IDトークンだけになる
        print(received_id_token)

        # JWTのヘッダを検証なしでdecode
        print(jwt.get_unverified_header(received_id_token))

        # JWTのペイロードを検証なしでdecode
        print(jwt.decode(received_id_token, options={"verify_signature": False}))

        return JsonResponse({
            'foo': 'bar'
        })

 
他に、リクエストが届くかどうか確認するためだけのViewも用意します。

class HealthCheckView(View):
    def get(self, request, *args, **kwargs):
        return JsonResponse({
            'ham': 'spam'
        })

 

Djangoアプリを作ったWSL2上に、ngrokを構築

上記のDjangoアプリをインターネット上にデプロイして確認することも考えましたが、環境構築をするのが手間でした。

そこで今回は、ngrokを使ってインターネットからDjangoアプリにHTTPリクエストが届くようにしてみます。
ngrok - Online in One Line

 
まずはngrokのサイトに行き、サインアップを行い、トークンを取得します。

次に、WSL2に ngrok をインストールします。

今回はWSL2上のUbuntuなので、 Install ngrok via Apt の方法でインストールします。

$ curl -s https://ngrok-agent.s3.amazonaws.com/ngrok.asc | sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null && echo "deb https://ngrok-agent.s3.amazonaws.com buster main" | sudo tee /etc/apt/sources.list.d/ngrok.list && sudo apt update && sudo apt install ngrok

 
実行後、インストールされたかを確認します。

$ ngrok version 3.1.0

 
続いて、 Add authtoken に従い、tokenを設定します。

$ ngrok config add-authtoken ***

Authtoken saved to configuration file: /home/user/.config/ngrok/ngrok.yml

 
最後に、ngrokを起動すると公開URLが表示されます。

$ ngrok http 8000
...
Forwarding                    https://***.jp.ngrok.io -> http://localhost:8000

 
アクセスできるかどうか、Windows上のターミナルからアクセスします。

> curl https://***.jp.ngrok.io/api/health-check

<!doctype html>
...

アクセスできたものの、ngrokのBrowser Warningページを取得していたようでした。

 
Browser WarningページではなくDjangoアプリのレスポンスを得たいため、リクエストヘッダに ngrok-skip-browser-warning を追加します。
ngrok - Online in One Line

再度 curl を実行すると、Djangoからのレスポンスがありました。

>curl -H 'ngrok-skip-browser-warning:hoge' https://***.jp.ngrok.io/api/health-check
{"ham": "spam"}

 

Cloud Scheduler のセットアップ

Django側の準備はできたため、次にCloud Schedulerのセットアップを行います。
https://console.cloud.google.com/cloudscheduler

ジョブを作成 をクリックし、新規ジョブを作成します。

まずは スケジュールを定義する ページです。

項目
名前 任意
リージョン asia-northeast1 (東京)
説明 任意
頻度 任意 (今回は都度実行するため)
タイムゾーン 日本標準時(JST)

 
実行内容を構成する ページです。

項目
ターゲットタイプ HTTP
URL https://***.jp.ngrok.io/api/command (IDトークンを解析するURL)
HTTPメソッド GET
HTTPヘッダ - ngrok-skip-browser-warning 任意の値
Authヘッダー OIDCトークンを追加
サービスアカウント 新規作成
対象 (空白)

 
なお、今回作成するサービスアカウントはGoogle Cloudへアクセスしないため、ロールを設定せずに作成します。

 
オプションの設定 はデフォルトのままです。

今回はコンソールからジョブを強制実行するだけなので、最大再試行回数も 0 で良いです。

 

ジョブの強制実行

Cloud Schedulerのページに先ほど作成したジョブが表示されます。

右側のドットから ジョブを強制実行する をクリックしてジョブを実行します。

 
すると、Djangoの実行ログにIDトークンの情報が表示されます。

# IDトークンのヘッダ
{'alg': 'RS256', 'kid': '***', 'typ': 'JWT'}

# IDトークンのペイロード
{
  'aud': 'https://***.jp.ngrok.io/api/command',
  'azp': '***',
  'email': '***@***.iam.gserviceaccount.com',
  'email_verified': True,
  'exp': 1664324413,
  'iat': 1664320813,
  'iss': 'https://accounts.google.com',
  'sub': '***'
}

 
それぞれ

項目
aud Cloud Schedulerで指定した 対象
email Cloud Schedulerで指定したサービスアカウントのemail形式
iss https://accounts.google.com で固定

なことが分かりました。

 

Cloud SchedulerからのHTTPリクエストのみ受け付けるAPIエンドポイントを作成

ここまで確認してきたことにより、 aud にはCloud Schedulerで指定した 対象 が入ると分かりました。

そのため、IDトークンを検証している以下のサンプルコードの CLIENT_ID は、 aud の値であるGoogle Schedulerで指定した 対象 の値をセットすれば良さそうでした。
https://developers.google.com/identity/sign-in/web/backend-auth?hl=ja#using-a-google-api-client-library

 
そこで、google-authを使って、Cloud SchedulerからのHTTPリクエストのみ受け付けるAPIエンドポイントを作成します。

 

Djangoアプリにエンドポイントを追加

Viewとurls.pyに修正を加えます。

CLIENT_ID = 'https://***.jp.ngrok.io/api/command-with-auth'

class CommandWithAuthView(View):
    def get(self, request, *args, **kwargs):
        authz_header = request.headers.get('Authorization')
        received_id_token = authz_header.replace('Bearer', '').lstrip()

        try:
            id_info = id_token.verify_oauth2_token(received_id_token, requests.Request(), CLIENT_ID)
            print(id_info)

            return JsonResponse({
                'status': 'success'
            })
        except ValueError as e:
            print(e)
            return JsonResponse({
                'status': 'unauthorized'
            })

 
urls.pyにも追加します。

urlpatterns = [
    # ...
    path('command-with-auth', CommandWithAuthView.as_view(), name='auth')  # 追加
]

 

Cloud Schedulerの設定変更

ジョブが動作した時にHTTPリクエストを飛ばす先を変更します。

実行内容を構成するURL に、先ほど作成したエンドポイントのURL https://***.jp.ngrok.io/api/command-with-auth を指定します。

また、 対象 にデフォルト値として

オーディエンスは、OIDC トークンの受信者を制限します。通常は、ジョブのターゲット URL(URL パラメータなし)です。指定しなかった場合、Cloud Scheduler はデフォルトで、リクエスト パラメータを含む URL 全体をオーディエンスとして使用します。

が指定されているため、空白に戻してデフォルト値の設定とします。

 

Cloud Schedulerの実行とDjangoのログを確認

Cloud Schedulerを再実行すると、ログにIDトークンのペイロードが表示されました。 id_token.verify_oauth2_token の戻り値は、IDトークンのペイロードのようです。

[28/Sep/2022 12:31:54] "GET /api/command-with-auth HTTP/1.1" 200 21
{
  'aud': 'https://***.jp.ngrok.io/api/command-with-auth',
  'azp': '***',
  'email': '***@***.iam.gserviceaccount.com',
  'email_verified': True,
  'exp': 1664324413,
  'iat': 1664320813,
  'iss': 'https://accounts.google.com',
  'sub': '***'
}

 
次に、IDトークンの検証が失敗する場合の挙動を確認してみます。

今回はお手軽な方法として、定数 CLIENT_IDaud とは異なる値にしてみます。

CLIENT_ID = 'https://***.jp.ngrok.io/api/command'

 
その状態で Cloud Scheduler を再実行すると、Djangoのログに以下が出力されました。

Token has wrong audience https://***.jp.ngrok.io/api/command-with-auth, expected one of ['https://***.jp.ngrok.io/api/command']
[28/Sep/2022 12:36:17] "GET /api/command-with-auth HTTP/1.1" 200 26

 
以上より、IDトークンを検証することで、Cloud SchedulerからのHTTPリクエストのみ受け付けるAPIエンドポイントを作成することができました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/cloud_scheduler_handler_sample

Djangoアプリを、Coogle Cloud の Cloud Run + Cloud Storage + Litestream な環境で動かしてみた

以前、Djangoで作った個人アプリを Python 3.10 & Django 4.1 へとアップデートしました。
Python3.7 & Django 2.1 な個人アプリを Python 3.10 & Django 4.1 へとアップデートした - メモ的な思考的な

その時、データベースをPostgreSQLからSQLiteに切り替えました。

 
元々Herokuで動かしていることもあり、再びDBをPostgreSQLに戻そうと思ったところ、以下のTweetに出会いました。

 
Litestreamの公式サイトを見たところ、SQLiteのレプリカは各種クラウドストレージに置けそうでした。
How-To Guides - Litestream

 
機会があれば Google Cloud を使ってみたいと思っていたこともあり、ためしに Google Cloud で Django + SQLite + Litestream環境を構築する方法を調べてみました。

すると、Cloud Run + Litestreamで環境構築をしている記事が見つかりました。

 
そこで今回、DjangoアプリをCoogle Cloud の Cloud Run + Cloud Storage + Litestream な環境で動かしてみることにしました。

 
目次

 

環境

  • 開発環境
  • Djangoアプリ
  • Litestream 0.3.9
  • Google Cloud
    • 開発用には何も設定していない状況
    • 使用する主なサービスは以下
      • Cloud Run
      • Cloud Storage

 

Google Cloud まわりの設定

Google アカウントまわり

Google Cloudの開発で使うアカウントを用意

個人で利用するため、Googleアカウントを用意します。

 

Googleアカウントへ二要素認証を設定

アカウントが乗っ取られると困るため、二要素認証を設定します。

 

請求先アカウントの作成

クレジットカードなどを登録します。

 

予算とアラートの設定

請求額が増えた時に気づけるよう、予算とアラートを設定します。
予算と予算アラートを作成、編集、削除する  |  Cloud Billing  |  Google Cloud

 

範囲

項目
名前 任意
期間 月別
サービス すべてのサービス
クレジット - 割引 チェックする
クレジット - プロモーションなど チェックする

 

金額

項目
予算タイプ 指定額
目標金額 100円

 

操作

予算の割合 金額 トリガー対象
1% 1円 予測
10% 10円 予測
30% 30円 予測
60% 60円 予測
100% 100円 実値

また、通知は 課金管理者とユーザーに送信されるメールアラート にチェックを入れます。

 

Litestreamを使うために、Google Cloudまわりを設定

冒頭で上げた記事を参考にして、Google Cloudまわりを設定します。

 

プロジェクトの作成

Djangoアプリ用に、新しくプロジェクトを作成します。

項目
プロジェクト名 任意の名前
場所 組織なし

 

Cloud Storageのバケットを作成

公式ドキュメントを参考に、Cloud Storage のバケットを作成します。
ストレージ バケットの作成  |  Cloud Storage  |  Google Cloud

 
今回はコンソールからバケットを作成します。
https://console.cloud.google.com/storage/browser

 
バケットは、東京に単一リージョン、Standardな形で用意します。

項目
バケット 任意
ロケーションタイプ Region (単一リージョン)
Region asia-northeast1 (東京)
ストレージクラス Standard
このバケットに対する公開アクセス禁止を適用する チェック
アクセス制御 均一
保護ツール なし

 
バケットが用意できたところで、いったん Google Cloud Console での作業は終わりです。

 

開発環境の設定

続いて WSL2上のUbuntuにて、必要な開発環境設定を行います。

 

WSL2に gcloud コマンドをインストール

公式ドキュメントに従い、Cloud SDKを WSL2 の Ubuntu にインストールします。
Cloud SDK のインストール  |  Google Cloud

 
なお、この記事ではログを残すという目的で、実行当時のコマンドを記載しています。

その後コマンドが新しくなっている可能性もあるため、公式ドキュメントのコマンドをコピペしたほうが良いです。

 

必要なパッケージを追加

$ sudo apt install apt-transport-https ca-certificates gnupg

実行すると

Daemons using outdated libraries

Which services should be restarted?

と画面に表示されるため、デフォルトのまま <了解> を選択します。

すると処理が続行します。

Restarting services...
 /etc/needrestart/restart.d/systemd-manager
 systemctl restart cron.service packagekit.service polkit.service ssh.service systemd-networkd.service systemd-resolved.service systemd-udevd.service
Service restarts being deferred:
 systemctl restart ModemManager.service
 /etc/needrestart/restart.d/dbus.service
 systemctl restart docker.service
 systemctl restart networkd-dispatcher.service
 systemctl restart systemd-logind.service
 systemctl restart unattended-upgrades.service

No containers need to be restarted.

User sessions running outdated binaries:
 <user> @ user manager service: systemd[2102]

 

source listを追加

$ echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | sudo tee -a /etc/apt/sources.list.d/google-cloud-sdk.list

 

Google Cloudの公開鍵を追加

$ curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo apt-key --keyring /usr/share/keyrings/cloud.google.gpg add -

...
OK

 

Cloud SDK をインストール

$ sudo apt update && sudo apt install google-cloud-sdk

を実行すると、再び画面に以下が表示されます。

Daemons using outdated libraries

Which services should be restarted?

今回もデフォルトのまま <了解> を選択すると、処理が続行します。

Restarting services...
Service restarts being deferred:
 /etc/needrestart/restart.d/dbus.service
 systemctl restart docker.service
 systemctl restart networkd-dispatcher.service
 systemctl restart systemd-logind.service
 systemctl restart unattended-upgrades.service

No containers need to be restarted.

User sessions running outdated binaries:
 <user> @ user manager service: systemd[2102]

 

WSL2上で gcloud init --console-only を実行

gcloudコマンドのインストールが終わったため、次は WSL2 上で gcloud コマンドによる初期設定を行います。

今回は画面のないWSL2上で実行するため、公式ドキュメントにある通り --console-only フラグを使用します。
Cloud SDK の初期化  |  Cloud SDK のドキュメント  |  Google Cloud

なお、画面には WARNING と出ていますが、現時点では使えるみたいです。

$ gcloud init --console-only

WARNING: The `--console-only/--no-launch-browser` are deprecated and will be removed in future updates. Use `--no-browser` as a replacement.
Welcome! This command will take you through the configuration of gcloud.

Your current configuration has been set to: [default]

You can skip diagnostics next time by using the following flag:
  gcloud init --skip-diagnostics

Network diagnostic detects and fixes local network connection issues.
Checking network connection...done.
Reachability Check passed.
Network diagnostic passed (1/1 checks passed).

 
そのうち以下が表示されるため、 y を入力します。

You must log in to continue. Would you like to log in (Y/n)?  y

 
次は

Go to the following link in your browser:

    https://accounts.google.com/o/oauth2/auth?***

Enter authorization code:

が表示されます。

Windows上のブラウザに表示されているURLをコピペし、冒頭で用意した Google Cloud 開発用のGoogleアカウントでログインします。

ログイン後に操作を進めていくと、verification code が表示されている Sign in to the gcloud CLI 画面になります。

この verification codeを、WSL2のターミナルにある Enter authorization code へ入力します。

 
入力後、対話型で設定が進みます。

使用するプロジェクトを聞かれるため、先ほど用意した Google Cloud のプロジェクトの番号を指定します。

You are logged in as: [***@example.com].

Pick cloud project to use:
 [1] <project id>
 [2] Enter a project ID
 [3] Create a new project
Please enter numeric choice or text value (must exactly match list item):  1

 
プロジェクトを指定すると、処理が進んでいき、初期設定が完了します。

Not setting default zone/region (this feature makes it easier to use
[gcloud compute] by setting an appropriate default value for the
--zone and --region flag).
See https://cloud.google.com/compute/docs/gcloud-compute section on how to set
default compute region and zone manually. If you would like [gcloud init] to be
able to do this for you the next time you run it, make sure the
Compute Engine API is enabled for your project on the
https://console.developers.google.com/apis page.

Created a default .boto configuration file at [/home/<user>/.boto]. See this file and
[https://cloud.google.com/storage/docs/gsutil/commands/config] for more
information about configuring Google Cloud Storage.
Your Google Cloud SDK is configured and ready to use!

* Commands that require authentication will use ***@example.com by default
* Commands will reference project `<project id>` by default
Run `gcloud help config` to learn how to change individual settings

This gcloud configuration is called [default]. You can create additional configurations if you work with multiple accounts and/or projects.
Run `gcloud topic configurations` to learn more.

Some things to try next:
* Run `gcloud --help` to see the Cloud Platform services you can interact with. And run `gcloud help COMMAND` to get help on any gcloud command.
* Run `gcloud topic --help` to learn about advanced features of the SDK like arg files and output formatting
* Run `gcloud cheat-sheet` to see a roster of go-to `gcloud` commands.

 
画面にもある通り /home/<user>/.boto ファイルが作成されています。

 

Djangoアプリの設定変更

Cloud Run で実行するため、Djangoアプリの設定を変更します。

 

Dockerfileの修正

以前の記事で用意したDockerfileを修正します。
WSL2 + Ubuntu 22.04.1 な環境上のDockerへ、既存のDjangoアプリを載せてみた - メモ的な思考的な

 
修正後の全体像はこんな感じです。

FROM python:3.10.7-slim

# Djangoアプリの8080番ポートを公開するおしらせ
EXPOSE 8080

# 必要なライブラリをインストール
# 実行時に `debconf: delaying package configuration, since apt-utils is not installed` が出るが、無視してよさそう
RUN apt-get update \
    && apt-get install -y --no-install-recommends sqlite3 \
    && apt-get -y clean \
    && rm -rf /var/lib/apt/lists/*

# Download the static build of Litestream directly into the path & make it executable.
# This is done in the builder and copied as the chmod doubles the size.
# ref: https://github.com/steren/litestream-cloud-run-example/blob/main/Dockerfile
ADD https://github.com/benbjohnson/litestream/releases/download/v0.3.9/litestream-v0.3.9-linux-amd64-static.tar.gz /tmp/litestream.tar.gz
RUN tar -C /usr/local/bin -xzf /tmp/litestream.tar.gz

# working directoryの設定
WORKDIR /app

# リポジトリのファイルをコピー
COPY . /app

# パッケージインストール
RUN pip install --no-cache-dir -r requirements.txt

# Copy Litestream configuration file & startup script.
COPY ./litestream.yml /etc/litestream.yml
COPY ./run.sh /app/run.sh

#CMD ["python", "manage.py", "runserver", "0.0.0.0:8080"]
CMD ["/bin/bash", "/app/run.sh"]

 

run.sh ファイルの作成

以下の記事をベースに、「Cloud RunでDjangoアプリを起動する際に、Litestreamにより Cloud Storage から SQLite をリストアする」処理を書いた run.sh ファイルを作成します。
SQLite + Litestream + CloudRun で「個人開発並みの予算でもSQLを捨てない」バックエンド構築(Next.jsを例にして) - Qiita

#!/bin/sh
set -e

# コンテナ起動時に持っているSQLiteのデータベースファイルは、
# 後続処理でリストアに成功したら削除したいので、リネームしておく
if [ -f /app/ringo.db ]; then
  mv /app/ringo.db /app/ringo.db.bk
fi

# Cloud Storage からリストア
litestream restore -if-replica-exists -config /etc/litestream.yml /app/ringo.db

if [ -f /app/ringo.db ]; then
  # リストアに成功したら、リネームしていたファイルを削除
  echo "---- Restored from Cloud Storage ----"
  rm /app/ringo.db.bk
else
  # 初回起動時にはレプリカが未作成であり、リストアに失敗するので、
  # その場合には、冒頭でリネームしたdbファイルを元の名前に戻す
  echo "---- Failed to restore from Cloud Storage ----"
  mv /app/ringo.db.bk /app/ringo.db
fi

# マイグレーションを実行
python manage.py migrate

# レプリケーションしながらDjangoを起動
exec litestream replicate -exec "python manage.py runserver 0.0.0.0:8080" -config /etc/litestream.yml

 

Litestreamの設定ファイルを作成

必要な設定ファイルになります。

なお、 REPLICA_URL は gcloud コマンドでデプロイする時に与える値となります。

実際には、バケットの名前 (+ 必要に応じてディレクトリ名) になります。

dbs:
  - path: /app/ringo.db
    replicas:
      - url: ${REPLICA_URL}

 

.gcloudignore ファイルの作成

今回、Dockerfileの中で COPY を行っています。

ただ、 Cloud Run 向けの場合、COPY対象外のファイルは .dockerignore ではなく、 .gcloudignore ファイルが使われます。

 
今回は、以下の記事に方針に従い、必要なものだけを指定します。
.gcloudignore で全部無視して必要なものだけ指定する - ぽ靴な缶

また、指定した後は、WSL2上で gcloud meta list-files-for-upload を実行し、余計なファイルが含まれていないか確認します。

なお、DBは ringo.db ファイルなので、忘れないように指定します。

# ignore all
*

# upload list
# directories
!apps
!dj_ringo_tabetter
!static
!templates

# files
!apples.yaml
!Dockerfile
!litestream.yml
!manage.py
!requirements.txt
!ringo.db
!run.sh

# ignore
**/*.pyc

 

Djangoの settings.py にある ALLOWED_HOSTS を修正

初めてデプロイするまでは Cloud RunのURLが決まらないっぽいので、ひとまず ALLOWED_HOSTS = ['*'] にしておきます。

URLが決まったら、そのホスト名に修正し、再度デプロイします。

 
以上で設定変更は完了です。

 

gcloud beta run deploy コマンドでデプロイ

以下を参考に、 gcloud beta run deploy コマンドでデプロイします。

今回使用するコマンドはこんな感じです。

gcloud beta run deploy ringo-tabetter \
  --source .  \
  --set-env-vars REPLICA_URL=gcs://<バケット名>/<ディレクトリ名> \
  --max-instances 1 \
  --execution-environment gen2 \
  --no-cpu-throttling \
  --allow-unauthenticated \
  --region asia-northeast1 \
  --project <プロジェクト名>

 
コマンドの引数は色々ついていますが、

な設定です。

 
なお、 execution-environment オプションはbetaにしか存在しないようで、 beta なしだとエラーになります。

公式ドキュメントにも beta の記載があります。
Selecting an execution environment (services)  |  Cloud Run Documentation  |  Google Cloud

ERROR: (gcloud.run.deploy) unrecognized arguments:

 --execution-environment flag is available in one or more alternate release tracks. Try:

  gcloud alpha run deploy --execution-environment
  gcloud beta run deploy --execution-environment

  --execution-environment
  gen2
  To search the help text of gcloud commands, run:
  gcloud help -- SEARCH_TERMS

 
gcloud beta run deploy を実行すると、以下が表示されます。 y を入力して進みます。

API [artifactregistry.googleapis.com] not enabled on project [***]. Would you like to enable and retry (this will take a few minutes)? (y/N)?  y

 
次の表示も、 y を入力します。

Enabling service [artifactregistry.googleapis.com] on project [***]...
Operation "operations/***" finished successfully.
Deploying from source requires an Artifact Registry Docker repository to store built containers. A repository named
[cloud-run-source-deploy] in region [asia-northeast1] will be created.

Do you want to continue (Y/n)?  y

 
次も y です。

This command is equivalent to running `gcloud builds submit --pack image=[IMAGE] .` and `gcloud run deploy <app> --image [IMAGE]`

API [run.googleapis.com] not enabled on project [***]. Would you like to enable and retry (this will take a fewminutes)? (y

 
すると、ビルドが走ります。質問があった時は y で続けます。

Enabling service [run.googleapis.com] on project [91644765501]...
Operation "operations/acf.***" finished successfully.
Building using Buildpacks and deploying container to Cloud Run service [ringo-tabetter] in project [***] region [asia-northeast1]
⠼ Building and deploying new service... Uploading sources.
⠹ Building and deploying new service... Uploading sources.
  ✓ Uploading sources...
  . Building Container...
  . Creating Revision...
  . Routing traffic...
  . Setting IAM Policy...
API [cloudbuild.googleapis.com] not enabled on project [***]. Would you like to enable and retry (this will take a few minutes)? (y/N)?

 
デプロイが正常に終わると、以下の表示になります。

Service [ringo-tabetter] revision [***] has been deployed and is serving 100 percent of traffic.
Service URL: https://ringo-tabetter-syqtxyot6q-an.a.run.app

 
なお、手元の環境では一度以下のエラーになりました。ただ、再実行したところデプロイが完了しました。

Enabling service [cloudbuild.googleapis.com] on project [***]...
Operation "operations/acf.***" finished successfully.
Deployment failed
ERROR: (gcloud.beta.run.deploy) INVALID_ARGUMENT: could not resolve source: googleapi: Error 403: ***@cloudbuild.gserviceaccount.com does not have storage.objects.get access to the Google Cloud Storage object., forbidden

 

動作確認

Service URLとして表示されている https://ringo-tabetter-syqtxyot6q-an.a.run.app にアクセスすると、いつものアプリが表示されました。

 

状況確認

請求の確認

以下のページより請求額を確認します。
https://console.cloud.google.com/billing

今のところ請求は発生していないようです。

 

Cloud Storage の使用量確認

Cloud StorageのConsoleでは確認できなかったため、Metrics Explorerより確認します。
hawksnowlog: GCP のクラウドストレージでバケットの使用量を確認する方法

デプロイ用バケットは約130MB、Litestream用のバケットは 0.07MBでした。

Cloud Storageは5GBまで無料枠があるため、当分は大丈夫そうです。
https://cloud.google.com/storage/pricing?hl=ja#cloud-storage-always-free  
 

Artifact Registoryへの登録確認

今回デプロイしたイメージが登録されています。
https://console.cloud.google.com/artifacts

Artifact Registoryの使用量はAPIとサービスで確認すればよいのかな。。。
https://console.cloud.google.com/apis/api/artifactregistry.googleapis.com/quotas

 

その他

Cloud Runの停止方法について

Cloud Runを停止するのではなく、ロールを外すことで実現できるようです。
Cloud Runでサービスを停止する

 

WSL2で gcloud auth login する場合の注意点

--no-launch-browser が使えなくなるようです。
WSL2でのCloud SDKツールの承認を行う方法(gcloud auth login)がちょっとややこしくなっているらしい - Qiita

ただ、上記QiitaでリンクされているIssue Trackerを見ると

update to what I have observed - using Google Cloud SDK 398.0.0: - the "--no-launch-browser" switch no longer gives a deprecation warning and continues to work the way it always did. That's awesome - much appreciated to the Google devs!

So anyone using the "--no-browser" switch, just change to "--no-launch-browser" and you should be good to go AFAIK.

https://issuetracker.google.com/issues/224754679?pli=1

というコメントが 8/25にあったため、また状況が変わっているのかもしれません。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi/dj_ringo_tabetter

今回のプルリクはこちらです。
https://github.com/thinkAmi/dj_ringo_tabetter/pull/19

Tweepyをアップデートしたタイミングで、使用するTwitter APIを v1.1 から v2 に切り替えてみた

以前、個人アプリを Python 3.10 & Django 4.1 へとアップデートしました。
Python3.7 & Django 2.1 な個人アプリを Python 3.10 & Django 4.1 へとアップデートした - メモ的な思考的な

その際、 pip-review で他のライブラリのバージョンも最新に上げたため、Tweepyも 3.8 から 4.10.1 になりました。

ただ、その時はTweepyまわりのソースコードを修正しなかったため、Tweepyの中で使用しているTwitter APIは1.1のままでした。

せっかくなので、Tweepyの中で使っているTwitter APIv1.1 から v2 へ切り替えようと考えました。

そこで、実際に切り替えてみた時のメモを残します。

 
目次

 

環境

 

切り替え作業

Twitter Developer account を申請

しばらく放置していたこともあり、Twitterの Developer Portal を開いたところ申請が必要な状態になっていました。
https://developer.twitter.com/en/portal/dashboard

 
そこで、画面の内容に従い、必要な項目を入力して申請を行いました。

「審査を開始する」メールが届いたものの、すぐには審査完了になりませんでした。

自分の場合は、約9時間後に無事審査が通りました。

 

Tweepyのドキュメントから、API v2で使えそうなメソッドを探す

Tweepy切り替え前は

tweepy.Cursor(api.user_timeline, **options).items(TWEET_COUNT)

のようにして、自分のタイムラインから自分のツイートを拾っていました。

そこで、Twitter API v2 + Tweepy を使う時の方法をTweepyのドキュメントで調べてみました。
Client — tweepy 4.10.1 documentation

 
自分のタイムラインからツイートを拾えそうなメソッドとしては

の2つがありました。

試してみたところ、

  • Client.get_home_timeline
    • 自分のタイムラインのツイートを取得
      • 自分以外のツイートも取得されてた
  • Client.get_users_tweets
    • 指定したユーザーのツイートを取得
    • 自分を指定すれば、自分のツイートを拾える

の違いがあったため、今回は Client.get_users_tweets を使うことにしました。

 

Client生成時に access_token と access_token_secret を指定する

今までは

auth = tweepy.AppAuthHandler(
    os.environ['TWITTER_CONSUMER_KEY'],
    os.environ['TWITTER_CONSUMER_SECRET'])
api = tweepy.API(auth)

のように、 consumer_keyconsumer_secret だけ指定すれば動作していました。

 
ただ、API v2では access_tokenaccess_token_secret も指定する必要があるため、追加します。
Client — tweepy 4.10.1 documentation

Client(
    consumer_key=os.environ['TWITTER_CONSUMER_KEY'],
    consumer_secret=os.environ['TWITTER_CONSUMER_SECRET'],
    access_token=os.environ['TWITTER_ACCESS_TOKEN'],
    access_token_secret=os.environ['TWITTER_ACCESS_TOKEN_SECRET']
)

 
なお、 access_tokenaccess_token_secret.env ファイルに設定し、リポジトリに追加しないようにします。

 

Client.get_me を使って、自分の user ID を取得する

Client.get_users_tweets のパラメータの説明に

Unique identifier of the Twitter account (user ID) for whom to return results. User ID can be referenced using the user/lookup endpoint. More information on Twitter IDs is here.

とあり、Client.get_users_tweets を使うには、自分の user ID が必要でした。ただ、 user ID はAPI経由で取得するしかなさそうでした。

 
そこでTweepy のメソッドを探してみたところ、 Client.get_me が使えそうでした。
https://docs.tweepy.org/en/stable/client.html#tweepy.Client.get_me

ただ、リクエストの都度 Client.get_me を呼んで user ID を取得するのはムダなため、

  • 取得した user ID をコンソールへ出力
  • 出力された内容を環境変数( .env) へ手で設定
  • API v2で user ID を使う場合は、環境変数から読み込む

とすることにしました。

そこで今回は、Djangoコマンドとして Client.get_me をラップしたものを作成しました。

import os

from django.core.management.base import BaseCommand
from tweepy import Client


class Command(BaseCommand):
    """ Twitter GET /2/users/me から自分の user_id を取得し、コンソールに表示する

        https://docs.tweepy.org/en/stable/client.html#tweepy.Client.get_me
        なお、取得した user_id は .env や環境変数に設定すること
    """
    def handle(self, *args, **options):
        client = Client(
            consumer_key=os.environ['TWITTER_CONSUMER_KEY'],
            consumer_secret=os.environ['TWITTER_CONSUMER_SECRET'],
            access_token=os.environ['TWITTER_ACCESS_TOKEN'],
            access_token_secret=os.environ['TWITTER_ACCESS_TOKEN_SECRET']
        )

        response = client.get_me()
        print(response)

 
実行すると、こんな感じでレスポンスが返ってきます。この中の User id を使えば良さそうです。

Response(data=<User id=*** name=thinkAmi username=thinkAmi>, includes={}, errors=[], meta={})

 

Client.get_users_tweets を使って自分のツイートを取得

user ID を取得できたので、次は Client.get_users_tweets を使って自分のツイートを取得します。

Client.get_users_tweets には引数があるため、今回必要そうな引数を設定します。

今回はこんな感じの実装になりました。これでTweepyにおける切り替え作業は終わりです。

self.twitter_client.get_users_tweets(
            id=os.environ['USER_ID'],
            exclude=['retweets', ],
            tweet_fields=['created_at', ],
            since_id=self.last_search.prev_since_id,
            user_auth=False,
            pagination_token=pagination_token
        )

 
なお、引数については以降で詳しく見ていきます。

 

Client.get_users_tweetsの引数について

id

Client.get_me で取得した user ID を設定します。

 

exclude

リツイートやリプライを除外するか指定できそうです。

ただ、Tweepyの説明には

When exclude=retweets is used, the maximum historical Tweets returned is still 3200. When the exclude=replies parameter is used for any value, only the most recent 800

とあり、 replies を指定すると取得できるツイート数が減ってしまいそうでした。

自分の場合はほとんどリプライを使っていないため、 retweets のみ指定することにしました。

 

tweet_fields

Tweepyの説明には

For methods that return Tweets, this fields parameter enables you to select which specific Tweet fields will deliver in each returned Tweet object. Specify the desired fields in a comma-separated list without spaces between commas and fields.

https://docs.tweepy.org/en/stable/expansions_and_fields.html#tweet-fields-parameter

とありました。

Twitterのドキュメントを見ると、デフォルトで返ってきそうなのは idtext だけのようでした。
Tweet object | Docs | Twitter Developer Platform

 
個人アプリでは created_at も利用していたため、 tweet_fields に created_at を指定することとしました。

 

since_id

API v1.1では「指定したid以降のツイートを取得する」という場合、 since_id を指定していました。
https://github.com/thinkAmi/dj_ringo_tabetter/blob/16a6663992/apps/tweets/management/commands/gather_tweets.py#L68

API v2 でも同じようなことができるかをTweepyのドキュメントで見たところ、 since_id がありました。引き続き利用すれば良さそうです。

 

user_auth

Client.get_users_tweets では、引数 user_auth のデフォルト値は False でした。

今回は OAuth 1.0a User Context to authentication を使うため、 user_auth=True にします。

False のままの場合、401エラーが返ってきます。

tweepy.errors.Unauthorized: 401 Unauthorized
Unauthorized

 

pagination_token

ツイートの取得が大量の場合、API v1.1 の時は

tweepy.Cursor(api.user_timeline, **options).items(TWEET_COUNT)

# https://github.com/thinkAmi/dj_ringo_tabetter/blob/16a6663992/apps/tweets/management/commands/gather_tweets.py#L61

のように Cursortimes を使っていました。

 
API v2の場合は next_tokenprevious_token のいずれかを指定することで、「次はここから取得する」を指定できそうです。
GET /2/users/:id/tweets | Docs | Twitter Developer Platform

今回は since_id を指定することから、 next_token がレスポンスとして返ってきます。その next_token の値を次のリクエストの pagination_token に載せれば良さそうです。

 

レスポンスと next_token について

今回 since_id を指定するため、いくつかのレスポンスパターンがありそうです。

どんな形でレスポンスされるのか見てみます。

 

since_id 以降のツイートがない場合

Tweepyの場合、 dataに None が返ってくるようです。

Response(data=None, includes={}, errors=[], meta={'result_count': 0})

 

since_id 以降のツイートはあるが、一度のリクエストですべて取得できる場合

data にツイートオブジェクトが含まれます。

一方、 meta には next_token がありません。

Response(data=[<Tweet id=*** text='***'>, ...], includes={}, errors=[], meta={'result_count': 1, 'newest_id': '***', 'oldest_id': '***'})

 

since_id 以降のツイートはあり、かつ、一度のリクエストで取得しきれない場合

dataTweetオブジェクトがあり、かつ、 metanext_token が含まれます。

Response(data=[<Tweet id=*** text='***'>, ...], includes={}, errors=[], meta={'result_count': 5, 'newest_id': '***', 'oldest_id': '***', 'next_token': '***'})

 

TweepyのTweetオブジェクトの型について

公式ドキュメントの以下に記載がありました。
Models — tweepy 4.10.1 documentation

 

動作確認

Djangoカスタムコマンド

$ python manage.py gather_tweets

を実行し、エラーとならないことを確認しました。

 

その他

Twitter API v2 へ切り替える作業はここまでで終わりです。

ただ、個人アプリではTweepyに関係ない部分にも修正を加えましたので、メモとして残しておきます。

 

Django の get_or_create や update_or_create を使うようにした

最近のDjangoでは

ができるので、実装を変更しました。

なお

Asynchronous version: aupdate_or_create()

もあるようですが、現在のDjangoアプリでは Asynchronous version を使っていないため、 aupdate_or_create などは使用していません。

 

SQLでデータベースの重複データの削除をした

改めてデータベースの中身を見たところ、いくつか重複しているツイートが存在しました。

アプリ作成当初はデータベースを適当に作っていたのが原因でしょう。。。

そこで、まずはどれだけデータが重複しているかを調べてみました。

SELECT id, tweet_id, name
FROM tweets_tweets
WHERE id NOT IN (
    SELECT tmp.id FROM (
        SELECT min(id) AS id
        FROM tweets_tweets
        GROUP BY tweet_id
    ) AS tmp
)
ORDER BY tweet_id DESC

 
データが存在することを確認できたら、一番古いid以外の重複データを削除します。

DELETE FROM tweets_tweets
WHERE id NOT IN (
    SELECT tmp.id FROM (
        SELECT min(id) AS id
        FROM tweets_tweets
        GROUP BY tweet_id
    ) AS tmp
)

 

Tweetモデルにユニーク制約を追加

運用していて tweet_id は重複することがないとわかったため、 Tweet モデルの tweet_id にユニーク制約をつけることにしました。

class Tweets(models.Model):
    """ リンゴに関係するツイートを持つModel """
    ...
    tweet_id = models.BigIntegerField('Tweet ID', unique=True)  # 追加
    ...

 
マイグレーションの作成と適用を行います。

$ python manage.py makemigrations

$ python manage.py migrate

 

LastSearchモデルに updated_at を追加

機能としては不要なのですが、運用する中で「LastSearchの更新がきちんと行えているかを知るために、いつLastSearchモデルを更新したか」を把握したくなりました。

そこで、LastSearchモデルに udpated_at を追加しました。

なお、 auto_now=True を付与し、データの更新をするたびに updated_at も更新されるようにします。

class LastSearch(models.Model):
    """ 前回検索時の情報を持たせておくModel """
    prev_since_id = models.BigIntegerField('前回検索時のsince_id')
    updated_at = models.DateTimeField('更新日時', auto_now=True)  # 追加

 
こちらもマイグレーションの作成と適用を行います。

 

テストコードを修正

Twitter API v2 対応に伴い、テストコードを修正しました。

また、実装ロジックも修正したため、不要となったテストコードは削除しました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi/dj_ringo_tabetter

今回のプルリクはこちらです。
https://github.com/thinkAmi/dj_ringo_tabetter/pull/18

WSL2 + Ubuntu 22.04.1 な環境上のDockerへ、既存のDjangoアプリを載せてみた

前回の記事にて、個人アプリをDjangoを4.1にアップデートするついでに、DBをPostgreSQLからSQLiteへと移行しました。
https://thinkami.hatenablog.com/entry/2022/09/14/215942

DBをSQLiteに移行したことにより、DjangoアプリをDockerの1コンテナで起動できそうでした。

そこで、WSL2上のDockerに既存のDjangoアプリを載せてみたため、メモを残します。

 
目次

 

環境

 

準備

Dockerfileの作成

今回、Dockerで動作させるDjangoアプリは、WSL2上にあるDjangoアプリのソースコードCOPY して使うことにします。

あとは、Dockerの中で SQLite を使うので、 sqlite3 パッケージを追加します。

FROM python:3.10.7-slim

# Djangoアプリの8000番ポートを公開するおしらせ
EXPOSE 8000

# 必要なライブラリをインストール
# 実行時に `debconf: delaying package configuration, since apt-utils is not installed` が出るが、無視してよさそう
RUN apt-get update -qq \
    && apt-get install -y --no-install-recommends sqlite3 \
    && apt-get -y clean \
    && rm -rf /var/lib/apt/lists/*

# working directoryの設定
WORKDIR /app

# リポジトリのファイルをコピー
COPY . /app

# パッケージインストール
RUN pip install --no-cache-dir -r requirements.txt

# 起動
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

 

.dockerignore ファイルを用意

今回、WSL2上のリポジトリのファイル群をDocker内に COPY しています。

ただ、venv環境のファイルや .env ファイルなどはコピーしてほしくありません。

そこで、 .dockerignore ファイルを用意し、Dockerにコピーしてほしくないファイルを指定しました。

 
以下が今回の .dockerignore ファイルです。

# .env
.env
.env_example

# git
.git
.gitignore
.idea

# Docker
Dockerfile
.dockerignore

# DB dumps
dump.json
latest.dump

# venv
env
env_3_10_7
env_3_10_7_1

# pyenv
.python-version

# pytest
.pytest_cache
conftest.py
pytest.ini

# docs
*.md

 

Dockerfileを書く上でのメモ

Dockerfileでは apt ではなく apt-get を使う

apt を指定すると、Dockerイメージのビルド時に以下の警告が出ます。

WARNING: apt does not have a stable CLI interface. Use with caution in scripts.

 
apt(8)の以下の記載にあるように、スクリプトでは apt-get を使ったほうが良いようです。

apt(8) コマンドラインはエンドユーザ向けツールとして設計されています。動作はバージョン間で変更される可能性があります。後方互換性を損なうことのないようには努めますが、変更がインタラクティブな使用に有益と思われる場合には、その保証はありません。

apt(8) のすべての機能は、apt-get(8) や apt-cache(8) など専用の APT ツールで利用可能です。apt(8) は、単にいくつかのオプションのデフォルト値を変更します (apt.conf(5) の特にバイ ナリ範囲を参照)。可能な限り下位互換性を保つように、スクリプトでは (潜在的に有効になっているいくつかの追加オプションをつけて) コマンドを使うべきです。

https://manpages.ubuntu.com/manpages/jammy/ja/man8/apt.8.html

 
そのため、Dockerfile内でも apt-get を使うようにします。

 

警告: debconf: delaying package configuration, since apt-utils is not installed

上記と同じく、Dockerイメージをビルドしている時に

debconf: delaying package configuration, since apt-utils is not installed

という警告が出ました。

調べてみたところ、以下の記事にある通り、無視しても良さそうでした。
Dockerビルド時のエラーメッセージ debconf: delaying package configuration, since apt-utils is not installed | かきノート

そこで、今回は個人アプリということもあり、無視することにしました。

 

EXPOSEでDjangoアプリを起動するポートを指定

Dockerの公式ドキュメントには

EXPOSE 命令だけは、実際にはポートを 公開しません。これは、どのポートを公開する意図なのかという、イメージの作者とコンテナ実行者の両者に対し、ある種のドキュメントとして機能します。コンテナの実行時に実際にポートを公開するには、 docker run で -p フラグを使い、公開用のポートと割り当てる( マップする)ポートを指定します。

https://docs.docker.jp/engine/reference/builder.html#expose

とあります。

今回のDocker上のDjangoアプリは 8000 ポートで起動するため、 EXPOSE 8000 を指定しておきます。

 

apt-get install の --no-install-recommends について

今回 apt-get install 時に、以下の記事を参考にして --no-install-recommends を追加しています。

デフォルトだと recommends しているだけの必須ではないパッケージも一緒に入って時間がかかるので --no-install-recommends をつけるのが常套手段

debianパッケージ周りでよく使うコマンドとオプション - sonots:blog

 

ビルド用ライブラリは追加してないので、 apt-get remove は不要

今回のDjangoアプリではビルド用ライブラリを追加していないため、 apt-get remove は不要でした。

 

aptキャッシュはクリーンにする

Dockerの公式ドキュメントの「Dockerfile のベスト・プラクティス」には以下の記載がありました。

apt キャッシュをクリーンアップし /var/lib/apt/lists を削除するのは、イメージ容量を小さくするためです。そもそも apt キャッシュはレイヤー内に保存されません。RUN 命令は apt-get update から始めているので、 apt-get install の前に必ずパッケージのキャッシュが更新されます。

注釈 公式の DebianUbuntu のイメージは 自動的に apt-get clean を実行する ので、明示的にこのコマンドを実行する必要はありません。

Dockerfile のベスト・プラクティス — Docker-docs-ja 20.10 ドキュメント

 
そこで、今回は

&& apt-get -y clean \
&& rm -rf /var/lib/apt/lists/*

を追加しています。

なお、今回のベースイメージが slim なこともあり、念のため apt-get clean を実行しています。

ちなみに、 apt-get clean は以下の挙動のようです。

The same as above, except it removes all packages from the package cache. This may not be desirable if you have a slow Internet connection, since it will cause you to redownload any packages you need to install a program.

The package cache is in /var/cache/apt/archives .

...

AptGet/Howto - Community Help Wiki  
 

pip installで no-cache-dir してキャッシュを使わないようにする

以下にあるように、 pip install する時に --no-cache-dir を指定して、キャッシュを使用しないようにしました。
python - What is pip's --no-cache-dir good for? - Stack Overflow

なお、上記のコメントにある通り、Python 3.6.10 以降のイメージであれば PIP_NO_CACHE_DIR という設定も使えるようです。
python - How to suppress pip upgrade warning? - Stack Overflow

 

ビルド

いつも通りDockerイメージをビルドします。

$ docker build -t ringo-tabetter:0.1 .
...
Successfully built 8b90c3a7c141
Successfully tagged ringo-tabetter:0.1

 

Dockerコンテナを起動

$ docker run -p 8888:8000 ringo-tabetter:0.1

Watching for file changes with StatReloader

 

Dockerコンテナの中身を確認

余計なファイルが COPY されていないか、別ターミナルを開いて確認します。

.dockerignore で指定したファイルは COPY されていませんでした。

# コンテナIDの確認
$ docker ps
CONTAINER ID   IMAGE                COMMAND  ...
2f3d4fdb4e6b   ringo-tabetter:0.1   "python manage.py ru…" ...

# コンテナの中に入る
$ docker exec -i -t 2f3d4fdb4e6b bash
root@2f3d4fdb4e6b:/app# 

# ファイルを確認
root@2f3d4fdb4e6b:/app# ls -al
total 516
drwxr-xr-x 1 root root   4096 Sep 19 11:19 .
drwxr-xr-x 1 root root   4096 Sep 19 11:20 ..
-rw-r--r-- 1 root root   1076 Sep  8 14:07 LICENSE
drwxr-xr-x 2 root root   4096 Sep 14 12:30 __pycache__
-rw-r--r-- 1 root root   4374 Sep 11 13:07 apples.yaml
drwxr-xr-x 7 root root   4096 Sep 11 02:31 apps
drwxr-xr-x 1 root root   4096 Sep 18 09:37 dj_ringo_tabetter
-rw-r--r-- 1 root root    266 Sep 18 09:31 manage.py
-rw-rw-r-- 1 root root    541 Sep 18 09:01 requirements.txt
-rw-r--r-- 1 root root 471040 Sep 13 11:49 ringo.db
drwxr-xr-x 4 root root   4096 Sep  8 14:07 static
drwxr-xr-x 3 root root   4096 Sep  8 14:07 templates

 

動作確認

今回は Docker のポート 8000 を、ホストの 8888 で公開しています。

そこで、 http://127.0.0.1:8888/hc/total にアクセスすると、いつものアプリが表示されました。

これにより、WSL2上のDockerで動いていることが確認できました。

 

Dockerコンテナの停止と削除

必要に応じて、コンテナの停止と削除を行います。

# コンテナIDを確認
$ docker ps
CONTAINER ID   IMAGE                COMMAND ...
2f3d4fdb4e6b   ringo-tabetter:0.1   "python manage.py ru…"  ...

# コンテナを停止
$ docker stop 2f3d4fdb4e6b
2f3d4fdb4e6b

# コンテナを削除
$ docker rm 2f3d4fdb4e6b
2f3d4fdb4e6b

 

その他やったこと

WSL2 + Ubuntu 22.04.1 な環境のDockerにDjangoアプリを載せる以外に、今回やったこともメモしておきます。

 

requirements.txt から不要なパッケージを削除

Heroku + PostgreSQLで動かしていた時のパッケージがあったため、削除しました。

 

pytzを削除し、zoneinfoを使うよう修正

Django4系から pytz パッケージが非推奨となりました。
Django 4.0 主な変更点まとめ | ryu22eBlog

そのため、pytz を使っていた部分を修正し、テストコードでも zoneinfo を使うよう修正しました。

 

settings.pyを分割

Herokuで動かしていた時は settings.py は1つでした。

ただ、本来は各環境ごとに settings.py を用意するのが良さそうです。

そこで、以下を参考に、 settings.py を分割し、各環境のファイルを用意しました。
[Django] プロジェクト構成のベストプラクティスを探る - 2.設定ファイルを本番用と開発用に分割する - Qiita

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi/dj_ringo_tabetter

今回のプルリクはこちらです。
https://github.com/thinkAmi/dj_ringo_tabetter/pull/17

Python3.7 & Django 2.1 な個人アプリを Python 3.10 & Django 4.1 へとアップデートした

前回の記事で環境を構築したアプリですが、動作環境は Python 3.7 & Django 2.1 でした。
Python3.4 & Django1.8な個人アプリを、Python3.7 & Django 2.1 へとアップデートした - メモ的な思考的な

また、ブログには書いていませんでしたが、その後 Python 3.8 系とDjango 2.2 系にバージョンアップしていました。
https://github.com/thinkAmi/dj_ringo_tabetter/commit/236895fb8a7e85a6f46ec7e3555bb400354438f1

 
今回、WSL2 Ubuntu 環境にて Djangoアプリを最新の Python 3.10 & Django 4.1 へバージョンアップしたため、メモを残します。

 
目次

 

環境

 

バージョンアップ方針

やること

基本的にはテストがパスして動作すればOKとします。

なお、Djangoの公式ドキュメントによると、Django 4.1系はPython3.8系をサポートしているため、

  • まずはDjangoまわりをバージョンアップ
    • pip-review で一気にバージョンアップ
      • 小さなアプリなため、Django3系を飛ばしてもある程度は動くはず...
  • 最後にPythonをバージョンアップ

の流れで実施することにします。

なお、バージョンアップする上で問題が発生した場合は、都度対応していきます。

 

やらないこと

パッケージ管理方法の変更

今は rquirements.txt で各パッケージのバージョンを管理しています。

将来的には何かしらに移行したいですが、今回はこのままにします。

 

機能追加

今回はバージョンアップだけにします。

 

バージョンアップ開始

pip-reviewによるバージョンアップ

前回の記事でDjangoが動くところまで確認しているため、 pip-review を使って一気にパッケージをバージョンアップします。
jgonggrijp/pip-review: A tool to keep track of your Python package updates.

 

importlib-metadata のエラーに対応

pip-reviewを実行したところ、 importlib-metadata のバージョンアップでエラーが出ました。

$ pip-review --auto
Collecting atomicwrites==1.4.1
  Using cached atomicwrites-1.4.1.tar.gz (14 kB)
  Preparing metadata (setup.py) ... error
  error: subprocess-exited-with-error
  
  × python setup.py egg_info did not run successfully.
  │ exit code: 1
  ╰─> [25 lines of output]
      /home/<user>/dev/projects/dj_ringo_tabetter/env/lib/python3.8/site-packages/setuptools/_importlib.py:23: UserWarning: `importlib-metadata` version is incompatible with `setuptools`.
      This problem is likely to be solved by installing an updated version of `importlib-metadata`.
        warnings.warn(msg)  # Ensure a descriptive message is shown.

 
同じようなエラーがないかを探したところ、 setuptools に issue がありました。
[BUG] ImportError when using importlib with setuptools 60.9.0+ · Issue #3292 · pypa/setuptools

 
そこで、 requirements.txt にある importlib-metadata のバージョンを 0.20 から 0.21 へと修正します。

importlib-metadata==0.21

 
続いて、 pip install -r requirements.txt して、現在の環境の importlib-metadata をバージョンアップします。

バージョンアップ後、 pip-review --auto したところ、エラーとなることなくバージョンアップが完了しました。

 

Djangoアプリのテストを実行

PostgreSQLに関するエラーが出て失敗

Djangoアプリには pytest のテストコードがあるため、実行してみました。

すると

$ python -m pytest
...
django.db.utils.NotSupportedError: PostgreSQL 11 or later is required (found 10.6).

のような PostgreSQLのバージョンに関するエラーが発生しました。

公式ドキュメントを見ると、Django 4.1系でPostgreSQL 10 系のサポートがなくなったために発生しているようでした。
SupportedDatabaseVersions – Django

 
PostgreSQLが今のままでは動作しないため、対応を考えました。案としては以下のとおりです。

  • PostgreSQLのバージョンを上げる
  • 別のDBに移行する

 
PostgreSQLのバージョンを上げる場合、Djangoアプリの構成を変更せずに済みそうです。

ただ、現在の開発環境だとPostgreSQLはDockerで構築しているため、アップグレード作業が必要そうでした。

アップグレードの方法は色々ありそうでした。

 
一方、PostgreSQLからSQLiteへ移行する場合、Djangoの機能だけでできそうでした。
DjangoをsqliteからPostgreSQLに切り替えた(dumpdata/loaddata) - [Dd]enzow(ill)? with DB and Python

 
そこで今回は、 PostgreSQL から SQLite へ移行することにしました。

 

DBをPostgreSQLからSQLiteへ移行

再掲となりますが、以下の記事を参考に移行を進めます。
DjangoをsqliteからPostgreSQLに切り替えた(dumpdata/loaddata) - [Dd]enzow(ill)? with DB and Python

 
以下のコマンドを実行し、PostgreSQLのデータをダンプします。

$ python manage.py dumpdata --exclude auth.permission --exclude contenttypes > dump.json

 
settings.pyをSQLiteの設定へと書き換えます。

DATABASES = {
    'default': {
        'NAME': os.path.join(BASE_DIR, 'ringo.db'),
        'ENGINE': 'django.db.backends.sqlite3',
    }

 
データベースのマイグレーションをします。

$ python manage.py migrate

 
データをロードします。

$ python manage.py loaddata dump.json

Installed 794 object(s) from 1 fixture(s)

 
PyCharm の Database で中身を確認すると、データが移行されていました。

 
その後、Djangoアプリを起動して確認すると円グラフが表示されました。移行は成功したようです。

 

PostgreSQLの関数を使っているコードを修正

月別の折れ線グラフ用のデータを作成するために、Djangoアプリでは以下のコードを書いていました。

ここの extra() で使っている date_partPostgreSQLの関数になります。
9.9. 日付/時刻関数と演算子

cls.objects.extra(select={'month': "date_part('month', tweeted_at)::int"}) \
                .values('name', 'month') \
                .annotate(quantity=models.Count('name')) \
                .order_by('name', 'month')

 
このままではSQLiteで動作しないため、SQLite用の関数に修正します。

SQLiteでは strftime 関数を使うことで、同じような挙動が実現できそうでした。
Date And Time Functions

 
ただ、上記の公式ドキュメントの例では、strftimeに列名を渡している例が見つかりませんでした。

そこで stackoverflowの記事を探したところ、以下で列名を strftime を渡しているものがありました。 strftime の第二引数に列名を ' 無しで渡せば良いようです。
strftime function in SQLite does not work - Stack Overflow

そこで、stackoverflowを参考に修正します。

cls.objects.extra(select={'month': "strftime('%m', tweeted_at)"}) \
    .values('name', 'month') \
    .annotate(quantity=models.Count('name')) \
    .order_by('name', 'month')

 
差し替えた後、Djangoアプリの月別折れ線グラフがエラーにならないか確認しましたが、問題なく表示できました。

ただ、テストコードが落ちるようになりました。戻り値の配列が PostgreSQLSQLite では異なっているのが原因だったため、テストコードも修正しました。

 

Djangoの警告に対応

ここまででDjangoは起動できるようになりましたが、 run server したときに警告が出てましたので、対応します。

 

DEFAULT_AUTO_FIELD の警告に対応

run sever したときに以下の警告が出ていました。

WARNINGS:
tweets.LastSearch: (models.W042) Auto-created primary key used when not defining a primary key type, by default 'django.db.models.AutoField'.
    HINT: Configure the DEFAULT_AUTO_FIELD setting or the TweetsConfig.default_auto_field attribute to point to a subclass of AutoField, e.g. 'django.db.models.BigAutoField'.
tweets.Tweets: (models.W042) Auto-created primary key used when not defining a primary key type, by default 'django.db.models.AutoField'.
    HINT: Configure the DEFAULT_AUTO_FIELD setting or the TweetsConfig.default_auto_field attribute to point to a subclass of AutoField, e.g. 'django.db.models.BigAutoField'.

System check identified 2 issues (0 silenced).

 
この警告を調べたところ、以下の記事がありました。Djagno 3.2の変更によるものでした。

 
主キーを django.db.models.AutoField のままにするか django.db.models.BigAutoField にするか考えましたが、桁数が多いほうが良いだろうと考え django.db.models.BigAutoField とすることにしました。
Model field reference | Django documentation | Django

そこで、 settings.py の末尾に以下を追加しました。

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

 
変更後マイグレーションが必要なので実行します。

# マイグレーションファイルを作成
$ python manage.py makemigrations
Migrations for 'tweets':
  apps/tweets/migrations/0002_alter_lastsearch_id_alter_tweets_id.py
    - Alter field id on lastsearch
    - Alter field id on tweets

# マイグレーションを適用
$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions, tweets
Running migrations:
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying tweets.0002_alter_lastsearch_id_alter_tweets_id... OK

 
Djangoアプリを再起動したところ、警告は出なくなりました。

 

Python環境を3.10.7へバージョンアップ

前回の記事で pyenv を使ってPythonを入れていたため、今回も pyenv を使って Python 3.10.7 をインストールします。

Pythonのバージョンアップ

# venvを無効化
(env) $ deactivate

# pyenvでPython3.10.7をインストール
$ pyenv install 3.10.7
Downloading Python-3.10.7.tar.xz...
-> https://www.python.org/ftp/python/3.10.7/Python-3.10.7.tar.xz
Installing Python-3.10.7...
Installed Python-3.10.7 to /home/<user>/.anyenv/envs/pyenv/versions/3.10.7

# pyenvでバージョン切り替え
$ pyenv local 3.10.7

# バージョン確認
$ python --version
Python 3.10.7

# venv環境の作成
$ python -m venv env_3_10_7

# 有効化
$ source env_3_10_7/bin/activate

 

パッケージの再インストール

requirements.txt からパッケージを再インストールします。

 

backports.zoneinfo のエラーに対応

再インストールしたところ、以下のエラーが発生しました。

(env_3_10_7) $ pip install -r requirements.txt

...
  note: This error originates from a subprocess, and is likely not a problem with pip.
  ERROR: Failed building wheel for backports.zoneinfo
Failed to build backports.zoneinfo

 
requirements.txt を見ると、たしかに backports.zoneinfo がありました。

backports.zoneinfo==0.2.1

 
zoneinfo モジュールですが、Python 3.9から標準パッケージとして導入されました。
Python 3.9の新機能 - python.jp

そのため、 backports.zoneinfoPython 3.9 未満向けのバックポートパッケージと考えられました。
pganssle/zoneinfo: Reference implementation for the proposed standard library module zoneinfo

 
今回構築するPython3.10系の環境では不要と判断し、 requiremetns.txt から backports.zoneinfo の行を削除しました。

再度 pip install -r requirements.txt すると、エラーにならずにインストールが完了しました。

 

動作確認

再度

  • Djangoアプリの起動
  • python -m pytest によるテストの実行

を行い、エラーなく動作していることが確認できました。

 
これにより、Python 3.10 & Django 4.1 へのバージョンアップが完了しました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi/dj_ringo_tabetter

今回のプルリクはこちら。
https://github.com/thinkAmi/dj_ringo_tabetter/pull/16

なお、Herokuへアップロードしている master ブランチへはプルリクをマージせず、しばらくは development ブランチへマージすることにします。