3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Haskell・Servant+Persistent/Esqueletoで作る実用WebAPI (10) API入力値の扱い

Last updated at Posted at 2018-01-06

はじめに

本記事シリーズも10本目まで来ました。まだ基本編なので先は長いですが...

本記事では、APIインタフェースでの型定義および型変換の後半として、API入力側の扱いについて説明します。WebAPIアクセスの入力としては2通りあります。1つが全てのHTTPメソッドで使用される「URLのパス+クエリ文字列」、もう1つがPUTやPOSTメソッドで使用される「リクエストボディ」となります。今回も新しい話が多いので、わかりづらいところもあるかと思いますが、徐々に手直ししていくつもりです。

URLのパス+クエリ文字列

概要

多くのWebAPIでは、APIアクセスで指定するURLのうち「パス+クエリ文字列」部分によってAPIの動作が決まります。パスとクエリ文字列は下記の例でいう「path_1/path_2/..path_n」と「query_string」の部分です。

http://example.com:1234/path_1/path_2/..path_n?query_string

Servantでは、パスの部分で呼び出すAPIハンドラ関数を決定します。APIハンドラ関数を決めるために、Servantのランタイムが下記の例のように定義された型にもとづき、「パス」の部分がマッチするパターンを検索します。型定義にマッチするパターンがあれば、そのパターンに含まれるパラメータを引数にして、対応するAPIハンドラ関数を呼び出します。

Main.hs
type MyAppAPI' = "person" :> Capture "person_id" PersonId :> Get '[JSON] ApiPerson
            :<|> "person" :> ReqBody '[JSON] ApiPersonReqBody :> Post '[JSON] ApiPerson
            :<|> "persons" :> QueryParam "type" PersonType :> Get '[JSON] [ApiPerson]

各パターンは「:<|>」という記号で区切ります。またパスやクエリ文字列の各要素は「:>」という記号で区切ります。以下では、「:>」によって区切られる各要素について機能と使い方を説明します。ただし、要素の最後はAPIの戻り値の型(前記事にて説明)ですので、それは除きます。

パス部分

パス部分は「固定文字列」と「パラメータ」の2種類があります。

固定文字列

要素が文字列のみの場合は、その文字列そのものとマッチします。

  • 例1) type MyAppAPI' = "person" :> Get '[JSON] ApiSample
    「/person」にマッチします。対応するAPIハンドラ関数は引数を持たない関数とします。
-- 引数なしの関数
sampleHandler :: MyAppHandler ApiSample
sampleHandler = errorHandler $ runSql $ do
 -- (以下略)
  • 例2) type MyAppAPI' = "person" :> "list"
    「/person/list」にマッチします。対応するAPIハンドラ関数は上と同様、引数を持ちません。

※注意:type MyAppAPI' = "person/list"としても、これは「/person/list」にはマッチしません。これはURLのパースの際に区切り文字(スラッシュ)でURLを分解した後にパターンマッチさせるであろうことを考えると自然でしょう。

パラメータ

要素が「Capture 文字列 型」となっている場合、URLで該当する部分をパラメータとして取り込み、指定した型としてAPIハンドラ関数に渡します。Captureの次の「文字列」はドキュメンテーション用です。API動作には影響しません。

  • 例3) type MyAppAPI' = "person" :> Capture "number" Int
    「/person/整数」にマッチし、それをInt型として扱います。対応するAPIハンドラ関数はInt引数を1つもつ関数とします。
sampleHandler :: Int -> MyAppHandler ApiSample
sampleHandler number = errorHandler $ runSql $ do
 -- (以下略)
  • 例4) type MyAppAPI' = "person" :> Capture "person_id" PersonId
    「/person/整数」にマッチし、それをPersonId型として扱います。対応するAPIハンドラ関数はPersonId引数を1つもつ関数とします。
sampleHandler :: PersonId -> MyAppHandler ApiSample
sampleHandler person_id = errorHandler $ runSql $ do
 -- (以下略)

