Phoenixお気軽API開発⑧：エラー処理もJSONテンプレートで簡単に＆コード最適化

[fukuoka.ex／kokura.ex](https://fukuokaex.connpass.com/)のpiacereです
ご覧いただいて、ありがとうございます :bow:

[前作でPhoenix軽量APIによるREST APIが完成](https://qiita.com/piacerex/items/78d3036e556e66981c79)しましたが、「エラー処理」「コード最適化」「全体バランス取り」「Mnesiaアクセッサの不足機能補充」等の対応等が残っていますので、これらのうち、幾つかを対応します

# 残対応のうち、今回、対応する対象

残対応は、下記4カテゴリで残っていますが、これらのうち、赤字にしている「エラー処理」の全部と「コード最適化」の半分を対応していきます

## <b>■エラー処理　※JSON内でラクに書く？</b>

<font color="red">- 【今回】show.json.eex／update.json.eex／delete.json.eexのデータ未存在時の404エラー
- 【今回】サーバエラー時の500エラー</font>

## <b>■コード最適化</b>

<font color="red">- 【今回】JSONテンプレート評価の共通関数化（ApiView.render()とApiController.execute()が同じ処理）
- 【今回】ApiControllerの各関数で、パス処理が同じなので、その共通関数化</font>
- index.json.eex／show.json.eexやApiController.execute()のフルパス指定を無くしたい
- JSONテンプレート内のJSON評価の共通関数化（ApiController.execute()と重複している）
- :mnesia.start～:mnesia.wait_for_tables()の共通関数化
- DbMnesiaの返却の共通関数化

## <b>■全体バランス取りなど</b>

- どこまでをAPIコントローラ側に置き、どこまでをJSONテンプレートに置くか？
- <font color="silver">【済】Mnesiaの独自実装では無く、Ecto Queryでロジックを組んだときの配置は大丈夫か？</font>
- APIでは無く、WebアプリやLiveViewでも、JSONテンプレートを応用できないか？

## <b>■Mnesiaアクセッサの不足機能補充　※どこまでマジメにやる？</b>

- 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 　　　　 ※[<b>最新版のインストール手順はコチラ</b>](https://qiita.com/piacere_ex/items/6714e1440e3f25fb46a1#%E4%BA%8B%E5%89%8D%E6%BA%96%E5%82%99elixir%E3%81%AE%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB)
- Phoenix 1.4.15　　　※[<b>最新版のインストール手順はコチラ</b>](https://qiita.com/piacerex/items/b7787580fce5f148242f#phoenix%E3%81%AE%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB)
- Node.js 12.14.0

あと下記コラムシリーズの続きとして実施するので、未実施であれば、事前に実施しておいてください（近々、ボイラープレート生成のmixコマンドとしてOSS化は考えています）

[<b>PHP的ハックを応用してNode.js Express／Go的な軽量APIをPhoenixで実現してみた</b>](https://qiita.com/piacerex/items/144089aa6f2052f4d1cb)
|> [<b>Express／Go的なPhoenix軽量APIの上に参照系REST APIを実装する</b>](https://qiita.com/piacerex/items/e11ef7b5cdcc81ac69c9)
|> [<b>軽量APIにて実装した一覧REST APIにDBを接続する</b>](https://qiita.com/piacerex/items/437e7b6c26c95dae0efb)
|> [<b>DB取得＋軽量APIで1件参照REST APIを実装する(TDD付)</b>](https://qiita.com/piacerex/items/7c969e42e7cf4fa5ed50)
|> [<b>DB追加／削除＋軽量APIで更新系REST API（暫定版）を実装する(TDD付)</b>](https://qiita.com/piacerex/items/94d9275e32ecd0e83da1)
|> [<b>軽量APIの追加・削除REST APIを完成させる</b>](https://qiita.com/piacerex/items/2c95ff8f01d4a9d2d241)

それと、Phoenix軽量APIを使ったサンプルも下記2本、作っていますので、実際の利用イメージを得たい方はご覧ください

[<b>「BFF的APIを40秒で支度しな」：Phoenix軽量APIでScaffoldした2テーブルをJOIN</b>](https://qiita.com/piacerex/items/2e4e6629222700bf3756)
|> [<b>「BFFを40秒で支度しな」：複数APIを呼び出して自前JOIN（Inner JOIN→Outer JOINのオマケ付き）</b>](https://qiita.com/piacerex/items/cd8395e16588cb86ed99)

# ■コード最適化

以降、記載は省略していますが、変更を行った際は、都度、動作確認を行っておいてください

また、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」付与もセットで）、更に、共通関数化します

```elixir:lib/basic_web/controllers/api_controller.ex
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モジュールとして関数化します

```elixir:lib/util/rest.ex
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）を返却するようにしておきます

```elixir:lib/basic_web/controllers/api_controller.ex
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テンプレート側でチェックとエラー返却（タプルでステータスコードとエラー内容を返却します）を追加します

```elixir:lib/basic_web/templates/api/v1/users/show.json.eex
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ボディを組み立てる関数も追加します

```elixir:lib/util/rest.ex
defmodule Rest do
…
	@doc """
	Error body
	"""
	def error_body( body ), do: %{ error: body } |> Jason.encode!
…
```

APIコントローラ側では、タプルが返却された場合は、タプル内のステータスコードとエラー内容でレンダリングするようにします

```elixir:lib/basic_web/controllers/api_controller.ex
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コントローラ利用も排除した構造とします

```elixir:lib/basic_web/controllers/api_controller.ex
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エラー結果のカスタマイズは、必要に応じて、以降のコラムで適宜解説します）

最終的なコードは、以下にまとめてあります

[<b>Phoenix軽量APIを使ったREST APIのコード（データはMnesia DBに保持）</b>](https://qiita.com/piacerex/items/a41176fb0c7c68523a4b)
https://qiita.com/piacerex/items/a41176fb0c7c68523a4b

今回の内容が上手く理解できなかった方は、上記からコラムを追って、まず手元で動かすところから始めてみてください

次回は、JSONテンプレートやAPIコントローラのパス指定内にあるPJ名依存を除去できないか試みます

# p.s.このコラムが、面白かったり、役に立ったら…

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/67568d54-6584-ad33-77c7-b3b3afe70287.png) や ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/f5b819ef-d655-ef72-294c-b1a9d2c5b563.png) にて、どうぞ応援よろしくお願いします:bow: