4
1

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 3 years have passed since last update.

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

Last updated at Posted at 2020-04-08

fukuoka.ex/kokura.exのpiacereです
ご覧いただいて、ありがとうございます :bow:

前作で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でも動作する想定です)

あと下記コラムシリーズの続きとして実施するので、未実施であれば、事前に実施しておいてください(近々、ボイラープレート生成の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」付与もセットで)、更に、共通関数化します

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モジュールとして関数化します

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)を返却するようにしておきます

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テンプレート側でチェックとエラー返却(タプルでステータスコードとエラー内容を返却します)を追加します

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ボディを組み立てる関数も追加します

lib/util/rest.ex
defmodule Rest do

	@doc """
	Error body
	"""
	def error_body( body ), do: %{ error: body } |> Jason.encode!

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

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コントローラ利用も排除した構造とします

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エラー結果のカスタマイズは、必要に応じて、以降のコラムで適宜解説します)

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

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

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

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

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

image.pngimage.png にて、どうぞ応援よろしくお願いします:bow:

4
1
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
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?