この例のようなURLを処理するURLを定義する際、Servantがよくわかってなかった頃は「RDBのIDはInt型なので、それをURLで受け取って、APIハンドラでInt型をtoSqlKeyを使ってPersonId型に変換して...」と考えていました。実は、APIハンドラでPersonId型を使いたければ、URL定義で直接PersonId型を指定できます。toSqlKeyでPersonId型を作ったりする必要はありません。つまり、APIハンドラにやってきた時点で必要な型変換は済んでいます。そんなわけで、URLでRDBのIDを指定してもらう場合には、モデルで定義して作成されるID型を指定しましょう。

クエリ文字列部分

クエリ文字列の要素は3種類です。APIハンドラにパラメータを引数を渡しつつクエリパラメータは引数とは別にアクセスするフレームワークもありますが、Servantではパラメータと同様、クエリパラメータもAPIハンドラ関数の引数として渡ります。

QueryFlag

クエリパラメータで値なしのパラメータが存在するかどうかを検証し、その結果をAPIハンドラの引数にBool型で渡します。存在すればTrue、しなければFalseとなります。

  • 例5) type MyAppAPI' = "person" :> Capture "person_id" PersonId :> QueryFlag "flag"
    「/person/整数」にマッチし、それをPersonId型として扱います。対応するAPIハンドラ関数は「PersonId」と「クエリパラメータに"flag"があるかどうかをBool型で」の2つの引数もつ関数とします。
sampleHandler :: PersonId -> Bool -> MyAppHandler ApiSample
sampleHandler person_id flag = errorHandler $ runSql $ do
 -- (以下略)

この例のように、URL型定義で登場するパラメータやクエリパラメータの順番と、APIハンドラ関数の引数の順番は同じである必要があります。これは以下の全ての例でも同じです。APIアクセスのURLと引数(flag)の値は下記の通りになります。

URL=/person/15 → flag=False
URL=/person/15?flag → flag=True
URL=/person/15?flag=abc → flag=False(値があると「ない」とみなされる)

クエリパラメータの名前が一致していても、値がある場合(flag=abc、など)には、そのQueryFlagは存在しないもの、となります。

QueryParam

クエリパラメータで値ありのパラメータが存在するかどうかを検証し、その結果をAPIハンドラの引数に「Maybe 指定した型」で渡します。パラメータが存在すれば「Just 値」となり、なければ「Nothing」となります。

  • 例6) type MyAppAPI' = "person" :> Capture "person_id" PersonId :> QueryParam "param" Text
    「/person/整数」にマッチし、それをPersonId型として扱います。対応するAPIハンドラ関数は「PersonId」と「クエリパラメータに"param=value"があるかどうかをMaybe Text型で」の2つの引数もつ関数とします。パラメータに対応するAPIハンドラ関数での引数の型は、URL定義で指定した型(例ではText型)ではなく、Maybeが付く(例ではMaybe Text型)ことに注意です。これはパラメータは存在するかもしれないし、しないかもしれないからです。
sampleHandler :: PersonId -> Maybe Text -> MyAppHandler ApiSample
sampleHandler person_id param = errorHandler $ runSql $ do
 -- (以下略)

APIアクセスのURLと引数(param)の値は下記の通りになります。

URL=/person/15 → param=Nothing
URL=/person/15?param → param=Nothing(値がないと「ない」とみなされる)
URL=/person/15?param=abc → param=Just "abc"
URL=/person/15?param=abc&param=def → param=Just "abc"(複数ある場合は最初の値が有効)

QueryParamでは、QueryFlagとは逆に、パラメータキーが一致していても、値がない場合には、そのQueryParamは存在しないもの、となります。URL中に同じパラメータが複数ある場合は、最初の値が採用されます。もし複数の値を全て取得したい場合は「QueryParams」を使います。

QueryParams

クエリパラメータで値ありのパラメータが存在するかどうかを検証し、その結果をAPIハンドラの引数に「指定した型、のリスト」で渡します。同じパラメータキーが複数ある場合でも、全ての値を取得できます。該当するパラメータが存在しなければ空リストになります。QueryParamの時と同様、APIハンドラ関数の引数の型は、URL定義の型(例ではText型)ではなく、その型のリスト型(例では[Text]型)である必要があります。

  • 例7) type MyAppAPI' = "person" :> Capture "person_id" PersonId :> QueryParams "param" Text
    「/person/整数」にマッチし、それをPersonId型として扱います。対応するAPIハンドラ関数は「PersonId」と「クエリパラメータに"param=value"を[Text]型で」の2つの引数もつ関数とします。
