Closed21

👌 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 を建てる
まずは適当にモック

app/api/entries/latest.ts
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という適当なヘルパー関数を作る
app/api/entries/latest.ts
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)
  },
})
app/utils/apiRoute.ts
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を建てる

app/api/entries/new.ts
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にできるじゃろ

app/api/entries/new.ts
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を切ったら動くようになった。 読み落としてた…

app/api/entries/new.ts
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します

db/schema.prisma
// 追加
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アプリがそうであったように、今回は雑にローカルのファイルシステムにデータをビャッって置きます。 プロダクションで真似すると多分悪い子に「エイエイッ(脆弱性の秘孔を突かれて死ぬ)」しちゃうから真似しないでね

app/api/entries/new.ts
// フォームをパースする
    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も落ちてくるようにする

app/api/entries/latest.ts
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

app/domains/entry/queries/getLatestEntries.ts
export default async function getLatestEntries(_ = null) {
  const entries = await db.entry.findMany({
    orderBy: { created_at: "desc" },
    // これな
    include: { image: true },
  })
  return entries
}

最新記事ページで雑にURLを組み立てて画像を出します

app/pages/latest.tsx
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〜〜〜〜〜〜〜〜 優勝オレ〜〜〜〜〜〜〜〜〜〜〜

このスクラップは2021/01/02にクローズされました
ログインするとコメントできます