🦔

[Part4] TypeScript, PostgreSQL, Next.js, Prisma & GraphQLでApp作成

2022/07/03に公開

Part1はこちら
https://zenn.dev/kanasugi/articles/a840e733f8e6a1

Part2はこちら
https://zenn.dev/kanasugi/articles/e14995bc1c8ad3

Part3はこちら
https://zenn.dev/kanasugi/articles/63ef05cf06abfd

本記事は、Next.js、GraphQL、TypeScript、Prisma、PostgreSQLを使ってフルスタックアプリを構築する講座の第4回目です。今回は、AWS S3を使って画像アップロードを追加する方法を学びます。

はじめに

このコースでは、ユーザーが精選されたリンクのリストを閲覧し、お気に入りのリンクをブックマークできるフルスタック・アプリ「awesome-links」の構築方法を学びます。

パート 3 では、アプリに認証を追加し、プレースホルダ画像を使用して新しいリンクを追加するページを作成しました。

このガイドでは、AWS S3を使用して画像のアップロードのサポートを追加する方法を説明します。パート3から続いている場合は、プロジェクトのセットアップをスキップして、AWS S3を使用して画像アップロードのサポートを追加するセクションにジャンプすることができます。

開発環境

このチュートリアルに沿って進めるには、Node.js と GraphQL 拡張がインストールされていることを確認します。また、PostgreSQLデータベースが稼働している必要があります。

リポジトリをクローンする

このコースの完全なソースコードはGitHubで見つけることができます。

まず、任意のディレクトリに移動して以下のコマンドを実行し、リポジトリをクローンします。

git clone -b part-4 https://github.com/prisma/awesome-links.git

クローンしたアプリケーションに移動して、依存関係をインストールします。

cd awesome-links
npm install

データベースのシード

PostgreSQLデータベースをセットアップした後、env.exampleファイルを.envにリネームし、データベースの接続文字列を設定します。その後、以下のコマンドを実行し、データベースにテーブルを作成します。

npx prisma db push

接続文字列の書式については、「Part 1 - Add Prisma to your Project」を参照してください。

次に、以下のコマンドを実行して、データベースのシードを作成します。

npx prisma db seed

このコマンドは、/prismaディレクトリにあるseed.tsファイルを実行します。seed.tsは、Prisma Clientを使用して、データベースに4つのリンクと1人のユーザーを作成します。

Part3はこちら
https://zenn.dev/kanasugi/articles/63ef05cf06abfd

プロジェクトの構成と依存関係

以下のようなフォルダ構成になっています。

awesome-links/
┣ components/
┣ data/
┃ ┗ links.ts
┣ graphql/
┃ ┣ types/
┃ ┣ context.ts
┃ ┣ schema.graphql
┃ ┗ schema.ts
┣ lib/
┃ ┣ apollo.ts
┃ ┗ prisma.ts
┣ pages/
┃ ┣ api/
┃ ┃ ┣ auth
┃ ┃ ┃ ┣ [...auth0].ts
┃ ┃ ┃ ┗ hook.ts
┃ ┃ ┗ graphql.ts
┃ ┣ link/
┃ ┣ _app.tsx
┃ ┣ admin.tsx
┃ ┣ favorites.tsx
┃ ┗ index.tsx
┣ prisma/
┃ ┣ schema.prisma
┃ ┗ seed.ts
┣ public/
┣ styles/
┃ ┗ tailwind.css
┣ .babelrc
┣ .env.example
┣ .gitignore
┣ README.md
┣ next-env.d.ts
┣ package-lock.json
┣ package.json
┣ postcss.config.js
┣ tailwind.config.js
┗ tsconfig.json

このアプリケーションはNext.jsで、次のライブラリやツールを利用しています。

  • データベースアクセス/CRUD操作のためのPrisma
  • フルスタックのReactフレームワークであるNext.js
  • スタイリングにTailwindCSS
  • GraphQLスキーマ構築ライブラリ:Nexus
  • GraphQLサーバー:Apollo Server
  • GraphQLクライアントであるApollo Client
  • 認証と認可のためのAuth0

