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