5
2

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開発⑤:DB追加/削除+軽量APIで更新系REST API(暫定版)を実装する(TDD付)

Last updated at Posted at 2020-04-02

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

前作までの参照系REST API実装に続き、今回は、POST、つまり「新規REST API」を実装します

ただし今回は、DBアクセッサの実装がメインで、軽量APIらしいJSONテンプレートの連動は行わず、APIコントローラも暫定実装としています

なお、本コラムシリーズの永続系DBとして利用している「Mnesia」は、Elixir専用PaaS「Gigalixir」においても、DB利用料がチャージされず、最低価格が月$10から運用でき、リーズナブルです(PostgreSQLを利用すると最低価格でも$35かかる)

本コラムの検証環境

本コラムは、以下環境で検証しています(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付)

手順①:JSONテンプレートにid追加

今回、追加や削除で、idの確認が必要になるため、JSONテンプレートにidを追加します

lib/basic_web/templates/api/v1/users/data.json.eex
%{
  name:     data[ "name" ], 
  age:      data[ "age" ], 
  team:     data[ "team" ], 
  position: data[ "position" ]
}

RESTクライアントで「GET http://localhost:4000/api/v1/users」のアクセスを行うと、idが追加されることを確認してください
image.png

手順②:DBアクセッサにinsert/delete処理を追加

1)DbMnesiaモジュールにinsert/delete処理を追加

DbMnesiaモジュールに、insert()とdelete()を追加し、サブ関数はTDDします

なおinsertは、列選択を省略したSQLを想定しています

deleteは、idのみ指定可能なSQLを想定します

lib/util/db_mnesia.ex
defmodule DbMnesia do
	def insert( table_name, _columns, values ) do
		:mnesia.start
		table_atom = table_name |> String.to_atom
		:mnesia.wait_for_tables( [ table_atom ], 1000 )
		# TODO: 列選択はそのうち
		# TODO: 自動発番は、以降で実装
		insert_spec = values |> values_value |> Tuple.insert_at( 0, table_atom )
		:mnesia.transaction( fn -> :mnesia.write( insert_spec ) end )
	end

	def delete( table_name, wheres ) do
		:mnesia.start
		table_atom = table_name |> String.to_atom
		:mnesia.wait_for_tables( [ table_atom ], 1000 )
		delete_spec = wheres |> wheres_value |> Tuple.insert_at( 0, table_atom )
		:mnesia.transaction( fn -> :mnesia.delete( delete_spec ) end )
	end

	@doc """
	SQL Values strings(list) to value list

	## Examples
		iex> DbMnesia.values_value( [ "123", "'hoge'" ] )
		{ 123, "hoge" } 
	"""
	def values_value( values ) do
		{}
	end

	@doc """
	SQL Where strings(list) to value list

	## Examples
		iex> DbMnesia.wheres_value( [ "id = 123" ] )
		{ 123 } 
	"""
	def wheres_value( wheres ) do
		{}
	end

2)空を返し、テスト失敗を確認(TDD Red)

上記ファイルを保存すると、テストが自動で走り、期待通り、テスト失敗します(TDD Redパターン)

...

1) doctest DbMnesia.wheres_value/1 (3) (DbMnesiaDocTest)
     test/doc_test.exs:8
     Doctest failed
     doctest:
       iex> DbMnesia.wheres_value( [ "id = 123" ] )
       { 123 }
     code:  DbMnesia.wheres_value(["id = 123"]) === { 123 }
     left:  {}
     right: {123}
     stacktrace:
       lib/util/db_mnesia.ex:85: DbMnesia (module)

2) doctest DbMnesia.values_value/1 (1) (DbMnesiaDocTest)
     test/doc_test.exs:8
     Doctest failed
     doctest:
       iex> DbMnesia.values_value( [ "123", "'hoge'" ] )
       { 123, "hoge" }
     code:  DbMnesia.values_value(["123", "'hoge'"]) === { 123, "hoge" }
     left:  {}
     right: {123, "hoge"}
     stacktrace:
       lib/util/db_mnesia.ex:85: DbMnesia (module)

..

Finished in 0.4 seconds
5 doctests, 3 tests, 2 failure

