ウェブエンジニア問題集
第13章

Fastifyハンズオン — Todo APIを最初から完成させる

4
この章の目次開く

この章では、ここまで学んだ内容を1つにつなげて、Todo APIを最初から組み立てます。細かい説明は前の章で扱っているため、この章は「手を動かして完成形を作る」ことに集中します。

完成するAPI

メソッドURL内容
GET/healthヘルスチェック
GET/todosTodo一覧
POST/todosTodo作成
GET/todos/:idTodo 1件取得
PATCH/todos/:idTodo更新
DELETE/todos/:idTodo削除
ハンズオンでは、まずインメモリ配列でAPIの形を完成させます。DB接続は、HTTPの入口が固まってから差し替える方が学びやすいです。

プロジェクトを作る

mkdir fastify-todo-api
cd fastify-todo-api
npm init -y
npm install fastify
bash

package.json を整えます。

{
  "type": "module",
  "scripts": {
    "dev": "node --watch src/server.js",
    "start": "node src/server.js",
    "test": "node --test"
  },
  "dependencies": {
    "fastify": "^5.0.0"
  }
}
json

Fastifyのバージョンは、実際にインストールされたものに合わせてください。既存プロジェクトへ入れる場合は、公式ドキュメントとリリースノートで対応Node.jsバージョンも確認します。

appとserverを分ける

src/app.js を作ります。

import Fastify from 'fastify';
import { todoRoutes } from './routes/todos.js';
 
export function buildApp(options = {}) {
  const fastify = Fastify({
    logger: options.logger ?? true,
  });
 
  fastify.get('/health', async () => {
    return { ok: true };
  });
 
  fastify.register(todoRoutes, { prefix: '/todos' });
 
  fastify.setNotFoundHandler((request, reply) => {
    return reply.code(404).send({
      error: {
        code: 'ROUTE_NOT_FOUND',
        message: '存在しないURLです',
      },
    });
  });
 
  fastify.setErrorHandler((error, request, reply) => {
    if (error.validation) {
      return reply.code(400).send({
        error: {
          code: 'VALIDATION_ERROR',
          message: '入力値が正しくありません',
        },
      });
    }
 
    request.log.error(error);
 
    return reply.code(500).send({
      error: {
        code: 'INTERNAL_ERROR',
        message: 'サーバーエラーが発生しました',
      },
    });
  });
 
  return fastify;
}
js

src/server.js を作ります。

import { buildApp } from './app.js';
 
const app = buildApp();
const port = Number(process.env.PORT ?? 3000);
 
try {
  await app.listen({ port, host: '0.0.0.0' });
} catch (err) {
  app.log.error(err);
  process.exit(1);
}
js

Todoルートを作る

src/routes/todos.js を作ります。

const todoSchema = {
  type: 'object',
  properties: {
    id: { type: 'integer' },
    title: { type: 'string' },
    completed: { type: 'boolean' },
  },
};
 
const idParamsSchema = {
  type: 'object',
  required: ['id'],
  properties: {
    id: { type: 'integer', minimum: 1 },
  },
};
 
export async function todoRoutes(fastify) {
  const todos = [
    { id: 1, title: 'Fastifyを学ぶ', completed: false },
  ];
  let nextId = 2;
 
  fastify.get('/', {
    schema: {
      response: {
        200: { type: 'array', items: todoSchema },
      },
    },
  }, async () => {
    return todos;
  });
 
  fastify.post('/', {
    schema: {
      body: {
        type: 'object',
        required: ['title'],
        additionalProperties: false,
        properties: {
          title: { type: 'string', minLength: 1, maxLength: 100 },
        },
      },
      response: {
        201: todoSchema,
      },
    },
  }, async (request, reply) => {
    const todo = {
      id: nextId,
      title: request.body.title,
      completed: false,
    };
 
    nextId += 1;
    todos.push(todo);
 
    return reply.code(201).send(todo);
  });
}
js

1件取得・更新・削除を追加する

同じ todoRoutes の中に追加します。

fastify.get('/:id', {
  schema: {
    params: idParamsSchema,
    response: {
      200: todoSchema,
    },
  },
}, async (request, reply) => {
  const todo = todos.find((item) => item.id === request.params.id);
 
  if (!todo) {
    return reply.code(404).send({
      error: { code: 'TODO_NOT_FOUND', message: 'Todoが見つかりません' },
    });
  }
 
  return todo;
});
 
fastify.patch('/:id', {
  schema: {
    params: idParamsSchema,
    body: {
      type: 'object',
      additionalProperties: false,
      properties: {
        title: { type: 'string', minLength: 1, maxLength: 100 },
        completed: { type: 'boolean' },
      },
    },
    response: {
      200: todoSchema,
    },
  },
}, async (request, reply) => {
  const todo = todos.find((item) => item.id === request.params.id);
 
  if (!todo) {
    return reply.code(404).send({
      error: { code: 'TODO_NOT_FOUND', message: 'Todoが見つかりません' },
    });
  }
 
  Object.assign(todo, request.body);
 
  return todo;
});
 
fastify.delete('/:id', {
  schema: {
    params: idParamsSchema,
  },
}, async (request, reply) => {
  const index = todos.findIndex((item) => item.id === request.params.id);
 
  if (index === -1) {
    return reply.code(404).send({
      error: { code: 'TODO_NOT_FOUND', message: 'Todoが見つかりません' },
    });
  }
 
  todos.splice(index, 1);
 
  return reply.code(204).send();
});
js

動作確認する

npm run dev
bash
curl http://localhost:3000/health
curl http://localhost:3000/todos
curl -X POST http://localhost:3000/todos \
  -H "content-type: application/json" \
  -d '{"title":"テストを書く"}'
bash

入力エラーも確認します。

curl -X POST http://localhost:3000/todos \
  -H "content-type: application/json" \
  -d '{}'
bash

400 と整理されたエラーJSONが返れば、schema validationとエラーハンドラが動いています。

テストを書く

test/todos.test.js を作ります。

import test from 'node:test';
import assert from 'node:assert/strict';
import { buildApp } from '../src/app.js';
 
test('GET /health returns ok', async () => {
  const app = buildApp({ logger: false });
 
  const response = await app.inject({
    method: 'GET',
    url: '/health',
  });
 
  assert.equal(response.statusCode, 200);
  assert.deepEqual(response.json(), { ok: true });
});
 
test('POST /todos creates todo', async () => {
  const app = buildApp({ logger: false });
 
  const response = await app.inject({
    method: 'POST',
    url: '/todos',
    payload: { title: 'テストを書く' },
  });
 
  assert.equal(response.statusCode, 201);
  assert.equal(response.json().title, 'テストを書く');
});
 
test('POST /todos rejects invalid body', async () => {
  const app = buildApp({ logger: false });
 
  const response = await app.inject({
    method: 'POST',
    url: '/todos',
    payload: {},
  });
 
  assert.equal(response.statusCode, 400);
});
js
npm test
bash
学習者学習者

ここまで作れたら、次は何を足すのがよいですか?

先生先生

DB接続、認証、OpenAPI出力、CIでのテスト実行が自然な次の一歩です。ただし一度に全部ではなく、1つずつテストを足しながら進めましょう。

参考リンク

この本では、FastifyでAPIを作るための基本を一通り学びました。次に実プロジェクトへ進むときは、ここで作ったTodo APIを土台に、DB、認証、CI、本番設定を少しずつ足していきましょう。

Node.jsクイズに挑戦するFastifyの土台になるNode.jsの知識を、4択クイズでアウトプットして定着させよう