Hono + React + Chart.js + TanStack Router + TanStack Query を使って、Hono製APIのレスポンスをPie chartとして表示してみた

前回、Chart.jsのPie chartをReactで表示してみました。
React + react-chartjs-2 + Chart.js を使って、Pie chart を表示してみた - メモ的な思考的な

その続きとして、次はバックエンドからのレスポンスを React + Charts.js で描画したくなりました。

バックエンドは今までさわったことがないもので作ろうと考え、気になっていた Hono を使うことにしました。

 
では、HonoとReactをどう組み合わせればよいか調べたところ、以下の記事が参考になりました。

 
そこで、Hono + React + Chart.js + TanStack Router + TanStack Query な構成でアプリを作ってみたことから、そのときのメモを残します。

 
目次

 

環境

  • Windows11 WSL2
  • React 18.2.0
  • Chart.js 4.4.2
  • react-chartjs-2 5.2.0
  • Hono 4.2.7
    • 今回は Node.js テンプレートを使用する
  • TanStack Router 1.30.1
  • TanStack Query 5.32.0

 

Honoで Hello world する

初めて Hono を使うことから、まずは Hello world してみます。

 

各種ライブラリをインストールする

まず、公式ドキュメントの Quick Start に従い、Honoをセットアップします。

なお、今回はローカルだけで動かしどこにもデプロイしないことから、 nodejs テンプレートを選択しました。

$ npm create hono@latest

create-hono version 0.7.0
? Target directory my-app
? Which template do you want to use? nodejs
✔ Cloning the template
? Do you want to install project dependencies? yes
? Which package manager do you want to use? npm
✔ Installing project dependencies
🎉 Copied project files

 
Hello worldするには不要ですが、今後必要になるライブラリをインストールします。

まずはReactまわりをインストールします。

$ npm i react react-dom

 
続いて、開発で使う系です。型定義の他

をインストールします。

$ npm i -D @vitejs/plugin-react-swc @types/react @types/react-dom @hono/vite-dev-server

 

tsconfig.json を修正する

テンプレートの tsconfig.json に対し

  • typesに DOMDOM.Iterable を追加
  • jsxImportSource に react を指定

と修正します。

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "lib" : [
      "ESNext",
      "DOM",
      "DOM.Iterable"
    ],
    "types": [
      "node"
    ],
    "jsx": "react-jsx",
    "jsxImportSource": "react",
  }
}

 

vite.config.ts を作成する

Node.jsテンプレートには vite.config.ts が含まれていなかったので新規作成します。

Hello world時点ではReactを使いませんが、一足先にReactの設定を追加しておきます。

import devServer from '@hono/vite-dev-server'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'

export default defineConfig(({ mode }) => {
  if (mode === 'client') {
    return {
      build: {
        rollupOptions: {
          input: './src/client.tsx',
          output: {
            entryFileNames: 'static/client.js'
          }
        }
      },
      plugins: [react()]
    }
  } else {
    return {
      ssr: {
        external: ['react', 'react-dom']
      },
      plugins: [
        devServer({
          entry: 'src/index.ts'
        })
      ]
    }
  }
})

 

src/index.ts を修正する

vite-dev-server を使うため、 src/index.ts を修正します。

Node.jsで起動する部分はコメントアウトしておきます。

また、 export app も忘れずに行っておきます。

// import { serve } from '@hono/node-server'
import { Hono } from 'hono'

const app = new Hono()

app.get('/', (c) => {
  return c.text('Hello Hono!')
})

export default app

// const port = 3000
// console.log(`Server is running on port ${port}`)
//
// serve({
//   fetch: app.fetch,
//   port
// })

 

動作確認

npm run dev してブラウザでアクセスすると、以下が表示されました。

ここまでのコミット

以下になります。
https://github.com/thinkAmi-sandbox/react_chartjs_with_hono-example/pull/1/commits/2e62c86b867c6be94b3664b623ce5247cfe87330

 

Hono + React でHTMLを表示する

では、次にReactコンポーネントを表示してみます。

 

src/client.ts を src/client.tsx へリネームし、Reactコンポーネントを書く

テンプレートで生成された src/client.ts を `` へとリネームし、React コンポーネントを書きます。

import * as React from "react"
import {createRoot} from "react-dom/client"

const App = () => {
  return (
    <h1>Hello world</h1>
  )
}

createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
)

 

src/index.tsx を修正する

JSONではなくHTMLを返すよう修正します。

import { Hono } from 'hono'
import {renderToReadableStream, renderToString} from "react-dom/server"

const app = new Hono()

app.get('*', (c) => {
  return c.html(
    renderToString(
      <html>
        <head>
          <meta charSet="utf-8"/>
          <meta content="width=device-width, initial-scale=1" name="viewport"/>
          <title>React app</title>
          {import.meta.env.PROD ? (
            <>
              <script type="module" src="/static/client.js"></script>
            </>
          ) : (
            <>
              <script type="module" src="/src/client.tsx"></script>
            </>
          )}
        </head>
        <body>
          <div id="root"></div>
        </body>
      </html>
    )
  )
})

export default app

 

TS2339 エラーへ対応するため、tsconfig.json を修正する

src/index.tsx を修正すると、以下のように

TS 2339: Property env does not exist on type ImportMeta

というエラーが表示されます。

 
調べてみたところ、Vite.jsのドキュメントに記載がありました。

 
今回は

tsconfig.json 内の compilerOptions.typesvite/client を追加

という方向で対応します。

// ...
"types": [
  "node",
  "vite/client"  // 追加
],
// ...

vite.config.ts を修正する

src/client.tsx へリネームしたことから、 entry の定義を修正します。

plugins: [
  devServer({
    entry: 'src/index.tsx'  // 修正後
  })
]

 

動作確認

ブラウザでアクセスすると、以下が表示されました。コンポーネントで定義した Hello world が表示されています。

 

ここまでのコミット

以下になります。
https://github.com/thinkAmi-sandbox/react_chartjs_with_hono-example/pull/1/commits/a2694a51d689ad6350e3eb5138898db6fc26e257

 

React + Chart.js で Pie chart を描画する

次は Chart.js で Pie chart を描画してみます。

 

Chart.js まわりをインストールする

Chart.js で Pie chart を描画するため、前回同様、以下をインストールします。

$ npm i chart.js react-chartjs-2

 

src/client.tsx を修正する

Pie chartを表示するよう修正します。

なお、まずは Pie chart が表示されるかだけ確認することから、 Pie chart のデータは React コンポーネントの中にハードコーディングしておきます。

また、Pie chart が画面いっぱい表示されるのを防ぐために、 <div style={{width: '300px'}}> としてサイズを指定しています。

import * as React from "react"
import {createRoot} from "react-dom/client"
import {ArcElement, Chart as ChartJS, Legend, Tooltip} from 'chart.js'
import {Pie} from 'react-chartjs-2'

const ChartComponent = () => {
  ChartJS.register(ArcElement, Tooltip, Legend)

  const data = {
    labels: ['奥州ロマン', 'シナノゴールド', 'ピンクレディ', 'ブラムリー'],
    datasets: [
      {
        label: '購入数',
        data: [1, 5, 3, 2],
        backgroundColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderWidth: 1
      }
    ]
  }

  return (
    <div style={{width: '300px'}}>
      <Pie data={data} />
    </div>
  )
}

const App = () => {
  return (
    <>
      <h1>Hello world</h1>
      <ChartComponent/>
    </>
  )
}

createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
)

 

動作確認

ブラウザでアクセスすると、Pie chart が表示されました。

ここまでのコミット

以下になります。
https://github.com/thinkAmi-sandbox/react_chartjs_with_hono-example/pull/1/commits/dde23faf7d9427e9b64e226203d0bbfd33e5e7f5

 

TanStack Router によるルーティングを追加する

本題とは異なるのですが、 Hono + React 構成とした場合にフロントエンドでのルーティングもできるかためしてみます。

ルーティングのライブラリとして、前回の記事同様 TanStack Router を使います。
TanStack Router

 

TanStack Router まわりをインストールする

必要なものをインストールします。

$ npm i @tanstack/react-router @tanstack/router-vite-plugin @tanstack/router-devtools

 

Reactのコンポーネントを修正する

TanStack Routerでルーティングするため、Reactのコンポーネント群を移動・修正します。

今回は以下の方針とします。

 

src/client/main.tsx を作成する

TanStack Routerのドキュメントに従い、エントリポイントとなるファイルを作成します。

import {createRoot} from "react-dom/client";
import * as React from "react";
import {createRouter, RouterProvider} from "@tanstack/react-router";
import {routeTree} from "./routeTree.gen"

const router = createRouter({ routeTree })

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
)

 

src/client/routes/__root.tsx ファイルを作成する

TanStack Routerで必要な src/client/routes/__root.tsx を作成します。

中身はほぼ空で、各ページのコンポーネントTanStackRouterDevtools だけ置いておきます。

import {createRootRoute, Outlet} from "@tanstack/react-router";
import {TanStackRouterDevtools} from "@tanstack/router-devtools";

export const Route = createRootRoute({
  component: () => (
    <>
      <Outlet />
      <TanStackRouterDevtools />
    </>
  )
})

 

/ 向けの index.lazy.tsx を作成する

/ にアクセスした時表示されるコンポーネント index.lazy.tsx を作成します。

今回はメッセージと Pie chart のあるページのリンクだけ置いておきます。

import {createLazyRoute, Link} from "@tanstack/react-router";

const Component = () => {
  return (
    <>
      <h1>Hello, TanStack Router</h1>
      <Link to="/chart">Chart</Link>
    </>
  )
}