3)期待値をリテラルで返し、テスト成功を確認(TDD Green)

期待値そのままを固定値で返すよう、ロジック側を修正します

lib/util/db_mnesia.ex
defmodule DbMnesia do

	def values_value( values ) do
		{ 123, "hoge" }
	end

	def wheres_value( wheres ) do
		{ 123 }
	end

保存すると、テストが自動的に走り、もちろんテスト成功します(TDD Greenパターン)

......

Finished in 0.3 seconds
5 doctests, 3 tests, 0 failures

各関数に、2つ目のテストパターンを追加すると、ロジックは固定値しか返していないため、テスト失敗します(TDD Redパターン)

lib/util/db_mnesia.ex
defmodule DbMnesia do

	@doc """
	SQL Values strings(list) to value list

	## Examples
		iex> DbMnesia.values_value( [ "123", "'hoge'" ] )
		{ 123, "hoge" } 
		iex> DbMnesia.values_value( [ "987", "'foo'" ] )
		{ 987, "foo" } 
	"""
	def values_value( values ) do

	@doc """
	SQL Where strings(list) to value list

	## Examples
		iex> DbMnesia.wheres_value( [ "id = 123" ] )
		{ 123 } 
		iex> DbMnesia.wheres_value( [ "id = 987" ] )
		{ 987 } 
	"""
	def wheres_value( wheres ) do

4)ロジック実装し、テスト成功を確認(TDD Refactoring)

ここで、テストが通る最低限のロジックを実装します

lib/util/db_mnesia.ex
defmodule DbMnesia do

	def values_value( values ) do
		values 
		|> Enum.reduce( {}, fn v, acc ->
			raw_v = case Type.is( v ) do
				"Integer" -> v |> String.to_integer
				"Float"   -> v |> String.to_float
				_         -> v |> String.trim( "'" ) |> String.trim( "\"" )
			end
			Tuple.append( acc, raw_v )
		end )
	end

	def wheres_value( wheres ) do
		wheres
		|> Enum.reduce( {}, fn where, acc ->
			kv = String.split( where, "=" ) |> Enum.map( & String.trim( &1 ) )
			v = List.last( kv )
			raw_v = case Type.is( v ) do
				"Integer" -> v |> String.to_integer
				"Float"   -> v |> String.to_float
				_         -> v |> String.trim( "'" ) |> String.trim( "\"" )
			end
			Tuple.append( acc, raw_v )
		end )
	end
A

保存すると、テストが自動的に走り、テスト成功が確認できます(TDD Refactoringパターン)

...

Finished in 0.3 seconds
5 doctests, 3 tests, 0 failures

5)共通処理を関数化する

ここで、wheres_param()/values_value()/wheres_value()内の、raw_vを算出する処理が、全く同じなので、raw_value()として関数化します

lib/util/db_mnesia.ex
defmodule DbMnesia do

	@doc """
	SQL Where strings(list) to Mnesia conditons(tuple list), but different only for columns name

	## Examples
		iex> DbMnesia.wheres_param( [ "id = 123", "name = 'hoge'", "team = 'foo'" ] )
		[
			{ :==, "id",   123    }, 
			{ :==, "name", "hoge" }, 
			{ :==, "team", "foo"  } 
		]
		iex> DbMnesia.wheres_param( [ "name = 'hogehoge'", "position = 'bar'" ] )
		[
			{ :==, "name",     "hogehoge" }, 
			{ :==, "position", "bar"  } 
		]
	"""
	def wheres_param( wheres ) do
		wheres
		|> Enum.map( fn where ->
			kv = String.split( where, "=" ) |> Enum.map( & String.trim( &1 ) )
			v = List.last( kv )
			{ :==, List.first( kv ), raw_value( v ) }
		end )
	end

	@doc """
	SQL Values strings(list) to value list

	## Examples
		iex> DbMnesia.values_value( [ "123", "'hoge'" ] )
		{ 123, "hoge" } 
		iex> DbMnesia.values_value( [ "987", "'foo'" ] )
		{ 987, "foo" } 
	"""
	def values_value( values ) do
		values 
		|> Enum.reduce( {}, fn v, acc ->
			Tuple.append( acc, raw_value( v ) )
		end )
	end

	@doc """
	SQL Where strings(list) to value list

	## Examples
		iex> DbMnesia.wheres_value( [ "id = 123" ] )
		{ 123 } 
		iex> DbMnesia.wheres_value( [ "id = 987" ] )
		{ 987 } 
	"""
	def wheres_value( wheres ) do
		wheres
		|> Enum.reduce( {}, fn where, acc ->
			kv = String.split( where, "=" ) |> Enum.map( & String.trim( &1 ) )
			v = List.last( kv )
			Tuple.append( acc, raw_value( v ) )
		end )
	end

	def raw_value( v ) do
		case Type.is( v ) do
			"Integer" -> v |> String.to_integer
			"Float"   -> v |> String.to_float
			_         -> v |> String.trim( "'" ) |> String.trim( "\"" )
		end
	end

