IntelliJ Platform Plugin開発で、Kotlin UI DSL Version 2 や Swing を使って、ToolWindow上にコンポーネントを表示してみた

前回、IntelliJ Platform Plugin Templateを使って、JetBrains系IDEプラグイン開発の Hello, world 的なことをやってみました。
IntelliJ Platform Plugin Templateを使って、「エディタのコンテキストメニューからダイアログを表示する」だけのJetBrains系IDEプラグインを作ってみた - メモ的な思考的な

その IntelliJ Platform Plugin Template には、 ToolWindow を表示するサンプルコードが同梱されていました。

サンプルコード を見ると

fun getContent() = JBPanel<JBPanel<*>>().apply {
    val label = JBLabel(MyBundle.message("randomLabel", "?"))

    add(label)
    add(JButton(MyBundle.message("shuffle")).apply {
        addActionListener {
            label.text = MyBundle.message("randomLabel", service.getRandomNumber())
        }
    })
}

という実装だけで、画面を伴う ToolWindow が表示されていました。

一方、これを見ただけではどうやって画面を作ればよいか分かりませんでした。

 
そこで、自分で ToolWindow をイチから作成することで、どのようにして画面上に各部品を置けばよいか把握できたことから、その時のメモを残します。

 
目次

 

環境

  • Widnows 11
  • IntelliJ IDEA 2023.3.4 Ultimate Edition
  • IntelliJ Platform Plugin Template 1.12.0
  • Kotlinで実装

 

ToolWindowを自作する

IntelliJ Platform Plugin SDKのドキュメントを見たところ、以下に ToolWindow の情報がありました。
Tool Windows | IntelliJ Platform Plugin SDK

ただ、ToolWindowの概念的な内容が多く、実際にどのような順で定義していけばよいか分かりませんでした。

 
次に、ToolWindowのページからリンクされていたサンプルコードを見てみました。
https://github.com/JetBrains/intellij-sdk-code-samples/tree/main/tool_window

サンプルコードはJavaでしたが、

  • ToolWindowFactory を継承したFactoryクラス
  • plugin.xml へのエントリ追加

をすれば良さそうでしたので、実際にためしてみます。

 
なお、サンプルコードではFactoryクラスに画面の構成も定義されていました。

ただ、画面の部品が増えると

  • 画面の定義
  • Factoryの定義

が混在して分かりづらくなりそうと考え、今回は画面とFactoryで定義を分けることにします。

 

Kotlin UI DSL Version 2で、画面を定義する

まず、Factoryから呼び出される画面の定義から行います。

IntelliJ Platform Pluginでの画面定義は

The IntelliJ Platform includes a large number of custom Swing components. Using those components in your plugins will ensure that your plugin looks and works consistently with the UI of the rest of the IDE, and can often reduce the code size compared to using the default Swing components.

 
https://plugins.jetbrains.com/docs/intellij/user-interface-components.html

とあるように、JavaSwing で行えるようです。

また、Figmaで画面レイアウトを描くためのUIKitも用意されていました。 https://jetbrains.design/intellij/resources/UI_kit/

引き続き公式ドキュメントを読んでいたところ、 Kotlin UI DSL Version 2 を使うことで、KotlinでSwingを簡潔に書けるような記載がありました。
Kotlin UI DSL Version 2 | IntelliJ Platform Plugin SDK

 
そこで、今回の画面定義は

  • src/main/kotlin/com/github/thinkami/hellojetbrainsplugin/ui/HelloContent.kt ファイルに定義する
  • Kotlin UI DSL Version 2 を使う
  • Factoryに画面定義を渡すため、 JComponent を返す関数を定義する

という方針で実装します。

方針に従った実際のコードは以下で、ToolWindowを開いた時に Hello が表示される想定です。

fun getHelloContent(): JComponent {
    return panel {
        row {
            label("Hello")
        }
    }
}

 

ToolWindowFactory を継承したFactoryクラスを作成

画面定義ができたので、次は IntelliJ Platform Plugin Template で生成されたサンプルコードを参考にして、Factory HelloToolWindowFactory を実装します。

class HelloToolWindowFactory : ToolWindowFactory {
    override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
        val content = ContentFactory.getInstance().createContent(getHelloContent(), null, false)
        toolWindow.contentManager.addContent(content)
    }
}

 

plugin.xml への追記

最後に plugin.xml へ追記し、IDEにHelloToolWindowを表示します。

今回は、 IntelliJ Platform Plugin Template で生成された extensions の下にある toolWindow を使って定義しました。

<extensions defaultExtensionNs="com.intellij">
    <toolWindow factoryClass="com.github.thinkami.hellojetbrainsplugin.toolWindow.HelloToolWindowFactory" id="HelloToolWindow"/>
</extensions>

 

動作確認

Run Plugin を実行し、HelloToolWindow を開くと、 Hello が表示されました。良さそうです。

 

常に自作のToolWindowを表示状態にする

ここまででToolWindowの表示はできたものの、Run Plugin を再度実行すると自作のToolWindowが閉じた状態での表示になってしまいます。

ただ、これだと開発で動作確認を繰り返すときに不便なので、色々試してみました。

すると、

  • Run PluginでIDEが起動する
  • Hello ToolWindow を開く
  • IDEx ボタンで閉じる

を行うことで、常に表示状態にできました。

どこかに設定があるのかもしれませんが、ひとまずこれで良さそうです。

 

JBScrollPane を使い、 ToolWindowをスクロール可能にする

今の実装では、1つのToolWindowに多数のコンポーネントを定義した場合、ToolWindowにスクロールバーが表示されないことから、すべてのコンポーネントを表示できません。

そこで、 JBScrollPane を使い、ToolWindowをスクロール可能にします。

// DSLで生成したものを `p` に入れる
val p = panel {
    row {
        label("Hello")
    }
    // ...
}

// `p` を JBScrollPane でラップして関数の呼び出し元に渡す
return JBScrollPane(p)

 
これにより、スクロールバーが表示されました。以下の画像では、右下に小さくなったスクロールバーが見えます。

 

Kotlin UI DSL Version 2を使って、画面に色々なコンポーネントを表示してみる

ここまでで、ToolWindowの画面に Hello という文字列を表示できるようになりました。

引き続き、 Kotlin UI DSL Version 2 を使って、画面へのコンポーネント表示を色々試してみます。

   

rowの引数にテキストを渡す

Githubにあるサンプルコードの中には row の引数にテキストを渡している箇所もありました。
https://github.com/JetBrains/intellij-community/blob/idea/233.14475.28/platform/platform-impl/src/com/intellij/internal/ui/uiDslShowcase/DemoBasics.kt

そこで、 label とどう違うのかを見てみます。

row("With row args") {
    label("Row args")
}

 
行の冒頭に指定したテキストが表示されるようです。

 

Indentを使う

Indent の有無による表示の違いを見てみます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#panelindent

indent {
    row {
        label("Indented row")
    }
}

 
今回追加した行は、インデントが設定されていました。

 

separatorを使う

続いて、 separator を使って、コンポーネントの区切りを表示してみます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#panelseparator

今回、「rowに引数あり」と「インデントあり」の間に separator() を追加してみます。

// rowに引数あり
row("With row args") {
    label("Row args")
}

// 追加
separator()

// インデントあり
indent {
    row {
        label("Indented row")
    }
}

 
その結果、横線が追加されました。

青矢印で横線を指してみましたが、背景が暗めなので見づらいですね。。

 

Kotlin UI DSL Version 2で使えるSwingのコンポーネントについて

ここまでは label だけを使ってきました。

ただ、以下のデモコードを見たところ、label の他にも textFieldcheckBox なども、DSLにてSwingコンポーネントを表示できそうでした。
https://github.com/JetBrains/intellij-community/blob/idea/233.14475.28/platform/platform-impl/src/com/intellij/internal/ui/uiDslShowcase/DemoComponents.kt

 

Cellを使い、Kotlin UI DSL Version 2に無いSwingのコンポーネントを扱う

Kotlin UI DSL Version 2に無いSwingのコンポーネント、例えば JBScrollPaneJBTable を扱う方法を探したところ、 Row.cell がありました。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#rowcell

 
そこで、DSLにない JBTableCell で表示してみます。

// データ定義
val data = arrayOf(
    arrayOf("1", "Fuji", "Red"),
    arrayOf("2", "Shinano Gold", "Yellow"),
    arrayOf("3", "Bramley", "Green")
)

