Web API作成入門

2024-2025 Shunsuke Watanabe

このチュートリアルについて

このチュートリアルはnodejsでREST APIを作成する過程を通じて、EventStormingでまとめたドメイン知識をコードに起こす基礎的な内容に触れてもらうことを目的としています。

twitchまたはYoutubeで配信します、配信時間は1時間前後です。

Twitch → https://www.twitch.tv/shun__suke__

Youtube → https://www.youtube.com/@Captain_Emo

🧭 配信予定はconnpassをご確認ください → https://hands-on.connpass.com/

👨‍💻サンプルコード → https://github.com/craftgear/api_handson

discord → https://discord.gg/3havwjWGsw

bluesky → https://bsky.app/profile/craftgear.bsky.social

📝 blog → https://craftgear.github.io/posts/

1. 開発資料

EventStorming

Big Picture

スクリーンショット 2024-11-03 10.34.08.png

Design Level

design level.png

image.png

Onion Architecture

オニオンアーキテクチャ.png

その他の3層アーキテクチャ

2. 開発環境の準備

対象環境

Node.js

SQLite3

コードエディタ

新しいプロジェクトの作成

3. ドメインモデルの定義

zod https://zod.dev/

  1. zodをインストール

    npm i zod
  2. /src/todo/domain.ts を作成、zodを使ってバリデーションスキーマと型を定義

    import { z } from 'zod';
    export const TodoSchema = z.object({
    id: z.number().brand('TodoId'),
    title: z.string(),
    done: z.coerce.boolean().default(false),
    doneAt: z.coerce.date().nullish(),
    });
    export type Todo = z.infer<typeof TodoSchema>;
    export type TodoId = Todo['id'];
  3. JSONのパース関数を追加

    export const parseTodo = (data: unknown): Todo => TodoSchema.parse(data);
    export const parseTodoId = (id: number): TodoId => TodoSchema.shape.id.parse(id);
  4. 新しく追加するTodoには id がないので、 NewTodo という型を追加する。

    export type NewTodo = Omit<Todo, 'id'>;
    export const parseNewTodo = (data: unknown): NewTodo => TodoSchema.omit({ id: true }).parse(data);
  5. vitestをインストール

    npm i -D vitest
  6. テスト作成 /src/todo/domain.spec.ts

    import { describe, it, expect, expectTypeOf } from 'vitest';
    import {
    parseTodo,
    parseTodoId,
    parseNewTodo,
    Todo,
    TodoId,
    NewTodo,
    } from './domain';
    describe('parseTodo', () => {
    it('parse a valid todo', () => {
    const result = parseTodo({ id: 1, title: "Buy milk" });
    expect(result).toEqual({
    id: 1,
    title: "Buy milk",
    done: false,
    });
    expectTypeOf(result).toEqualTypeOf<Todo>();
    const now = new Date();
    expect(parseTodo({ id: 1, title: 'Buy milk done', done: true, doneAt: now })).toEqual({
    id: 1,
    title: 'Buy milk done',
    done: true,
    doneAt: now,
    });
    });
    });
    describe('parseTodoId', () => {
    it('parse a valid todo id', () => {
    const todoId = parseTodoId(1);
    expectTypeOf(todoId).toEqualTypeOf<TodoId>();
    });
    });
    describe('parseNewTodo', () => {
    it('parse a valid new todo', () => {
    const result = parseNewTodo({ title: "Buy milk" });
    expect(result).toEqual({
    title: "Buy milk",
    done: false,
    });
    expectTypeOf(result).toEqualTypeOf<NewTodo>();
    });
    });
  7. package.jsontest コマンドを追加

  8. テスト実行

    npm test