export const Route = createLazyRoute('/')({
  component: Component
})

 
なお、TanStack Routerの Link コンポーネントですが、上記の書き方だと

Element Link does't have required attribute search

というワーニングが出ます。

 
ただ、今回の実装には影響しないので、いったんこのままにしておきます。。

 

/chart 向けの chart.lazy.tsx を作成する

Pie chartを表示するコンポーネントを作成します。

なお、Pie chartのデータはまだフロントエンド側に置いたままにしておきます。

import {createLazyRoute, Link} from "@tanstack/react-router";
import * as React from "react"
import {ArcElement, Chart as ChartJS, Legend, Tooltip} from 'chart.js'
import {Pie} from 'react-chartjs-2'


const ChartComponent = () => {
  ChartJS.register(ArcElement, Tooltip, Legend)

  const data = {
    labels: ['奥州ロマン', 'シナノゴールド', 'ピンクレディ', 'ブラムリー'],
    datasets: [
      {
        label: '購入数',
        data: [1, 5, 3, 2],
        backgroundColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderWidth: 1
      }
    ]
  }

  return (
    <div style={{width: '300px'}}>
      <Pie data={data} />
    </div>
  )
}

const Component = () => {
  return (
    <>
      <h1>Hello, Chart</h1>
      <Link to="/">Home</Link>
      <hr />
      <ChartComponent />
    </>
  )
}

export const Route = createLazyRoute('/chart')({
  component: Component
})

 

エントリポイントの移動に伴う修正をする

src/index.tsx を修正する

// 変更前
// <script type="module" src="/src/client.tsx"></script>

// 変更後
<script type="module" src="/src/client/main.tsx"></script>

 

vite.config.ts を修正する

(今回は使いませんが) rollupOptions にエントリポイントとなるファイルの指定があるので差し替えます。

rollupOptions: {
  // 修正前
  // input: './src/client.tsx',

  // 修正後
  input: './src/client/main.tsx',
  // ...
}

 
また、 plugins にも TanStackRouterVite を追加しておきます。

plugins: [
  react(),
  TanStackRouterVite()  // 追加
]

 

tsr.config.json ファイルを追加する

TanStack Router のデフォルト設定とは異なり、今回は src/client の下にフロントエンドのファイルを置いています。

そのため、デフォルト設定のままだと TanStack Router が自動でルーティングファイルを生成するときに想定した動作になりません。

そこで、ルートディレクトリに tsr.config.json ファイルを追加し、今回のディレクトリ構成に合わせた設定にします。
Configuration | File-Based Routes | TanStack Router React Docs

{
  "routesDirectory": "./src/client/routes",
  "generatedRouteTree": "./src/client/routeTree.gen.ts"
}

 

Router CLI を使って src/client/routeTree.gen.ts を初期生成する

ここまでの設定をすれば routeTree.gen.ts をTanStack Routerが自動で生成してくれる想定でした。

ただ、今回はうまく生成できなかったことから、 Router CLI を使って初期生成します。
Router CLI | File-Based Routes | TanStack Router React Docs

 
まずは公式ドキュメントに従いパッケージを追加します。

$ npm install @tanstack/router-cli

 
続いて、Router CLI を使ってファイルを生成します。

$ tsr generate

 

動作確認

以上で準備ができたので、動作確認します。

まず / へブラウザでアクセスすると、以下が表示されました。

 
次に /chart へアクセスすると Pie chart が表示されました。

なお、各リンクも正しく動作しています。

 

ここまでのコミット

以下になります。
https://github.com/thinkAmi-sandbox/react_chartjs_with_hono-example/pull/1/commits/6fe512264263ba8ad92cd99ebe244b8738ddd0ae

 

WSL2向けのHMR を追加する

今回WSL2上で開発しているせいか、ホットリロードがうまく動作しません。

そこで、以下の記事を参考に vite.config.ts へ設定を追加します。
Laravel Sailを使い、WSL2上にローカル環境を構築したが、Viteのホットリロードが動作しない

hmr: {
  host: 'localhost'
},
watch: {
  usePolling: true
}

 
設定追加後、コンポーネントファイルの修正を行うと、その内容が即座に反映されるようになりました。

 
ここまでのコミットは以下です。
https://github.com/thinkAmi-sandbox/react_chartjs_with_hono-example/pull/1/commits/57af2bae13baf86865fc2e77da48a467c84c78a3

 

HonoのJSONレスポンスの内容をChart.jsのdataとして使う

ようやく本題です。

 

src/index.tsx を修正し、HonoでJSONレスポンスを返す

今までフロントにハードコーディングしてあった Chart.js 向けのJSONを、Honoで返すよう修正します。

ちなみに、Honoではバックエンドとフロントエンドで型を共有する RPC という機能があるため、合わせて実装しておきます(今回は GET しか使わないので有益ではないかもしれませんが。。)。
RPC - Hono

import {Hono} from 'hono'
import {renderToString} from "react-dom/server"

const app = new Hono()

const appleRoute = app.get('/api/apples', (c) => {
  return c.json({
    labels: ['奥州ロマン', 'シナノゴールド', 'ピンクレディ', 'ブラムリー'],
    datasets: [
      {
        label: '購入数',
        data: [1, 5, 3, 2],
        backgroundColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderWidth: 1
      }
    ]
  })
})

// フロントエンドと型を共有するため、export type しておく
export type ApplesType = typeof appleRoute

app.get('*', (c) => {
  return c.html(
    renderToString(
      <html>
        <head>
          <meta charSet="utf-8"/>
          <meta content="width=device-width, initial-scale=1" name="viewport"/>
          <title>React app</title>
          {import.meta.env.PROD ? (
            <>
              <script type="module" src="/static/client.js"></script>
            </>
          ) : (
            <>
              <script type="module" src="/src/client/main.tsx"></script>
            </>
          )}
        </head>
        <body>
          <div id="root"></div>
        </body>
      </html>
    )
  )
})

export default app

 

src/client/routes/chart.lazy.tsx を修正し、HonoのレスポンスをChart.jsで描画する

今回は Hono の RPC 機能を使うため、 fetch関数を使うのではなく、Honoの hc 関数を使います。
https://hono.dev/guides/rpc#client

import {createLazyRoute, Link} from "@tanstack/react-router"
import * as React from "react"
import {useEffect, useState} from "react"
import {ArcElement, Chart as ChartJS, ChartData, Legend, Tooltip} from 'chart.js'
import {Pie} from 'react-chartjs-2'
import {ApplesType} from "../../index"
import {hc} from 'hono/client'

type MyChart = ChartData<"pie", number[], unknown>

// HonoのRPC機能を使う
const client = hc<ApplesType>('http://localhost:5173')

const ChartComponent = () => {
  ChartJS.register(ArcElement, Tooltip, Legend)

  const [data, setData] = useState<MyChart>()

  useEffect(() => {
    const fetchApples = async () => {
      const response = await client.api.apples.$get()
      console.log(response)
      if (response.ok) {
        const apples = await response.json()
        setData(apples)
      }
    }

    fetchApples()
  }, [])

  if (!data) return

  return (
    <div style={{width: '300px'}}>
      <Pie data={data} />
    </div>
  )
}

const Component = () => {
  return (
    <>
      <h1>Hello, Chart</h1>
      <Link to="/">Home</Link>
      <hr />
      <ChartComponent />
    </>
  )
}

export const Route = createLazyRoute('/chart')({
  component: Component
})

 
実装が終わったところで動作確認すると、Pie chartが同じように表示されました。

また、ブラウザのNetworkタブを見ても、バックエンドからデータが送信されていることが確認できました。

 

ここまでのコミット

以下になります。
https://github.com/thinkAmi-sandbox/react_chartjs_with_hono-example/pull/1/commits/fd566b134e5c5d980ca0c87c63ee321cdcf27dce

 

Hono APIからのデータ取得に TanStack Query を使う

HonoのRPCドキュメントを見ていたところ

You can also use a React Hook library such as SWR.

 

https://hono.dev/guides/rpc#using-swr

との記載がありました。

 
そこで、TanStack Queryでもできるか気になったため、ためしてみることにします。
TanStack Query

 

TanStack Query をインストールする

$ npm i @tanstack/react-query

 

TanStack Query を使うよう修正する

src/client/main.tsx に TanStack Query用の設定を追加する

TanStack Queryを使えるよう

  • queryClient の作成
  • <QueryClientProvider client={queryClient}> の追加

を行います。

import {createRoot} from "react-dom/client";
import * as React from "react";
import {createRouter, RouterProvider} from "@tanstack/react-router";
import {routeTree} from "./routeTree.gen"
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";

const queryClient = new QueryClient()
const router = createRouter({ routeTree })

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

createRoot(document.getElementById('root')!).render(
  <QueryClientProvider client={queryClient}>
    <React.StrictMode>
      <RouterProvider router={router} />
    </React.StrictMode>
  </QueryClientProvider>
)

 

TanStack Query によるカスタムフックを追加する

Hono APIにリクエストを飛ばすところを、TanStack Queryを使ったカスタムフックにします。

なお、このカスタムフックの中で Hono の hc 関数を使っています。

import {hc} from 'hono/client'
import {ApplesType} from "../../index";
import {useQuery} from "@tanstack/react-query";

const client = hc<ApplesType>('http://localhost:5173')

const queryFn = async () => {
  const response = await client.api.apples.$get()
  if (response.ok) {
    return await response.json()
  }
}

export const useApplesApi = () => {
  return useQuery({
    queryKey: ['ApiApples'],
    queryFn: queryFn
  })
}

 

src/client/routes/chart.lazy.tsx でカスタムフックを使うよう修正する