val columnNames = arrayOf("ID", "Name", "Color")
val tableModel = DefaultTableModel(data, columnNames)
val table = JBTable(tableModel)
val scrollableTablePanel = JBScrollPane(table)

// cellで表示
row {
    cell(scrollableTablePanel)
}

 
すると、テーブルが表示できました。

 
なお、 JBScrollPane の代わりに Row.scrollCell を使うこともできそうです。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#rowscrollcell-component

 

checkBoxの状態により、有効/無効や表示/非表示を切り替える

checkBoxvisibleIfenabledIf を組み合わせることで、有効/無効や表示/非表示を切り替えられます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#rowvisibleifenabledif

lateinit var myCheckBox: com.intellij.ui.dsl.builder.Cell<JBCheckBox>
row {
    myCheckBox = checkBox("Enable option if checked.")
    checkBox("Option by enable").enabledIf(myCheckBox.selected)
    checkBox("Option by visible").visibleIf(myCheckBox.selected)
}

 
チェックが無い状態では、無効・非表示です。

 
チェックすると、有効・表示へと切り替わります。

 

checkBox + rowsRange で、ON/OFFの状態をまとめて切り替える

checkBoxにより切り替わる項目が複数ある場合、 rowsRange を使うと定義が1度で済みます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#panelrowsrange

ためしに、

  • rowごとに visibleIf を定義する
  • rowsRangeでまとめて visibleIf を定義する

の2つを比べてみます。

// rowsRangeなしの場合
row {
    label("1st row label without rowsRange")
}.visibleIf(myCheckBoxForRowsRange.selected)

row {
    label("2nd row label without rowsRange")
}.visibleIf(myCheckBoxForRowsRange.selected)

// rowsRangeありの場合
rowsRange {
    row {
        label("1st row label with rowsRange")
    }
    row {
        label("2nd row label with rowsRange")
    }
}.visibleIf(myCheckBoxForRowsRange.selected)

 
チェックが無い場合、何も表示されていません。

 
チェックを入れると、複数行が表示されました。

挙動は同じだったため、 rowsRange を使ったほうがまとめて定義できて便利そうでした。

 

ラジオボタン + component + addActionListener で、選択によりラベルのテキストを切り替える

公式ドキュメントの Panel.buttonsGroup には、ラジオボタンをグループ化できるコードが掲載されていました。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#panelbuttonsgroup

 
そこで、「ラジオボタンの選択を変えたときにラベルのテキストを切り替える」ような機能を実装してみます。

実際には

  • 公式ドキュメントに従い、 buttonsGroup を使ってラジオボタンをグループ化する
  • addActionListenerラジオボタンにイベントを割り当てる
    • イベントの中でテキストを差し替える

とすれば良さそうです。

 
ちなみに、 radioButton DSLで生成されるオブジェクトは Cell でラップされているため、 イベントの割り当ては

  • component プロパティからラップされていないSwingのオブジェクトを取り出す
  • そのSwingオブジェクトに addActionListener でイベントを割り当てる

という手順になります。

なお、 component プロパティから取り出せることが公式ドキュメントには書かれていないため、これも正しいやり方なのかはわかりません。。

 
では順に実装していきます。

まずは、buttonsGroup の中にラジオボタンを定義します。

lateinit var firstRadio: Cell<JBRadioButton>
lateinit var secondRadio: Cell<JBRadioButton>
var selectedRadioValue = "Default message"
buttonsGroup("Select Radio Button") {
    row {
        firstRadio = radioButton("1st choice", "1st selected")
    }
    row {
        secondRadio = radioButton("2nd choice", "2nd selected")
    }
}.bind( {selectedRadioValue}, { selectedRadioValue = it } ) // bindしないと表示できない

 
次にラベルコンポーネントを用意します。あとでイベントを割り当てられるよう、ラベルコンポーネントlateinit の変数に入れておきます。

lateinit var labelForRadioButton: Cell<JLabel>
row {
    labelForRadioButton = label(selectedRadioValue)
}

 
続いて、 ActionListener を使って、イベント発火時の処理を記載します。

今回は、選択したラジオボタンのテキストをラベルに割り当てます。

val myListener = ActionListener { e ->
    val rb = e.source as JBRadioButton
    labelForRadioButton.component.text = rb.text
}

 
最後にラジオボタンcomponent プロパティでラジオボタンオブジェクトを取り出して、 addActionListener でイベントを割り当てます。

// Cell<JBRadioButton> 型なので、 component を使って <> の中を取り出す
firstRadio.component.addActionListener(myListener)
secondRadio.component.addActionListener(myListener)

 
実装が終わったので動作確認します。

最初はデフォルトの文字列が表示されています。

 
ラジオボタンを選択すると、ラジオボタンのテキストがラベルに表示されます。

 

groupにより、項目をグループ化する

続いて、 group によるグループ化を見てみます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#panelgroup

なお、 group のネストも可能です。

group("My Group Title") {
    row("1st row") {
        textField()
    }
    row("2nd row") {
        textField()
    }
}

group("Root Group") {
    row("Root row") { textField() }

    group("Nested Group") {
        row("Nested row") { textField() }
    }
}

 
タイトル付きでグループ化されました。また、ネストしたグループも表示されています。

 

groupRowsRangeで、グループ単位の有効/無効・表示/非表示を切り替える

groupRowsRange を使うことで、「checkBoxkによる有効/無効や表示/非表示の切り替え」をグループ単位でも行なえます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#panelgrouprowsrange

lateinit var groupCheckbox: com.intellij.ui.dsl.builder.Cell<JBCheckBox>
row {
    groupCheckbox = checkBox("Checkbox for groups")
}
groupRowsRange {
    group("1st Group") {
        row("1st row") { textField() }
    }
    group("2nd Group") {
        row("2nd row") { textField() }
    }
}.visibleIf(groupCheckbox.selected)

 
チェックが無い場合、グループは表示されません。

 
チェックを入れると、グループが表示されるようになりました。

 

collapsibleGroupで開閉できるグループを作る

必要に応じて開閉したいグループを作りたい時は、 collapsibleGroup が使えます。 デフォルトだと最初は閉じた状態になります。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#panelcollapsiblegroup

もし、最初から開きたい場合は、 expanded = true を設定すれば良いようです。ドキュメントやサンプルコードでは事例を見つけられなかったので、これが正しいやり方かは不明ですが。。

// デフォルトだと、最初は閉じている
collapsibleGroup("Collapsible Group") {
    group("My Group") {
        row("My row") { textField() }
    }
}

// 最初から開いておく
collapsibleGroup("Collapsible Open Group") {
    group("Open Group") {
        row("Open row") { textField() }
    }
}.expanded = true

 
上は閉じていて、下は開いています。

 

Cell.commentでコメントを追加する

ラベル以外で文字列を表示したい場合、 Cell.comment が使えます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#cellcomment

row {
    label("With comment").comment("My comment")
}

row {
    // .text() で textField のデフォルト値を設定する
    textField().comment("Text field comment").text("text property")
}

row("Row args") {
    textField().comment("Text field comment").text("text property")
}

row {
    label("Label args")
    textField().comment("Text field comment").text("text property")
}

 
comment では、コメントの追加対象のコンポーネントの下に、定義したコメントが表示されるようです。

 

Kotlin UI DSL Version 2で、コンポーネントのレイアウトを調整する

ここまででコンポーネントの定義を見てきました。

ここからは、コンポーネントの表示位置などのレイアウトを調整する方法を見ていきます。

 

Row.topGap/bottomGapでコンポーネントの上の余白を調整する

上下の余白を調整したい場合、 topGapbottomGap で調整できます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#rowtopgapbottomgap

設定可能な値は

  • None (何も設定しない)
  • SMALL
  • MEDIUM

の3種類あるので、それぞれ見てみます。

row {
    label("Default top gap")
}

row {
    label("Small top gap")
}.topGap(TopGap.SMALL)

row {
    label("Medium top gap")
}.topGap(TopGap.MEDIUM)

 
前のコンポーネントとの余白がそれぞれ調整されています。

 

Cell.alignでCellの中のコンポーネントの位置を調整する

Cellの中のコンポーネントの表示位置を調整するには align が使えます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#cell-align

以下の例では、ラベルの表示位置を調整しています。

