MVC/ルーティング一切不要、JSONテンプレートをフォルダ配置するだけで、APIが開発できる「Phoenix軽量API」の構築は、下記コラムシリーズをご覧ください
①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を完成させる
|> ⑦更新REST APIの実装で軽量REST API実装完成(TDD付)
|> ⑧エラー処理もJSONテンプレートで簡単に&コード最適化
軽量API JSONテンプレート
Phoenix軽量APIを使うと、これらJSONテンプレートをPhoenixのtemplatesフォルダ配下に、任意のフォルダ階層で置くだけで、エンドポイント付きのJSON APIが構築できます
create.json.eex/update.json.eex/delete.json.eexは、{ :ok, 【結果】 }
か{ :error, 【結果】 }
の形式での返却を前提にしています
%{
name: data[ "name" ],
age: data[ "age" ],
team: data[ "team" ],
position: data[ "position" ]
}
json = File.read!( "lib/basic_web/templates/api/v1/users/data.json.eex" )
datas = Db.query( "select * from members" ) |> Db.columns_rows
for data <- datas do
json
|> Code.eval_string( [ params: params, data: data ] )
|> elem( 0 )
end
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
data = params[ "data" ]
Db.query( "insert into members values( '#{ data[ "name" ] }', #{ data[ "age" ] }, '#{ data[ "team" ] }', '#{ data[ "position" ] }' )" )
data = params[ "data" ]
Db.query( "update members set name = '#{ data[ "name" ] }', age = #{ data[ "age" ] }, team = '#{ data[ "team" ] }', position = '#{ data[ "position" ] }' where id = #{ params[ "id" ] }" )
Db.query( "delete from members where id = #{ params[ "id" ] }" )
ルータ
defmodule BasicWeb.Router do
…
scope "/api/", BasicWeb do
pipe_through :browser
# v-- add start
get "/*path_", ApiController, :index
post "/*path_", ApiController, :create
put "/*path_", ApiController, :update
delete "/*path_", ApiController, :delete
# ^-- add end
end
…
APIコントローラ
※軽量APIを使うだけの方は、以降のコードは理解しなくてもOKです
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
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
ビュー
defmodule BasicWeb.ApiView do
use BasicWeb, :view
end
Restモジュール
※軽量APIを使うだけの方は、以降のコードは理解しなくてもOKです
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
@doc """
Error body
"""
def error_body( body ), do: %{ error: body } |> Jason.encode!
end
Mnesia DBアクセッサ
※軽量APIを使うだけの方は、以降のコードは理解しなくてもOKです
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
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
parsed_map = Regex.named_captures(~r/update( *)(?<tables>.*)( *)set( *)(?<sets>.*)( *)where( *)(?<wheres>.*)/, sql)
DbMnesia.update(
parsed_map["tables"] |> string_to_list |> List.first,
parsed_map["sets" ] |> string_to_list,
parsed_map["wheres"] |> string_to_list)
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
@doc """
String to list(item trimmed)
## Examples
iex> Db.string_to_list("id = 123, name = 'hoge'")
["id = 123", "name = 'hoge'"]
iex> Db.string_to_list("team = 'foo'")
["team = 'foo'"]
iex> Db.string_to_list("")
[""]
"""
def string_to_list(string), do: string |> String.split(",") |> Enum.map(& String.trim(&1))
def columns_rows(result) do
result
|> rows
|> Enum.map(fn row -> Enum.into(List.zip([columns(result), row]), %{}) end)
end
def rows(%{ rows: rows } = _result), do: rows
def columns(%{ columns: columns } = _result), do: columns
end
defmodule DbMnesia do
def insert(table_name, _columns, values) do
# TODO: 列選択はそのうち
:mnesia.start
table_atom = String.to_atom(table_name)
:mnesia.wait_for_tables([table_atom], 1000)
result = :mnesia.transaction(fn ->
next_id = max_id(table_atom) + 1
insert_spec = values |> values_value |> Tuple.insert_at(0, next_id) |> Tuple.insert_at(0, table_atom)
writed = :mnesia.write(insert_spec)
{writed, next_id}
end)
case result do
{:atomic, {:ok, id}} -> {:ok, id }
{:aborted, {err, _}} -> {:error, err}
{_, {err, _}} -> {:error, err}
{err, _} -> {:error, err}
end
end
def max_id(table_atom) do
{:atomic, keys} = :mnesia.transaction(fn -> :mnesia.all_keys(table_atom) end)
case keys do
[] -> -1
_ -> keys |> Enum.max
end
end
def update(table_name, sets, wheres) do
:mnesia.start
table_atom = String.to_atom(table_name)
:mnesia.wait_for_tables([table_atom], 1000)
where_value = wheres |> wheres_value |> elem(0)
update_spec = sets |> sets_value |> Tuple.insert_at(0, where_value) |> Tuple.insert_at(0, table_atom)
result = :mnesia.transaction(fn -> :mnesia.write(update_spec) end)
case result do
{:atomic, :ok} -> {:ok}
{:aborted, err} -> {:error, err}
{_, err} -> {:error, err}
err -> {:error, err}
end
end
def delete(table_name, wheres) do
:mnesia.start
table_atom = String.to_atom(table_name)
:mnesia.wait_for_tables([table_atom], 1000)
where_spec = wheres |> wheres_value |> Tuple.insert_at(0, table_atom)
result = :mnesia.transaction(fn -> :mnesia.delete(where_spec) end)
case result do
{:atomic, :ok} -> {:ok}
{:aborted, err} -> {:error, err}
{_, err} -> {:error, err}
err -> {:error, err}
end
end
def select(table_name, _columns, wheres) do
# TODO: 列選択はそのうち
:mnesia.start
table_atom = String.to_atom(table_name)
:mnesia.wait_for_tables([table_atom], 1000)
columns = :mnesia.table_info(table_atom, :attributes) |> Enum.map(& Atom.to_string(&1))
specs = [table_atom] ++ Enum.map(1..Enum.count(columns), & :"$#{&1}") |> List.to_tuple
wheres_spec = wheres |> wheres_param |> wheres_spec(columns, specs)
rows = :mnesia.transaction(fn ->
:mnesia.select(table_atom, [{specs, wheres_spec, [:"$$"]}]) end) |> elem(1)
%{
columns: columns,
command: :select,
connection_id: 0,
num_rows: Enum.count(rows),
rows: rows
}
end
@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" }
]
"""
# TODO: 「=」以外の演算にも対応したい
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"}
"""
# TODO: Keys指定からTableKeysの並び順に直したValuesを返却して、呼出側で列バインドしたい
def values_value(values) do
values
|> Enum.reduce({}, fn v, acc ->
Tuple.append(acc, raw_value(v))
end)
end
@doc """
SQL Sets strings(list) to value list
## Examples
iex> DbMnesia.sets_value(["id = 123", "name = 'hoge'", "team = 'foo'"])
{123, "hoge", "foo"}
iex> DbMnesia.sets_value(["name = 'hogehoge'", "position = 'bar'"])
{"hogehoge", "bar"}
"""
# TODO: KeyValueを返却して、呼出側で列バインドしたい
def sets_value(sets) do
sets
|> Enum.reduce({}, fn set, acc ->
kv = String.split(set, "=") |> Enum.map(& String.trim(&1))
v = List.last(kv)
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
@doc """
Mnesia conditons columns name convert to params
## Examples
iex> DbMnesia.wheres_spec([{:==, "id", 123}, {:==, "team", "foo"}], ["id", "name", "age", "team", "position"], {:members, :"$1", :"$2", :"$3", :"$4", :"$5"})
[
{:==, :"$1", 123 },
{:==, :"$4", "foo" }
]
iex> DbMnesia.wheres_spec([{:==, "name", "hoge"}], ["id", "name", "age", "team", "position"], {:members, :"$1", :"$2", :"$3", :"$4", :"$5"})
[
{:==, :"$2", "hoge"}
]
"""
def wheres_spec(wheres_params, columns, columns_spec) do
param_only = columns_spec |> Tuple.delete_at(0) |> Tuple.to_list
wheres_params
|> Enum.map(fn {op, column, v} -> {op, Enum.at(param_only, Enum.find_index(columns, & &1 == column)), v} end)
end
@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) when is_binary(v) do
case String.match?(v, ~r/^([0-9]|\.)+$/) do
true ->
case String.match?(v, ~r/\./) do
true -> v |> String.to_float
_ -> v |> String.to_integer
end
_ -> v |> String.trim("'") |> String.trim("\"")
end
end
def raw_value(v) when is_number(v), do: v
def raw_value(v) when is_boolean(v), do: v |> Atom.to_string
def table_columns(table_atom) do
:mnesia.table_info(table_atom, :attributes)
|> Enum.reduce([], fn(item, acc) -> acc ++ [Atom.to_string(item)] end)
end
def all_columns_param(table_atom) do
1..Enum.count(table_columns(table_atom))
|> Enum.reduce({}, fn(x, acc) -> Tuple.append(acc, :"$#{x}") end)
end
end
ライブラリ
defmodule Basic.MixProject do
…
defp deps do
[
{ :smallex, "~> 0.0" },
…