useEffect の中でHono APIを呼ぶところを、カスタムフックの利用へと修正します。

import {createLazyRoute, Link} from "@tanstack/react-router"
import * as React from "react"
import {ArcElement, Chart as ChartJS, Legend, Tooltip} from 'chart.js'
import {Pie} from 'react-chartjs-2'
import {useApplesApi} from "../hooks/useApplesApi"

const ChartComponent = () => {
  ChartJS.register(ArcElement, Tooltip, Legend)

  const {data, isLoading} = useApplesApi()
  if (isLoading) return <div>Loading...</div>
  if (!data) return

  return (
    <div style={{width: '300px'}}>
      <Pie data={data} />
    </div>
  )
}

const Component = () => {
  return (
    <>
      <h1>Hello, Chart</h1>
      <Link to="/">Home</Link>
      <hr />
      <ChartComponent />
    </>
  )
}

export const Route = createLazyRoute('/chart')({
  component: Component
})

 
実装が終わったので動作確認したところ、今までと変わらず Pie chart が表示されました。

 

ここまでのコミット

以下になります。
https://github.com/thinkAmi-sandbox/react_chartjs_with_hono-example/pull/1/commits/c8ec948097ff9ba3cc434e9c9f9c64691b616ac8

 

ソースコード

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

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

React + react-chartjs-2 + Chart.js を使って、Pie chart を表示してみた

Reactアプリで Pie chart (円グラフ)を表示したくなったので調べたところ、以下の記事がありました。

   
上記では色々なライブラリが紹介されていましたが、今回はWebに情報の多そうな Chart.js を使ってみることにしました。
Chart.js | Open source HTML5 Charts for your website

 
次に、Chart.jsをReactで扱う方法を調べたところ、 react-chartjs-2 がありました。
react-chartjs-2 | react-chartjs-2

 
そこで、 Chart.js + react-chartjs-2 を使って React で Pie chart を表示してみたことから、メモを残します。

 
目次

 

環境

  • React 18.2.0
  • Chart.js 4.4.2
  • react-chartjs-2 5.2.0
  • TanStack Router 1.29.2

 

ViteでReactアプリを作る

Vite公式ドキュメントに従い、ViteでReactアプリを作ります。
Getting Started | Vite

$ npm create vite@latest
✔ Project name: … react_chart_example
✔ Select a framework: › React
✔ Select a variant: › TypeScript

 

react-chartjs-2 の環境を構築する

リポジトリのREADMEに従い、 chart.js react-chartjs-2 をインストールします。

$ npm install --save chart.js react-chartjs-2

 

TanStack Router によるファイルベースルーティングの設定を追加する

今回はいくつかサンプルコードを作るので、ルーティングライブラリを入れておきます。

ただ、パスを考えるのが手間なので、ファイルベースルーティングができるライブラリを使いたくなりました。

以前は Generouted + React Router を使っていました。
ファイルベースルーティング: Generouted + React Router | Reactにて、useStateやuseEffectを使っていたところをTanstack Queryに置き換えてみた - メモ的な思考的な

 
そんな中、 TanStack Routerでもファイルベースルーティングができるようになったと知りました。

 
そこで今回は、TanStack Routerのファイルベースルーティングを試してみることにしました。
TanStack Router Docs

 

TanStack Router のインストール

TanStack Routerのドキュメントに従い、tanstack router と viteのプラグインをインストールします。
Vite Plugin | TanStack Router Docs

$ npm install @tanstack/react-router @tanstack/router-vite-plugin

 
devtools もインストールしておきます。
Devtools | TanStack Router Docs

$ npm install @tanstack/router-devtools --save

 

vite.config.ts への追加

公式ドキュメントに従い、 vite.config.ts へ設定を追加します。
https://tanstack.com/router/latest/docs/framework/react/guide/file-based-routing#vite-configuration

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import {TanStackRouterVite} from "@tanstack/router-vite-plugin";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    react(),
    TanStackRouterVite()  // 追加
  ],
})

 
続いて、 npm run dev し、 routeTree.gen.ts を生成しておきます。

♻️  Generating routes...
✅ Processed route in 148ms

 

main.tsx の修正

TanStack Router を組み込むため、 main.tsx を修正します。

import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
import {createRouter, RouterProvider} from "@tanstack/react-router"
import {routeTree} from "./routeTree.gen"

const router = createRouter({ routeTree })

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>,
)

 

__root.tsx の追加

ルートとなるファイル __root.tsx を作成します。

import {createRootRoute, Outlet} from "@tanstack/react-router";
import {TanStackRouterDevtools} from "@tanstack/router-devtools";

export const Route = createRootRoute({
  component: () => (
    <>
      <Outlet />
      <TanStackRouterDevtools />
    </>
  )
})

 
以上で TanStack Router の準備ができました。

 

Chart.js で Pie chart を作成する

はじめての Pie chart

Chart.js の exampleを参考に、Pie chart を作ります。

 
また、ファイルベースルーティングできるよう、Pie chartのコンポーネントとルーティングを src/routes/pie_chart/first_pie_chart.lazy.tsx に作ります。

import {createLazyRoute} from "@tanstack/react-router"
import {ArcElement, Chart as ChartJS, Legend, Tooltip} from 'chart.js'
import {Pie} from 'react-chartjs-2'


const Component = () => {
  ChartJS.register(ArcElement, Tooltip, Legend)

  const data = {
    labels: ['奥州ロマン', 'シナノゴールド', 'ピンクレディ', 'ブラムリー'],
    datasets: [
      {
        label: '購入数',
        data: [1, 5, 3, 2],
        backgroundColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderWidth: 1
      }
    ]
  }

  return (
    <Pie data={data} />
  )
}

export const Route = createLazyRoute('/pie_chart/first_pie_chart')({
  component: Component
})

 
実装ができたので、 npm run dev で起動します。

その後、 http://localhost:5173/pie_chart/first_pie_chart へアクセスすると、Pie chartが表示されました。

 

Legend (凡例) をカスタマイズする

今回は Legend の表示位置を Pie chart の右側に表示するようカスタマイズします。

カスタマイズ方法を調べたところ、 Chart.js のドキュメントの Legend に情報がありました。 Legend | Chart.js

また、ドキュメントには Warning として

The doughnut, pie, and polar area charts override the legend defaults. To change the overrides for those chart types, the options are defined in Chart.overrides[type].plugins.legend .

と書かれていました。

 
そこで、

  • Chart.overrides.pie.plugins.legend を指定
  • 表示位置のオプション positionright を指定
  • ファイル名は pie_chart_with_legend_on_the_right.lazy.tsx

として実装しました。

const Component = () => {
  ChartJS.register(ArcElement, Tooltip, Legend)

  // 以下を追加
  ChartJS.overrides.pie.plugins.legend.position = 'right'
// ... 
// 他は同じ
}

 
実装が終わったので、ブラウザで http://localhost:5173/pie_chart/pie_chart_with_legend_on_the_right を開くと、Legend が右側に表示されました。

 

Pie chartの上にラベルをを常に表示する

今まで見てきた通り、Chart.js の場合、デフォルトではマイスオーバーすることで、各領域の内訳が表示されます。

ただ、マウスオーバーしなくてもPie chart上に表示する方法がないかを調べたところ、以下の記事がありました。
【Vue.js】Vue.js + Chart.js ドーナツグラフのちょっとした小技【vue-chart.js】| blog(スワブロ) | スワローインキュベート

これを参考に、 pie_chart_with_always_text.lazy.tsx として実装してみました。

実装したときのメモは以下です。

  • alwaysTooltipPlugin という名前で Chart.js のプラグインを作る
    • その中の afterDraw コールバックで、各領域の上にテキストを描画する
  • ChartJS.register() の引数に、追加したプラグイン alwaysTooltipPlugin を指定する
import {ArcElement, Chart as ChartJS, Legend, Tooltip} from "chart.js";
import {Pie} from "react-chartjs-2";
import {createLazyRoute} from "@tanstack/react-router";

const alwaysTooltipPlugin = {
  id: 'alwaysShowTooltip',
  afterDraw(chart: ChartJS) {
    const {ctx} = chart
    chart.data.datasets.forEach((_dataset, i) => {
      chart.getDatasetMeta(i).data.forEach((datapoint, index) => {
        const {x, y} = datapoint.tooltipPosition(false)

        const text = chart.data.labels ?
          (chart.data.labels[index] ?? '' + ': ' + chart.data.datasets[i].data[index]).toString() :
          ''

        ctx.textAlign = 'center'
        ctx.textBaseline = 'middle'
        ctx.fillText(text, x, y)
      })
    })
  }
}

const Component = () => {
  ChartJS.register(ArcElement, Tooltip, Legend, alwaysTooltipPlugin)
  ChartJS.overrides.pie.plugins.legend.position = 'right'

  const data = {
    labels: ['奥州ロマン', 'シナノゴールド', 'ピンクレディ', 'ブラムリー'],
    datasets: [
      {
        label: '購入数',
        data: [1, 5, 3, 2],
        backgroundColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderWidth: 1
      }
    ]
  }

  return (
    <Pie data={data} />
  )
}

export const Route = createLazyRoute('/pie_chart/pie_chart_with_always_text')({
  component: Component
})

 
実装が終わったので、ブラウザで http://localhost:5173/pie_chart/pie_chart_with_always_text を開くと、各領域の上にラベルが表示されました。

なお、各領域のラベルの位置は調整していないため、やや重なっています。ただ、今回はサンプルコードなので気にしないこととします。

 
ちなみに、マウスオーバーすると、今まで通り Tooltip が表示されます。

 