row {
    label("With alignY-Top").align(AlignY.TOP)
    textField()
}

row {
    label("With alignX and alignY").align(AlignX.RIGHT).align(AlignY.BOTTOM)
    textField()
}

 
ラベルの縦の表示位置が

  • AlignY.TOP は上揃え
  • AlignY.BOTTOM は下揃え

になっています。

 

Cell.resizableColumn で水平方向の余白を自動調整する

resizableColumn の有無による表示差異を見てみます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#cellresizablecolumn

row("Without Resizable Column") {
    textField()
    link("My link") {}
}
row("With Resizable Column") {
    textField().resizableColumn()
    link("My link") {}
}

 
表示してみると、「MyLink」の表示位置が調整されていました。

また、ToolWindowの横幅を変えてみると、「MyLink」の位置がいい感じに調整されます。

 

Cell.gap で、同じ行のコンポーネント間の余白を調整する

余白の幅は

  • 指定しない
  • SMALL
  • COLUMNS

の3つを指定できそうだったため、それぞれためしてみます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#cellgap

row {
    label("Gap nothing")
    textField()
}

row {
    label("Gap small__").gap(RightGap.SMALL)
    textField()
}

row {
    label("Gap columns").gap(RightGap.COLUMNS)
    textField()
}

 
SMALL よりも何も指定しないほうが、横の余白が広くなるようです。

 

Row.layoutによる表示位置の調整

ここでは、 RowLayout 定数ごとに、

  • Rowの引数がある時
  • Rowの中にlabelがある時

の表示差異を確認してみます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#rowlayout

row("Row args") {
    textField().text("default")
}

row("Row args") {
    textField().text("independent")
}.layout(RowLayout.INDEPENDENT)

row("Row args") {
    textField().text("label aligned")
}.layout(RowLayout.LABEL_ALIGNED)

row("Row args") {
    textField().text("parent grid")
}.layout(RowLayout.PARENT_GRID)

// rowに引数はないが、labelコンポーネントがある場合
row {
    label("Label args")
    textField().text("default")
}

row {
    label("Label args")
    textField().text("independent")
}.layout(RowLayout.INDEPENDENT)

row {
    label("Label args")
    textField().text("label aligned")
}.layout(RowLayout.LABEL_ALIGNED)

row {
    label("Label args")
    textField().text("parent grid")
}.layout(RowLayout.PARENT_GRID)

 
以下のように調整されました。今回の場合、 INDEPENDENT による調整が一番わかりやすいです。

より良い例があるかもしれませんが、今回はいったんここまでにします。

 

Cell.labelの第2引数で、ラベルの表示位置を調整する

Cell.label では、ラベルのテキストの他、第2引数でラベルの表示位置を調整できます。
https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html#celllabel

実際に、第2引数の値を変えてみて、どのように表示が変わるか見てみます。

row {
    textField().label("Cell label")
}
row {
    textField().label("Cell label", LabelPosition.TOP)
}
row {
    textField().label("Cell label", LabelPosition.LEFT)
}

 
LabelPosition.TOP を指定した時だけ、続くコンポーネントの上にラベルが表示されました。

 

textFieldの横幅を columns で調整する

ドキュメントには記載されていませんでしたが、入力補完を使っていたときに気づきました。

columns には引数の値として

  • COLUMNS_TINY
  • COLUMNS_SHORT
  • COLUMNS_MEDIUM
  • COLUMNS_LARGE

が取れたので、それぞれためしてみます。

row {
    textField().columns(COLUMNS_TINY)
}
row {
    textField().columns(COLUMNS_SHORT)
}
row {
    textField().columns(COLUMNS_MEDIUM)
}
row {
    textField().columns(COLUMNS_LARGE)
}

 
columsに指定した値ごとに、TextBoxの幅が異なりました。

 

参考資料

Githubの uiDslShowcase

Githubの以下のディレクトリの下に、色々なサンプルコードがありました。
https://github.com/JetBrains/intellij-community/tree/idea/233.14475.28/platform/platform-impl/src/com/intellij/internal/ui/uiDslShowcase

 

リポジトリ intellij-sdk-code-samples

この中には、IntelliJ Platform Plugin SDKのサンプルコードがあります。
https://github.com/JetBrains/intellij-sdk-code-samples

例えば、ToolWindowのサンプルコードはこちらです。
https://github.com/JetBrains/intellij-sdk-code-samples/tree/main/tool_window

ただ、中にはJavaで書かれているサンプルコードもあるので、Kotlinへと変換する必要があります。

 

JetBrains の Platform Blog

深く読んでいませんが、参考になる記事があるかも。
JetBrains Platform | The JetBrains Platform Blog

 

Swingまわり

昔からある機能なので、Web上にもいろいろ記事がありました。

 

ソースコード

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

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/hello_jetbrains_plugin/pull/10

IntelliJ Platform Plugin Templateを使って、「エディタのコンテキストメニューからダイアログを表示する」だけのJetBrains系IDEプラグインを作ってみた

JetBrains系IDEを使って日常的にコードを書いていますが、ふとJetBrains系IDEプラグインを作ってみたくなりました。

とはいえ、JetBrains系IDEプラグインの作り方がよく分からなかったので調べてみたところ、 IntelliJ Platform Plugin Template というリポジトリを使うと簡単に作成できそうだと分かりました。
https://github.com/JetBrains/intellij-platform-plugin-template

そこで、IntelliJ Platform Plugin Template を使って、「コンテキストメニューからダイアログを表示する」だけのHello world的なJetBrains系IDEプラグインを作ってみたことから、メモを残します。

 
目次

 

環境

  • Windows11
    • WSL2上ではなく、Windows11上で開発します
  • IntelliJ IDEA 2023.3.4 Ultimate Edition

なお、ローカルではJava/Kotlinを使った開発をしていないことから、Javaまわりは何もインストールしていない状態でした。

 

IntelliJ Platform Plugin Templateを元にした環境構築

まずは、READMEのGetting startedの内容から始めます。
https://github.com/JetBrains/intellij-platform-plugin-template?tab=readme-ov-file#getting-started

最初にリポジトリUse this template ボタンをクリックし、プラグイン名を hello_jetbrains_plugin とするなど、必要な事項を入力して自分のリポジトリへとcloneしました。

できあがったリポジトリは以下です。
https://github.com/thinkAmi-sandbox/hello_jetbrains_plugin

 
続いて、 IntelliJ Platform Plugin Template のREADMEに従い、 Get from VCS 機能でソースコードをローカルに clone & 開きます。

今までこの機能を使ったことがなかったのですが、簡単にcloneとセットアップができました。

 
引き続きREADMEに従い、WindowsJavaSDKをインストールします。

バージョンとベンダーを選べますが、ひとまず

  • Java17
  • ベンダーは JetBrains Runtime version 17.0.9
    • 深い意味はなく、とりあえずJetBrainsが提供しているものにしてみました

を選んでおきました。

 
この時点でプラグインを起動できるため、 Run Plugin を実行してみます。

すると、プラグインがインストールされた状態でIntelliJ IDEAが起動しました。

 

コンテキストメニューからダイアログを開く」機能を追加する

IntelliJ Platform Plugin TemplateのREADMEにはプラグインの作り方は記載されていなかったので、別のドキュメントを探すことにしました。

まず、 IntelliJ Platform SDK のドキュメントを見に行きましたが、量に圧倒されてしまいました。
IntelliJ Platform SDK | IntelliJ Platform Plugin SDK

 
そこで、まずは簡単なプラグインを作るところから始めようということで、チュートリアル的に書かれている以下のブログを読みました。

 
上記のブログを読んだところ、「エディタコンテキストメニューからダイアログを表示する」プラグインを作るのが最初の一歩として良さそうに感じましたので、作っていきます。

 
まずは、公式SDKドキュメントに

The action implementation determines the contexts in which an action is available, and its functionality when selected in the UI.

 
https://plugins.jetbrains.com/docs/intellij/basic-action-system.html

とあるような、アクションと呼ばれる機能を作成してみます。

そこで、 src/main/kotlin/com/github/thinkami/hellojetbrainsplugin/actions/HelloAction.kt ファイルを作成し、 AnAction を継承したActionのクラスを実装します。

ダイアログの表示は Messages.showMessageDialog を使うことで実現できます。

package com.github.thinkami.hellojetbrainsplugin.actions

import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.ui.Messages

class HelloAction: AnAction() {
    override fun actionPerformed(e: AnActionEvent) {
        Messages.showMessageDialog(buildString {
            append("ハロー")
        }, "ワールド", null)
    }
}

 
続いて、

Registration determines where an action appears in the IDE UI. Once implemented and registered, an action receives callbacks from the IntelliJ Platform in response to user gestures.

 
https://plugins.jetbrains.com/docs/intellij/basic-action-system.html

のように、アクションを呼び出す方法を定義します。

今回は「エディタのコンテキストメニューから上記のActionを起動する」設定を定義します。

そこで、 src/main/resources/META-INF/plugin.xml を開き、末尾にActionの呼び出しを追加します。

<idea-plugin>
    ...
    </applicationListeners>

    <actions>
        <action id="com.github.thinkami.hellojetbrainsplugin.actions.HelloAction"
                class="com.github.thinkami.hellojetbrainsplugin.actions.HelloAction"
                text="Hello Action"
                description="hello world action">
            <add-to-group group-id="EditorPopupMenu" anchor="first" />
        </action>
    </actions>
</idea-plugin>

 

動作確認

先ほどと同様、 Run Plugin を実行し、IntelliJ IDEAを起動します。

今回の機能はエディタでコンテキストメニューを開く必要があるため、適当なKotlinプロジェクトを探してみます。

すると、 Kotlin Programming Tutorial for Beginners と書かれているリポジトリがありました。
https://github.com/smartherd/KotlinTutorial

今回はこれを使うことにして、 Get from VCS 機能にてローカルにclone・プロジェクトを開きます。

 
適当なkotlinのファイルを開き、エディタ上で右クリックしてコンテキストメニューを表示したところ、 Hello Action というメニューがありました。

 
このメニューをクリックしたところ、Hello world的なダイアログが表示されました。

 
以上より、今回やりたかったことは実現できました。

 

ソースコード

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

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/hello_jetbrains_plugin/pull/9

 

Github Actionsの様子

ちなみに、プルリクを作ったところ、IntelliJ Platform Plugin Template に設定されていたようで、Github ActionsによるCIが自動実行されました。

 
最終的にはこんな感じになりました。

Railsにて、1つのテーブルの複数列で同じテーブルを参照し、参照先を主キーもしくは主キー以外の属性としたい場合に、モデルやマイグレーションの定義方法を調べてみた

Railsを使っている中で、記事のタイトルのようなことがしたくなりました。

例えば、以下のようなことがしたくなりました。

  • applesとcolorsという2テーブルがある
  • applesテーブルには以下の3列があり、いずれもcolorsと関連を持たせたい
    • 列について
      • 果実の色 (fruit_color)
      • 花の色 (flower_color)
      • 葉の色 (leaf_color)
    • 関連について
      • fruit_colorflower_color は、どちらも colors.id に対して外部キー制約をつけたい
      • leaf_color について、列名は leaf_color_name 、参照先は colors.name にしたい
        • 無理に外部キー制約を付けなくても良い

 
そこで、マイグレーション・モデルにどのような設定をすればよいか調べたため、メモを残します。

 
目次

 

環境

 

複数の列の外部キー制約を、同じテーブルに対して行う

前述の例で言えば、「applesテーブルの fruit_colorflower_color の各列から、 colors.id に対して外部キー制約を付けたい」を行いたいときのマイグレーションとモデルの定義を確認します。

 

マイグレーションの定義

今回は一度に定義するのではなく、各ステップごとにマイグレーションを用意します。

まずは apples テーブルがあるとします。

class CreateApples < ActiveRecord::Migration[7.1]
  def change
    create_table :apples do |t|
      t.string :name

      t.timestamps
    end
  end
end

 
次に、 colors テーブルを用意します。

class CreateColors < ActiveRecord::Migration[7.1]
  def change
    create_table :colors do |t|
      t.string :name

      t.timestamps
    end
  end
end

 
続いて、 fruit_color から apples テーブルへの外部キー制約を追加します。

今回は add_reference を使って

な感じで定義します。

class AddColumnToApple < ActiveRecord::Migration[7.1]
  def change
    add_reference :apples, :fruit_color, foreign_key: { to_table: :colors }
  end
end

 
もう一つの列も同様に定義します。

class AddFlowerColorColumnToApple < ActiveRecord::Migration[7.1]
  def change
    add_reference :apples, :flower_color, foreign_key: { to_table: :colors }
  end
end

 

モデルの定義

続いて、モデルに関連付けを定義します。

今回は、関連付け名はデフォルトではなく、 fruit_colorflower_color にしたいことから、 belongs_to のオプション class_nameforeign_key を使って定義します。

class Apple < ApplicationRecord
  # 各リレーションを分かりやすくするため、belongs_to で別名を付けて、 class_name で関連先のモデル名を指定している
  belongs_to :fruit_color, class_name: 'Color', foreign_key: 'fruit_color_id'
  belongs_to :flower_color, class_name: 'Color', foreign_key: 'flower_color_id'
end

 

動作確認

以上でマイグレーションとモデルの定義ができました。

そこで、テストコード (model spec + factory_bot) を書いて動作確認します。

まずはテストデータです。

RSpec.describe Apple, type: :model do
  let!(:yellow_color) { create(:color, :yellow_color) }
  let!(:white_color) { create(:color, :white_color) }

  let!(:shinano_gold) { create(:apple,
                               name: 'シナノゴールド',
                               fruit_color: yellow_color,
                               flower_color: white_color)}
end

 
次に、 apples から colors をたどれるか確認します。今回は、 apples から colors.name を取得できるか確認します。

また、取得方法も

  • ドット(関連付け)で取得
  • eager_load で取得
  • joins + select で取得

のパターンをためせるテストコードを書きます。

describe 'fruit_color' do
  it 'ドットで取得できること' do
    p Apple.find_by(name: 'シナノゴールド').fruit_color.name
    actual = Apple.find_by(name: 'シナノゴールド').fruit_color.name
    expect(actual).to eq('')
  end

  it 'eager_load + ドットで取得できること' do
    actual = Apple.eager_load(:fruit_color).where(name: 'シナノゴールド' ).first.fruit_color.name
    expect(actual).to eq('')
  end

  it 'joins + select で取得できること' do
    actual = Apple.joins(:fruit_color).where(name: 'シナノゴールド').select('colors.name').first
    expect(actual.name).to eq('')
  end
end

describe 'flower_color' do
  it 'ドットで取得できること' do
    actual = Apple.find_by(name: 'シナノゴールド').flower_color.name
    expect(actual).to eq('')
  end

  it 'eager_load + ドットで取得できること' do
    actual = Apple.eager_load(:flower_color).where(name: 'シナノゴールド' ).first.flower_color.name
    expect(actual).to eq('')
  end

  it 'joins + select で取得できること' do
    actual = Apple.joins(:flower_color).where(name: 'シナノゴールド').select('colors.name').first
    expect(actual.name).to eq('')
  end
end

 
テストコードを実行してみると、いずれのテストもパスしました。

 

外部キー制約の参照先を主キー以外の列にも指定できるか

ここまでで、複数の列で同じテーブルを参照するときの外部キー制約をためしてみました。

ただ、データベースによっては外部キー制約を主キー以外にも設定できます。

そこで、Railsの場合にはどのように設定するかを調べてみました。

 

調査

マイグレーションでは「生SQLを書く」以外の方法が分からず

Rails APIのドキュメントを見ながら、主キー以外の列を参照する外部キー制約が付けられるか試してみました。

 
しかし、主キーを参照する前提のようだったため、Rails API を使って記述することはできませんでした。

SQLを書けばいけるかもしれませんが、レールを外れそうだったのと、データベースによってはうまく動作しないようでした。

例えばMySQLの場合、

UNIQUE でないキーを参照する FOREIGN KEY 制約は、標準 SQL ではなく InnoDB拡張機能です。 一方、NDB ストレージエンジンでは、外部キーとして参照される任意のカラムに明示的な一意キー (または主キー) が必要です。

 

一意でないキーまたは NULL 値を含むキーへの外部キー参照の処理は、UPDATE や DELETE CASCADE などの操作に対して適切に定義されていません。 UNIQUE (PRIMARY を含む) および NOT NULL キーのみを参照する外部キーを使用することをお勧めします。

 
https://dev.mysql.com/doc/refman/8.0/ja/ansi-diff-foreign-keys.html