4. ビジネスロジックの実装

  1. タイトルのチェックをzodのスキーマに追加

    export const TodoSchema = z
    .object({
    id: z.number().brand('TodoId'),
    title: z.string({
    // パース時にタイトルの有無をチェック
    required_error: 'Title is required',
    })
    .trim()
    // パース時にタイトルが一文字以上あるかどうかチェック
    .min(1, { message: 'Title must be at least 1 character long' }),
    done: z.coerce.boolean().default(false),
    doneAt: z.date().optional(),
    });
  2. 完了チェックの関数を追加

    export const isComplete = (todo: Todo) => todo.done;
  3. テスト追加

    import { describe, it, expect, expectTypeOf } from 'vitest';
    import {
    parseTodo,
    parseTodoId,
    isComplete,
    parseNewTodo,
    Todo,
    TodoId,
    NewTodo,
    } from './domain';
    describe.concurrent('parseTodo', () => {
    it('parse a valid todo', () => {
    const result = parseTodo({ id: 1, title: "Buy milk" });
    expect(result).toEqual({
    id: 1,
    title: "Buy milk",
    done: false,
    });
    expectTypeOf(result).toEqualTypeOf<Todo>();
    const now = new Date();
    expect(parseTodo({ id: 1, title: 'Buy milk done', done: true, doneAt: now })).toEqual({
    id: 1,
    title: 'Buy milk done',
    done: true,
    doneAt: now,
    });
    });
    it('throws an error when title is missing', () => {
    expect(() => parseTodo({ id: 1 })).toThrowError('Title is required');
    });
    it('throws an error when title is empty', () => {
    expect(() => parseTodo({ id: 1, title: '' })).toThrowError(
    'Title must be at least 1 character long'
    );
    });
    it('throws an error when title is only whitespaces', () => {
    expect(() => parseTodo({ id: 1, title: ' ' })).toThrowError(
    'Title must be at least 1 character long'
    );
    });
    })
    describe('parseTodoId', () => {
    it('parse a valid todo id', () => {
    const todoId = parseTodoId(1);
    expectTypeOf(todoId).toEqualTypeOf<TodoId>();
    });
    });
    describe.concurrent('parseNewTodo', () => {
    it('parse a valid new todo', () => {
    const result = parseNewTodo({ title: "Buy milk" });
    expect(result).toEqual({
    title: "Buy milk",
    done: false,
    });
    expectTypeOf(result).toEqualTypeOf<NewTodo>();
    });
    it('throws an error when title is missing', () => {
    expect(() => parseNewTodo({})).toThrowError('Title is required');
    });
    it('throws an error when title is empty', () => {
    expect(() => parseNewTodo({ title: '' })).toThrowError(
    'Title must be at least 1 character long'
    );
    });
    it("throws an error when title is only whitespaces", () => {
    expect(() => parseTodo({ title: " " })).toThrowError(
    "Title must be at least 1 character long",
    );
    });
    });
    describe.concurrent('isComplete', () => {
    it('returns true when todo is completed', () => {
    const todo = parseTodo({ id: 1, title: 'Buy milk', done: true });
    expect(isComplete(todo)).toBe(true);
    });
    it('returns false when todo is not completed', () => {
    const todo = parseTodo({ id: 1, title: 'Buy milk', done: false });
    expect(isComplete(todo)).toBe(false);
    });
    });

ドメイン層のコードはすべての関数が純粋関数になります。

5. ユースケース作成