pagesディレクトリには、以下のファイルが含まれます。

  • index.tsx:アプリ内の全リンクを表示するページ。ページネーションに対応
  • link/[id].tsx: 個々のリンクを表示し、ユーザーがブックマークできるようにするページ。
  • admin.tsx: ADMINロールを持っているログインユーザーを必要とする管理ページ。このページでは、管理者が新しいリンクを作成することができます。
  • favorites.tsx: ユーザーがブックマークしたリンクを表示するページ。
  • _app.tsx: グローバルアプリコンポーネント。ページ変更時にレイアウトを持続させ、ページ移動時に状態を保持することができます。
  • api/graphql.ts。Next.jsのAPIルートを利用したGraphQLエンドポイントです。
  • api/auth/[...auth0].ts: Auth0が生成する動的なAPIルートで、認証処理をおこないます。
  • api/auth/hook.ts。データベースにユーザーレコードを作成する処理を行うAPIルートです。

AWS S3を使って画像アップロードのサポートを追加する

現在のアプリケーションの状態では、管理者はリンクを作成することができます。しかし、管理者は作成されたリンクに画像を添付することができません。このガイドでは、AWS S3(オブジェクトストレージサービス)を利用して、画像をアップロードする方法を説明します。

アイデンティティアクセス管理のユーザーを作成する

AWSのリソースと対話するには、適切な権限を持つIdentity Access Management(IAM)ユーザーを作成する必要があります。IAMユーザーは、AWS上のリソースとプログラム的に対話することを可能にします。

これを行うには、ページの右上隅にあるドロップダウンメニューからSecurity Credentialsを選択します - あなたのユーザー名があるところです。

次に、左サイドバーにあるAccess Managementのドロップダウンから、Usersオプションを選択します。

次に、Add usersボタンをクリックして、新しいユーザーを作成します。

新規作成したユーザーの認識可能なユーザー名を入力し、Access key - programmatic access チェックボックスにチェックを入れます。

次に、権限を設定することで、ユーザーが異なるAWSリソースでできることを指定する必要があります。Attach existing policies directly オプションを選択し、検索フィルターに「S3」と入力します。AmazonS3FullAccessを選択します。

オプションで新しく作成されたIAMユーザーのタグを定義できますが、このプロジェクトではその必要はないので、[Next]をクリックします。Reviewをクリックします。

ユーザーが正しい権限とユーザー名を持っていることを確認した後、Create userをクリックします。

最後に「アクセスキーID」と「シークレットアクセスキー」をコピーして、.envファイルに格納します

#.env
app_aws_access_key = ''
app_aws_secret_key = ''

S3バケットの新規作成と設定

次に、アップロードしたオブジェクトを格納するAWS S3バケットを作成します。S3サービスは、検索バーで調べるか、https://s3.console.aws.amazon.com/。

次に、Create bucketをクリックして、新しいバケットを作成します。

バケットの名前とリージョンを選びます。これらの値を .env ファイルに保存します。
Note: バケット名は一意でなければならず、空白や大文字を含んではいけません。

# .env
app_aws_region = ''
AWS_S3_BUCKET_NAME = '' # APIルートで使用される予定です。
NEXT_PUBLIC_AWS_S3_BUCKET_NAME = '' # クライアント側で使用されます。

ページの一番下まで移動して、「Create bucket」ボタンをクリックして、バケットを作成します。今はデフォルトの設定のままでも構いませんが、次のステップで更新します。

Permissions」タブに移動し、「Block public access (bucket settings)」セクションの「Edit」ボタンをクリックします。

Block all public accessのチェックを外し、Save changesをクリックします。アプリケーションがAWS S3上にアップロードされた画像にアクセスする必要があるため、パブリックアクセスを許可する必要があります。

S3がバケットをプロビジョニングしたら、テーブルでバケットを選択してナビゲートします。