との記載があります。

 
そのため、今回マイグレーションで外部キー制約を付与する、つまりデータベースレイヤでデータを保護するのは諦めました。

 

モデルで belongs_to を使って、外部キー制約なしの関連付けする

前述の通りデータベースレイヤでは諦めましたが、Railsレイヤで行えることがあるかもしれないと思い、調べてみました。

すると、モデルで belongs_to + class_name + foreign_key + primary_key を使えば関連付けができそうでした。

 
気になるのは、「primary_key に主キー以外の項目を設定してもよいのか」ですが、以下の記事やソースコードを見るとうまく動きそうな気がします。

 

実装

では実際にためしてみます。

まずはマイグレーションで、 apples テーブルに string 型の leaf_color_name 列を追加します。

class AddLeafColorColumnToApple < ActiveRecord::Migration[7.1]
  def change
    add_column :apples, :leaf_color_name, :string
  end
end

 
続いて、モデル Apple で、 belongs_to

  • foreign_key に、Appleの属性である leaf_color_name を指定
  • primary_key に、参照先のColorの属性である name を指定

の各オプションを渡して関連付けを定義します。

class Apple < ApplicationRecord
  # name列同士の関連付けをもたせる
  belongs_to :leaf_color, class_name: 'Color', foreign_key: 'leaf_color_name', primary_key: 'name'
end

 

動作確認

では、先ほどの外部キー制約があるときと同様、テストコードを書いて動作を確認してみます。

以下のテストコードを書いて実行したところ、いずれもテストがパスしました。

describe 'leaf_color_name' do
  it 'ドットで取得できること' do
    actual = Apple.find_by(name: 'シナノゴールド').leaf_color.name
    expect(actual).to eq('')
  end

  it 'eager_load + ドットで取得できること' do
    actual = Apple.eager_load(:leaf_color).where(name: 'シナノゴールド' ).first.leaf_color.name
    expect(actual).to eq('')
  end

  it 'joins + select で取得できること' do
    actual = Apple.joins(:leaf_color).where(name: 'シナノゴールド').select('colors.name').first
    expect(actual.name).to eq('')
  end

  it 'joins + pluck で取得できること' do
    actual = Apple.joins(:leaf_color).where(name: 'シナノゴールド').pluck('colors.name').first
    expect(actual).to eq('')
  end
end

 

実際に発行されるSQLを確認

テストはパスしたものの、実際に発行されるSQLのJOINの条件が気になりました。

そこで、JOINが発生する

  • eager_load
  • joins

の各メソッドにて、実際に発行されるSQLを確認してみます。

 

eager_loadのときのSQL

LEFT OUTER JOIN の ON 句で "colors"."name" = "apples"."leaf_color_name" となっていました。

SELECT
  "apples"."id" AS t0_r0,
  "apples"."name" AS t0_r1,
  "apples"."created_at" AS t0_r2,
  "apples"."updated_at" AS t0_r3,
  "apples"."fruit_color_id" AS t0_r4,
  "apples"."flower_color_id" AS t0_r5,
  "apples"."leaf_color_name" AS t0_r6,
  "colors"."id" AS t1_r0,
  "colors"."name" AS t1_r1,
  "colors"."created_at" AS t1_r2,
  "colors"."updated_at" AS t1_r3
FROM
  "apples"
  LEFT OUTER JOIN "colors" ON "colors"."name" = "apples"."leaf_color_name"
WHERE
  "apples"."name" IN ("name", "シナノゴールド")
ORDER BY
  "apples"."id" ASC
LIMIT
  1

 

joinsのときのSQL

こちらも、INNER JOIN の ON 句で "colors"."name" = "apples"."leaf_color_name" となっていました。

SELECT
  "colors"."name"
FROM
  "apples"
  INNER JOIN "colors" ON "colors"."name" = "apples"."leaf_color_name"
WHERE
  "apples"."name" IN ("name", "シナノゴールド")
ORDER BY
  "apples"."id" ASC
LIMIT
  1

 
以上より、主キー以外の属性でも関連付けができました。

 

SQLのJOINが発生するメソッド + select ('*') したときの挙動を確認

ところで本題とはズレるのですが、join系メソッドを調べていた時に以下の記事とGithubへのリンクを見つけました。

 
そのissueやプルリクはまだcloseしていなかったため、Rails 7.1系ではどのような結果になるか試してみたくなりました。

そこでテストコードを書いてみたところ、いずれもパスしました。

describe "applesとcolorsでname列が重複しているときの join系 + select('*')の挙動" do
  context 'eager_loadの時' do
    it 'apple.nameを期待したいが、color.nameになっていること' do
      actual = Apple.eager_load(:fruit_color).where(name: 'シナノゴールド' ).select('*').first

      expect(actual.name).not_to eq('シナノゴールド')
      expect(actual.name).to eq('')
    end
  end

  context 'left_joinsの時' do
    it 'apple.nameを期待したいが、color.nameになっていること' do
      actual = Apple.left_joins(:fruit_color).where(name: 'シナノゴールド' ).select('*').first

      expect(actual.name).not_to eq('シナノゴールド')
      expect(actual.name).to eq('')
    end
  end

  context 'joinsの時' do
    it 'apple.nameを期待したいが、color.nameになっていること' do
      actual = Apple.joins(:fruit_color).where(name: 'シナノゴールド' ).select('*').first

      expect(actual.name).not_to eq('シナノゴールド')
      expect(actual.name).to eq('')
    end
  end
end

 
参照した記事にもある通り、 SQLのJOINが発生するメソッド + select('*') を使うことは無いと思いますが、覚えておいたほうが良いかもしれません。

 

ソースコード

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

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/rails_7_1_minimal_app/pull/2

Railsにて、トランザクションの中だとモデルのid列はいつ設定されるのか確認してみた

Railsを使っている中で、「トランザクションの中だとモデルのid列はいつ設定されるのか」が気になったことから、確認してみたときのメモを残します。

目次

 

環境

  • Rails 7.1.3
  • DBはデフォルトのSQLite3

 
また、今回は以下のモデルとマイグレーションを用意します。

apple.rb

class Apple < ApplicationRecord
end

 
モデルに対するマイグレーションファイルはこちら。

class CreateApples < ActiveRecord::Migration[7.1]
  def change
    create_table :apples do |t|
      t.string :name

      t.timestamps
    end
  end
end

 

動作確認

上記のAppleモデルに対して、

の各タイミングで、モデルオブジェクトの id がどのように設定されるかを確認します。

まずは以下の rake task を作成します。

namespace :print_model_id_with_transaction do
  desc 'トランザクション利用時のmodelのidを確認'

  task run: :environment do
    # rake taskだと、`config.active_record.verbose_query_logs = true` であってもSQLログが出ないので、設定しておく
    # ActiveRecord::Base.logger = Logger.new(STDOUT)
    # Rails.logger.level = Logger::DEBUG

    ActiveRecord::Base.transaction do
      apple = Apple.build(name: 'シナノゴールド')
      puts "step1: plan.id => #{apple.id}"
      apple.save
      puts "step2: plan.id => #{apple.id}"

      raise ActiveRecord::Rollback
    end

    apple_without_transaction = Apple.build(name: '奥州ロマン')
    puts "step3: plan.id => #{apple_without_transaction.id}"
    apple_without_transaction.save
    puts "step4: plan.id => #{apple_without_transaction.id}"
  end
end

 
続いて rake task を実行したところ

$ bin/rails print_model_id_with_transaction:run
step1: plan.id => 
step2: plan.id => 1
step3: plan.id => 
step4: plan.id => 1

となりました。

これより、

という挙動と分かりました。トランザクションの中と外で設定されるタイミングは同じなようです。

 
次に、実際に発行されるSQLも見てみます。

今回は rake task で動作確認をしているため、task の先頭に以下を追加します。
rakeタスクでクエリのログを標準出力に出す | このコードわからん

task run: :environment do
  # 追加
  ActiveRecord::Base.logger = Logger.new(STDOUT)
  Rails.logger.level = Logger::DEBUG

  # 以下同じ
  ActiveRecord::Base.transaction do
  ...

 
再度 rake task を実行すると、以下のような感じで表示されました(横長になったので、タイムスタンプ部分を削除しています)。