Applicationという単語が一般的すぎるので、ここでは代わりにUsecaseと呼ぶことにします。

  1. /src/todo/usecase.ts を作成

    import { TodoId, TodoRepository } from './domain';
    export const readTodos = (repository: TodoRepository, page = 1, limit = 10) => {
    // TODO: implement
    };
    export const createTodo = (repository: TodoRepository, title: string) => {
    // TODO: Implement
    };
    export const completeTodo = (repository: TodoRepository, id: TodoId) => {
    // TODO: Implement
    };
  2. /src/todo/domain.ts にリポジトリの型定義を追加

    export type TodoRepository = {
    selectAll: (offset: number, limit: number) => Promise<Todo[]>;
    selectById: (id: TodoId) => Promise<Todo | null>;
    insert: (todo: NewTodo) => Promise<Todo | null>;
    setCompleted: (id: TodoId) => Promise<Todo | null>;
    };
  3. 1で定義したユースケースの実装

    import {
    parseNewTodo,
    isComplete,
    TodoRepository,
    TodoId,
    } from './domain';
    export const readTodos = async ({ selectAll }: TodoRepository, page = 1, limit = 10) => {
    if (page < 1) {
    throw Error('page should be a positive number');
    }
    return await selectAll(page - 1, limit);
    };
    export const createTodo = async ({ insert }: TodoRepository, title: string) => {
    const todo = parseNewTodo({ title });
    return await insert(todo);
    };
    export const completeTodo = async (
    { selectById, setCompleted }: TodoRepository,
    id: TodoId
    ) => {
    const todo = await selectById(id);
    if (!todo) {
    throw new Error('Todo not found');
    }
    if (isComplete(todo)) {
    return todo;
    }
    return await setCompleted(id);
    };
  4. ユースケースのテストを追加 /src/todo/usecase.spec.ts

    import { describe, it, expect, vi, afterEach } from 'vitest';
    import { readTodos, createTodo, completeTodo } from './usecase';
    import type { TodoRepository, TodoId } from './domain';
    describe.concurrent('todo usecases', () => {
    const repository: TodoRepository = {
    insert: vi.fn(),
    selectAll: vi.fn(),
    selectById: vi.fn(),
    setCompleted: vi.fn(),
    };
    afterEach(() => {
    vi.resetAllMocks();
    });
    it('reads todos', async () => {
    await readTodos(repository);
    expect(repository.selectAll).toHaveBeenCalledWith(0, 10);
    });
    it("page number should be larger then 0", async () => {
    await expect(() => readTodos(repository, 0)).rejects.toThrowError(
    "page should be a positive number",
    );
    });
    it('creates a new todo', async () => {
    const title = 'Buy milk';
    await createTodo(repository, title);
    expect(repository.insert).toHaveBeenCalledWith({ title, done: false });
    });
    it('completes a todo', async () => {
    const id = 1 as TodoId;
    const repo = {
    ...repository,
    selectById: vi.fn().mockResolvedValueOnce({
    id: 1 as TodoId,
    title: "Buy milk",
    done: false,
    }),
    };
    await completeTodo(repo, id);
    expect(repo.selectById).toHaveBeenCalledWith(id);
    expect(repo.setCompleted).toHaveBeenCalledWith(id);
    });
    it('does NOT completes a todo which is already done', async () => {
    const id = 2 as TodoId;
    const repo = {
    ...repository,
    selectById: vi.fn().mockResolvedValueOnce({
    id: 2 as TodoId,
    title: "Buy eggs",
    done: true,
    }),
    };
    await completeTodo(repo, id);
    expect(repo.selectById).toHaveBeenCalledWith(id);
    expect(repo.setCompleted).not.toHaveBeenCalledWith(id);
    });
    it('throws an error when todo is not found', async () => {
    const id = 999 as TodoId;
    const repo = {
    ...repository,
    selectById: vi.fn().mockResolvedValueOnce(null),
    };
    await expect(() => completeTodo(repo, id)).rejects.toThrowError("Todo not found");
    });
    });
  5. テストカバレッジを測定する

6. Httpサーバー作成

