AdonisJS v6入門 CRUD編

Aug 11, 2024

はじめに

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

AdonisJS v6 Home

この画面が表示されれば成功です。

VSCode Extension

AdonisJSでは公式でVSCode拡張が提供されています。 aceコマンドの実行やルーティングの一覧を見ることができたりなど便利なのでインストールしておくことをおすすめします。

AdonisJS VSCode Extensions

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を使用します。 LucidKnex.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はコントローラ側で定義した型をそのままビューでも使うことができました。

Todos

[詳細/削除]

// 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>
  )
}

Show

[新規作成]

// 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は追加で定義しないとダメそうでした。

Create

[編集]

// 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>
  )
}

Edit

見た目は雑ですがこれで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ライクに書けるのも良い

次の記事で認証周りを触っていこうと思います。

ソースコード

ソースコードを公開していますので、気になる方は参考にしてみてください。