👌 Blitz.jsで古臭いBBSを作るやつのメモ #02 API Routes / ファイルアップロード編
概ねWebに必要な全ては把握できましたね
- APIがちゃんと定義できたのでかなりよさげみが上がってきました。
- ファイルアップロード周りはお好みでS3とか使えばいいんじゃないすか。
- Next.jsあたりのファイルアップロード知見がそのままBlitzでも使えるっぽい
- クエリビルダが結構柔軟な割に細部までしっかり型がついてて気持ちよすぎる🤪
- そんなに型へのお祈りコードは書いてない。Prismaは最高
- ビジネスロジックがフリーダムすぎるので、DDDかどっかの知見を持ってきて最初に設計をしっかりしたほうが良さそう
- あとActiveSerializerみたいなのが欲しい。 Prismaを生で扱ってると軽率に見せちゃいけないデータを見せれてしまう(もしかして対策が用意されてたりするのかな…?)
-
useMutation
は使っていいけど、生クエリめいたデータを引数に渡すのはやめたほうがいい- いわゆる「ビューにドッキリ! ドメインロジック! 2(ツー)」が発生するので終わる
- 「REST APIだったときにその構造でデータ送ってた?」というのを意識する。リレーションの
{ connect: ... }
とか送り始めたら崩壊の兆しあり。 - 前回作ったcreateEntryMutationは多分これくらいの形が理想
// コンポーネント側のコールバック await createEntryMutation({ // FormDataにしても問題ないくらいのデータを投げる content: textareaRef.current?.value!, }) // `createEntry` mutation内 export default async function createEntry( { content }: { content: string }, { session }: { session?: SessionContext } ) { if (!session) return null const data: Prisma.EntryCreateArgs = { // リレーションの繋ぎこみはmutationで data: { content, user: { connect: { id: session.userId! } } }, } return await db.entry.create(data) }
とりあえず最新投稿を取ってくるAPI /api/entries/latest
を建てる
まずは適当にモック
import { BlitzApiRequest, BlitzApiResponse } from "blitz"
export default async function latest(req: BlitzApiRequest, res: BlitzApiResponse) {
res.json({ hi: "hi" })
}
http://localhost:3000/api/entries/latest にアクセスしてJSON返ってきた
ヨシ!!!!!!!
ちなみにBlitzのデフォルト設定だとexport default () =>
とか書くとESLintとかいうヤクザが「export defaultする前に名前つけろやオラァ!!!」って殴り込んできます
dbと繋ぎこむ。
- 補完が効かなくて面倒だったので
app/db/index.ts
にexport { prisma as db }
を追記 - 後々POST API生やすときに条件分岐書くのダルいので
apiRoute
という適当なヘルパー関数を作る
import { apiRoute } from "app/utils/apiRoute"
import { db } from "db"
export default apiRoute({
whenGet: async function latest(req, res) {
const entries = await db.entry.findMany({ orderBy: { created_at: "desc" } })
res.status(200).json(entries)
},
})
import { BlitzApiRequest, BlitzApiResponse, Middleware, MiddlewareRequest, MiddlewareResponse } from "blitz"
type AcceptMethod = "POST" | "GET" | "HEAD"
type MiddlewareOption =
| Middleware
| {
when: AcceptMethod | AcceptMethod[]
then: Middleware
}
type Handler = (req: BlitzApiRequest, res: BlitzApiResponse) => void | Promise<void>
interface Options {
middlewares?: MiddlewareOption[]
whenGet?: Handler
whenPost?: Handler
}
export const apiRoute = (opt: Options) => {
const middlewares = (opt.middlewares ?? []).map((m) => {
if ("when" in m) {
return { method: m.when, then: m.then }
} else {
return { method: null, then: m }
}
})
return async (req: MiddlewareRequest, res: MiddlewareResponse) => {
for (const m of middlewares) {
if (m.method == null || m.method === req.method) {
await new Promise((resolve, reject) => {
m.then(req, res, (result) => (result ? reject(result) : resolve()))
})
}
}
if (req.method === "GET") opt.whenGet?.(req, res)
else if (req.method === "POST") opt.whenPost?.(req, res)
}
}
とりあえずのレスポンスは出たけど、あまりに全てが出るので注意。
ここはActiveSerializerみたいなのが欲しいですね…
次はPOSTを作る
POST /api/entries/new
を建てる
import { apiRoute } from "app/utils/apiRoute"
export default apiRoute({
whenPost: async function latest(req, res) {
console.log(req.body)
res.end()
},
})
ブラウザでコンソール開いて雑にリクエストを飛ばしてreq.body
の中身を確認
await fetch('/api/entries/new', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ 'hell': 'word' }),
})
Blitz側のコンソールに送った内容が出てるのでヨシ!!!!(何回か送ったので何回か出力されてる)
new.ts
に投稿処理を実装する。
先に作ったapiRoute
にはmiddlewareを渡せるようにしてあるので、ログインしてなかったら適当にお帰りいただくmiddlewareを追加する。
このmiddleware関数を切り出せばアプリの至るところにある認証処理をDRYにできるじゃろ
import { apiRoute } from "app/utils/apiRoute"
import { getSessionContext } from "@blitzjs/server"
import { db } from "db"
export default apiRoute({
middlewares: [
async function signinRequired(req, res, next) {
const session = await getSessionContext(req, res)
if (!session.userId) res.status(401).json({ error: "signin required" })
else next()
},
],
whenPost: async function latest(req, res) {
const session = await getSessionContext(req, res)
const entry = await db.entry.create({
data: { content: req.body.content, user: { connect: { id: session.userId! } } },
})
res.status(200).json(entry)
},
})
[...'CSRFTokenMismatchError'].join(' ')
C S R F T o k e n M i s m a t c h E r r o r
import {getAntiCSRFToken} from "blitz"
。はい。
(コンソールからできるわけないだろ〜〜〜〜〜〜〜)
Blitzのコードを読んでみると、どうやらprocess.env.DISABLE_CSRF_PROTECTION
が設定されているとデフォルトのCSRFをオフにできるようだ(@blitzjs/server/dist/server.esm.js
のgetSession
関数を参照)
流石に治安が悪いので他の方法でうまいこと凌ぎたいけど、まあしんどい方法しかなさそうなので仕方ねぇな〜コード書いてやるよ〜〜〜仕方ないな〜〜〜〜
昨日作ったentry/new
で、useMutationを使わずに実装することにする。
useMutationにいつもREST APIに送ってる以上に複雑なデータをコンポーネントで組み立てて送るのやめろ。 ○すぞ。マジで1層以上に深いオブジェクトをmutation関数に渡すの大怪我の元だからやめろ。
import { BlitzPage, getAntiCSRFToken, useMutation } from "blitz"
// ...
await fetch("/api/entries/new", {
method: "POST",
headers: {
"anti-csrf": getAntiCSRFToken(),
"Content-Type": "application/json",
},
body: JSON.stringify({ content: textareaRef.current?.value! }),
})
やったね!!!
fetch状態とか便利なのでreact-queryは使ったほうがいいと思った(POSTで使えるのか知らんけど)
ここでおもむろにファイルを送りつけてみましょう。
Blitz.jsは標準だとbody-parser
を使っていそうな記述がドキュメントにあるので、multipartなデータは処理できないようです。
multer!出番だ!
yarn add multer
→ なんかダメ(調べてる)
詰まった、一旦手を止める
Next.jsの方でformidable
を使った方法が取られていそうなんだけど、手元で動かすとマジでうんともすんとも言わない… せめてスンならスンと言ってほしい。
OK. bodyParser
を切ったら動くようになった。 読み落としてた…
export const config = {
api: {
bodyParser: false,
},
}
export default apiRoute({
middlewares: [
async function signinRequired(req, res, next) {
const session = await getSessionContext(req, res)
if (!session.userId) res.status(401).json({ error: "signin required" })
else next()
},
],
whenPost: async function latest(req, res) {
const session = await getSessionContext(req, res)
const { fields, files } = await new Promise<{
fields: formidable.Fields
files: formidable.Files
}>((resolve, reject) => {
const form = new formidable.IncomingForm()
form.parse(req, (err, fields, files) => {
err ? reject(err) : resolve({ fields, files })
})
})
const entry = await db.entry.create({
data: {
content: fields.content as string,
user: { connect: { id: session.userId! } },
},
})
res.status(200).json(entry)
},
})
ではPrismaでファイルが扱えるかをやってみましょう。
なんか雑にファイル用のテーブルをはやしてblitz db migrate
します
// 追加
model EntryImage {
id Int @id @default(autoincrement())
entry_id Int
uid String @unique @default(uuid())
size Int
mime String
created_at DateTime @default(now())
updated_at DateTime @updatedAt
entry Entry @relation(references: [id], fields: [entry_id])
}
// フィールドが生える
model Entry {
image EntryImage?
}
で、記事作成エンドポイントでエントリに画像も入れるようにします。
リクエストボディの解釈はformidableが動くようになったのでformidableを使います。
で、古きゆかしきPHPアプリがそうであったように、今回は雑にローカルのファイルシステムにデータをビャッって置きます。 プロダクションで真似すると多分悪い子に「エイエイッ(脆弱性の秘孔を突かれて死ぬ)」しちゃうから真似しないでね
// フォームをパースする
const { fields, files } = await new Promise<{
fields: formidable.Fields
files: formidable.Files
}>((resolve, reject) => {
const form = new formidable.IncomingForm()
form.keepExtensions = true
form.parse(req, (err, fields, files) => {
err ? reject(err) : resolve({ fields, files })
})
})
// なんと`image`にデータを渡すだけでEntryImageも作ってくれる。
// トランザクションで囲ってくれてそうな気がする(クエリ見とらんからわからん
// SEE: https://www.prisma.io/docs/concepts/components/prisma-client/transactions
const entry = await db.entry.create({
data: {
content: fields.content as string,
user: { connect: { id: session.userId! } },
image: {
create: {
size: file.size,
mime: file.type,
},
},
},
// includeを指定してEntryImageも返してもらおう!
include: { image: true },
})
// 神に祈りを捧げながらファイルを移動するぞ、失敗したら泣こう!
const name = path.parse(file.path)
rename(file.path, path.join(process.env.UPLOAD_DIR!, "entry", `${entry.image?.uid}${name.ext}`))
res.status(200).json(entry)
そして投稿! うわあ、素敵なレスポンスだ!走っちゃおう!(部屋の中を走り回っちゃう音)
追記 2020/01/02 21:09
トランザクションちゃんと張ってくれてる!
じゃあ表示をします。本当はファイルのURLを組むのはサーバーサイドでやったほうがいいんですけど、これはプロダクションではないし僕は悪い子なのでクライアントサイドで組み立てます(ゲス顔)
最新記事取得APIにincludesを追記してimageも落ちてくるようにする
import { apiRoute } from "app/utils/apiRoute"
import { db } from "db"
export default apiRoute({
whenGet: async function latest(req, res) {
const entries = await db.entry.findMany({
orderBy: { created_at: "desc" },
// これな
include: { image: true },
})
res.status(200).json(entries)
},
})
しかし実はこのAPIは使っていなくて、Reactはqueriesの方から取ってたんだな〜〜〜〜これがwwwww
export default async function getLatestEntries(_ = null) {
const entries = await db.entry.findMany({
orderBy: { created_at: "desc" },
// これな
include: { image: true },
})
return entries
}
最新記事ページで雑にURLを組み立てて画像を出します
import { useLatestEntries } from "app/hooks/useLatestEntries"
import Layout from "app/layouts/Layout"
import { BlitzPage } from "blitz"
import { Suspense } from "react"
const Entries = () => {
const entries = useLatestEntries()
return (
<div>
{entries.map((entry) => (
<div style={{ borderTop: "1px solid #225", padding: "16px 0", margin: "32px 0" }}>
{entry.image && (
// 最悪
<img style={{ width: "100%" }} src={`/uploads/entry/${entry.image.uid}.png`} />
)}
<div>{entry.content}</div>
</div>
))}
</div>
)
}
const Latest: BlitzPage = () => {
return (
<Suspense fallback="Loading...">
<Entries />
</Suspense>
)
}
Latest.getLayout = (page) => <Layout title="">{page}</Layout>
export default Latest
はいGG〜〜〜〜〜〜〜〜 優勝オレ〜〜〜〜〜〜〜〜〜〜〜