hono https://hono.dev/docs/

  1. /src/todo/route.ts を作成し、エンドポイントを定義

    import { Hono } from 'hono';
    import { HTTPException } from 'hono/http-exception';
    import { parseTodoId } from './domain';
    import { readTodos, createTodo, completeTodo } from './usecase';
    import { todoRepository } from './infra/repo';
    export const todoRoute = new Hono().basePath('/todos');
    todoRoute.get('/', async (c) => {
    const { page, limit } = c.req.query();
    const pageNumber = page ? Number(page) : undefined;
    const limitNumber = limit ? Number(limit) : undefined;
    const todos = await readTodos(todoRepository, pageNumber, limitNumber);
    return c.json(todos);
    });
    todoRoute.post('/', async (c) => {
    const data = await c.req.json();
    try {
    const newTodo = await createTodo(todoRepository, data.title);
    return c.json(newTodo);
    } catch (e: unknown) {
    throw new HTTPException(400, { message: (e as Error).message });
    }
    });
    todoRoute.patch('/:id/complete', async (c) => {
    const id = c.req.param('id');
    try {
    const todoId = parseTodoId(Number(id));
    const todo = await completeTodo(todoRepository, todoId);
    return c.json(todo);
    } catch (e: unknown) {
    throw new HTTPException(400, { message: (e as Error).message });
    }
    });
  2. /src/todo/infra/repo.ts を作成し、todoRepositoryのスケルトンを作成

    import type { TodoRepository, Todo } from '../domain';
    export const todoRepository: TodoRepository = {
    selectAll: async (offset, limit) => {
    //TODO: Implement
    return [];
    },
    selectById: async (id) => {
    //TODO: Implement
    return {} as Todo;
    },
    insert: async (todo) => {
    //TODO: Implement
    return {} as Todo;
    },
    setCompleted: async (id) => {
    //TODO: Implement
    return {} as Todo;
    },
    };
  3. /src/index.ts でルートを登録

    import { serve } from "@hono/node-server";
    import { Hono } from "hono";
    import { HTTPException } from "hono/http-exception";
    import { logger } from 'hono/logger';
    import { cors } from "hono/cors";
    import { todoRoute } from "./todo/route";
    const app = new Hono();
    app.use(logger());
    app.use("/v1/*", cors());
    app.route("/v1", todoRoute);
    app.onError((e, c) => {
    if (e instanceof HTTPException) {
    return e.getResponse();
    }
    return c.json({ error: e.message }, 500);
    });
    const port = 3000;
    console.log(`Server is running on port ${port}`);
    serve({
    fetch: app.fetch,
    port,
    });
  4. npm run dev でサーバーを起動

7. リポジトリ作成

kysely https://kysely.dev/docs/getting-started

  1. DBスキーマを作成 /src/todo/infra/schema.sql

    CREATE TABLE todo (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    done INTEGER DEFAULT 0,
    done_at INTEGER
    );
  2. DB作成

    Terminal window
    cat src/todo/infra/schema.sql | sqlite3 database.sqlite
  3. クエリビルダーとSQLドライバーをインストール

    Terminal window
    npm i kysely better-sqlite3 snake-camel
    npm i -D kysely-codegen @types/better-sqlite3
  4. データベースの型情報を生成

    Terminal window
    echo 'DATABASE_URL=./database.sqlite' > .env && npx kysely-codegen\
    --out-file=./src/todo/infra/database.d.ts
  5. /src/todo/infra/db.ts を作成

    import type { DB } from './database';
    import SQLite from 'better-sqlite3';
    import { Kysely, SqliteDialect } from 'kysely';
    const dialect = new SqliteDialect({
    database: new SQLite('./database.sqlite'),
    });
    export const db = new Kysely<DB>({ dialect });
  6. /src/todo/infra/repo.ts のTODOを実装

    import { db } from './db';
    import { toCamel } from 'snake-camel';
    import type { TodoRepository } from '../domain';
    import { parseTodo } from '../domain';
    export const todoRepository: TodoRepository = {
    selectAll: async (offset, limit) => {
    const data = await db
    .selectFrom('todo')
    .selectAll()
    .offset(offset * limit)
    .limit(limit)
    .orderBy('id desc')
    .execute();
    return data.map((x) => parseTodo(toCamel(x)));
    },
    selectById: async (id) => {
    const data = await db
    .selectFrom('todo')
    .selectAll()
    .where('todo.id', '=', id)
    .executeTakeFirst();
    return data ? parseTodo(toCamel(data)) : null;
    },
    insert: async (input) => {
    const data = await db
    .insertInto('todo')
    .values({
    ...input,
    done: 0,
    })
    .returningAll()
    .executeTakeFirst();
    return data ? parseTodo(toCamel(data)) : null;
    },
    setCompleted: async (id) => {
    const data = await db
    .updateTable('todo')
    .set({ done: 1, done_at: Date.now() })
    .where('todo.id', '=', id)
    .returningAll()
    .executeTakeFirst();
    return data ? parseTodo(toCamel(data)) : null;
    },
    };
  7. postman等を用いてAPI呼び出しができることを確認する