トランザクションの中では、 commit transaction がなくてもモデルの id 列に値が設定されるようです。

$ bin/rails print_model_id_with_transaction:run
D, :   TRANSACTION (0.1ms)  begin transaction
D, :   ↳ lib/tasks/print_model_id_with_transaction.rake:10:in `block (3 levels) in <top (required)>'
step1: plan.id => 
D, :   Apple Create (0.2ms)  INSERT INTO "apples" ("name", "created_at", "updated_at") VALUES (?, ?, ?) RETURNING "id"  [["name", "シナノゴールド"], ["creat "2024-02-17 11:22:40.829966"], ["updated_at", "2024-02-17 11:22:40.829966"]]
D, :   ↳ lib/tasks/print_model_id_with_transaction.rake:12:in `block (3 levels) in <top (required)>'
step2: plan.id => 2
D, :   TRANSACTION (0.1ms)  rollback transaction
D, :   ↳ lib/tasks/print_model_id_with_transaction.rake:9:in `block (2 levels) in <top (required)>'
step3: plan.id => 
D, :   TRANSACTION (0.1ms)  begin transaction
D, :   ↳ lib/tasks/print_model_id_with_transaction.rake:20:in `block (2 levels) in <top (required)>'
D, :   Apple Create (1.0ms)  INSERT INTO "apples" ("name", "created_at", "updated_at") VALUES (?, ?, ?) RETURNING "id"  [["name", "奥州ロマン"], ["created_a2024-02-17 11:49:59.591712"], ["updated_at", "2024-02-17 11:49:59.591712"]]
D, :   ↳ lib/tasks/print_model_id_with_transaction.rake:20:in `block (2 levels) in <top (required)>'
D, :   TRANSACTION (7.3ms)  commit transaction
D, :   ↳ lib/tasks/print_model_id_with_transaction.rake:20:in `block (2 levels) in <top (required)>'
step4: plan.id => 2

 

余談:Railsのバージョンにより、トランザクション内での return 等の挙動が変わる

本題とは関係ない内容です。

Railsトランザクションまわりを調べていたところ、Railsのバージョンによりトランザクションの挙動が変わると知りました。

 

ソースコード

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

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

書籍「Solving Identity Management in Modern Applications - 2nd edition」を読みました

前回、SAML2のSP-initiated フローをためしてみました。
Python + Flask + pysaml2 でSP、KeycloakでIdPを構築し、SAML2 の SP-initiated フローでSAML認証してみた - メモ的な思考的な

書籍「SAML入門 - かなめりぜ」を読んだり手を動かしたりして、なんとなくSAML2への理解が進んだような気がしました。

 
ただ、もう少し理解を勧めたいと考えて書籍を探したところ、「Solving Identity Management in Modern Applications - 2nd edition」がありました。
Solving Identity Management in Modern Applications: Demystifying OAuth 2, OpenID Connect, and SAML 2 | SpringerLink

1st editionは2019年に出版され、今回読んだ 2nd edition は2022年に出版されたようでした。

SAML2について書かれた本は貴重なため、読んでみました。

 

書籍の概要

SAML2に特化した内容というよりは、目次にある通り

  • 認証認可まわりフレームワークO
    • Auth2やOIDC、SMAL2
  • 権限管理
  • セッション管理
  • ログイン
  • ログアウト
  • アカウント管理
  • アカウントの失効

など、IDまわりの開発~運用まで一通りふれられていました。

また、OAuth2.1 draft などについても書かれており、最新の状況に追随している印象を受けました。

 
一方、ID管理まわりの技術に対するサンプルコードやライブラリ・製品の紹介などがほとんどありませんでした。

そのため、もっと実装寄りのことを学ぶには

  1. この書籍でID管理まわりのキーワードを拾う
  2. 別の資料を読んだり、自分で実装を試してみる

という流れが必要そうと感じました。

 

SAML2 の記載について

この書籍を読む目的である、SAML2に関する情報については

  • 「7. SAML2」
  • 「Back Matter」の中の APPENDIX C SAML2 AUTHENTICATION REQUEST AND RESPONSE

に記載されていました。

以降では、それぞれの内容を軽くメモしておきます。

 

7. SAML2 について

SAML入門同様、SAML認証の各フローやシングルサインオンについて記載されていました。

また、SAMLに関係する内容として、

  • Identity Federation
  • Authentication Brokers

についてもふれられていました。

 
個人的には、 Authentication Brokers について知らなかったので、ためになりました。

ちなみに、読書後に Authentication Brokers についてWebで調べてみたところ、Keycloakの Identity Broker 機能のような印象を受けています。
KeycloakでSAMLログインをテストしたい #テスト - Qiita

 

APPENDIX Cについて

SAML入門と比較すると以下のような感じでした。

  • C1. SAML2 AUTHENTICATION REQUEST
    • SAML入門の「5章 やりとりする内容」あたり
  • C2. SAML2 AUTHENTICATION RESPONSE
    • SAML入門の「5章 やりとりする内容」あたり
  • C3. VALIDATION
    • SAML入門 p55 の 「署名以外にチェックする項目」あたり

 
そのため、「SAML入門でも十分だけど、補助的な資料としてここを読む」ような感じで良さそうでした。

 

おわりに

サンプルコードが無いこともあり、この本だけで各ID管理技術を実装するのは難しく感じる一方で、ID管理の全体像を知ることができて有意義でした。

今回はSAML2を知るために読みましたが、他のID管理技術の内容をざっくりつかみたい時は、また読んでみようと思いました。

Rubyのオブジェクト配列にて、各要素の同一属性で同じ値が何個あるかを数える

Rubyのオブジェクト配列にて、各要素の同一属性で同じ値が何個あるかを数えたくなる機会がありました。

例えば

class Apple
  attr_reader :name, :grower

  def initialize(name, grower)
    @name = name
    @grower = grower
  end
end

apples = [
  Apple.new('シナノゴールド', 'Aさん'),
  Apple.new('秋映', 'Aさん'),
  Apple.new('シナノゴールド', 'Bさん'),
  Apple.new('秋映', 'Cさん'),
  Apple.new('奥州ロマン', 'Cさん'),
  Apple.new('シナノゴールド', 'Dさん'),
]

というオブジェクト配列があったときに、りんごの名前 (name) ごとの生産者(grower) の人数の取得方法を知りたくなったのでした。

そこで、調べたときのメモを残します。

 
目次

 

環境

 

gropu_by + transform_values を使う

調べてみたところ、以下の記事を参考になりました。ありがとうございました。
配列に同じ要素が何個あるかを数える - patorashのブログ

 
そこで、記事に記載のあった通り、 gropu_by 後に transform_values を使って実装してみました。

 
すると、欲しい結果が得られました。

apples = [
  Apple.new('シナノゴールド', 'Aさん'),
  Apple.new('秋映', 'Aさん'),
  Apple.new('シナノゴールド', 'Bさん'),
  Apple.new('秋映', 'Cさん'),
  Apple.new('奥州ロマン', 'Cさん'),
  Apple.new('シナノゴールド', 'Dさん'),
]

p apples.map(&:name).group_by(&:itself).transform_values(&:size)
# => {"シナノゴールド"=>3, "秋映"=>2, "奥州ロマン"=>1}

 
ただ、これだけだとメソッドチェーンの途中でどのような形になっているか、まだ理解できていませんでした。

そこで、途中結果を表示してみた上で、自分向けのメモも残してみます。

 

途中経過のメモ

まずは map を使い、オブジェクトの name 属性の配列にします。

r1 = apples.map(&:name)
p r1
# => ["シナノゴールド", "秋映", "シナノゴールド", "秋映", "奥州ロマン", "シナノゴールド"]

 
続いて、group_by(&:itself) にて

  • キー
    • ブロックの中で、オブジェクトの itself メソッドを使うことで得られた、 シナノゴールド秋映奥州ロマン
    • itself の結果を、キーごとに配列化

という形のハッシュにします。

r2 = r1.group_by(&:itself)
p r2
# => {"シナノゴールド"=>["シナノゴールド", "シナノゴールド", "シナノゴールド"], "秋映"=>["秋映", "秋映"], "奥州ロマン"=>["奥州ロマン"]}

 
最後に、 transform_values(&:size) で、ハッシュの値をブロックの結果 (&:size による配列の要素数) へと差し替えます。