保存すると、テストが自動的に走り、テスト成功が確認できます

こうしたリファクタリングが安全に行えることが、TDDの大きな恩恵です

...

Finished in 0.3 seconds
5 doctests, 3 tests, 0 failures

更に、raw_value()にもdoctestを追加しておきましょう

lib/util/db_mnesia.ex
defmodule DbMnesia do

	@doc """
	String value to raw value

	## Examples
		iex> DbMnesia.raw_value( "" )
		""
		iex> DbMnesia.raw_value( "123" )
		123
		iex> DbMnesia.raw_value( "12.34" )
		12.34
		iex> DbMnesia.raw_value( "'hoge'" )
		"hoge"
		iex> DbMnesia.raw_value( "foo" )
		"foo"
		iex> DbMnesia.raw_value( "12ab3" )
		"12ab3"
	"""
	def raw_value( v ) do

6)Dbモジュールにinsert/delete処理を追加

Dbモジュールは、元々query()内にあったDB取得処理を、select()に移し、queryは、CRUDを呼び分ける処理に差し替えます

その上で、insert()とdelete()を追加します

なお、insert時のidは、自動発番にするべきところですが、次回以降に預け、今回は、リクエスト時に指定することにします

insert時の列選択も、次回以降に預けます

update()は、今回、空実装にし、以降で実装します

lib/util/db.ex
defmodule Db do
	def query( sql ) when sql != "" do
		cond do
			String.match?( sql, ~r/select.*/ ) -> select( sql )
			String.match?( sql, ~r/insert.*/ ) -> insert( sql )
			String.match?( sql, ~r/update.*/ ) -> update( sql )
			String.match?( sql, ~r/delete.*/ ) -> delete( sql )
		end
	end

	def select( sql ) do
		if String.match?( sql, ~r/.*where.*/ ) do
			parsed_map = Regex.named_captures( ~r/select( *)(?<columns>.*)( *)from( *)(?<tables>.*)( *)where( *)(?<wheres>.*)/, sql )
			DbMnesia.select( 
				parsed_map[ "tables" ]  |> string_to_list |> List.first, 
				parsed_map[ "columns" ] |> string_to_list, 
				parsed_map[ "wheres" ]  |> string_to_list )
		else
			parsed_map = Regex.named_captures( ~r/select( *)(?<columns>.*)( *)from( *)(?<tables>.*)/, sql )
			DbMnesia.select( 
				parsed_map[ "tables" ]  |> string_to_list |> List.first, 
				parsed_map[ "columns" ] |> string_to_list, 
				[] )
		end
	end

	def insert( sql ) do
		if false do  # TODO: 列選択はそのうち
			parsed_map = Regex.named_captures( ~r/insert into( *)(?<tables>.*)\(( *)(?<columns>.*)( *)\)( *)values\(( *)(?<values>.*)( *)\)/, sql )
			DbMnesia.insert( 
				parsed_map[ "tables" ]  |> string_to_list |> List.first, 
				parsed_map[ "columns" ] |> string_to_list, 
				parsed_map[ "values" ]  |> string_to_list )
		else
			parsed_map = Regex.named_captures( ~r/insert into( *)(?<tables>.*)( *)values\(( *)(?<values>.*)( *)\)/, sql )
			DbMnesia.insert( 
				parsed_map[ "tables" ]  |> string_to_list |> List.first, 
				[], 
				parsed_map[ "values" ]  |> string_to_list )
		end
	end

	def update( _sql ) do
		# TODO: 以降で実装
	end

	def delete( sql ) do
		if String.match?( sql, ~r/.*where.*/ ) do
			parsed_map = Regex.named_captures( ~r/delete( *)from( *)(?<tables>.*)( *)where( *)(?<wheres>.*)/, sql )
			DbMnesia.delete( 
				parsed_map[ "tables" ]  |> string_to_list |> List.first, 
				parsed_map[ "wheres" ]  |> string_to_list )
		else
			parsed_map = Regex.named_captures( ~r/delete( *)from( *)(?<tables>.*)/, sql )
			DbMnesia.delete( 
				parsed_map[ "tables" ]  |> string_to_list |> List.first, 
				[] )
		end
	end