8. OpenAPI定義作成

  1. ライブラリを追加

    npm i @hono/zod-openapi @hono/swagger-ui
  2. /src/todo/route.ts のルート定義を OpenAPI対応のものに変更

    import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';
    import { HTTPException } from 'hono/http-exception';
    import { parseTodoId, TodoSchema } from "./domain";
    import { readTodos, createTodo, completeTodo } from "./usecase";
    import { todoRepository } from "./infra/repo";
    export const todoRoute = new OpenAPIHono();
    const getTodosRoute = createRoute({
    operationId: 'getTodos',
    tags: ['todos'],
    path: '/todos',
    method: 'get',
    description: 'Read all todos',
    request: {
    query: z.object({
    page: z.coerce.number().optional(),
    limit: z.coerce.number().optional(),
    })
    },
    responses: {
    200: {
    description: 'Get a list of todos',
    content: {
    'application/json': {
    schema: z.array(TodoSchema),
    },
    },
    },
    },
    });
    todoRoute.openapi(getTodosRoute, async (c) => {
    const { page, limit } = c.req.valid('query');
    const pageNumber = page ? Number(page) : undefined;
    const limitNumber = limit ? Number(limit) : undefined;
    const todos = await readTodos(todoRepository, pageNumber, limitNumber);
    return c.json(todos);
    });
    const postTodoRoute = createRoute({
    operationId: 'createTodo',
    tags: ['todos'],
    path: '/todos',
    method: 'post',
    description: 'Create a new todo item',
    request: {
    body: {
    required: true,
    content: {
    'application/json': {
    schema: z.object({
    title: z.string().openapi({
    example: 'Buy milk',
    description: 'The title of the todo',
    }),
    }),
    },
    },
    },
    },
    responses: {
    200: {
    description: 'a newly created todo item',
    content: {
    'application/json': {
    schema: TodoSchema,
    },
    },
    },
    400: {
    description: 'Bad request',
    },
    },
    });
    todoRoute.openapi(postTodoRoute, async (c) => {
    const data = await c.req.json();
    try {
    const newTodo = await createTodo(todoRepository, data.title);
    return c.json(newTodo);
    } catch (e: unknown) {
    throw new HTTPException(400, { message: (e as Error).message });
    }
    });
    const patchTodoRoute = createRoute({
    operationId: 'completeTodo',
    tags: ['todos'],
    path: '/todos/{id}/complete',
    method: 'patch',
    description: 'Mark a todo item as complete',
    request: {
    params: z.object({
    id: z.string(),
    }),
    },
    responses: {
    200: {
    description: 'a completed todo todo item',
    content: {
    'application/json': {
    schema: TodoSchema,
    },
    },
    },
    400: {
    description: 'Bad request',
    },
    },
    });
    todoRoute.openapi(patchTodoRoute, async (c) => {
    const id = c.req.param('id');
    try {
    const todoId = parseTodoId(Number(id));
    const todo = await completeTodo(todoRepository, todoId);
    return c.json(todo);
    } catch (e: unknown) {
    throw new HTTPException(400, { message: (e as Error).message });
    }
    });
  3. /src/index.ts に OpenAPI の定義とUIを表示するルートを追加

    import { serve } from "@hono/node-server";
    import { OpenAPIHono } from '@hono/zod-openapi';
    import { swaggerUI } from '@hono/swagger-ui';
    import { HTTPException } from 'hono/http-exception';
    import { logger } from 'hono/logger';
    import { cors } from "hono/cors";
    import { todoRoute } from "./todo/route";
    const app = new OpenAPIHono();
    app.use(logger());
    app.use("/v1/*", cors());
    app.route('/v1', todoRoute);
    app.onError((e, c) => {
    if (e instanceof HTTPException) {
    return e.getResponse();
    }
    return c.json({ error: e.message }, 500);
    });
    app.doc31('/open-api', {
    openapi: '3.1.0',
    info: { title: 'API Hands On', version: '1' },
    servers: [{ url: 'http://localhost:3000' }],
    tags: [
    {
    name: 'todos',
    description: 'Operations about todos',
    },
    ],
    });
    app.get('/doc', swaggerUI({ url: '/open-api' }));
    const port = 3000;
    console.log(`Server is running on port ${port}`);
    serve({
    fetch: app.fetch,
    port,
    });
  4. /src/todo/domain.ts に OpenAPI用のデータを追加

    import { z } from "@hono/zod-openapi";
    export const TodoSchema = z
    .object({
    id: z.number().brand("TodoId").openapi("TodoId"),
    title: z
    .string({
    // パース時にタイトルの有無をチェック
    required_error: "Title is required",
    })
    .trim()
    // パース時にタイトルが一文字以上あるかどうかチェック
    .min(1, { message: "Title must be at least 1 character long" }),
    done: z.coerce.boolean().default(false),
    doneAt: z.coerce.date().optional(),
    })
    .openapi("Todo");
  5. http://localhost:3000/open-api で API定義表示、http://localhost:3000/doc で ドキュメント表示