sampleHandler :: PersonId -> [Text] -> MyAppHandler ApiSample
sampleHandler person_id param = errorHandler $ runSql $ do
 -- (以下略)

APIアクセスのURLとflagの値は下記の通りになります。

URL=/person/15 → param=[]
URL=/person/15?param → param=[](値がないと「ない」とみなされる)
URL=/person/15?param=abc → param=["abc"]
URL=/person/15?param=abc&param=def → param=["abc","def"]

QueryParamsでもQueryParamと同様に、パラメータキーが一致していても、値がない場合には、そのQueryParamsは存在しないもの、となります。

列挙型の型を使えるようにする

パラメータの説明で、PersonIdといった型がそのまま使えることを書きました。それであれば列挙型として定義した「PersonType型」などもURL型定義で使いたくなります。例えば、

type MyAppAPI' = "persons" :> QueryParam "type" PersonType :> Get '[JSON] [ApiPerson]

といった具合いです。これはクエリパラメータに検索条件としてPersonTypeを指定して、それがあればその条件でPersonの一覧を返すAPIです。ところが、列挙型は型定義をしただけではURL型定義には使えません。使用できるようにするためには、定義した列挙型を「FromHttpApiDataクラス」のインスタンスにします。実装は下記のように機械的でOKです。

Types.hs
data PersonType = PersonTypeUser | PersonTypeAdmin
    deriving (Show, Read, Eq, Ord, Enum, Bounded)
-- FromHttpApiDataクラスのインスタンスにする
instance FromHttpApiData PersonType where
    parseQueryParam = maybe (Left "HttpApiData parse error") Right . readMaybe . unpack

定義を共通化する

WebAPIの機能が増えてくると、複数のURLのパターンで同じようなパラメータやクエリパラメータを使いまわすことになります。例えば、全てのパターンで「QueryParam "param" Text」と書くよりは、

type QParam = QueryParam "param" Text

と定義してして、下記のように

type MyAppAPI' = "person" :> QParam :> Get '[JSON] ApiPerson
            :<|> "persons" :> QParam :> Get '[JSON] [ApiPerson]

とした方が保守性が上がります。

ただし、こういった定義には制約もあって、複数のクエリパラメータの組を定義して

-- 2つのクエリパラメータを定義
type QParam2 = QueryParam "foo" Text :> QueryParam "bar" Text

これをURL型定義に使いたくなりますが、

-- ビルドエラーとなる
type MyAppAPI' = "person" :> QParam2 :> Get '[JSON] ApiPerson
            :<|> "persons" :> QParam2 :> Get '[JSON] [ApiPerson]

残念ながら、これはビルドエラーとなります。ビルドエラーにならないような方法は無くなはないのですが、型定義がかなり複雑になるため、デメリットも大きいかと思います。

リクエストボディ

URL定義とAPIハンドラ関数の引数

APIハンドラでリクエストボディを受け取るためには、クエリパラメータの時と同様、URL定義に記述します。要素の部分を「ReqBody 形式 型」とすると、指定した形式(JSONなど)、型のリクエストボディをパースしてAPIハンドラ関数に渡します。

type MyAppAPI' = "person" :> Capture "person_id" PersonId :> ReqBody '[JSON] ApiPersonReqBody :> Put '[JSON] ApiPerson

この例では、Capture(型はPersonId)、ReqBody(型はApiPersonReqBody)の順に並んでいますので、APIハンドラ関数の引数もそれに対応した引数にする必要があります。

sampleHandler :: PersonId -> ApiPersonReqBody -> MyAppHandler ApiSample
sampleHandler person_id reqbody = errorHandler $ runSql $ do
 -- (以下略)

基本は以上となるのですが、ではApiPersonReqBodyはどういう型であるべきでしょうか?

リクエストボディ用の型を考える