手順②:create/deleteのAPIコントローラを暫定実装

APIコントローラに、POSTで受けたリクエストJSONでデータ追加するためのcreate()と、DELETEでデータ削除するためのdelete()を追加します

put_status()で、create()はステータスコード201(Created)を返却、delete()はステータスコード204(No Contents)を返却するようにします

update()は空振りで、以降で実装します

いずれも、リクエストJSON内の"data"にデータが入っている前提とします

なお、本来であれば、どのURLに対するPOST/DELETEかで、JSONテンプレートのパスを特定し、そこに置いてあるdata.json.eexの列と突き合わせすることで、妥当なリクエストかを判定するべきですが、ここの実装も以降に預け、今回は、コントローラ内から、直接DBアクセッサを呼び出します

それと、POST時のidは、insertでの自動発番の実装が次回以降となるので、今回は暫定的に、リクエストJSON内で指定することとします

lib/basic_web/controllers/api_controller.ex
defmodule BasicWeb.ApiController do

  def create( conn, params ) do
    path = params[ "path_" ]
    prefix = if params[ "path_" ] == nil, do: "", else: Enum.join( path, "/" ) <> "/" 

    # TODO:URLからJSONテンプレートパスを特定/実行 & data.json.eexと列突合チェック
    Db.query( "insert into members values( #{ params[ "id" ] }, '#{ data[ "name" ] }', '#{ data[ "age" ] }', '#{ data[ "team" ] }', '#{ data[ "position" ] }' )" )

    conn
    |> put_status( :created )
    |> render( "#{ prefix }show.json", params: params )
  end

  def update( _conn, _params ) do
    # TODO: 以降で実装
  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:URLからJSONテンプレートパスを特定/実行 & data.json.eexと列突合チェック
    Db.query( "delete from members where id = #{ new_params[ "id" ] }" )

    send_resp( conn, :no_content, "" )
  end
end

手順③:動作確認

1)追加REST APIの動作確認

RESTクライアントで、下記のリクエストbodyを設定します

{
	"id": 123,
	"data": 
	{
		"name": "テストユーザ123", 
		"age": 45, 
		"team": "テストチーム", 
		"position": "テスト担当"
	}
}

RESTクライアント画面では、下記の通り、URL直下の「Body」を押下し、「raw」ラジオボタンを押下すると、リクエストbodyが入力できます
image.png

それから、「POST http://localhost:4000/api/v1/users」でデータ追加が行われます(ステータスコードが201 Created)になっています)
image.png

「GET http://localhost:4000/api/v1/users」で確認すると、追加されたデータが表示されます
image.png

「GET http://localhost:4000/api/v1/users/123」で1件表示でも確認してみましょう
image.png

2)削除REST APIの動作確認

今度は、「DELETE http://localhost:4000/api/v1/users/123」でデータ削除してみます(ステータスコードが201 Created)になっています)
image.png

「GET http://localhost:4000/api/v1/users」で確認すると、データが削除されています
image.png

終わり

軽量APIらしいJSONテンプレートの連動は行わず、APIコントローラも暫定実装ではありますが、追加・削除REST APIが動くようになりました

参照系・追加・削除が揃ったので、だいぶREST APIらしさが出てきたと思います

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

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

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

次回は、追加・削除REST APIを、暫定版から本格版に作り直します

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

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

5
2
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
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?