AWSJavaScriptNext.jsReact

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年以上前の記事は情報が古いから読まないで欲しい。なので消します」とかを仕様に加えてもよいかもしれません。

ブックマークや限定記事(予定)など

WP Kyotoサポーター募集中

WordPressやフロントエンドアプリのホスティング、Algolia・AWSなどのサービス利用料を支援する「WP Kyotoサポーター」を募集しています。
月額または年額の有料プランを契約すると、ブックマーク機能などのサポーター限定機能がご利用いただけます。

14日間のトライアルも用意しておりますので、「このサイトよく見るな」という方はぜひご検討ください。

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

Related Category posts