Pie Chartの上にTooltipを常に表示する

ラベルが常に表示できるとすれば、Tooltipも常に表示できるかもしれないと考えました。

そこで、

  • 各領域の上に Tooltip を常に表示する
  • マウスオーバーしても、デフォルトの Tooltip は表示しない

の方法を調べたところ、Chart.js の公式 Youtube に情報がありました。
How to Always Show Tooltip on Pie Chart in Chart js - YouTube

 
そこで、Youtubeの内容を参考にしながら pie_chart_with_always_tooltip.lazy.tsx を実装してみました。

実装したときのメモは以下です。

  • ctx.beginPath() 以降の実装で、Tooltipを描画する
  • Pieコンポーネントの options に対して、マウスオーバーしたときのTooltipを表示しないように設定する
import {ArcElement, Chart as ChartJS, Legend, Tooltip} from "chart.js";
import {Pie} from "react-chartjs-2";
import {createLazyRoute} from "@tanstack/react-router";

const alwaysTooltipPlugin = {
  id: 'alwaysShowTooltip',
  afterDraw(chart: ChartJS) {
    const {ctx} = chart
    ctx.save()

    chart.data.datasets.forEach((_dataset, i) => {
      chart.getDatasetMeta(i).data.forEach((datapoint, index) => {
        const {x, y} = datapoint.tooltipPosition(false)

        const text = chart.data.labels ?
          (chart.data.labels[index] ?? '' + ': ' + chart.data.datasets[i].data[index]).toString() :
          ''

        const textWidth = ctx.measureText(text).width
        ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'

        ctx.fillRect(x - (textWidth + 10) / 2, y - 25, textWidth + 10, 20 )

        // triangle
        ctx.beginPath()
        ctx.moveTo(x, y)
        ctx.lineTo(x - 5, y - 5)
        ctx.lineTo(x + 5, y - 5)
        ctx.fill()
        ctx.restore()

        // text
        ctx.font = '12px Arial'
        ctx.fillStyle = 'white'
        ctx.fillText(text, x - (textWidth / 2), y - 10)
        ctx.restore()
      })
    })
  }
}

const options = {
  plugins: {
    tooltip: {
      enabled: false
    }
  }
}

const Component = () => {
  ChartJS.register(ArcElement, Tooltip, Legend, alwaysTooltipPlugin)
  ChartJS.overrides.pie.plugins.legend.position = 'right'

  const data = {
    labels: ['奥州ロマン', 'シナノゴールド', 'ピンクレディ', 'ブラムリー'],
    datasets: [
      {
        label: '購入数',
        data: [1, 5, 3, 2],
        backgroundColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderColor: [
          'firebrick', 'gold', 'pink', 'mediumseagreen'
        ],
        borderWidth: 1
      }
    ]
  }

  return (
    <Pie data={data} options={options} />
  )
}

export const Route = createLazyRoute('/pie_chart/pie_chart_with_always_tooltip')({
  component: Component
})

 
実装が終わったので、ブラウザで http://localhost:5173/pie_chart/pie_chart_with_always_tooltip を開くと、各領域の上にラベルが表示されました。

なお、各領域のTooltipの位置は調整していないため、やや重なっています。ただ、今回はサンプルコードなので気にしないこととします。

 

ソースコード

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

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

routes.rbに色々なルーティングを定義したRailsアプリを作ってみた

先日 Railroads という、Rails開発向けのIntelliJ Platform Pluginを作りました。
RubyMine 2023.3系から、rails routes を便利に扱える Railways プラグインが動かなくなったので、代替プラグイン Railroads を作りました - メモ的な思考的な

 
そのプラグインの動作確認をするために

  • rails routes を実行し、設定したルーティグがどのように表示されるか確認
  • rails routes の結果を元に、Railroadsプラグインでコントローラとメソッドが表示されているか確認