9. APIテスト

StepCI https://docs.stepci.com

  1. Step CIをインストール

    npm i -D stepci
  2. テストワークフローを生成

    mkdir api-tests
    npx stepci generate http://localhost:3000/open-api ./api-tests/todo.yml
  3. 生成されたworkflowの名前と数値を修正する

  4. テスト実行

    npx stepci run ./api-tests/todo.yml

10. クライアント用コード生成

React入門で作ったTodoページのAPI呼び出しを置き換えてみます

React入門

Hey API https://heyapi.vercel.app/openapi-ts/get-started.html

  1. React入門のリポジトリをcloneし、ディレクトリに入る

    git clone https://github.com/craftgear/react-hands-on
    cd react-hands-on && npm i
  2. hey-apiをインストール

    npm i -D @hey-api/openapi-ts @hey-api/client-fetch
  3. プロジェクトルートに openapi-ts.config.ts ファイルを作成

    import { defineConfig } from '@hey-api/openapi-ts';
    export default defineConfig({
    input: 'http://localhost:3000/open-api',
    output: {
    path: "src/api/generated",
    format: "prettier",
    },
    plugins: ['@hey-api/client-fetch', '@tanstack/react-query']
    });
  4. TanStack Queryのフックに渡すオプションを生成

    npx @hey-api/openapi-ts
  5. /src/api/todos.ts の クエリを生成したコードで置き換え

    import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
    import { client } from "./generated/client.gen";
    import {
    getTodosOptions,
    getTodosQueryKey,
    completeTodoMutation,
    createTodoMutation,
    } from './generated/@tanstack/react-query.gen';
    client.setConfig({
    baseUrl: "http://localhost:3000",
    });
    const queryKey = getTodosQueryKey();
    export const useGetTodos = () => useQuery(getTodosOptions());
    export const usePatchTodo = () => {
    const queryClient = useQueryClient();
    return useMutation({
    ...completeTodoMutation(),
    onSuccess: () => {
    queryClient.invalidateQueries({ queryKey });
    },
    });
    };
    export const usePostTodo = () => {
    const queryClient = useQueryClient();
    return useMutation({
    ...createTodoMutation(),
    onSuccess: () =>
    queryClient.invalidateQueries({ queryKey }),
    });
    };
  6. usePatchTodousePostTodo にわたす引数を修正

    /src/pages/Todo.tsx
    ...
    const { mutate } = usePatchTodo();
    const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    const id = e.target.value;
    mutate({ path: { id });
    };
    ...
    /src/components/TodoForm.tsx
    ...
    const { mutate } = usePostTodo();
    const submitForm: SubmitHandler<FormData> = (data) => {
    if (!data.title) return;
    mutate({ body: { title: data.title } }, { onSuccess: () => reset() });
    };
    ...

TanStack Startで溶ける境界

Tanstack Start

