2016年12月6日火曜日

[haskell][yesod] TypedContentを利用してクライアントが要求するフォーマットでレスポンスを返す

Yesod Advent Calendar 2016の6日目の記事です。

RESTfulなAPIを提供する場合、クライアントの都合にあわせて、フォーマットを変えてレスポンスを返したいケースがあります。サーバー上で管理しているDBから、表現だけをHTML, JSON, XML, CSVなどに変更して返すイメージです。例えば、人物情報(名前、年齢、性別など)の一覧を返す際には以下のようなデータが返されることになります。
  • HTML
  • <table border>
      <tr>
        <th>name</th>    <th>sex</th>    <th>age</th>
      </tr>
      <tr>
        <td>Taro Yamada</td>    <td>Male</td>    <td>18</td>
      </tr>
      <tr>
        <td>Hanako Yamada</td>    <td>Female</td>    <td>25</td>
      </tr>
      <tr>
        <td>Ichiro Suzuki</td>    <td>Male</td>    <td>43</td>
      </tr>
    </table>
    
  • JSON
  •  [
      {"name":"Taro Yamada", "sex":"Male", "age":18},
      {"name":"Hanako Yamada", "sex":"Female", "age":25},
      {"name":"Ichiro Suzuki", "sex":"Male", "age":43}
     ]
    
  • CSV
  •  Taro Yamada,Male,18
     Hanako Yamada,Female,25
     Ichiro Suzuki,Male,43
    

通常はフォーマット毎に別のURLを割り当てますが、Yesodでは1つのURLで複数フォーマットのレスポンスを返す実装が簡単にできます。
このエントリでは、http://localhost:3000/に対応する一つのハンドラの中でTypedContentを利用して複数のフォーマットを扱う方法を紹介します。クライアントが送信するリクエストに記載されるAcceptヘッダによって、サーバーの挙動を変えます。


準備:

まず、素のYesodのscaffolding siteを起動できる環境を作ります。stackが利用できる環境を前提にしています。
  1. yesod-simpleを指定して空の"typedcontent"プロジェクトを生成
  2. % stack new typedcontent yesod-simple
    
    yesod-simpleは最も単純なyesodテンプレートでDB関連ライブラリへの依存がありません。
  3. 生成したプロジェクトディレクトリに移動し、依存ツールをビルド
  4. % cd typedcontent
    % stack build yesod-bin cabal-install
  5. 生成したtypedcontentプロジェクトをビルド
  6. % stack build
  7. develサーバーを起動
  8. % stack exec -- yesod devel
    ブラウザからhttp://localhost:3000/を開いてdevelサーバーにアクセスできることを確認してください。


基礎1:Home.hsを修正してHTMLを返すシンプルなハンドラを作ってみる

  1. Handler/Home.hsに人物情報データ定義を追加
  2. yesod-simpleテンプレートではDBを利用しないプロジェクトが生成されます。プロジェクト内のHandlerディレクトリにHome.hsとCommon.hsが存在しているので、Home.hsをエディタで開いて、以下のコードを追加してください。
    -- サーバー上で管理するデータ定義。
    data Sex = Male | Female
        deriving (Show)
    data Person = Person
        { name :: Text
        , age  :: Int
        , sex  :: Sex
        }
        deriving (Show)
    
    -- サーバー上のサンプルデータ。3人分の情報を保持。
    samplePersonList :: [Person]
    samplePersonList = [ (Person "Taro Yamada" 18 Male)
                       , (Person "Hanako Yamada" 25 Female)
                       , (Person "Ichiro Suzuki" 41 Male) ]
    
    
  3. Handler/Home.hsのgetHomeRの定義を更新
  4. 既存のgetHomeRの実装を削除し、toTableHtmlで置き換えて下さい。
    -- HTML tableフォーマットでレスポンスを生成。
    toTableHtml :: Handler Html
    toTableHtml = withUrlRenderer [hamlet|
                 <table border>
                     <tr>                                                         
                       <th>name                                                   
                       <th>age                                                    
                       <th>sex                                                    
                   $forall person <- samplePersonList                             
                     <tr>                                                         
                       <td>#{name person}                                         
                       <td>#{age person}                                          
                       <td>#{show $ sex person}                            
                 |]
    
    getHomeR :: Handler Html
    getHomeR = toTableHtml
    
    
  5. 動作確認
  6. ブラウザでhttp://localhost:3000/にアクセスし、以下のようなテーブルが表示されていればOKです。

    curlコマンドを実行すると実際にサーバーから返されているHTMLフォーマットデータを確認することができます。
    % curl http://localhost:3000/
    


基礎2:クライアント要求に応じてHTMLとplain textのどちらかを返す