次に、リソースポリシーを更新して、アプリケーションがバケットとそのコンテンツにアクセスできるようにします。S3 BucketのPermissionsで、Bucket policyセクションにナビゲートしてください。Edit]を選択し、"name-of-your-bucket "のプレースホルダーをバケット名に変更しながら、以下を追加します。

{
  "Version": "2008-10-17",
  "Statement": [
    {
      "Sid": "AllowPublicRead",
      "Effect": "Allow",
      "Principal": {
        "AWS": "*"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::name-of-your-bucket/*"
    }
  ]
}

次に、別ドメインになるアプリケーションから保存画像にアクセスできるようにする必要があります。バケットのPermissionsタブで、一番下のCross-origin Resource Sharing(CORS)セクションまでスクロールし、以下を追加してください。

[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["PUT", "POST", "DELETE"],
    "AllowedOrigins": ["http://localhost:3000"],
    "ExposeHeaders": []
  }
]

アプリケーションに画像アップロード機能を追加する
S3をセットアップしたので、次のステップでは、アプリケーションに画像アップロード機能を追加します。APIエンドポイントを作成し、画像アップロードを処理するためにフロントエンドを更新します。

まず、以下のコマンドを実行して、aws-sdk パッケージをインストールします。

npm install aws-sdk

次に、pages/api/ ディレクトリに upload-image.ts というファイルを新規に作成し、以下のコードを追加します。

// pages/api/upload-image.ts
import aws from 'aws-sdk'

export default async function handler(req, res) {
  try {
    // 1. 
    const s3 = new aws.S3({
      accessKeyId: process.env.APP_AWS_ACCESS_KEY,
      secretAccessKey: process.env.APP_AWS_SECRET_KEY,
      region: process.env.APP_AWS_REGION,
    })

    // 2. 
    aws.config.update({
      accessKeyId: process.env.APP_AWS_ACCESS_KEY,
      secretAccessKey: process.env.APP_AWS_SECRET_KEY,
      region: process.env.APP_AWS_REGION,
      signatureVersion: 'v4',
    })

    // 3. 
    const post = await s3.createPresignedPost({
      Bucket: process.env.AWS_S3_BUCKET_NAME,
      Fields: {
        key: req.query.file,
      },
      Expires: 60, // seconds
      Conditions: [
        ['content-length-range', 0, 5048576], // up to 1 MB
      ],
    })

    // 4. 
    return res.status(200).json(post)
  } catch (error) {
    console.log(error)
  }
}
  1. S3 Bucketの新しいインスタンスを作成します。
  2. リージョン、クレデンシャル、追加のリクエストオプションでメイン設定クラスを更新します。
  3. S3バケットへの書き込みを許可する署名付きURLを生成します。
  4. ファイルアップロードに使用される署名済みURLを返します。

最後に、次のコードで pages/admin.tsx ファイルを更新します。

import React, { useState } from 'react'
import { useForm } from 'react-hook-form'
import { gql, useMutation } from '@apollo/client'
import toast, { Toaster } from 'react-hot-toast'

const CreateLinkMutation = gql`
  mutation($title: String!, $url: String!, $imageUrl: String!, $category: String!, $description: String!) {
    createLink(title: $title, url: $url, imageUrl: $imageUrl, category: $category, description: $description) {
      title
      url
      imageUrl
      category
      description
    }
  }
`

const Admin = () => {
  const [createLink, { data, loading, error }] = useMutation(CreateLinkMutation)
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm()

  // Upload photo function
  const uploadPhoto = async e => {
    const file = e.target.files[0]
    const filename = encodeURIComponent(file.name)
    const res = await fetch(`/api/upload-image?file=${filename}`)
    const data = await res.json()
    const formData = new FormData()

    // @ts-ignore
    Object.entries({ ...data.fields, file }).forEach(([key, value]) => {
      formData.append(key, value)
    })

    toast.promise(
      fetch(data.url, {
        method: 'POST',
        body: formData,
      }),
      {
        loading: 'Uploading...',
        success: 'Image successfully uploaded!🎉',
        error: `Upload failed 😥 Please try again ${error}`,
      },
    )
  }

  const onSubmit = async data => {
    const { title, url, category, description, image } = data
    const imageUrl = `https://${NEXT_PUBLIC_AWS_S3_BUCKET_NAME}.s3.amazonaws.com/${image[0].name}`
    const variables = { title, url, category, description, imageUrl }
    try {
      toast.promise(createLink({ variables }), {
        loading: 'Creating new link..',
        success: 'Link successfully created!🎉',
        error: `Something went wrong 😥 Please try again -  ${error}`,
      })
    } catch (error) {
      console.error(error)
    }
  }

  return (
    <div className="container mx-auto max-w-md py-12">
      <Toaster />
      <h1 className="text-3xl font-medium my-5">Create a new link</h1>
      <form className="grid grid-cols-1 gap-y-6 shadow-lg p-8 rounded-lg" onSubmit={handleSubmit(onSubmit)}>
        <label className="block">
          <span className="text-gray-700">Title</span>
          <input
            placeholder="Title"
            name="title"
            type="text"
            {...register('title', { required: true })}
            className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
          />
        </label>
        <label className="block">
          <span className="text-gray-700">Description</span>
          <input
            placeholder="Description"
            {...register('description', { required: true })}
            name="description"
            type="text"
            className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
          />
        </label>
        <label className="block">
          <span className="text-gray-700">Url</span>
          <input
            placeholder="https://example.com"
            {...register('url', { required: true })}
            name="url"
            type="text"
            className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
          />
        </label>
        <label className="block">
          <span className="text-gray-700">Category</span>
          <input
            placeholder="Name"
            {...register('category', { required: true })}
            name="category"
            type="text"
            className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
          />
        </label>
        <label className="block">
          <span className="text-gray-700">Upload a .png or .jpg image (max 1MB).</span>
          <input
            {...register('image', { required: true })}
            onChange={uploadPhoto}
            type="file"
            accept="image/png, image/jpeg"
            name="image"
          />
        </label>

        <button
          disabled={loading}
          type="submit"
          className="my-4 capitalize bg-blue-500 text-white font-medium py-2 px-4 rounded-md hover:bg-blue-600"
        >
          {loading ? (
            <span className="flex items-center justify-center">
              <svg
                className="w-6 h-6 animate-spin mr-1"
                fill="currentColor"
                viewBox="0 0 20 20"
                xmlns="http://www.w3.org/2000/svg"
              >
                <path d="M11 17a1 1 0 001.447.894l4-2A1 1 0 0017 15V9.236a1 1 0 00-1.447-.894l-4 2a1 1 0 00-.553.894V17zM15.211 6.276a1 1 0 000-1.788l-4.764-2.382a1 1 0 00-.894 0L4.789 4.488a1 1 0 000 1.788l4.764 2.382a1 1 0 00.894 0l4.764-2.382zM4.447 8.342A1 1 0 003 9.236V15a1 1 0 00.553.894l4 2A1 1 0 009 17v-5.764a1 1 0 00-.553-.894l-4-2z" />
              </svg>
              Creating...
            </span>
          ) : (
            <span>Create Link</span>
          )}
        </button>
      </form>
    </div>
  )
}

export default Admin

このフォームには、ファイルのアップロードを処理するための新しい入力フィールドが含まれています。入力フィールドは、.png または .jpeg 形式の画像を受け付けます。画像がアップロードされるたびに、uploadPhoto関数は/api/upload-image APIエンドポイントにリクエストを送信します。リクエストがAPIによって解決されると、成功、ロード、エラーの各状態でトーストが表示されます。

フォームが送信されると、画像の URL が createLink ミューテーションの変数として含まれます。トーストは、ミューテーションが実行されているときに表示されます。

まとめと次のステップ

AWS S3 を使用して画像アップロードのサポートを追加する方法を学びました。次のパートでは、アプリを Vercel にデプロイし、Prisma Data Proxy を使用してデータベース接続プールを管理し、アプリケーションが接続を使い果たさないようにする方法を学びます。

GitHubで編集を提案

Discussion