リクエストボディはPOSTとPUTの2つのメソッドで使用されます。POSTは新規データ作成(CRUDでいえばCreate)、PUTはデータ変更(CRUDでいえばUpdate)に相当します。POSTメソッドで新規にデータを作成する場合、APIの戻り値の型(例だとApiPerson)の要素(例だとname, age, type)を指定するので、ApiPersonと同じ型でいけそうですが、POSTの場合「id」がまだない状態ですので、そのままでは使えません。

ApiTypes.hs
-- ApiPersonの型定義(再掲)
data ApiPerson = ApiPerson
  { apiPersonId   :: PersonId -- POST時はidが存在しない
  , apiPersonName :: Text
  , apiPersonAge  :: Maybe Int
  , apiPersonPersonType :: PersonType
  } deriving (Generic, Show)

では、PUTメソッドではどうなるでしょうか?PUTでは「指定した要素だけ変更したい」という要求があると思います。例えば「nameはTomからFredに変えるが、他の要素は変更しない」などです。そうなると、全てのメンバを「Maybe」でくくり、変更するメンバはJustの値、変更しないメンバは「Nothing」としておきたいところです。

以上をふまえると、前記事で「Personと似ているが別の型としてApiPersonを定義した」のと同様に、「PUTとPOSTで使用する別の型ApiPersonReqBodyを定義する」のがよいかと思います。POST時は各メンバをそれぞれオプションとして使えますし、PUT時にはidが必要ですが、それはURLのパラメータとして指定できます。結局、ApiPersonReqBodyは基本的に「ApiPersonからidを削除し、全てMaybeで包む」というアプローチになります。メンバには全てプレフィックス「apiPersonReqBody」をつけています。

ApiTypes.hs
data ApiPersonReqBody = ApiPersonReqBody
  { apiPersonReqBodyName :: Maybe Text
  , apiPersonReqBodyAge  :: Maybe (Maybe Int)
  , apiPersonReqBodyPersonType :: Maybe PersonType
  } deriving (Generic, Show)

JSONパース対応

リクエストボディとしてJSON形式のデータをApiPersonReqBodyで扱うためには、JSON形式のパースに対応する必要があります。そのために、ApiPersonReqBodyをFromJSONクラスのインスタンスにします。ToJSONクラスの場合とは違い、FromJSONクラスのインスタンスにするためには、2つのやり方を使い分けることになります。この話題はServantというよりは、HaskellでのJSON処理のデファクトスタンダードであるAesonモジュールの話です(ServantはAesonで提供されるインタフェースを前提としています)。

TemplateHaskellを使う

FromJSONクラスへのインスタンス化をほぼ全自動でやってくれる手法です。楽な方法ですが、この手法が使える条件があります。

やり方は、前回の記事で使った「deriveToJSON」の逆の操作をします。fieldLabelModifierはdriveToJSONの時と同じです。メンバ関数名からこのフィルタを通したものをJSONキー名として扱います。今回もDeriveGeneric拡張とTemplateHaskell拡張が必要ですので、ソースの行頭に入れておきましょう。

ApiTypes.hs
-- 必要なGHC拡張
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE TemplateHaskell #-}

deriveFromJSON defaultOptions {fieldLabelModifier = snakeKey 16} ''ApiPersonReqBody

この方法が使える条件としては「Maybe (Maybe a)型がない」となります。Maybe (Maybe a)型の意図としては、前記事で書いた話と同じで

  • JSONキーがなければ「Nothing」にする
  • JSONキーがあり、値がnullなら「Just Nothing」にする
  • JSONキーがあり、値がnull以外なら「Just (Just 値)」にする

となって欲しいところです。ところがdriveFromJSONとした場合には、この処理をやってくれません。次に書く方法を使う必要があります。

parseJSONメソッドを実装する

※ 2018/11/10修正 [Haskell-jp2018にて「Aesonで用意されている演算子でMaybe (Maybe a)への対応ができる」という情報をいただきましたので、こちらでも確認した上で記事を修正しました]

全自動でMaybe (Maybe a)の対応をしてくれないとなると、手動で対応するしかありません。下記のようにparseJSONメソッドを実装してあげれば、ApiPersonReqBody型がFromJSONクラスのインスタンスになります。Maybe (Maybe a)への対応もOKです。