いよいよ、クライアントからの要求に応じて異なるフォーマットを返すよう、getHomeRを改変します。ここでは"text/html"が要求されている場合にはHTMLを、"text/plain"が要求されている場合にはshow関数の実行結果を返すようにします。
  1. getHomeRの型をHandler HtmlからHandler TypedContentに変更し、"text/html"と"text/plain"に対応する
  2. getHomeRの実装をselectRep/provideRepを用いて以下のように変更します。
    getHomeR :: Handler TypedContent
    getHomeR = selectRep $ do
        provideRep $ toTableHtml
        provideRep $ return $ repPlain $ show samplePersonList
    
    selectRepはdoブロックの中でprovidRepによって提供される複数のフォーマットから、クライアントの要求に適合するものを選択します。provideRepの引数で渡されているHtml, RepPlainはHasContentTypeのインスタンスであり、hasContentTypeが実装されています。この関数によりmime typeが比較され適切なContentが選択されます。マッチするものがない場合にはクライアントには406 Not Acceptableが返されます。
  3. 動作確認
  4. curlコマンドで-HでAcceptヘッダを指定することで、HTMLとplain textの2種類の結果が得られることが確認できます。
    % curl -H "Accept: text/plain" http://localhost:3000/
    [Person {name = "Taro Yamada", age = 18, sex = Male},Person {name = "Hanako Yamada", age = 25, sex = Female},Person {name = "Ichiro Suzuki", age = 41, sex = Male}]
    % curl -H "Accept: text/html" http://localhost:3000/
    <table border><tr><th>name</th>
    <th>age</th>
    <th>sex</th>
    ...
    
    Yesodにはquery string parameterからAcceptヘッダを自動生成する便利機能が実装されています。 この仕組みを利用することで、Acceptヘッダを入力できないブラウザ上でも(URL入力だけで)動作を確認できます。
    % curl http://localhost:3000/?_accept=text/plain
    [Person {name = "Taro Yamada", age = 18, sex = Male},Person {name = "Hanako Yamada", age = 25, sex = Female},Person {name = "Ichiro Suzuki", age = 41, sex = Male}]
    
  5. provideRepをprovideRepTypeに置き換えてみる
  6. "text/plain"をprovideRepTypeを用いて実装すると以下のようになります。
    getHomeR :: Handler TypedContent
    getHomeR = selectRep $ do
        provideRep $ toTableHtml
    --    provideRep $ return $ repPlain $ show samplePersonList
        provideRepType "text/plain" (return $ show samplePersonList)
    
    provideRepTypeを利用することで、HasContentTypeのインスタンスを持っていないフォーマットを返すことができます。


応用:JSON, CSVフォーマットをサポートする

ここまでの手順を応用して、CSV及びJSONフォーマットを返すようにgetHomeRを拡張します。
  1. provideRepTypeを利用してCSVフォーマットを返す実装を追加
  2. -- CSVフォーマットのレスポンスを生成する。
    class ToCSV a where
      toCsv :: a -> Text
    instance ToCSV Person where
      toCsv p = (name p) 
               ++ ("," :: Text) 
               ++ (pack $ show $ age p) 
               ++ ("," :: Text) 
               ++ (pack $ show $ sex p) 
               ++ ("\n" :: Text)
    instance (ToCSV a) => ToCSV [a] where
      toCsv [] = ""
      toCsv (x:xs) = (toCsv x) ++ (toCsv xs)
    
    getHomeR :: Handler TypedContent
    getHomeR = selectRep $ do
        provideRep $ toTableHtml
        provideRep $ return $ repPlain $ show samplePersonList
        provideRepType "text/csv" (return $ toCsv samplePersonList) -- 追加!
    
    
  3. 動作確認
  4. % curl -H "Accept: text/csv" http://localhost:3000/ 
    Taro Yamada,18,Male
    Hanako Yamada,25,Female
    Ichiro Suzuki,41,Male
    
    
  5. ついでにJSONもサポート
  6. {-# LANGUAGE DeriveGeneric #-}
    
    ...
    -- toJSONを自動導出。DeriveGeneric言語拡張が必要。
    instance ToJSON Sex 
    instance ToJSON Person 
    
    getHomeR :: Handler TypedContent
    getHomeR = selectRep $ do
        provideRep $ toTableHtml
        provideRep $ return $ repPlain $ show samplePersonList
        provideRepType "text/csv" (return $ toCsv samplePersonList) 
        provideJson $ samplePersonList -- 追加!
    
    
  7. 動作確認
  8. % curl -H "Accept: application/json" http://localhost:3000/ 
    [{"age":18,"name":"Taro Yamada","sex":"Male"},{"age":25,"name":"Hanako Yamada","sex":"Female"},{"age":41,"name":"Ichiro Suzuki","sex":"Male"}]
    


まとめ:

TypedContentを利用して一つのURLからHTML, PlainText, CSV, JSONフォーマットのデータを返す方法を説明しました。ハンドラを実装する上で、selectRep/provideRep/provideRepTypeをどう使えばよいか、YesodフレームワークがContentTypeの判定にHasContentTypeを用いている、といったキモになる情報をまとめています。
このエントリで利用したコードは以下のgithubリポジトリにコミットしてあるので、参考にしてください。
https://github.com/kurokawh/work_haskell/tree/master/yesodweb/typedcontent


参考:

0 件のコメント:

コメントを投稿