fukuoka.ex/kokura.exのpiacereです
ご覧いただいて、ありがとうございます
前作までの参照系REST API実装に続き、今回は、POST、つまり「新規REST API」を実装します
ただし今回は、DBアクセッサの実装がメインで、軽量APIらしいJSONテンプレートの連動は行わず、APIコントローラも暫定実装としています
なお、本コラムシリーズの永続系DBとして利用している「Mnesia」は、Elixir専用PaaS「Gigalixir」においても、DB利用料がチャージされず、最低価格が月$10から運用でき、リーズナブルです(PostgreSQLを利用すると最低価格でも$35かかる)
本コラムの検証環境
本コラムは、以下環境で検証しています(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付)
手順①:JSONテンプレートにid追加
今回、追加や削除で、idの確認が必要になるため、JSONテンプレートにidを追加します
%{
name: data[ "name" ],
age: data[ "age" ],
team: data[ "team" ],
position: data[ "position" ]
}
RESTクライアントで「GET http://localhost:4000/api/v1/users」
のアクセスを行うと、idが追加されることを確認してください
手順②:DBアクセッサにinsert/delete処理を追加
1)DbMnesiaモジュールにinsert/delete処理を追加
DbMnesiaモジュールに、insert()とdelete()を追加し、サブ関数はTDDします
なおinsertは、列選択を省略したSQLを想定しています
deleteは、idのみ指定可能なSQLを想定します
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)
期待値そのままを固定値で返すよう、ロジック側を修正します
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パターン)
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)
ここで、テストが通る最低限のロジックを実装します
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()として関数化します
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を追加しておきましょう
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()は、今回、空実装にし、以降で実装します
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内で指定することとします
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が入力できます
それから、「POST http://localhost:4000/api/v1/users」
でデータ追加が行われます(ステータスコードが201 Created)になっています)
「GET http://localhost:4000/api/v1/users」
で確認すると、追加されたデータが表示されます
「GET http://localhost:4000/api/v1/users/123」
で1件表示でも確認してみましょう
2)削除REST APIの動作確認
今度は、「DELETE http://localhost:4000/api/v1/users/123」
でデータ削除してみます(ステータスコードが201 Created)になっています)
「GET http://localhost:4000/api/v1/users」
で確認すると、データが削除されています
終わり
軽量APIらしいJSONテンプレートの連動は行わず、APIコントローラも暫定実装ではありますが、追加・削除REST APIが動くようになりました
参照系・追加・削除が揃ったので、だいぶREST APIらしさが出てきたと思います
最終的なコードは、以下にまとめてあります
Phoenix軽量APIを使ったREST APIのコード(データはMnesia DBに保持)
https://qiita.com/piacerex/items/a41176fb0c7c68523a4b
今回の内容が上手く理解できなかった方は、上記からコラムを追って、まず手元で動かすところから始めてみてください
次回は、追加・削除REST APIを、暫定版から本格版に作り直します