p r2.transform_values(&:size)
# => {"シナノゴールド"=>3, "秋映"=>2, "奥州ロマン"=>1}

 

ソースコード全体

以下のソースコードmain.rb で保存し、ruby main.rb と実行することで、同じ結果が得られます。

class Apple
  attr_reader :name, :grower

  def initialize(name, grower)
    @name = name
    @grower = grower
  end
end

apples = [
  Apple.new('シナノゴールド', 'Aさん'),
  Apple.new('秋映', 'Aさん'),
  Apple.new('シナノゴールド', 'Bさん'),
  Apple.new('秋映', 'Cさん'),
  Apple.new('奥州ロマン', 'Cさん'),
  Apple.new('シナノゴールド', 'Dさん'),
]

# 途中経過版
r1 = apples.map(&:name)
p r1
# => ["シナノゴールド", "秋映", "シナノゴールド", "秋映", "奥州ロマン", "シナノゴールド"]

r2 = r1.group_by(&:itself)
p r2
# => {"シナノゴールド"=>["シナノゴールド", "シナノゴールド", "シナノゴールド"], "秋映"=>["秋映", "秋映"], "奥州ロマン"=>["奥州ロマン"]}

p r2.transform_values(&:size)
# => {"シナノゴールド"=>3, "秋映"=>2, "奥州ロマン"=>1}

# チェーン版
puts '=' * 30
p apples.map(&:name).group_by(&:itself).transform_values(&:size)

 
実行結果は以下です。

% ruby --version   
ruby 3.3.0 (2023-12-25 revision 5124f9ac75) [x86_64-darwin21]

% ruby main.rb     
["シナノゴールド", "秋映", "シナノゴールド", "秋映", "奥州ロマン", "シナノゴールド"]
{"シナノゴールド"=>["シナノゴールド", "シナノゴールド", "シナノゴールド"], "秋映"=>["秋映", "秋映"], "奥州ロマン"=>["奥州ロマン"]}
{"シナノゴールド"=>3, "秋映"=>2, "奥州ロマン"=>1}
==============================
{"シナノゴールド"=>3, "秋映"=>2, "奥州ロマン"=>1}

Python + Flask + pysaml2 でSP、KeycloakでIdPを構築し、SAML2 の SP-initiated フローでSAML認証してみた

以前、OpenID Connect によるシングルサインオン環境を構築しました。
Railsとdoorkeeper-openid_connectやOmniAuth を使って、OpenID Connectの OpenID Provider と Relying Party を作ってみた - メモ的な思考的な

OpenID Connect以外でシングルサインオン環境を構築する方法として SAML がありますが、今までさわってきませんでした。

 
そんな中、書籍「SAML入門」を読む機会がありました。

書籍では

  • SAMLの認証フロー
  • 認証フローのリクエスト・レスポンスの中身を掲載
  • Dockerを使って実際にSAML認証を試す
  • SAMLの仕様へのリンクやお役立ちツール

などが分かりやすく記載されており、とてもためになりました。ありがとうございました。

 
本を読んでみて気持ちが盛り上がり、自分でもSPとIdPの環境を構築してみたくなりました。

そこで、Python + Flask + pysaml2 でSP、KeycloakでIdPを構築し、SAML2 の SP-initiated フローでSAML認証してみたので、メモを残します。

 
目次

 

環境

  • WSL2
  • SP
    • Python 3.11.7
    • Flask 3.0.2
    • pysaml2 7.5.0
  • IdP
    • Keycloak 23.0.6

 
なお、ソースコードは必要に応じて記載しているものの、全部は記載していません。

詳細はGithubリポジトリを確認してください。
https://github.com/thinkAmi-sandbox/flask_with_pysaml2_and_keycloak-example

 

やらないこと

あくまで「SAMLの認証フローを体験する」がメインなので、以下は行いません。

  • 本番運用に即した、Keycloakやpysaml2の設定
  • セキュリティまわりを真剣に考えること

 
また、SPとIdPの両方を自作すると完成が遅くなりそうでした。

そのため、SAML入門同様、今回はIdPにKeycloakを使うことにして、IdPの自作は行いません。

 

SP向けのライブラリについて検討

今回はPythonで書いてみようと考え、SAML2関連のライブラリを探したところ、以下の2つがありました。

両方とも同じくらいのstarだったため、どんな違いがあるのか調べたところ、2016年のstackoverflowに情報がありました。
single sign on - Python SSO: pysaml2 and python3-saml - Stack Overflow

python3-saml のauthorのコメントだったものの、python3-saml が良さそうに感じました。

ただ、

ということから、今回は pysaml2 でSPを作ることにしました。

 
ライブラリは決まったものの、 pysaml2 を使ってゼロから作るのは大変そうです。

サンプルコードを探したところ、oktaにてサンプルコードが公開されていました。

そこで、これをベースに作っていくことにしました。

 
なお、上記oktaのサンプルだと Flask-Login を使うことでログインまわりをきちんと作っています。

ただ、今回は必要最低限の実装にするので、ログインまわりについては

  • Flask-Login は使用しない
  • その代わり、SAML認証成功時にセッションへデータをセットする
    • セッションにデータがあればログイン成功とみなす

とします。

 

SAML用のChrome拡張について調査

SAMLのリクエスト・レスポンスをChromeで確認できると便利です。

調べた見たところ、SAML-tracer がありました。
SAML-tracer

この拡張ですが、

ということから、今回使ってみることにしました。

この拡張を使うことで、SAMLのパラメータを見たり、

 
実際のリクエストで使われるSAMLを見れたりと、開発をする上で便利になりました。

 

Keycloakのセットアップ

Keycloakは、公式のDockerイメージが quay.io で提供されています。
https://quay.io/repository/keycloak/keycloak?tab=tags

今回は、最新バージョンの docker compose で Keycloak をたてることにしました。

そこで、以下の compose.yaml を用意しました。

ちなみに、ポート 8080 はよく見かけるため、 18080 へと変更しています。

services:
  keycloak:
    image: quay.io/keycloak/keycloak:23.0.6
    # dockerコマンドのitオプションと同様にするため、 ttyとstdin_openを付けておく
    tty: true
    stdin_open: true
    ports:
        - 18080:8080
    environment:
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
    command:
      - start-dev

 
準備ができたので、起動します。

$ docker compose up -d

 
続いて、公式ドキュメントの手順に従い、Keycloakの設定を行います。
Docker - Keycloak

  • ポートを変更しているので、以下のURLにアクセス
  • realmを作る
    • nameを myrealm とする
    • それ以外はデフォルト
  • ユーザー作る
    • myrealm に切り替える
    • 以下を入力
      • Username: myuser
      • First Name: Foo
      • Last Name: Bar
  • ユーザーにパスワードを設定する
    • Credentials タブを開く
    • 以下を入力
      • Password: baz
      • Password confirmation: baz
      • Temporary: Off
  • 作成したユーザー myuser でKeycloakへログインしてみる
  • Realm settings の Endpoints をクリックし、エンドポイント情報を確認しておく

 

pysaml2を使って、SPを作る

各ライブラリのインストール

今回、WSL2上にSPをたてます。

はじめに、pysaml2のREADMEにある通り、 xmlsec1 をインストールします。

$ sudo apt install xmlsec1

続いて、Flaskとpysaml2をインストールします。

$ pip install pysaml2 flask

 

Flaskアプリの作成

ミニマムな saml_client_for メソッドへと変更

oktaのサンプルコードを一部改変し、ミニマムな実装にします。

まず、今回はHTTP通信だけ使うので、変数 asc_urlSAML Requestのみの

acs_url = url_for(
    'saml_request',
    _external=True)

とします。

変数 settings については、

  • endpoint は以下の2つ分を定義
    • SAML Requestのときの HTTP Redirect Binding
    • SAML Responseのときの HTTP POST Binding
  • 各種 signed は False
  • allow_unsolicitedTrue
    • 未設定だと saml2.response.UnsolicitedResponse: Unsolicited response: id-*** エラーが発生する
      • Keycloak側の設定不足かもしれない
  • metadata には remote を追加
    • これがないと、 saml2.client_base.SignOnError: {'message': 'No supported bindings available for authentication', 'bindings_to_try': ['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'], 'unsupported_bindings': ['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect']} エラーが発生する

とします。

関数全体は以下の通りです。

