Amazon DynamoDBとCognito User Pools + Next.jsで、簡単なブックマークシステムを実装する

このテキストは、特定のユーザーによって利用される有料のブックマーク機能についての説明です。DynamoDBとCognitoを使用して構築されており、APIの作成やデータの管理方法などが簡単にまとめられています。ブックマークの追加、削除、取得のための関数も提供されています。また、UIの作成にはReactとAmplify UIが使用されています。

広告ここから
広告ここまで

目次

    このサイトには、有料プランユーザー限定のブックマーク機能が2023/12時点で存在します。DynamoDBとCognitoを使って構築しているので、やり方を簡単にまとめました。

    DynamoDBにAWS SDK(v3)でデータを管理する

    テーブル名やキーを変数で定義しておきます。AWS SSMや環境変数でもよいかもしれません。

    
    const BOOKMARK_TABLE_NAME = 'wpkyoto-bookmark'
    const BOOKMARK_TABLE_KEYS = {
      PRIMARY_KEY: 'username',
      SORT_KEY: 'post_id',
      BOOKMARKED_AT: 'bookmarked_at',
    }

    クライアントはAWS SDK(v3)を利用します。アクセスキーなどは環境変数かAWS SSM / Secrets Managerから渡しましょう。

    import { DynamoDB, WriteRequest } from '@aws-sdk/client-dynamodb'
    
    export const createDynamoDBClient = () => {
      const client = new DynamoDB({
        region: 'us-west-2',
      })
      return client
    }
    

    あとはユーザー名と記事IDでputItemするだけです。同時に今の時間をUNIX TIMEで保存しています。

    
    export const putBookmarkItem = async (params: { username: string; post_id: number }) => {
      await createDynamoDBClient().putItem({
        TableName: BOOKMARK_TABLE_NAME,
        Item: {
          [BOOKMARK_TABLE_KEYS.PRIMARY_KEY]: {
            S: params.username,
          },
          [BOOKMARK_TABLE_KEYS.SORT_KEY]: {
            N: params.post_id.toString(),
          },
          [BOOKMARK_TABLE_KEYS.BOOKMARKED_AT]: {
            N: new Date().getTime().toString(),
          },
        },
      })
    }

    取得部分も関数を用意しています。こちらはユーザー名でQueryをかけています。

    export const listBookmarkItems = async (username: string) => {
      const result = await createDynamoDBClient().query({
        TableName: BOOKMARK_TABLE_NAME,
        KeyConditionExpression: `${BOOKMARK_TABLE_KEYS.PRIMARY_KEY} = :username`,
        ExpressionAttributeValues: {
          ':username': { S: username },
        },
      })
      return result.Items
    }

    ブックマークを解除したいケースに備えて、アイテムの削除処理も用意しましょう。こちらもユーザー名と記事IDを利用します。

    export const removeBookmarkItem = async (params: { username: string; post_id: number }) => {
      await createDynamoDBClient().deleteItem({
        TableName: BOOKMARK_TABLE_NAME,
        Key: {
          [BOOKMARK_TABLE_KEYS.PRIMARY_KEY]: {
            S: params.username,
          },
          [BOOKMARK_TABLE_KEYS.SORT_KEY]: {
            N: params.post_id.toString(),
          },
        },
      })
    }

    Cognito User Poolsの処理を用意する

    ユーザー名はCognito User Poolsから取得します。AuthorizationヘッダーのTokenを利用して取得しますので、その処理を用意しておきましょう。

    import { CognitoIdentityProvider, AttributeType } from '@aws-sdk/client-cognito-identity-provider'
    import { AwS_CREDENTIALS } from '../config'
    
    export const COGNITO_USER_POOL_ID = process.env.AWS_COGNITO_USER_POOL_ID as string
    
    export const createCognitoClient = () => {
      const client = new CognitoIdentityProvider({
        region: 'us-east-1',
      })
      return client
    }
    
    export const getCognitoUserByToken = async (req: {
      headers: {
        authorization?: string
      }
    }) => {
      const authorization = req.headers.authorization
      if (!authorization) {
        return
      }
      const client = createCognitoClient()
      const data = await client.getUser({
        AccessToken: authorization || '',
      })
      if (!data) return
      return data
    }

    これで大体の準備ができました。

    APIを用意する

    続いてブックマーク管理APIを作ります。Next.js v12以前のバージョンで動いているので、Page Routerの書き方で実装しています。

    import { NextApiRequest, NextApiResponse } from 'next'
    import {
      getCognitoUserByToken,
    } from '../../../libs/AWS/cognito'
    import {
      listBookmarkItems,
      putBookmarkItem,
      removeBookmarkItem,
    } from '../../../libs/AWS/DynamoDB/bookmark'
    
    async function handler(req: NextApiRequest, res: NextApiResponse) {
      if (!req.method || ['POST'].includes(req.method.toLocaleLowerCase())) {
        res.status(405).json({
          message: 'Method not allowed',
        })
        return
      }
      try {
        const user = await getCognitoUserByToken(req)
        if (!user) {
          res.status(401).json({
            message: 'Unauthorized',
          })
          return
        }
    
        switch (req.method.toLocaleLowerCase()) {
          case 'put':
            await putBookmarkItem({
              username: user.Username,
              post_id: req.body.post_id,
            })
            res.status(201).end()
          case 'get':
            const items = await listBookmarkItems(user.Username)
            res.status(200).json(
              items?.map((item) => {
                return {
                  username: item.username.S,
                  bookmarked_at: Number(item.bookmarked_at.N),
                  post_id: Number(item.post_id.N),
                }
              }),
            )
            return
          case 'delete':
            await removeBookmarkItem({
              username: user.Username,
              post_id: req.body.post_id,
            })
            res.status(204).end()
            return
        }
      } catch (e) {
        console.log(e)
        res.status(500).json({
          message: (e as Error).message,
        })
      }
    }
    

    ある程度簡易化しているのもありますが、それでもかなりシンプルな作りになっていると思います。

    ReactとAmplify UIで画面を作る

    最後にブックマーク管理のUIやHookを作ります。複数の箇所で操作用APIを呼び出しそうなので、ProviderとHookを用意しました。

    import { useAuthenticator } from '@aws-amplify/ui-react'
    import {
      createContext,
      FC,
      PropsWithChildren,
      useCallback,
      useContext,
      useEffect,
      useMemo,
      useRef,
      useState,
    } from 'react'
    import { callAPIWithAuth } from '../../libs/AWS/amplify/user.utils'
    import { canUseBookmarkFeature } from '../../libs/bookmarks/permissions'
    
    export type Bookmark = {
      username: string
      post_id: number
      bookmarked_at: number
    }
    
    const BookmarkContext = createContext<{
      putBookmark: (postId: number, callback?: () => void) => Promise<void> | void
      listBookmark: () => Promise<void> | void
      removeBookmark: (postId: number, callback?: () => void) => Promise<void> | void
      bookmarks: Bookmark[] | undefined
    }>({} as any)
    
    export const useBookmark = () => useContext(BookmarkContext)
    
    export const BookmarkProviderContent: FC<PropsWithChildren<{}>> = ({ children }) => {
      const [bookmarks, setBookmarks] = useState<Bookmark[] | undefined>(undefined)
    
      const listBookmark = useCallback(() => {
        callAPIWithAuth('/api/bookmarks', {
          method: 'GET',
        })
          .then((data) => data.json())
          .then(async (bookmarks) => {
            setBookmarks(bookmarks)
          })
          .catch((e) => {
            console.log(e)
            setBookmarks([])
          })
      }, [setBookmarks])
      const putBookmark = useCallback(
        (postId: number, callback?: () => void) => {
          callAPIWithAuth('/api/bookmarks', {
            method: 'PUT',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({
              post_id: postId,
            }),
          })
            .then(() => {
              listBookmark()
              callback?.()
            })
            .catch((e) => {
              console.log(e)
              window.alert((e as Error).message)
            })
        },
        [listBookmark],
      )
      const removeBookmark = useCallback(
        (postId: number, callback?: () => void) => {
          callAPIWithAuth('/api/bookmarks', {
            method: 'DELETE',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({
              post_id: postId,
            }),
          })
            .then(() => {
              listBookmark()
              callback?.()
            })
            .catch((e) => {
              console.log(e)
              window.alert((e as Error).message)
            })
        },
        [listBookmark],
      )
    
      const didLoadRef = useRef(false)
      useEffect(() => {
        if (!canUseBookmark) return
        if (didLoadRef.current) return
        didLoadRef.current = true
        listBookmark()
      }, [canUseBookmark, listBookmark])
    
      return (
        <BookmarkContext.Provider
          value={{
            listBookmark,
            bookmarks,
            putBookmark,
            removeBookmark,
          }}
        >
          {children}
        </BookmarkContext.Provider>
      )
    }
    
    export const BookmarkProvider: FC<PropsWithChildren<{}>> = ({ children }) => {
      const { user } = useAuthenticator((context) => [context.user])
      return <BookmarkProviderContent key={user?.username || ''}>{children}</BookmarkProviderContent>
    }
    

    あとは取得したIDを使って記事を取得するAPIを叩いたり、ボタン操作からブックマークの追加や削除をしたりするだけです。

    import { Authenticator, useAuthenticator } from '@aws-amplify/ui-react'
    
    export const BookmarkPage: NextPage = () => {
      const { bookmarks } = useBookmark()
      const { user } = useAuthenticator((context) => [context.user])
    
      const bookmarkedPostIds = bookmarks ? bookmarks.map((item) => item.post_id) : []
      const { data: bookmarkedPosts } = useSWR<IWPPost[]>(
        getListWPPostURL({
          include: bookmarkedPostIds,
          per_page: 100,
        }),
        fetcher,
      )
      if (!user) {
        return (
          <div className='text-center'>
            <div className='mb-12'>
              <QuestionMarkCircleIcon
                className='mx-auto h-12 w-12 text-gray-400'
                width={24}
                height={24}
              />
              <h3 className='mt-2 text-sm font-medium text-gray-900'>WP Kyoto Supporter&apos; only</h3>
              <p className='mt-1 text-sm text-gray-500'>
                You need to sign in and subscribe the supporter plan to use this bookmark feature.
              </p>
            </div>
            <Authenticator></Authenticator>
          </div>
        )
      }
    
      if (bookmarks === undefined) {
        return <Loader />
      }
      if (bookmarks.length < 1) {
        return (
          <div className='text-center'>
            <QuestionMarkCircleIcon
              className='mx-auto h-12 w-12 text-gray-400'
              width={24}
              height={24}
            />
            <h3 className='mt-2 text-sm font-medium text-gray-900'>No bookmark items</h3>
            <p className='mt-1 text-sm text-gray-500'>Let&apos;s bookmark the post that you like.</p>
          </div>
        )
      }
      return (
        <ul role='list' className='space-y-4'>
          {bookmarkedPosts?.map((post) => {
            if (!post) return null
            return (
              <li key={post?.id} className=''>
                <ListPostItem post={post} canUseBookmark={canUseBookmark} />
              </li>
            )
          })}
        </ul>
      )
    }
    
    export default BookmarkPage

    おわりに

    かなり説明も実装も簡略的ですが、こんな感じで簡単にブックマークシステムが作れます。「だれが、どの記事をブックマークしたか」が最低限あれば、あとは記事情報などは必要な時にとればよいかなと考えてます。今の所そうなる可能性は低いですが、もしDynamoDBのコストが気になってくる場合は、TTLを設定してたとえば「2年以上前の記事は情報が古いから読まないで欲しい。なので消します」とかを仕様に加えてもよいかもしれません。

    広告ここから
    広告ここまで
    Home
    Search
    Bookmark