【tRPCの恩恵】T3 Stackを使ったWebアプリ開発

# はじめに
株式会社マーズフラッグ、フロントエンドエンジニアの安座間です。
今回は、T3 Stackを使った、認証付きの画像生成Webアプリの開発手順と使用感を紹介させていただきます。

画像生成にはLeonardo.AiのAPIを使い、以下の処理を実装していきます。

1. 画面で入力したプロンプトをサーバーサイドに送信
1. サーバーサイドでLeonardo.AiのAPIで画像生成
1. 生成した画像をクライアントに返し画面に表示

レオナルドとは？という方はこちらをご覧ください。

https://qiita.com/m-azama/private/4bef2717cbdcfd6fb3c8


# この記事で分かること
1. T3 Stackの開発体験
1. tRPCの恩恵
1. Leonardo.AiのAPIの使い方

# T3 Stackとは？

https://create.t3.gg/

以下3つの思想に基づいたフルスタックフレームワークで、端的にいうと「**一貫性のある型でシンプルで再利用可能なWebアプリを作ろうぜ**」的な考えでしょうか。

- simplicity
- modularity
- full-stack typesafety


そして、この思想を実現するための技術セットがこちら

- [Next.js](https://nextjs.org/)
- [tRPC](https://trpc.io/)
- [Tailwind CSS](https://tailwindcss.com/)
- [TypeScript](https://www.typescriptlang.org/)
- [Prisma](https://www.prisma.io/)
- [NextAuth.js](https://next-auth.js.org/)

# tRPCとは？
上記技術の中でも注目したいのがtRPC

https://trpc.io/


**スキーマやコード生成を行わずに、完全にタイプセーフな API を簡単に構築できる**ライブラリのようです。

公式から引用
> tRPC allows you to easily build & consume fully typesafe APIs without schemas or code generation.
https://trpc.io/docs/#introduction
> 

サーバーサイドとクライアントサイド間でスキーマなしで型安全なAPIを構築できるので、例えばOpenAPI GeneratorでAPIクライアントを生成して、クライアントにマージして。的な工程が不要なります。

また、サーバー、クライアントサイドともにTypeScriptを使う制約はありますが、 [trpc-openapi](https://github.com/jlalmes/trpc-openapi) を使うことでOpenAPI Generatorで多言語のAPIクライアントを生成することもできるようです。（今回は使っていません）

# セットアップ
筆者の環境
```zsh
% node -v
v18.13.0

% pnpm -v
8.6.10
```

```zsh
% pnpm create t3-app@latest

.../Library/pnpm/store/v3/tmp/dlx-44332  | +149 +++++++++++++++
Packages are hard linked from the content-addressable store to the virtual store.
  Content-addressable store is at: /Users/mitsufumiazama/Library/pnpm/store/v3
  Virtual store is at:             ../../../Library/pnpm/store/v3/tmp/dlx-44332/node_modules/.pnpm
.../Library/pnpm/store/v3/tmp/dlx-44332  | Progress: resolved 149, reused 136, downloaded 13, added 149, done

   ___ ___ ___   __ _____ ___   _____ ____    __   ___ ___
  / __| _ \ __| /  \_   _| __| |_   _|__ /   /  \ | _ \ _ \
 | (__|   / _| / /\ \| | | _|    | |  |_ \  / /\ \|  _/  _/
  \___|_|_\___|_/‾‾\_\_| |___|   |_| |___/ /_/‾‾\_\_| |_|


│
◆  What will your project be called?
│  
```
任意のプロジェクト名を入力しEnter
色々と聞かれるので以下の設定で進めます。

```
Will you be using TypeScript or JavaScript?
TypeScript

Will you be using Tailwind CSS for styling?
Yes

Would you like to use tRPC?
Yes

What authentication provider would you like to use?
NextAuth.js

What database ORM would you like to use?
Prisma

Would you like to use Next.js App Router?
No

Should we initialize a Git repository and stage the changes?
Yes

Should we run 'pnpm install' for you?
Yes

What import alias would you like to use?
~/
```

## 認証設定
NextAuth.jsを含めたのでプロバイダーの設定が必要になります。
今回は、一番簡単なDiscordを使用するので[こちらのドキュメント](https://create.t3.gg/ja/usage/first-steps)にならい、以下の値を取得し.envに追記します。
```
DISCORD_CLIENT_ID="取得した値"
DISCORD_CLIENT_SECRET="取得した値"
```

## ローカルサーバーを起動
```zsh
% pnpm dev
```
http://localhost:3000 でこちらの画面が表示されればOK

![aaasdfadsf.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624653/6f868c26-ee53-a599-26e9-f2a466380d5e.png)


## Prisma Studioを起動
ログイン後にユーザー情報がDBに保存されるかを確認できるように起動しておきます。
```zsh
% pnpm db:studio
```
http://localhost:5555 でこちらの画面が表示されればOK

![adfadsfadf.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624653/6ba87d3a-947c-a14b-223c-99ca4676a8a4.png)

## ログイン動作確認
画面上の **Sign in** ボタンをからログインしてみます。
AccountとUserテーブルにレコードが追加されればOK

![adfafadf.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624653/2e28f7b1-0490-7f5b-f236-33e00261be34.png)
![adsfadfa.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624653/1d34ee79-98b6-8c25-ea46-3750d9891458.png)


# Leonardo.Aiの設定
## APIキーを発行
[こちらのドキュメント](https://docs.leonardo.ai/docs/create-your-api-key)の手順でAPIキーを発行します。なお、APIの利用は有料となりますので、今回は最大380枚ほど生成できるBasicプランを契約しました。

.env にAPIキーと後段で紹介するURLを追記しておきます。

![adfadsfadsfadfa.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624653/cfdea6a4-0b2f-004c-a373-95c5e9a21ad1.png)

```
LEONARDOAI_BASE_URL="https://cloud.leonardo.ai/api/rest/v1/generations"
LEONARDOAI_API_KEY="{APIキー}"
```

## 画像生成の流れ

APIを使った画像生成の流れは、**プロンプトからID（generationId）を取得 → 取得したIDを使い画像を生成** となり、以下の2つのエンドポイントを使用します。


### Create a Generation of Images
Request
```jsx
const options = {
  method: 'POST',
  headers: {
    accept: 'application/json',
    'content-type': 'application/json',
    authorization: 'Bearer {APIキー}'
  },
  body: JSON.stringify({
    height: 512,
    prompt: '{プロンプト}',
    width: 512
  })
};

fetch('https://cloud.leonardo.ai/api/rest/v1/generations', options)
  .then(response => response.json())
  .then(response => console.log(response))
  .catch(err => console.error(err));
```
Response
```jsx
{
  "sdGenerationJob": {
    "generationId": "bc01981-3312-4229-a2de-fa7d52988290",
    "apiCreditCost": 11
  }
}
```
オプションで画像サイズやネガティブプロンプト、コントラストや解像度など色々と指定できます。

https://docs.leonardo.ai/reference/creategeneration

### **Get a Single Generation**
Request
    
```jsx
const options = {
  method: 'GET',
  headers: {
    accept: 'application/json',
    authorization: 'Bearer {APIキー}'
  }
};

fetch('https://cloud.leonardo.ai/api/rest/v1/generations/bc01981-3312-4229-a2de-fa7d52988290', options)
  .then(response => response.json())
  .then(response => console.log(response))
  .catch(err => console.error(err));
``` 
Response
    
```jsx
{
  "generations_by_pk": {
    "generated_images": [
      {
        "url": "https://cdn.leonardo.ai/users/178fd63b-3e50-4117-950d-c560a0a68f7f/generations/fbc01981-3312-4229-a2de-fa7d52988290/Leonardo_Creative_An_oil_painting_of_a_cat_0.jpg",
        "nsfw": false,
        "id": "170bcef8-6b69-47eb-a7d7-f63b6c242323",
        "likeCount": 0,
        "generated_image_variation_generics": []
      },
      {
        "url": "https://cdn.leonardo.ai/users/178fd63b-3e50-4117-950d-c560a0a68f7f/generations/fbc01981-3312-4229-a2de-fa7d52988290/Leonardo_Creative_An_oil_painting_of_a_cat_1.jpg",
        "nsfw": false,
        "id": "ab50bb5c-0b46-48ef-b7ba-23ea7b518bbb",
        "likeCount": 0,
        "generated_image_variation_generics": []
      },
      {
        "url": "https://cdn.leonardo.ai/users/178fd63b-3e50-4117-950d-c560a0a68f7f/generations/fbc01981-3312-4229-a2de-fa7d52988290/Leonardo_Creative_An_oil_painting_of_a_cat_2.jpg",
        "nsfw": false,
        "id": "2bf2ef2a-ae7e-40cf-a6c6-3914a730f528",
        "likeCount": 0,
        "generated_image_variation_generics": []
      },
      {
        "url": "https://cdn.leonardo.ai/users/178fd63b-3e50-4117-950d-c560a0a68f7f/generations/fbc01981-3312-4229-a2de-fa7d52988290/Leonardo_Creative_An_oil_painting_of_a_cat_3.jpg",
        "nsfw": false,
        "id": "c5faf06f-dc02-4408-ad33-653309b387c8",
        "likeCount": 0,
        "generated_image_variation_generics": []
      }
    ],
    "modelId": "6bef9f1b-29cb-40c7-b9df-32b51c1f67d3",
    "prompt": "An oil painting of a cat",
    "negativePrompt": "",
    "imageHeight": 512,
    "imageWidth": 512,
    "inferenceSteps": 30,
    "seed": 465788672,
    "public": false,
    "scheduler": "EULER_DISCRETE",
    "sdVersion": "v2",
    "status": "COMPLETE",
    "presetStyle": null,
    "initStrength": null,
    "guidanceScale": 7,
    "id": "fbc01981-3312-4229-a2de-fa7d52988290",
    "createdAt": "2023-12-03T13:41:38.253",
    "promptMagic": false,
    "promptMagicVersion": null,
    "promptMagicStrength": null,
    "photoReal": false,
    "photoRealStrength": null,
    "fantasyAvatar": null,
    "generation_elements": []
  }
}
```


# サーバーサイド
クライアントから受け取ったプロンプトをLeonardo.Aiに渡して画像を生成し、クライアントに返すシンプルな処理を実装します。

## エンドポイントを追加
### src/server/api/routers/image.ts を作成

```ts
import { z } from "zod";

import {
  createTRPCRouter,
  publicProcedure,
} from "~/server/api/trpc";

import type { LeonardoGenerationsImages, LeonardoGenerations } from "~/types";

export const imageRouter = createTRPCRouter({
  generate: publicProcedure
    .input(z.object({ prompt: z.string() }))
    .query(async ({ input }) => {

      const generationsResponse = await fetch(`${process.env.LEONARDOAI_BASE_URL}`, {
        method: 'POST',
        headers: {
          accept: 'application/json',
          'content-type': 'application/json',
          authorization: `Bearer ${process.env.LEONARDOAI_API_KEY}`
        },
        body: JSON.stringify({
          height: 512,
          prompt: input.prompt,
          width: 512
        })
      });

      const generations: LeonardoGenerations = await generationsResponse.json();

      await new Promise((resolve) => setTimeout(resolve, 20000));

      const url = `${process.env.LEONARDOAI_BASE_URL}/${generations.sdGenerationJob.generationId}`;

      const generationsImagesResponse = await fetch(url, {
        method: 'GET',
        headers: {
          accept: 'application/json',
          authorization: `Bearer ${process.env.LEONARDOAI_API_KEY}`
        }
      });

      const generationsImages: LeonardoGenerationsImages = await generationsImagesResponse.json();

      return {
        images: generationsImages.generations_by_pk?.generated_images,
      };
    }),
});
```

今回の肝となるtRPCがこちら

```ts
generate: publicProcedure
  .input(z.object({ prompt: z.string() }))
  .query(async ({ input }) => {

	//省略...
}),
```
バリデーションライブリである [zod](https://zod.dev/) を使いクライアントサイドからのリクエストの引数をチェック。**z.string()** で promptの値を文字列のみ許可していますが、**z.number()** とすると数値のみとなります。

上記変更をコード上で行うと以下のようにサーバーサイドとクライアントサイドでシームレスに型が共有され即エラーが表示され快適です。

![ｄふぁｄふぁｄふぁｄふぁｄ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624653/d2eb333d-166f-3305-4d47-100d120b5a14.gif)


**補足**
本来、Leonardo.Aiの画像生成が完了するまで数秒時間がかかるので、画像生成の完了通知をWebhookで受信する必要があります。
.
.

が、今回は本筋とずれるという正当な理由でsetTimeoutで割愛してます。
詳細は[こちら](https://docs.leonardo.ai/docs/guide-to-the-webhook-callback-feature)に記載があります。



### src/server/api/root.ts にimageRouterを追加
createTRPCRouterに先程作成したimageRouterを追加します。

```ts
import { postRouter } from "~/server/api/routers/post";
import { imageRouter } from "~/server/api/routers/image"; // 追加
import { createTRPCRouter } from "~/server/api/trpc";

/**
 * This is the primary router for your server.
 *
 * All routers added in /api/routers should be manually added here.
 */
export const appRouter = createTRPCRouter({
  post: postRouter,
  image: imageRouter, //追加
});

// export type definition of API
export type AppRouter = typeof appRouter;
```

これでAPIエンドポイント http://localhost:3000/api/trpc/image.generate が作成されたはずです。

# クライアントサイド
プロンプトの入力欄と送信ボタンのシンプルな画面を作っていきます。

![あｄふぁｄふぁふぁｄ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624653/5722a7ed-1806-f54f-08bc-f00465da34f5.png)


src/pages/generator/index.tsx を追加

```jsx
import { useState } from "react";
import { api } from "~/utils/api";
import { type GeneratedImage } from "~/types";

import Image from "next/image";

export default function Home() {
  const [prompt, setPrompt] = useState("");

  const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    setPrompt(e.target.value);
  }
  const { isLoading, fetchStatus, data, refetch } = api.image.generate.useQuery({ prompt }, {
    enabled: false,
  });
  
  const generateImage = async () => {
    await refetch();
  }
  

  return (
    <main className="py-32 text-center">
      <div className="mb-10">
        <div className="mb-5">
          <textarea
            defaultValue={prompt}
            onChange={(e) => handleChange(e)}
            className="textarea textarea-bordered textarea-md w-5/12 text-white"
          >
          </textarea>
        </div>
        <button
          className="btn btn-primary btn-wide text-white"
          onClick={ () => void generateImage() }
        >
          Generate
        </button>
      </div>

      {isLoading && fetchStatus === "fetching" ? (
        <div>
          <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-10 h-10 animate-spin m-auto">
            <path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
          </svg>
        </div>
      ): null}

      {data ? (
      <ul className="lg:flex">
        {data?.images.map((image: GeneratedImage, index: number) => (
          <li key={index} className="p-7">
            <Image
              src={image.url}
              alt={`image_${index}`}
              width="512"
              height="512"
              className="m-auto"
            />
          </li>
        ))}
      </ul>
      ): null}
    </main>
  );
}
```

以下の箇所でバックエンドにプロンプトを渡していますが、コーディングで補完が効き、バックエンドで定義した型（string）が共有されているのを確認することができます。

```jsx
const { isLoading, fetchStatus, data, refetch } = api.image.generate.useQuery({ prompt }, {
    enabled: false,
});
```

![adfadfadfadfadf.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624653/4b58f707-f579-3268-2cb4-8ed6245bb03d.gif)

因みにこちらのuseQueryは データフェッチライブラリのTanStack QueryのuseQueryのラッパーのようです。

https://trpc.io/docs/v9/react-queries

https://tanstack.com/query/v3/docs/react/guides/queries


# 動作確認
http://localhost:3000/generator からプロンプトを入力してGenerateしてみると、、
正常に画像を生成することができました。

![あｄふぁｄふぁｄふぁふぁｆｄ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624653/372b46fd-9430-c3da-bd8e-2c9f5e621d06.gif)


**プロンプト**
```
# クリスマスをテーマにしたマシュマロの漫画のキャラクター
Cartoon Marshmallow character with Christmas theme
```

# まとめ

いかがでしたか。
T3 Stackを使うと、認証機能付きWebアプリの雛形を簡単に作ることができ、tRPCのコード補完や型チェックにより安全で効率的な開発を体験することができました。

また、Leonardo.AiのAPIは、プロンプトからの画像生成以外にも、i2iやモデルのファインチューニングなども可能ですので、興味が湧いた方は是非使ってみてください。