Nuxt.js の serverMiddleware で API を作る

Nuxt.js
公開日:

Jamstack 構成のサイトでも、検索機能など、ユーザー入力に合わせたコンテンツを提供する場面では、大元の Headless CMS 等の API を叩いてデータを取得する必要があります。

しかし、スクリプトに API キーを含んでしまうと、エンドポイントが分かれば、誰でも元データを取得できるようになってしまいます。

それではまずいので、Vercel の Serverless Functions などを使って、自作の API を作成します。

スクリプトからは自作の API を叩き、そこから大元の API を叩くようにすれば、API キーを漏らさずに大元のデータを利用できます。

Netlify を使っている場合は、専用の netlify-lambda というツールがあるので、そちらを使ってください。

API の設定

Vercel では、ルートディレクトリに api フォルダがあると、その中の JS ファイルを API として使えるようにしてくれます。

ファイル名がそのままエンドポイントに使用され、/api/foo.js がある場合は、/api/foo がエンドポイントとなります。

ローカルでの開発時は、Nuxt.js の serverMiddleware を使うことでこの動作をシミュレートできます。

req, res でそれぞれ使えるプロパティやメソッドは、Node.js のドキュメントで確認することができます。

res.writeHead() は、ステータスコードに応じたメッセージ(Not Found や Forbidden)を自動で設定してくれるので、自分で書く必要はありません。

なんと 418 にも対応しており、res.writeHead(418) で、自動的に I'm a teapot が設定されます。

// api/foo.js
export default (req, res) => {
  // req からリクエストの情報が得られます
  console.log(req.method) // リクエストメソッド(GET, POST など)
  console.log(req.headers['x-requested-with']) // リクエストヘッダ(キーは全て小文字化される)

  // URL の解析は URL API を使うと楽です。テキトーなベース URL を指定しないとエラー
  const url = new URL(req.url, 'http://localhost')
  url.searchParams.get('q') // クエリから q 検索パラメータの値を得る(/api/foo?q=bar のとき 'bar')

  // レスポンスを返すには res を使います
  const data = { foo: 'bar' }
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
  res.end(JSON.stringify(data), 'utf8')
}

nuxt.config.js でエンドポイントと使用するファイルを指定します。

// nuxt.config.js
export default {
  serverMiddleware: [
    { path: '/api/foo', handler: '~/api/foo.js' },
  ],
}

これで、npx nuxt 実行中は /api/foo にアクセスすると、foo.js が動くようになります。

API から HTTP リクエストを飛ばす

Node.js では XMLHttpRequest や fetch API が使えませんが、node-fetch というモジュールを使うと、fetch 互換の API を Node.js で使えるようになります。

Nuxt.js を使っている場合は、既にインストールされているはずです。

これを require したものを fetch 変数に入れれば、window.fetch と同じように使えます。

// api/search.js
const fetch = require('node-fetch')

export default (req, res) => {
  // GET リクエストでなければ 405 Method Not Allowed
  if (req.method !== 'GET') {
    res.writeHead(405, { Allow'GET' }).end()
    return
  }

  // X-Requested-With ヘッダーがなければ 400 Bad Request
  if (!req.headers['x-requested-with']) {
    res.writeHead(400).end()
    return
  }

  const { searchParams } = new URL(req.url, 'http://localhost')
  const q = searchParams.get('q')

  // q 検索パラメータがなければ 400 Bad Request
  if (!q) {
    res.writeHead(400).end()
    return
  }

  const page = parseInt(searchParams.get('p'), 10) || 1
  const limit = process.env.POSTS_LIMIT
  const offset = limit * page - limit

  // リクエストする URL を生成する
  const url = new URL('path/to/endpoint', process.env.API_URL)
  url.searchParams.set('q', q)
  url.searchParams.set('offset', offset)
  url.searchParams.set('limit', limit)

  // node-fetch で API にアクセス
  fetch(url.href, { headers: { 'X-API-KEY': process.env.API_KEY }})
  .then(resp => {
    if (resp.ok) return resp.json()
    throw resp.status
  })
  .then(data => {
    res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
    res.end(JSON.stringify(data), 'utf8')
  })
  .catch(err => {
    // 2つ上で throw した resp.status っぽかったらステータスコードにする(テキトーエラーハンドリング)
    const status = (400 <= err && err < 600) ? err : 400
    res.writeHead(status).end()
  })
}

クライアントからは、X-Requested-With ヘッダーを付けて GET リクエストを飛ばします。

いちいちヘッダーを付けたり、res.ok を検証したりするのは面倒なので、プラグインにしておくと便利です。

// plugins/fetchJSON.client.js
export default ({ app }, inject) => {
  const fetchJSON = (path, query) => {
    const { href } = app.router.resolve({ path, query })
    const promise = fetch(href, {
      headers: {
        'Accept': 'application/json',
        'X-Requested-With': 'fetch',
      },
    })
    .then(res => {
      if (res.ok) return res.json()
      throw new Error(`${res.status} ${res.statusText}`)
    })

    return promise
  }

  inject('fetchJSON', fetchJSON)
}

search ページ用のテンプレートでは、これを使って自作 API を叩きます。

<!-- pages/search/index.vue -->
<script>
export default {
  data() {
    return {
      posts: [],
      loading: true,
      failed: false,
    }
  },
  computed: {
    query() {
      return this.$route.query.q || null
    },
    page() {
      return parseInt(this.$route.query.page10) || 1
    },
  },
  methods: {
    search(q, p) {
      this.loading = true
      this.failed = false

      this.$fetchJSON('/api/search', { q, p })
      .then(data => {
        this.posts = data.contents
      })
      .catch(() => {
        this.failed = true
      })
      .finally(() => {
        this.loading = false
      })
    },
  },
  mounted() {
    // ページが表示されたとき
    this.search(this.query, this.page)
  },
  beforeRouteUpdate(to, _from, next) {
    // このページに居ながら検索パラメータなどが変更されるとき
    const page = parseInt(to.query.page, 10) || 1
    this.search(to.query.q, page)
    next()
  },
}
</script>

残念ながら npx nuxt generate && npx nuxt start では動作確認ができませんが、npx nuxt で問題がなければ大丈夫でしょう。