7
1

More than 1 year has passed since last update.

Phoenix軽量APIを使ったREST APIのコード(データはMnesia DBに保持)

Last updated at Posted at 2020-04-02

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, 【結果】 }の形式での返却を前提にしています

lib/basic_web/templates/api/v1/users/data.json.eex
%{
  name:     data[ "name" ], 
  age:      data[ "age" ], 
  team:     data[ "team" ], 
  position: data[ "position" ]
}
lib/basic_web/templates/api/v1/users/index.json.eex
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
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
lib/basic_web/templates/api/v1/users/create.json.eex
data = params[ "data" ]
Db.query( "insert into members values( '#{ data[ "name" ] }', #{ data[ "age" ] }, '#{ data[ "team" ] }', '#{ data[ "position" ] }' )" )
lib/basic_web/templates/api/v1/users/update.json.eex
data = params[ "data" ]
Db.query( "update members set name = '#{ data[ "name" ] }', age = #{ data[ "age" ] }, team = '#{ data[ "team" ] }', position = '#{ data[ "position" ] }' where id = #{ params[ "id" ] }" )
lib/basic_web/templates/api/v1/users/delete.json.eex
Db.query( "delete from members where id = #{ params[ "id" ] }" )

ルータ

lib/basic_web/router.ex
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です

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

  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

ビュー

lib/basic_web/views/api_view.ex
defmodule BasicWeb.ApiView do
  use BasicWeb, :view
end

Restモジュール

※軽量APIを使うだけの方は、以降のコードは理解しなくてもOKです

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

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

Mnesia DBアクセッサ

※軽量APIを使うだけの方は、以降のコードは理解しなくてもOKです

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
      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
lib/util/db_mnesia.ex
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

ライブラリ

mix.exs
defmodule Basic.MixProject do

  defp deps do
    [
      { :smallex, "~> 0.0" },
      
7
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
7
1