fukuoka.ex/kokura.exのpiacereです
ご覧いただいて、ありがとうございます
前作でPhoenix軽量APIによるREST APIが完成しましたが、「エラー処理」「コード最適化」「全体バランス取り」「Mnesiaアクセッサの不足機能補充」等の対応等が残っていますので、これらのうち、幾つかを対応します
残対応のうち、今回、対応する対象
残対応は、下記4カテゴリで残っていますが、これらのうち、赤字にしている「エラー処理」の全部と「コード最適化」の半分を対応していきます
■エラー処理 ※JSON内でラクに書く?
- 【今回】show.json.eex/update.json.eex/delete.json.eexのデータ未存在時の404エラー
- 【今回】サーバエラー時の500エラー
■コード最適化
- 【今回】JSONテンプレート評価の共通関数化(ApiView.render()とApiController.execute()が同じ処理)
- 【今回】ApiControllerの各関数で、パス処理が同じなので、その共通関数化
- index.json.eex/show.json.eexやApiController.execute()のフルパス指定を無くしたい
- JSONテンプレート内のJSON評価の共通関数化(ApiController.execute()と重複している)
- :mnesia.start~:mnesia.wait_for_tables()の共通関数化
- DbMnesiaの返却の共通関数化
■全体バランス取りなど
- どこまでをAPIコントローラ側に置き、どこまでをJSONテンプレートに置くか?
- 【済】Mnesiaの独自実装では無く、Ecto Queryでロジックを組んだときの配置は大丈夫か?
- APIでは無く、WebアプリやLiveViewでも、JSONテンプレートを応用できないか?
■Mnesiaアクセッサの不足機能補充 ※どこまでマジメにやる?
- index.json.eex/show.json.eexのselect列指定対応
- insert.json.eexのSQLでのinsert列指定対応
- update.json.eexのSQLでのupdate対象列順変動/全列指定無し対応
- whereの「=」以外対応
- id以外のwhere対応(Mnesiaでそもそも実現できるかしら?)
本コラムの検証環境
本コラムは、以下環境で検証しています(Windowsで実施していますが、Linuxやmacでも動作する想定です)
- Windows 10
- Elixir 1.10.1 ※最新版のインストール手順はコチラ
- Phoenix 1.4.15 ※最新版のインストール手順はコチラ
- Node.js 12.14.0
あと下記コラムシリーズの続きとして実施するので、未実施であれば、事前に実施しておいてください(近々、ボイラープレート生成のmixコマンドとしてOSS化は考えています)
PHP的ハックを応用してNode.js Express/Go的な軽量APIをPhoenixで実現してみた
|> Express/Go的なPhoenix軽量APIの上に参照系REST APIを実装する
|> 軽量APIにて実装した一覧REST APIにDBを接続する
|> DB取得+軽量APIで1件参照REST APIを実装する(TDD付)
|> DB追加/削除+軽量APIで更新系REST API(暫定版)を実装する(TDD付)
|> 軽量APIの追加・削除REST APIを完成させる
それと、Phoenix軽量APIを使ったサンプルも下記2本、作っていますので、実際の利用イメージを得たい方はご覧ください
「BFF的APIを40秒で支度しな」:Phoenix軽量APIでScaffoldした2テーブルをJOIN
|> 「BFFを40秒で支度しな」:複数APIを呼び出して自前JOIN(Inner JOIN→Outer JOINのオマケ付き)
■コード最適化
以降、記載は省略していますが、変更を行った際は、都度、動作確認を行っておいてください
また、doctestは記述しますが、TDD過程も省略します
JSONテンプレート評価の共通関数化
JSONテンプレートの評価/実行を行っている関数が、ApiView.render()と、ApiController.execute()に分かれているため、APIコントローラ内では、ApiController.execute()だけを使うように変更します
これに伴い、全てのレンダリング(≒JSON返却)を、render()から、send_resp()に差し替え(put_resp_header()での「content-type: application/json; charset=utf-8」付与もセットで)、更に、共通関数化します
defmodule BasicWeb.ApiController do
use BasicWeb, :controller
def index( conn, params ) do
id = params[ "path_" ] |> List.last |> Type.to_number
{ new_params, template, path } =
if id == nil do
{
params,
"index.json",
params[ "path_" ]
}
else
{
params |> Map.put( "id", id ),
"show.json",
params[ "path_" ] |> Enum.drop( -1 )
}
end
prefix = if params[ "path_" ] == nil, do: "", else: Enum.join( path, "/" ) <> "/"
# v-- replace here
result = execute( "#{ prefix }#{ template }", params: new_params )
response( conn, :ok, result |> Jason.encode! )
# ^-- replace here
end
def create( conn, params ) do
path = params[ "path_" ]
prefix = if params[ "path_" ] == nil, do: "", else: Enum.join( path, "/" ) <> "/"
# TODO:data.json.eexと列突合チェック
result = execute( "#{ prefix }create.json", params: params )
if elem( result, 0 ) == :ok do
new_params = params |> Map.put( "id", elem( result, 1 ) )
# v-- replace here
response = execute( "#{ prefix }show.json", params: new_params )
response( conn, :created, response |> Jason.encode! )
# ^-- replace here
else
result
end
end
def update( conn, params ) do
id = params[ "path_" ] |> List.last |> Type.to_number
new_params = params |> Map.put( "id", id )
path = params[ "path_" ] |> Enum.drop( -1 )
prefix = if params[ "path_" ] == nil, do: "", else: Enum.join( path, "/" ) <> "/"
# TODO:data.json.eexと列突合チェック
result = execute( "#{ prefix }update.json", params: new_params )
if elem( result, 0 ) == :ok do
# v-- replace here
response = execute( "#{ prefix }show.json", params: new_params )
response( conn, :ok, response |> Jason.encode! )
# ^-- replace here
else
result
end
end
def delete( conn, params ) do
id = params[ "path_" ] |> List.last |> Type.to_number
new_params = params |> Map.put( "id", id )
path = params[ "path_" ] |> Enum.drop( -1 )
prefix = if params[ "path_" ] == nil, do: "", else: Enum.join( path, "/" ) <> "/"
# TODO:data.json.eexと列突合チェック
result = execute( "#{ prefix }delete.json", params: new_params )
if elem( result, 0 ) == :ok do
response( conn, :no_content, "" )
else
result
end
end
def execute( path, params: params ) do
File.read!( "lib/basic_web/templates/api/#{ path }.eex" )
|> Code.eval_string( params: params, data: params[ "data" ] )
|> elem( 0 )
end
def response( conn, status, body ) do
conn
|> put_resp_header( "content-type", "application/json; charset=utf-8" )
|> send_resp( status, body )
end
end
ApiViewは、モジュール自体が不要になったので、「lib/basic_web/views/api_view.ex」のファイル削除を行ってください
ApiControllerの各関数内のパス処理の共通関数化
REST APIにおけるパスは、以下のようなルールになっています
- 末尾にidを指定しない
- GETの一覧、POST
- 末尾にidを指定する
- GETの詳細、PUT、DELETE
また、これらのために必要な処理は、以下です
- 共通
- パス要素のリストの「/」連結
- 末尾にidが指定されるケース固有
- 末尾のidを除去したパスの作成
- 末尾のid抜き出し
これらを踏まえ、パス操作をRestモジュールとして関数化します
defmodule Rest do
@doc """
Path list devide last id
## Examples
iex> Rest.separate_id( [ "abc", "def", "123" ] )
{ "abc/def/", 123 }
iex> Rest.separate_id( [ "456" ] )
{ "", 456 }
iex> Rest.separate_id( [ "abc", "def" ] )
{ "abc/def/", nil }
iex> Rest.separate_id( [] )
{ "", nil }
iex> Rest.separate_id( [ "" ] )
{ "", nil }
iex> Rest.separate_id( nil )
{ "", nil }
"""
def separate_id( nil ), do: { "", nil }
def separate_id( path_list ) when is_list( path_list ) do
id = path_list |> List.last |> Type.to_number
no_id_path_list = if id == nil, do: path_list, else: path_list |> Enum.drop( -1 )
{ no_id_path_list |> concat_path, id }
end
@doc """
Path list concat to path string
## Examples
iex> Rest.concat_path( [ "abc", "def" ] )
"abc/def/"
iex> Rest.concat_path( [] )
""
iex> Rest.concat_path( [ "" ] )
""
iex> Rest.concat_path( [ "", "" ] )
""
"""
def concat_path( [] ), do: ""
def concat_path( path_list ) when is_list( path_list ) do
if List.first( path_list ) == "" do
""
else
Enum.join( path_list, "/" ) <> "/"
end
end
end
そして、APIコントローラ内のパス処理を、Restモジュール利用に書き換えます(ドラスティックな変更のため、差分の記載は割愛します)
けっこう簡潔な構造になったかと思います
なお、update()とdelete()は、id未指定時に「idが空のデータで更新・削除をしてしまう」という想定しない挙動があったため、id未指定時は、ステータスコード404(Not Found)を返却するようにしておきます
defmodule BasicWeb.ApiController do
use BasicWeb, :controller
def index( conn, params ) do
{ no_id_path, id } = Rest.separate_id( params[ "path_" ] )
{ new_params, template } =
if id == nil do
{
params,
"index.json",
}
else
{
params |> Map.put( "id", id ),
"show.json",
}
end
result = execute( "#{ no_id_path }#{ template }", params: new_params )
response( conn, :ok, result |> Jason.encode! )
end
def create( conn, params ) do
{ no_id_path, _ } = Rest.separate_id( params[ "path_" ] )
# TODO:data.json.eexと列突合チェック
result = execute( "#{ no_id_path }create.json", params: params )
if elem( result, 0 ) == :ok do
new_params = params |> Map.put( "id", elem( result, 1 ) )
response = execute( "#{ no_id_path }show.json", params: new_params )
response( conn, :created, response |> Jason.encode! )
else
result
end
end
def update( conn, params ) do
{ no_id_path, id } = Rest.separate_id( params[ "path_" ] )
if id == nil do
response( conn, :not_found, %{ error: "Not Found" } |> Jason.encode! )
else
new_params = params |> Map.put( "id", id )
# TODO:data.json.eexと列突合チェック
result = execute( "#{ no_id_path }update.json", params: new_params )
if elem( result, 0 ) == :ok do
response = execute( "#{ no_id_path }show.json", params: new_params )
response( conn, :ok, response |> Jason.encode! )
else
result
end
end
end
def delete( conn, params ) do
{ no_id_path, id } = Rest.separate_id( params[ "path_" ] )
if id == nil do
response( conn, :not_found, %{ error: "Not Found" } |> Jason.encode! )
else
new_params = params |> Map.put( "id", id )
# TODO:data.json.eexと列突合チェック
result = execute( "#{ no_id_path }delete.json", params: new_params )
if elem( result, 0 ) == :ok do
response( conn, :no_content, "" )
else
result
end
end
end
def execute( path, params: params ) do
File.read!( "lib/basic_web/templates/api/#{ path }.eex" )
|> Code.eval_string( params: params, data: params[ "data" ] )
|> elem( 0 )
end
def response( conn, status, body ) do
conn
|> put_resp_header( "content-type", "application/json; charset=utf-8" )
|> send_resp( status, body )
end
end
■エラー処理
データ未存在時/データ重複時の404エラー
show.json.eexで、対象idのデータが未存在、もしくはデータ重複時は、ステータスコード404(Not Found)を返却したいので、JSONテンプレート側でチェックとエラー返却(タプルでステータスコードとエラー内容を返却します)を追加します
datas = Db.query( "select * from members where id = #{ params[ "id" ] }" ) |> Db.columns_rows
if datas == [] || Enum.count( datas ) > 1 do
{ :not_found, "Not Found" |> Rest.error_body }
else
File.read!( "lib/basic_web/templates/api/v1/users/data.json.eex" )
|> Code.eval_string( params: params, data: data )
|> elem( 0 )
end
Restモジュールに、エラー時JSONボディを組み立てる関数も追加します
defmodule Rest do
…
@doc """
Error body
"""
def error_body( body ), do: %{ error: body } |> Jason.encode!
…
APIコントローラ側では、タプルが返却された場合は、タプル内のステータスコードとエラー内容でレンダリングするようにします
defmodule BasicWeb.ApiController do
…
def index( conn, params ) do
…
result = execute( "#{ no_id_path }#{ template }", params: new_params )
# v-- add here
if is_tuple( result ) do
response( conn, elem( result, 0 ), elem( result, 1 ) |> Jason.encode! )
else
# ^-- add here
response( conn, :ok, result |> Jason.encode! )
end # <-- add here
end
…
サーバエラー時の500エラー
全APIで、サーバエラーが発生したときは、ステータスコード500(Internal Server Error)を返却したいので、本体処理をtry~rescueで囲み、rescue内で500エラーを返却します
なお、極力シンプルさを保つため、rescue時には複雑になりがちなFallbackコントローラ利用も排除した構造とします
defmodule BasicWeb.ApiController do
use BasicWeb, :controller
def index( conn, params ) do
{ no_id_path, id } = Rest.separate_id( params[ "path_" ] )
{ new_params, template } =
if id == nil do
{
params,
"index.json",
}
else
{
params |> Map.put( "id", id ),
"show.json",
}
end
try do
result = execute( "#{ no_id_path }#{ template }", params: new_params )
if is_tuple( result ) do
response( conn, elem( result, 0 ), elem( result, 1 ) |> Jason.encode! )
else
response( conn, :ok, result |> Jason.encode! )
end
rescue
err ->
response( conn, :internal_server_error, err |> inspect |> Rest.error_body )
end
end
def create( conn, params ) do
{ no_id_path, _ } = Rest.separate_id( params[ "path_" ] )
# TODO:data.json.eexと列突合チェック
try do
result = execute( "#{ no_id_path }create.json", params: params )
if elem( result, 0 ) == :ok do
new_params = params |> Map.put( "id", elem( result, 1 ) )
response = execute( "#{ no_id_path }show.json", params: new_params )
response( conn, :created, response |> Jason.encode! )
else
response( conn, :internal_server_error, elem( result, 1 ) |> inspect |> Rest.error_body )
end
rescue
err ->
response( conn, :internal_server_error, err |> inspect |> Rest.error_body )
end
end
def update( conn, params ) do
{ no_id_path, id } = Rest.separate_id( params[ "path_" ] )
if id == nil do
response( conn, :not_found, %{ error: "Not Found" } |> Jason.encode! )
else
new_params = params |> Map.put( "id", id )
# TODO:data.json.eexと列突合チェック
try do
result = execute( "#{ no_id_path }update.json", params: new_params )
if elem( result, 0 ) == :ok do
response = execute( "#{ no_id_path }show.json", params: new_params )
response( conn, :ok, response |> Jason.encode! )
else
response( conn, :internal_server_error, elem( result, 1 ) |> inspect |> Rest.error_body )
end
rescue
err ->
response( conn, :internal_server_error, err |> inspect |> Rest.error_body )
end
end
end
def delete( conn, params ) do
{ no_id_path, id } = Rest.separate_id( params[ "path_" ] )
if id == nil do
response( conn, :not_found, "Not Found" |> Rest.error_body )
else
new_params = params |> Map.put( "id", id )
# TODO:data.json.eexと列突合チェック
try do
_result = execute( "#{ no_id_path }delete.json", params: new_params )
response( conn, :no_content, "" )
rescue
err ->
response( conn, :internal_server_error, err |> inspect |> Rest.error_body )
end
end
end
…
終わり
今回は、Phoenix軽量APIの「エラー処理」「コード最適化」について実施しました
エラー処理も、JSONテンプレート内に記述していけることが、お分かりになったかと思います
一方で、「500エラーの検出のようなカスタマイズ不要なもの」は、APIコントローラ内に封じ込めてあるので、JSONテンプレートからは見なくても良い構造にしています(なお、500エラー結果のカスタマイズは、必要に応じて、以降のコラムで適宜解説します)
最終的なコードは、以下にまとめてあります
Phoenix軽量APIを使ったREST APIのコード(データはMnesia DBに保持)
https://qiita.com/piacerex/items/a41176fb0c7c68523a4b
今回の内容が上手く理解できなかった方は、上記からコラムを追って、まず手元で動かすところから始めてみてください
次回は、JSONテンプレートやAPIコントローラのパス指定内にあるPJ名依存を除去できないか試みます