https://tanstack.com/start/latest

  1. cd tanstack-start

  2. npm i; npm run dev

  3. app/routes/todos.tsx ファイルを新規作成すると、自動で以下の内容が生成される。

    import { createFileRoute } from '@tanstack/react-router'
    export const Route = createFileRoute('/todos')({
    component: RouteComponent,
    })
    function RouteComponent() {
    return <div>Hello "/todos"!</div>
    }
  4. TodoTodoForm コンポーネントを app/routes/todos.tsx にコピーする。

    コンポーネント
    import { ChangeEvent } from "react";
    import { useForm, type SubmitHandler } from 'react-hook-form';
    function Todos() {
    const router = useRouter();
    const data = Route.useLoaderData();
    const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    const id = e.target.value;
    updateTodo({ data: Number(id) }).then(() => router.invalidate());
    };
    return (
    <div>
    <h1>Todos</h1>
    <TodoForm />
    {data?.map(({ id, title, done }) => (
    <div key={id}>
    <input
    type="checkbox"
    id={`${id}`}
    value={id}
    checked={done ? true : false}
    onChange={handleChange}
    />
    <label htmlFor={`${id}`}>{title}</label>
    </div>
    ))}
    </div>
    );
    }
    type FormData = {
    title: string;
    };
    function TodoForm() {
    const {
    register,
    handleSubmit,
    reset,
    formState: { errors },
    } = useForm<FormData>();
    const router = useRouter();
    const submitForm: SubmitHandler<FormData> = (data) => {
    if (!data.title) return;
    addTodo({ data: data.title }).then(() => {
    router.invalidate();
    reset();
    });
    };
    return (
    <form onSubmit={handleSubmit(submitForm)}>
    {errors.title && <p>タイトルを入力してください</p>}
    <input type="text" {...register("title", { required: true })} />
    <button className="btn btn-primary btn-sm">追加</button>
    </form>
    );
    }
  5. app/domain ディレクトリを作成し、api_handson のリポジトリから、todo/ ディレクトリを app/domain/todo にコピーする

  6. api_handson のリポジトリから、database.sqlilte をコピーする。

  7. 必要なライブラリをインストールする

    npm i react-hook-form npm i kysely better-sqlite3 snake-camel; npm i -D kysely-codegen @types/better-sqlite3 @hono/zod-openapi prettier eslint typescript-eslint;
  8. フロントエンドとサーバーサイドをつなぐコードを追加する

    import { createFileRoute, useRouter } from '@tanstack/react-router'
    ...
    import { createServerFn } from '@tanstack/start'
    import { parseTodoId, parseTodo } from '../domain/todo/domain'
    import { readTodos, completeTodo, createTodo } from '../domain/todo/usecase'
    import { todoRepository } from '../domain/todo/infra/repo'
    const getTodos = createServerFn({ method: 'GET' }).handler(async () => {
    const data = await readTodos(todoRepository)
    return data.map((x) => ({
    ...x,
    id: x.id as number,
    }))
    }
    )
    const updateTodo = createServerFn({ method: 'POST' })
    .validator((data: number) => data)
    .handler(async ({ data }) => {
    await completeTodo(todoRepository, parseTodoId(data))
    })
    const addTodo = createServerFn({ method: 'POST' })
    .validator((data: string) => data)
    .handler(async ({ data }) => {
    await createTodo(todoRepository, data)
    })
    export const Route = createFileRoute('/todos')({
    loader: async () => (await getTodos()).map((x) => parseTodo(x)),
    component: Todos,
    })
    function Todos() {
    const { data, isPending, error } = useGetTodos();
    const { mutate } = usePatchTodo();
    const router = useRouter()
    const data = Route.useLoaderData()
    const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    const id = e.target.value
    mutate({ id: Number(id), done: e.target.checked });
    updateTodo({ data: Number(id) }).then(() => router.invalidate())
    }
    if (isPending) {
    return <div>Loading... </div>;
    }
    if (error) {
    return <div>Error: {error.message}</div>;
    }
    ...
    }
    function TodoForm() {
    ...
    const router = useRouter()
    const submitForm: SubmitHandler<FormData> = (data) => {
    if (!data.title) return
    mutate({ title: data.title }, { onSuccess: () => reset() })
    addTodo({ data: data.title }).then(() => {
    router.invalidate()
    reset()
    })
    }
    ...
    }
  9. http://localhost:3000/todos で動作確認