Table of Contents
はじめに
TypeScriptファーストなフルスタックWebアプリケーションであるAdonisJSに入門してみました。 まずは簡単なCRUD(TODOアプリ)を実装してみます。
環境
- Node.js v20.16.0
- AdonisJS v6.12.1
AdonisJSプロジェクト作成
はじめは公式ドキュメントに従って、プロジェクトを作成します。
npm init adonisjs@latest my-first-adonis-app
Need to install the following packages:
create-adonisjs@2.4.0
Ok to proceed? (y)
> npx
> create-adonisjs my-first-adonis-app
_ _ _ _ ____
/ \ __| | ___ _ __ (_)___ | / ___|
/ _ \ / _` |/ _ \| '_ \| / __|_ | \___ \
/ ___ \ (_| | (_) | | | | \__ \ |_| |___) |
/_/ \_\__,_|\___/|_| |_|_|___/\___/|____/
❯ Which starter kit would you like to use · Inertia Starter Kit
❯ Which authentication guard you want to use · session
❯ Which database driver you want to use · postgres
❯ Which frontend adapter you want to use with Inertia · react
❯ Do you want to setup server-side rendering with Inertia (y/N) · false
以下設定でプロジェクトを作成しました。
- スターターキット: Inertia Starter Kit
- 認証ガード: Session
- データベースドライバー: PostgreSQL
- フロントエンドアダプター: React
- SSR設定: なし
PostgreSQL環境準備
データベースはPostgreSQLを選択したので準備します。 以下のようなdocker-compose.ymlを作成しました。
services:
postgresql:
image: postgres:16
environment:
POSTGRES_HOST_AUTH_METHOD: trust
TZ: "Asia/Tokyo"
ports:
- 5432:5432
volumes:
- postgres:/var/lib/postgresql/data
volumes:
postgres:
環境を起動します。
docker compose up -d
AdonisJSのアプリケーションと接続するには環境変数DB_DATABASE
に値をセットします。
# .env
DB_DATABASE=postgres
起動
これで準備が整ったのでアプリケーションを起動してみます。
npm run dev
[ info ] starting HTTP server...
╭─────────────────────────────────────────────────╮
│ │
│ Server address: http://localhost:3333 │
│ Watch Mode: HMR │
│ Ready in: 304 ms │
│ │
╰─────────────────────────────────────────────────╯
[23:23:40.274] INFO (87291): started HTTP server on localhost:3333
この画面が表示されれば成功です。
VSCode Extension
AdonisJSでは公式でVSCode拡張が提供されています。 aceコマンドの実行やルーティングの一覧を見ることができたりなど便利なのでインストールしておくことをおすすめします。
Controller
コントローラ作成
セットアップが無事完了したので、まずはコントローラから作ってみます。 以下コマンドでコントローラファイルが作成できます。
node ace make:controller todo -r
-r
または--resource
はindex/create/store/show/edit/update/destroyメソッドをscaffoldするオプションです。
実行後は以下のようなファイルが作成されます。
// app/controllers/todos_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
export default class TodosController {
/**
* Display a list of resource
*/
async index({}: HttpContext) {}
/**
* Display form to create a new record
*/
async create({}: HttpContext) {}
/**
* Handle form submission for the create action
*/
async store({ request }: HttpContext) {}
/**
* Show individual record
*/
async show({ params }: HttpContext) {}
/**
* Edit individual record
*/
async edit({ params }: HttpContext) {}
/**
* Handle form submission for the edit action
*/
async update({ params, request }: HttpContext) {}
/**
* Delete record
*/
async destroy({ params }: HttpContext) {}
}
ルーティング設定
次にルーティングを見ていきます。 ルーティングはroutes.tsに記述していくことになります。 通常はだと以下のような書き方になります。
router.get('/about', () => {
return 'This is the about page.'
})
router.get('/posts/:id', [PostsController, 'show'])
このように書くんですが、先ほど-r
オプションで作成したコントローラのように、resourcefulなルートを定義する場合は、resource
メソッドが使えます。
// start/routes.ts
import router from '@adonisjs/core/services/router'
const TodosController = () => import('#controllers/todos_controller')
router.resource('todos', TodosController)
これでnode ace list:routes
またはVSCode拡張で定義されたルート一覧が確認できます。
node ace list:routes
METHOD ROUTE ............................................................................................................................ HANDLER MIDDLEWARE
GET / ................................................................................................................................ closure
GET /todos (todos.index) ................................................................................. #controllers/todos_controller.index auth
GET /todos/create (todos.create) ........................................................................ #controllers/todos_controller.create auth
POST /todos (todos.store) ................................................................................. #controllers/todos_controller.store auth
GET /todos/:id (todos.show) ............................................................................... #controllers/todos_controller.show auth
GET /todos/:id/edit (todos.edit) .......................................................................... #controllers/todos_controller.edit auth
PUT /todos/:id (todos.update) ........................................................................... #controllers/todos_controller.update auth
PATCH /todos/:id (todos.update) ........................................................................... #controllers/todos_controller.update auth
DELETE /todos/:id (todos.destroy) ......................................................................... #controllers/todos_controller.destroy auth
【おまけ】Dependency Injection
特に今回は実装はしませんが、AdonisJSはDependency Injection(DI)にも対応しています。
export default class UserService {
async all() {
// return users from db
}
}
import { inject } from '@adonisjs/core'
import UserService from '#services/user_service'
@inject()
export default class UsersController {
constructor(protected userService: UserService) {}
index() {
return this.userService.all()
}
}
ビジネスロジックを担うサービスクラスを用意してコントローラクラスにDIすることで、Fat Controllerになることを避けられます。 関心の分離を実現することができ、テストも書きやすくなると思います。
【おまけ】Method injection
先ほどはコントローラへのDIでしたが、メソッドに対してもDIできるそうです。
import { inject } from '@adonisjs/core'
import { HttpContext } from '@adonisjs/core/http'
import UserService from '#services/user_service'
export default class UsersController {
@inject()
index(ctx: HttpContext, userService: UserService) {
return userService.all()
}
}
Model
モデル作成
コントローラができたので、次はモデルを作成してみます。 AdonisJSではLucid ORMを使用します。 LucidはKnex.jsというクエリビルダーライブラリをベースに作られたORMです。
まずはそのLucidをインストールします。
Note
もしかしたらStarter Kitでプロジェクト作成した場合は不要かもしれないです。 ドキュメント見ながら手探りでやっていたので、私は実行してしまいました。
node ace add @adonisjs/lucid
[ info ] Installing the package using the following command : npm add @adonisjs/lucid
❯ Continue ? (Y/n) · true
[ wait ] package installed successfully ...
❯ Select the database you want to use · postgres
❯ Do you want to install additional packages required by "@adonisjs/lucid"? (y/N) · true
SKIPPED: create config/database.ts (File already exists)
DONE: update adonisrc.ts file
DONE: update .env file
DONE: update start/env.ts file
[ wait ] installing dependencies using npm ...
[ success ] Packages installed
dev @types/luxon
prod pg
prod luxon
[ success ] Installed and configured @adonisjs/lucid
この時にログにもあるように.envファイルが更新されてしまうので、DB_DATABASE
の値を再度セットする必要がありました。
インストールが完了したら、モデルファイルを作成します。
node ace make:model Todo
app/models/todo.tsが作成されるので、カラムを追加します。
// app/models/todo.ts
import { DateTime } from 'luxon'
import { BaseModel, column } from '@adonisjs/lucid/orm'
export default class Todo extends BaseModel {
@column({ isPrimary: true })
declare id: number
@column()
declare title: string // <- 追加
@column()
declare description: string // <- 追加
@column()
declare isCompleted: boolean // <- 追加
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}
Migration
モデルが準備できたら、次はマイグレーションファイルを用意します。
node ace make:migration todos
作成されたファイルを編集して以下のようになりました。
// database/migrations/xxxxxxx_create_todos_table.ts
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'todos'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('title') // <- 追加
table.string('description') // <- 追加
table.boolean('is_completed').defaultTo(false) // <- 追加
table.timestamp('created_at')
table.timestamp('updated_at')
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
マイグレーションを実行します。
node ace migration:run
❯ migrated database/migrations/xxxxxxxx_create_users_table
❯ migrated database/migrations/xxxxxxxx_create_todos_table
Starter Kitの場合はusersテーブルのマイグレーションがすでに作られていたので、一緒に実行されました。
ORM
テーブルが作成できたら、実際にLucidを使ってデータベースへの読み書きをする処理を書いてみましょう。
import type { HttpContext } from '@adonisjs/core/http'
import Todo from '#models/todo'
class TodoDto {
constructor(private todo: Todo) {}
toJson() {
return {
id: this.todo.id,
title: this.todo.title,
description: this.todo.description,
isCompleted: this.todo.isCompleted,
}
}
}
export default class TodosController {
/**
* Display a list of resource
*/
async index({ inertia }: HttpContext) {
const todos = await Todo.all()
return inertia.render('todos/index', {
todos: todos.map((t) => new TodoDto(t).toJson()),
})
}
/**
* Display form to create a new record
*/
async create({ inertia }: HttpContext) {
return inertia.render('todos/create')
}
/**
* Handle form submission for the create action
*/
async store({ request, response }: HttpContext) {
const todo = await Todo.create(request.all())
return response.redirect(`/todos/${todo.id}`)
}
/**
* Show individual record
*/
async show({ inertia, params }: HttpContext) {
const todo = await Todo.findOrFail(params.id)
return inertia.render('todos/show', { todo: new TodoDto(todo).toJson() })
}
/**
* Edit individual record
*/
async edit({ inertia, params }: HttpContext) {
const todo = await Todo.findOrFail(params.id)
return inertia.render('todos/edit', { todo: new TodoDto(todo).toJson() })
}
/**
* Handle form submission for the edit action
*/
async update({ params, request, response }: HttpContext) {
const todo = await Todo.findOrFail(params.id)
todo.merge(request.all())
await todo?.save()
return response.redirect(`/todos/${todo.id}`)
}
/**
* Delete record
*/
async destroy({ inertia, params }: HttpContext) {
const todo = await Todo.findOrFail(params.id)
await todo.delete()
return response.redirect('/todos')
}
}
ActiveRecordパターンを採用しているとドキュメントに書かれていただけあって、Ruby on Railsと似たような感じで書けました。
ここでTodoDtoクラスというものを定義していますが、これはモデルをInertia側のpropsへそのまま渡す場合、ModelObject型というほぼ中身のない型に変換されてしまうためDTO(Data Transfer Object)システムを利用してシンプルなオブジェクトに変換しています。
詳しくは以下ドキュメントを参考にしてください。
Seeder
次にシードファイルを用意しましょう。
node ace make:seeder Todo
データの作成はシンプルにORMのcrateMany
メソッドで実現しました。
// database/seeders/todo_seeder.ts
import { BaseSeeder } from '@adonisjs/lucid/seeders'
import Todo from '#models/todo'
export default class extends BaseSeeder {
async run() {
// ↓追加
await Todo.createMany([
{
title: 'Learn AdonisJS',
description: 'Learn AdonisJS from the official documentation',
},
{
title: 'Develop a new project',
description: 'Develop a new project using AdonisJS',
},
])
}
}
シードを実行します。
node ace db:seed
【おまけ】Factory
Lucidではテストで使用するFactoryという機能も提供しています。 これでテストコードで使用するデータを容易に作ることができます。
// database/factories/user.ts
import User from '#models/user'
import Factory from '@adonisjs/lucid/factories'
export const UserFactory = Factory.define(User, ({ faker }) => {
return {
username: faker.internet.userName(),
email: faker.internet.email(),
password: faker.internet.password(),
}
}).build()
このようにfakerを使ってランダムなデータの生成を行えます。 テスト時には以下のように使います。
import { UserFactory } from '#database/factories/user'
const user = await UserFactory.create()
// 複数作成
const users = await UserFactory.createMany(10)
今回はテストコードまで書かないので、Factoryは用意しません。
View
ビューの作成
最後はビューを作成してデータを表示させましょう。 残念ながらInertiaの場合、ファイルを生成するaceコマンドはないようでした。 手動で以下のファイルを作成しました。
[一覧]
// inertia/pages/todos/index.tsx
import { Link } from '@inertiajs/react'
import TodosController from '#controllers/todos_controller'
import { InferPageProps } from '@adonisjs/inertia/types'
export default function Index(props: InferPageProps<TodosController, 'index'>) {
const { todos } = props
return (
<div style={{ padding: '6rem' }}>
<h2>Todos</h2>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<Link href={`/todos/${todo.id}`}>{todo.title}</Link> | {todo.description} |{' '}
{todo.isCompleted ? 'Completed' : 'Not completed'}
</li>
))}
</ul>
<Link href="/todos/create">Create a new todo</Link>
</div>
)
}
propsはコントローラ側で定義した型をそのままビューでも使うことができました。
[詳細/削除]
// inertia/pages/todos/show.tsx
import { Link } from '@inertiajs/react'
import TodosController from '#controllers/todos_controller'
import { InferPageProps } from '@adonisjs/inertia/types'
export default function Show(props: InferPageProps<TodosController, 'show'>) {
const { todo } = props
return (
<div
style={{
padding: '6rem',
}}
>
<h2>Todo {todo.id}</h2>
<p>{todo.title}</p>
<p>{todo.description}</p>
<p>{todo.isCompleted ? 'Completed' : 'Not completed'}</p>
<div
style={{
display: 'flex',
gap: '1rem',
}}
>
<Link href="/todos">Back</Link>
<Link href={`/todos/${todo.id}/edit`}>Edit</Link>
<Link href={`/todos/${todo.id}`} method="delete" as="button">
Delete
</Link>
</div>
</div>
)
}
[新規作成]
// inertia/pages/todos/create.tsx
import Todo from '#models/todo'
import { Link, router } from '@inertiajs/react'
import { useState } from 'react'
export default function Create(
props: InferPageProps<TodosController, 'create'> & {
errors?: { [key in keyof Pick<Todo, 'title' | 'description'>]: string[] }
) {
const { errors } = props
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
router.post(`/todos`, {
title,
description,
})
}
return (
<div
style={{
padding: '6rem',
}}
>
<h2>Create Todo</h2>
<form
onSubmit={handleSubmit}
style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem',
maxWidth: '400px',
}}
>
<label>Title</label>
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} />
{errors?.title && (
<ul style={{ color: 'red' }}>
{errors.title.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
)}
<label>Description</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} />
{errors?.description && (
<ul style={{ color: 'red' }}>
{errors.description.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
)}
<button type="submit">Create</button>
</form>
<Link href="/todos">Back</Link>
</div>
)
}
このあと説明予定ですが、バリデーションエラーが返ってくる想定のerrors
は追加で定義しないとダメそうでした。
[編集]
// inertia/pages/todos/edit.tsx
import { Link, router } from '@inertiajs/react'
import { useState } from 'react'
import TodosController from '#controllers/todos_controller'
import { InferPageProps } from '@adonisjs/inertia/types'
import Todo from '#models/todo'
export default function Edit(
props: InferPageProps<TodosController, 'edit'> & {
errors?: { [key in keyof Pick<Todo, 'title' | 'description' | 'isCompleted'>]: string[] }
}
) {
const { todo, errors } = props
const [title, setTitle] = useState(todo.title)
const [description, setDescription] = useState(todo.description)
const [isCompleted, setIsCompleted] = useState(todo.isCompleted)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
router.put(`/todos/${todo.id}`, {
title,
description,
isCompleted,
})
}
return (
<div
style={{
padding: '6rem',
}}
>
<h2>Edit Todo {todo.id}</h2>
<form
onSubmit={handleSubmit}
style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem',
maxWidth: '400px',
}}
>
<label>Title</label>
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} />
{errors?.title && (
<ul style={{ color: 'red' }}>
{errors.title.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
)}
<label>Description</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} />
{errors?.description && (
<ul style={{ color: 'red' }}>
{errors.description.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
)}
<label>Completed</label>
<input
type="checkbox"
checked={isCompleted}
onChange={(e) => setIsCompleted(e.target.checked)}
/>
<button type="submit">Update</button>
</form>
<Link href={`/todos/${todo.id}`}>Back</Link>
</div>
)
}
見た目は雑ですがこれでCRUDができました。
Validation
無事にCRUDができたんですが、不正なリクエストから守るためバリデーション処理を追加しましょう。 バリデーションライブラリはAdonisJSのコアチームが作成したVineJSを使います。
パッケージの追加
まずはVineJSをインストールします。
node ace add vinejs
Validator作成
次にバリデータを用意します。 ここにバリデーションルールを定義していきます。
node ace make:validator todo
作成したファイルを見てみると、vine
をインポートしているだけで中身はほぼありません。
以下のようにバリデーションルールを定義しました。
// app/validators/todo.ts
import vine from '@vinejs/vine'
/**
* Validates the todo's creation action
*/
export const createTodoValidator = vine.compile(
vine.object({
title: vine.string().trim().minLength(1),
description: vine.string().trim().escape().minLength(1),
})
)
/**
* Validates the todo's update action
*/
export const updateTodoValidator = vine.compile(
vine.object({
title: vine.string().trim().minLength(1),
description: vine.string().trim().escape().minLength(1),
isCompleted: vine.boolean(),
})
)
リクエストのバリデーション
バリデータが用意できたらコントローラで使用します。
// app/controllers/todos_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import Todo from '#models/todo'
import { createTodoValidator, updateTodoValidator } from '#validators/todo'
export default class TodosController {
/**
* Handle form submission for the create action
*/
async store({ request, response }: HttpContext) {
const data = request.all()
const result = await createTodoValidator.validate(data)
const todo = await Todo.create(result)
return response.redirect(`/todos/${todo.id}`)
}
/**
* Handle form submission for the edit action
*/
async update({ params, request, response }: HttpContext) {
const todo = await Todo.findOrFail(params.id)
const data = request.all()
const result = await updateTodoValidator.validate(data)
todo.merge(result)
await todo.save()
return response.redirect(`/todos/${todo.id}`)
}
}
ここでtry/catch
は不要で、エラーがあっても自動でHTTPレスポンスに変換してくれます。
Viewのセクションでも書きましたが、Inertiaの場合はerrorsというpropにバリデーションエラーが含まれて返ってきます。
また、validateUsing
メソッドを使った書き方もできます。
// const data = request.all()
// const result = await createTodoValidator.validate(data)
const result = await request.validateUsing(createTodoValidator)
こちらの方が簡潔に書けますね。
所感
簡単ですがざっくりCRUDを実装するまでやってみましたが、個人的には好印象でした。 以下感じたことをあげてみます。
- Inertiaが設定不要で動くのは嬉しい
- MVCパターンでわかりやすい。DIできるのも良い
- scaffoldがちょっと物足りない。
ace make:resource
コマンドがあっても良いかも - コントローラで返すpropsの型をビュー(React)で引き継げるのが良い
- バリデーションがzodライクに書けるのも良い
次の記事で認証周りを触っていこうと思います。
ソースコード
ソースコードを公開していますので、気になる方は参考にしてみてください。