ApiTypes.hs
-- 必要なGHC拡張
{-# LANGUAGE RecordWildCards   #-}

instance FromJSON ApiPersonReqBody where
  parseJSON = withObject "apipersonreqbody" $ \o -> do
    apiPersonReqBodyName <- o .:? "name" -- Maybe a型なので「.:?」を使う
    apiPersonReqBodyAge <- o .:! "age" -- Maybe (Maybe a)型の場合は、逆に「.:!」を使う
    apiPersonReqBodyType <- o .:? "type"
    return ApiPersonReqBody{..}

注意点がいくつかあります。

  • 上記の実装の最後の行で「ApiPersonReqBody{..}」と、ApiPersonReqBodyのメンバを改めて書かなくても済むように、{-# LANGUAGE RecordWildCards #-}が必要です
  • 各メンバの定義で使用されている.:?はMaybe型のデータを処理します。これはAesonの標準に含まれる演算子です
  • 上述のMaybe (Maybe a)型を受け取るためには、逆に.:!を使用します。.:?を使ってしまうと、コンパイルは通るのですが、nullが来たときに「Just Nothing」ではなく「Nothing」を受け取ってしまい、意図した動作となりません。リクエストボディのメンバの型と演算子の関係をまとめると下記のようになります
メンバの型 用途 演算子
a メンバは必ず存在する。null不可 .:!
Maybe a メンバは省略可。null不可 .:?
Maybe a メンバは必ず存在する。null可 .:?
Maybe (Maybe a) メンバは省略可。null可 .:!
  • 各メンバの型は「FromJSONクラスのインスタンス」である必要があります。ToJSONクラスの時と同様、TextやInt等の基本的な型はFromJSONインスタンスとなっています。自分で作成した列挙型の型については、定義に併せて「deriveFromJSON」しておきましょう。自分で作成した型を入れ子にする場合についても同様にderiveFromJSONしておけば、その型もメンバにできます。
data PersonType = PersonTypeUser | PersonTypeAdmin
    deriving (Show, Read, Eq, Ord, Enum, Bounded, Generic)
-- FromJSONクラスのインスタンスにする
deriveFromJSON defaultOptions ''PersonType

APIハンドラでのリクエストボディの使い方

このように定義したリクエストボディの使い方として、ここではPOSTメソッドでの例を説明します。PUTメソッドでは、PersistentというよりEsqueletoとの組み合わせになるため、PUTメソッドについては、Esqueletoの解説記事の回で説明します。

Handler1.hs
postPerson :: ApiPersonReqBody -> MyAppHandler ApiPerson
postPerson ApiPersonReqBody {apiPersonReqBodyName = m_name, apiPersonReqBodyAge = m_age, apiPersonReqBodyPersonType = m_ptype} = errorHandler $ runSql $ do

  -- JSONにキー、値なければAPIエラー、とする例
  name <- fromJustWithError (err400, "No parameter: name") m_name
  age <- fromJustWithError (err400, "No parameter: age") m_age
  -- JSONにキー、値がなければ、デフォルト値を使う、とする例
  ptype <- fromMaybe PersonTypeUSer m_ptype

  -- データをCreate
  pid <- insert Person {personName = name, personAge = age, personType = ptype}

  -- 最後に作成したデータを取得し直して、戻り値とする
  getPerson' pid

APIハンドラで受け取ったリクエストボディは各メンバにばらけさせますので、引数のところでコンストラクタを使って、各要素を取得してしまいます。各要素は、値がJustであれば(=JSONのキー、値があれば)その中身を取り出します。Nothingであれば(=JSONのキー、値がなければ)APIエラーとする場合もあるでしょうし、デフォルト値を使う場合もあるでしょう(上記のサンプルコードは両方の例を書いています)。

このようにして、POSTメソッドのリクエストボディについて、中身の精査と対処について実装が可能となります。

まとめ

WebAPIの入力として、URL文字列(のうちのパスとクエリ文字列)やリクエストボディに含まれる情報について、「キー」あるいは「キー+値」の有無、および存在する時にはその値をAPIハンドラでどうやって受け取れるかについて説明しました。ここに書いた実装を一通り使えば、WebAPIとして必要なる各種処理・判定ができるかと思います。

次回以降はEsqueletoによるRDBアクセスについて詳しく見ていきます。

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?