def saml_client_for():
    acs_url = url_for(
        'saml_request',
        _external=True)
    
    settings = {
        'entityid': 'flask',
        'metadata': {
            'remote': [
                {'url': 'http://localhost:18080/realms/myrealm/protocol/saml/descriptor'},
            ],
        },
        'service': {
            'sp': {
                'endpoint': {
                    'assertion_consumer_service': [
                        (acs_url, BINDING_HTTP_REDIRECT),
                        (acs_url, BINDING_HTTP_POST),
                    ]
                },
                'allow_unsolicited': True,
                'authn_requests_signed': False,
                'want_assertions_signed': False,
                'want_response_signed': False,

            }
        }
    }

    spConfig = Saml2Config()
    spConfig.load(settings)
    spConfig.allow_unknown_attributes = True
    saml_client = Saml2Client(config=spConfig)
    return saml_client

 

SAML Requestを送信する関数を作成

今回のSAML Request は HTTP Redirect Binding とするため、oktaのサンプルコードとほぼ同じです。

なお、今回は SP-initiated フローでの認証のみ動作確認することから、メソッド名を sp_initiated から saml_request へと変更しています。

def saml_request():
    # SAMLクライアントを生成する
    saml_client = saml_client_for()
    
    # 認証準備をする
    _reqid, info = saml_client.prepare_for_authenticate()

    # HTTP Redirect Binding のリダイレクト先はLocationヘッダに保存されているため、
    # その値を redirect 関数に渡す
    redirect_url = None
    # Select the IdP URL to send the AuthN request to
    for key, value in info['headers']:
        if key == 'Location':
            redirect_url = value
    response = redirect(redirect_url, code=302)
    # NOTE:
    #   I realize I _technically_ don't need to set Cache-Control or Pragma:
    #     http://stackoverflow.com/a/5494469
    #   However, Section 3.2.3.2 of the SAML spec suggests they are set:
    #     http://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf
    #   We set those headers here as a 'belt and suspenders' approach,
    #   since enterprise environments don't always conform to RFCs
    response.headers['Cache-Control'] = 'no-cache, no-store'
    response.headers['Pragma'] = 'no-cache'
    return response

 

SAML ResponseをPOSTで受け付ける関数を作成

元々のサンプルコードでは idp_initiated 関数でしたが、 IdP-initiated フロー向けと誤解しそうなので、関数名を saml_response へと変更しました。

また、 authn_response.parse_assertion() をしないと、 get_identity()get_subject() で値が取得できなかったことから、修正を加えています。

他に、セッションの中にSAML入門で確認していた各値を設定し、ブラウザ上で表示できるようにしておきます。

なお、セッションの各値についてはpysaml2のドキュメントでは示されていなかったため、デバッガを使って一つ一つどこにあるかを確認しました。

@app.route('/saml/response/keycloak', methods=['POST'])
def saml_response():
    saml_client = saml_client_for()
    authn_response = saml_client.parse_authn_request_response(
        request.form['SAMLResponse'],
        entity.BINDING_HTTP_POST)

    # parse_assertion()してからでないと、get_identity()やget_subject()で値が取れない
    authn_response.parse_assertion()
    user_info = authn_response.get_subject()

    session['saml_attributes'] = {
        'name_id': user_info.text,
        'name_id_format': user_info.format,
        'name_id_name_qualifier': user_info.name_qualifier,
        'name_id_sp_name_qualifier': user_info.sp_name_qualifier,
        'session_index': authn_response.assertion.authn_statement[0].session_index,
        'session_expiration': authn_response.assertion.authn_statement[0].session_not_on_or_after,
        'message_id': authn_response.response.id,
        'message_issue_instant': authn_response.response.issue_instant,
        'assertion_id': authn_response.assertion.id,
        'assertion_not_on_or_after': authn_response.assertion.issue_instant,
        'relay_status': 'NOT_USED',
        'identity': authn_response.get_identity()
    }

    return redirect('/')

 

SAML Requestを送信するためのリンクやセッションの中身を表示するindexを用意

テンプレートを描画するだけです。

@app.route('/')
def index():
    return render_template('index.html')

 
テンプレートはこんな感じで、セッションの値の有無により表示を分岐しています。

{% if session['saml_attributes'] %}
    {% set s = session['saml_attributes'] %}

    <h1>KeyCloak Status</h1>
    <table>
        <thead>
            <tr>
                <th>Attribute</th>
                <th>Value</th>
            </tr>
        </thead>
        <tbody>
            <tr>
                <td>Name ID</td>
                <td>{{ s['name_id'] }}</td>
            </tr>
            <tr>
                <td>Name ID Format</td>
                <td>{{ s['name_id_format'] }}</td>
            </tr>
            <tr>
                <td>Name ID Name Qualifier</td>
                <td>{{ s['name_id_name_qualifier'] }}</td>
            </tr>
            <tr>
                <td>Name ID SP Name Qualifier</td>
                <td>{{ s['name_id_sp_name_qualifier'] }}</td>
            </tr>
            <tr>
                <td>Session Index</td>
                <td>{{ s['session_index'] }}</td>
            </tr>
            <tr>
                <td>Session Expiration</td>
                <td>{{ s['session_expiration'] }}</td>
            </tr>
            <tr>
                <td>Message ID</td>
                <td>{{ s['message_id'] }}</td>
            </tr>
            <tr>
                <td>Message Issue Instant</td>
                <td>{{ s['message_issue_instant'] }}</td>
            </tr>
            <tr>
                <td>Assertion ID</td>
                <td>{{ s['assertion_id'] }}</td>
            </tr>
            <tr>
                <td>Assertion NotOnOrAfter</td>
                <td>{{ s['assertion_not_on_or_after'] }}</td>
            </tr>
            <tr>
                <td>Relay Status</td>
                <td>{{ s['relay_status'] }}</td>
            </tr>
            <tr>
                <td>Identity</td>
                <td>{{ s['identity'] }}</td>
            </tr>
        </tbody>
    </table>

{% else %}
    <h1>Login</h1>
    <ul>
      <li><a href="/saml/login/keycloak">KeyCloak</a></li>
    </ul>
{% endif %}

 
以上で、SP側の実装は完了です。

 

Keycloakへ設定を追加

続いて、公式ドキュメントを参考にしつつ、SPの情報をKeycloakへ設定します。
Creating a SAML client | Server Administration Guide

  • Create Client でクライアントを作成
    • Client Typeは SAML
    • Client IDは任意の値
      • 今回は flask
      • ただし、本番運用の場合は重複しないような値のほうが良さそう
    • Valid Redirect URIsは http://localhost:15000/saml/response/keycloak
    • NameID Formatは username
    • Force POST bindingは On
  • clientから flask を選択
  • Keyタブを選択
    • Client signature requiredOff にする
  • Client scopes タブから、 flask-dedicated を選択
    • デフォルトで作成されている
  • Scopeタブを選択
    • Full scope allowed を Off にする
  • Mappersタブを選択
    • Configure a new mapper をクリック
    • Nameで User Attribute をクリック
      • Name, User Attribute, Friendly Name, SAML Attribute Name のいずれも username
      • SAML Attribute NameFormatは Basic (デフォルト)
      • Aggregate attribute valuesは Off (デフォルト)
  • Advancedタブを選択
    • Assertion Consumer Service POST Binding URL に http://localhost:15000/saml/response/keycloak を設定
      • SAML Response の送信先
      • これがないと、Keycloak上で Invalid Request エラーが表示されてしまう

 

動作確認

ここまでで環境構築が完了したので、実際に動作を確認してみます。

http://localhost:15000/ にアクセスすると、Keycloakでログインするためのリンクが表示されます。

 
リンクをクリックすると、Keycloak上のログイン画面が表示されるので、ログインユーザーとパスワードを入力します。

 
ログインに成功するとSPに戻り、SAML Responseの内容が表示されます。

 
SAML-tracerの状態も確認します。

SAML Request の時はこんな感じでした。

 
2回目のSAML Response の場合はこんな感じです。

 
以上で、Python + Flask + pysaml2 でSP、KeycloakでIdPを構築し、SAML2 の SP-initiated フローでSAML認証ができるようになりました。

 

その他の参考資料

ソースコード

Githubにあげました。
https://github.com/thinkAmi-sandbox/flask_with_pysaml2_and_keycloak-example

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/flask_with_pysaml2_and_keycloak-example/pull/1