frourioのAPI開発が快適すぎました

20872 ワード

Typescriptのフルスタックフレームワークであるfrourioを使用して、趣味で個人CMSを作ってみました。
型定義やAPI開発が大変はかどったので、備忘録の意味も含め記事に残しておこうと思います。

CMSってどんなやつ作ったの?

今回作成したのは、Webサイト構築用のCMSです。
コンポーネントをJson形式で保存し、そのデータを元にWebページのレンダリングを行います。
モチベーションのほとんどは何か作りたい欲で、自分でツールを作りたい!というのが大きなところを占めています笑
主な機能は以下の通りです。

・記事作成
・コンテンツ作成
・予約投稿
・OAuthでのログイン

型安全なAPI

一番最初はフロントエンドをNextjs、バックエンドをDjangoで作成していました。
初学者ゆえ勉強も兼ねていたので、少しだけ触ったことのあるフレームワークを選定したのが大きな理由です。
しかし、API周りの型定義で開発が苦しくなっていきました。

以下はフロントから投稿を取得する一例です。
useSWRにて取得データへ型付けを行なっていますが、取得データの検証を行うことはできません。

export const usePost = (id: string) => {
    const proxy = '/api/proxy';
    const prefix = '/__admin/post';
    const url = `${proxy}${prefix}?id=${id}`
    const { data, mutate, error } = useSWR<Post>(url, fetcher)
    return {
        post: data,
        mutate: mutate,
        isLoading: !error && !data,
        isError: error
    }
}

フォーマット用の関数を作成することで、より型安全に使用することはできます。
しかし、APIの仕様や投稿の型が変わるたびに修正する必要が出てくるため、少し面倒だなあと思っていました。

export const usePost = (id: string) => {
    const proxy = '/api/proxy';
    const prefix = '/__admin/post';
    const url = `${proxy}${prefix}?id=${id}`
    const { data, mutate, error } = useSWR<Post>(url, fetcher)
    return {
        post: formatPost(data),
        mutate: mutate,
        isLoading: !error && !data,
        isError: error
    }
}

const formatPost = (data: any): Post => {
    if (!data) {
       return null
    }
    
    return {
       id: data.id ?? '',
       title: data.title ?? '',
       slug: data.slug ?? '',
       elements: data.elements ?? ''
    }
}

frourioに組み込まれているaspidaはこういった型問題を簡単にすることができます。
リクエストを送るクライアントはエンドポイントを文字列ではなく、プロパティで指定をしてリクエストを送ることが可能です
プロパティを指定することで、文字列によるエンドポイント指定ではできなかった型精査を行うことができるようになります。

const id = 'abc'

// dataの型は Any となる
const {body: data} = await fetch (`http://localhost:8000/api/post?id=${id}`)
   .then(r => r.json());

// dataの型は Post となる
const {body: data} = apiClient.api.post({ query: { id } })

サーバー側はエンドポイントをディレクトリ・ファイルを用いて直感的に定義することが可能です。
server/api配下にディレクトリを作成することで、そのディレクトリのルートをエンドポイントとすることができます。
また、ディレクトリ作成時に自動的にエンドポイントの型ファイルindex.tsとコントローラーファイルcontroller.tsが作成されます。

// index.ts

export type Methods = {
  get: {
    query: {
      id: Post['id']
    }
    resBody: Post | null
  }
  put: {
    reqBody: Post
    resBody: APIResult
  }
  delete: {
    reqBody: Post['id']
    resBody: APIResult
  }
}
// controller.ts

export default defineController(() => ({
  get: async ({ query }) => ({ status: 200, body: await getPost(query.id) }),
  put: async ({ body }) => {
    const isValid = await isValidPost(body)

    if (isValid.status === 'invalid') {
      return {
        status: 200,
        body: { status: 'failed', exception: isValid.exception }
      }
    }

    await updatePost(body.id, body)
    return { status: 200, body: { status: 'success' } }
  },
  delete: async ({ body }) => {
    await deletePost(body)
    return { status: 200, body: { status: 'success' } }
  }
}))

controller.tsのそれぞれのメソッドの返り値は、index.tsで定義したresBodyに沿っていないと型エラーとなります。
これにより、サーバー側は型安全なapiを定義しやすくなり、またそれを利用するクライアント側は安心してリクエストを送ることができます。

サーバーとクライアントで型共有

frourioはORMとして、prismaを採用しています。
prismaにてモデルを定義することで、そのモデルの型を自動的に定義することが可能です。
また、定義された型はサーバー・クライアント両側で使用でき、DB定義を正として開発を進められるため、「DBのモデル変えて、サーバー・クライアントそれぞれ型ファイル書き換えて。。。」という面倒なことをしなくて良くなりました。

datasource db {
  provider = "postgresql"
  url      = env("API_DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model Post {
  id String @id @default(cuid())
  title String?
  slug String
  publish Boolean @default(false)
}
/**
 * Model Post
 * 
 */
export type Post = {
  id: string
  title: string | null
  slug: string
  publish: boolean
}

終わりに

今回は簡単ですが、frourioを使って便利だなあと思った点を書いてみました。
特にaspidaを初めて使用した時は、あまりの便利さにびっくりしました。。