ができるRailsアプリが必要になりました。

 
そこで、 routes.rb に色々なルーティングを設定したRailsアプリを作ってみたことから、メモを残します。

 
なお、今回定義したRailsアプリのルーティングについては、 Railroads プラグインでの確認に加え、ブラウザでの表示 (http://localhost:3000/rails/info/routes) でも確認しています。
6.1 既存のルールを一覧表示する | Rails のルーティング - Railsガイド

 
目次

 

環境

 

今回実装したルーティング一覧

Railsガイドに書かれている各種ルーティングを実装してみました。
Rails のルーティング - Railsガイド

 
具体的には以下を実装しました。

 
以降は作ったときのメモです。

 

メモ

resource とresources は同じコントローラにマッピングされる

Railsガイドに記載がありました。

単数形リソースは複数形のコントローラに対応付けられます。これは、同じコントローラで単数形のルーティング(/account)と複数形のルーティング(/accounts/45)を両方使いたい場合を想定しているためです。従って、resource :photoとresources :photosのどちらも、単数形ルーティングと複数形ルーティングを両方作成し、同一のコントローラ(PhotosController)に割り当てられます。

2.5 単数形リソース | Rails のルーティング - Railsガイド

 

resource (単数形ルーティング) には :id がない

単数形ルーティングの用途は以下のため、 :id がないようです。

ユーザーがページを表示する際にidを一切参照しないリソースが使われることがあります。たとえば、/profileでは常に「現在ログインしているユーザー自身」のプロファイルを表示し、他のユーザーidを参照する必要がないとします。

2.5 単数形リソース | Rails のルーティング - Railsガイド

 

浅いネストという書き方がある

2.7.2 浅いネスト | Rails のルーティング - Railsガイド によると、こんな感じで書けるようです。

resources :parents, shallow: true do
  resources :children
end

 
ルーティングを確認してみたところ、Railsガイドの

コレクション(index/new/createのような、idを持たないもの)のアクションだけを親のスコープの下で生成するという手法があります

というルーティングになっていました。

 
また、 shallow について調べたところ、以下にまとまっている通り、賛否両論あるようでした。
Railsの”shallow(浅い)”ルーティングを理解する #Rails - Qiita

 

ルーティングにも concern がある

concern を使ってルーティングを書きます。

concern :image_attachable do
  resources :images, only: :index
end
resources :news, concerns: :image_attachable, only: :index

 
すると、ルーティングは以下になりました。

news の下に images というルーティングができる一方、コントローラは ImagesController になりました。

 

match + via の場合、rails routes の HTTP Verb が まとめて表示される

以下のRailsガイドにあるように、 match + via で定義した場合、 rails routes の表示が他と HTTP Verb 列の表示が異なります。
3.7 HTTP verbを制限する | Rails のルーティング - Railsガイド

 
例えば、

match '/multiple_match', to: 'multiple#call', via: [:get, :post]

と定義した場合、rails routes では HTTP Verb が GET|POST のように表示されます。

ちなみに、Railroadsプラグインの作成当初、このことに気づかず「Railways プラグインとルーティングの数が合わない...」と悩みました。

 

ソースコード

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

プルリクは以下の2つです。

IntelliJ Platform Plugin開発にて、KotestやBasePlatformTestCaseを使ったテストコードを書いてみた

先日 Railroads という、Rails開発向けのIntelliJ Platform Pluginを作りました。
RubyMine 2023.3系から、rails routes を便利に扱える Railways プラグインが動かなくなったので、代替プラグイン Railroads を作りました - メモ的な思考的な

 
最初に作った段階では「動くものを作り切る」ことを優先し、

  • 動作が正しいことは実機で担保
  • テストコードは後で追加

という方針でプラグインを作成・リリースしました。

ただ、今後の継続的なメンテナンスのことを考えると、テストコードがあると色々安心できそうです。

 
そこで、今回テストコードを追加してみたことから、メモを残します。

 
目次

 

環境

 

事前調査

Kotlin + IntelliJ Platform Pluginの環境でテストコードを書くのは初めてだったので、事前にいくつか調査しました。

 

IntelliJ Platform Pluginでテストコードを書くには

公式ドキュメントにはテストに関する記載があります。
Testing Overview | IntelliJ Platform Plugin SDK

これによると、IntelliJ Platform Plugin SDKでは

  • プラットフォームの機能のモック
  • UIテストをするための機能

などのテスト向けの機能が提供されているようでした。

 
また、IntelliJ Platformのテストは特定のフレームワークに依存せずに書くこともできるようです。

When writing your tests, you have the choice between using a standard base class to perform the test set up for you and using a fixture class, which lets you perform the setup manually and does not tie you to a specific test framework.

 

With the former approach, you can use classes such as BasePlatformTestCase (LightPlatformCodeInsightFixtureTestCase before 2019.2).

 

With the latter approach, you use the IdeaTestFixtureFactory class to create instances of fixtures for the test environment. You need to call the fixture creation and setup methods from the test setup method used by your test framework.

 
Tests and Fixtures | IntelliJ Platform Plugin SDK

 

Kotestのテストフレームワークについて

RailroadsプラグインはKotlinで書いていることから、テストフレームワークもKotlinのものを使いたいと考えました。

そこでKotlinのテストフレームワークを調べたところ、 Kotest がありました。
Kotest | Kotest

 
Kotestのドキュメントを見ると

など、いろいろ良さそうな機能がありました。

 
また、Kotlinのテストコードでモックを使いたい場合を調べたところ、 Mockk を使うのが良さそうでした。
MockK | mocking library for Kotlin

 

テストコードを書くときの方針について

ここまでのドキュメントより、 Kotest + Mockk + IntelliJ Platform Pluginの BasePlatformTestCaseIdeaTestFixtureFactory などを使えば、Kotlinでテストコードを書けそうでした。

ただ、 IdeaTestFixtureFactory の使い方は公式ドキュメントに記載されておらず、使いこなすには他のプラグインのテストコードを読んだりする必要がありそうでした。

 
そこで、現時点では「プラグインに対してテストコードを書く」ことを目的として、以下の方針でテストコードを書くことにしました。

  • テストを書く上で IntelliJ Platform Plugin SDKの機能が不要な部分は、Kotest で書く
  • テストを書く上で IntelliJ Platform Plugin SDKの機能が必要な部分は、 JUnit5 + BasePlatformTestCase で書く

 

Kotestでテストを書く

まずはKotestで書いてみます。

ただ、Railroadsプラグインでは IntelliJ Platform Plugin Template を使っていますが、そのテンプレートには Kotest の設定がありません。
https://github.com/JetBrains/intellij-platform-plugin-template

 
そこで今回は、Kotest を追加してから、Kotestでテストを書いていきます。

 

KotestやMockkを追加する

テストコードで KotestMockk を使えるようにするため、 build.gradle.ktsdependencies へ追加します。

dependencies {
    implementation(libs.annotations)
    val kotestVersion = "5.8.1"
    testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion")
    testImplementation("io.kotest:kotest-assertions-core:$kotestVersion")

    val mockkVersion = "1.13.10"
    testImplementation("io.mockk:mockk:${mockkVersion}")
}

 

Kotest プラグインを追加する

Railroadsプラグインを書く IntelliJ IDEA Ultimateには、Kotestを容易に実行するための機能は提供されていません。

一方、Kotestでは IntelliJ IDEA向けのプラグインを提供しています。
IntelliJ Plugin | Kotest

そこで、開発用のIntelliJ IDEA UltimateにKotestプラグインを追加しておきます。
Kotest Plugin for JetBrains IDEs | JetBrains Marketplace

 

KotestのDescribe Specでテストコードを書く

公式ドキュメントや以下の記事にある通り、Kotestではテストコードのスタイルが複数用意されています。
Kotestの各種Specを比べてみた #Kotlin - Qiita

 
RailroadsはRails開発向けのプラグインであることから、Rails開発で見慣れている Describe Spec で書くことにします。

ちなみに、Describe Specは describecontextit キーワードを使って書けます。一方、検証は現在のRSpecのような expect記法ではなく、 shouldBe などを使うようです。

 
続いて、 IntelliJ Platform Plugin SDKの機能が不要な部分を探したところ、 models/routes ディレクトリのクラス群が該当しそうでした。

それらのクラスでは引数として module というIntelliJ Platformの機能を受け取っていますが、内部では使っていません。

 
そこで、

というのをKotestで実装したのが以下のテストコードです。

このテストコードを実行したところ、テストがパスしました。

package com.github.thinkami.railroads.models.routes

import com.github.thinkami.railroads.ui.RailroadIcon
import com.intellij.openapi.module.Module
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.shouldBe
import io.mockk.mockk

class RedirectRouteTest: DescribeSpec({
    describe("RedirectRoute") {
        context ("set value at redirect route path") {
            // RedirectRoute does not use modules, so mock module
            val module = mockk<Module>()
            val actual = RedirectRoute(
                module,
                "GET",
                "/test",
                "redirect",
                "/test_redirect"
            )

            it("the title includes path") {
                actual.getActionIcon().shouldBe(RailroadIcon.NodeRedirect)
                actual.getActionTitle().shouldBe("/test_redirect")
                actual.getQualifiedActionTitle().shouldBe("redirect to /test_redirect")
            }
        }

        context ("set null at redirect route path") {
            // RedirectRoute does not use modules, so mock module
            val module = mockk<Module>()
            val actual = RedirectRoute(
                module,
                "GET",
                "/test",
                "redirect",
                null
            )

            it("the title is a fixed value") {
                actual.getActionIcon().shouldBe(RailroadIcon.NodeRedirect)
                actual.getActionTitle().shouldBe("[redirect]")
                actual.getQualifiedActionTitle().shouldBe("[runtime define redirect]")
            }
        }
    }
})

 

BasePlatformTestCaseでテストを書く

IntelliJ Platformの module などを使っている場合、 IdeaTestFixtureFactory を使えばテストコードは書けそうです。

ただ、前述の通り、 IdeaTestFixtureFactory に関する情報があまり得られないことから、現時点ではこの場合のテストコードをKotestで書くのが難しいと考えています。

 
そこで、この場合は、

  • BasePlatformTestCase を継承したテストクラスを使う
  • テストランナーとしてJUnit5を使う
    • KotestのテストランナーとしてJUnit5を使っているため、同じようにJUnit5を使いたい

という方針で進めます。

 

JUnit5を追加する

先ほどKotest向けのJUnit5の設定は追加しましたが、JUnit5向けの設定は追加していませんでした。

そこで、 build.gradle.ktsdependencies に対し、JUnit5を使うために必要な設定を追加します。

dependencies {
    // ...
    val junitVersion = "5.10.2"
    testImplementation("org.junit.jupiter:junit-jupiter-api:${junitVersion}")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${junitVersion}")
    testRuntimeOnly("org.junit.vintage:junit-vintage-engine:${junitVersion}")
}

 

BasePlatformTestCaseクラスを継承してテストコードを書く

BasePlatformTestCase クラスを継承する例として、ここでは RailsRoutesParser の parse メソッドに対するテストコードを取り上げます。

parse メソッドでは rails routes の出力結果を、Railroadsで扱いやすいクラスの配列へと変換します。

そのため、テストでは配列の要素数が想定通りになっているかを検証すれば良さそうです。

 
ところで、JUnit5では rails routes を実行することができません。そこで

  • 事前に rails routes の実行結果をファイルへ保存する
  • テストコードではファイルを読み込む

という形にします。

今回は、以下の内容を src/test/testData/RailsRoutesParserTest.data.txt として保存します。

                                  Prefix Verb     URI Pattern                                                                                       Controller#Action
                          multiple_match GET|POST /multiple_match(.:format)                                                                         multiple#call
                      blog_post_comments GET      /blogs/:blog_id/posts/:post_id/comments(.:format)                                                 blogs/posts/comments#index
                                         POST     /blogs/:blog_id/posts/:post_id/comments(.:format)                                                 blogs/posts/comments#create
                   new_blog_post_comment GET      /blogs/:blog_id/posts/:post_id/comments/new(.:format)                                             blogs/posts/comments#new
                  edit_blog_post_comment GET      /blogs/:blog_id/posts/:post_id/comments/:id/edit(.:format)                                        blogs/posts/comments#edit
                       blog_post_comment GET      /blogs/:blog_id/posts/:post_id/comments/:id(.:format)                                             blogs/posts/comments#show
                                         PATCH    /blogs/:blog_id/posts/:post_id/comments/:id(.:format)                                             blogs/posts/comments#update
                                         PUT      /blogs/:blog_id/posts/:post_id/comments/:id(.:format)                                             blogs/posts/comments#update
                                         DELETE   /blogs/:blog_id/posts/:post_id/comments/:id(.:format)                                             blogs/posts/comments#destroy

 
続いてテストコードを書きます。

BasePlatformTestCase を使ったテストコードは

という形で実装したのが以下のテストコードです。

このテストコードを実行したところ、テストがパスしました。

package com.github.thinkami.railroads.parser

import com.intellij.testFramework.fixtures.BasePlatformTestCase
import junit.framework.TestCase
import java.io.File
import java.io.FileInputStream

class RailsRoutesParserParseTest: BasePlatformTestCase() {
    fun testParse() {
        val basePath = System.getProperty("user.dir")
        val inputStream = FileInputStream(File(basePath, "src/test/testData/RailsRoutesParserTest.data.txt"))
        val parser = RailsRoutesParser(module)
        val actual = parser.parse(inputStream)

        // 8 routes and 1 multiple route
        TestCase.assertEquals(10, actual.size)
    }
}

 

BasePlatformTestCase で ParameterizedTest を使う

ここでは RailsRoutesParser の parseLine メソッドに対するテストコードを取り上げます。

 
parseLine メソッドでは、 rails routes の出力結果をRailroadsで扱いやすいクラスへと変換します。

そのため、 rails routes の種類ごとにテストメソッドを用意すればよさそうです。ただ、この書き方だとテストメソッドが増えてしまい、メンテナンスが大変そうです。

 
そこで今回は、以下のJUnit5のドキュメントにある ParameterizedTest を使って検証とテストデータを分離します。
https://junit.org/junit5/docs/current/user-guide/#writing-tests-parameterized-tests

 

ParameterizedTest を使うために必要なパッケージを追加する

build.gradle.ktsdependencies に対して追加します。

dependencies {
    // ...
    val junitVersion = "5.10.2"
    // ...
    // 追加
    testImplementation("org.junit.jupiter:junit-jupiter-params:${junitVersion}")
    // ...
}

 

ParameterizedTest を書く

ParameterizedTest を行うために以下を定義します。

  • テストメソッドに以下のアノテーションを追加する
    • ParameterizedTest アノテーション
    • ValueSource でテストデータを直接指定するか、テストデータが複雑であれば MethodSource を使ってメソッドの戻り値をテストデータにする
      • 今回はそこそこ複雑なので MethodSource を使う
  • MethodSource の場合、メソッドは以下のような形で作る
    • companion objectMethodSource で指定したメソッドを用意する
    • メソッドには JvmStatic アノテーションを付ける
    • 今回の場合、戻り値の型は Stream<Arguments> にする

 
また、BasePlatformTestCase は JUnit5対応されていないのか、JUnit5の ParameterizedTest と組み合わせて使おうとするといくつか問題が発生します。

そこで、以下の対応も追加で行います。
※ JUnit5や IntelliJ Platform のバージョンによっては発生しないかもしれません。

  • テストメソッドの中で setUp を呼ぶ
    • 背景
      • 何もしないと BasePlatformTestCasesetUp が呼ばれないようで、module などののIntelliJ Platform Plugin SDKで必要な値が BasePlatformTestCase に設定されない
      • そこで、ややトリッキーではあるがテストメソッドの中で setUp を呼び、 module などを設定する
  • ダミーのテストメソッドを定義する
    • 背景
      • ParameterizedTest だけを BasePlatformTestCase に定義した場合、テストメソッドがないと誤認されてしまう
      • そこで、常にパスするダミーのテストメソッドを定義することで、ParameterizedTest も認識してもらえるようにする

 
また、ここではRailwaysの以下のテストコードをベースにします。
https://github.com/basgren/railways/blob/master/test/net/bitpot/railways/parser/RailsRoutesParseLineTest.java

 
上記を踏まえて実装したのが以下のテストコードです。

このテストコードを実行したところ、テストがパスしました。

package com.github.thinkami.railroads.parser

import com.github.thinkami.railroads.models.routes.RedirectRoute
import com.github.thinkami.railroads.models.routes.SimpleRoute
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import junit.framework.TestCase
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import java.util.stream.Stream

class RailsRoutesParserParseLineTest: BasePlatformTestCase() {
    // The fact that BasePlatformTestCase predates JUnit5 may have an impact.
    //
    // If there is no test method and only JUnit5's parameterized test is used,
    // the test will fail with the following error.
    // junit.framework.AssertionFailedError: No tests found in com.github.thinkami.railroads.parser.RailsRoutesParserParseLineTest
    fun testDummy() {
        TestCase.assertEquals(1, 1)
    }

    @ParameterizedTest
    @MethodSource("variousRoute")
    fun testParseVariousLine(line: String, routeClass: String, routeName: String, method: String, path: String, actionTitle: String) {
        // The parameterized test is written in JUnit5, but the BasePlatformTestCase is implemented in a format earlier than JUnit5.
        // If nothing is done, the setUp method of BasePlatformTestCase is not executed and the module is not set in the fixture.
        // Therefore, by calling the setUp method, the module is set.
        setUp()

        val parser = RailsRoutesParser(module)
        val parsedLine = parser.parseLine(line)

        TestCase.assertNotNull(parsedLine)
        TestCase.assertEquals(1, parsedLine.size)

        val actual = parsedLine.first()
        TestCase.assertEquals(routeName, actual.routeName)
        TestCase.assertEquals(routeClass, actual::class.simpleName)
        TestCase.assertEquals(method, actual.requestMethod)
        TestCase.assertEquals(path, actual.routePath)
        TestCase.assertEquals(actionTitle, actual.getActionTitle())
    }

    companion object {
        @JvmStatic
        fun variousRoute(): Stream<Arguments> {
            return Stream.of(
                Arguments.arguments(
                    "    blog_post_comments GET      /blogs/:blog_id/posts/:post_id/comments(.:format)   blogs/posts/comments#index",
                    SimpleRoute::class.simpleName,
                    "blog_post_comments",
                    "GET",
                    "/blogs/:blog_id/posts/:post_id/comments(.:format)",
                    "blogs/posts/comments#index"),
                Arguments.arguments(
                    "  PUT      /blogs/:blog_id/posts/:post_id/comments/:id(.:format)   blogs/posts/comments#update",
                    SimpleRoute::class.simpleName,
                    "",
                    "PUT",
                    "/blogs/:blog_id/posts/:post_id/comments/:id(.:format)",
                    "blogs/posts/comments#update"),
                Arguments.arguments(
                    "  PATCH    /blogs/:blog_id/posts/:post_id/comments/:id(.:format)    blogs/posts/comments#update",
                    SimpleRoute::class.simpleName,
                    "",
                    "PATCH",
                    "/blogs/:blog_id/posts/:post_id/comments/:id(.:format)",
                    "blogs/posts/comments#update"),
                Arguments.arguments(
                    "   DELETE   /blogs/:blog_id/posts/:post_id/comments/:id(.:format)   blogs/posts/comments#destroy",
                    SimpleRoute::class.simpleName,
                    "",
                    "DELETE",
                    "/blogs/:blog_id/posts/:post_id/comments/:id(.:format)",
                    "blogs/posts/comments#destroy"),

                // route for Rack application
                Arguments.arguments(
                    "         rack_app    /rack_app(.:format)      #<HelloRackApp:0x000001988fad1b40>",
                    SimpleRoute::class.simpleName,
                    "rack_app",
                    "",
                    "/rack_app(.:format)",
                    "<HelloRackApp:0x000001988fad1b40>"),

                // inline handler
                Arguments.arguments(
                    "        inline GET      /inline(.:format)       Inline handler (Proc/Lambda)",
                    SimpleRoute::class.simpleName,
                    "inline",
                    "GET",
                    "/inline(.:format)",
                    ""),

                // route with additional requirements
                Arguments.arguments(
                    "  GET      /photos/:id(.:format)      photos#show {:id=>/[A-Z]\\d{5}/}",
                    SimpleRoute::class.simpleName,
                    "",
                    "GET",
                    "/photos/:id(.:format)",
                    "photos#show"),

                // redirect route
                Arguments.arguments(
                    "  redirect GET      /redirect(.:format)         redirect(301, /blogs)",
                    RedirectRoute::class.simpleName,
                    "redirect",
                    "GET",
                    "/redirect(.:format)",
                    "/blogs"),
            )
        }
    }
}

 

ソースコード

Railroadsのリポジトリにはテストコードを追加済です。
https://github.com/thinkAmi/railroads

テストコードを追加したときのプルリクはこちら。
https://github.com/thinkAmi/railroads/pull/10

ESLintプラグイン eslint-plugin-security はReactアプリの実装にも反応してくれるか試してみた

Reactアプリを実装するとき、LinterとしてESLintを使っています。

また、Vite.jsを使ってReactアプリを実装する場合、デフォルトで導入される

eslint-plugin-react などを使ってたりします。

 
そんな中、他にもReactのセキュリティ面を見てくれるESLintプラグインがないか調べたところ、Reactのセキュリティ関係のものについては

という状況でした。

 
一方、React以外のセキュリティについては eslint-plugin-security がありました。

リポジトリのREADMEには

ESLint rules for Node Security

This project will help identify potential security hotspots, but finds a lot of false positives which need triage by a human.

と書かれていましたが、Node.js以外にも使えそうな気がしたことから、ためしてみたときのメモを残します。

 
目次

 

環境

  • React 18.2.0
  • Vite 5.2.0
  • ESLint 8.57.0
  • eslint-plugin-security 3.0.0
  • ReDosに関する他のESLintプラグイン
    • eslint-plugin-regexp 2.5.0
    • eslint-plugin-redos 4.4.5

 

eslint-plugin-securityについて

READMEに記載のある通り、セキュリティに関するルールが定義されています。
https://github.com/eslint-community/eslint-plugin-security?tab=readme-ov-file#rules

Node.jsに関するものもありますが、以下の Regular Expression Denial of Service (ReDoS) のように Node.js環境以外でも使えそうなものも定義されたりします。
https://github.com/eslint-community/eslint-plugin-security/blob/main/docs/regular-expression-dos-and-node.md

 
そこで今回は、ReDoSが発生するコードを実装した時、ESLintプラグイン eslint-plugin-security がルール違反を検知するか確認します。

 

Reactアプリの作成

まずは、ReDoSが発生するReactアプリを作成します。

 
最初に、Vite.jsでひな形となるReactアプリを生成します。

手順としてはVite.jsのドキュメントのままなので、ここでは記述を省略します。
https://vitejs.dev/guide/#scaffolding-your-first-vite-project

 
次に、メールアドレスに対してバリデーションが行うフォームを作成します。

そのフォームでは

  • eslint-plugin-security のドキュメントにある正規表現を使って、メールアドレスに対してバリデーションする
  • バリデーションの実行時間を計測する

を実装します。

import './App.css'

function App() {
  const handleSubmit = (e: React.SyntheticEvent) => {
    // https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/forms_and_events/
    e.preventDefault()
    const startsAt = performance.now()
    const target = e.target as typeof e.target & {
      email: { value: string }
    }
    const email = target.email.value
    console.log(email)

    // ReDoS が発生するコード
    const emailExpression = /^([a-zA-Z0-9_\.\-])+\@(([a-zA-Z0-9\-])+\.)+([a-zA-Z0-9]{2,4})+$/
    if (emailExpression.test(email)) {
      console.log("OK")
    } else {
      console.log("NG")
    }
    console.log(performance.now() - startsAt)
  }

  return (
    <>
      <form onSubmit={handleSubmit}>
        <input type="text" name={"email"} />
        <button type="submit">Run</button>
      </form>
    </>
  )
}

export default App

 
Reactアプリができたので、動作を確認します。

フォームの入力値を色々試したところ、文字数が増えるごとに処理時間が増えました。そのため、ReDoSが発生していると分かりました。

 

ESLintプラグイン eslint-plugin-security の挙動を確認する

ReDoSの発生が確認できたので、次は eslint-plugin-security の挙動を確認します。

まず、READMEに従い、 eslint-plugin-security をインストールします。
https://github.com/eslint-community/eslint-plugin-security

$ npm install --save-dev eslint-plugin-security

 
続いて、プラグインを設定します。

なお、Vite 5.2.0 の時点では .eslintrc.cjs が生成されるので、READMEの eslintrc config (deprecated) な書き方で設定します。

module.exports = {
  // ...
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:react-hooks/recommended',
    'plugin:security/recommended-legacy',  // 追加
  ],
// ...

 
準備ができたのでWebStormでソースコードを開いたところ、該当箇所でESLintがルール違反を検知しました。

 
これより、 eslint-plugin-security はReactアプリであってもルール違反を検知すると分かりました。

 

余談:ReDoSに対する、ESLintプラグインについて

今回調べたReDoSについては、以下に詳しい記事がありました。
正規表現の脆弱性 (ReDoS) を JavaScript で学ぶ

また、他にもいくつかESLintプラグインがあったため、それらがどんな状況だったかをメモしておきます。

 

eslint-plugin-regexp

こちらは正規表現の誤りやスタイルガイドのESLintプラグインです。

こちらにもReDoSに関するルールはあります。
regexp/no-super-linear-backtracking | eslint-plugin-regexp

ただ、今回のコードの場合、ReDoSのルール以外で検知していました。

 
そこで、 eslint-plugin-regexp のドキュメントにあるコードを追加したところ、ルール違反を検知しました。

 

eslint-plugin-redos

以下の記事や資料を読んで、このプラグインを知りました。

 
インストールしてためしてみたところ、ESLintがルール違反を検知しました。

 
eslint-plugin-regexp 向けに追加したコードに対しても、ルール違反を検知しています。

 

ソースコード

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

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

IntelliJ Platform Plugin開発にて、Plugin Signing を試してみた

IntelliJ Platform Plugin SDKのドキュメントを読んでいたところ、 Plugin Signing というページがありました。
Plugin Signing | IntelliJ Platform Plugin SDK

そこで、自作のプラグイン Railroads にPlugin Signingしてみたときのメモを残します。

 
目次

 

環境

 

署名の実施

ドキュメントに従い、署名を実施してみます。

 

秘密鍵の生成

ドキュメントの Generate Private Key に従い、秘密鍵を生成します。
https://plugins.jetbrains.com/docs/intellij/plugin-signing.html#generate-private-key

WSL2を開き、以下を実行します。

今回 pem ファイルは railroads_private_encrypted.pem とします。

また、パスワードも設定しておきます。

# 実行
$ openssl genpkey\
  -aes-256-cbc\
  -algorithm RSA\
  -out railroads_private_encrypted.pem\
  -pkeyopt rsa_keygen_bits:4096

# パスワード入力
Enter PEM pass phrase:
Verifying - Enter PEM pass phrase:

 
続いて、生成した鍵をRSA形式に変換します。

# 実行
$ openssl rsa\
  -in railroads_private_encrypted.pem\
  -out railroads_private.pem

# パスワードを求められるので、railroads_private_encrypted.pem を生成したときのパスワードを入力
Enter pass phrase for railroads_private_encrypted.pem:

# 結果
writing RSA key

 
最後に、 crt ファイルを生成します。

# 実行
$ openssl req\
  -key railroads_private.pem\
  -new\
  -x509\
  -days 3650\
  -out railroads_2024_0406.crt

# 注意書きが表示された
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.

# 適当な値を入力
Country Name (2 letter code) [AU]:JP
State or Province Name (full name) [Some-State]:Tokyo
Locality Name (eg, city) []:
Organization Name (eg, company) [Internet Widgits Pty Ltd]:thinkAmi
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:https://github.com/thinkAmi
Email Address []:

 

IDE環境変数に各鍵の値を設定

プラグインへの署名については

  1. Gradle IntelliJ Plugin の signPlugin タスク
  2. Gradle IntelliJ Plugin の publishPlugin タスク
  3. CLI

のいずれかの方法で行えば良いようです。

今回は

ということで、上記1. の方法で進めることにします。

 
次に、 signPlugin タスクで実行するためには、

  1. build.gradle.ktsファイルにて、秘密鍵などの情報をハードコード
  2. build.gradle.ktsファイルにて、秘密鍵などのファイルを読み込むよう指定
  3. IntelliJ IDEAの環境変数に、秘密鍵などをBASE64化した値を設定

のいずれかが必要そうでした。

今回は

ということで、上記3.の方法を取ることにしました。

 
そこで、IntelliJ IDEAの環境変数に設定できるよう、

  • railroads_2024_0406.crt
  • railroads_private.pem

の2ファイルの中身をBASE64化することにします。

また、都度BASE64化するのは手間なので、

  • certificate ディレクトリを作り、その中にpemやcrtを置く
  • 誤ってコミットしないよう、 .gitignore にて、 *.pem*.crt を追加する
  • certificate ディレクトリに何を置くか忘れないよう、pemやcrtのサンプルファイルを追加する
  • certificate ディレクトリの中にある pem や crt の中身をBASE64化するRubyスクリプトを作成する

という作業を行います。

 
ちなみに、Rubyスクリプト (base64encode.rb) は以下のような感じです。

なお、BASE64化するときに改行を追加しないよう、 Base64#strict_encode64 を使っています。
https://docs.ruby-lang.org/ja/latest/class/Base64.html#M_STRICT_ENCODE64

require 'base64'

file_paths = Dir.glob('*')
file_paths.each do |file_path|
  file_name = File.basename(file_path)

  # Do not output Base64-encoded strings for running Ruby scripts and example files
  next if file_name == File.basename(__FILE__)
  next if File.extname(file_name) == '.example'

  file_data = File.read(file_path)

  # Do not add newlines when Base64 encoded
  encoded_data = Base64.strict_encode64(file_data)

  puts "File Name: #{file_name}"
  puts "Encoded Data:"
  puts encoded_data
  puts "\n\n\n"
end

 
できたRubyスクリプトを実行し、BASE64化した値を確認します。

>ruby base64encode.rb
File Name: railroads_2024_0406.crt
Encoded Data:
***

File Name: railroads_private.pem
Encoded Data:
***

File Name: railroads_private_encrypted.pem
Encoded Data:
***

 
準備ができたので、公式ドキュメントに従い、IntelliJ IDEAで signPlugin タスクを追加します。
https://plugins.jetbrains.com/docs/intellij/plugin-signing.html#provide-secrets-to-ide

 
まず、 Run > Edit configuration から、 +Gradle を選択します。

続いて、以下の設定を行います。

  • Name: (任意の値)
  • Run on: Local machine (デフォルト)
  • Run: singPlugin
  • Gradle project: railroads (デフォルト)
  • Environment variables は、以下の3つの値を設定
    • なお、公式ドキュメントとは異なり、今回は署名するだけなので PUBLISH_TOKEN は設定不要
Name Value
CERTIFICATE_CHAIN railroads_2024_0406.crt の中身をBase64エンコードしたもの(改行なし)
PRIVATE_KEY railroads_private.pem の中身をBase64エンコードしたもの(改行なし)
PRIVATE_KEY_PASSWORD OpenSSLで railroads_private_encrypted.pem を作成したときに設定したパスワード

 
設定ができたので、 railroads [signPlugin] を実行します。

すると、以下のログが出力されました。成功したようです。

...
> Task :signPlugin

BUILD SUCCESSFUL in 59s
17 actionable tasks: 9 executed, 8 up-to-date
Configuration cache entry stored.
11:43:58: Execution finished 'signPlugin'.

 
また、 build/distributions/railroads-0.1.1-signed.zip というファイルも生成されていました。

 

署名の検証

では、次にプラグインの署名を検証します。

検証方法としては

  • Gradle で verifyPluginSignature タスクの実行
  • CLIで検証

の2つがありました。

Gradleのタスクで検証するためにドキュメントを読んだところ、環境変数から値を読み込んだり、動的に対象のプラグインファイルを指定するのが難しそうでした。
https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#tasks-verifypluginsignature

そこで今回はCLIで検証することにしました。

 
ドキュメントによると、CLIで検証するには CLI Tool が必要そうでした。
https://plugins.jetbrains.com/docs/intellij/plugin-signing.html#cli-tool

そこで、ドキュメントに従い、 marketplace-zip-signer-cli.jar をダウンロードしました。

 
続いて、署名をしたプラグインに対してツールを実行してみましたが、何も出力されませんでした。

> C:\Users\<UserName>\.jdks\jbr-17.0.9\bin\java.exe -jar marketplace-zip-signer-cli.jar verify -in "build\distributions\railroads-0.1.1-signed.zip" -cert "certificate\railroads_2024_0406.crt"

 
次に、署名がないプラグインに対してツールを実行したところ、メッセージ Provided zip archive is not signed が表示されました。

> C:\Users\<UserName>\.jdks\jbr-17.0.9\bin\java.exe -jar marketplace-zip-signer-cli.jar verify -in "build\distributions\railroads-0.1.1.zip" -cert "certificate\railroads_2024_0406.crt"

Provided zip archive is not signed

 
念のためソースコードを見たところ、成功したときは何も出力されないのが正解のようでした。
https://github.com/JetBrains/marketplace-zip-signer/blob/1b365366563540d8b70f46578cc10c3bcd541b13/cli/src/main/kotlin/org/jetbrains/zip/signer/ZipSigningTool.kt#L90

 

その他資料:Busy Plugin Developers

JetBrainsがYoutubeで公開している Busy Plugin Developers #1 の21分あたりから、Pluginについてふれられていました。
Plugin Signing at JetBrains Marketplace. IntelliJ Plugins UI Testing - YouTube

 

ソースコード

Githubに追加しています。
https://github.com/thinkAmi/railroads

今回のプルリクはこちら。
https://github.com/thinkAmi/railroads/pull/8

RubyMine 2023.3系から、rails routes を便利に扱える Railways プラグインが動かなくなったので、代替プラグイン Railroads を作りました

JetBrainsのIDE (IntelliJ IDEA Ultimate や RubyMine) で Rails アプリ開発をする場合、 Railways プラグインが手放せません。
Railways - RubyMine Plugin | Marketplace

 
上記のプラグインページにあるように、Railwaysには

  • rails routes の結果を一覧化する
    • パスとコントローラが紐づいていない場合は、 ? アイコンが表示される
  • 一覧から行を選択後、Actionに表示されているコントローラをクリックすると、そのコントローラが含まれるファイルが開く
  • コンテキストメニューから、パスや名前をコピーする

などの機能があります。

これらのおかげで、Railsアプリ開発を効率的に進めることができていました。

 
そんな中、RubyMineを 2023.3系にバージョンを上げたところ

Not compatible with the version of your running IDE (IntelliJ IDEA 2023.3.5, RubyMine 2023.3.5)

というメッセージが表示され、Railwaysプラグインがインストール・実行できなくなりました。

 
また、Githubリポジトリを見たところ、同じ現象に対する issue が立っていました。
Update for RubyMine Versions 2023.3.2+ · Issue #58 · basgren/railways

 
以前、Railwaysプラグインがロードされないというissueには回避策がありました。
Routes window not visible in IDEA 2022.1 · Issue #54 · basgren/railways

ただ、今回はそもそもインストール自体ができないことから、回避策がない可能性も考えられました。

 
このままでは色々つらいことから、Railways の代替となるプラグイン Railroads を作り、JetBrains Marketplaceで公開しました。
https://plugins.jetbrains.com/plugin/24076-railroads

 
Railways と同じような感覚で使えます。

 
そこで、この記事では

  • Railroads プラグインを作るまでの経緯
  • 現在のバージョン 0.1.0 でサポートしている機能

などについて書いていこうと思います。  
 
目次

 

対応の検討について

対応方法としては

  • forkして修正
  • イチから作る

のどちらかを考えました。

ここでは、なぜイチから作ることにしたかを記載します。

 

forkして修正しようとしたがビルドできなかった

イチから作るのは大変な気がしたので、まずは fork して修正する方向で考えました。

 
まずリポジトリのREADMEを読んだところ

  • Current release is tested on RubyMine 2016.1, RubyMine 2016.2, IntelliJ IDEA 2016.2.
  • Development Environment
  • Building the Plugin

などが記載されていました。

 
そこで、該当バージョンのIDEJDK、各種設定を行った上でビルドしてみたものの、ビルドできませんでした。

ビルドができないことには修正もできないことから、forkして修正する方向はあきらめました。

 

イチから作る

forkして修正ができないのであれば、イチから作るしかありません。

とはいえ、それはそれで考えることがありました。

 

何を使ってプラグインを作るか

昔からあるプラグインということもあり、Railwaysプラグイン

  • 開発言語は Java
  • ビルドは Ant

を使っているようでした。

 
一方、InttelliJ Platform Pluginのドキュメントを読んだところ、最近は

という構成でも開発できそうでした。

 
どちらで作るか少々悩みましたが、Kotlinをさわってみたかったということもあり、今回は Kotlin + Gradle + Template でプラグインを作ることにしました。

 

環境づくり

都合により、開発マシンは Windows + WSL2 です。

ふつうのRailsアプリ開発であれば、上記の開発マシンでもほとんど問題なく開発ができます。

一方、 IntelliJ Platform Plugin開発中に、プラグインの動作確認のため WSL2 上でIDEを起動しようとしましたが、JetBrains Gatewayを使っているとIDEが起動しませんでした。

 
これは mac もしくは Linux を用意するしかないのか...と思いましたが、

  • RubyInstaller for Windows を使って Ruby をインストール
  • その環境で Rails をインストール

という環境を作った上で、Windows上のRubyMineで Rails アプリを作ってみたところ、 rails routes が動作しました。

今回は rails routes さえ動けばプラグインの動作確認はできることから、いったんこれで良しとしました。

 
なお、動作確認に使った Rails アプリのリポジトリは以下です。
https://github.com/thinkAmi-sandbox/rails_routes_app

 

代替プラグインの名前について

Railways プラグインの機能を実現したいことから、名前も似た感じにしようと Railroads としました。

 

Railroads プラグインの機能について

次は、最初のリリースでは Railways プラグインのどの機能を移植するか考えました。

 

最初からサポートする機能

Railways プラグインのうちどの機能がよく使われているのか分かりませんでした。

そこでまずは自分が使いたい機能をサポートしようと考え、以下としました。

  • rails routes の結果を一覧化
  • ルートとコントローラが紐づいていない場合は、 ? アイコンが表示される
  • 一覧から行を選択後、Actionに表示されているコントローラをクリックすると、そのコントローラが含まれるファイルが開く
  • コンテキストメニューから、ルートのパスや名前をコピーできる

 
これらの機能については、Railways からそのまま移植できるものは移植し、難しいものについては Kotlin で書き直しています。

 

Railwaysとは別の形でサポートする機能

rails routes 時のエラーは Notification へ通知

Railwaysでは、 rails routes 時にエラーが発生した場合、エラーのダイアログにその内容を表示していました。

ただ、まずは動くものを作ろうと考えて、現時点ではエラーが発生したら IDE の Notification へ通知するようにしています。

 

アイコンは JetBrains のものを利用

Railwaysでは、png形式で各種アイコンを用意していました。
https://github.com/basgren/railways/tree/master/src/net/bitpot/railways/icons

一方、現在では、 png 形式ではなく svg 形式が推奨されています。
https://plugins.jetbrains.com/docs/intellij/icons.html#png-format-deprecated

ただ、時間の都合上、Railwaysで使っている png 形式のアイコンを svg 形式で用意するのは難しかったことから、今のところ JetBrains で用意しているアイコンで代替しています。

 

サポートを予定しない機能

rails routesの古いフォーマット

Railways のソースコードを読んだところ、 rails routes の昔のフォーマットをサポートしていました。

ただ、手元には昔のフォーマットで出力できる環境がなかったことから、昔のフォーマットのサポートはやめることにしました。

というのも、

あたりを読むとわかるのですが、Railways では

  • rails route を実行し、標準出力に結果を出力する
  • 標準出力から文字列を取り出し、正規表現でパース
  • パースした結果をいい感じに表示する

としてルーティングの一覧を作成しています。

そのため、 rails routes で昔のフォーマットを出力する環境がないのであれば、動作確認をするのが難しいと判断し、サポート対象外としました。

 
もし、昔のフォーマットでの出力に対応したい場合は、 RubyMine の古いバージョン + Railways プラグインを使ったほうが、機能は豊富だし適切だろうと考えています。

 

将来、必要に応じてサポートできればいいなと思う機能

Railways は機能豊富なため、それをはじめから全部移植するとプラグイン開発が終わりそうにもありませんでした。

そこで、最初のリリースでは自分が必要な機能だけに絞り実装しました。

 
そのため、以下のように「あると便利だけど、なくても何とかなるかも」という機能については、必要になった時点で実装・サポートする予定です。

 

ToolWindowを開いた時点で rails routes を実行する機能

Railways では実現できています。

ただ、 Railroads で実現するには「どのタイミングで rails routes をバックグラウンドで実行するのが良いか」がまだつかめていないことから、未実装となっています。

 

rails routes 結果のキャッシュ機能

こちらも Railways では実現できています。

ただ、

  • どのキャッシュ機構を使うのが適切か
  • キャッシュのリセットや追加はどのタイミングで行うのが適切か

など、まだ分からないことがあるため、実装していません。

 

environment やタスク名などのカスタマイズ機能

今のところはデフォルトの environment やタスク名でしか実行できません。

そのため、Railways同様、設定画面を用意して、カスタマイズされた環境でも実行できるようにできればいいなと考えています。

 

コードエディタでのナビゲーション機能

Railwaysにある

Adds quick navigation to action implementation from "Routes" panel or "Go to route action" popup available in code editor

という機能です。

自分の環境で使う機会がなかったので、まだ実装していません。

 

パスを絞り込んだときのハイライト機能

Railwaysでは、検索ボックスに入力することで、パスを絞り込めます。

Railroadsでも同様の機能を用意していますが、「入力値のどの部分がパスにマッチしているか」をハイライトするような機能はありません。

ハイライトするには、以下のディレクトリにあるような機能を実装しないといけないため、今は実装を先送りしています。
https://github.com/basgren/railways/tree/master/src/net/bitpot/railways/parser/route

 

テストコードの追加

IntelliJ Platform Plugin では、プラグインに対するテストコードも書くことができるようです。
Testing Overview | IntelliJ Platform Plugin SDK

 
Railsアプリ開発では、RSpecなどを使ってテストコードを書いています。

一方、 IntelliJ Platform Pluginに対するテストコードについては、今まで書いたことがありません。そのため、テストコードをきちんと書いて進めるとした場合、完成までに時間がかかりそうでした。

 
そこで、現時点では動作確認は実機で行うものとして、テストコードの実装は先送りしました。

なお、機能が増えるなどした場合、動作確認を実機で行うほうが手間がかかりそうと考えているため、そのうちテストコードを追加したいと思っています。

 

Railroads プラグインの公開URLについて

再掲となりますが、以下のページで公開しています。
https://plugins.jetbrains.com/plugin/24076-railroads

 

ソースコード

Railroads プラグインソースコードは、Githubで公開しています。
https://github.com/thinkAmi/railroads

まだまだ荒削りなプラグインですので、使ってみて何か気になることがありましたら、Githubのissueなどで情報を共有していただけるとありがたいです。

 
また、動作確認で使っている Rails アプリは以下で公開しています。
https://github.com/thinkAmi-sandbox/rails_routes_app